diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b14ba8..e20bd8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ 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 + +- **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..ef2dfc1 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -6,6 +6,26 @@ [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 + +### 新增 + +- **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/INSTALL.md b/INSTALL.md index 83662aa..6a8f6c8 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 -v` / `npm -v` 成功,**且** `git --version` 成功。两个条件都成立时,再回退到: ```bash -bl skill add --name bailian-protocol,bailian-gen +npx skills@1.5.18 add modelstudioai/cli --all -g ``` -安装成功后,用中文简要说明已安装的 skills 及用户可做什么。 +> 必须使用 `skills@1.5.18`:这是最后兼容 Node ≥18 的版本(与 Bailian CLI 的 Node ≥18.17.0 一致); + +安装成功后,按第 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 且有 Git 时降级 `npx skills@1.5.18 add modelstudioai/cli --all -g`;否则重试或告知用户 | diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index b11a1f9..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` +- `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,8 +44,12 @@ 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 > 默认值 -- 显式 `auth login --config ` 在凭证验证并落盘成功后自动激活目标 Profile;未传 +- `--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 > 默认 - `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/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md index 554dbda..c028a9f 100644 --- a/docs/agents/cli-e2e-tests.md +++ b/docs/agents/cli-e2e-tests.md @@ -101,8 +101,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..116e087 100644 --- a/docs/agents/config-profile-change.md +++ b/docs/agents/config-profile-change.md @@ -18,15 +18,19 @@ - 解析阶段用局部变量保留“是否显式传入 `--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 和失败登录均不修改该白名单。 +- Capability ID 直接使用产品实际叶子命令路径并以 `.` 连接(例如 `video task get` → `video.task.get`);不新增命令元数据。新增或改名后的 API Key 路由未进入白名单时自然 fail closed。 - Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。 - Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。 ## 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` 只清理指定项。 @@ -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 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/package.json b/packages/cli/package.json index adf4ef6..2270d1c 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.1", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", diff --git a/packages/cli/tests/e2e/command-packs.e2e.test.ts b/packages/cli/tests/e2e/command-packs.e2e.test.ts index f2e740b..e709ea2 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", () => { @@ -72,6 +84,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 04a0488..19a9fd6 100644 --- a/packages/cli/tests/fixtures/command-pack/commands.mjs +++ b/packages/cli/tests/fixtures/command-pack/commands.mjs @@ -44,7 +44,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/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/package.json b/packages/commands/package.json index a1943e1..2fa34ce 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.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/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts index 91204ed..f31e993 100644 --- a/packages/commands/src/commands/auth/login-api-key.ts +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -1,91 +1,35 @@ -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; defaultImageToVideoModel?: string; defaultReferenceToVideoModel?: string; defaultImageModel?: string; + defaultSpeechModel?: string; + defaultSpeechRecognitionModel?: string; + apiKeyCapabilities?: readonly string[]; 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, @@ -95,5 +39,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-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 6c31780..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,21 +164,27 @@ 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 storedBaseUrl = store.stored().baseUrl; - const resolvedBaseUrl = baseUrl || store.resolveBaseUrl(profilePreset?.baseUrl); + const stored = store.stored(); + const storedBaseUrl = stored.baseUrl; const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined); - await validateAndPersistApiKey(deps, key, { - baseUrl: resolvedBaseUrl, + const apiKeyCapabilities = profilePreset + ? [...new Set([...(stored.apiKeyCapabilities ?? []), ...profilePreset.apiKeyCapabilities])] + : stored.apiKeyCapabilities; + await persistApiKey(deps, key, { persistBaseUrl, defaultTextModel: profilePreset?.defaultTextModel, defaultVideoModel: profilePreset?.defaultVideoModel, defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel, defaultReferenceToVideoModel: profilePreset?.defaultReferenceToVideoModel, defaultImageModel: profilePreset?.defaultImageModel, + defaultSpeechModel: profilePreset?.defaultSpeechModel, + defaultSpeechRecognitionModel: profilePreset?.defaultSpeechRecognitionModel, + apiKeyCapabilities, }); + process.stderr.write(`API key saved to ${store.path}\n`); }, }); 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..66865d4 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,14 +66,18 @@ 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 = { 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_speech_recognition_model: "fun-asr", default_omni_model: "qwen3.5-omni-plus", }; @@ -100,12 +107,26 @@ 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" }, - { 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 +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", }; @@ -141,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)) { @@ -178,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; } @@ -187,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 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/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/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..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,90 +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", - }); - } 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 物化并重置内置预设", async () => { - const validationServer = await startValidationServer(); + test("auth login --config token-plan 追加新版 capability preset 且不删除已有能力", async () => { const configDir = makeE2eOutputDir("auth-token-plan-preset-login"); writeFileSync( join(configDir, "config.json"), @@ -293,6 +239,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, @@ -300,64 +247,110 @@ 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", - }); - 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 configDir = makeE2eOutputDir("auth-token-plan-empty-capabilities-login"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify( + { + "token-plan": { + api_key_capabilities: [], + }, + }, + null, + 2, + ) + "\n", + ); + + 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", + ], + }); }); 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, @@ -369,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 () => { @@ -438,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/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/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..97d7337 100644 --- a/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts @@ -114,46 +114,27 @@ 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[]. + // 用普通 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?: { @@ -168,6 +149,98 @@ describe("e2e: video generate (i2v)", () => { expect(data.request?.input?.img_url).toBeUndefined(); } }); + + test("video generate --dry-run --file 走 media file 字段", async () => { + 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?: { + 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 { 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 { 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 { 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, + ); + }); }); describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( @@ -179,7 +252,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "generate", ...cliTimeoutPrefix(), "--model", - "happyhorse-1.1-i2v", + "wan3.0-video", "--image", "https://example.com/placeholder.png", ]); @@ -194,7 +267,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( ...cliTimeoutPrefix(), "--dry-run", "--model", - "happyhorse-1.1-t2v", + "wan3.0-video", "--prompt", "干跑无图", "--output", @@ -208,7 +281,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 +307,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..559bfbc 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,125 @@ 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/package.json b/packages/core/package.json index 497396a..6c7706e 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.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/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..096f991 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 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/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/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..a49ae24 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 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/kscli/package.json b/packages/kscli/package.json index d024860..f6cd49f 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.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 a8ba716..7da44e1 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.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/packages/runtime/src/command-packs/validate.ts b/packages/runtime/src/command-packs/validate.ts index f6c0db9..907f54c 100644 --- a/packages/runtime/src/command-packs/validate.ts +++ b/packages/runtime/src/command-packs/validate.ts @@ -175,7 +175,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 767e0fc..1cf98e3 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -235,7 +235,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 07f0d53..eed4da4 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -15,14 +15,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, @@ -65,6 +68,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) => { @@ -91,13 +131,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/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/packages/runtime/tests/auth-profile-fallback.test.ts b/packages/runtime/tests/auth-profile-fallback.test.ts new file mode 100644 index 0000000..7b62fe7 --- /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 a217e8c..1d023a4 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -27,6 +27,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/SKILL.md b/skills/bailian-cli/SKILL.md index 17761a2..d05b72b 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.1" requires: bins: ["bl"] description: >- 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-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-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index b1e0e65..76aee9b 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.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index 24f99cb..45f3dc3 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.1" requires: bins: ["bl"] description: >- @@ -33,14 +33,16 @@ 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 | | 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 85cab64..39934d7 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 | [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/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-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 diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index ce90163..a9a963f 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.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index c0c3b1a..7fd71a4 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.1" requires: bins: ["bl"] description: >- @@ -77,7 +77,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 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 4cf942d..8c97458 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -82,9 +82,34 @@ 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 + +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: @@ -95,14 +120,16 @@ 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. -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`. diff --git a/skills/bailian-web-search/SKILL.md b/skills/bailian-web-search/SKILL.md index 368c50f..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.17.1" + version: "1.18.1" requires: bins: ["bl"] description: >-