diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index b11a1f91e..50b524b7f 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;preset 只在 API Key 登录验证成功后物化写入,升级 preset 需要重新登录 + - 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 cfae07704..5449e1ab0 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 a9f980a63..57cb2b271 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 ff1d20c71..c8d688705 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 b9b00dbda..3267f6da5 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。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()`,避免绕过激活状态。 - 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 字段时不启用 fallback;重新登录后使用并持久化当前 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 b2c959209..c47d07310 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 0ee79bee5..791e1bbff 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 698c1b398..c91d5ee77 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 91204ed11..bb3af2859 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 6c31780db..9000a8361 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 5aa9e1dcc..7a4a0dc83 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 d849901ed..66865d4e5 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 = { @@ -73,6 +77,7 @@ export const UI_MODEL_DEFAULTS: Record = { default_image_to_video_model: "wan3.0-video", default_reference_to_video_model: "wan3.0-video", default_speech_model: "cosyvoice-v3-flash", + default_speech_recognition_model: "fun-asr", default_omni_model: "qwen3.5-omni-plus", }; @@ -117,7 +122,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" }, @@ -140,7 +149,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", }; @@ -153,7 +164,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)) { @@ -190,6 +201,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; } @@ -199,7 +243,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 0b7c27a42..7d23af462 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 8e86d9246..082f05e7f 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 b67332b7a..ff1b4d2ed 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 4924e07f6..b6a5d1786 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 000000000..2c30e41e1 --- /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 0e489927b..dfccaa727 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 3e60d3e93..b97346e20 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 877170fd9..cbf740265 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 2805f1653..bd0f2780d 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 4881bca0d..1a59a3539 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 914abce5b..9ac4d8617 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 4dfafa38a..785e2c1a4 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 029905bce..422b7bbf7 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 ef1492553..096f9913e 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -195,6 +195,45 @@ 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. 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, +): 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; + 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 +278,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 9d8fc0d15..adfa11349 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 2196bb73b..0b945c6a0 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 1aa52c417..9d5ce1b58 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 a907a2ba2..a49ae24ad 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,6 +15,7 @@ import { readConfigProfiles, activateConfigProfile, deleteConfigProfile, + selectApiKeyResolutionSources, } from "../src/config/loader.ts"; import { getConfigPath } from "../src/config/paths.ts"; @@ -364,3 +366,116 @@ 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 字段时不启用 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 selected = selectApiKeyResolutionSources(selectedSources, "search.web"); + expect(selected.fallbackFrom).toBeUndefined(); + expect(selected.sources.file.api_key).toBe("sk-token-plan"); + }); +}); + +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 fallback = selectApiKeyResolutionSources(selectedSources, "video.generate"); + 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 806e317af..85e87b315 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 79e47d4e8..a098ebb81 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 be75345f6..6b8f65236 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 c1579a9b5..5322479ac 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -14,14 +14,17 @@ import type { } from "bailian-cli-core"; import { Client, + DEFAULT_LANGUAGE, 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 +62,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 +125,23 @@ export const authStage: Middleware = async (ctx, next) => { baseUrl: resolveModelBaseUrl(sources), }; if (command.auth === "apiKey") { + const capability = ctx.path.join("."); + const selection = selectApiKeyResolutionSources(sources, capability); + 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 000000000..7b62fe712 --- /dev/null +++ b/packages/runtime/tests/auth-profile-fallback.test.ts @@ -0,0 +1,349 @@ +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 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", + }); +}); + +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 a12865760..598c7facc 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 44de377da..2c3f6969d 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/SKILL.md b/skills/bailian-gen/SKILL.md index 4b7b78057..5aaa87bc8 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. diff --git a/skills/bailian-gen/reference/index.md b/skills/bailian-gen/reference/index.md index a85ecb331..39934d726 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 (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v) | [video.md](video.md) | diff --git a/skills/bailian-gen/reference/speech.md b/skills/bailian-gen/reference/speech.md index 81e8fecdb..f6fd39aa6 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 541a84881..d62652f8f 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 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 4cf942d1b..79d35673f 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -86,6 +86,31 @@ 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. + +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 \ + --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,9 +125,11 @@ 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: +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` @@ -110,6 +137,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`.