From 4ca3e2de80a22d9b0693ffb9903d32f9c83b1201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Mon, 20 Jul 2026 16:44:53 +0800 Subject: [PATCH 1/2] feat(config): update token-plan default models - switch the default text model to qwen3.8-max-preview - add dedicated T2V, I2V, and R2V model defaults - persist and consume per-mode video model settings - enable thinking when validating the qwen3.8 preview model --- docs/token-plan-profile-integration.md | 63 +++++++++++-------- .../src/commands/auth/login-api-key.ts | 11 +++- packages/commands/src/commands/auth/login.ts | 3 + .../commands/src/commands/config/shared.ts | 4 ++ .../commands/src/commands/video/generate.ts | 5 +- packages/commands/src/commands/video/ref.ts | 2 +- packages/commands/tests/config-ui.test.ts | 2 + packages/commands/tests/e2e/auth.e2e.test.ts | 17 +++-- .../commands/tests/e2e/config.e2e.test.ts | 38 +++++++++++ .../tests/e2e/video-generate-i2v.e2e.test.ts | 48 ++++++++++++++ .../tests/e2e/video-ref-r2v.e2e.test.ts | 44 +++++++++++++ packages/core/src/auth/store.ts | 3 + packages/core/src/config/loader.ts | 2 + packages/core/src/config/profile-presets.ts | 8 ++- packages/core/src/config/schema.ts | 16 +++++ packages/core/tests/config-priority.test.ts | 17 ++++- skills/bailian-cli/assets/setup.md | 8 ++- 17 files changed, 251 insertions(+), 40 deletions(-) diff --git a/docs/token-plan-profile-integration.md b/docs/token-plan-profile-integration.md index f62adf86..30f6b316 100644 --- a/docs/token-plan-profile-integration.md +++ b/docs/token-plan-profile-integration.md @@ -27,7 +27,7 @@ token-plan assign-seats token-plan add-member ``` -这些命令属于管理面,继续使用 OpenAPI AK/SK。本方案增加的是模型消费面:用户把 `create-key` 获得的 `PlainApiKey` 保存到 Profile,然后通过现有文本和图片命令调用模型。 +这些命令属于管理面,继续使用 OpenAPI AK/SK。本方案增加的是模型消费面:用户把 `create-key` 获得的 `PlainApiKey` 保存到 Profile,然后通过现有文本、图片和视频命令调用模型。 ```text OpenAPI AK/SK @@ -42,7 +42,7 @@ OpenAPI AK/SK - 将 Token Plan 模型 API Key 作为普通 `apiKey` credential 使用。 - 将 `token-plan` 作为内置命名 Profile 管理。 - 支持 Config 激活状态和默认切换。 -- 复用现有文本、图片命令与 Client。 +- 复用现有文本、图片、视频命令与 Client。 - 对所有来源的模型 Base URL 做统一归一化。 - 登录验证成功后原子保存 API Key 和 Base URL。 - 服务端错误保持原消息,不在 CLI 内翻译。 @@ -52,7 +52,7 @@ OpenAPI AK/SK - 不重写现有 Token Plan 管控命令。 - 不把模型消费 API Key 合并到 OpenAPI AK/SK 鉴权域。 - 不新增 Token Plan 专用 Client。 -- 基础阶段不承诺视频、语音和音频模型消费。 +- 基础阶段不承诺语音和音频模型消费。 - 暂不维护会阻断请求的本地模型白名单。 - 暂不把服务端错误翻译成 CLI 自定义错误。 @@ -76,7 +76,10 @@ CLI 应解析并保存以下配置: "token-plan": { "api_key": "", "base_url": "https://token-plan.cn-beijing.maas.aliyuncs.com", - "default_text_model": "qwen3.7-max", + "default_text_model": "qwen3.8-max-preview", + "default_video_model": "happyhorse-1.1-t2v", + "default_image_to_video_model": "happyhorse-1.1-i2v", + "default_reference_to_video_model": "happyhorse-1.1-r2v", "default_image_model": "qwen-image-2.0" } } @@ -237,8 +240,11 @@ Config 激活只改变配置文件 block 的选择,`--config` 本身不提升 `token-plan` 是允许用户选择的内置 Profile 名,不应加入非法名称列表。它提供以下默认值: ```text -base_url: https://token-plan.cn-beijing.maas.aliyuncs.com -default_text_model: qwen3.7-max +base_url: https://token-plan.cn-beijing.maas.aliyuncs.com +default_text_model: qwen3.8-max-preview +default_video_model: happyhorse-1.1-t2v +default_image_to_video_model: happyhorse-1.1-i2v +default_reference_to_video_model: happyhorse-1.1-r2v default_image_model: qwen-image-2.0 ``` @@ -253,7 +259,7 @@ Token Plan Base URL 预设只在登录写入阶段提供最低优先级的缺省 登录成功时应把显式 Base URL 或缺失的预设 Base URL,以及默认模型写入 Profile,使 `config show --config token-plan` 能看到完整配置。环境变量不复制进 Profile。运行时不再合并预设;如果手工删除字段,则按统一的环境变量、配置文件和系统默认值链继续解析。 -默认模型采用更简单的固定策略:每次执行 `auth login --config token-plan`,都将 `default_text_model` 重置为 `qwen3.7-max`,将 `default_image_model` 重置为 `qwen-image-2.0`。登录不保留用户之前写入的其他 Profile 默认模型;用户需要临时调用其他 Token Plan 模型时,通过具体模型命令的 `--model` 覆盖,不修改这两个内置默认值。 +默认模型采用更简单的固定策略:每次执行 `auth login --config token-plan`,都将 `default_text_model` 重置为 `qwen3.8-max-preview`,将 `default_video_model` 重置为 `happyhorse-1.1-t2v`,将 `default_image_to_video_model` 重置为 `happyhorse-1.1-i2v`,将 `default_reference_to_video_model` 重置为 `happyhorse-1.1-r2v`,将 `default_image_model` 重置为 `qwen-image-2.0`。登录不保留用户之前写入的其他 Profile 默认模型;用户需要临时调用其他 Token Plan 模型时,通过具体模型命令的 `--model` 覆盖,不修改这些内置默认值。 预设建议通过集中 registry 表达,不在 resolver、命令和 Client 中散落名称判断: @@ -261,7 +267,10 @@ Token Plan Base URL 预设只在登录写入阶段提供最低优先级的缺省 const MODEL_PROFILE_PRESETS = { "token-plan": { baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", - defaultTextModel: "qwen3.7-max", + defaultTextModel: "qwen3.8-max-preview", + defaultVideoModel: "happyhorse-1.1-t2v", + defaultImageToVideoModel: "happyhorse-1.1-i2v", + defaultReferenceToVideoModel: "happyhorse-1.1-r2v", defaultImageModel: "qwen-image-2.0", }, }; @@ -363,20 +372,23 @@ image: /api/v1/services/aigc/.../generation } ``` -登录验证使用的模型必须在目标 Profile 中可用。基础阶段 Token Plan 预设使用 `qwen3.7-max`;后续如不同订阅计划的模型集合分化,应将验证模型纳入 Profile 预设,而不是继续在登录函数里硬编码唯一模型。 +登录验证使用的模型必须在目标 Profile 中可用。Token Plan 预设使用 `qwen3.8-max-preview`;后续如不同订阅计划的模型集合分化,应将验证模型纳入 Profile 预设,而不是继续在登录函数里硬编码唯一模型。 ## 模型消费范围 基础阶段承诺: -| 能力 | 默认模型 | 调用方式 | -| -------------- | ---------------- | ---------------------------------- | -| 文本生成和推理 | `qwen3.7-max` | OpenAI Compatible Chat Completions | -| 图片生成和编辑 | `qwen-image-2.0` | DashScope 原生图片接口 | +| 能力 | 默认模型 | 调用方式 | +| -------------- | --------------------- | ---------------------------------- | +| 文本生成和推理 | `qwen3.8-max-preview` | OpenAI Compatible Chat Completions | +| 图片生成和编辑 | `qwen-image-2.0` | DashScope 原生图片接口 | +| 文生视频 | `happyhorse-1.1-t2v` | DashScope 原生视频接口 | +| 图生视频 | `happyhorse-1.1-i2v` | `bl video generate --image` | +| 参考生视频 | `happyhorse-1.1-r2v` | `bl video ref` | -Token Plan 当前模型快照中还包含其他文本、视觉理解和图片模型,但该列表可能由后端调整。基础接入不维护阻断请求的本地白名单;用户可通过具体模型命令的 `--model` 临时覆盖本次请求,但再次登录时 Profile 默认模型仍重置为内置版本。 +Token Plan 当前模型快照中还包含其他文本、视觉理解、图片和视频模型,但该列表可能由后端调整。基础接入不维护阻断请求的本地白名单;用户可通过具体模型命令的 `--model` 临时覆盖本次请求,但再次登录时 Profile 默认模型仍重置为内置版本。 -视频、语音和音频不作为本阶段支持承诺。现有命令仍保持通用实现,但 Token Plan Profile 的验收不包含这些模态。 +语音和音频不作为本阶段支持承诺。现有命令仍保持通用实现,但 Token Plan Profile 的验收不包含这些模态。 ## 错误处理 @@ -408,9 +420,9 @@ feat(core): add token-plan model profile preset 完成内容: - 将 `token-plan` 注册为内置、可选择的 Profile 名。 -- 提供 canonical 默认 Base URL、文本模型和图片模型。 +- 提供 canonical 默认 Base URL、文本模型、图片模型和视频模型。 - Base URL 登录验证遵循 flag > 环境变量 > 已保存 Profile > 预设;环境变量不复制进 Profile。 -- Profile 缺少 Base URL 时物化预设地址;每次 Token Plan 登录都重置并写入内置默认文本和图片模型。 +- Profile 缺少 Base URL 时物化预设地址;每次 Token Plan 登录都重置并写入内置默认文本、图片和视频模型。 - 运行时 loader/resolver 不再合并预设。 - 不新增 AuthRequirement,不修改 Token Plan 管控命令。 - 补充预设值单元测试;不重复增加 Token Plan 专属消费 E2E。 @@ -431,23 +443,23 @@ feat(auth): support token-plan API key login - 使用 Token Plan 预设文本模型验证 API Key。 - 登录验证前不写配置。 - 验证成功后一次写入 API Key、canonical Base URL 和默认模型。 -- 每次登录都将默认模型重置为 `qwen3.7-max` 和 `qwen-image-2.0`。 +- 每次登录都将默认模型重置为 `qwen3.8-max-preview`、`qwen-image-2.0`、`happyhorse-1.1-t2v`、`happyhorse-1.1-i2v` 和 `happyhorse-1.1-r2v`。 - 验证失败不留下半配置。 - 补充一个最小 Token Plan 登录 E2E,覆盖命名 Profile 落盘、环境变量不复制、预设 Base URL 物化和默认模型重置;通用 API Key 登录 E2E 继续覆盖成功原子保存和失败不写半配置。 - 该 commit 暂不承诺自动归一化用户显式输入的 SDK Base URL。 -### Commit 3:Token Plan 文本与图片消费验收(已实现) +### Commit 3:Token Plan 文本、图片与视频消费验收(已实现) 建议提交信息: ```text -feat(cli): enable token-plan text and image consumption +feat(cli): enable token-plan text, image, and video consumption ``` 完成内容: -- Token Plan 消费复用现有 API Key、文本和图片调用链,不重复增加专属 E2E。 -- 发布前按需人工验证 `auth login --config token-plan --api-key ...`、文本和图片调用。 +- Token Plan 消费复用现有 API Key、文本、图片和视频调用链,不重复增加专属 E2E。 +- 发布前按需人工验证 `auth login --config token-plan --api-key ...`、文本、图片和视频调用。 - 更新 Token Plan 消费方案文档和 Skill reference。 - 到该 commit 为止即可先交付显式 `--config token-plan` 的紧急消费能力。 @@ -475,7 +487,7 @@ feat(config): add active profile selection - 新增 `bl config use --name `。 - `config show`、`auth status` 展示最终选择项,`config list` 和 `config ui` 展示激活状态。 - 删除激活 Profile 时处理状态一致性。 -- 验证激活 `token-plan` 后不传 `--config` 的文本和图片请求。 +- 验证激活 `token-plan` 后不传 `--config` 的文本、图片和视频请求。 - 验证临时 `--config default` 不改变激活状态。 - 更新命令导出、`packages/cli/src/commands.ts`、E2E 和生成 reference。 @@ -533,9 +545,10 @@ fix(core): normalize model base URLs across all sources - `token-plan` 登录初始化时缺省写入官方根地址。 - 显式 Base URL 覆盖预设并经过通用归一化。 - 登录验证失败不写入任何 Token Plan 半配置。 -- 文本默认使用 `qwen3.7-max`。 +- 文本默认使用 `qwen3.8-max-preview`。 - 图片默认使用 `qwen-image-2.0`。 -- 文本和图片均复用现有 `apiKey` Client。 +- 视频默认使用 `happyhorse-1.1-t2v`;图生和参考生入口分别使用 `happyhorse-1.1-i2v` 和 `happyhorse-1.1-r2v`。 +- 文本、图片和视频均复用现有 `apiKey` Client。 - 管控命令继续使用 OpenAPI AK/SK,不受模型 Profile 影响。 ## 完成后检查 diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts index 8a9991fe..6200f8e6 100644 --- a/packages/commands/src/commands/auth/login-api-key.ts +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -20,6 +20,9 @@ interface ApiKeyLoginProfile { baseUrl: string; persistBaseUrl?: string; defaultTextModel?: string; + defaultVideoModel?: string; + defaultImageToVideoModel?: string; + defaultReferenceToVideoModel?: string; defaultImageModel?: string; persistPatch?: AuthPersistPatch; } @@ -54,17 +57,18 @@ export async function validateAndPersistApiKey( const persistBaseUrl = profile.persistBaseUrl ? normalizeModelBaseUrl(profile.persistBaseUrl) : undefined; + const validationModel = profile.defaultTextModel || "qwen3.7-max"; const requestOpts = { url: baseUrl + chatPath(), method: "POST", headers: { Authorization: `Bearer ${key}` }, timeout: Math.min(deps.settings.timeout, 30), body: { - model: profile.defaultTextModel || "qwen3.7-max", + model: validationModel, messages: [{ role: "user", content: "hi" }], max_tokens: 1, stream: false, - enable_thinking: false, + enable_thinking: validationModel === "qwen3.8-max-preview", }, }; @@ -88,6 +92,9 @@ export async function validateAndPersistApiKey( api_key: key, base_url: persistBaseUrl, default_text_model: profile.defaultTextModel, + default_video_model: profile.defaultVideoModel, + default_image_to_video_model: profile.defaultImageToVideoModel, + default_reference_to_video_model: profile.defaultReferenceToVideoModel, default_image_model: profile.defaultImageModel, }); } diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index aa7c0a65..92b2ca36 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -150,6 +150,9 @@ export default defineCommand({ baseUrl: resolvedBaseUrl, persistBaseUrl, defaultTextModel: profilePreset?.defaultTextModel, + defaultVideoModel: profilePreset?.defaultVideoModel, + defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel, + defaultReferenceToVideoModel: profilePreset?.defaultReferenceToVideoModel, defaultImageModel: profilePreset?.defaultImageModel, }); }, diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts index 7ff612d2..4757140c 100644 --- a/packages/commands/src/commands/config/shared.ts +++ b/packages/commands/src/commands/config/shared.ts @@ -13,6 +13,8 @@ export const VALID_KEYS = [ "security_token", "default_text_model", "default_video_model", + "default_image_to_video_model", + "default_reference_to_video_model", "default_image_model", "default_speech_model", "default_omni_model", @@ -41,6 +43,8 @@ export const KEY_ALIASES: Record = { "security-token": "security_token", "default-text-model": "default_text_model", "default-video-model": "default_video_model", + "default-image-to-video-model": "default_image_to_video_model", + "default-reference-to-video-model": "default_reference_to_video_model", "default-image-model": "default_image_model", "default-speech-model": "default_speech_model", "default-omni-model": "default_omni_model", diff --git a/packages/commands/src/commands/video/generate.ts b/packages/commands/src/commands/video/generate.ts index 4789844b..26c450fa 100644 --- a/packages/commands/src/commands/video/generate.ts +++ b/packages/commands/src/commands/video/generate.ts @@ -103,8 +103,9 @@ export default defineCommand({ const model = flags.model || - settings.defaultVideoModel || - (flags.image ? "happyhorse-1.1-i2v" : "happyhorse-1.1-t2v"); + (flags.image + ? settings.defaultImageToVideoModel || "happyhorse-1.1-i2v" + : settings.defaultVideoModel || "happyhorse-1.1-t2v"); const format = detectOutputFormat(settings.output); const imageUrl = flags.image; diff --git a/packages/commands/src/commands/video/ref.ts b/packages/commands/src/commands/video/ref.ts index 052dd123..e2a8f070 100644 --- a/packages/commands/src/commands/video/ref.ts +++ b/packages/commands/src/commands/video/ref.ts @@ -117,7 +117,7 @@ export default defineCommand({ const imageVoices = flags.imageVoice || []; const videoVoices = flags.videoVoice || []; - const model = flags.model || "happyhorse-1.1-r2v"; + const model = flags.model || settings.defaultReferenceToVideoModel || "happyhorse-1.1-r2v"; const format = detectOutputFormat(settings.output); // --- Resolve file URLs (auto-upload local files) --- diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts index 5a915591..db309265 100644 --- a/packages/commands/tests/config-ui.test.ts +++ b/packages/commands/tests/config-ui.test.ts @@ -81,6 +81,8 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", expect(res.json.default).toMatchObject({ api_key: "sk-default", output: "json" }); expect(res.json.named.dev).toMatchObject({ api_key: "sk-dev", access_token: "tok-dev" }); expect(res.json.secretKeys).toContain("api_key"); + expect(res.json.keys).toContain("default_image_to_video_model"); + expect(res.json.keys).toContain("default_reference_to_video_model"); }); }); diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index f1461e71..b325e854 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -267,7 +267,10 @@ describe("e2e: auth", () => { expect(config["token-plan"]).toMatchObject({ api_key: "sk-sp-e2e-placeholder", base_url: validationServer.baseUrl, - default_text_model: "qwen3.7-max", + default_text_model: "qwen3.8-max-preview", + default_video_model: "happyhorse-1.1-t2v", + default_image_to_video_model: "happyhorse-1.1-i2v", + default_reference_to_video_model: "happyhorse-1.1-r2v", default_image_model: "qwen-image-2.0", }); } finally { @@ -284,6 +287,9 @@ describe("e2e: auth", () => { { "token-plan": { default_text_model: "custom-text-model", + default_video_model: "custom-video-model", + default_image_to_video_model: "custom-image-to-video-model", + default_reference_to_video_model: "custom-reference-to-video-model", default_image_model: "custom-image-model", }, }, @@ -309,9 +315,9 @@ describe("e2e: auth", () => { authorization: "Bearer sk-sp-e2e-placeholder", sourceConfig: expect.any(String), body: { - model: "qwen3.7-max", + model: "qwen3.8-max-preview", stream: false, - enable_thinking: false, + enable_thinking: true, }, }); @@ -324,7 +330,10 @@ describe("e2e: auth", () => { expect(config["token-plan"]).toMatchObject({ api_key: "sk-sp-e2e-placeholder", base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", - default_text_model: "qwen3.7-max", + default_text_model: "qwen3.8-max-preview", + default_video_model: "happyhorse-1.1-t2v", + default_image_to_video_model: "happyhorse-1.1-i2v", + default_reference_to_video_model: "happyhorse-1.1-r2v", default_image_model: "qwen-image-2.0", }); expect((config["token-plan"] as Record).base_url).not.toBe( diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index a28dcb6b..1391c1a5 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -299,6 +299,44 @@ describe("e2e: config", () => { expect(data.would_set?.default_text_model).toBe("qwen3.7-max"); }); + test("config set --dry-run 支持图生视频默认模型别名", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + "config", + "set", + "--dry-run", + "--key", + "default-image-to-video-model", + "--value", + "happyhorse-1.1-i2v", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + would_set?: { default_image_to_video_model?: string }; + }>(stdout); + expect(data.would_set?.default_image_to_video_model).toBe("happyhorse-1.1-i2v"); + }); + + test("config set --dry-run 支持参考生视频默认模型别名", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + "config", + "set", + "--dry-run", + "--key", + "default-reference-to-video-model", + "--value", + "happyhorse-1.1-r2v", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + would_set?: { default_reference_to_video_model?: string }; + }>(stdout); + expect(data.would_set?.default_reference_to_video_model).toBe("happyhorse-1.1-r2v"); + }); + test("config set --dry-run 展示归一化后的 Base URL", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", diff --git a/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts index 1363ed33..fc21bf36 100644 --- a/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts @@ -1,3 +1,4 @@ +import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vite-plus/test"; import { @@ -21,6 +22,53 @@ describe("e2e: video generate (i2v)", () => { expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/generate|--prompt|--image|model/i); }); + + test("Token Plan 使用独立的图生视频默认模型", async () => { + const configDir = makeE2eOutputDir("video-i2v-token-plan-default"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify( + { + "token-plan": { + api_key: "sk-sp-e2e-placeholder", + base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", + default_video_model: "happyhorse-1.1-t2v", + default_image_to_video_model: "custom-image-to-video-model", + }, + }, + null, + 2, + ) + "\n", + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + VIDEO_ROUTES, + [ + "video", + "generate", + "--config", + "token-plan", + "--dry-run", + "--image", + "https://example.com/placeholder.png", + "--prompt", + "干跑校验", + "--output", + "json", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { model?: string; input?: { media?: Array<{ type?: string }> } }; + }>(stdout); + expect(data.request?.model).toBe("custom-image-to-video-model"); + expect(data.request?.input?.media?.[0]?.type).toBe("first_frame"); + }); }); describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( diff --git a/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts index 9a3af7fb..e2629605 100644 --- a/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts @@ -1,3 +1,4 @@ +import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vite-plus/test"; import { @@ -43,6 +44,49 @@ describe("e2e: video ref (r2v)", () => { ); expect(data.request?.input?.media?.[0]?.url).toBe("https://example.com/person.png"); }); + + test("Token Plan 使用独立的参考生视频默认模型", async () => { + const configDir = makeE2eOutputDir("video-r2v-token-plan-default"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify( + { + "token-plan": { + api_key: "sk-sp-e2e-placeholder", + base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", + default_reference_to_video_model: "custom-reference-to-video-model", + }, + }, + null, + 2, + ) + "\n", + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + VIDEO_ROUTES, + [ + "video", + "ref", + "--config", + "token-plan", + "--dry-run", + "--prompt", + "Image 1 waves", + "--image", + "https://example.com/person.png", + "--output", + "json", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ request?: { model?: string } }>(stdout); + expect(data.request?.model).toBe("custom-reference-to-video-model"); + }); }); describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index c7621887..47794f69 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -26,6 +26,9 @@ export type AuthPersistPatch = Pick< | "console_switch_agent" | "workspace_id" | "default_text_model" + | "default_video_model" + | "default_image_to_video_model" + | "default_reference_to_video_model" | "default_image_model" >; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 9f51c4e7..ef149255 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -235,6 +235,8 @@ export function buildSettings(s: ResolutionSources): Settings { timeout, defaultTextModel: file.default_text_model, defaultVideoModel: file.default_video_model, + defaultImageToVideoModel: file.default_image_to_video_model, + defaultReferenceToVideoModel: file.default_reference_to_video_model, defaultImageModel: file.default_image_model, defaultSpeechModel: file.default_speech_model, defaultOmniModel: file.default_omni_model, diff --git a/packages/core/src/config/profile-presets.ts b/packages/core/src/config/profile-presets.ts index 197839eb..83a19f11 100644 --- a/packages/core/src/config/profile-presets.ts +++ b/packages/core/src/config/profile-presets.ts @@ -1,13 +1,19 @@ interface ModelProfilePreset { baseUrl: string; defaultTextModel: string; + defaultVideoModel: string; + defaultImageToVideoModel: string; + defaultReferenceToVideoModel: string; defaultImageModel: string; } const MODEL_PROFILE_PRESETS: Readonly> = { "token-plan": { baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", - defaultTextModel: "qwen3.7-max", + defaultTextModel: "qwen3.8-max-preview", + defaultVideoModel: "happyhorse-1.1-t2v", + defaultImageToVideoModel: "happyhorse-1.1-i2v", + defaultReferenceToVideoModel: "happyhorse-1.1-r2v", defaultImageModel: "qwen-image-2.0", }, }; diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 71c12e90..56f4ff1b 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -32,6 +32,8 @@ export interface ConfigFile { timeout?: number; default_text_model?: string; default_video_model?: string; + default_image_to_video_model?: string; + default_reference_to_video_model?: string; default_image_model?: string; default_speech_model?: string; default_omni_model?: string; @@ -54,6 +56,8 @@ export const CONFIG_FILE_KEYS = [ "timeout", "default_text_model", "default_video_model", + "default_image_to_video_model", + "default_reference_to_video_model", "default_image_model", "default_speech_model", "default_omni_model", @@ -117,6 +121,16 @@ export function parseConfigFile(raw: unknown): ConfigFile { out.default_text_model = obj.default_text_model; if (typeof obj.default_video_model === "string" && obj.default_video_model.length > 0) out.default_video_model = obj.default_video_model; + if ( + typeof obj.default_image_to_video_model === "string" && + obj.default_image_to_video_model.length > 0 + ) + out.default_image_to_video_model = obj.default_image_to_video_model; + if ( + typeof obj.default_reference_to_video_model === "string" && + obj.default_reference_to_video_model.length > 0 + ) + out.default_reference_to_video_model = obj.default_reference_to_video_model; if (typeof obj.default_image_model === "string" && obj.default_image_model.length > 0) out.default_image_model = obj.default_image_model; if (typeof obj.default_speech_model === "string" && obj.default_speech_model.length > 0) @@ -166,6 +180,8 @@ export interface Settings { timeout: number; defaultTextModel?: string; defaultVideoModel?: string; + defaultImageToVideoModel?: string; + defaultReferenceToVideoModel?: string; defaultImageModel?: string; defaultSpeechModel?: string; defaultOmniModel?: string; diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index acf7b02b..0b6ebbbf 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -32,7 +32,10 @@ const resolve = (s: Parameters[0]): Settings => buildSettings(src(s) test("token-plan Profile 预设保持固定", () => { expect(getModelProfilePreset("token-plan")).toEqual({ baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", - defaultTextModel: "qwen3.7-max", + defaultTextModel: "qwen3.8-max-preview", + defaultVideoModel: "happyhorse-1.1-t2v", + defaultImageToVideoModel: "happyhorse-1.1-i2v", + defaultReferenceToVideoModel: "happyhorse-1.1-r2v", defaultImageModel: "qwen-image-2.0", }); }); @@ -316,10 +319,18 @@ test("openapi 凭证:低优先级来源缺字段时不影响更高优先级成 test("default*Model / outputDir:仅 file 源", () => { const c = resolve({ - file: { default_text_model: "qwen-max", default_video_model: "wan-x", output_dir: "/tmp/out" }, + file: parseConfigFile({ + default_text_model: "qwen-max", + default_video_model: "wan-t2v", + default_image_to_video_model: "wan-i2v", + default_reference_to_video_model: "wan-r2v", + output_dir: "/tmp/out", + }), }); expect(c.defaultTextModel).toBe("qwen-max"); - expect(c.defaultVideoModel).toBe("wan-x"); + expect(c.defaultVideoModel).toBe("wan-t2v"); + expect(c.defaultImageToVideoModel).toBe("wan-i2v"); + expect(c.defaultReferenceToVideoModel).toBe("wan-r2v"); expect(c.outputDir).toBe("/tmp/out"); expect(resolve({}).defaultTextModel).toBeUndefined(); }); diff --git a/skills/bailian-cli/assets/setup.md b/skills/bailian-cli/assets/setup.md index 4869de9d..07f9ceea 100644 --- a/skills/bailian-cli/assets/setup.md +++ b/skills/bailian-cli/assets/setup.md @@ -24,7 +24,7 @@ Verify: `bl --version` (prints `bl X.Y.Z`). | Auth | How | Used by | | ------------------ | ------------------------------------------------------------------------------------------------ | --------------------------------------------- | | API key | `export DASHSCOPE_API_KEY=sk-...` or `bl auth login --api-key sk-...` | Most DashScope API commands | -| Token Plan API key | `bl auth login --config token-plan --api-key sk-sp-...` | Token Plan text and image model consumption | +| Token Plan API key | `bl auth login --config token-plan --api-key sk-sp-...` | Token Plan text, image, and video consumption | | Console | `bl auth login --console --console-site domestic` or `... international` | `app list`, `usage free`, `console call` | | OpenAPI AK | `bl auth login --open-api --access-key-id --access-key-secret ` or Alibaba env vars | Token Plan management commands (`token-plan`) | @@ -45,6 +45,7 @@ Use the `PlainApiKey` returned by `bl token-plan create-key` as a model API key. bl auth login --config token-plan --api-key sk-sp-xxx bl text chat --message "Hello" bl image generate --prompt "A cat" +bl video generate --prompt "A horse running through a field" ``` Successful login automatically activates the explicitly selected Profile. Use `bl config list` to @@ -66,8 +67,11 @@ Activation selects the entire Config for every credential domain, not only model The built-in `token-plan` profile defaults to: - Base URL: `https://token-plan.cn-beijing.maas.aliyuncs.com` -- Text model: `qwen3.7-max` +- Text model: `qwen3.8-max-preview` - Image model: `qwen-image-2.0` +- Text-to-video model (`default_video_model`): `happyhorse-1.1-t2v` +- Image-to-video model (`default_image_to_video_model`): `happyhorse-1.1-i2v` +- Reference-to-video model (`default_reference_to_video_model`): `happyhorse-1.1-r2v` The usual priority applies to this profile too: per-command `--api-key` / `--base-url`, then `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL`, then the selected profile. Unset environment overrides when you want to use the credentials saved in `token-plan`. From 4c566fd60e58a2f45ab7ed9a11b03b5839b1f1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Wed, 22 Jul 2026 09:54:54 +0800 Subject: [PATCH 2/2] feat(token-plan): support local images with base64 data URIs - convert local images to Base64 for Token Plan image and video commands - preserve the existing OSS upload flow for standard API Key profiles - use wan2.7-image as the default image model with the sync endpoint - hide full Base64 image content in dry-run output - add Token Plan compatibility tests and update related docs --- docs/token-plan-profile-integration.md | 30 ++++-- packages/commands/src/commands/image/edit.ts | 30 ++++-- .../commands/src/commands/image/generate.ts | 13 ++- .../commands/src/commands/video/generate.ts | 14 ++- packages/commands/src/commands/video/ref.ts | 36 +++++--- .../commands/src/commands/vision/describe.ts | 41 ++++----- packages/commands/tests/e2e/auth.e2e.test.ts | 4 +- .../commands/tests/e2e/image-edit.e2e.test.ts | 52 ++++++++++- .../tests/e2e/image-generate.e2e.test.ts | 40 ++++++++ packages/commands/tests/e2e/topic-routes.ts | 2 + .../tests/e2e/video-generate-i2v.e2e.test.ts | 43 +++++++++ .../tests/e2e/video-ref-r2v.e2e.test.ts | 43 +++++++++ .../tests/e2e/vision-describe.e2e.test.ts | 45 +++++++++ packages/core/src/client/client.ts | 28 +++++- packages/core/src/config/profile-presets.ts | 2 +- packages/core/src/files/index.ts | 8 +- packages/core/src/files/upload.ts | 45 ++++++++- packages/core/tests/config-priority.test.ts | 2 +- packages/core/tests/image-input.test.ts | 91 +++++++++++++++++++ skills/bailian-cli/assets/setup.md | 2 +- skills/bailian-cli/reference/image.md | 22 +++-- skills/bailian-cli/reference/index.md | 2 +- 22 files changed, 521 insertions(+), 74 deletions(-) create mode 100644 packages/commands/tests/e2e/vision-describe.e2e.test.ts create mode 100644 packages/core/tests/image-input.test.ts diff --git a/docs/token-plan-profile-integration.md b/docs/token-plan-profile-integration.md index 30f6b316..8e124635 100644 --- a/docs/token-plan-profile-integration.md +++ b/docs/token-plan-profile-integration.md @@ -80,7 +80,7 @@ CLI 应解析并保存以下配置: "default_video_model": "happyhorse-1.1-t2v", "default_image_to_video_model": "happyhorse-1.1-i2v", "default_reference_to_video_model": "happyhorse-1.1-r2v", - "default_image_model": "qwen-image-2.0" + "default_image_model": "wan2.7-image" } } ``` @@ -245,7 +245,7 @@ default_text_model: qwen3.8-max-preview default_video_model: happyhorse-1.1-t2v default_image_to_video_model: happyhorse-1.1-i2v default_reference_to_video_model: happyhorse-1.1-r2v -default_image_model: qwen-image-2.0 +default_image_model: wan2.7-image ``` Token Plan Base URL 预设只在登录写入阶段提供最低优先级的缺省值: @@ -259,7 +259,7 @@ Token Plan Base URL 预设只在登录写入阶段提供最低优先级的缺省 登录成功时应把显式 Base URL 或缺失的预设 Base URL,以及默认模型写入 Profile,使 `config show --config token-plan` 能看到完整配置。环境变量不复制进 Profile。运行时不再合并预设;如果手工删除字段,则按统一的环境变量、配置文件和系统默认值链继续解析。 -默认模型采用更简单的固定策略:每次执行 `auth login --config token-plan`,都将 `default_text_model` 重置为 `qwen3.8-max-preview`,将 `default_video_model` 重置为 `happyhorse-1.1-t2v`,将 `default_image_to_video_model` 重置为 `happyhorse-1.1-i2v`,将 `default_reference_to_video_model` 重置为 `happyhorse-1.1-r2v`,将 `default_image_model` 重置为 `qwen-image-2.0`。登录不保留用户之前写入的其他 Profile 默认模型;用户需要临时调用其他 Token Plan 模型时,通过具体模型命令的 `--model` 覆盖,不修改这些内置默认值。 +默认模型采用更简单的固定策略:每次执行 `auth login --config token-plan`,都将 `default_text_model` 重置为 `qwen3.8-max-preview`,将 `default_video_model` 重置为 `happyhorse-1.1-t2v`,将 `default_image_to_video_model` 重置为 `happyhorse-1.1-i2v`,将 `default_reference_to_video_model` 重置为 `happyhorse-1.1-r2v`,将 `default_image_model` 重置为 `wan2.7-image`。登录不保留用户之前写入的其他 Profile 默认模型;用户需要临时调用其他 Token Plan 模型时,通过具体模型命令的 `--model` 覆盖,不修改这些内置默认值。 预设建议通过集中 registry 表达,不在 resolver、命令和 Client 中散落名称判断: @@ -271,7 +271,7 @@ const MODEL_PROFILE_PRESETS = { defaultVideoModel: "happyhorse-1.1-t2v", defaultImageToVideoModel: "happyhorse-1.1-i2v", defaultReferenceToVideoModel: "happyhorse-1.1-r2v", - defaultImageModel: "qwen-image-2.0", + defaultImageModel: "wan2.7-image", }, }; ``` @@ -381,13 +381,29 @@ image: /api/v1/services/aigc/.../generation | 能力 | 默认模型 | 调用方式 | | -------------- | --------------------- | ---------------------------------- | | 文本生成和推理 | `qwen3.8-max-preview` | OpenAI Compatible Chat Completions | -| 图片生成和编辑 | `qwen-image-2.0` | DashScope 原生图片接口 | +| 图片生成和编辑 | `wan2.7-image` | DashScope 多模态图片接口 | | 文生视频 | `happyhorse-1.1-t2v` | DashScope 原生视频接口 | | 图生视频 | `happyhorse-1.1-i2v` | `bl video generate --image` | | 参考生视频 | `happyhorse-1.1-r2v` | `bl video ref` | Token Plan 当前模型快照中还包含其他文本、视觉理解、图片和视频模型,但该列表可能由后端调整。基础接入不维护阻断请求的本地白名单;用户可通过具体模型命令的 `--model` 临时覆盖本次请求,但再次登录时 Profile 默认模型仍重置为内置版本。 +### 当前模型与本地图片兼容范围 + +| 模型 | 图片输入能力 | CLI 本地图片处理 | +| ----------------------------------- | -------------- | ---------------------------------------------------------- | +| `qwen3.8-max-preview` | 视觉理解 | Token Plan 下转换为 Base64 Data URI | +| `qwen3.7-plus` | 视觉理解 | Token Plan 下转换为 Base64 Data URI | +| `qwen3.7-max` | 纯文本 | 不涉及图片上传 | +| `qwen3.6-flash` | 视觉理解 | Token Plan 下转换为 Base64 Data URI | +| `wan2.7-image` / `wan2.7-image-pro` | 图片生成与编辑 | 文生图不需要输入图片;编辑本地图片时转换为 Base64 Data URI | +| `happyhorse-1.1-i2v` | 图生视频 | 首帧本地图片转换为 Base64 Data URI | +| `happyhorse-1.1-t2v` | 文生视频 | 不涉及图片上传 | +| `happyhorse-1.1-r2v` | 参考生视频 | 参考本地图片转换为 Base64 Data URI | +| `deepseek-v4-pro` / `glm-5.2` | 纯文本 | 不涉及图片上传 | + +Token Plan 图片兼容只处理官方明确支持 Base64 的图片字段;参考视频和参考音频仍要求可访问 URL。普通 API Key 保持各命令既有行为:图片编辑和视频入口继续使用临时 OSS,视觉理解的小图继续使用原有 Base64 路径。 + 语音和音频不作为本阶段支持承诺。现有命令仍保持通用实现,但 Token Plan Profile 的验收不包含这些模态。 ## 错误处理 @@ -443,7 +459,7 @@ feat(auth): support token-plan API key login - 使用 Token Plan 预设文本模型验证 API Key。 - 登录验证前不写配置。 - 验证成功后一次写入 API Key、canonical Base URL 和默认模型。 -- 每次登录都将默认模型重置为 `qwen3.8-max-preview`、`qwen-image-2.0`、`happyhorse-1.1-t2v`、`happyhorse-1.1-i2v` 和 `happyhorse-1.1-r2v`。 +- 每次登录都将默认模型重置为 `qwen3.8-max-preview`、`wan2.7-image`、`happyhorse-1.1-t2v`、`happyhorse-1.1-i2v` 和 `happyhorse-1.1-r2v`。 - 验证失败不留下半配置。 - 补充一个最小 Token Plan 登录 E2E,覆盖命名 Profile 落盘、环境变量不复制、预设 Base URL 物化和默认模型重置;通用 API Key 登录 E2E 继续覆盖成功原子保存和失败不写半配置。 - 该 commit 暂不承诺自动归一化用户显式输入的 SDK Base URL。 @@ -546,7 +562,7 @@ fix(core): normalize model base URLs across all sources - 显式 Base URL 覆盖预设并经过通用归一化。 - 登录验证失败不写入任何 Token Plan 半配置。 - 文本默认使用 `qwen3.8-max-preview`。 -- 图片默认使用 `qwen-image-2.0`。 +- 图片默认使用 `wan2.7-image`。 - 视频默认使用 `happyhorse-1.1-t2v`;图生和参考生入口分别使用 `happyhorse-1.1-i2v` 和 `happyhorse-1.1-r2v`。 - 文本、图片和视频均复用现有 `apiKey` Client。 - 管控命令继续使用 OpenAPI AK/SK,不受模型 Profile 影响。 diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index 587610b5..a6996e40 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -22,6 +22,7 @@ import { resolveWatermark, ASYNC_FLAG, CONCURRENT_FLAG, + redactDataUri, } from "bailian-cli-core"; import { poll } from "bailian-cli-runtime"; import { downloadFile } from "bailian-cli-runtime"; @@ -31,10 +32,15 @@ import { resolveImageSize } from "bailian-cli-runtime"; import { join } from "path"; import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; -const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; +const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"]; +const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; function isSyncModel(model: string): boolean { - return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p)); + return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix)); +} + +function enablesPromptExtendByDefault(model: string): boolean { + return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix)); } const EDIT_FLAGS = { @@ -98,7 +104,7 @@ const EDIT_FLAGS = { type EditFlags = ParsedFlags; export default defineCommand({ - description: "Edit an existing image with text instructions (Qwen-Image)", + description: "Edit an existing image with text instructions (Qwen-Image / Wan 2.7)", auth: "apiKey", usageArgs: "--image --prompt [flags]", flags: EDIT_FLAGS, @@ -107,6 +113,7 @@ export default defineCommand({ '--image https://example.com/logo.png --prompt "Change color to blue" --n 3', '--image ./a.png --image ./b.png --prompt "Merge two images into one collage"', '--image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro', + '--image ./photo.png --prompt "Change the style" --model wan2.7-image', '--image ./photo.png --prompt "Replace the background with a beach" --watermark false', ], async run(ctx) { @@ -125,13 +132,13 @@ export default defineCommand({ // Auto-upload local files (resolve all images in parallel) const resolvedImages = await Promise.all( - rawImages.map((img) => ctx.client.uploadFile(img, model)), + rawImages.map((image) => ctx.client.resolveImageInput(image, model)), ); const n = flags.n ?? 1; const promptExtend = resolveBooleanFlag( flags.promptExtend, - useSync ? true : undefined, + enablesPromptExtendByDefault(model) ? true : undefined, "prompt-extend", ); @@ -169,7 +176,18 @@ export default defineCommand({ const format = detectOutputFormat(settings.output); if (settings.dryRun) { - emitResult({ request: body, mode: useSync ? "sync" : "async" }, format); + const previewBody = { + ...body, + input: { + messages: body.input.messages.map((message) => ({ + ...message, + content: message.content.map((item) => + item.image ? { ...item, image: redactDataUri(item.image) } : item, + ), + })), + }, + }; + emitResult({ request: previewBody, mode: useSync ? "sync" : "async" }, format); return; } diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index 16ca555c..00bd7f5f 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -31,11 +31,16 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai import { join } from "path"; -// qwen-image-2.0 series uses the sync multimodal-generation endpoint -const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; +// Qwen-Image 2.0 and Wan 2.7 use the sync multimodal-generation endpoint. +const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"]; +const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; function isSyncModel(model: string): boolean { - return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p)); + return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix)); +} + +function enablesPromptExtendByDefault(model: string): boolean { + return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix)); } const GENERATE_FLAGS = { @@ -121,7 +126,7 @@ export default defineCommand({ const promptExtend = resolveBooleanFlag( flags.promptExtend, - useSync ? true : undefined, + enablesPromptExtendByDefault(model) ? true : undefined, "prompt-extend", ); diff --git a/packages/commands/src/commands/video/generate.ts b/packages/commands/src/commands/video/generate.ts index 26c450fa..be3f89af 100644 --- a/packages/commands/src/commands/video/generate.ts +++ b/packages/commands/src/commands/video/generate.ts @@ -13,6 +13,7 @@ import { resolveWatermark, ASYNC_FLAG, CONCURRENT_FLAG, + redactDataUri, } from "bailian-cli-core"; import { poll } from "bailian-cli-runtime"; import { downloadFile, formatBytes } from "bailian-cli-runtime"; @@ -113,7 +114,7 @@ export default defineCommand({ // Auto-upload local image file for i2v let resolvedImageUrl: string | undefined; if (imageUrl) { - resolvedImageUrl = await ctx.client.uploadFile(imageUrl, model); + resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model); } const watermark = resolveWatermark(flags.watermark); @@ -140,7 +141,16 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult({ request: body }, format); + const previewBody = resolvedImageUrl + ? { + ...body, + input: { + ...body.input, + media: [{ type: "first_frame" as const, url: redactDataUri(resolvedImageUrl) }], + }, + } + : body; + emitResult({ request: previewBody }, format); return; } diff --git a/packages/commands/src/commands/video/ref.ts b/packages/commands/src/commands/video/ref.ts index e2a8f070..644d7edd 100644 --- a/packages/commands/src/commands/video/ref.ts +++ b/packages/commands/src/commands/video/ref.ts @@ -13,6 +13,7 @@ import { resolveWatermark, ASYNC_FLAG, CONCURRENT_FLAG, + redactDataUri, } from "bailian-cli-core"; import { poll } from "bailian-cli-runtime"; import { downloadFile, formatBytes } from "bailian-cli-runtime"; @@ -124,16 +125,16 @@ export default defineCommand({ const media: DashScopeVideoRefRequest["input"]["media"] = []; // Add reference images - for (let i = 0; i < images.length; i++) { - const resolved = await ctx.client.uploadFile(images[i]!, model); + for (let imageIndex = 0; imageIndex < images.length; imageIndex++) { + const resolved = await ctx.client.resolveImageInput(images[imageIndex]!, model); const entry: DashScopeVideoRefRequest["input"]["media"][number] = { type: "reference_image", url: resolved, }; // Pair voice by position - if (imageVoices[i]) { - const resolvedVoice = await ctx.client.uploadFile(imageVoices[i]!, model); + if (imageVoices[imageIndex]) { + const resolvedVoice = await ctx.client.uploadFile(imageVoices[imageIndex]!, model); entry.reference_voice = resolvedVoice; } @@ -141,16 +142,16 @@ export default defineCommand({ } // Add reference videos - for (let i = 0; i < refVideos.length; i++) { - const resolved = await ctx.client.uploadFile(refVideos[i]!, model); + for (let videoIndex = 0; videoIndex < refVideos.length; videoIndex++) { + const resolved = await ctx.client.uploadFile(refVideos[videoIndex]!, model); const entry: DashScopeVideoRefRequest["input"]["media"][number] = { type: "reference_video", url: resolved, }; // Pair voice by position - if (videoVoices[i]) { - const resolvedVoice = await ctx.client.uploadFile(videoVoices[i]!, model); + if (videoVoices[videoIndex]) { + const resolvedVoice = await ctx.client.uploadFile(videoVoices[videoIndex]!, model); entry.reference_voice = resolvedVoice; } @@ -178,7 +179,18 @@ export default defineCommand({ }; if (settings.dryRun) { - emitResult({ request: body }, format); + const previewBody = { + ...body, + input: { + ...body.input, + media: body.input.media.map((item) => ({ + ...item, + url: redactDataUri(item.url), + reference_voice: item.reference_voice ? redactDataUri(item.reference_voice) : undefined, + })), + }, + }; + emitResult({ request: previewBody }, format); return; } @@ -233,11 +245,11 @@ export default defineCommand({ ); const videos: Array<{ taskId: string; videoUrl: string }> = []; - for (let i = 0; i < results.length; i++) { - const result = results[i]!; + for (let resultIndex = 0; resultIndex < results.length; resultIndex++) { + const result = results[resultIndex]!; const videoUrl = result.output.video_url || (result.output.results && result.output.results[0]?.url); - if (videoUrl) videos.push({ taskId: taskIds[i]!, videoUrl }); + if (videoUrl) videos.push({ taskId: taskIds[resultIndex]!, videoUrl }); } if (videos.length === 0) { diff --git a/packages/commands/src/commands/vision/describe.ts b/packages/commands/src/commands/vision/describe.ts index 2d12a244..37dc7f72 100644 --- a/packages/commands/src/commands/vision/describe.ts +++ b/packages/commands/src/commands/vision/describe.ts @@ -8,18 +8,13 @@ import { BailianError, ExitCode, isLocalFile, + imageFileToDataUri, + redactDataUri, } from "bailian-cli-core"; import { emitResult, emitBare } from "bailian-cli-runtime"; -import { readFileSync, existsSync } from "fs"; +import { existsSync, statSync } from "fs"; import { extname } from "path"; -const IMAGE_MIME_TYPES: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".webp": "image/webp", -}; - const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]); function isVideoInput(input: string): boolean { @@ -35,18 +30,7 @@ async function toImageUrl(image: string): Promise { if (image.startsWith("data:")) return image; if (image.startsWith("http://") || image.startsWith("https://")) return image; if (image.startsWith("oss://")) return image; - - // Local file → data URI (for small files < 10MB, fallback) - if (!existsSync(image)) throw new BailianError(`File not found: ${image}`, ExitCode.USAGE); - const ext = extname(image).toLowerCase(); - const mime = IMAGE_MIME_TYPES[ext]; - if (!mime) - throw new BailianError( - `Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`, - ExitCode.USAGE, - ); - const buf = readFileSync(image); - return `data:${mime};base64,${buf.toString("base64")}`; + return imageFileToDataUri(image); } export default defineCommand({ @@ -86,7 +70,10 @@ export default defineCommand({ const { settings, flags } = ctx; let image = flags.image; const videoInputs = flags.video ?? []; - const model = flags.model || "qwen3-vl-plus"; + const model = + flags.model || + (ctx.client.usesTokenPlanEndpoint() ? settings.defaultTextModel : undefined) || + "qwen3-vl-plus"; // Auto-detect: if --image was given a video file, treat it as --video if (image && isVideoInput(image)) { @@ -102,7 +89,14 @@ export default defineCommand({ if (settings.dryRun) { emitResult( - { request: { prompt, image, video: videoInputs.length ? videoInputs : undefined, model } }, + { + request: { + prompt, + image: image ? redactDataUri(image) : undefined, + video: videoInputs.length ? videoInputs.map(redactDataUri) : undefined, + model, + }, + }, format, ); return; @@ -132,10 +126,9 @@ export default defineCommand({ let finalImageUrl = imageUrl; if (isLocalFile(image) && imageUrl.startsWith("data:")) { - const { statSync } = await import("fs"); const fileSize = statSync(image).size; if (fileSize > 5 * 1024 * 1024) { - finalImageUrl = await ctx.client.uploadFile(image, model); + finalImageUrl = await ctx.client.resolveImageInput(image, model); } } diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index b325e854..6f499b8a 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -271,7 +271,7 @@ describe("e2e: auth", () => { default_video_model: "happyhorse-1.1-t2v", default_image_to_video_model: "happyhorse-1.1-i2v", default_reference_to_video_model: "happyhorse-1.1-r2v", - default_image_model: "qwen-image-2.0", + default_image_model: "wan2.7-image", }); } finally { await validationServer.close(); @@ -334,7 +334,7 @@ describe("e2e: auth", () => { default_video_model: "happyhorse-1.1-t2v", default_image_to_video_model: "happyhorse-1.1-i2v", default_reference_to_video_model: "happyhorse-1.1-r2v", - default_image_model: "qwen-image-2.0", + default_image_model: "wan2.7-image", }); expect((config["token-plan"] as Record).base_url).not.toBe( validationServer.baseUrl, diff --git a/packages/commands/tests/e2e/image-edit.e2e.test.ts b/packages/commands/tests/e2e/image-edit.e2e.test.ts index 486bbca8..7476a8d5 100644 --- a/packages/commands/tests/e2e/image-edit.e2e.test.ts +++ b/packages/commands/tests/e2e/image-edit.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; -import { join } from "path"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; import { e2eFixturesDir, e2eLabelFromMetaUrl, @@ -46,6 +47,55 @@ describe("e2e: image edit", () => { expect(data.mode).toBe("async"); expect(data.request?.input?.messages?.length).toBeGreaterThan(0); }); + + test("Token Plan 使用 Base64 传入 wan2.7-image 本地图片", async () => { + const configDir = makeE2eOutputDir("image-edit-token-plan-local-image"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ + "token-plan": { + api_key: "sk-sp-e2e-placeholder", + base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", + default_image_model: "wan2.7-image", + }, + }), + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + IMAGE_ROUTES, + [ + "image", + "edit", + "--config", + "token-plan", + "--image", + join(e2eFixturesDir, ".smoke-32.png"), + "--prompt", + "改成蓝色", + "--dry-run", + "--output", + "json", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + mode?: string; + request?: { + model?: string; + input?: { messages?: Array<{ content?: Array<{ image?: string }> }> }; + }; + }>(stdout); + expect(data.mode).toBe("sync"); + expect(data.request?.model).toBe("wan2.7-image"); + expect(data.request?.input?.messages?.[0]?.content?.[0]?.image).toBe( + "data:image/png;base64,", + ); + }); }); describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: image edit", () => { diff --git a/packages/commands/tests/e2e/image-generate.e2e.test.ts b/packages/commands/tests/e2e/image-generate.e2e.test.ts index b9875369..0a5cab6e 100644 --- a/packages/commands/tests/e2e/image-generate.e2e.test.ts +++ b/packages/commands/tests/e2e/image-generate.e2e.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; import { e2eLabelFromMetaUrl, isBailianE2EMediaEnabled, @@ -21,6 +23,44 @@ describe("e2e: image generate", () => { expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/generate|--prompt|--model/i); }); + + test("Token Plan 默认使用 wan2.7-image 同步接口", async () => { + const configDir = makeE2eOutputDir("image-generate-token-plan-default"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ + "token-plan": { + api_key: "sk-sp-e2e-placeholder", + base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", + default_image_model: "wan2.7-image", + }, + }), + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + IMAGE_ROUTES, + [ + "image", + "generate", + "--config", + "token-plan", + "--prompt", + "一只猫", + "--dry-run", + "--output", + "json", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ mode?: string; request?: { model?: string } }>(stdout); + expect(data.mode).toBe("sync"); + expect(data.request?.model).toBe("wan2.7-image"); + }); }); describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 6f3e98a8..fa050065 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -59,6 +59,8 @@ export const VIDEO_ROUTES: E2eRouteExports = { "video download": "videoDownload", }; +export const VISION_ROUTES: E2eRouteExports = { "vision describe": "visionDescribe" }; + export const SPEECH_ROUTES: E2eRouteExports = { "speech synthesize": "speechSynthesize", "speech recognize": "speechRecognize", diff --git a/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts index fc21bf36..1a26e2aa 100644 --- a/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts @@ -69,6 +69,49 @@ describe("e2e: video generate (i2v)", () => { expect(data.request?.model).toBe("custom-image-to-video-model"); expect(data.request?.input?.media?.[0]?.type).toBe("first_frame"); }); + + test("Token Plan 图生视频将本地首帧转换为 Base64", async () => { + const configDir = makeE2eOutputDir("video-i2v-token-plan-local-image"); + const imagePath = join(configDir, "first-frame.png"); + writeFileSync(imagePath, Buffer.from([1, 2, 3])); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ + "token-plan": { + api_key: "sk-sp-e2e-placeholder", + base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", + default_image_to_video_model: "happyhorse-1.1-i2v", + }, + }), + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + VIDEO_ROUTES, + [ + "video", + "generate", + "--config", + "token-plan", + "--dry-run", + "--image", + imagePath, + "--prompt", + "让画面动起来", + "--output", + "json", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { input?: { media?: Array<{ url?: string }> } }; + }>(stdout); + expect(data.request?.input?.media?.[0]?.url).toBe("data:image/png;base64,"); + }); }); describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( diff --git a/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts index e2629605..bd697f51 100644 --- a/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts @@ -87,6 +87,49 @@ describe("e2e: video ref (r2v)", () => { const data = parseStdoutJson<{ request?: { model?: string } }>(stdout); expect(data.request?.model).toBe("custom-reference-to-video-model"); }); + + test("Token Plan 参考生视频将本地参考图转换为 Base64", async () => { + const configDir = makeE2eOutputDir("video-r2v-token-plan-local-image"); + const imagePath = join(configDir, "reference.png"); + writeFileSync(imagePath, Buffer.from([1, 2, 3])); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ + "token-plan": { + api_key: "sk-sp-e2e-placeholder", + base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", + default_reference_to_video_model: "happyhorse-1.1-r2v", + }, + }), + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + VIDEO_ROUTES, + [ + "video", + "ref", + "--config", + "token-plan", + "--dry-run", + "--image", + imagePath, + "--prompt", + "Image 1 waves", + "--output", + "json", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { input?: { media?: Array<{ url?: string }> } }; + }>(stdout); + expect(data.request?.input?.media?.[0]?.url).toBe("data:image/png;base64,"); + }); }); describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( diff --git a/packages/commands/tests/e2e/vision-describe.e2e.test.ts b/packages/commands/tests/e2e/vision-describe.e2e.test.ts new file mode 100644 index 00000000..a063911e --- /dev/null +++ b/packages/commands/tests/e2e/vision-describe.e2e.test.ts @@ -0,0 +1,45 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { makeE2eOutputDir, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { VISION_ROUTES } from "./topic-routes.ts"; + +describe("e2e: vision describe", () => { + test("Token Plan 默认使用支持视觉理解的文本模型", async () => { + const configDir = makeE2eOutputDir("vision-describe-token-plan-default"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ + "token-plan": { + api_key: "sk-sp-e2e-placeholder", + base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", + default_text_model: "qwen3.8-max-preview", + }, + }), + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + VISION_ROUTES, + [ + "vision", + "describe", + "--config", + "token-plan", + "--image", + "https://example.com/image.png", + "--dry-run", + "--output", + "json", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ request?: { model?: string } }>(stdout); + expect(data.request?.model).toBe("qwen3.8-max-preview"); + }); +}); diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index a1ef15c7..cb0a720a 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -4,7 +4,7 @@ import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import { request, requestJson, type HttpDeps, type RequestOpts } from "./http.ts"; import { buildAcsCanonicalQuery, signAcsRequest, type AcsQueryParams } from "./acs.ts"; -import { isLocalFile, resolveFileUrl } from "../files/upload.ts"; +import { imageFileToDataUri, isLocalFile, resolveFileUrl } from "../files/upload.ts"; import { McpClient } from "./mcp.ts"; import { callConsoleGateway } from "../console/gateway.ts"; import { refreshAccessToken } from "../auth/refresh-token.ts"; @@ -118,6 +118,32 @@ export class Client { return resolveFileUrl(source, this.requireApi().token, model, opts); } + /** + * Resolve an image input while keeping Token Plan's upload limitation isolated. + * Token Plan local images are sent as Data URIs; every other connection keeps + * the established temporary OSS upload flow. URLs and existing Data URIs pass through. + */ + resolveImageInput( + source: string, + model: string, + opts: { signal?: AbortSignal } = {}, + ): Promise { + if (!isLocalFile(source)) return Promise.resolve(source); + if (this.usesTokenPlanEndpoint()) { + return Promise.resolve(imageFileToDataUri(source)); + } + return this.uploadFile(source, model, { signal: opts.signal }); + } + + usesTokenPlanEndpoint(): boolean { + if (this.deps.settings.configName === "token-plan") return true; + try { + return /^token-plan\.[a-z0-9-]+\.maas\.aliyuncs\.com$/i.test(new URL(this.baseUrl).hostname); + } catch { + return false; + } + } + /** Open an MCP client. Accepts a path (prepended with the model baseUrl) or an absolute URL. */ mcp(pathOrUrl: string): McpClient { const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : this.requireApi().baseUrl + pathOrUrl; diff --git a/packages/core/src/config/profile-presets.ts b/packages/core/src/config/profile-presets.ts index 83a19f11..f1d167c2 100644 --- a/packages/core/src/config/profile-presets.ts +++ b/packages/core/src/config/profile-presets.ts @@ -14,7 +14,7 @@ const MODEL_PROFILE_PRESETS: Readonly> = { defaultVideoModel: "happyhorse-1.1-t2v", defaultImageToVideoModel: "happyhorse-1.1-i2v", defaultReferenceToVideoModel: "happyhorse-1.1-r2v", - defaultImageModel: "qwen-image-2.0", + defaultImageModel: "wan2.7-image", }, }; diff --git a/packages/core/src/files/index.ts b/packages/core/src/files/index.ts index a2931ae0..cda2ca17 100644 --- a/packages/core/src/files/index.ts +++ b/packages/core/src/files/index.ts @@ -1 +1,7 @@ -export { uploadFile, isLocalFile, resolveFileUrl } from "./upload.ts"; +export { + uploadFile, + isLocalFile, + resolveFileUrl, + imageFileToDataUri, + redactDataUri, +} from "./upload.ts"; diff --git a/packages/core/src/files/upload.ts b/packages/core/src/files/upload.ts index c5ecdb02..2dffe619 100644 --- a/packages/core/src/files/upload.ts +++ b/packages/core/src/files/upload.ts @@ -6,7 +6,7 @@ * X-DashScope-OssResourceResolve: enable */ import { existsSync, readFileSync, statSync } from "fs"; -import { basename } from "path"; +import { basename, extname } from "path"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import { trackingHeaders } from "../client/headers.ts"; @@ -112,6 +112,49 @@ export interface UploadOptions { signal?: AbortSignal; } +const IMAGE_MIME_TYPES: Readonly> = { + ".bmp": "image/bmp", + ".heic": "image/heic", + ".jpe": "image/jpeg", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".webp": "image/webp", +}; + +/** Encode a local image as a Data URI. */ +export function imageFileToDataUri(filePath: string): string { + if (!existsSync(filePath)) { + throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE); + } + + const stat = statSync(filePath); + if (!stat.isFile()) { + throw new BailianError(`Not a file: ${filePath}`, ExitCode.USAGE); + } + + const extension = extname(filePath).toLowerCase(); + const mimeType = IMAGE_MIME_TYPES[extension]; + if (!mimeType) { + throw new BailianError( + `Unsupported image format "${extension || "unknown"}".`, + ExitCode.USAGE, + "Use an image file with a recognized extension.", + ); + } + + const encoded = readFileSync(filePath).toString("base64"); + return `data:${mimeType};base64,${encoded}`; +} + +/** Keep dry-run output readable and avoid echoing the complete inline image. */ +export function redactDataUri(input: string): string { + const match = /^data:([^;,]+);base64,/i.exec(input); + return match ? `data:${match[1]};base64,` : input; +} + /** * Upload a local file to DashScope temporary storage and return the oss:// URL. * The URL is valid for 48 hours. diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index 0b6ebbbf..89788672 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -36,7 +36,7 @@ test("token-plan Profile 预设保持固定", () => { defaultVideoModel: "happyhorse-1.1-t2v", defaultImageToVideoModel: "happyhorse-1.1-i2v", defaultReferenceToVideoModel: "happyhorse-1.1-r2v", - defaultImageModel: "qwen-image-2.0", + defaultImageModel: "wan2.7-image", }); }); diff --git a/packages/core/tests/image-input.test.ts b/packages/core/tests/image-input.test.ts new file mode 100644 index 00000000..806e317a --- /dev/null +++ b/packages/core/tests/image-input.test.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { Client } from "../src/client/client.ts"; +import { imageFileToDataUri, redactDataUri } from "../src/files/upload.ts"; +import type { Settings } from "../src/config/schema.ts"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +function makeImage(extension = ".png", content = Buffer.from([1, 2, 3, 4])): string { + const tempDir = mkdtempSync(join(tmpdir(), "bailian-image-input-")); + tempDirs.push(tempDir); + const filePath = join(tempDir, `input${extension}`); + writeFileSync(filePath, content); + return filePath; +} + +function makeSettings(configName?: string): Settings { + return { + configName, + output: "json", + outputExplicit: false, + timeout: 30, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, + }; +} + +function makeClient(baseUrl: string, configName?: string): Client { + return new Client({ + identity: { + binName: "bl", + version: "test", + npmPackage: "bailian-cli", + clientName: "bailian-cli-test", + }, + settings: makeSettings(configName), + baseUrl, + }); +} + +describe("Token Plan image input compatibility", () => { + test("encodes supported local images and redacts previews", () => { + const imagePath = makeImage(".png"); + const dataUri = imageFileToDataUri(imagePath); + + expect(dataUri).toBe("data:image/png;base64,AQIDBA=="); + expect(redactDataUri(dataUri)).toBe("data:image/png;base64,"); + }); + + test("rejects files whose image MIME type cannot be inferred", () => { + const imagePath = makeImage(".unknown"); + expect(() => imageFileToDataUri(imagePath)).toThrow(/Unsupported image format/); + }); + + test("uses Data URI for the token-plan profile even through a custom proxy", async () => { + const imagePath = makeImage(".webp"); + const client = makeClient("https://proxy.example.com/bailian", "token-plan"); + + await expect(client.resolveImageInput(imagePath, "happyhorse-1.1-i2v")).resolves.toMatch( + /^data:image\/webp;base64,/, + ); + }); + + test("uses Data URI for an official Token Plan endpoint under any profile name", async () => { + const imagePath = makeImage(".jpg"); + const client = makeClient("https://token-plan.ap-southeast-1.maas.aliyuncs.com", "custom-plan"); + + await expect(client.resolveImageInput(imagePath, "wan2.7-image")).resolves.toMatch( + /^data:image\/jpeg;base64,/, + ); + }); + + test("ordinary endpoints retain the existing upload path", () => { + const imagePath = makeImage(".png"); + const client = makeClient("https://dashscope.aliyuncs.com", "default"); + + expect(() => client.resolveImageInput(imagePath, "wan2.7-image")).toThrow( + /model-domain API key/, + ); + }); +}); diff --git a/skills/bailian-cli/assets/setup.md b/skills/bailian-cli/assets/setup.md index 07f9ceea..7dfe1e09 100644 --- a/skills/bailian-cli/assets/setup.md +++ b/skills/bailian-cli/assets/setup.md @@ -68,7 +68,7 @@ The built-in `token-plan` profile defaults to: - Base URL: `https://token-plan.cn-beijing.maas.aliyuncs.com` - Text model: `qwen3.8-max-preview` -- Image model: `qwen-image-2.0` +- Image model: `wan2.7-image` - Text-to-video model (`default_video_model`): `happyhorse-1.1-t2v` - Image-to-video model (`default_image_to_video_model`): `happyhorse-1.1-i2v` - Reference-to-video model (`default_reference_to_video_model`): `happyhorse-1.1-r2v` diff --git a/skills/bailian-cli/reference/image.md b/skills/bailian-cli/reference/image.md index 028e9bf1..84419f8b 100644 --- a/skills/bailian-cli/reference/image.md +++ b/skills/bailian-cli/reference/image.md @@ -7,20 +7,20 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| ------------------- | ---------------------------------------------------------- | -| `bl image edit` | Edit an existing image with text instructions (Qwen-Image) | -| `bl image generate` | Generate images (Qwen-Image / wan2.x) | +| Command | Description | +| ------------------- | -------------------------------------------------------------------- | +| `bl image edit` | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | +| `bl image generate` | Generate images (Qwen-Image / wan2.x) | ## Command details ### `bl image edit` -| Field | Value | -| --------------- | ---------------------------------------------------------- | -| **Name** | `image edit` | -| **Description** | Edit an existing image with text instructions (Qwen-Image) | -| **Usage** | `bl image edit --image --prompt [flags]` | +| Field | Value | +| --------------- | -------------------------------------------------------------------- | +| **Name** | `image edit` | +| **Description** | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | +| **Usage** | `bl image edit --image --prompt [flags]` | #### Flags @@ -61,6 +61,10 @@ bl image edit --image ./a.png --image ./b.png --prompt "Merge two images into on bl image edit --image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro ``` +```bash +bl image edit --image ./photo.png --prompt "Change the style" --model wan2.7-image +``` + ```bash bl image edit --image ./photo.png --prompt "Replace the background with a beach" --watermark false ``` diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 9601e166..58f7e0d9 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -51,7 +51,7 @@ Use this index for the full quick index and global flags. | `bl finetune logs` | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) | | `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) | | `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [finetune.md](finetune.md) | -| `bl image edit` | Edit an existing image with text instructions (Qwen-Image) | [image.md](image.md) | +| `bl image edit` | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) | | `bl image generate` | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | | `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) | | `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) |