From f29f5fe6321884065e51f94911818f64edccb26b Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Mon, 24 Aug 2026 14:01:50 +0800 Subject: [PATCH 01/13] feat: update install guide --- INSTALL.md | 64 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 83662aa..a4f2cf2 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -13,22 +13,25 @@ npm install -g bailian-cli ``` 4. 校验:`bl --version`。 +5. 按下方「安装 skills」安装。 -安装 skills(CLI 内置,无需 Git / npx skills): +### 安装 skills + +**Supported(优先):** 用 CLI 内置命令一次装齐 registry 中全部 `bailian-*`(含共享协议 `bailian-protocol`): ```bash bl skill init ``` -**Supported:** `bl skill init` 一次装齐 registry 中全部 `bailian-*`(含共享协议 `bailian-protocol`)。 - -**Advanced / 按需子集:** +**降级:** 若 `bl skill init` 失败,且本机有可用 Node / npm(`node -v` / `npm -v` 成功),再回退到: ```bash -bl skill add --name bailian-protocol,bailian-gen +npx skills add modelstudioai/cli --all -g ``` -安装成功后,用中文简要说明已安装的 skills 及用户可做什么。 +无 Node 时不要走 `npx` 降级;向用户说明 skill 安装失败,并保留 `bl skill init` 的错误信息。 + +安装成功后,按第 5 节向用户说明语言切换与能力 tip(若也装了 skills,一并简要说明)。 ## 2. 备选:二进制安装(无需 Node) @@ -67,8 +70,6 @@ bl --version which bl # Windows: where.exe bl ``` -若自动 skill 安装失败,再手动执行:`bl skill init`。 - > CDN / GitHub Release 未就绪或下载失败时,若本机已有合格 Node,回退到上方 npm 安装。 --- @@ -99,12 +100,43 @@ which bl # Windows: where.exe bl bl auth status --output json ``` -## 5. 常见问题 +## 5. 安装成功后向用户说明(必做) -| 现象 | 可能原因 | 建议动作 | -| ------------------------ | ---------------------------- | ------------------------------------------------ | -| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin` 或 `npm prefix -g` | -| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` | -| Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 | -| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node,或改用 npm 版 CLI | -| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 | +安装与校验完成后,**主动**用中文向用户说明下列两项;不要等用户追问。可执行 `bl`(已登录时)对照终端 tip,但以下列表为准,勿臆造。 + +### 5.1 界面语言 + +Bailian CLI 支持中英文界面(help / 欢迎语 / Quick Start 主次文案)。默认 `en-US`,可切换为 `zh-CN`: + +```bash +bl config set --key language --value zh-CN +bl config set --key language --value en-US +``` + +向用户说明:可随时用上述命令切换语言。 + +### 5.2 能力 tip(Quick Start) + +向用户展示「试试使用Bailian CLI完成这些任务」及下列 5 条(中英各一行,与 `bl` 根帮助 tip 一致): + +1. 帮我创建一个能够生成短片分镜和视频的 Managed Agent。 + Help me create a Managed Agent that can generate short-film storyboards and videos. +2. 生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。 + Generate an image of a cat in a spacesuit standing on Mars, then turn it into a video. +3. 查看最近的模型用量、免费额度和限流情况。 + Check my recent model usage, free quota, and rate limits. +4. 推荐一个适合图片理解和智能客服的模型。 + Recommend a model suitable for image understanding and intelligent customer service. +5. 介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。 + Explain what Bailian CLI can help me accomplish, and recommend how to use it based on my needs. + +## 6. 常见问题 + +| 现象 | 可能原因 | 建议动作 | +| ------------------------ | ---------------------------- | ------------------------------------------------------------------------------------ | +| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin` 或 `npm prefix -g` | +| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` | +| Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 | +| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node,或改用 npm 版 CLI | +| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 | +| `bl skill init` 失败 | 网络 / registry 不可达等 | 有 Node 时降级 `npx skills add modelstudioai/cli --all -g`;无 Node 则重试或告知用户 | From 4c51c95f313ef47f8e8a3d99330191f26a9d06c5 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Mon, 24 Aug 2026 18:00:52 +0800 Subject: [PATCH 02/13] feat(video): switch default video model to wan3.0-video and adapt All-in-One support - Update default models for video generate/ref and config UI to wan3.0-video - Add --file file-to-video support for wan3.0; route kf2v through media[] instead of image2video - Emit reference voices as standalone reference_audio entries on wan3.0 ref - Sync e2e/stress tests and bailian-gen reference docs --- packages/cli/tests/stress/lib/fixtures.mjs | 2 +- .../cli/tests/stress/lib/suite-fixtures.mjs | 2 +- .../cli/tests/stress/targets/video-i2v.mjs | 2 +- .../cli/tests/stress/targets/video-ref.mjs | 2 +- .../cli/tests/stress/targets/video-t2v.mjs | 2 +- .../commands/src/commands/config/shared.ts | 16 +- .../commands/src/commands/video/generate.ts | 93 ++++++-- packages/commands/src/commands/video/ref.ts | 47 +++- packages/commands/tests/e2e/topic-routes.ts | 1 + .../tests/e2e/video-generate-i2v.e2e.test.ts | 11 +- .../tests/e2e/video-generate-t2v.e2e.test.ts | 10 +- .../tests/e2e/video-ref-r2v.e2e.test.ts | 211 +++++++++++++++++- packages/core/src/types/api.ts | 11 +- packages/runtime/src/pipeline/steps/bl-api.ts | 2 +- skills/bailian-gen/SKILL.md | 4 +- skills/bailian-gen/reference/index.md | 26 +-- skills/bailian-gen/reference/video.md | 117 +++++----- 17 files changed, 431 insertions(+), 128 deletions(-) diff --git a/packages/cli/tests/stress/lib/fixtures.mjs b/packages/cli/tests/stress/lib/fixtures.mjs index 40380c0..e0152af 100644 --- a/packages/cli/tests/stress/lib/fixtures.mjs +++ b/packages/cli/tests/stress/lib/fixtures.mjs @@ -178,7 +178,7 @@ export async function ensurePrerequisites(ctx) { "video", "generate", "--model", - "happyhorse-1.1-t2v", + "wan3.0-video", "--prompt", "压测前置短视频:海浪与静态远景,无明显人物。", "--duration", diff --git a/packages/cli/tests/stress/lib/suite-fixtures.mjs b/packages/cli/tests/stress/lib/suite-fixtures.mjs index 589b2ea..147184c 100644 --- a/packages/cli/tests/stress/lib/suite-fixtures.mjs +++ b/packages/cli/tests/stress/lib/suite-fixtures.mjs @@ -130,7 +130,7 @@ export async function generateCombinedFixtures({ suiteRoot, cliPackage }) { "video", "generate", "--model", - "happyhorse-1.1-t2v", + "wan3.0-video", "--prompt", "压测前置短视频:海浪与静态远景,无明显人物。", "--duration", diff --git a/packages/cli/tests/stress/targets/video-i2v.mjs b/packages/cli/tests/stress/targets/video-i2v.mjs index ba8769a..d72b78f 100644 --- a/packages/cli/tests/stress/targets/video-i2v.mjs +++ b/packages/cli/tests/stress/targets/video-i2v.mjs @@ -16,7 +16,7 @@ const motions = [ export const runStress = defineStressTarget({ canonical: "video-i2v", - defaultModel: "happyhorse-1.1-i2v", + defaultModel: "wan3.0-video", batchDirPrefix: "video-i2v-batch", helpText: "pnpm run test:stress -- video-i2v [--reuse-fixtures] -- --count 5 -c 2", diff --git a/packages/cli/tests/stress/targets/video-ref.mjs b/packages/cli/tests/stress/targets/video-ref.mjs index 913f794..e2354e1 100644 --- a/packages/cli/tests/stress/targets/video-ref.mjs +++ b/packages/cli/tests/stress/targets/video-ref.mjs @@ -16,7 +16,7 @@ const prompts = [ export const runStress = defineStressTarget({ canonical: "video-ref", - defaultModel: "happyhorse-1.1-r2v", + defaultModel: "wan3.0-video", batchDirPrefix: "video-ref-batch", helpText: "pnpm run test:stress -- video-ref [--reuse-fixtures] -- --count 5 -c 2", diff --git a/packages/cli/tests/stress/targets/video-t2v.mjs b/packages/cli/tests/stress/targets/video-t2v.mjs index 1f015d9..cc32165 100644 --- a/packages/cli/tests/stress/targets/video-t2v.mjs +++ b/packages/cli/tests/stress/targets/video-t2v.mjs @@ -45,7 +45,7 @@ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; export const runStress = defineStressTarget({ canonical: "video-t2v", - defaultModel: "happyhorse-1.1-t2v", + defaultModel: "wan3.0-video", batchDirPrefix: "video-t2v-batch", helpText: `用法:pnpm run test:stress -- video-t2v -- --concurrency 1 --count 3 详见 docs/agents/stress-batch-tests.md`, diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts index 8cd5446..d849901 100644 --- a/packages/commands/src/commands/config/shared.ts +++ b/packages/commands/src/commands/config/shared.ts @@ -69,7 +69,9 @@ export const UI_BOOLEAN_KEYS = new Set(["telemetry"]); export const UI_MODEL_DEFAULTS: Record = { default_text_model: "qwen3.8-max", default_image_model: "qwen-image-3.0", - default_video_model: "happyhorse-1.1-t2v", + default_video_model: "wan3.0-video", + default_image_to_video_model: "wan3.0-video", + default_reference_to_video_model: "wan3.0-video", default_speech_model: "cosyvoice-v3-flash", default_omni_model: "qwen3.5-omni-plus", }; @@ -100,8 +102,18 @@ export const UI_MODEL_CATALOG: Record = { { id: "wanx2.x", role: "image/generate · async series" }, ], default_video_model: [ - { id: "happyhorse-1.1-t2v", role: "video/generate default · text-to-video" }, + { id: "wan3.0-video", role: "video/generate · t2v / i2v / r2v default" }, + { id: "happyhorse-1.1-t2v", role: "video/generate · text-to-video" }, { id: "happyhorse-1.1-i2v", role: "video/generate · image-to-video" }, + { id: "happyhorse-1.1-r2v", role: "video/ref · reference-to-video" }, + ], + default_image_to_video_model: [ + { id: "wan3.0-video", role: "video/generate default · image-to-video" }, + { id: "happyhorse-1.1-i2v", role: "video/generate · image-to-video" }, + ], + default_reference_to_video_model: [ + { id: "wan3.0-video", role: "video/ref default · reference-to-video" }, + { id: "happyhorse-1.1-r2v", role: "video/ref · reference-to-video" }, ], default_speech_model: [ { id: "cosyvoice-v3-flash", role: "speech/synthesize (TTS) default" }, diff --git a/packages/commands/src/commands/video/generate.ts b/packages/commands/src/commands/video/generate.ts index 62276fd..a2aab9a 100644 --- a/packages/commands/src/commands/video/generate.ts +++ b/packages/commands/src/commands/video/generate.ts @@ -24,9 +24,8 @@ import { BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, BOOL_FLAG_WATERMARK } from "bailia export default defineCommand({ description: { - "en-US": - "Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v)", - "zh-CN": "根据文本或图片生成视频(happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v)", + "en-US": "Generate a video from text or image (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v)", + "zh-CN": "根据文本或图片生成视频(wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v)", }, auth: "apiKey", usageArgs: "--prompt [--image ] [flags]", @@ -35,8 +34,8 @@ export default defineCommand({ type: "string", valueHint: "", description: { - "en-US": "Model ID (default: happyhorse-1.1-t2v, or happyhorse-1.1-i2v with --image)", - "zh-CN": "模型 ID(默认:happyhorse-1.1-t2v;使用 --image 时为 happyhorse-1.1-i2v)", + "en-US": "Model ID (default: wan3.0-video)", + "zh-CN": "模型 ID(默认:wan3.0-video)", }, }, prompt: { @@ -119,6 +118,16 @@ export default defineCommand({ "zh-CN": "完成后将视频保存到文件", }, }, + file: { + type: "string", + valueHint: "", + description: { + "en-US": + "Reference file URL or local path for file-to-video (wan3.0-video only; mutually exclusive with --image/--last-frame)", + "zh-CN": + "参考文件 URL 或本地路径,用于文件生视频(仅 wan3.0-video;与 --image/--last-frame 互斥)", + }, + }, ...ASYNC_FLAG, ...CONCURRENT_FLAG, pollInterval: { @@ -159,12 +168,20 @@ export default defineCommand({ const model = flags.model || (flags.image - ? settings.defaultImageToVideoModel || "happyhorse-1.1-i2v" - : settings.defaultVideoModel || "happyhorse-1.1-t2v"); + ? settings.defaultImageToVideoModel || "wan3.0-video" + : settings.defaultVideoModel || "wan3.0-video"); const format = detectOutputFormat(settings.output); const imageUrl = flags.image; const lastFrameUrl = flags.lastFrame as string | undefined; + const fileUrl = flags.file as string | undefined; + + if (fileUrl && (imageUrl || lastFrameUrl)) { + throw new BailianError( + "--file is mutually exclusive with --image/--last-frame.", + ExitCode.USAGE, + ); + } // Auto-upload local image file for i2v let resolvedImageUrl: string | undefined; @@ -178,6 +195,18 @@ export default defineCommand({ // kf2v mode: both --image and --last-frame provided. const isKf2v = Boolean(resolvedImageUrl && resolvedLastFrameUrl); + // 万相 3.x(All-in-One)首尾帧走 media[];旧 kf2v 仍走 image2video 平铺字段。 + // 与 video/ref.ts 的 useReferenceAudio 保持同一判定:忽略大小写、覆盖 wan3.x 系列。 + const isWan30 = /^wan3\./i.test(model); + + if (fileUrl && !isWan30) { + throw new BailianError("--file is only supported by wan3.0-video.", ExitCode.USAGE); + } + + let resolvedFileUrl: string | undefined; + if (fileUrl) { + resolvedFileUrl = await ctx.client.uploadFile(fileUrl, model); + } const watermark = resolveWatermark(flags.watermark); const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend"); @@ -187,16 +216,26 @@ export default defineCommand({ input: { prompt: prompt, negative_prompt: flags.negativePrompt || undefined, - // kf2v: first+last frame flat fields via image2video endpoint. + // wan3.0 kf2v: media[first_frame, last_frame] via video-generation endpoint. + // legacy kf2v: first_frame_url/last_frame_url via image2video endpoint. // wan2.1~2.6 i2v: flat img_url via video-generation endpoint. // wan2.7+ / happyhorse i2v: media[] via video-generation endpoint. - ...(isKf2v - ? { first_frame_url: resolvedImageUrl, last_frame_url: resolvedLastFrameUrl } - : resolvedImageUrl - ? /wan[x]?2\.[1-6]/i.test(model) - ? { img_url: resolvedImageUrl } - : { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] } - : {}), + ...(resolvedFileUrl + ? { media: [{ type: "file" as const, url: resolvedFileUrl }] } + : isKf2v + ? isWan30 + ? { + media: [ + { type: "first_frame" as const, url: resolvedImageUrl! }, + { type: "last_frame" as const, url: resolvedLastFrameUrl! }, + ], + } + : { first_frame_url: resolvedImageUrl, last_frame_url: resolvedLastFrameUrl } + : resolvedImageUrl + ? /wan[x]?2\.[1-6]/i.test(model) + ? { img_url: resolvedImageUrl } + : { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] } + : {}), }, parameters: { resolution: flags.resolution || undefined, @@ -210,13 +249,29 @@ export default defineCommand({ if (settings.dryRun) { let previewBody = body; - if (isKf2v) { + if (resolvedFileUrl) { previewBody = { ...body, input: { ...body.input, - first_frame_url: redactDataUri(resolvedImageUrl ?? ""), - last_frame_url: redactDataUri(resolvedLastFrameUrl ?? ""), + media: [{ type: "file" as const, url: redactDataUri(resolvedFileUrl) }], + }, + }; + } else if (isKf2v) { + const redactedFirst = redactDataUri(resolvedImageUrl ?? ""); + const redactedLast = redactDataUri(resolvedLastFrameUrl ?? ""); + previewBody = { + ...body, + input: { + ...body.input, + ...(isWan30 + ? { + media: [ + { type: "first_frame" as const, url: redactedFirst }, + { type: "last_frame" as const, url: redactedLast }, + ], + } + : { first_frame_url: redactedFirst, last_frame_url: redactedLast }), }, }; } else if (resolvedImageUrl) { @@ -243,7 +298,7 @@ export default defineCommand({ settings, () => ctx.client.requestJson({ - path: isKf2v ? image2videoPath() : videoGeneratePath(), + path: isKf2v && !isWan30 ? image2videoPath() : videoGeneratePath(), method: "POST", body, async: true, diff --git a/packages/commands/src/commands/video/ref.ts b/packages/commands/src/commands/video/ref.ts index 4d75de5..6535907 100644 --- a/packages/commands/src/commands/video/ref.ts +++ b/packages/commands/src/commands/video/ref.ts @@ -24,8 +24,9 @@ import { BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, BOOL_FLAG_WATERMARK } from "bailia export default defineCommand({ description: { "en-US": - "Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice", - "zh-CN": "参考生视频(happyhorse-1.1-r2v / wan2.6-r2v):支持多主体、多镜头和语音", + "Reference-to-video generation (wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice", + "zh-CN": + "参考生视频(wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v):支持多主体、多镜头和语音", }, auth: "apiKey", usageArgs: "--prompt --image ... [--ref-video ...] [flags]", @@ -34,8 +35,8 @@ export default defineCommand({ type: "string", valueHint: "", description: { - "en-US": "Model ID (default: happyhorse-1.1-r2v)", - "zh-CN": "模型 ID(默认:happyhorse-1.1-r2v)", + "en-US": "Model ID (default: wan3.0-video)", + "zh-CN": "模型 ID(默认:wan3.0-video)", }, }, prompt: { @@ -67,16 +68,20 @@ export default defineCommand({ type: "array", valueHint: "", description: { - "en-US": "Voice URL for corresponding image (pairs by position)", - "zh-CN": "对应图片的语音 URL(按位置配对)", + "en-US": + "Voice URL for corresponding image (pairs by position). On wan3.0-video emitted as reference_audio (refer to Audio 1, Audio 2 in prompt)", + "zh-CN": + "对应图片的语音 URL(按位置配对)。在 wan3.0-video 上作为 reference_audio 条目发送(prompt 中用「音频1」「音频2」引用)", }, }, videoVoice: { type: "array", valueHint: "", description: { - "en-US": "Voice URL for corresponding ref-video (pairs by position)", - "zh-CN": "对应参考视频的语音 URL(按位置配对)", + "en-US": + "Voice URL for corresponding ref-video (pairs by position). On wan3.0-video emitted as reference_audio (refer to Audio 1, Audio 2 in prompt)", + "zh-CN": + "对应参考视频的语音 URL(按位置配对)。在 wan3.0-video 上作为 reference_audio 条目发送(prompt 中用「音频1」「音频2」引用)", }, }, resolution: { @@ -182,11 +187,16 @@ export default defineCommand({ const imageVoices = flags.imageVoice || []; const videoVoices = flags.videoVoice || []; - const model = flags.model || settings.defaultReferenceToVideoModel || "happyhorse-1.1-r2v"; + const model = flags.model || settings.defaultReferenceToVideoModel || "wan3.0-video"; const format = detectOutputFormat(settings.output); + // wan3.0-video emits voice as standalone reference_audio entries (referenced as Audio 1, Audio 2 in prompt); + // other models (wan2.7-r2v / happyhorse-1.1-r2v, etc.) keep the legacy reference_voice field attached to each asset. + const useReferenceAudio = /^wan3\./i.test(model); + // --- Resolve file URLs (auto-upload local files) --- const media: DashScopeVideoRefRequest["input"]["media"] = []; + const audioEntries: Array<{ type: "reference_audio"; url: string }> = []; // Add reference images for (let imageIndex = 0; imageIndex < images.length; imageIndex++) { @@ -199,7 +209,11 @@ export default defineCommand({ // Pair voice by position if (imageVoices[imageIndex]) { const resolvedVoice = await ctx.client.uploadFile(imageVoices[imageIndex]!, model); - entry.reference_voice = resolvedVoice; + if (useReferenceAudio) { + audioEntries.push({ type: "reference_audio", url: resolvedVoice }); + } else { + entry.reference_voice = resolvedVoice; + } } media.push(entry); @@ -216,12 +230,23 @@ export default defineCommand({ // Pair voice by position if (videoVoices[videoIndex]) { const resolvedVoice = await ctx.client.uploadFile(videoVoices[videoIndex]!, model); - entry.reference_voice = resolvedVoice; + if (useReferenceAudio) { + audioEntries.push({ type: "reference_audio", url: resolvedVoice }); + } else { + entry.reference_voice = resolvedVoice; + } } media.push(entry); } + // wan3.0: append reference voices as standalone media entries, ordered image-voice then video-voice (Audio 1, Audio 2, ...) + if (useReferenceAudio) { + for (const audioEntry of audioEntries) { + media.push(audioEntry); + } + } + // --- Build request body --- const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend"); const watermark = resolveWatermark(flags.watermark); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index fe249e8..2c31677 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -62,6 +62,7 @@ export const VIDEO_ROUTES: E2eRouteExports = { "video ref": "videoRef", "video task get": "videoTaskGet", "video download": "videoDownload", + "speech synthesize": "speechSynthesize", }; export const VISION_ROUTES: E2eRouteExports = { 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 3b68657..0cc5a55 100644 --- a/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts @@ -114,10 +114,11 @@ describe("e2e: video generate (i2v)", () => { }); test.each([ - // wan2.1~2.6 (legacy) use flat img_url; wan2.7+ and happyhorse use media[]. + // wan2.1~2.6 (legacy) use flat img_url; wan2.7+ / wan3.0 / happyhorse use media[]. ["wan2.5-i2v-preview", "img_url"], ["wan2.6-i2v", "img_url"], ["wan2.7-i2v", "media"], + ["wan3.0-video", "media"], ["happyhorse-1.1-i2v", "media"], ])("video generate --dry-run %s 首帧走 %s 字段", async (model, field) => { const configDir = makeE2eOutputDir(`video-i2v-input-shape-${model}`); @@ -179,7 +180,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "generate", ...cliTimeoutPrefix(), "--model", - "happyhorse-1.1-i2v", + "wan3.0-video", "--image", "https://example.com/placeholder.png", ]); @@ -194,7 +195,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( ...cliTimeoutPrefix(), "--dry-run", "--model", - "happyhorse-1.1-t2v", + "wan3.0-video", "--prompt", "干跑无图", "--output", @@ -208,7 +209,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( expect(data.request?.input?.media).toBeUndefined(); }); - test("【happyhorse-1.1-i2v】图片生成视频", async () => { + test("【wan3.0-video】图片生成视频", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); const png = join(outDir, "e2e-gen.png"); const gen = await runCommandE2e(VIDEO_ROUTES, [ @@ -234,7 +235,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "generate", ...cliTimeoutPrefix(), "--model", - "happyhorse-1.1-i2v", + "wan3.0-video", "--image", imagePath, "--prompt", diff --git a/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts b/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts index e1efdfb..9151548 100644 --- a/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts @@ -32,7 +32,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "generate", ...cliTimeoutPrefix(), "--model", - "happyhorse-1.1-t2v", + "wan3.0-video", ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--prompt|Usage:/i); @@ -44,8 +44,6 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "generate", "--dry-run", ...cliTimeoutPrefix(), - "--model", - "happyhorse-1.1-t2v", "--prompt", "干跑校验", "--output", @@ -55,18 +53,18 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( const data = parseStdoutJson<{ request?: { model?: string; input?: { prompt?: string } } }>( stdout, ); - expect(data.request?.model).toBe("happyhorse-1.1-t2v"); + expect(data.request?.model).toBe("wan3.0-video"); expect(data.request?.input?.prompt).toBe("干跑校验"); }); - test("【happyhorse-1.1-t2v】文本生成视频", async () => { + test("【wan3.0-video】文本生成视频", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", ...cliTimeoutPrefix(), "--model", - "happyhorse-1.1-t2v", + "wan3.0-video", "--prompt", "夕阳下海面波光,远景静态镜头", "--download", 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 6d48568..8d37b9d 100644 --- a/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts @@ -45,6 +45,87 @@ describe("e2e: video ref (r2v)", () => { expect(data.request?.input?.media?.[0]?.url).toBe("https://example.com/person.png"); }); + test("video ref --dry-run 在 wan3.0-video 上将 --image-voice 转为独立 reference_audio 条目", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "ref", + "--dry-run", + "--model", + "wan3.0-video", + "--prompt", + "Image 1 speaks with Audio 1", + "--image", + "https://example.com/a.jpg", + "--ref-video", + "https://example.com/b.mp4", + "--image-voice", + "https://example.com/va.mp3", + "--video-voice", + "https://example.com/vb.mp3", + "--resolution", + "720P", + "--ratio", + "16:9", + "--duration", + "5", + "--prompt-extend", + "true", + "--watermark", + "false", + "--seed", + "42", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { + input?: { + media?: Array<{ type?: string; url?: string; reference_voice?: string }>; + }; + parameters?: { prompt_extend?: boolean; watermark?: boolean; seed?: number }; + }; + }>(stdout); + const media = data.request?.input?.media ?? []; + // wan3.0: image/video 条目不挂 reference_voice + const imageEntry = media.find((m) => m.type === "reference_image"); + expect(imageEntry?.reference_voice).toBeUndefined(); + // 音色作为独立 reference_audio 条目,按 image-voice 再 video-voice 顺序追加 + const audioEntries = media.filter((m) => m.type === "reference_audio"); + expect(audioEntries).toHaveLength(2); + expect(audioEntries[0]?.url).toBe("https://example.com/va.mp3"); + expect(audioEntries[1]?.url).toBe("https://example.com/vb.mp3"); + // 全部生成参数进入 parameters + expect(data.request?.parameters?.prompt_extend).toBe(true); + expect(data.request?.parameters?.watermark).toBe(false); + expect(data.request?.parameters?.seed).toBe(42); + }); + + test("video ref --dry-run 在 wan2.7-r2v 上沿用 reference_voice 字段(既有逻辑)", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "ref", + "--dry-run", + "--model", + "wan2.7-r2v", + "--prompt", + "Image 1 speaks", + "--image", + "https://example.com/a.jpg", + "--image-voice", + "https://example.com/va.mp3", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { input?: { media?: Array<{ type?: string; reference_voice?: string }> } }; + }>(stdout); + const media = data.request?.input?.media ?? []; + expect(media).toHaveLength(1); + expect(media[0]?.reference_voice).toBe("https://example.com/va.mp3"); + }); + test("Token Plan 使用独立的参考生视频默认模型", async () => { const configDir = makeE2eOutputDir("video-r2v-token-plan-default"); writeFileSync( @@ -141,7 +222,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "ref", ...cliTimeoutPrefix(), "--model", - "happyhorse-1.1-r2v", + "wan3.0-video", "--image", "https://example.com/x.png", ]); @@ -155,7 +236,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "ref", ...cliTimeoutPrefix(), "--model", - "happyhorse-1.1-r2v", + "wan3.0-video", "--prompt", "仅有描述无素材", ]); @@ -163,7 +244,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( expect(stderr).toMatch(/--image|ref-video|At least one|required/i); }); - test("【happyhorse-1.1-r2v】视频参考生成", async () => { + test("【wan3.0-video】视频参考生成", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); const gen = await runCommandE2e(VIDEO_ROUTES, [ "image", @@ -189,7 +270,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "ref", ...cliTimeoutPrefix(), "--model", - "happyhorse-1.1-r2v", + "wan3.0-video", "--prompt", "图1在画面中心轻微晃动", "--image", @@ -204,5 +285,127 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( expect(data.status).toBe("SUCCEEDED"); expect(data.video_url?.startsWith("https://")).toBe(true); }, 3_600_000); + + test("【wan3.0-video】全参数参考生视频(图+视频+双音色+全部生成参数)", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + + // 1. 参考图 + const gen = await runCommandE2e(VIDEO_ROUTES, [ + "image", + "generate", + "--model", + "qwen-image-3.0", + "--prompt", + "一片绿色的树叶,白底", + "--out-dir", + outDir, + "--out-prefix", + "e2e-ref-img", + "--output", + "json", + ]); + expect(gen.exitCode, gen.stderr).toBe(0); + const imagePath = parseStdoutJson<{ saved?: string[] }>(gen.stdout).saved?.[0]; + expect(imagePath).toBeTruthy(); + + // 2. 参考视频(t2v 生成一段素材) + const vid = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "generate", + ...cliTimeoutPrefix(), + "--model", + "wan3.0-video", + "--prompt", + "海面波光粼粼,远景静态镜头", + "--download", + join(outDir, "e2e-ref-video.mp4"), + "--output", + "json", + ]); + expect(vid.exitCode, vid.stderr).toBe(0); + const refVideoPath = parseStdoutJson<{ saved?: string }>(vid.stdout).saved; + expect(refVideoPath).toBeTruthy(); + + // 3. 两个参考音色(文本足够长确保 ≥1s,合计 ≤15s) + const vaOut = join(outDir, "e2e-va.mp3"); + const va = await runCommandE2e(VIDEO_ROUTES, [ + "speech", + "synthesize", + "--model", + "cosyvoice-v3-flash", + "--voice", + "longwan_v3", + "--text", + "你好,很高兴见到你,今天我们一起聊聊吧", + "--out", + vaOut, + "--output", + "json", + ]); + expect(va.exitCode, va.stderr).toBe(0); + expect(parseStdoutJson<{ saved?: string }>(va.stdout).saved).toBe(vaOut); + + const vbOut = join(outDir, "e2e-vb.mp3"); + const vb = await runCommandE2e(VIDEO_ROUTES, [ + "speech", + "synthesize", + "--model", + "cosyvoice-v3-flash", + "--voice", + "longxiaochun_v3", + "--text", + "你好呀,今天天气真不错,我们出去走走吧", + "--out", + vbOut, + "--output", + "json", + ]); + expect(vb.exitCode, vb.stderr).toBe(0); + expect(parseStdoutJson<{ saved?: string }>(vb.stdout).saved).toBe(vbOut); + + // 4. 全参数 video ref:图1/视频1/音频1/音频2 + 全部生成参数 + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "ref", + ...cliTimeoutPrefix(), + "--model", + "wan3.0-video", + "--prompt", + "图1中的人物看向视频1中的角色,并用音频1的音色说:你好,视频1中的角色用音频2的音色回应:你好呀", + "--image", + imagePath!, + "--ref-video", + refVideoPath!, + "--image-voice", + vaOut, + "--video-voice", + vbOut, + "--resolution", + "720P", + "--ratio", + "16:9", + "--duration", + "5", + "--prompt-extend", + "true", + "--watermark", + "false", + "--seed", + "42", + "--poll-interval", + "10", + "--download", + join(outDir, "e2e-video-ref-full.mp4"), + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ status?: string; video_url?: string; saved?: string }>( + stdout, + ); + expect(data.status).toBe("SUCCEEDED"); + expect(data.video_url?.startsWith("https://")).toBe(true); + expect(data.saved).toBeTruthy(); + }, 3_600_000); }, ); diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index fd3186d..c6b0531 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -209,7 +209,14 @@ export interface DashScopeVideoRequest { first_frame_url?: string; last_frame_url?: string; media?: Array<{ - type: "image" | "video" | "first_frame" | "last_frame" | "driving_audio" | "first_clip"; + type: + | "image" + | "video" + | "first_frame" + | "last_frame" + | "driving_audio" + | "first_clip" + | "file"; url: string; }>; }; @@ -228,7 +235,7 @@ export interface DashScopeVideoRefRequest { input: { prompt: string; media: Array<{ - type: "reference_image" | "reference_video"; + type: "reference_image" | "reference_video" | "reference_audio"; url: string; reference_voice?: string; }>; diff --git a/packages/runtime/src/pipeline/steps/bl-api.ts b/packages/runtime/src/pipeline/steps/bl-api.ts index 8206068..3bbaa05 100644 --- a/packages/runtime/src/pipeline/steps/bl-api.ts +++ b/packages/runtime/src/pipeline/steps/bl-api.ts @@ -433,7 +433,7 @@ export async function videoGenerate( }); } - const model = input.model || (input.image ? "happyhorse-1.1-i2v" : "happyhorse-1.1-t2v"); + const model = input.model || "wan3.0-video"; let resolvedImageUrl: string | undefined; if (input.image) { diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index 24f99cb..4b7b780 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -33,9 +33,9 @@ description: >- | ---------------------------------------- | ----------------------------------------- | --------------------------------------------------- | | Text-to-image | `bl image generate` | `qwen-image-3.0` | | Image edit / multi-image merge | `bl image edit` (repeat `--image`) | `qwen-image-3.0` | -| Text-to-video / image-to-video | `bl video generate` | `happyhorse-1.1-t2v` / `-i2v` (with `--image`) | +| Text-to-video / image-to-video | `bl video generate` | `wan3.0-video` | | Video edit / style transfer | `bl video edit` | `happyhorse-1.0-video-edit` | -| Reference-to-video + voice | `bl video ref` | `happyhorse-1.1-r2v` | +| Reference-to-video + voice | `bl video ref` | `wan3.0-video` | | Speech synthesis (TTS / voiceover) | `bl speech synthesize` | `cosyvoice-v3-flash` | | Speech recognition (ASR / transcription) | `bl speech recognize` | `fun-asr` | | Image describe | `bl vision describe` | `qwen3-vl-plus`;宿主能做且未点名 → host-first | diff --git a/skills/bailian-gen/reference/index.md b/skills/bailian-gen/reference/index.md index 85cab64..a85ecb3 100644 --- a/skills/bailian-gen/reference/index.md +++ b/skills/bailian-gen/reference/index.md @@ -9,19 +9,19 @@ Use this index for the skill-scoped quick index and global flags. ## Quick index -| Command | Authentication | Description | Detail | -| ---------------------- | -------------- | ----------------------------------------------------------------------------------------------------- | ---------------------- | -| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) | -| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | -| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | -| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) | -| `bl speech synthesize` | API Key | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) | -| `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) | -| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | -| `bl video generate` | API Key | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) | -| `bl video ref` | API Key | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) | -| `bl video task get` | API Key | Query async task status | [video.md](video.md) | -| `bl vision describe` | API Key | Describe an image or video using Qwen-VL | [vision.md](vision.md) | +| Command | Authentication | Description | Detail | +| ---------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) | +| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | +| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | +| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) | +| `bl speech synthesize` | API Key | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) | +| `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) | +| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | +| `bl video generate` | API Key | Generate a video from text or image (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v) | [video.md](video.md) | +| `bl video ref` | API Key | Reference-to-video generation (wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) | +| `bl video task get` | API Key | Query async task status | [video.md](video.md) | +| `bl vision describe` | API Key | Describe an image or video using Qwen-VL | [vision.md](vision.md) | ## By group diff --git a/skills/bailian-gen/reference/video.md b/skills/bailian-gen/reference/video.md index 5278873..8e6a21d 100644 --- a/skills/bailian-gen/reference/video.md +++ b/skills/bailian-gen/reference/video.md @@ -7,13 +7,13 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Authentication | Description | -| ------------------- | -------------- | ----------------------------------------------------------------------------------------------------- | -| `bl video download` | API Key | Download a completed video by task ID | -| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | -| `bl video generate` | API Key | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | -| `bl video ref` | API Key | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | -| `bl video task get` | API Key | Query async task status | +| Command | Authentication | Description | +| ------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- | +| `bl video download` | API Key | Download a completed video by task ID | +| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | +| `bl video generate` | API Key | Generate a video from text or image (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v) | +| `bl video ref` | API Key | Reference-to-video generation (wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | +| `bl video task get` | API Key | Query async task status | ## Command details @@ -97,34 +97,35 @@ bl video edit --video https://example.com/input.mp4 --prompt "Put clothes on the ### `bl video generate` -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------------ | -| **Name** | `video generate` | -| **Description** | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | -| **Authentication** | API Key | -| **Usage** | `bl video generate --prompt [--image ] [flags]` | +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------ | +| **Name** | `video generate` | +| **Description** | Generate a video from text or image (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v) | +| **Authentication** | API Key | +| **Usage** | `bl video generate --prompt [--image ] [flags]` | #### Flags -| Flag | Type | Required | Description | -| --------------------------- | ------- | -------- | --------------------------------------------------------------------------------------- | -| `--model ` | string | no | Model ID (default: happyhorse-1.1-t2v, or happyhorse-1.1-i2v with --image) | -| `--prompt ` | string | yes | Video description | -| `--image ` | string | no | Input image URL for image-to-video generation | -| `--last-frame ` | string | no | Last frame image URL (with --image, enables kf2v first+last frame mode) | -| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | -| `--resolution ` | string | no | Resolution: 720P or 1080P (default: 1080P) | -| `--ratio ` | string | no | Aspect ratio (e.g. 16:9, 9:16, 1:1) | -| `--duration ` | number | no | Video duration in seconds (default: 5) | -| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | -| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | -| `--seed ` | number | no | Random seed for reproducible generation | -| `--download ` | string | no | Save video to file on completion | -| `--async` | switch | no | Return async task id without waiting | -| `--concurrent ` | number | no | Run N parallel requests (default: 1) | -| `--poll-interval ` | number | no | Polling interval when waiting (default: 5) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `--model ` | string | no | Model ID (default: wan3.0-video) | +| `--prompt ` | string | yes | Video description | +| `--image ` | string | no | Input image URL for image-to-video generation | +| `--last-frame ` | string | no | Last frame image URL (with --image, enables kf2v first+last frame mode) | +| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | +| `--resolution ` | string | no | Resolution: 720P or 1080P (default: 1080P) | +| `--ratio ` | string | no | Aspect ratio (e.g. 16:9, 9:16, 1:1) | +| `--duration ` | number | no | Video duration in seconds (default: 5) | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--seed ` | number | no | Random seed for reproducible generation | +| `--download ` | string | no | Save video to file on completion | +| `--file ` | string | no | Reference file URL or local path for file-to-video (wan3.0-video only; mutually exclusive with --image/--last-frame) | +| `--async` | switch | no | Return async task id without waiting | +| `--concurrent ` | number | no | Run N parallel requests (default: 1) | +| `--poll-interval ` | number | no | Polling interval when waiting (default: 5) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples @@ -150,35 +151,35 @@ bl video generate --prompt "A cat playing with a ball" --watermark false ### `bl video ref` -| Field | Value | -| ------------------ | ----------------------------------------------------------------------------------------------------- | -| **Name** | `video ref` | -| **Description** | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | -| **Authentication** | API Key | -| **Usage** | `bl video ref --prompt --image ... [--ref-video ...] [flags]` | +| Field | Value | +| ------------------ | -------------------------------------------------------------------------------------------------------------------- | +| **Name** | `video ref` | +| **Description** | Reference-to-video generation (wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | +| **Authentication** | API Key | +| **Usage** | `bl video ref --prompt --image ... [--ref-video ...] [flags]` | #### Flags -| Flag | Type | Required | Description | -| --------------------------- | ------- | -------- | --------------------------------------------------------------------------------------- | -| `--model ` | string | no | Model ID (default: happyhorse-1.1-r2v) | -| `--prompt ` | string | yes | Video description with reference markers (image1, video1, etc.) | -| `--image ` | array | no | Reference image URL or local file (repeatable for multiple subjects) | -| `--ref-video ` | array | no | Reference video URL or local file (repeatable) | -| `--image-voice ` | array | no | Voice URL for corresponding image (pairs by position) | -| `--video-voice ` | array | no | Voice URL for corresponding ref-video (pairs by position) | -| `--resolution ` | string | no | Resolution: 720P or 1080P (default: 1080P) | -| `--ratio ` | string | no | Aspect ratio (16:9, 9:16, 1:1) | -| `--duration ` | number | no | Video duration in seconds (default: 5) | -| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | -| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | -| `--seed ` | number | no | Random seed for reproducible generation | -| `--download ` | string | no | Save video to file on completion | -| `--async` | switch | no | Return async task id without waiting | -| `--concurrent ` | number | no | Run N parallel requests (default: 1) | -| `--poll-interval ` | number | no | Polling interval when waiting (default: 15) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `--model ` | string | no | Model ID (default: wan3.0-video) | +| `--prompt ` | string | yes | Video description with reference markers (image1, video1, etc.) | +| `--image ` | array | no | Reference image URL or local file (repeatable for multiple subjects) | +| `--ref-video ` | array | no | Reference video URL or local file (repeatable) | +| `--image-voice ` | array | no | Voice URL for corresponding image (pairs by position). On wan3.0-video emitted as reference_audio (refer to Audio 1, Audio 2 in prompt) | +| `--video-voice ` | array | no | Voice URL for corresponding ref-video (pairs by position). On wan3.0-video emitted as reference_audio (refer to Audio 1, Audio 2 in prompt) | +| `--resolution ` | string | no | Resolution: 720P or 1080P (default: 1080P) | +| `--ratio ` | string | no | Aspect ratio (16:9, 9:16, 1:1) | +| `--duration ` | number | no | Video duration in seconds (default: 5) | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--seed ` | number | no | Random seed for reproducible generation | +| `--download ` | string | no | Save video to file on completion | +| `--async` | switch | no | Return async task id without waiting | +| `--concurrent ` | number | no | Run N parallel requests (default: 1) | +| `--poll-interval ` | number | no | Polling interval when waiting (default: 15) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples From 18fd2ed8e589fea56bc2c3e6411d800331523d57 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Mon, 24 Aug 2026 18:38:02 +0800 Subject: [PATCH 03/13] fix: fixed format --- packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 8d37b9d..559bfbc 100644 --- a/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts @@ -400,9 +400,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "json", ]); expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ status?: string; video_url?: string; saved?: string }>( - stdout, - ); + const data = parseStdoutJson<{ status?: string; video_url?: string; saved?: string }>(stdout); expect(data.status).toBe("SUCCEEDED"); expect(data.video_url?.startsWith("https://")).toBe(true); expect(data.saved).toBeTruthy(); From 670c15532b8200d858ba72f96c00465d8a93153d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Wed, 26 Aug 2026 13:42:27 +0800 Subject: [PATCH 04/13] feat(auth): expand Token Plan capabilities and speech defaults --- docs/agents/auth-change.md | 8 +- docs/agents/cli-e2e-tests.md | 4 +- docs/agents/command-pack.md | 3 + docs/agents/config-add.md | 4 + docs/agents/config-profile-change.md | 9 + docs/agents/error-hint-change.md | 1 + .../cli/tests/e2e/command-packs.e2e.test.ts | 78 +++- .../tests/fixtures/command-pack/commands.mjs | 4 +- .../src/commands/auth/login-api-key.ts | 6 + packages/commands/src/commands/auth/login.ts | 9 +- packages/commands/src/commands/config/set.ts | 5 +- .../commands/src/commands/config/shared.ts | 55 ++- .../commands/src/commands/config/ui-html.ts | 4 +- packages/commands/src/commands/config/ui.ts | 20 +- .../commands/src/commands/speech/recognize.ts | 6 +- .../src/commands/speech/synthesize.ts | 17 +- packages/commands/tests/config-shared.test.ts | 35 ++ packages/commands/tests/config-ui.test.ts | 36 +- packages/commands/tests/e2e/auth.e2e.test.ts | 84 ++++- .../commands/tests/e2e/config.e2e.test.ts | 64 ++++ .../tests/e2e/speech-recognize.e2e.test.ts | 42 ++- .../tests/e2e/speech-synthesize.e2e.test.ts | 67 ++++ packages/core/src/auth/store.ts | 14 +- packages/core/src/client/client.ts | 6 +- packages/core/src/config/index.ts | 11 +- packages/core/src/config/loader.ts | 41 ++ packages/core/src/config/profile-presets.ts | 17 + packages/core/src/config/schema.ts | 36 ++ packages/core/tests/config-priority.test.ts | 39 ++ packages/core/tests/config-store.test.ts | 133 +++++++ packages/core/tests/image-input.test.ts | 6 +- .../runtime/src/command-packs/validate.ts | 2 +- packages/runtime/src/create-cli.ts | 6 +- packages/runtime/src/middleware.ts | 56 ++- .../tests/auth-profile-fallback.test.ts | 356 ++++++++++++++++++ packages/runtime/tests/error-handler.test.ts | 1 + skills/bailian-cli/reference/config.md | 12 +- skills/bailian-gen/reference/index.md | 2 +- skills/bailian-gen/reference/speech.md | 36 +- skills/bailian-protocol/SKILL.md | 2 +- skills/bailian-protocol/assets/setup.md | 31 +- 41 files changed, 1290 insertions(+), 78 deletions(-) create mode 100644 packages/commands/tests/config-shared.test.ts create mode 100644 packages/runtime/tests/auth-profile-fallback.test.ts diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index b11a1f9..c0dc43f 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -33,7 +33,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx `~/.bailian/config.json` 可同时保存 `api_key`、`access_token` 与 `access_key_*`。登录任一种方式不得删除另一种: -- `bl auth login --api-key ...` 只更新 `api_key` / `base_url` +- `bl auth login --api-key ...` 更新 `api_key`;显式 `base_url` 会一并写入,所选命名 Profile 若命中内置套餐预设(当前为 `token-plan`),则在尚未保存 `base_url` 时补写预设地址,并把该预设的默认模型物化写入。API Key 验证成功后,`api_key_capabilities` 保留已有项并追加当前 preset 中缺少的项,不自动删除任何已有能力;无 preset 的自定义 Profile 不做合并。登录仍不得删除其他鉴权域的凭证 - `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段 - `bl auth login --open-api ...` 更新 `access_key_id` / `access_key_secret`,同时会调用 OpenAPI 生成 CLI `access_token` 并一并写入;即一次 `--open-api` 登录同时产生 `openapi` 与 `console` 域凭证 - `bl auth logout --console` 只清 `access_token` @@ -44,7 +44,11 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - `resolveApiKey()` — `auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key` - `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin(仅保留协议、host 和显式端口,去除 path、query、fragment) -- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级;内置套餐 Profile(当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值 +- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级。对 `auth: "apiKey"` 命令,runtime 会先按叶子命令路径检查所选 Profile 的 `api_key_capabilities`: + - `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式连接覆盖存在时,完全跳过自动降级,继续走统一的 flag > env > selected config file > 默认值 + - 配置文件显式声明的 `api_key_capabilities` 优先于内置预设;命中能力时保留所选 Profile,未命中时仅把 file-backed `api_key` / `base_url` 来源切到顶层 `default`,其他 Settings 仍来自所选 Profile + - 字段缺失且 Profile 命中内置套餐预设(当前为 `token-plan`)时,runtime 使用当前预设白名单兼容旧配置,但不自动写回磁盘;字段缺失且没有内置预设时不启用降级 + - fallback 反馈写 stderr:text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象;若后续鉴权失败,warning 与多行 `error` 对象以空行分隔,stdout 仍只保留命令结果 - 显式 `auth login --config ` 在凭证验证并落盘成功后自动激活目标 Profile;未传 `--config` 时继续写当前激活项,失败和 dry-run 不切换 - `resolveConsole()` — `auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认 diff --git a/docs/agents/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md index cfae077..5449e1a 100644 --- a/docs/agents/cli-e2e-tests.md +++ b/docs/agents/cli-e2e-tests.md @@ -94,8 +94,8 @@ describe.skipIf()("e2e: (DashScope …)", () => { ## 安全与例外 -- **禁止破坏真实用户配置**:`auth logout` 默认只用 `--dry-run`;需要验证实际落盘时,必须通过 - `BAILIAN_CONFIG_DIR` 指向隔离 fixture;`config set` 只用 `--dry-run` +- **禁止破坏真实用户配置**:`auth logout` 和 `config set` 默认只用 `--dry-run`;只有验证持久化契约时,才允许通过 + `BAILIAN_CONFIG_DIR` 指向每个用例独占的临时目录实际落盘,并必须在 `finally` 中清理;禁止写入或复用真实 `~/.bailian` - **不加 dry-run**:`dryRun` 在 `resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit`、`speech recognize` 带 `--url`) - **`--list-voices` 等旁路**:先于 `--text` 校验的 flag,缺参用例勿带该 flag - 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言 diff --git a/docs/agents/command-pack.md b/docs/agents/command-pack.md index a9f980a..57cb2b2 100644 --- a/docs/agents/command-pack.md +++ b/docs/agents/command-pack.md @@ -32,6 +32,8 @@ - [ ] Command Pack 不能覆盖内置命令、其他 pack 命令或重声明保留 flag。 - [ ] 普通网络请求走 `ctx.client`;基础 Context 提供 `identity/settings/flags/client/output/errors`,不提供原始凭据。 - [ ] `ctx.credentials.apiKey()` 仅限 policy 显式声明 `credentialAccess: ["apiKey"]`,且命令自身为 `auth: "apiKey"`。 +- [ ] API Key capability ID 由 host 按实际叶子命令路径生成(例如 `agent credential` → `agent.credential`);Command Pack 不声明额外元数据,Profile allowlist 对 pack 命令同样 fail closed。 +- [ ] `ctx.credentials.apiKey()` 必须返回 `authStage` 已注入 `ctx.client` 的实际凭证,不能重新读取原 Profile 绕过 capability fallback。 - [ ] 不向 Command Pack 暴露原始 Console Token、OpenAPI AK/SK、`authStore` 或 `configStore`。 - [ ] 不向 Command Pack 暴露宿主的 `commandPacks` manager,避免 pack 安装或删除其他 pack。 - [ ] 单包失败必须 fail-open:保留内置命令和其他合法 pack。 @@ -40,6 +42,7 @@ ## 测试与文档 - [ ] `packages/runtime/tests/command-packs.test.ts` 覆盖产品 policy、安装目录隔离、协议版本、前缀和导出契约。 +- [ ] Command Pack 测试覆盖 capability 格式/鉴权域、adapter 保留字段,以及 raw API Key 委托使用 fallback 后的实际 Base URL。 - [ ] `packages/cli/tests/e2e/command-packs.e2e.test.ts` 覆盖 help、link、执行、output/errors、凭据授权、list、remove。 - [ ] `packages/kscli/tests/e2e/command-packs.e2e.test.ts` 覆盖统一 host 和 runtime 默认空 policy 下不暴露管理命令。 - [ ] fixture 的包名必须在测试白名单内,且构建入口不依赖工作区运行时解析。 diff --git a/docs/agents/config-add.md b/docs/agents/config-add.md index ff1d20c..c8d6887 100644 --- a/docs/agents/config-add.md +++ b/docs/agents/config-add.md @@ -29,6 +29,7 @@ config 文件 ─┘ - `ConfigFile`(disk 形状,snake_case)加新字段(如果允许写文件) - `parseConfigFile()` 解析新字段 - 如果是 enum 字段,加校验 + - 如果是数组字段,明确“缺失 / 空数组 / 非法值”的不同语义;安全策略字段的非法值必须 fail closed ### B. 加载逻辑 @@ -51,6 +52,7 @@ config 文件 ─┘ - [ ] 配置展示 / 修改命令同步: - `packages/commands/src/commands/config/show.ts` 显示新字段 - `packages/commands/src/commands/config/set.ts` 的 `VALID_KEYS` / `KEY_ALIASES` / description 允许 set + - `packages/commands/src/commands/config/ui.ts` / `ui-html.ts` 能按原类型往返数组字段,不能把 `[]` 保存成字段缺失 ### E. 文档 @@ -61,6 +63,7 @@ config 文件 ─┘ - [ ] 单测覆盖优先级:flag > env > file - [ ] 校验失败抛错(非法值) - [ ] 默认值正确 +- [ ] 数组配置覆盖 CLI 逗号/JSON 输入、Config UI 往返、去重和显式空数组 ## 完成后自查 @@ -82,3 +85,4 @@ cat ~/.bailian/config.json - ✗ 全局 switch 没标 `type: "switch"`,被当成需要值的 `--xxx ` - ✗ 加了 env var 但 README 表格没更新,用户不知道有这条 - ✗ `config show` 不显示新字段,用户改了无法回查 +- ✗ UI 用 `String([])` 把显式空数组渲染为空串,保存后意外关闭安全策略 diff --git a/docs/agents/config-profile-change.md b/docs/agents/config-profile-change.md index b9b00db..c9ea5ce 100644 --- a/docs/agents/config-profile-change.md +++ b/docs/agents/config-profile-change.md @@ -20,6 +20,10 @@ - 普通命令的显式 `--config` 只覆盖本次选择,不修改持久化激活状态;例外是 `auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。 - 激活状态只选择配置 block,不改变字段优先级;字段仍为 flag > env > selected config > 默认值。 +- API Key capability fallback 是窄例外:命名 Profile 显式配置 `api_key_capabilities` 后,不在白名单中的 `auth: "apiKey"` 叶子命令只把 file 层 `api_key` / `base_url` 切到顶层 `default`;所选 Profile 的其他 settings 和 `active_config` 均不变。如果 `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一提供了更高优先级的模型连接参数,则整个 capability fallback 跳过,file 层也不切换;未显式提供的另一部分继续按 flag > env > 所选 Profile 解析。 +- 自定义 Profile 是否启用 capability fallback 只看 `api_key_capabilities`,与名称无关:字段缺失表示关闭策略,`[]` 表示全部 API Key 命令 fallback。内置 `token-plan` 是当前唯一的名称 preset 兼容例外:旧配置缺少该字段时,runtime 使用当前内置白名单,但不自动写回磁盘。 +- 对命中内置 preset 的 Profile,API Key 登录验证成功后会把当前 preset 中缺少的 capability 追加落盘,同时保留已有项且不做删除;Console/OpenAPI 登录、自定义 Profile、dry-run 和失败登录均不修改该白名单。 +- Capability ID 直接使用产品实际叶子命令路径并以 `.` 连接(例如 `video task get` → `video.task.get`);不新增命令元数据。新增或改名后的 API Key 路由未进入白名单时自然 fail closed。 - Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。 - Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。 @@ -55,6 +59,8 @@ - 旧配置无 `active_config` -> `default`。 - 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。 +- 任意名称 Profile 的叶子路由 capability 命中时使用自身 API Key;未命中或空白名单时使用 `default` API Key;`--api-key` / `--base-url` 和 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一覆盖时跳过 fallback。 +- 旧内置 Plan Profile 缺 capability 字段时使用 preset;显式配置(含 `[]`)覆盖 preset,且不自动迁移写盘。 - 显式命名 `--config` 和 `--config default` 均覆盖激活项且不修改磁盘状态。 - 激活不存在的 Profile 失败且不写盘。 - 悬空 `active_config` 明确失败。 @@ -63,6 +69,9 @@ - 显式 `auth login --config ` 成功后激活该 Profile,失败或 dry-run 不创建、不切换; `--config default` 成功后切回 `default`。 - Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。 +- Console/OpenAPI/none 命令不参与 API Key capability fallback;fallback 后的 Client 特殊端点行为必须跟随最终解析的 `base_url`,不能根据原 `settings.configName` 推断端点类型。 +- Fallback 反馈只描述 CLI 能权威确认的本地行为:当前 Profile 不支持空格分隔的用户可见叶子命令,本次将从 `default` 读取 API Key 配置;不得声称整个 Profile 已切换,也不得展示 capability ID 或 `` 等内部值。存在 `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式覆盖时必须跳过 fallback 且不输出反馈。 +- Fallback 反馈写 stderr,`--quiet` 抑制;text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象。dry-run 和后续鉴权失败仍保留反馈;JSON 模式下多个 diagnostics 以空行分隔,任何模式都不得输出凭证值。 - `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。 - `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。 - Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。 diff --git a/docs/agents/error-hint-change.md b/docs/agents/error-hint-change.md index b2c9592..c47d073 100644 --- a/docs/agents/error-hint-change.md +++ b/docs/agents/error-hint-change.md @@ -46,6 +46,7 @@ runtime/error-handler.ts: - 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 enhanceHint(只 AUTH 还有增强) - TypeError("fetch failed") → 读 err.cause.code 翻成 NETWORK - Node fs errno → 翻成 GENERAL + errno hint + - `--output json` → stderr 输出两空格缩进的多行 error JSON;前面已有结构化 warning 时,两个 diagnostics 以空行分隔 - 其它 Error → 默认走 cause 链 ↓ process.exit(err.exitCode) diff --git a/packages/cli/tests/e2e/command-packs.e2e.test.ts b/packages/cli/tests/e2e/command-packs.e2e.test.ts index 0ee79be..791e1bb 100644 --- a/packages/cli/tests/e2e/command-packs.e2e.test.ts +++ b/packages/cli/tests/e2e/command-packs.e2e.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,7 +9,19 @@ const fixtureRoot = join(fileURLToPath(import.meta.url), "..", "..", "fixtures", let configDir: string; function env(): NodeJS.ProcessEnv { - return { BAILIAN_CONFIG_DIR: configDir, DO_NOT_TRACK: "1" }; + return { + BAILIAN_CONFIG_DIR: configDir, + DO_NOT_TRACK: "1", + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }; +} + +function parseStderrJsonDiagnostics(stderr: string): unknown[] { + return stderr + .trim() + .split(/\n\s*\n/) + .map((diagnostic) => JSON.parse(diagnostic) as unknown); } describe("e2e: Command Pack", () => { @@ -71,6 +83,68 @@ describe("e2e: Command Pack", () => { expect(executed.exitCode, executed.stderr).toBe(0); expect(executed.stdout).toContain("command-pack:hello"); + await writeFile( + join(configDir, "config.json"), + JSON.stringify( + { + base_url: "https://default.example.com", + active_config: "company-plan", + "company-plan": { + api_key: "sk-plan", + base_url: "https://plan.example.com", + api_key_capabilities: [], + }, + }, + null, + 2, + ) + "\n", + ); + const fallbackFailure = await runCli(["agent", "credential", "--output", "json"], env()); + expect(fallbackFailure.exitCode).toBe(3); + expect(fallbackFailure.stderr).toMatch(/^\{\n {2}"warning": \{/); + expect(fallbackFailure.stderr).toContain("\n\n{\n"); + expect(parseStderrJsonDiagnostics(fallbackFailure.stderr)).toEqual([ + { + warning: { + code: "PROFILE_API_KEY_FALLBACK", + message: + 'Profile "company-plan" does not support command "agent credential"; API Key settings will be read from Profile "default" for this run.', + profile: "company-plan", + command_path: ["agent", "credential"], + fallback_profile: "default", + credential_fields: ["api_key", "base_url"], + }, + }, + { + error: expect.objectContaining({ + code: 3, + message: "No API key found.", + }), + }, + ]); + + await writeFile( + join(configDir, "config.json"), + JSON.stringify( + { + api_key: "sk-default", + base_url: "https://default.example.com", + active_config: "company-plan", + "company-plan": { + api_key: "sk-plan", + base_url: "https://plan.example.com", + api_key_capabilities: [], + }, + }, + null, + 2, + ) + "\n", + ); + const fallbackCredential = await runCli(["agent", "credential"], env()); + expect(fallbackCredential.exitCode, fallbackCredential.stderr).toBe(0); + expect(fallbackCredential.stderr).toContain('command "agent credential"'); + expect(fallbackCredential.stdout).toContain("credential-base-url:https://default.example.com"); + const credential = await runCli(["agent", "credential", "--api-key", "fixture-key"], env()); expect(credential.exitCode, credential.stderr).toBe(0); expect(credential.stdout).toContain("credential-source:flag"); diff --git a/packages/cli/tests/fixtures/command-pack/commands.mjs b/packages/cli/tests/fixtures/command-pack/commands.mjs index 698c1b3..c91d5ee 100644 --- a/packages/cli/tests/fixtures/command-pack/commands.mjs +++ b/packages/cli/tests/fixtures/command-pack/commands.mjs @@ -24,7 +24,9 @@ const credential = { auth: "apiKey", async run(ctx) { const apiKey = ctx.credentials.apiKey(); - process.stdout.write(`credential-source:${apiKey.source}\n`); + process.stdout.write( + `credential-source:${apiKey.source} credential-base-url:${apiKey.baseUrl}\n`, + ); }, }; diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts index 91204ed..bb3af28 100644 --- a/packages/commands/src/commands/auth/login-api-key.ts +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -24,6 +24,9 @@ interface ApiKeyLoginProfile { defaultImageToVideoModel?: string; defaultReferenceToVideoModel?: string; defaultImageModel?: string; + defaultSpeechModel?: string; + defaultSpeechRecognitionModel?: string; + apiKeyCapabilities?: readonly string[]; persistPatch?: AuthPersistPatch; } @@ -95,5 +98,8 @@ export async function validateAndPersistApiKey( default_image_to_video_model: profile.defaultImageToVideoModel, default_reference_to_video_model: profile.defaultReferenceToVideoModel, default_image_model: profile.defaultImageModel, + default_speech_model: profile.defaultSpeechModel, + default_speech_recognition_model: profile.defaultSpeechRecognitionModel, + api_key_capabilities: profile.apiKeyCapabilities ? [...profile.apiKeyCapabilities] : undefined, }); } diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index 6c31780..9000a83 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -168,9 +168,13 @@ export default defineCommand({ return; } const profilePreset = getModelProfilePreset(settings.configName); - const storedBaseUrl = store.stored().baseUrl; + const stored = store.stored(); + const storedBaseUrl = stored.baseUrl; const resolvedBaseUrl = baseUrl || store.resolveBaseUrl(profilePreset?.baseUrl); const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined); + const apiKeyCapabilities = profilePreset + ? [...new Set([...(stored.apiKeyCapabilities ?? []), ...profilePreset.apiKeyCapabilities])] + : stored.apiKeyCapabilities; await validateAndPersistApiKey(deps, key, { baseUrl: resolvedBaseUrl, persistBaseUrl, @@ -179,6 +183,9 @@ export default defineCommand({ defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel, defaultReferenceToVideoModel: profilePreset?.defaultReferenceToVideoModel, defaultImageModel: profilePreset?.defaultImageModel, + defaultSpeechModel: profilePreset?.defaultSpeechModel, + defaultSpeechRecognitionModel: profilePreset?.defaultSpeechRecognitionModel, + apiKeyCapabilities, }); }, }); diff --git a/packages/commands/src/commands/config/set.ts b/packages/commands/src/commands/config/set.ts index 5aa9e1d..7a4a0dc 100644 --- a/packages/commands/src/commands/config/set.ts +++ b/packages/commands/src/commands/config/set.ts @@ -12,9 +12,9 @@ export default defineCommand({ valueHint: "", description: { "en-US": - "Config key (language, base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)", + "Config key (language, base_url, output, output_dir, timeout, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)", "zh-CN": - "配置项名称(language、base_url、output、output_dir、timeout、api_key、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id)", + "配置项名称(language、base_url、output、output_dir、timeout、api_key、api_key_capabilities、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id)", }, required: true, }, @@ -30,6 +30,7 @@ export default defineCommand({ "--key output --value json", "--key timeout --value 600", "--key base_url --value https://dashscope.aliyuncs.com", + "--config company-plan --key api-key-capabilities --value text.chat,image.generate", ], async run(ctx) { const { settings, flags } = ctx; diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts index 8cd5446..a7da1bc 100644 --- a/packages/commands/src/commands/config/shared.ts +++ b/packages/commands/src/commands/config/shared.ts @@ -1,6 +1,7 @@ import { BailianError, ExitCode, + isApiKeyCapability, normalizeModelBaseUrl, SUPPORTED_LANGUAGES, } from "bailian-cli-core"; @@ -23,7 +24,9 @@ export const VALID_KEYS = [ "default_reference_to_video_model", "default_image_model", "default_speech_model", + "default_speech_recognition_model", "default_omni_model", + "api_key_capabilities", "workspace_id", ] as const; @@ -63,7 +66,8 @@ export const UI_BOOLEAN_KEYS = new Set(["telemetry"]); // Default model each `default_*_model` key falls back to when left unset. These // mirror the inline `|| ""` fallbacks in the generation commands -// (text/chat, image/generate, video/generate, speech/synthesize, omni/chat) and +// (text/chat, image/generate, video/generate, speech/synthesize, +// speech/recognize, omni/chat) and // are surfaced as input placeholders so users can see the effective default // without persisting a value that would pin the model. export const UI_MODEL_DEFAULTS: Record = { @@ -71,6 +75,7 @@ export const UI_MODEL_DEFAULTS: Record = { default_image_model: "qwen-image-3.0", default_video_model: "happyhorse-1.1-t2v", default_speech_model: "cosyvoice-v3-flash", + default_speech_recognition_model: "fun-asr", default_omni_model: "qwen3.5-omni-plus", }; @@ -105,7 +110,11 @@ export const UI_MODEL_CATALOG: Record = { ], default_speech_model: [ { id: "cosyvoice-v3-flash", role: "speech/synthesize (TTS) default" }, - { id: "fun-asr", role: "speech/recognize (ASR)" }, + { id: "qwen-audio-3.0-tts-plus", role: "Token Plan speech/synthesize (TTS)" }, + ], + default_speech_recognition_model: [ + { id: "fun-asr", role: "speech/recognize (ASR) default" }, + { id: "qwen-audio-3.0-asr-flash", role: "Token Plan speech/recognize (ASR)" }, ], default_omni_model: [ { id: "qwen3.5-omni-plus", role: "omni/chat default" }, @@ -128,7 +137,9 @@ export const KEY_ALIASES: Record = { "default-reference-to-video-model": "default_reference_to_video_model", "default-image-model": "default_image_model", "default-speech-model": "default_speech_model", + "default-speech-recognition-model": "default_speech_recognition_model", "default-omni-model": "default_omni_model", + "api-key-capabilities": "api_key_capabilities", "workspace-id": "workspace_id", }; @@ -141,7 +152,7 @@ export function resolveKey(key: string): string { * Validate a single config entry and coerce its value to the stored type. * Throws BailianError(USAGE) for unknown keys or invalid values. */ -export function validateAndCoerce(key: string, value: string): string | number { +export function validateAndCoerce(key: string, value: string): string | number | string[] { const resolvedKey = resolveKey(key); if (!(VALID_KEYS as readonly string[]).includes(resolvedKey)) { @@ -178,6 +189,39 @@ export function validateAndCoerce(key: string, value: string): string | number { if (resolvedKey === "base_url") return normalizeModelBaseUrl(value); + if (resolvedKey === "api_key_capabilities") { + let rawCapabilities: unknown; + if (value.trim().startsWith("[")) { + try { + rawCapabilities = JSON.parse(value) as unknown; + } catch { + throw new BailianError( + `Invalid API Key capability list "${value}". Use comma-separated values or a JSON array.`, + ExitCode.USAGE, + ); + } + } else { + rawCapabilities = value.split(",").map((capability) => capability.trim()); + } + + if (!Array.isArray(rawCapabilities)) { + throw new BailianError("Invalid API Key capability list. Expected an array.", ExitCode.USAGE); + } + const capabilities: string[] = []; + for (const rawCapability of rawCapabilities) { + if (typeof rawCapability !== "string" || !isApiKeyCapability(rawCapability.trim())) { + throw new BailianError( + `Invalid API Key capability "${String(rawCapability)}".`, + ExitCode.USAGE, + "Use lowercase alphanumeric segments separated by '.' or '-'.", + ); + } + const capability = rawCapability.trim(); + if (!capabilities.includes(capability)) capabilities.push(capability); + } + return capabilities; + } + return value; } @@ -187,7 +231,10 @@ export function validateAndCoerce(key: string, value: string): string | number { * extras (console_*, telemetry) are validated here. Booleans are returned as * real booleans so they persist correctly in config.json. */ -export function validateAndCoerceUi(key: string, value: string): string | number | boolean { +export function validateAndCoerceUi( + key: string, + value: string, +): string | number | boolean | string[] { const resolvedKey = resolveKey(key); if ((VALID_KEYS as readonly string[]).includes(resolvedKey)) { diff --git a/packages/commands/src/commands/config/ui-html.ts b/packages/commands/src/commands/config/ui-html.ts index 0b7c27a..7d23af4 100644 --- a/packages/commands/src/commands/config/ui-html.ts +++ b/packages/commands/src/commands/config/ui-html.ts @@ -1215,7 +1215,9 @@ const PAGE_HTML = ` var row = el('div', 'row'); var label = el('label', '', key); label.htmlFor = 'f_' + key; var val = data[key]; - var strVal = (val === undefined || val === null) ? '' : String(val); + var strVal = Array.isArray(val) + ? (val.length ? val.join(', ') : '[]') + : ((val === undefined || val === null) ? '' : String(val)); if (SECRETS.indexOf(key) >= 0) { var input = el('input'); input.id = 'f_' + key; input.name = key; input.value = strVal; input.type = 'password'; diff --git a/packages/commands/src/commands/config/ui.ts b/packages/commands/src/commands/config/ui.ts index 8e86d92..082f05e 100644 --- a/packages/commands/src/commands/config/ui.ts +++ b/packages/commands/src/commands/config/ui.ts @@ -139,15 +139,17 @@ function tokenMatches(provided: string | null, expected: string): boolean { /** Build the request cleaned/validated config block from a posted `data` map. */ function buildProfilePatch( data: Record, -): Record { - const cleaned: Record = {}; - for (const [k, v] of Object.entries(data)) { +): Record { + const cleaned: Record = {}; + for (const [key, rawValue] of Object.entries(data)) { let value = ""; - if (typeof v === "string") value = v; - else if (typeof v === "number" || typeof v === "boolean") value = String(v); - // null/undefined/objects fall through as "" and clear the key + if (typeof rawValue === "string") value = rawValue; + else if (typeof rawValue === "number" || typeof rawValue === "boolean") { + value = String(rawValue); + } else if (Array.isArray(rawValue)) value = JSON.stringify(rawValue); + // null/undefined/non-array objects fall through as "" and clear the key if (value === "") continue; - cleaned[resolveKey(k)] = validateAndCoerceUi(k, value); + cleaned[resolveKey(key)] = validateAndCoerceUi(key, value); } return cleaned; } @@ -155,7 +157,7 @@ function buildProfilePatch( /** Preserve valid Config fields that the UI does not expose or manage. */ function mergeUnmanagedProfileFields( existing: Record, - managedPatch: Record, + managedPatch: Record, ): Record { const managedKeys = new Set(UI_VALID_KEYS); const merged: Record = {}; @@ -635,7 +637,7 @@ export function createConfigUiServer( return; } let normalized: string | undefined; - let cleaned: Record; + let cleaned: Record; try { normalized = normalizeConfigName(body.name); cleaned = buildProfilePatch(body.data as Record); diff --git a/packages/commands/src/commands/speech/recognize.ts b/packages/commands/src/commands/speech/recognize.ts index b67332b..ff1b4d2 100644 --- a/packages/commands/src/commands/speech/recognize.ts +++ b/packages/commands/src/commands/speech/recognize.ts @@ -42,9 +42,9 @@ const RECOGNIZE_FLAGS = { valueHint: "", description: { "en-US": - "Model ID (default: fun-asr). Async: fun-asr / *-filetrans / paraformer-*; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-*-asr-flash", + "Model ID (default: configured Profile ASR model, otherwise fun-asr). Async: fun-asr / *-filetrans / paraformer-*; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-*-asr-flash", "zh-CN": - "模型 ID(默认:fun-asr)。异步:fun-asr / *-filetrans / paraformer-*;同步:qwen3-asr-flash* / fun-asr-flash* / qwen-audio-*-asr-flash", + "模型 ID(默认:Profile 配置的 ASR 模型,否则为 fun-asr)。异步:fun-asr / *-filetrans / paraformer-*;同步:qwen3-asr-flash* / fun-asr-flash* / qwen-audio-*-asr-flash", }, }, language: { @@ -165,7 +165,7 @@ export default defineCommand({ ); } - const model = flags.model || "fun-asr"; + const model = flags.model || settings.defaultSpeechRecognitionModel || "fun-asr"; const route = resolveAsrApi(model); if (route.kind === "unsupported") { throw new BailianError( diff --git a/packages/commands/src/commands/speech/synthesize.ts b/packages/commands/src/commands/speech/synthesize.ts index 4924e07..b6a5d17 100644 --- a/packages/commands/src/commands/speech/synthesize.ts +++ b/packages/commands/src/commands/speech/synthesize.ts @@ -167,8 +167,9 @@ const SYNTHESIZE_FLAGS = { valueHint: "", description: { "en-US": - "Model ID (default: cosyvoice-v3-flash). System voices available for cosyvoice-v3-flash", - "zh-CN": "模型 ID(默认:cosyvoice-v3-flash)。cosyvoice-v3-flash 支持系统音色", + "Model ID (default: configured Profile TTS model, otherwise cosyvoice-v3-flash). System voices vary by model", + "zh-CN": + "模型 ID(默认:Profile 配置的 TTS 模型,否则为 cosyvoice-v3-flash)。系统音色因模型而异", }, }, voice: { @@ -193,8 +194,8 @@ const SYNTHESIZE_FLAGS = { type: "string", valueHint: "", description: { - "en-US": "Audio format: mp3, pcm, wav, opus (default: mp3)", - "zh-CN": "音频格式:mp3、pcm、wav、opus(默认:mp3)", + "en-US": "Audio format: mp3, pcm, wav, opus (default: mp3; streaming default: pcm)", + "zh-CN": "音频格式:mp3、pcm、wav、opus(默认:mp3;流式模式默认:pcm)", }, choices: ["mp3", "pcm", "wav", "opus"] as const, }, @@ -282,8 +283,8 @@ type SynthesizeFlags = ParsedFlags; export default defineCommand({ description: { - "en-US": "Synthesize speech from text (CosyVoice TTS)", - "zh-CN": "将文本合成为语音(CosyVoice TTS)", + "en-US": "Synthesize speech from text", + "zh-CN": "将文本合成为语音", }, auth: "apiKey", usageArgs: "--text [flags]", @@ -357,14 +358,14 @@ export default defineCommand({ const language = flags.language || undefined; const instruction = flags.instruction || undefined; - const audioFormat = flags.format || undefined; + const useStream = flags.stream === true; + const audioFormat = flags.format || (useStream ? "pcm" : "mp3"); const sampleRate = flags.sampleRate !== undefined ? Number(flags.sampleRate) : undefined; const volume = flags.volume !== undefined ? Number(flags.volume) : undefined; const rate = flags.rate !== undefined ? Number(flags.rate) : undefined; const pitch = flags.pitch !== undefined ? Number(flags.pitch) : undefined; const seed = flags.seed !== undefined ? Number(flags.seed) : undefined; const enableSsml = flags.enableSsml === true ? true : undefined; - const useStream = flags.stream === true; const format = detectOutputFormat(settings.output); diff --git a/packages/commands/tests/config-shared.test.ts b/packages/commands/tests/config-shared.test.ts new file mode 100644 index 0000000..2c30e41 --- /dev/null +++ b/packages/commands/tests/config-shared.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "vite-plus/test"; +import { resolveKey, validateAndCoerce } from "../src/commands/config/shared.ts"; + +test("api-key-capabilities alias accepts comma-separated leaf capabilities", () => { + expect(resolveKey("api-key-capabilities")).toBe("api_key_capabilities"); + expect( + validateAndCoerce( + "api-key-capabilities", + " text.chat, image.generate, text.chat, video.task.get ", + ), + ).toEqual(["text.chat", "image.generate", "video.task.get"]); +}); + +test("api_key_capabilities accepts JSON arrays and preserves an explicit empty allowlist", () => { + expect( + validateAndCoerce("api_key_capabilities", '["text.chat", "image.generate", "text.chat"]'), + ).toEqual(["text.chat", "image.generate"]); + expect(validateAndCoerce("api_key_capabilities", "[]")).toEqual([]); +}); + +test("api_key_capabilities rejects malformed identifiers and non-string JSON entries", () => { + expect(() => validateAndCoerce("api_key_capabilities", "Video Generate")).toThrow( + /Invalid API Key capability/, + ); + expect(() => validateAndCoerce("api_key_capabilities", '["text.chat", 42]')).toThrow( + /Invalid API Key capability/, + ); +}); + +test("default-speech-recognition-model alias accepts an ASR model ID", () => { + expect(resolveKey("default-speech-recognition-model")).toBe("default_speech_recognition_model"); + expect(validateAndCoerce("default-speech-recognition-model", "qwen-audio-3.0-asr-flash")).toBe( + "qwen-audio-3.0-asr-flash", + ); +}); diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts index 0e48992..dfccaa7 100644 --- a/packages/commands/tests/config-ui.test.ts +++ b/packages/commands/tests/config-ui.test.ts @@ -199,6 +199,7 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", // Console/telemetry fields are editable via the UI (full ConfigFile surface). expect(res.json.keys).toContain("console_site"); expect(res.json.keys).toContain("telemetry"); + expect(res.json.keys).toContain("default_speech_recognition_model"); expect(res.json.enums.console_site).toEqual(["domestic", "international"]); expect(res.json.booleanKeys).toContain("telemetry"); // Default field hints are surfaced as prefilled values in the UI. @@ -212,9 +213,11 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", expect(res.json.modelCatalog.default_video_model.map((m: { id: string }) => m.id)).toContain( "happyhorse-1.1-i2v", ); - expect(res.json.modelCatalog.default_speech_model.map((m: { id: string }) => m.id)).toContain( - "fun-asr", - ); + expect( + res.json.modelCatalog.default_speech_recognition_model.map( + (model: { id: string }) => model.id, + ), + ).toEqual(["fun-asr", "qwen-audio-3.0-asr-flash"]); }); }); @@ -319,6 +322,33 @@ test("POST /api/profile 可编辑 console/telemetry 字段并按类型持久化" }); }); +test("POST /api/profile 按浏览器字符串形态往返持久化 capability 白名单", async () => { + await withServer(async (port) => { + const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { + body: { + name: "company-plan", + data: { + api_key_capabilities: "text.chat, image.generate, text.chat", + }, + }, + }); + expect(save.status).toBe(200); + expect(readConfigFile("company-plan").api_key_capabilities).toEqual([ + "text.chat", + "image.generate", + ]); + + const closeAll = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { + body: { + name: "company-plan", + data: { api_key_capabilities: "[]" }, + }, + }); + expect(closeAll.status).toBe(200); + expect(readConfigFile("company-plan").api_key_capabilities).toEqual([]); + }); +}); + test("New profile 立即保存空 Profile,其他配置读取可以看到", async () => { await withServer(async (port) => { const create = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 3e60d3e..b97346e 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -274,13 +274,27 @@ describe("e2e: auth", () => { default_image_to_video_model: "happyhorse-1.1-i2v", default_reference_to_video_model: "happyhorse-1.1-r2v", default_image_model: "wan2.7-image", + default_speech_model: "qwen-audio-3.0-tts-plus", + default_speech_recognition_model: "qwen-audio-3.0-asr-flash", + api_key_capabilities: [ + "text.chat", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], }); } finally { await validationServer.close(); } }); - test("auth login --config token-plan 物化并重置内置预设", async () => { + test("auth login --config token-plan 追加新版 capability preset 且不删除已有能力", async () => { const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-token-plan-preset-login"); writeFileSync( @@ -293,6 +307,7 @@ describe("e2e: auth", () => { 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", + api_key_capabilities: ["text.chat", "custom.command"], }, }, null, @@ -337,6 +352,21 @@ describe("e2e: auth", () => { default_image_to_video_model: "happyhorse-1.1-i2v", default_reference_to_video_model: "happyhorse-1.1-r2v", default_image_model: "wan2.7-image", + default_speech_model: "qwen-audio-3.0-tts-plus", + default_speech_recognition_model: "qwen-audio-3.0-asr-flash", + api_key_capabilities: [ + "text.chat", + "custom.command", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], }); expect((config["token-plan"] as Record).base_url).not.toBe( validationServer.baseUrl, @@ -349,6 +379,58 @@ describe("e2e: auth", () => { } }); + test("auth login --config token-plan 为显式空白名单追加 capability preset", async () => { + const validationServer = await startValidationServer(); + const configDir = makeE2eOutputDir("auth-token-plan-empty-capabilities-login"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify( + { + "token-plan": { + api_key_capabilities: [], + }, + }, + null, + 2, + ) + "\n", + ); + + try { + const login = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: validationServer.baseUrl, + }, + ); + expect(login.exitCode, login.stderr).toBe(0); + expect(validationServer.requests).toHaveLength(1); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config["token-plan"]).toMatchObject({ + api_key_capabilities: [ + "text.chat", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], + }); + } finally { + await validationServer.close(); + } + }); + test("auth login 未传 --config 时写当前激活 Config", async () => { const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-active-profile-login"); diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index 877170f..cbf7402 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -278,6 +278,70 @@ describe("e2e: config", () => { } }); + test("config set 将 API Key 能力列表写入命名 Profile 并保留显式空列表", async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-config-api-key-capabilities-")); + try { + const configPath = join(configDir, "config.json"); + const env = { BAILIAN_CONFIG_DIR: configDir }; + const setResult = await runCommandE2e( + CONFIG_ROUTES, + [ + "config", + "set", + "--config", + "company-plan", + "--key", + "api-key-capabilities", + "--value", + "text.chat,image.generate,text.chat", + "--output", + "json", + ], + env, + ); + expect(setResult.exitCode, setResult.stderr).toBe(0); + const setData = parseStdoutJson<{ + api_key_capabilities?: string[]; + config?: string; + }>(setResult.stdout); + expect(setData.api_key_capabilities).toEqual(["text.chat", "image.generate"]); + expect(setData.config).toBe("company-plan"); + + const persisted = JSON.parse(readFileSync(configPath, "utf8")) as Record; + expect((persisted["company-plan"] as Record).api_key_capabilities).toEqual([ + "text.chat", + "image.generate", + ]); + + const clearResult = await runCommandE2e( + CONFIG_ROUTES, + [ + "config", + "set", + "--config", + "company-plan", + "--key", + "api-key-capabilities", + "--value", + "[]", + "--output", + "json", + ], + env, + ); + expect(clearResult.exitCode, clearResult.stderr).toBe(0); + expect( + parseStdoutJson<{ api_key_capabilities?: string[] }>(clearResult.stdout) + .api_key_capabilities, + ).toEqual([]); + + const cleared = JSON.parse(readFileSync(configPath, "utf8")) as Record; + expect((cleared["company-plan"] as Record).api_key_capabilities).toEqual([]); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); + test("config set --dry-run 不落盘(仅输出 would_set)", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", diff --git a/packages/commands/tests/e2e/speech-recognize.e2e.test.ts b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts index 2805f16..bd0f278 100644 --- a/packages/commands/tests/e2e/speech-recognize.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import http from "node:http"; import type { AddressInfo } from "node:net"; import { join } from "node:path"; @@ -59,6 +59,46 @@ describe("e2e: speech recognize", () => { expect(stderr).toMatch(/recognize|--url|model|audio/i); }); + test("Token Plan 未显式传 model 时默认使用 qwen-audio ASR", async () => { + const configDir = makeE2eOutputDir("speech-recognize-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_speech_recognition_model: "qwen-audio-3.0-asr-flash", + }, + }), + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + SPEECH_ROUTES, + [ + "speech", + "recognize", + "--config", + "token-plan", + "--url", + "https://example.com/audio.wav", + "--dry-run", + "--output", + "json", + "--quiet", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ request?: { model?: string }; mode?: string }>(stdout); + expect(data.request?.model).toBe("qwen-audio-3.0-asr-flash"); + expect(data.mode).toBe("sync"); + }); + test("speech recognize sync-flash dry-run 走 multimodal-generation", async () => { const body = await runRecognizeDryRun([ "--model", diff --git a/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts b/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts index 4881bca..1a59a35 100644 --- a/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; +import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { e2eLabelFromMetaUrl, @@ -24,6 +25,72 @@ describe("e2e: speech synthesize", () => { expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/synthesize|--text|--voice|model/i); }); + + test("Token Plan 未显式传 model/format 时使用 qwen-audio TTS 和 MP3", async () => { + const configDir = makeE2eOutputDir("speech-synthesize-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_speech_model: "qwen-audio-3.0-tts-plus", + }, + }), + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + SPEECH_ROUTES, + [ + "speech", + "synthesize", + "--config", + "token-plan", + "--voice", + "longanlingxin", + "--text", + "Token Plan 默认语音模型测试", + "--dry-run", + "--output", + "json", + "--quiet", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { model?: string; input?: { format?: string } }; + }>(stdout); + expect(data.request?.model).toBe("qwen-audio-3.0-tts-plus"); + expect(data.request?.input?.format).toBe("mp3"); + }); + + test("speech synthesize 流式模式未显式传 format 时保持 PCM", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "synthesize", + "--model", + "qwen-audio-3.0-tts-plus", + "--voice", + "longanlingxin", + "--text", + "流式格式测试", + "--stream", + "--dry-run", + "--output", + "json", + "--quiet", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ request?: { input?: { format?: string } } }>(stdout); + expect(data.request?.input?.format).toBe("pcm"); + }); }); describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index 914abce..9ac4d86 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -37,6 +37,9 @@ export type AuthPersistPatch = Pick< | "default_image_to_video_model" | "default_reference_to_video_model" | "default_image_model" + | "default_speech_model" + | "default_speech_recognition_model" + | "api_key_capabilities" >; /** @@ -46,8 +49,14 @@ export type AuthPersistPatch = Pick< export interface AuthStore { /** 各域"将会解析出"的凭证快照(auth status 用)。 */ describe(): AuthState; - /** 磁盘上当前是否存有各域凭证及 model baseUrl(区别于 describe:只看 file,不含 flag/env 源)。 */ - stored(): { apiKey: boolean; console: boolean; openapi: boolean; baseUrl?: string }; + /** 磁盘上当前是否存有各域凭证,以及 model baseUrl/capability 配置(只看 file,不含 flag/env 源)。 */ + stored(): { + apiKey: boolean; + console: boolean; + openapi: boolean; + baseUrl?: string; + apiKeyCapabilities?: string[]; + }; /** model 域 baseUrl 链(flag > env > config file > fallback)。 */ resolveBaseUrl(fallback?: string): string; /** 登录落盘:合并写入,undefined 键忽略;显式 --config 成功后同时激活目标 Profile。 */ @@ -74,6 +83,7 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore { console: !!file.access_token, openapi: !!(file.access_key_id || file.access_key_secret || file.security_token), baseUrl: file.base_url, + apiKeyCapabilities: file.api_key_capabilities, }; }, resolveBaseUrl: (fallback) => resolveModelBaseUrl(sources, fallback), diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 4dfafa3..785e2c1 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -100,8 +100,9 @@ export class Client { * Export the model-domain credential for delegation to an embedded SDK that * owns its own transport (e.g. @openagentpack/sdk). Deliberate escape hatch: * regular commands keep calling {@link request}/{@link requestJson} and never - * handle tokens — lint restricts callers to managed-agent/_engine. Undefined - * when no credential resolved (authStage tolerates that only under dry-run). + * handle tokens; only trusted internal adapters such as managed-agent/_engine + * and the Command Pack host use this export. Undefined when no credential + * resolved (authStage tolerates that only under dry-run). */ exportApiCredential(): ApiKeyCredential | undefined { return this.deps.apiCred; @@ -156,7 +157,6 @@ export class Client { } 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 { diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 029905b..422b7bb 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -2,10 +2,13 @@ export type { ConfigFile, Language, Region, Identity, Settings } from "./schema. export { BAILIAN_HOST, CONFIG_FILE_KEYS, + API_KEY_CAPABILITY_PATTERN, DEFAULT_LANGUAGE, DOCS_HOSTS, REGIONS, SUPPORTED_LANGUAGES, + isApiKeyCapability, + normalizeApiKeyCapabilities, parseConfigFile, } from "./schema.ts"; export { normalizeConfigName, readConfigFile, writeConfigFile } from "./loader.ts"; @@ -16,7 +19,13 @@ export { deleteConfigProfile, type ConfigProfiles, } from "./loader.ts"; -export { buildSources, buildSettings, type ResolutionSources } from "./loader.ts"; +export { + buildSources, + buildSettings, + selectApiKeyResolutionSources, + type ApiKeyResolutionSourceSelection, + type ResolutionSources, +} from "./loader.ts"; export { makeConfigStore, type ConfigStore } from "./store.ts"; export { ensureConfigDir, getConfigDir, getConfigPath, getCredentialsPath } from "./paths.ts"; export { getModelProfilePreset } from "./profile-presets.ts"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index ef14925..1c4d4bd 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -195,6 +195,46 @@ export interface ResolutionSources { configPath?: string; } +export interface ApiKeyResolutionSourceSelection { + sources: ResolutionSources; + /** Named Profile whose file-backed API credential was replaced by default. */ + fallbackFrom?: string; +} + +/** + * Select the file-backed API-key source for a command capability. An explicit + * flag or environment API connection override bypasses Profile capability + * fallback entirely. + */ +export function selectApiKeyResolutionSources( + sources: ResolutionSources, + capability: string, + presetCapabilities?: readonly string[], +): ApiKeyResolutionSourceSelection { + if ( + sources.flags.apiKey || + sources.flags.baseUrl || + sources.env.DASHSCOPE_API_KEY?.trim() || + sources.env.DASHSCOPE_BASE_URL + ) { + return { sources }; + } + if (!sources.configName) return { sources }; + + const allowedCapabilities = sources.file.api_key_capabilities ?? presetCapabilities; + if (allowedCapabilities === undefined) return { sources }; + if (allowedCapabilities.includes(capability)) return { sources }; + + return { + sources: { + ...sources, + file: readConfigFile(), + configName: undefined, + }, + fallbackFrom: sources.configName, + }; +} + export function buildSources(flags: Partial): ResolutionSources { const raw = readRawConfigObject(); const configExplicit = flags.config !== undefined; @@ -239,6 +279,7 @@ export function buildSettings(s: ResolutionSources): Settings { defaultReferenceToVideoModel: file.default_reference_to_video_model, defaultImageModel: file.default_image_model, defaultSpeechModel: file.default_speech_model, + defaultSpeechRecognitionModel: file.default_speech_recognition_model, defaultOmniModel: file.default_omni_model, workspaceId: flags.workspaceId || env.BAILIAN_WORKSPACE_ID || file.workspace_id || undefined, consoleRegion: flags.consoleRegion || file.console_region || undefined, diff --git a/packages/core/src/config/profile-presets.ts b/packages/core/src/config/profile-presets.ts index 9d8fc0d..adfa113 100644 --- a/packages/core/src/config/profile-presets.ts +++ b/packages/core/src/config/profile-presets.ts @@ -5,6 +5,9 @@ interface ModelProfilePreset { defaultImageToVideoModel: string; defaultReferenceToVideoModel: string; defaultImageModel: string; + defaultSpeechModel: string; + defaultSpeechRecognitionModel: string; + apiKeyCapabilities: readonly string[]; } const MODEL_PROFILE_PRESETS: Readonly> = { @@ -15,6 +18,20 @@ const MODEL_PROFILE_PRESETS: Readonly> = { defaultImageToVideoModel: "happyhorse-1.1-i2v", defaultReferenceToVideoModel: "happyhorse-1.1-r2v", defaultImageModel: "wan2.7-image", + defaultSpeechModel: "qwen-audio-3.0-tts-plus", + defaultSpeechRecognitionModel: "qwen-audio-3.0-asr-flash", + apiKeyCapabilities: [ + "text.chat", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], }, }; diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 2196bb7..0b945c6 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -41,7 +41,10 @@ export interface ConfigFile { default_reference_to_video_model?: string; default_image_model?: string; default_speech_model?: string; + default_speech_recognition_model?: string; default_omni_model?: string; + /** Leaf API-key capabilities this named Profile endpoint can serve. */ + api_key_capabilities?: string[]; workspace_id?: string; console_site?: "domestic" | "international"; console_region?: string; @@ -66,7 +69,9 @@ export const CONFIG_FILE_KEYS = [ "default_reference_to_video_model", "default_image_model", "default_speech_model", + "default_speech_recognition_model", "default_omni_model", + "api_key_capabilities", "workspace_id", "console_site", "console_region", @@ -76,6 +81,29 @@ export const CONFIG_FILE_KEYS = [ const VALID_OUTPUTS = new Set(["text", "json"]); const VALID_CONSOLE_SITES = new Set(["domestic", "international"]); +export const API_KEY_CAPABILITY_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/; + +export function isApiKeyCapability(value: string): boolean { + return API_KEY_CAPABILITY_PATTERN.test(value); +} + +/** + * Normalize a persisted capability allowlist. Absence keeps the policy disabled; + * a present malformed value fails closed to an empty allowlist. + */ +export function normalizeApiKeyCapabilities(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) return []; + + const normalized: string[] = []; + for (const item of value) { + if (typeof item !== "string") return []; + const capability = item.trim(); + if (!isApiKeyCapability(capability)) return []; + if (!normalized.includes(capability)) normalized.push(capability); + } + return normalized; +} /** * A syntactically valid absolute http(s) URL. Used to validate `base_url` @@ -146,8 +174,15 @@ export function parseConfigFile(raw: unknown): ConfigFile { out.default_image_model = obj.default_image_model; if (typeof obj.default_speech_model === "string" && obj.default_speech_model.length > 0) out.default_speech_model = obj.default_speech_model; + if ( + typeof obj.default_speech_recognition_model === "string" && + obj.default_speech_recognition_model.length > 0 + ) + out.default_speech_recognition_model = obj.default_speech_recognition_model; if (typeof obj.default_omni_model === "string" && obj.default_omni_model.length > 0) out.default_omni_model = obj.default_omni_model; + const apiKeyCapabilities = normalizeApiKeyCapabilities(obj.api_key_capabilities); + if (apiKeyCapabilities !== undefined) out.api_key_capabilities = apiKeyCapabilities; if (typeof obj.workspace_id === "string" && obj.workspace_id.length > 0) out.workspace_id = obj.workspace_id; if (typeof obj.console_site === "string" && VALID_CONSOLE_SITES.has(obj.console_site)) @@ -195,6 +230,7 @@ export interface Settings { defaultReferenceToVideoModel?: string; defaultImageModel?: string; defaultSpeechModel?: string; + defaultSpeechRecognitionModel?: string; defaultOmniModel?: string; workspaceId?: string; consoleRegion?: string; diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index 1aa52c4..9d5ce1b 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -37,9 +37,48 @@ test("token-plan Profile 预设保持固定", () => { defaultImageToVideoModel: "happyhorse-1.1-i2v", defaultReferenceToVideoModel: "happyhorse-1.1-r2v", defaultImageModel: "wan2.7-image", + defaultSpeechModel: "qwen-audio-3.0-tts-plus", + defaultSpeechRecognitionModel: "qwen-audio-3.0-asr-flash", + apiKeyCapabilities: [ + "text.chat", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], }); }); +test("api_key_capabilities 解析并去重叶子能力,保留显式空白名单", () => { + expect( + parseConfigFile({ + api_key_capabilities: [" text.chat ", "image.generate", "text.chat", "video.task.get"], + }).api_key_capabilities, + ).toEqual(["text.chat", "image.generate", "video.task.get"]); + expect(parseConfigFile({ api_key_capabilities: [] }).api_key_capabilities).toEqual([]); + expect(parseConfigFile({}).api_key_capabilities).toBeUndefined(); +}); + +test("api_key_capabilities 手工配置格式错误时 fail closed", () => { + expect(parseConfigFile({ api_key_capabilities: "text.chat" }).api_key_capabilities).toEqual([]); + expect( + parseConfigFile({ api_key_capabilities: ["text.chat", "Video Generate"] }).api_key_capabilities, + ).toEqual([]); +}); + +test("default_speech_recognition_model 从配置文件进入运行时 Settings", () => { + const file = parseConfigFile({ + default_speech_recognition_model: "qwen-audio-3.0-asr-flash", + }); + expect(file.default_speech_recognition_model).toBe("qwen-audio-3.0-asr-flash"); + expect(resolve({ file }).defaultSpeechRecognitionModel).toBe("qwen-audio-3.0-asr-flash"); +}); + test("baseUrl:flag > env > file > 默认,所有来源统一归一化", () => { const flags = { baseUrl: "https://flag.example.com/compatible-mode/v1?source=flag" }; const env = { DASHSCOPE_BASE_URL: "https://env.example.com/apps/anthropic#env" }; diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts index a907a2b..875e7fc 100644 --- a/packages/core/tests/config-store.test.ts +++ b/packages/core/tests/config-store.test.ts @@ -4,6 +4,7 @@ import { join } from "path"; import { expect, test } from "vite-plus/test"; import { makeConfigStore } from "../src/config/store.ts"; import { makeAuthStore } from "../src/auth/store.ts"; +import { resolveApiKey } from "../src/auth/resolver.ts"; import { refreshAccessToken } from "../src/auth/refresh-token.ts"; import { buildSettings, @@ -14,8 +15,10 @@ import { readConfigProfiles, activateConfigProfile, deleteConfigProfile, + selectApiKeyResolutionSources, } from "../src/config/loader.ts"; import { getConfigPath } from "../src/config/paths.ts"; +import { getModelProfilePreset } from "../src/config/profile-presets.ts"; /** 在隔离的临时配置目录里执行,结束后恢复环境。 */ async function inTempConfigDir(fn: () => Promise): Promise { @@ -364,3 +367,133 @@ test("buildSources 暴露命名 config 且 default 等价顶层", async () => { expect(devSources.file.api_key).toBeUndefined(); }); }); + +test("API Key capability 白名单支持任意命名 Profile,并只替换 fallback 的 file 层", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" }); + await writeConfigFile( + { + api_key: "sk-company-plan", + base_url: "https://plan.example.com", + api_key_capabilities: ["image.generate"], + }, + "company-plan", + ); + + const selectedSources = { ...buildSources({ config: "company-plan" }), env: {} }; + const supported = selectApiKeyResolutionSources(selectedSources, "image.generate"); + expect(supported).toMatchObject({ sources: { configName: "company-plan" } }); + expect(supported.sources.file.api_key).toBe("sk-company-plan"); + + const fallback = selectApiKeyResolutionSources(selectedSources, "text.chat"); + expect(fallback.fallbackFrom).toBe("company-plan"); + expect(fallback.sources.configName).toBeUndefined(); + expect(fallback.sources.file).toMatchObject({ + api_key: "sk-default", + base_url: "https://default.example.com", + }); + }); +}); + +test("API Key capability policy 缺失时保持原 Profile,空白名单对任意叶子路由 fail closed", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default" }); + await writeConfigFile({ api_key: "sk-ordinary" }, "ordinary"); + await writeConfigFile({ api_key: "sk-closed", api_key_capabilities: [] }, "closed-plan"); + + const ordinary = selectApiKeyResolutionSources( + { ...buildSources({ config: "ordinary" }), env: {} }, + "text.chat", + ); + expect(ordinary.fallbackFrom).toBeUndefined(); + expect(ordinary.sources.file.api_key).toBe("sk-ordinary"); + + const closed = selectApiKeyResolutionSources( + { ...buildSources({ config: "closed-plan" }), env: {} }, + "speech.synthesize", + ); + expect(closed.fallbackFrom).toBe("closed-plan"); + expect(closed.sources.file.api_key).toBe("sk-default"); + }); +}); + +test("显式 API Key 跳过 capability fallback,其他字段仍按既有优先级解析", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" }); + await writeConfigFile( + { + api_key: "sk-plan", + base_url: "https://plan.example.com", + api_key_capabilities: ["image.generate"], + }, + "company-plan", + ); + + const selectedSources = buildSources({ + config: "company-plan", + apiKey: "sk-flag", + }); + selectedSources.env = { DASHSCOPE_BASE_URL: "https://env.example.com" }; + const selected = selectApiKeyResolutionSources(selectedSources, "text.chat"); + + expect(selected.fallbackFrom).toBeUndefined(); + expect(selected.sources.file.api_key).toBe("sk-plan"); + expect(resolveApiKey(selected.sources)).toMatchObject({ + token: "sk-flag", + baseUrl: "https://env.example.com", + source: "flag", + }); + }); +}); + +test("旧 token-plan Profile 缺少持久化字段时使用内置 capability 预设", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default" }); + await writeConfigFile({ api_key: "sk-token-plan" }, "token-plan"); + const selectedSources = { ...buildSources({ config: "token-plan" }), env: {} }; + const presetCapabilities = getModelProfilePreset("token-plan")?.apiKeyCapabilities; + + const supported = selectApiKeyResolutionSources( + selectedSources, + "video.generate", + presetCapabilities, + ); + expect(supported.sources.file.api_key).toBe("sk-token-plan"); + + const fallback = selectApiKeyResolutionSources( + selectedSources, + "search.web", + presetCapabilities, + ); + expect(fallback.fallbackFrom).toBe("token-plan"); + expect(fallback.sources.file.api_key).toBe("sk-default"); + }); +}); + +test("token-plan 显式空 capability 白名单覆盖内置预设", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default" }); + await writeConfigFile({ api_key: "sk-token-plan", api_key_capabilities: [] }, "token-plan"); + const selectedSources = { ...buildSources({ config: "token-plan" }), env: {} }; + const presetCapabilities = getModelProfilePreset("token-plan")?.apiKeyCapabilities; + + const fallback = selectApiKeyResolutionSources( + selectedSources, + "video.generate", + presetCapabilities, + ); + expect(fallback.fallbackFrom).toBe("token-plan"); + expect(fallback.sources.file.api_key).toBe("sk-default"); + }); +}); + +test("default 配置不对自身应用 capability fallback", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default", api_key_capabilities: [] }); + const defaultSources = { ...buildSources({ config: "default" }), env: {} }; + + const selected = selectApiKeyResolutionSources(defaultSources, "text.chat"); + expect(selected.fallbackFrom).toBeUndefined(); + expect(selected.sources.file.api_key).toBe("sk-default"); + }); +}); diff --git a/packages/core/tests/image-input.test.ts b/packages/core/tests/image-input.test.ts index 806e317..85e87b3 100644 --- a/packages/core/tests/image-input.test.ts +++ b/packages/core/tests/image-input.test.ts @@ -62,12 +62,12 @@ describe("Token Plan image input compatibility", () => { expect(() => imageFileToDataUri(imagePath)).toThrow(/Unsupported image format/); }); - test("uses Data URI for the token-plan profile even through a custom proxy", async () => { + test("uses ordinary upload behavior for a non-Token-Plan endpoint regardless of Profile name", () => { 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,/, + expect(() => client.resolveImageInput(imagePath, "happyhorse-1.1-i2v")).toThrow( + /model-domain API key/, ); }); diff --git a/packages/runtime/src/command-packs/validate.ts b/packages/runtime/src/command-packs/validate.ts index 79e47d4..a098ebb 100644 --- a/packages/runtime/src/command-packs/validate.ts +++ b/packages/runtime/src/command-packs/validate.ts @@ -164,7 +164,7 @@ function adaptCommandPack( ExitCode.GENERAL, ); } - const credential = ctx.authStore.describe().apiKey; + const credential = ctx.client.exportApiCredential(); if (!credential) { throw new BailianError( "No API key available to the Command Pack.", diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index be75345..6b8f652 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -206,7 +206,11 @@ export function createCli(commands: Record, opts: CliOptions configStore: makeConfigStore(sources.configName), authStore: makeAuthStore(sources), commandPacks: commandPackManager, - client: new Client({ identity, settings, baseUrl: resolveModelBaseUrl(sources) }), + client: new Client({ + identity, + settings, + baseUrl: resolveModelBaseUrl(sources), + }), }; await runMiddleware(ctx); await flushTelemetry(1000); diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index c1579a9..993ba6a 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -14,14 +14,18 @@ import type { } from "bailian-cli-core"; import { Client, + DEFAULT_LANGUAGE, + getModelProfilePreset, resolveApiKey, resolveConsole, resolveOpenApi, resolveModelBaseUrl, + selectApiKeyResolutionSources, trackCommandExecution, } from "bailian-cli-core"; import { maybeShowStatusBar } from "./output/status-bar.ts"; import { ansi } from "./output/color.ts"; +import { createTranslator } from "./i18n.ts"; import { checkForUpdate, getPendingUpdateNotification, @@ -59,6 +63,43 @@ export interface RunContext { /** Koa-style onion middleware: do work, call `next()`, do work after it returns. */ export type Middleware = (ctx: RunContext, next: () => Promise) => Promise; +function formatApiKeyFallbackNotice( + ctx: RunContext, + fallbackFrom: string, + commandPath: string, +): string { + const translator = createTranslator(ctx.sources.file.language ?? DEFAULT_LANGUAGE); + return translator.localize({ + "en-US": `Profile "${fallbackFrom}" does not support command "${commandPath}"; API Key settings will be read from Profile "default" for this run.`, + "zh-CN": `Profile "${fallbackFrom}" 不支持命令 "${commandPath}",本次将从 Profile "default" 读取 API Key 配置。`, + }); +} + +function writeApiKeyFallbackNotice(ctx: RunContext, fallbackFrom: string): void { + const commandPath = ctx.path.join(" "); + const message = formatApiKeyFallbackNotice(ctx, fallbackFrom, commandPath); + if (ctx.settings.output === "json") { + process.stderr.write( + JSON.stringify( + { + warning: { + code: "PROFILE_API_KEY_FALLBACK", + message, + profile: fallbackFrom, + command_path: ctx.path, + fallback_profile: "default", + credential_fields: ["api_key", "base_url"], + }, + }, + null, + 2, + ) + "\n\n", + ); + return; + } + process.stderr.write(`${message}\n`); +} + /** Fold a middleware list into a single runnable function. */ export function compose(stack: Middleware[]): (ctx: RunContext) => Promise { return (ctx) => { @@ -85,13 +126,24 @@ export const authStage: Middleware = async (ctx, next) => { baseUrl: resolveModelBaseUrl(sources), }; if (command.auth === "apiKey") { + const capability = ctx.path.join("."); + const presetCapabilities = getModelProfilePreset(sources.configName)?.apiKeyCapabilities; + const selection = selectApiKeyResolutionSources(sources, capability, presetCapabilities); + const apiSources = selection.sources; + if (selection.fallbackFrom && !settings.quiet) { + writeApiKeyFallbackNotice(ctx, selection.fallbackFrom); + } let cred: ApiKeyCredential | undefined; try { - cred = resolveApiKey(sources); + cred = resolveApiKey(apiSources); } catch (err) { if (!settings.dryRun) throw err; } - ctx.client = new Client({ ...base, apiCred: cred }); + ctx.client = new Client({ + ...base, + baseUrl: resolveModelBaseUrl(apiSources), + apiCred: cred, + }); if (cred) maybeShowStatusBar(settings, cred.token, cred); } else if (command.auth === "console") { let cred: ConsoleCredential | undefined; diff --git a/packages/runtime/tests/auth-profile-fallback.test.ts b/packages/runtime/tests/auth-profile-fallback.test.ts new file mode 100644 index 0000000..52a2f4c --- /dev/null +++ b/packages/runtime/tests/auth-profile-fallback.test.ts @@ -0,0 +1,356 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vite-plus/test"; +import { + Client, + buildSettings, + buildSources, + defineCommand, + makeAuthStore, + makeConfigStore, + resolveModelBaseUrl, + writeConfigFile, + type CommandPackManager, + type SourceFlags, +} from "bailian-cli-core"; +import { authStage, type RunContext } from "../src/middleware.ts"; + +const tempDirs: string[] = []; +const originalConfigDir = process.env.BAILIAN_CONFIG_DIR; + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } + if (originalConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR; + else process.env.BAILIAN_CONFIG_DIR = originalConfigDir; +}); + +function useTempConfigDir(): void { + const configDir = mkdtempSync(join(tmpdir(), "bl-auth-fallback-")); + tempDirs.push(configDir); + process.env.BAILIAN_CONFIG_DIR = configDir; +} + +function makeContext( + path: string[], + flags: Partial = { config: "company-plan" }, + env: NodeJS.ProcessEnv = {}, +): RunContext { + const sources = buildSources(flags); + sources.env = env; + const settings = { ...buildSettings(sources), quiet: true }; + const command = defineCommand({ + description: "test command", + auth: "apiKey", + async run() {}, + }); + return { + identity: { + binName: "bl", + version: "test", + npmPackage: "bailian-cli", + clientName: "bailian-cli-test", + }, + path, + command, + flags: {}, + settings, + sources, + configStore: makeConfigStore(sources.configName), + authStore: makeAuthStore(sources), + commandPacks: {} as CommandPackManager, + client: new Client({ + identity: { + binName: "bl", + version: "test", + npmPackage: "bailian-cli", + clientName: "bailian-cli-test", + }, + settings, + baseUrl: resolveModelBaseUrl(sources), + }), + }; +} + +async function runAuth(context: RunContext): Promise { + await authStage(context, async () => {}); +} + +async function captureStderr(operation: () => Promise): Promise<{ + output: string; + error?: unknown; +}> { + const chunks: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + await operation(); + return { output: chunks.join("") }; + } catch (error) { + return { output: chunks.join(""), error }; + } finally { + process.stderr.write = originalWrite; + } +} + +test("authStage 为不支持的 capability 注入 default API Key,同时保留所选 Profile settings", async () => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" }); + await writeConfigFile( + { + api_key: "sk-plan", + base_url: "https://plan.example.com", + default_text_model: "plan-text-model", + api_key_capabilities: ["image.generate"], + }, + "company-plan", + ); + const context = makeContext(["text", "chat"]); + + await runAuth(context); + + expect(context.client.exportApiCredential()).toMatchObject({ + token: "sk-default", + baseUrl: "https://default.example.com", + source: "config", + }); + expect(context.settings).toMatchObject({ + configName: "company-plan", + defaultTextModel: "plan-text-model", + }); +}); + +test("authStage 为支持的 capability 保留所选 Profile API Key", async () => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-default" }); + await writeConfigFile( + { api_key: "sk-plan", api_key_capabilities: ["image.generate"] }, + "company-plan", + ); + const context = makeContext(["image", "generate"]); + + await runAuth(context); + + expect(context.client.exportApiCredential()?.token).toBe("sk-plan"); +}); + +test("authStage 为缺少 capability 字段的旧 token-plan 应用内置 preset", async () => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" }); + await writeConfigFile( + { api_key: "sk-token-plan", base_url: "https://token-plan.example.com" }, + "token-plan", + ); + + const supportedContext = makeContext(["image", "generate"], { config: "token-plan" }); + await runAuth(supportedContext); + expect(supportedContext.client.exportApiCredential()).toMatchObject({ + token: "sk-token-plan", + baseUrl: "https://token-plan.example.com", + }); + + const unsupportedContext = makeContext(["search", "web"], { config: "token-plan" }); + await runAuth(unsupportedContext); + expect(unsupportedContext.client.exportApiCredential()).toMatchObject({ + token: "sk-default", + baseUrl: "https://default.example.com", + }); +}); + +test("authStage 在 --api-key 覆盖 Profile API Key 时跳过 capability fallback", async () => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" }); + await writeConfigFile( + { + api_key: "sk-plan", + base_url: "https://plan.example.com", + api_key_capabilities: ["image.generate"], + }, + "company-plan", + ); + const context = makeContext(["text", "chat"], { + config: "company-plan", + apiKey: "sk-flag", + }); + context.settings.quiet = false; + + const captured = await captureStderr(() => runAuth(context)); + + expect(captured.error).toBeUndefined(); + expect(captured.output).toBe(""); + expect(context.client.exportApiCredential()).toMatchObject({ + token: "sk-flag", + baseUrl: "https://plan.example.com", + source: "flag", + }); +}); + +test("authStage fallback 准确描述 Profile 文件层降级,--quiet 可抑制提示", async () => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-default" }); + await writeConfigFile( + { + language: "zh-CN", + api_key: "sk-plan", + api_key_capabilities: ["image.generate"], + }, + "company-plan", + ); + const visibleContext = makeContext(["text", "chat"]); + visibleContext.settings.quiet = false; + const visible = await captureStderr(() => runAuth(visibleContext)); + expect(visible.error).toBeUndefined(); + expect(visible.output).toContain( + 'Profile "company-plan" 不支持命令 "text chat",本次将从 Profile "default" 读取 API Key 配置。', + ); + expect(visible.output).not.toContain("正在使用"); + + const quietContext = makeContext(["text", "chat"]); + const quiet = await captureStderr(() => runAuth(quietContext)); + expect(quiet.error).toBeUndefined(); + expect(quiet.output).toBe(""); +}); + +test("authStage 在 JSON 模式输出多行结构化 fallback warning", async () => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-default" }); + await writeConfigFile( + { + language: "zh-CN", + api_key: "sk-plan", + api_key_capabilities: ["image.generate"], + }, + "company-plan", + ); + const context = makeContext(["text", "chat"]); + context.settings.output = "json"; + context.settings.quiet = false; + + const captured = await captureStderr(() => runAuth(context)); + + expect(captured.error).toBeUndefined(); + expect(captured.output).toMatch(/^\{\n {2}"warning": \{\n/); + expect(JSON.parse(captured.output.trim())).toEqual({ + warning: { + code: "PROFILE_API_KEY_FALLBACK", + message: + 'Profile "company-plan" 不支持命令 "text chat",本次将从 Profile "default" 读取 API Key 配置。', + profile: "company-plan", + command_path: ["text", "chat"], + fallback_profile: "default", + credential_fields: ["api_key", "base_url"], + }, + }); +}); + +test("authStage 在 DASHSCOPE_API_KEY 覆盖 Profile API Key 时不降级也不提示", async () => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" }); + await writeConfigFile( + { + api_key: "sk-plan", + base_url: "https://plan.example.com", + api_key_capabilities: ["image.generate"], + }, + "company-plan", + ); + const context = makeContext( + ["text", "chat"], + { config: "company-plan" }, + { DASHSCOPE_API_KEY: "sk-env" }, + ); + context.settings.quiet = false; + + const captured = await captureStderr(() => runAuth(context)); + + expect(captured.error).toBeUndefined(); + expect(captured.output).toBe(""); + expect(context.client.exportApiCredential()).toMatchObject({ + token: "sk-env", + source: "env", + baseUrl: "https://plan.example.com", + }); +}); + +test.each([ + { + name: "--base-url", + flags: { config: "company-plan", baseUrl: "https://flag.example.com" }, + env: {}, + expectedBaseUrl: "https://flag.example.com", + }, + { + name: "DASHSCOPE_BASE_URL", + flags: { config: "company-plan" }, + env: { DASHSCOPE_BASE_URL: "https://env.example.com" }, + expectedBaseUrl: "https://env.example.com", + }, +])("authStage 在只有 $name 时不降级也不提示", async ({ flags, env, expectedBaseUrl }) => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" }); + await writeConfigFile( + { + api_key: "sk-plan", + base_url: "https://plan.example.com", + api_key_capabilities: ["image.generate"], + }, + "company-plan", + ); + const context = makeContext(["text", "chat"], flags, env); + context.settings.quiet = false; + + const captured = await captureStderr(() => runAuth(context)); + + expect(captured.error).toBeUndefined(); + expect(captured.output).toBe(""); + expect(context.client.exportApiCredential()).toMatchObject({ + token: "sk-plan", + source: "config", + baseUrl: expectedBaseUrl, + }); +}); + +test("authStage fallback 展示空格分隔的叶子命令路径", async () => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-default" }); + await writeConfigFile( + { api_key: "sk-plan", api_key_capabilities: ["image.generate"] }, + "company-plan", + ); + const context = makeContext(["speech", "synthesize"]); + context.settings.quiet = false; + + const captured = await captureStderr(() => runAuth(context)); + + expect(captured.error).toBeUndefined(); + expect(captured.output).toContain( + 'Profile "company-plan" does not support command "speech synthesize"; API Key settings will be read from Profile "default" for this run.', + ); +}); + +test("authStage fallback 后鉴权失败或 dry-run 时仍提供降级反馈", async () => { + useTempConfigDir(); + await writeConfigFile({ base_url: "https://default.example.com" }); + await writeConfigFile( + { api_key: "sk-plan", api_key_capabilities: ["image.generate"] }, + "company-plan", + ); + + const failedContext = makeContext(["text", "chat"]); + failedContext.settings.quiet = false; + const failed = await captureStderr(() => runAuth(failedContext)); + expect(String(failed.error)).toMatch(/No API key found/); + expect(failed.output).toContain('command "text chat"'); + + const dryRunContext = makeContext(["text", "chat"]); + dryRunContext.settings.quiet = false; + dryRunContext.settings.dryRun = true; + const dryRun = await captureStderr(() => runAuth(dryRunContext)); + expect(dryRun.error).toBeUndefined(); + expect(dryRun.output).toContain('command "text chat"'); +}); diff --git a/packages/runtime/tests/error-handler.test.ts b/packages/runtime/tests/error-handler.test.ts index a128657..598c7fa 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -26,6 +26,7 @@ test("handleError: fetch failed JSON includes cause.code from errno", () => { expect(() => handleError(fetchFailed, "bl")).toThrow( new RegExp(`process\\.exit:${ExitCode.NETWORK}`), ); + expect(stderr).toMatch(/^\{\n {2}"error": \{\n/); const payload = JSON.parse(stderr.trim()) as { error: { code: number; message: string; cause?: { message: string; code?: string } }; }; diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index 44de377..2c3f696 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -88,10 +88,10 @@ bl config list --output json #### Flags -| Flag | Type | Required | Description | -| ----------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--key ` | string | yes | Config key (language, base*url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) | -| `--value ` | string | yes | Value to set | +| Flag | Type | Required | Description | +| ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--key ` | string | yes | Config key (language, base*url, output, output_dir, timeout, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) | +| `--value ` | string | yes | Value to set | #### Examples @@ -111,6 +111,10 @@ bl config set --key timeout --value 600 bl config set --key base_url --value https://dashscope.aliyuncs.com ``` +```bash +bl config set --config company-plan --key api-key-capabilities --value text.chat,image.generate +``` + ### `bl config show` | Field | Value | diff --git a/skills/bailian-gen/reference/index.md b/skills/bailian-gen/reference/index.md index 85cab64..95b8247 100644 --- a/skills/bailian-gen/reference/index.md +++ b/skills/bailian-gen/reference/index.md @@ -15,7 +15,7 @@ Use this index for the skill-scoped quick index and global flags. | `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | | `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | | `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) | -| `bl speech synthesize` | API Key | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) | +| `bl speech synthesize` | API Key | Synthesize speech from text | [speech.md](speech.md) | | `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) | | `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | | `bl video generate` | API Key | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) | diff --git a/skills/bailian-gen/reference/speech.md b/skills/bailian-gen/reference/speech.md index 81e8fec..f6fd39a 100644 --- a/skills/bailian-gen/reference/speech.md +++ b/skills/bailian-gen/reference/speech.md @@ -10,7 +10,7 @@ Index: [index.md](index.md) | Command | Authentication | Description | | ---------------------- | -------------- | ----------------------------------------------------------------- | | `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | -| `bl speech synthesize` | API Key | Synthesize speech from text (CosyVoice TTS) | +| `bl speech synthesize` | API Key | Synthesize speech from text | ## Command details @@ -25,20 +25,20 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| --------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `--url ` | array | yes | Audio file URL or local file path (repeatable, max 100) | -| `--model ` | string | no | Model ID (default: fun-asr). Async: fun-asr / _-filetrans / paraformer-_; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-\*-asr-flash | -| `--language ` | string | no | Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language | -| `--diarization` | switch | no | Enable automatic speaker diarization | -| `--speaker-count ` | number | no | Expected number of speakers (requires --diarization) | -| `--vocabulary-id ` | string | no | Hot-word vocabulary ID for improved accuracy | -| `--channel-id ` | number | no | Audio channel ID (default: 0) | -| `--out ` | string | no | Save full transcription result to JSON file | -| `--async` | switch | no | Return async task id without waiting | -| `--poll-interval ` | number | no | Polling interval in seconds (default: 2) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--url ` | array | yes | Audio file URL or local file path (repeatable, max 100) | +| `--model ` | string | no | Model ID (default: configured Profile ASR model, otherwise fun-asr). Async: fun-asr / _-filetrans / paraformer-_; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-\*-asr-flash | +| `--language ` | string | no | Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language | +| `--diarization` | switch | no | Enable automatic speaker diarization | +| `--speaker-count ` | number | no | Expected number of speakers (requires --diarization) | +| `--vocabulary-id ` | string | no | Hot-word vocabulary ID for improved accuracy | +| `--channel-id ` | number | no | Audio channel ID (default: 0) | +| `--out ` | string | no | Save full transcription result to JSON file | +| `--async` | switch | no | Return async task id without waiting | +| `--poll-interval ` | number | no | Polling interval in seconds (default: 2) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples @@ -79,7 +79,7 @@ bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-a | Field | Value | | ------------------ | -------------------------------------------- | | **Name** | `speech synthesize` | -| **Description** | Synthesize speech from text (CosyVoice TTS) | +| **Description** | Synthesize speech from text | | **Authentication** | API Key | | **Usage** | `bl speech synthesize --text [flags]` | @@ -89,10 +89,10 @@ bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-a | -------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------- | | `--text ` | string | no | Text to synthesize into speech (or use --text-file) | | `--text-file ` | string | no | Read text from a file instead of --text | -| `--model ` | string | no | Model ID (default: cosyvoice-v3-flash). System voices available for cosyvoice-v3-flash | +| `--model ` | string | no | Model ID (default: configured Profile TTS model, otherwise cosyvoice-v3-flash). System voices vary by model | | `--voice ` | string | no | Voice ID. Use --list-voices to see built-in voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID | | `--list-voices` | switch | no | List built-in system voices for the selected model and exit (console link shown in output) | -| `--format ` | string | no | Audio format: mp3, pcm, wav, opus (default: mp3) | +| `--format ` | string | no | Audio format: mp3, pcm, wav, opus (default: mp3; streaming default: pcm) | | `--sample-rate ` | string | no | Audio sample rate in Hz (e.g. 24000) | | `--volume ` | string | no | Volume 0-100 (default: 50) | | `--rate ` | string | no | Speech rate 0.5-2.0 (default: 1.0) | diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index 55d827e..2797856 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -67,7 +67,7 @@ After approval, treat Bailian as selected for the current task. Do not ask again Install, API key / console login, endpoint override, and config keys: [`assets/setup.md`](assets/setup.md). -**Token Plan:** Get the API key from the [subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview), then run `bl auth login --config token-plan --api-key `. The built-in Profile supplies the Base URL, and login validates the key before saving it. +**Token Plan:** Get the API key from the [subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview), then run `bl auth login --config token-plan --api-key `. The built-in Profile supplies the Base URL and its supported leaf API Key capabilities, and login validates the key before saving it. While that Profile is active, unsupported API Key commands automatically use `default` credentials. A per-command `--api-key` / `--base-url` or `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` bypasses this fallback entirely. **Console login:** never run bare `bl auth login --console` — always pass `--console-site domestic` or `--console-site international`. Before login, run `bl config show --output json` and follow the site-selection rules in [`assets/setup.md` → Console site selection](assets/setup.md#console-site-selection). diff --git a/skills/bailian-protocol/assets/setup.md b/skills/bailian-protocol/assets/setup.md index 4cf942d..04a5c65 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -86,6 +86,32 @@ The built-in Profile supplies the Token Plan Base URL. `auth login` tests the ke and activates the Profile only when validation succeeds; do not ask the user to configure the Base URL or run a duplicate smoke test. +### API Key capability fallback + +A named Profile can limit the API Key commands it supports through `api_key_capabilities`. + +- Supported commands use the selected Profile's API Key configuration. +- Unsupported commands read `api_key` and `base_url` from `default`; other settings remain on the + selected Profile. +- An explicit API Key or Base URL from flags or environment variables disables fallback for that + command. +- Unless `--quiet` is used, the CLI reports fallback on stderr. With `--output json`, it emits a + structured warning. + +Custom Profiles opt in only by defining `api_key_capabilities`. A missing field disables fallback, +while `[]` makes every API Key command fall back to `default`. + +The built-in `token-plan` Profile is the compatibility exception: older configs without the field +use the current built-in preset. A successful API Key login appends missing preset capabilities +without removing existing entries. + +```bash +bl config set --config company-plan --key api-key-capabilities \ + --value text.chat,image.generate,video.generate +``` + +Capability IDs are leaf command paths joined with dots, such as `video.task.get`. + Successful login automatically activates the explicitly selected Profile. Use `bl config list` to inspect it, and switch back when needed: @@ -100,7 +126,7 @@ credential is validated and saved. Failed login and `--dry-run` do not switch Pr persisted `active_config` > `default`; credential and endpoint fields inside the selected Profile still follow flag > environment > config. -Activation selects the entire Config for every credential domain, not only model consumption. After activating `token-plan`, Token Plan management and Console commands also read their OpenAPI or Console credentials from that Profile. If those credentials remain in `default`, invoke the command with `--config default` or log the corresponding credential domain into `token-plan`. +Activation selects the entire Config for every credential domain, not only model consumption. The only exception is the API Key capability fallback described above. After activating `token-plan`, Token Plan management and Console commands still read their OpenAPI or Console credentials from that Profile. If those credentials remain in `default`, invoke the command with `--config default` or log the corresponding credential domain into `token-plan`. The built-in `token-plan` profile defaults to: @@ -110,6 +136,9 @@ The built-in `token-plan` profile defaults to: - 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` +- Speech synthesis model (`default_speech_model`): `qwen-audio-3.0-tts-plus` +- Speech recognition model (`default_speech_recognition_model`): `qwen-audio-3.0-asr-flash` +- API Key capabilities: `text.chat`, `vision.describe`, `image.generate`, `image.edit`, `speech.recognize`, `speech.synthesize`, `video.generate`, `video.ref`, `video.task.get`, `video.download` 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 124c42513335c4901991121c3405cbe4c6860c23 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 26 Aug 2026 15:25:51 +0800 Subject: [PATCH 05/13] =?UTF-8?q?chore:=20=E4=BC=98=E5=8C=96=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=AE=89=E8=A3=85=E5=BC=95=E5=AF=BC=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E4=B8=ADnode=E7=89=88=E6=9C=AC=E5=85=BC=E5=AE=B9=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- INSTALL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index a4f2cf2..f291d3c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -23,13 +23,13 @@ npm install -g bailian-cli bl skill init ``` -**降级:** 若 `bl skill init` 失败,且本机有可用 Node / npm(`node -v` / `npm -v` 成功),再回退到: +**降级:** 若 `bl skill init` 失败,先确认本机同时满足两个条件:`node -v` / `npm -v` 成功,**且** `git --version` 成功。两个条件都成立时,再回退到: ```bash -npx skills add modelstudioai/cli --all -g +npx skills@1.5.18 add modelstudioai/cli --all -g ``` -无 Node 时不要走 `npx` 降级;向用户说明 skill 安装失败,并保留 `bl skill init` 的错误信息。 +> 必须使用 `skills@1.5.18`:这是最后兼容 Node ≥18 的版本(与 Bailian CLI 的 Node ≥18.17.0 一致); 安装成功后,按第 5 节向用户说明语言切换与能力 tip(若也装了 skills,一并简要说明)。 @@ -139,4 +139,4 @@ bl config set --key language --value en-US | Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 | | `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node,或改用 npm 版 CLI | | 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 | -| `bl skill init` 失败 | 网络 / registry 不可达等 | 有 Node 时降级 `npx skills add modelstudioai/cli --all -g`;无 Node 则重试或告知用户 | +| `bl skill init` 失败 | 网络 / registry 不可达等 | 有 Node 且有 Git 时降级 `npx skills@1.5.18 add modelstudioai/cli --all -g`;否则重试或告知用户 | From 90d1ef9dc5e791e49e5c05e3685f19ee98d658ba Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 26 Aug 2026 15:30:05 +0800 Subject: [PATCH 06/13] fix: format fix --- INSTALL.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index f291d3c..6a8f6c8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -132,11 +132,11 @@ bl config set --key language --value en-US ## 6. 常见问题 -| 现象 | 可能原因 | 建议动作 | -| ------------------------ | ---------------------------- | ------------------------------------------------------------------------------------ | -| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin` 或 `npm prefix -g` | -| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` | -| Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 | -| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node,或改用 npm 版 CLI | -| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 | +| 现象 | 可能原因 | 建议动作 | +| ------------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------- | +| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin` 或 `npm prefix -g` | +| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` | +| Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 | +| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node,或改用 npm 版 CLI | +| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 | | `bl skill init` 失败 | 网络 / registry 不可达等 | 有 Node 且有 Git 时降级 `npx skills@1.5.18 add modelstudioai/cli --all -g`;否则重试或告知用户 | From 8360a9f58402379fb3995eab48cda60a29d4ab60 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 26 Aug 2026 15:48:08 +0800 Subject: [PATCH 07/13] feat(e2e): add tests for video generation with --file option and mutual exclusivity checks --- .../tests/e2e/video-generate-i2v.e2e.test.ts | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) 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 0cc5a55..2b5f2fd 100644 --- a/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts @@ -169,6 +169,182 @@ describe("e2e: video generate (i2v)", () => { expect(data.request?.input?.img_url).toBeUndefined(); } }); + + test("video generate --dry-run --file 走 media file 字段", async () => { + const configDir = makeE2eOutputDir("video-generate-file-url"); + 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", + }, + }), + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + VIDEO_ROUTES, + [ + "video", + "generate", + "--config", + "token-plan", + "--dry-run", + "--model", + "wan3.0-video", + "--file", + "https://example.com/reference.pdf", + "--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; url?: string }>; + img_url?: string; + first_frame_url?: string; + }; + }; + }>(stdout); + expect(data.request?.model).toBe("wan3.0-video"); + expect(data.request?.input?.media?.[0]?.type).toBe("file"); + expect(data.request?.input?.media?.[0]?.url).toBe("https://example.com/reference.pdf"); + expect(data.request?.input?.img_url).toBeUndefined(); + expect(data.request?.input?.first_frame_url).toBeUndefined(); + }); + + test("--file 与 --image 互斥时报用法错误并退出 (2)", async () => { + const configDir = makeE2eOutputDir("video-generate-file-mutex-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", + }, + }), + ); + + const { stderr, exitCode } = await runCommandE2e( + VIDEO_ROUTES, + [ + "video", + "generate", + "--config", + "token-plan", + "--dry-run", + "--model", + "wan3.0-video", + "--file", + "https://example.com/reference.pdf", + "--image", + "https://example.com/placeholder.png", + "--prompt", + "互斥校验", + "--output", + "json", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--file.*mutually exclusive|--file.*互斥|mutually exclusive/i); + }); + + test("--file 与 --last-frame 互斥时报用法错误并退出 (2)", async () => { + const configDir = makeE2eOutputDir("video-generate-file-mutex-lastframe"); + 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", + }, + }), + ); + + const { stderr, exitCode } = await runCommandE2e( + VIDEO_ROUTES, + [ + "video", + "generate", + "--config", + "token-plan", + "--dry-run", + "--model", + "wan3.0-video", + "--file", + "https://example.com/reference.pdf", + "--last-frame", + "https://example.com/last-frame.png", + "--prompt", + "互斥校验", + "--output", + "json", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--file.*mutually exclusive|--file.*互斥|mutually exclusive/i); + }); + + test("非 wan3.0 模型使用 --file 时报用法错误并退出 (2)", async () => { + const configDir = makeE2eOutputDir("video-generate-file-model-restricted"); + 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", + }, + }), + ); + + const { stderr, exitCode } = await runCommandE2e( + VIDEO_ROUTES, + [ + "video", + "generate", + "--config", + "token-plan", + "--dry-run", + "--model", + "wan2.6-i2v", + "--file", + "https://example.com/reference.pdf", + "--prompt", + "模型限制校验", + "--output", + "json", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(exitCode).toBe(2); + expect(stderr).toMatch( + /--file.*only supported by wan3\.0-video|--file.*仅.*wan3\.0-video|only supported by wan3\.0-video/i, + ); + }); }); describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( From 0d24f46f73b8461a03c8912b4de49e8384608514 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 26 Aug 2026 17:09:27 +0800 Subject: [PATCH 08/13] fix(e2e): unbind wan3.0/--file dry-run tests from token-plan --- .../tests/e2e/video-generate-i2v.e2e.test.ts | 244 +++++------------- 1 file changed, 70 insertions(+), 174 deletions(-) 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 2b5f2fd..97d7337 100644 --- a/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts @@ -115,46 +115,26 @@ describe("e2e: video generate (i2v)", () => { test.each([ // wan2.1~2.6 (legacy) use flat img_url; wan2.7+ / wan3.0 / happyhorse use media[]. + // 用普通 dry-run(不绑 token-plan):TP 不支持 wan3.0,绑 TP 会形成错误测试契约。 ["wan2.5-i2v-preview", "img_url"], ["wan2.6-i2v", "img_url"], ["wan2.7-i2v", "media"], ["wan3.0-video", "media"], ["happyhorse-1.1-i2v", "media"], ])("video generate --dry-run %s 首帧走 %s 字段", async (model, field) => { - const configDir = makeE2eOutputDir(`video-i2v-input-shape-${model}`); - 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", - }, - }), - ); - - const { stdout, stderr, exitCode } = await runCommandE2e( - VIDEO_ROUTES, - [ - "video", - "generate", - "--config", - "token-plan", - "--dry-run", - "--model", - model, - "--image", - "https://example.com/placeholder.png", - "--prompt", - "干跑校验", - "--output", - "json", - ], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "generate", + "--dry-run", + "--model", + model, + "--image", + "https://example.com/placeholder.png", + "--prompt", + "干跑校验", + "--output", + "json", + ]); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson<{ request?: { @@ -171,40 +151,19 @@ describe("e2e: video generate (i2v)", () => { }); test("video generate --dry-run --file 走 media file 字段", async () => { - const configDir = makeE2eOutputDir("video-generate-file-url"); - 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", - }, - }), - ); - - const { stdout, stderr, exitCode } = await runCommandE2e( - VIDEO_ROUTES, - [ - "video", - "generate", - "--config", - "token-plan", - "--dry-run", - "--model", - "wan3.0-video", - "--file", - "https://example.com/reference.pdf", - "--prompt", - "文件生视频干跑", - "--output", - "json", - ], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "generate", + "--dry-run", + "--model", + "wan3.0-video", + "--file", + "https://example.com/reference.pdf", + "--prompt", + "文件生视频干跑", + "--output", + "json", + ]); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson<{ request?: { @@ -224,122 +183,59 @@ describe("e2e: video generate (i2v)", () => { }); test("--file 与 --image 互斥时报用法错误并退出 (2)", async () => { - const configDir = makeE2eOutputDir("video-generate-file-mutex-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", - }, - }), - ); - - const { stderr, exitCode } = await runCommandE2e( - VIDEO_ROUTES, - [ - "video", - "generate", - "--config", - "token-plan", - "--dry-run", - "--model", - "wan3.0-video", - "--file", - "https://example.com/reference.pdf", - "--image", - "https://example.com/placeholder.png", - "--prompt", - "互斥校验", - "--output", - "json", - ], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "generate", + "--dry-run", + "--model", + "wan3.0-video", + "--file", + "https://example.com/reference.pdf", + "--image", + "https://example.com/placeholder.png", + "--prompt", + "互斥校验", + "--output", + "json", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--file.*mutually exclusive|--file.*互斥|mutually exclusive/i); }); test("--file 与 --last-frame 互斥时报用法错误并退出 (2)", async () => { - const configDir = makeE2eOutputDir("video-generate-file-mutex-lastframe"); - 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", - }, - }), - ); - - const { stderr, exitCode } = await runCommandE2e( - VIDEO_ROUTES, - [ - "video", - "generate", - "--config", - "token-plan", - "--dry-run", - "--model", - "wan3.0-video", - "--file", - "https://example.com/reference.pdf", - "--last-frame", - "https://example.com/last-frame.png", - "--prompt", - "互斥校验", - "--output", - "json", - ], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "generate", + "--dry-run", + "--model", + "wan3.0-video", + "--file", + "https://example.com/reference.pdf", + "--last-frame", + "https://example.com/last-frame.png", + "--prompt", + "互斥校验", + "--output", + "json", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--file.*mutually exclusive|--file.*互斥|mutually exclusive/i); }); test("非 wan3.0 模型使用 --file 时报用法错误并退出 (2)", async () => { - const configDir = makeE2eOutputDir("video-generate-file-model-restricted"); - 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", - }, - }), - ); - - const { stderr, exitCode } = await runCommandE2e( - VIDEO_ROUTES, - [ - "video", - "generate", - "--config", - "token-plan", - "--dry-run", - "--model", - "wan2.6-i2v", - "--file", - "https://example.com/reference.pdf", - "--prompt", - "模型限制校验", - "--output", - "json", - ], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "generate", + "--dry-run", + "--model", + "wan2.6-i2v", + "--file", + "https://example.com/reference.pdf", + "--prompt", + "模型限制校验", + "--output", + "json", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch( /--file.*only supported by wan3\.0-video|--file.*仅.*wan3\.0-video|only supported by wan3\.0-video/i, From d343dc3265b4f0b8c9e9c4fee5a92e7228b0dbaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Wed, 26 Aug 2026 17:10:00 +0800 Subject: [PATCH 09/13] fix(auth): only apply profile presets on login --- docs/agents/auth-change.md | 4 +-- docs/agents/config-profile-change.md | 4 +-- packages/core/src/config/loader.ts | 9 +++--- packages/core/tests/config-store.test.ts | 30 ++++--------------- packages/runtime/src/middleware.ts | 4 +-- .../tests/auth-profile-fallback.test.ts | 15 +++------- skills/bailian-protocol/assets/setup.md | 15 +++++----- 7 files changed, 27 insertions(+), 54 deletions(-) diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index c0dc43f..50b524b 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -46,8 +46,8 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin(仅保留协议、host 和显式端口,去除 path、query、fragment) - `--config` 只选择 config 文件 block,不提升该 block 的字段优先级。对 `auth: "apiKey"` 命令,runtime 会先按叶子命令路径检查所选 Profile 的 `api_key_capabilities`: - `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式连接覆盖存在时,完全跳过自动降级,继续走统一的 flag > env > selected config file > 默认值 - - 配置文件显式声明的 `api_key_capabilities` 优先于内置预设;命中能力时保留所选 Profile,未命中时仅把 file-backed `api_key` / `base_url` 来源切到顶层 `default`,其他 Settings 仍来自所选 Profile - - 字段缺失且 Profile 命中内置套餐预设(当前为 `token-plan`)时,runtime 使用当前预设白名单兼容旧配置,但不自动写回磁盘;字段缺失且没有内置预设时不启用降级 + - 配置文件显式声明 `api_key_capabilities` 后,命中能力时保留所选 Profile,未命中时仅把 file-backed `api_key` / `base_url` 来源切到顶层 `default`,其他 Settings 仍来自所选 Profile + - 字段缺失时不启用降级,包括命中内置套餐预设的 Profile;preset 只在 API Key 登录验证成功后物化写入,升级 preset 需要重新登录 - fallback 反馈写 stderr:text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象;若后续鉴权失败,warning 与多行 `error` 对象以空行分隔,stdout 仍只保留命令结果 - 显式 `auth login --config ` 在凭证验证并落盘成功后自动激活目标 Profile;未传 `--config` 时继续写当前激活项,失败和 dry-run 不切换 diff --git a/docs/agents/config-profile-change.md b/docs/agents/config-profile-change.md index c9ea5ce..3267f6d 100644 --- a/docs/agents/config-profile-change.md +++ b/docs/agents/config-profile-change.md @@ -21,7 +21,7 @@ `auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。 - 激活状态只选择配置 block,不改变字段优先级;字段仍为 flag > env > selected config > 默认值。 - API Key capability fallback 是窄例外:命名 Profile 显式配置 `api_key_capabilities` 后,不在白名单中的 `auth: "apiKey"` 叶子命令只把 file 层 `api_key` / `base_url` 切到顶层 `default`;所选 Profile 的其他 settings 和 `active_config` 均不变。如果 `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一提供了更高优先级的模型连接参数,则整个 capability fallback 跳过,file 层也不切换;未显式提供的另一部分继续按 flag > env > 所选 Profile 解析。 -- 自定义 Profile 是否启用 capability fallback 只看 `api_key_capabilities`,与名称无关:字段缺失表示关闭策略,`[]` 表示全部 API Key 命令 fallback。内置 `token-plan` 是当前唯一的名称 preset 兼容例外:旧配置缺少该字段时,runtime 使用当前内置白名单,但不自动写回磁盘。 +- Profile 是否启用 capability fallback 只看持久化的 `api_key_capabilities`,与名称无关:字段缺失表示关闭策略,`[]` 表示全部 API Key 命令 fallback。runtime 不注入内置 preset;升级内置 Plan Profile 的 preset 需要重新登录。 - 对命中内置 preset 的 Profile,API Key 登录验证成功后会把当前 preset 中缺少的 capability 追加落盘,同时保留已有项且不做删除;Console/OpenAPI 登录、自定义 Profile、dry-run 和失败登录均不修改该白名单。 - Capability ID 直接使用产品实际叶子命令路径并以 `.` 连接(例如 `video task get` → `video.task.get`);不新增命令元数据。新增或改名后的 API Key 路由未进入白名单时自然 fail closed。 - Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。 @@ -60,7 +60,7 @@ - 旧配置无 `active_config` -> `default`。 - 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。 - 任意名称 Profile 的叶子路由 capability 命中时使用自身 API Key;未命中或空白名单时使用 `default` API Key;`--api-key` / `--base-url` 和 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一覆盖时跳过 fallback。 -- 旧内置 Plan Profile 缺 capability 字段时使用 preset;显式配置(含 `[]`)覆盖 preset,且不自动迁移写盘。 +- 旧内置 Plan Profile 缺 capability 字段时不启用 fallback;重新登录后使用并持久化当前 preset,显式配置(含 `[]`)按文件值生效。 - 显式命名 `--config` 和 `--config default` 均覆盖激活项且不修改磁盘状态。 - 激活不存在的 Profile 失败且不写盘。 - 悬空 `active_config` 明确失败。 diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 1c4d4bd..096f991 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -202,14 +202,13 @@ export interface ApiKeyResolutionSourceSelection { } /** - * Select the file-backed API-key source for a command capability. An explicit - * flag or environment API connection override bypasses Profile capability - * fallback entirely. + * Select the file-backed API-key source for a command capability. Only a + * persisted Profile capability list enables fallback. An explicit flag or + * environment API connection override bypasses it entirely. */ export function selectApiKeyResolutionSources( sources: ResolutionSources, capability: string, - presetCapabilities?: readonly string[], ): ApiKeyResolutionSourceSelection { if ( sources.flags.apiKey || @@ -221,7 +220,7 @@ export function selectApiKeyResolutionSources( } if (!sources.configName) return { sources }; - const allowedCapabilities = sources.file.api_key_capabilities ?? presetCapabilities; + const allowedCapabilities = sources.file.api_key_capabilities; if (allowedCapabilities === undefined) return { sources }; if (allowedCapabilities.includes(capability)) return { sources }; diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts index 875e7fc..a49ae24 100644 --- a/packages/core/tests/config-store.test.ts +++ b/packages/core/tests/config-store.test.ts @@ -18,7 +18,6 @@ import { selectApiKeyResolutionSources, } from "../src/config/loader.ts"; import { getConfigPath } from "../src/config/paths.ts"; -import { getModelProfilePreset } from "../src/config/profile-presets.ts"; /** 在隔离的临时配置目录里执行,结束后恢复环境。 */ async function inTempConfigDir(fn: () => Promise): Promise { @@ -446,42 +445,25 @@ test("显式 API Key 跳过 capability fallback,其他字段仍按既有优先 }); }); -test("旧 token-plan Profile 缺少持久化字段时使用内置 capability 预设", async () => { +test("旧 token-plan Profile 缺少 capability 字段时不启用 fallback", async () => { await inTempConfigDir(async () => { await writeConfigFile({ api_key: "sk-default" }); await writeConfigFile({ api_key: "sk-token-plan" }, "token-plan"); const selectedSources = { ...buildSources({ config: "token-plan" }), env: {} }; - const presetCapabilities = getModelProfilePreset("token-plan")?.apiKeyCapabilities; - const supported = selectApiKeyResolutionSources( - selectedSources, - "video.generate", - presetCapabilities, - ); - expect(supported.sources.file.api_key).toBe("sk-token-plan"); - - const fallback = selectApiKeyResolutionSources( - selectedSources, - "search.web", - presetCapabilities, - ); - expect(fallback.fallbackFrom).toBe("token-plan"); - expect(fallback.sources.file.api_key).toBe("sk-default"); + const selected = selectApiKeyResolutionSources(selectedSources, "search.web"); + expect(selected.fallbackFrom).toBeUndefined(); + expect(selected.sources.file.api_key).toBe("sk-token-plan"); }); }); -test("token-plan 显式空 capability 白名单覆盖内置预设", async () => { +test("token-plan 显式空 capability 白名单启用全量 fallback", async () => { await inTempConfigDir(async () => { await writeConfigFile({ api_key: "sk-default" }); await writeConfigFile({ api_key: "sk-token-plan", api_key_capabilities: [] }, "token-plan"); const selectedSources = { ...buildSources({ config: "token-plan" }), env: {} }; - const presetCapabilities = getModelProfilePreset("token-plan")?.apiKeyCapabilities; - const fallback = selectApiKeyResolutionSources( - selectedSources, - "video.generate", - presetCapabilities, - ); + const fallback = selectApiKeyResolutionSources(selectedSources, "video.generate"); expect(fallback.fallbackFrom).toBe("token-plan"); expect(fallback.sources.file.api_key).toBe("sk-default"); }); diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index 993ba6a..5322479 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -15,7 +15,6 @@ import type { import { Client, DEFAULT_LANGUAGE, - getModelProfilePreset, resolveApiKey, resolveConsole, resolveOpenApi, @@ -127,8 +126,7 @@ export const authStage: Middleware = async (ctx, next) => { }; if (command.auth === "apiKey") { const capability = ctx.path.join("."); - const presetCapabilities = getModelProfilePreset(sources.configName)?.apiKeyCapabilities; - const selection = selectApiKeyResolutionSources(sources, capability, presetCapabilities); + const selection = selectApiKeyResolutionSources(sources, capability); const apiSources = selection.sources; if (selection.fallbackFrom && !settings.quiet) { writeApiKeyFallbackNotice(ctx, selection.fallbackFrom); diff --git a/packages/runtime/tests/auth-profile-fallback.test.ts b/packages/runtime/tests/auth-profile-fallback.test.ts index 52a2f4c..7b62fe7 100644 --- a/packages/runtime/tests/auth-profile-fallback.test.ts +++ b/packages/runtime/tests/auth-profile-fallback.test.ts @@ -139,7 +139,7 @@ test("authStage 为支持的 capability 保留所选 Profile API Key", async () expect(context.client.exportApiCredential()?.token).toBe("sk-plan"); }); -test("authStage 为缺少 capability 字段的旧 token-plan 应用内置 preset", async () => { +test("authStage 不为缺少 capability 字段的旧 token-plan 注入内置 preset", async () => { useTempConfigDir(); await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" }); await writeConfigFile( @@ -147,19 +147,12 @@ test("authStage 为缺少 capability 字段的旧 token-plan 应用内置 preset "token-plan", ); - const supportedContext = makeContext(["image", "generate"], { config: "token-plan" }); - await runAuth(supportedContext); - expect(supportedContext.client.exportApiCredential()).toMatchObject({ + const context = makeContext(["search", "web"], { config: "token-plan" }); + await runAuth(context); + expect(context.client.exportApiCredential()).toMatchObject({ token: "sk-token-plan", baseUrl: "https://token-plan.example.com", }); - - const unsupportedContext = makeContext(["search", "web"], { config: "token-plan" }); - await runAuth(unsupportedContext); - expect(unsupportedContext.client.exportApiCredential()).toMatchObject({ - token: "sk-default", - baseUrl: "https://default.example.com", - }); }); test("authStage 在 --api-key 覆盖 Profile API Key 时跳过 capability fallback", async () => { diff --git a/skills/bailian-protocol/assets/setup.md b/skills/bailian-protocol/assets/setup.md index 04a5c65..79d3567 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -98,12 +98,11 @@ A named Profile can limit the API Key commands it supports through `api_key_capa - Unless `--quiet` is used, the CLI reports fallback on stderr. With `--output json`, it emits a structured warning. -Custom Profiles opt in only by defining `api_key_capabilities`. A missing field disables fallback, -while `[]` makes every API Key command fall back to `default`. - -The built-in `token-plan` Profile is the compatibility exception: older configs without the field -use the current built-in preset. A successful API Key login appends missing preset capabilities -without removing existing entries. +Profiles opt in only by defining `api_key_capabilities`. A missing field disables fallback, while +`[]` makes every API Key command fall back to `default`. This applies to the built-in `token-plan` +Profile too: the CLI never injects a newer preset at runtime. A successful Token Plan API Key login +appends missing preset capabilities without removing existing entries, so log in again to upgrade +the persisted preset. ```bash bl config set --config company-plan --key api-key-capabilities \ @@ -128,7 +127,9 @@ still follow flag > environment > config. Activation selects the entire Config for every credential domain, not only model consumption. The only exception is the API Key capability fallback described above. After activating `token-plan`, Token Plan management and Console commands still read their OpenAPI or Console credentials from that Profile. If those credentials remain in `default`, invoke the command with `--config default` or log the corresponding credential domain into `token-plan`. -The built-in `token-plan` profile defaults to: +The built-in `token-plan` preset contains the following values. The CLI materializes them only after +a successful `auth login --config token-plan`. It does not replace them at runtime; run the login +command again to persist a newer preset: - Base URL: `https://token-plan.cn-beijing.maas.aliyuncs.com` - Text model: `qwen3.8-max` From 804a43f8ed1aa330a335e7faa7e58e3352739e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Wed, 26 Aug 2026 17:55:44 +0800 Subject: [PATCH 10/13] docs(skill): use profile default model when unspecified --- skills/bailian-gen/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index ee4b971..71a2ddc 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -41,6 +41,8 @@ description: >- | Image describe | `bl vision describe` | `qwen3-vl-plus`;宿主能做且未点名 → host-first | | Video / A-V understand | `bl vision describe --video` 或 `bl omni` | 视频理解默认走百炼;`omni` 默认 `qwen3.5-omni-plus` | +Unless the user explicitly specifies a model, omit `--model` and let the CLI use the active Profile’s default. + For ASR model selection, keep `fun-asr` (or other `*-filetrans`) for long recordings, repeated files, speaker diarization, or asynchronous task IDs. For one local or remote audio file up to about five minutes when the user asks for low-latency Flash models, use `--model fun-asr-flash-2026-06-15`, `--model qwen-audio-3.0-asr-flash`, or `--model qwen3-asr-flash`. Flash recognition is synchronous and accepts exactly one file per call. Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl --help` — do not guess flags. From 17ffde4ac2e901cbdfd0137be581c3d39d0886d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 27 Aug 2026 11:33:50 +0800 Subject: [PATCH 11/13] chore(release): prepare 1.18.0 --- CHANGELOG.md | 14 ++++++++++++++ CHANGELOG.zh.md | 14 ++++++++++++++ packages/cli/package.json | 2 +- packages/commands/package.json | 2 +- packages/core/package.json | 2 +- packages/kscli/package.json | 2 +- packages/runtime/package.json | 2 +- skills/bailian-cli/SKILL.md | 2 +- skills/bailian-finetune/SKILL.md | 2 +- skills/bailian-gen/SKILL.md | 2 +- skills/bailian-managed-agent/SKILL.md | 2 +- skills/bailian-protocol/SKILL.md | 2 +- skills/bailian-web-search/SKILL.md | 2 +- 13 files changed, 39 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b14ba8..675dce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md) +## [1.18.0] - 2026-08-27 + +### Added + +- **Automatic Profile API Key fallback** — unsupported commands use the API Key and endpoint from `default` without requiring a Profile switch; explicit credentials still take priority. +- **Independent speech defaults** — Profiles support separate default models for TTS and ASR. +- **Wan3.0 file-to-video** — `bl video generate --file` accepts local files or URLs and validates incompatible inputs. + +### Changed + +- **Wan3.0 is now the default video model** — regular API Key text, image, and reference video tasks default to `wan3.0-video`, with first/last-frame and reference-audio support. Token Plan defaults remain unchanged. +- **Expanded Token Plan preset** — login adds speech defaults and missing capabilities while preserving existing user configuration. +- **Improved speech and Skill installation experience** — streaming synthesis defaults to PCM; installation docs add a Node 18-compatible fallback and clarify the Node.js and Git prerequisites. + ## [1.17.1] - 2026-08-22 ### Fixed diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 4fb5c45..5731a3c 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -6,6 +6,20 @@ [English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md) +## [1.18.0] - 2026-08-27 + +### 新增 + +- **Profile API Key 自动回退** —— 当前 Profile 不支持某条命令时,自动使用 `default` 中的 API Key 和接入地址,无需手动切换 Profile;显式凭证仍然优先。 +- **语音合成与识别支持独立默认模型** —— Profile 支持分别配置 TTS 和 ASR 默认模型。 +- **Wan3.0 文件生视频** —— `bl video generate --file` 支持本地文件或 URL,并校验互斥输入。 + +### 变更 + +- **视频默认模型升级至 Wan3.0** —— 普通 API Key 的文生、图生和参考生视频默认使用 `wan3.0-video`,并支持首尾帧与参考音频;Token Plan 默认模型保持不变。 +- **扩展 Token Plan 预设** —— 登录后自动补充语音默认模型和缺失能力,同时保留用户已有配置。 +- **优化语音与 Skill 安装体验** —— 流式语音合成默认输出 PCM;Skill 安装文档补充兼容 Node 18 的降级方式及 Node.js、Git 前置条件。 + ## [1.17.1] - 2026-08-22 ### 修复 diff --git a/packages/cli/package.json b/packages/cli/package.json index adf4ef6..6c716ec 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli", - "version": "1.17.1", + "version": "1.18.0", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", diff --git a/packages/commands/package.json b/packages/commands/package.json index a1943e1..88eefb5 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-commands", - "version": "1.17.1", + "version": "1.18.0", "description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/core/package.json b/packages/core/package.json index 497396a..8a83e2b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-core", - "version": "1.17.1", + "version": "1.18.0", "description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/kscli/package.json b/packages/kscli/package.json index d024860..2f2dc9b 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-studio-cli", - "version": "1.17.1", + "version": "1.18.0", "description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.", "keywords": [ "alibaba-cloud", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index a8ba716..663d26f 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-runtime", - "version": "1.17.1", + "version": "1.18.0", "description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index b1a8d13..06e51c9 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-cli metadata: - version: "1.17.1" + version: "1.18.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index 30339f1..45696dc 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-finetune metadata: - version: "1.17.1" + version: "1.18.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index 5aaa87b..462f358 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-gen metadata: - version: "1.17.1" + version: "1.18.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index 7277707..37e72a1 100644 --- a/skills/bailian-managed-agent/SKILL.md +++ b/skills/bailian-managed-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-managed-agent metadata: - version: "1.17.1" + version: "1.18.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index d62652f..48232d3 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-protocol metadata: - version: "1.17.1" + version: "1.18.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-web-search/SKILL.md b/skills/bailian-web-search/SKILL.md index 368c50f..87f45de 100644 --- a/skills/bailian-web-search/SKILL.md +++ b/skills/bailian-web-search/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-web-search metadata: - version: "1.17.1" + version: "1.18.0" requires: bins: ["bl"] description: >- From bd8b08b6171f757fe40a0e5163f47e35503734bd Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Fri, 28 Aug 2026 13:10:56 +0800 Subject: [PATCH 12/13] feat: cancel validate API key --- docs/agents/auth-change.md | 6 +- docs/agents/config-profile-change.md | 6 +- .../src/commands/auth/login-api-key.ts | 73 +-- .../src/commands/auth/login-console.ts | 6 +- packages/commands/src/commands/auth/login.ts | 13 +- packages/commands/tests/e2e/auth.e2e.test.ts | 469 +++++++----------- skills/bailian-cli/reference/auth.md | 2 +- skills/bailian-protocol/SKILL.md | 2 +- skills/bailian-protocol/assets/setup.md | 8 +- 9 files changed, 211 insertions(+), 374 deletions(-) diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index 50b524b..837a5e9 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -33,7 +33,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx `~/.bailian/config.json` 可同时保存 `api_key`、`access_token` 与 `access_key_*`。登录任一种方式不得删除另一种: -- `bl auth login --api-key ...` 更新 `api_key`;显式 `base_url` 会一并写入,所选命名 Profile 若命中内置套餐预设(当前为 `token-plan`),则在尚未保存 `base_url` 时补写预设地址,并把该预设的默认模型物化写入。API Key 验证成功后,`api_key_capabilities` 保留已有项并追加当前 preset 中缺少的项,不自动删除任何已有能力;无 preset 的自定义 Profile 不做合并。登录仍不得删除其他鉴权域的凭证 +- `bl auth login --api-key ...` 更新 `api_key`;显式 `base_url` 会一并写入,所选命名 Profile 若命中内置套餐预设(当前为 `token-plan`),则在尚未保存 `base_url` 时补写预设地址,并把该预设的默认模型物化写入。API Key 落盘成功后,`api_key_capabilities` 保留已有项并追加当前 preset 中缺少的项,不自动删除任何已有能力;无 preset 的自定义 Profile 不做合并。登录仍不得删除其他鉴权域的凭证 - `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段 - `bl auth login --open-api ...` 更新 `access_key_id` / `access_key_secret`,同时会调用 OpenAPI 生成 CLI `access_token` 并一并写入;即一次 `--open-api` 登录同时产生 `openapi` 与 `console` 域凭证 - `bl auth logout --console` 只清 `access_token` @@ -47,9 +47,9 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - `--config` 只选择 config 文件 block,不提升该 block 的字段优先级。对 `auth: "apiKey"` 命令,runtime 会先按叶子命令路径检查所选 Profile 的 `api_key_capabilities`: - `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式连接覆盖存在时,完全跳过自动降级,继续走统一的 flag > env > selected config file > 默认值 - 配置文件显式声明 `api_key_capabilities` 后,命中能力时保留所选 Profile,未命中时仅把 file-backed `api_key` / `base_url` 来源切到顶层 `default`,其他 Settings 仍来自所选 Profile - - 字段缺失时不启用降级,包括命中内置套餐预设的 Profile;preset 只在 API Key 登录验证成功后物化写入,升级 preset 需要重新登录 + - 字段缺失时不启用降级,包括命中内置套餐预设的 Profile;preset 只在 API Key 登录落盘成功后物化写入,升级 preset 需要重新登录 - fallback 反馈写 stderr:text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象;若后续鉴权失败,warning 与多行 `error` 对象以空行分隔,stdout 仍只保留命令结果 -- 显式 `auth login --config ` 在凭证验证并落盘成功后自动激活目标 Profile;未传 +- 显式 `auth login --config ` 在凭证落盘成功后自动激活目标 Profile;未传 `--config` 时继续写当前激活项,失败和 dry-run 不切换 - `resolveConsole()` — `auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认 - `resolveOpenApi()` — `auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段 diff --git a/docs/agents/config-profile-change.md b/docs/agents/config-profile-change.md index 3267f6d..116e087 100644 --- a/docs/agents/config-profile-change.md +++ b/docs/agents/config-profile-change.md @@ -18,11 +18,11 @@ - 解析阶段用局部变量保留“是否显式传入 `--config`”的信息;完成 Config 选择后不进入 `Settings`。 - `--config default` 必须显式选择顶层配置并绕过命名激活项。 - 普通命令的显式 `--config` 只覆盖本次选择,不修改持久化激活状态;例外是 - `auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。 + `auth login --config ...`,凭证落盘成功后自动激活该 Profile。 - 激活状态只选择配置 block,不改变字段优先级;字段仍为 flag > env > selected config > 默认值。 - API Key capability fallback 是窄例外:命名 Profile 显式配置 `api_key_capabilities` 后,不在白名单中的 `auth: "apiKey"` 叶子命令只把 file 层 `api_key` / `base_url` 切到顶层 `default`;所选 Profile 的其他 settings 和 `active_config` 均不变。如果 `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一提供了更高优先级的模型连接参数,则整个 capability fallback 跳过,file 层也不切换;未显式提供的另一部分继续按 flag > env > 所选 Profile 解析。 - Profile 是否启用 capability fallback 只看持久化的 `api_key_capabilities`,与名称无关:字段缺失表示关闭策略,`[]` 表示全部 API Key 命令 fallback。runtime 不注入内置 preset;升级内置 Plan Profile 的 preset 需要重新登录。 -- 对命中内置 preset 的 Profile,API Key 登录验证成功后会把当前 preset 中缺少的 capability 追加落盘,同时保留已有项且不做删除;Console/OpenAPI 登录、自定义 Profile、dry-run 和失败登录均不修改该白名单。 +- 对命中内置 preset 的 Profile,API Key 登录落盘成功后会把当前 preset 中缺少的 capability 追加落盘,同时保留已有项且不做删除;Console/OpenAPI 登录、自定义 Profile、dry-run 和失败登录均不修改该白名单。 - Capability ID 直接使用产品实际叶子命令路径并以 `.` 连接(例如 `video task get` → `video.task.get`);不新增命令元数据。新增或改名后的 API Key 路由未进入白名单时自然 fail closed。 - Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。 - Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。 @@ -30,7 +30,7 @@ ## 3. 保持读写命令交互一致 - `auth login`、`config set` 等写命令未传 `--config` 时修改当前激活项。 -- `auth login --config ` 显式指定不存在的 Profile 时,仅在凭证验证成功并实际落盘时 +- `auth login --config ` 显式指定不存在的 Profile 时,仅在凭证实际落盘时 创建和激活;`config set --config ` 可创建但不自动激活。 - `config show`、`auth status` 和业务消费等读命令不得因为显式指定不存在的名称而创建 Profile。 - `auth logout` 默认只清理当前激活项;显式 `--config` 只清理指定项。 diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts index bb3af28..f31e993 100644 --- a/packages/commands/src/commands/auth/login-api-key.ts +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -1,23 +1,10 @@ -import { - BailianError, - ExitCode, - chatPath, - requestJson, - normalizeModelBaseUrl, - type AuthPersistPatch, - type AuthStore, - type Identity, - type Settings, -} from "bailian-cli-core"; +import { normalizeModelBaseUrl, type AuthPersistPatch, type AuthStore } from "bailian-cli-core"; interface ApiKeyLoginDeps { - identity: Identity; - settings: Settings; authStore: AuthStore; } interface ApiKeyLoginProfile { - baseUrl: string; persistBaseUrl?: string; defaultTextModel?: string; defaultVideoModel?: string; @@ -30,65 +17,19 @@ interface ApiKeyLoginProfile { persistPatch?: AuthPersistPatch; } -const RETRY_DELAY_BASE_MS = 500; - -function canRetry(error: unknown): boolean { - if (error instanceof BailianError) { - if (error.exitCode === ExitCode.NETWORK || error.exitCode === ExitCode.TIMEOUT) return true; - const status = error.api?.httpStatus; - return status === 401 || (status !== undefined && status >= 500); - } - if (error instanceof Error) { - return ( - error.name === "AbortError" || - error.name === "TimeoutError" || - error.message.includes("timed out") || - error.message === "fetch failed" - ); - } - return false; -} - -export async function validateAndPersistApiKey( +/** + * Persist an API key (and optional profile defaults) without a live model probe. + * Login is credential storage; connectivity is verified on the first API command. + * A former chat/completions smoke test conflated quota/model-access 403s with bad keys. + */ +export async function persistApiKey( deps: ApiKeyLoginDeps, key: string, profile: ApiKeyLoginProfile, ): Promise { - process.stderr.write("Testing key... "); - const httpDeps = { identity: deps.identity, settings: deps.settings }; - const baseUrl = normalizeModelBaseUrl(profile.baseUrl); const persistBaseUrl = profile.persistBaseUrl ? normalizeModelBaseUrl(profile.persistBaseUrl) : undefined; - const validationModel = "qwen3.8-max"; - const requestOpts = { - url: baseUrl + chatPath(), - method: "POST", - headers: { Authorization: `Bearer ${key}` }, - timeout: Math.min(deps.settings.timeout, 30), - body: { - model: validationModel, - messages: [{ role: "user", content: "hi" }], - max_tokens: 1, - stream: false, - }, - }; - - for (let attempt = 1; attempt <= 3; attempt++) { - try { - await requestJson(httpDeps, requestOpts); - break; - } catch (error) { - if (attempt >= 3 || !canRetry(error)) { - process.stderr.write("Failed\n"); - throw error; - } - const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - - process.stderr.write("Valid\n"); await deps.authStore.login({ ...profile.persistPatch, api_key: key, diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts index 5b1a0f7..c37d3d8 100644 --- a/packages/commands/src/commands/auth/login-console.ts +++ b/packages/commands/src/commands/auth/login-console.ts @@ -11,7 +11,7 @@ import { type Settings, } from "bailian-cli-core"; import { listenLocalServer, openInBrowser } from "../shared/local-server.ts"; -import { validateAndPersistApiKey } from "./login-api-key.ts"; +import { persistApiKey } from "./login-api-key.ts"; /** 登录流程的能力面:身份(UA)、有效配置(timeout 等)、auth 域落盘。 */ export interface LoginDeps { @@ -413,9 +413,7 @@ export async function runConsoleLogin( workspace_id: workspaceId || undefined, }; if (apiKey) { - const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl(); - await validateAndPersistApiKey(deps, apiKey, { - baseUrl: testBaseUrl, + await persistApiKey(deps, apiKey, { persistBaseUrl: baseUrl || undefined, persistPatch: callbackPatch, }); diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index 9000a83..a565fde 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -5,7 +5,7 @@ import { normalizeModelBaseUrl, } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; -import { validateAndPersistApiKey } from "./login-api-key.ts"; +import { persistApiKey } from "./login-api-key.ts"; import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts"; const LOGIN_MODE_HINT = "Choose exactly one login mode: --api-key, --console, or --open-api"; @@ -33,8 +33,8 @@ export default defineCommand({ type: "string", valueHint: "", description: { - "en-US": "Model API base URL (used with --api-key for validation)", - "zh-CN": "模型 API Base URL(用于配合 --api-key 进行验证)", + "en-US": "Model API base URL to store with --api-key", + "zh-CN": "与 --api-key 一并保存的模型 API Base URL", }, }, console: { @@ -164,19 +164,17 @@ export default defineCommand({ if (!key) return; if (settings.dryRun) { - emitBare("Would validate and save API key."); + emitBare("Would save API key."); return; } const profilePreset = getModelProfilePreset(settings.configName); const stored = store.stored(); const storedBaseUrl = stored.baseUrl; - const resolvedBaseUrl = baseUrl || store.resolveBaseUrl(profilePreset?.baseUrl); const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined); const apiKeyCapabilities = profilePreset ? [...new Set([...(stored.apiKeyCapabilities ?? []), ...profilePreset.apiKeyCapabilities])] : stored.apiKeyCapabilities; - await validateAndPersistApiKey(deps, key, { - baseUrl: resolvedBaseUrl, + await persistApiKey(deps, key, { persistBaseUrl, defaultTextModel: profilePreset?.defaultTextModel, defaultVideoModel: profilePreset?.defaultVideoModel, @@ -187,5 +185,6 @@ export default defineCommand({ defaultSpeechRecognitionModel: profilePreset?.defaultSpeechRecognitionModel, apiKeyCapabilities, }); + process.stderr.write(`API key saved to ${store.path}\n`); }, }); diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index b97346e..0d33482 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -1,6 +1,4 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; -import http from "node:http"; -import type { AddressInfo } from "node:net"; import { tmpdir } from "os"; import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; @@ -13,51 +11,6 @@ import { } from "./helpers.ts"; import { AUTH_ROUTES } from "./topic-routes.ts"; -interface ValidationServer { - baseUrl: string; - requests: Array<{ - path: string; - body: Record; - authorization?: string; - sourceConfig?: string; - openApiSource?: string; - }>; - close(): Promise; -} - -async function startValidationServer(statusCode = 200): Promise { - const requests: ValidationServer["requests"] = []; - const server = http.createServer((request, response) => { - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - const rawBody = Buffer.concat(chunks).toString("utf8"); - requests.push({ - path: request.url ?? "", - body: rawBody ? (JSON.parse(rawBody) as Record) : {}, - authorization: request.headers.authorization, - sourceConfig: request.headers["x-dashscope-source-config"] as string | undefined, - openApiSource: request.headers["x-dashscope-openapisource"] as string | undefined, - }); - response.writeHead(statusCode, { "Content-Type": "application/json" }); - if (statusCode >= 400) { - response.end(JSON.stringify({ code: "InvalidApiKey", message: "invalid key" })); - return; - } - response.end( - JSON.stringify({ choices: [{ message: { role: "assistant", content: "ok" } }] }), - ); - }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address() as AddressInfo; - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve) => server.close(() => resolve())), - }; -} - /** Auth E2E:本地参数/持久化契约默认执行;真实鉴权请求按对应 readiness gate 执行。 */ describe("e2e: auth", () => { @@ -172,7 +125,7 @@ describe("e2e: auth", () => { expect(stderr).toMatch(/Choose exactly one login mode/); }); - test("auth login --dry-run --api-key 不发起校验与落盘", async () => { + test("auth login --dry-run --api-key 不发起落盘", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "login", @@ -181,7 +134,7 @@ describe("e2e: auth", () => { "sk-e2e-dry-run-placeholder", ]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain("Would validate and save API key."); + expect(stdout).toContain("Would save API key."); }); test("auth login --dry-run 仍校验显式 Base URL", async () => { @@ -198,104 +151,83 @@ describe("e2e: auth", () => { expect(stderr).toMatch(/Invalid model base URL/); }); - test("auth login --api-key 验证后原子保存凭证和 Base URL", async () => { - const validationServer = await startValidationServer(); + test("auth login --api-key 原子保存凭证和 Base URL(无联网探测)", async () => { const configDir = makeE2eOutputDir("auth-api-key-login"); - const sdkBaseUrl = `${validationServer.baseUrl}/compatible-mode/v1/?source=login#fragment`; - try { - const login = await runCommandE2e( - AUTH_ROUTES, - ["auth", "login", "--api-key", "sk-e2e-placeholder", "--base-url", sdkBaseUrl], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); - expect(login.exitCode, login.stderr).toBe(0); - expect(validationServer.requests).toHaveLength(1); - expect(validationServer.requests[0]).toMatchObject({ - path: "/compatible-mode/v1/chat/completions", - authorization: "Bearer sk-e2e-placeholder", - sourceConfig: expect.any(String), - openApiSource: "BailianCLI", - body: { - model: "qwen3.8-max", - stream: false, - }, - }); + const origin = "https://dashscope.example.test"; + const sdkBaseUrl = `${origin}/compatible-mode/v1/?source=login#fragment`; + const login = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--api-key", "sk-e2e-placeholder", "--base-url", sdkBaseUrl], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); + expect(login.stderr).toMatch(/API key saved to/); - const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< - string, - unknown - >; - expect(config.api_key).toBe("sk-e2e-placeholder"); - expect(config.base_url).toBe(validationServer.baseUrl); - } finally { - await validationServer.close(); - } + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.api_key).toBe("sk-e2e-placeholder"); + expect(config.base_url).toBe(origin); }); test("auth login --config token-plan 接受 Anthropic SDK Base URL", async () => { - const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-token-plan-anthropic-base-url"); - try { - const login = await runCommandE2e( - AUTH_ROUTES, - [ - "auth", - "login", - "--config", - "token-plan", - "--api-key", - "sk-sp-e2e-placeholder", - "--base-url", - `${validationServer.baseUrl}/apps/anthropic?source=sdk#fragment`, - ], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); - expect(login.exitCode, login.stderr).toBe(0); - expect(validationServer.requests).toHaveLength(1); - expect(validationServer.requests[0].path).toBe("/compatible-mode/v1/chat/completions"); + const origin = "https://token-plan.example.test"; + const login = await runCommandE2e( + AUTH_ROUTES, + [ + "auth", + "login", + "--config", + "token-plan", + "--api-key", + "sk-sp-e2e-placeholder", + "--base-url", + `${origin}/apps/anthropic?source=sdk#fragment`, + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); - const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< - string, - unknown - >; - expect(config["token-plan"]).toMatchObject({ - api_key: "sk-sp-e2e-placeholder", - base_url: validationServer.baseUrl, - default_text_model: "qwen3.8-max", - 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", - default_speech_model: "qwen-audio-3.0-tts-plus", - default_speech_recognition_model: "qwen-audio-3.0-asr-flash", - api_key_capabilities: [ - "text.chat", - "vision.describe", - "image.generate", - "image.edit", - "speech.recognize", - "speech.synthesize", - "video.generate", - "video.ref", - "video.task.get", - "video.download", - ], - }); - } finally { - await validationServer.close(); - } + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config["token-plan"]).toMatchObject({ + api_key: "sk-sp-e2e-placeholder", + base_url: origin, + default_text_model: "qwen3.8-max", + 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", + default_speech_model: "qwen-audio-3.0-tts-plus", + default_speech_recognition_model: "qwen-audio-3.0-asr-flash", + api_key_capabilities: [ + "text.chat", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], + }); }); test("auth login --config token-plan 追加新版 capability preset 且不删除已有能力", async () => { - const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-token-plan-preset-login"); writeFileSync( join(configDir, "config.json"), @@ -315,72 +247,56 @@ describe("e2e: auth", () => { ) + "\n", ); - try { - const login = await runCommandE2e( - AUTH_ROUTES, - ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "sk-env-must-not-be-persisted", - DASHSCOPE_BASE_URL: validationServer.baseUrl, - }, - ); - expect(login.exitCode, login.stderr).toBe(0); - expect(validationServer.requests).toHaveLength(1); - expect(validationServer.requests[0]).toMatchObject({ - path: "/compatible-mode/v1/chat/completions", - authorization: "Bearer sk-sp-e2e-placeholder", - sourceConfig: expect.any(String), - openApiSource: "BailianCLI", - body: { - model: "qwen3.8-max", - stream: false, - }, - }); + const login = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "sk-env-must-not-be-persisted", + DASHSCOPE_BASE_URL: "https://env-must-not-override-preset.example.test", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); - const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< - string, - unknown - >; - expect(config.api_key).toBeUndefined(); - expect(config.active_config).toBe("token-plan"); - 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.8-max", - 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", - default_speech_model: "qwen-audio-3.0-tts-plus", - default_speech_recognition_model: "qwen-audio-3.0-asr-flash", - api_key_capabilities: [ - "text.chat", - "custom.command", - "vision.describe", - "image.generate", - "image.edit", - "speech.recognize", - "speech.synthesize", - "video.generate", - "video.ref", - "video.task.get", - "video.download", - ], - }); - expect((config["token-plan"] as Record).base_url).not.toBe( - validationServer.baseUrl, - ); - expect((config["token-plan"] as Record).api_key).not.toBe( - "sk-env-must-not-be-persisted", - ); - } finally { - await validationServer.close(); - } + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.api_key).toBeUndefined(); + expect(config.active_config).toBe("token-plan"); + 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.8-max", + 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", + default_speech_model: "qwen-audio-3.0-tts-plus", + default_speech_recognition_model: "qwen-audio-3.0-asr-flash", + api_key_capabilities: [ + "text.chat", + "custom.command", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], + }); + expect((config["token-plan"] as Record).base_url).not.toBe( + "https://env-must-not-override-preset.example.test", + ); + expect((config["token-plan"] as Record).api_key).not.toBe( + "sk-env-must-not-be-persisted", + ); }); test("auth login --config token-plan 为显式空白名单追加 capability preset", async () => { - const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-token-plan-empty-capabilities-login"); writeFileSync( join(configDir, "config.json"), @@ -395,51 +311,46 @@ describe("e2e: auth", () => { ) + "\n", ); - try { - const login = await runCommandE2e( - AUTH_ROUTES, - ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: validationServer.baseUrl, - }, - ); - expect(login.exitCode, login.stderr).toBe(0); - expect(validationServer.requests).toHaveLength(1); + const login = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "https://env-ignored-for-persist.example.test", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); - const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< - string, - unknown - >; - expect(config["token-plan"]).toMatchObject({ - api_key_capabilities: [ - "text.chat", - "vision.describe", - "image.generate", - "image.edit", - "speech.recognize", - "speech.synthesize", - "video.generate", - "video.ref", - "video.task.get", - "video.download", - ], - }); - } finally { - await validationServer.close(); - } + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config["token-plan"]).toMatchObject({ + api_key_capabilities: [ + "text.chat", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], + }); }); test("auth login 未传 --config 时写当前激活 Config", async () => { - const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-active-profile-login"); + const storedBaseUrl = "https://dev-profile.example.test"; writeFileSync( join(configDir, "config.json"), JSON.stringify( { active_config: "dev", - dev: { base_url: validationServer.baseUrl }, + dev: { base_url: storedBaseUrl }, }, null, 2, @@ -451,60 +362,48 @@ describe("e2e: auth", () => { DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "", }; - try { - const activeLogin = await runCommandE2e( - AUTH_ROUTES, - ["auth", "login", "--api-key", "sk-active-placeholder"], - env, - ); - expect(activeLogin.exitCode, activeLogin.stderr).toBe(0); + const activeLogin = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--api-key", "sk-active-placeholder"], + env, + ); + expect(activeLogin.exitCode, activeLogin.stderr).toBe(0); - expect(validationServer.requests).toHaveLength(1); - - const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< - string, - unknown - >; - expect(config.api_key).toBeUndefined(); - expect(config.active_config).toBe("dev"); - expect(config.dev).toMatchObject({ - api_key: "sk-active-placeholder", - base_url: validationServer.baseUrl, - }); - } finally { - await validationServer.close(); - } + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.api_key).toBeUndefined(); + expect(config.active_config).toBe("dev"); + expect(config.dev).toMatchObject({ + api_key: "sk-active-placeholder", + base_url: storedBaseUrl, + }); }); - test("auth login --api-key 验证失败不留下半配置", async () => { - const validationServer = await startValidationServer(400); + test("auth login --api-key 非法 Base URL 失败时不留下半配置", async () => { const configDir = makeE2eOutputDir("auth-api-key-login-failure"); - try { - const login = await runCommandE2e( - AUTH_ROUTES, - [ - "auth", - "login", - "--config", - "failed-profile", - "--api-key", - "sk-invalid", - "--base-url", - validationServer.baseUrl, - ], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); - expect(login.exitCode).not.toBe(0); - expect(login.stderr).toMatch(/invalid key/); - expect(login.stderr).not.toMatch(/API key validation failed|Invalid API key/); - expect(existsSync(join(configDir, "config.json"))).toBe(false); - } finally { - await validationServer.close(); - } + const login = await runCommandE2e( + AUTH_ROUTES, + [ + "auth", + "login", + "--config", + "failed-profile", + "--api-key", + "sk-invalid", + "--base-url", + "ftp://example.com/models", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode).not.toBe(0); + expect(login.stderr).toMatch(/Invalid model base URL/); + expect(existsSync(join(configDir, "config.json"))).toBe(false); }); test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => { @@ -520,7 +419,7 @@ describe("e2e: auth", () => { "120", ]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain("Would validate and save API key."); + expect(stdout).toContain("Would save API key."); }); test("auth login 缺少密钥且 --output json 时报用法错误并退出 (2)", async () => { diff --git a/skills/bailian-cli/reference/auth.md b/skills/bailian-cli/reference/auth.md index 3b9222e..a98d856 100644 --- a/skills/bailian-cli/reference/auth.md +++ b/skills/bailian-cli/reference/auth.md @@ -53,7 +53,7 @@ bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxx | Flag | Type | Required | Description | | ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------- | | `--api-key ` | string | no | Model API key to store | -| `--base-url ` | string | no | Model API base URL (used with --api-key for validation) | +| `--base-url ` | string | no | Model API base URL to store with --api-key | | `--console` | switch | no | Sign in via browser; use --console-site to choose domestic (default) or international | | `--console-site ` | string | no | Console site: domestic, international | | `--open-api` | switch | no | Store Alibaba Cloud OpenAPI AK/SK credentials | diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index 48232d3..c0ebf91 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -68,7 +68,7 @@ After approval, treat Bailian as selected for the current task. Do not ask again Install, API key / console login, endpoint override, and config keys: [`assets/setup.md`](assets/setup.md). -**Token Plan:** Get the API key from the [subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview), then run `bl auth login --config token-plan --api-key `. The built-in Profile supplies the Base URL and its supported leaf API Key capabilities, and login validates the key before saving it. While that Profile is active, unsupported API Key commands automatically use `default` credentials. A per-command `--api-key` / `--base-url` or `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` bypasses this fallback entirely. +**Token Plan:** Get the API key from the [subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview), then run `bl auth login --config token-plan --api-key `. The built-in Profile supplies the Base URL and its supported leaf API Key capabilities, and login saves the key without a live model probe. While that Profile is active, unsupported API Key commands automatically use `default` credentials. A per-command `--api-key` / `--base-url` or `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` bypasses this fallback entirely. **Console login:** never run bare `bl auth login --console` — always pass `--console-site domestic` or `--console-site international`. Before login, run `bl config show --output json` and follow the site-selection rules in [`assets/setup.md` → Console site selection](assets/setup.md#console-site-selection). diff --git a/skills/bailian-protocol/assets/setup.md b/skills/bailian-protocol/assets/setup.md index 79d3567..8c97458 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -82,9 +82,9 @@ bl image generate --prompt "A cat" bl video generate --prompt "A horse running through a field" ``` -The built-in Profile supplies the Token Plan Base URL. `auth login` tests the key first, then saves -and activates the Profile only when validation succeeds; do not ask the user to configure the Base -URL or run a duplicate smoke test. +The built-in Profile supplies the Token Plan Base URL. `auth login` saves the key and activates +the Profile without a live model probe; do not ask the user to configure the Base URL or run a +duplicate smoke test. ### API Key capability fallback @@ -120,7 +120,7 @@ bl config use --name default ``` `auth login --config token-plan` creates or updates that Profile and activates it only after the -credential is validated and saved. Failed login and `--dry-run` do not switch Profiles. Use +credential is saved. Failed login and `--dry-run` do not switch Profiles. Use `--config default` for a one-command override. Config selection follows explicit `--config` > persisted `active_config` > `default`; credential and endpoint fields inside the selected Profile still follow flag > environment > config. From 8a7674e7c3b8608b8d7433140eb73cfbd7787645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 28 Aug 2026 15:37:15 +0800 Subject: [PATCH 13/13] chore(release): prepare 1.18.1 --- CHANGELOG.md | 6 ++++++ CHANGELOG.zh.md | 6 ++++++ packages/cli/package.json | 2 +- packages/commands/package.json | 2 +- packages/core/package.json | 2 +- packages/kscli/package.json | 2 +- packages/runtime/package.json | 2 +- skills/bailian-cli/SKILL.md | 2 +- skills/bailian-finetune/SKILL.md | 2 +- skills/bailian-gen/SKILL.md | 2 +- skills/bailian-managed-agent/SKILL.md | 2 +- skills/bailian-protocol/SKILL.md | 2 +- skills/bailian-web-search/SKILL.md | 2 +- 13 files changed, 23 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 675dce9..e20bd8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md) +## [1.18.1] - 2026-08-28 + +### Removed + +- Removed API Key validation from `bl auth login`. + ## [1.18.0] - 2026-08-27 ### Added diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 5731a3c..ef2dfc1 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -6,6 +6,12 @@ [English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md) +## [1.18.1] - 2026-08-28 + +### 已移除 + +- 移除 `bl auth login` 的 API Key 校验。 + ## [1.18.0] - 2026-08-27 ### 新增 diff --git a/packages/cli/package.json b/packages/cli/package.json index 6c716ec..2270d1c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli", - "version": "1.18.0", + "version": "1.18.1", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", diff --git a/packages/commands/package.json b/packages/commands/package.json index 88eefb5..2fa34ce 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-commands", - "version": "1.18.0", + "version": "1.18.1", "description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/core/package.json b/packages/core/package.json index 8a83e2b..6c7706e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-core", - "version": "1.18.0", + "version": "1.18.1", "description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/kscli/package.json b/packages/kscli/package.json index 2f2dc9b..f6cd49f 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-studio-cli", - "version": "1.18.0", + "version": "1.18.1", "description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.", "keywords": [ "alibaba-cloud", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 663d26f..7da44e1 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-runtime", - "version": "1.18.0", + "version": "1.18.1", "description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 06e51c9..2673ebd 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-cli metadata: - version: "1.18.0" + version: "1.18.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index 45696dc..f56533a 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-finetune metadata: - version: "1.18.0" + version: "1.18.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index 462f358..45f3dc3 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-gen metadata: - version: "1.18.0" + version: "1.18.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index 37e72a1..16168ca 100644 --- a/skills/bailian-managed-agent/SKILL.md +++ b/skills/bailian-managed-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-managed-agent metadata: - version: "1.18.0" + version: "1.18.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index c0ebf91..9a3d122 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-protocol metadata: - version: "1.18.0" + version: "1.18.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-web-search/SKILL.md b/skills/bailian-web-search/SKILL.md index 87f45de..28dbeda 100644 --- a/skills/bailian-web-search/SKILL.md +++ b/skills/bailian-web-search/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-web-search metadata: - version: "1.18.0" + version: "1.18.1" requires: bins: ["bl"] description: >-