mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fac2b2d18b | |||
| 3e249279bc | |||
| f9012a6330 | |||
| 92ee845bdd | |||
| 678f60be75 | |||
| d11b55b956 | |||
| e4e3f069e1 | |||
| 440cbfe6ae | |||
| 1adfe797bd | |||
| d04012b0a4 | |||
| 81539005cc | |||
| 4c566fd60e | |||
| 853ce3caae | |||
| 3b779a708d | |||
| b4a2a1c42d | |||
| 4ca3e2de80 | |||
| 1f91fa42fa | |||
| 39f12e1a78 | |||
| a853319dd0 | |||
| bc162f4678 | |||
| 12ecac4d96 | |||
| 52f6e267e6 | |||
| c9e5913034 | |||
| ba062c1a71 | |||
| a03ba673be | |||
| 8196f67300 | |||
| 4b504a1a52 | |||
| 00b4d095ed | |||
| 92719a1a21 | |||
| 310e6ead33 | |||
| ece0c8dd1c | |||
| 39f92567d3 | |||
| 8b91b9f35f | |||
| 1fbafa8b1d | |||
| 052960e269 | |||
| a8f45e93af | |||
| 84805f287c | |||
| 196b2a1f51 | |||
| de9f1a3889 | |||
| 75a45e1a2a | |||
| 64ff057fe5 | |||
| 88fcda62aa | |||
| 68336d41a2 | |||
| e246cb96d4 | |||
| 569057f4d0 | |||
| f7dbafc6d2 | |||
| f45d0e3a71 | |||
| 6d588a57b4 | |||
| 52aab33a3b | |||
| cc23b4be37 | |||
| 91cd68b46e | |||
| 054d4deb26 | |||
| cbf871c8d6 | |||
| 20b67a6628 | |||
| 0956695f28 | |||
| 96fcb1892c | |||
| 84383f1c83 | |||
| fc8351f136 | |||
| de78a281c6 | |||
| 86aec0b723 | |||
| 06f932c1bd | |||
| 1c5d1a8fcd | |||
| 6388fd11e2 | |||
| eca5403e71 | |||
| 153e176c13 | |||
| 16893b3f00 | |||
| 78977dbe4e | |||
| 7dd5431a22 | |||
| 63edde3588 | |||
| e932495560 | |||
| 2ae76e2013 | |||
| f3b85914ab | |||
| 8b99617716 | |||
| b2ca78512f | |||
| 7004e58866 | |||
| 97e47f7054 | |||
| e0f3d450ae | |||
| d37f4c07eb | |||
| ac4dbb9e88 | |||
| 155c9dc883 | |||
| 0e857775fe | |||
| 1a63fcdb5c | |||
| 58ab622e11 | |||
| e1532bf35c | |||
| 6e095a6ce5 | |||
| 94adb919b1 | |||
| a0b4666940 | |||
| e8666b33ae | |||
| d646a4e780 | |||
| 91a7106bf4 | |||
| 03405d5cc1 | |||
| 1870500f97 | |||
| 3fb0c7211c | |||
| 7b77bf6a6d | |||
| 6e5ecf8923 | |||
| 301986b669 | |||
| 0d420b6e5a | |||
| 9602209113 | |||
| 19b7aacb2e | |||
| 049eecd991 | |||
| 2907ad2625 | |||
| 0f23527bfc | |||
| 8e3f8586b0 | |||
| efb5243d0d | |||
| f66889c939 | |||
| 068ec0bfd3 | |||
| d8aa89dc6c | |||
| 8fd072bcd1 |
@@ -39,7 +39,7 @@ body:
|
||||
attributes:
|
||||
label: Node version
|
||||
description: "Output of node --version"
|
||||
placeholder: "v22.12.0"
|
||||
placeholder: "v18.17.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Regenerate skill reference + SKILL metadata (needs bailian-cli-core dist).
|
||||
# Regenerate skill reference + SKILL metadata from source (no package build).
|
||||
pnpm run sync:skill-assets
|
||||
|
||||
# Stage generator output so it is included in this commit.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
monorepo 现在按"纯逻辑 → 运行时框架 → 命令库 → 产品入口"分层:
|
||||
|
||||
- `packages/core` — `bailian-cli-core`,纯逻辑层:鉴权、配置、HTTP client、错误、类型、文件工具
|
||||
- `packages/runtime` — `bailian-cli-runtime`,通用 CLI 运行时:`createCli`、参数解析、registry/help、middleware、error handler、输出、pipeline
|
||||
- `packages/runtime` — `bailian-cli-runtime`,通用 CLI 运行时:`createCli`、参数解析、registry/help、middleware、error handler、输出、pipeline、Command Pack host
|
||||
- `packages/commands` — `bailian-cli-commands`,可复用命令实现库,只导出 command,不决定产品路径
|
||||
- `packages/cli` — `bailian-cli`,完整 `bl` 产品入口;`src/commands.ts` 组装 `bl` 暴露的命令路径
|
||||
- `packages/kscli` — `knowledge-studio-cli`,Knowledge Studio 专用入口;`src/main.ts` 复用 commands 并重映射为 `kscli` 路径
|
||||
@@ -17,14 +17,16 @@ monorepo 现在按"纯逻辑 → 运行时框架 → 命令库 → 产品入口"
|
||||
```
|
||||
packages/cli/src/main.ts # bl 入口,注入 binName/version/clientName/npmPackage
|
||||
packages/cli/src/commands.ts # bl 产品命令 map,tools/generate-reference.ts 也读它
|
||||
packages/cli/src/command-pack-policy.ts # bl 的 Command Pack policy
|
||||
packages/kscli/src/main.ts # kscli 入口和命令 map
|
||||
|
||||
packages/commands/src/index.ts # re-export 单个命令实现
|
||||
packages/commands/src/commands/ # defineCommand({ auth, flags, usageArgs, exampleArgs, run })
|
||||
|
||||
packages/runtime/src/create-cli.ts # createCli(commands, identity)
|
||||
packages/runtime/src/create-cli.ts # createCli(commands, options)
|
||||
packages/runtime/src/registry.ts # 命令树解析 + 动态 help
|
||||
packages/runtime/src/middleware.ts # auth / telemetry / update / run command
|
||||
packages/runtime/src/command-packs/ # 通用 Command Pack 加载、校验、隔离安装目录和管理命令
|
||||
packages/runtime/src/urls.ts # 用户面控制台 URL
|
||||
|
||||
packages/core/src/types/command.ts # Command / flags / auth 类型
|
||||
@@ -54,20 +56,23 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/
|
||||
|
||||
按当前任务从下表挑一条进入对应文档:
|
||||
|
||||
| 场景 | 何时进入 | 详见 |
|
||||
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
|
||||
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
|
||||
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
|
||||
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
|
||||
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
|
||||
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
|
||||
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
|
||||
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
|
||||
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
|
||||
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
|
||||
| 场景 | 何时进入 | 详见 |
|
||||
| -------------- | -------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
|
||||
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
|
||||
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
|
||||
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
|
||||
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
|
||||
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
|
||||
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
|
||||
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
|
||||
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
|
||||
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
|
||||
| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) |
|
||||
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
|
||||
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
|
||||
|
||||
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
|
||||
|
||||
@@ -104,6 +109,17 @@ CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的
|
||||
|
||||
如果命令调用 Console Gateway,`defineCommand` 必须设置 `auth: "console"`。runtime 会基于 `CONSOLE_AUTH_FLAGS` 自动在 help 中展示 `--console-region`、`--console-site`、`--console-switch-agent`、`--workspace-id`,并由 `authStage` 解析/注入 console credential。命令不要重复声明这些凭证域 flag,也不要手动从 env/config 解析 token。
|
||||
|
||||
### 5. 禁止单字母变量命名
|
||||
|
||||
所有变量、参数、回调形参必须使用有语义的命名,不允许单字母(如 `i`、`m`、`p`、`t`、`e`、`s`)。具体表现:
|
||||
|
||||
- 回调参数: `.map((m) => ...)` → `.map((model) => ...)`, `.find((t) => ...)` → `.find((template) => ...)`
|
||||
- catch 变量: `catch (e)` → `catch (error)`
|
||||
- for-of 循环: `for (const i of items)` → `for (const item of items)`
|
||||
- 临时变量: `const s = ...` → `const strategy = ...`
|
||||
|
||||
例外: 仅当作用域极小(≤3 行)且语义从上下文完全明确时,可使用 `k`/`v`(Object.entries 的 key/value)。
|
||||
|
||||
## 完成改动后的快速验证
|
||||
|
||||
```sh
|
||||
|
||||
@@ -6,6 +6,97 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Token Plan defaults now use the current text, image, and dedicated text-to-video, image-to-video, and reference-to-video models.
|
||||
- The Bailian CLI Skill now distinguishes Bailian-specific tasks from ordinary host-agent work more accurately and avoids repeated consent prompts within an approved workflow.
|
||||
- Published CLI packages now support Node.js 18.17 and later, lowering the previous minimum requirement from Node.js 22.12.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Token Plan now handles local images correctly for image editing, image-to-video, reference-to-video, and vision understanding without requiring a separately hosted URL.
|
||||
|
||||
## [1.10.0] - 2026-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl config agent`** — configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope in one command.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Bailian CLI Skill now routes only matching Bailian and multimodal tasks to `bl`, and asks for consent before provider-neutral remote or billable calls.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Full `bl auth logout` now clears the model Base URL so later logins cannot inherit a stale custom or Token Plan endpoint.
|
||||
|
||||
## [1.9.0] - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Token Plan support** — log in and call supported models directly without manually configuring the endpoint.
|
||||
- **Named Config Profiles** — create, switch, and manage isolated configurations; logging in to a named Profile activates it automatically.
|
||||
- **Console Access Token automation** — generate and automatically refresh Console Access Tokens.
|
||||
- **`bl workspace init`** — initialize a Bailian workspace and activate the required services in one workflow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved configuration safety and consistency, including secret masking and preservation of custom configuration fields.
|
||||
|
||||
## [1.8.3] - 2026-07-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `bl text chat --messages-file -` failing on Windows by treating standard input as a `/dev/stdin` file path; piped JSON messages are now read from standard input correctly. (#103)
|
||||
|
||||
## [1.8.2] - 2026-07-15
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl model list` now defaults to JSON output; pass `--output text` for the table view.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `bl model list --enrich` now returns each model's input parameter schema (predictConfig); it was previously always empty because the console gateway response envelope was not unwrapped.
|
||||
|
||||
## [1.8.1] - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Expanded the Command Pack allowlist to accept an additional internal command extension.
|
||||
|
||||
## [1.8.0] - 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl model list`** — browse the Bailian model marketplace: list model families or show full details for a single family (`--model`), with filters for provider, capability, feature, and context-window, pagination (`--page` / `--page-size`), pricing, and `--enrich` for richer metadata.
|
||||
- **`bl usage summary`** — a unified usage view combining free-tier quota and a recent usage overview; `--days` sets the overview window (default 7).
|
||||
- **Command Pack host support** — added support for allowlisted internal command extensions.
|
||||
- **Audio & image fine-tuning** — `bl finetune audio create` (CosyVoice TTS) and `bl finetune image create` (Wan image generation) join the existing text flow. `bl finetune image create` supports `--generation-type t2i|i2i` to select text-to-image or image-to-image training.
|
||||
- **Audio & image deployment** — `bl deploy audio create` and `bl deploy image create` deploy fine-tuned TTS and image models as endpoints.
|
||||
- **Multimodal dataset validation** — `bl dataset upload` and `bl dataset validate` now accept `.zip` archives with `tts` and `image` schemas, validate referenced media files, and allow image archives up to 1 GB.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fine-tune and deploy commands are now split by modality (BREAKING)**: `bl finetune create` → `bl finetune text create`, and `bl deploy create` → `bl deploy text create`. Update any scripts that use the old paths.
|
||||
- **Deployment option renamed (BREAKING)**: `--template-id` → `--deploy-spec` on deployment creation commands.
|
||||
- **Fine-tune status exit behavior changed (BREAKING)**: `bl finetune watch` no longer reserves exit code 3 for running jobs. Running and succeeded jobs return 0; failed and canceled jobs use normal CLI errors.
|
||||
- `bl deploy audio create` now defaults to `--plan mu` (model-unit billing, per the CosyVoice deployment contract); text and image continue to default to `lora`.
|
||||
- `bl finetune audio create` now validates CosyVoice training data: audio files must be `.wav`, each `wav_fn` must start with `train/`, and exactly one training file is accepted.
|
||||
- `bl quota list` and `bl quota check` now report real RPM/TPM usage against limits, adding `RPM Left` / `TPM Left` columns with remaining-quota progress bars sourced from monitoring data.
|
||||
- `bl usage free` output now shares its rendering with `bl usage summary` for consistent free-tier tables.
|
||||
- `bl advisor recommend` no longer depends on a dedicated intent-detection model to analyze your request.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Removed the `tongyi-intent-detect-v3` integration (BREAKING)** used by `bl advisor recommend`, along with the `intent_detect_base_url` config field and the `DASHSCOPE_INTENT_DETECT_BASE_URL` environment variable.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Skill command-reference generation now reads product command maps directly from source and produces stable formatting during release checks.
|
||||
|
||||
## [1.7.0] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,6 +6,97 @@
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### 变更
|
||||
|
||||
- Token Plan 默认模型已更新为当前文本、图片,以及文生视频、图生视频和参考生视频的专用模型。
|
||||
- 百炼 CLI Skill 现在能更准确地区分百炼专属任务与普通宿主 Agent 任务,并避免在已授权的工作流中重复征求同意。
|
||||
- 已发布的 CLI 包现在支持 Node.js 18.17 及以上版本,最低版本要求由 Node.js 22.12 下调至 18.17。
|
||||
|
||||
### 修复
|
||||
|
||||
- Token Plan 现在能在图片编辑、图生视频、参考生视频和视觉理解中正确处理本地图片,无需另行托管为 URL。
|
||||
|
||||
## [1.10.0] - 2026-07-19
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl config agent`** —— 一键配置 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 和 Codex 接入百炼模型服务。
|
||||
|
||||
### 变更
|
||||
|
||||
- 百炼 CLI Skill 现在只将匹配的百炼任务与多模态任务路由到 `bl`,并会在调用与平台无关的远程或计费能力前征求同意。
|
||||
|
||||
### 修复
|
||||
|
||||
- 完整执行 `bl auth logout` 时会同时清除模型 Base URL,避免后续登录继承失效的自定义或 Token Plan 接入地址。
|
||||
|
||||
## [1.9.0] - 2026-07-17
|
||||
|
||||
### 新增
|
||||
|
||||
- **支持 Token Plan** —— 登录后即可直接调用支持的模型,无需手动配置接入地址。
|
||||
- **命名 Config Profile** —— 支持创建、切换和管理相互隔离的配置,登录后会自动激活当前 Profile。
|
||||
- **Console Access Token 自动化** —— 支持生成并自动刷新 Console Access Token。
|
||||
- **`bl workspace init`** —— 一站式完成百炼工作空间初始化和所需服务开通。
|
||||
|
||||
### 修复
|
||||
|
||||
- 提升配置安全性与一致性,包括密钥脱敏和自定义配置字段保留。
|
||||
|
||||
## [1.8.3] - 2026-07-16
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 Windows 上 `bl text chat --messages-file -` 将标准输入当作 `/dev/stdin` 文件路径读取的问题;通过管道传入的 JSON 消息现在可以从标准输入正常读取。(#103)
|
||||
|
||||
## [1.8.2] - 2026-07-15
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl model list` 现在默认以 JSON 输出;需要表格视图请传 `--output text`。
|
||||
|
||||
### 修复
|
||||
|
||||
- `bl model list --enrich` 现在能正确返回每个模型的输入参数 schema(predictConfig);此前因未解包控制台网关响应信封而始终为空。
|
||||
|
||||
## [1.8.1] - 2026-07-14
|
||||
|
||||
### 变更
|
||||
|
||||
- 扩展 Command Pack 白名单,允许加载额外的内部命令扩展。
|
||||
|
||||
## [1.8.0] - 2026-07-13
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl model list`** —— 浏览百炼模型市场:列出模型家族,或用 `--model` 查看单个家族的完整详情;支持按 provider、能力、特性、上下文窗口过滤,分页(`--page` / `--page-size`)、价格展示,以及 `--enrich` 获取更丰富的元数据。
|
||||
- **`bl usage summary`** —— 统一用量视图,一屏合并免费额度与近期用量概览;`--days` 设置概览时间窗口(默认 7 天)。
|
||||
- **Command Pack 宿主支持** —— 新增面向白名单内部命令扩展包的加载能力。
|
||||
- **音频与图像精调** —— 在原有文本流程之外新增 `bl finetune audio create`(CosyVoice 语音合成)与 `bl finetune image create`(万相图像生成)。`bl finetune image create` 支持 `--generation-type t2i|i2i` 显式选择文生图或图生图训练。
|
||||
- **音频与图像部署** —— `bl deploy audio create` 与 `bl deploy image create` 可将精调后的语音合成与图像模型部署为推理接入点。
|
||||
- **多模态数据集校验** —— `bl dataset upload` 与 `bl dataset validate` 现在支持使用 `tts`、`image` schema 的 `.zip` 压缩包,可校验包内引用的媒体文件,图像数据压缩包上限提升至 1 GB。
|
||||
|
||||
### 变更
|
||||
|
||||
- **精调与部署命令按模态拆分(BREAKING)**:`bl finetune create` → `bl finetune text create`,`bl deploy create` → `bl deploy text create`。请更新使用旧路径的脚本。
|
||||
- **部署参数重命名(BREAKING)**:部署创建命令的 `--template-id` 更名为 `--deploy-spec`。
|
||||
- **精调状态退出行为变更(BREAKING)**:`bl finetune watch` 不再使用退出码 3 表示任务运行中;运行中与成功均返回 0,失败与取消使用 CLI 的常规错误流程。
|
||||
- `bl deploy audio create` 默认使用 `--plan mu`(按模型单元计费,符合 CosyVoice 部署契约);文本与图像仍默认 `lora`。
|
||||
- `bl finetune audio create` 现在会校验 CosyVoice 训练数据:音频必须为 `.wav`,每条 `wav_fn` 必须以 `train/` 开头,且只接受一个训练文件。
|
||||
- `bl quota list` 与 `bl quota check` 现在会基于监控数据展示真实的 RPM/TPM 用量与限额,新增 `RPM Left` / `TPM Left` 列及剩余额度进度条。
|
||||
- `bl usage free` 的输出现在与 `bl usage summary` 共用渲染逻辑,免费额度表格更一致。
|
||||
- `bl advisor recommend` 不再依赖独立的意图识别模型来分析你的需求。
|
||||
|
||||
### 已移除
|
||||
|
||||
- **移除 `bl advisor recommend` 使用的 `tongyi-intent-detect-v3` 集成(BREAKING)**,同时移除 `intent_detect_base_url` 配置字段与 `DASHSCOPE_INTENT_DETECT_BASE_URL` 环境变量。
|
||||
|
||||
### 修复
|
||||
|
||||
- Skill 命令参考文档生成现在直接读取产品命令源码,并在发布检查中保持稳定格式。
|
||||
|
||||
## [1.7.0] - 2026-07-09
|
||||
|
||||
### 新增
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js ≥ 22.12
|
||||
- Node.js ≥ 22.12 (required for developing this repo)
|
||||
- End users installing the published CLI only need Node.js >= 18.17
|
||||
- pnpm 10.33.2 (`npm i -g pnpm@10.33.2`)
|
||||
- A DashScope API key for running e2e tests
|
||||
|
||||
|
||||
+3
-2
@@ -6,8 +6,9 @@
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js ≥ 22.12
|
||||
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
|
||||
- 参与本仓库开发:Node.js ≥ 22.12
|
||||
- 仅安装/使用已发布的 CLI:Node.js >= 18.17
|
||||
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
|
||||
- 跑 e2e 需要一个百炼 API Key
|
||||
|
||||
## 仓库结构
|
||||
|
||||
+17
-9
@@ -7,7 +7,7 @@
|
||||
**原则**:先执行检查;若 **Node / 包管理器 / 网络** 任一不满足下文要求,Agent 应 **在本机终端内主动完成安装或配置**(例如升级 Node、切换镜像、配置 `npm` 代理等),再继续 `bailian-cli` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。
|
||||
|
||||
1. **Node.js**
|
||||
- 要求 **≥ 22.12.0**,执行 `node -v` 确认。
|
||||
- 要求 **≥ 18.17.0**,执行 `node -v` 确认。
|
||||
- 未安装或版本不足:由 Agent 自行安装或升级 Node。
|
||||
|
||||
2. **npm**(**唯一**允许的包管理器)
|
||||
@@ -69,7 +69,7 @@ npx skills add modelstudioai/cli --all -g
|
||||
|
||||
> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。
|
||||
|
||||
### 备选:由 Agent 引导用户输入 API Key 后登录
|
||||
### 备选一:由 Agent 引导用户输入普通 API Key 后登录
|
||||
|
||||
适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等):
|
||||
|
||||
@@ -80,6 +80,15 @@ npx skills add modelstudioai/cli --all -g
|
||||
3. 用户提供了 Key 之后,在**用户本机终端**执行(Agent 用终端工具跑,勿把 Key 写进回复正文):`bl auth login --api-key <用户提供的_Key>`
|
||||
4. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整 Key。
|
||||
|
||||
### 备选二:使用 Token Plan API Key
|
||||
|
||||
- 获取入口:[Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview)
|
||||
|
||||
1. 请用户从订阅详情页获取或复制 Token Plan API Key,勿要求用户发到公开渠道。
|
||||
2. 在用户本机终端执行:`bl auth login --config token-plan --api-key <用户提供的_Key>`。
|
||||
3. `token-plan` Profile 已内置默认 Base URL;登录命令会先测试 Key,通过后才保存并激活该 Profile,无需另行配置或重复测试。
|
||||
4. 执行 `bl auth status --config token-plan --output json` 确认;汇报时只使用 masked 字段。
|
||||
|
||||
### 其他方式
|
||||
|
||||
- **环境变量**(不落盘到配置文件):在 shell 中配置 API Key 环境变量;变量名见 `bl auth status --help`,勿在对话中向用户解释底层命名。
|
||||
@@ -89,20 +98,19 @@ npx skills add modelstudioai/cli --all -g
|
||||
### Agent 安全约束
|
||||
|
||||
- **禁止**把真实 API Key 写入仓库、日志、Skill、聊天记录的可公开部分。
|
||||
- CI / 非交互环境:使用 `bl ... --non-interactive`;通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
|
||||
- CI / 非交互环境:显式传入必填参数并使用 `--output json` 获取机器可读结果;如需纯文本输出,设置 `NO_COLOR=1`。通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
|
||||
|
||||
---
|
||||
|
||||
## 4. 最小功能验证
|
||||
## 4. 配置验证
|
||||
|
||||
在鉴权配置完成后执行:
|
||||
API Key 登录命令本身已经完成可用性测试,通过后只需确认配置状态:
|
||||
|
||||
```bash
|
||||
bl auth status --output json
|
||||
bl text chat --message "ping" --non-interactive --output json
|
||||
```
|
||||
|
||||
若失败:根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、`base_url` 等)。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`。
|
||||
无需再执行重复的模型调用测试。若登录失败,根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、`base_url` 等)。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`。
|
||||
|
||||
---
|
||||
|
||||
@@ -111,7 +119,7 @@ bl text chat --message "ping" --non-interactive --output json
|
||||
| 现象 | 可能原因 | 建议动作 |
|
||||
| ----------------------- | -------------------- | --------------------------------------------------------------- |
|
||||
| `bl: command not found` | 全局 bin 不在 PATH | 检查 `npm prefix -g` 与 PATH |
|
||||
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 22.12 |
|
||||
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 引导用户更新 Key 并 `bl auth login --api-key` |
|
||||
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 18.17 |
|
||||
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 按 Key 类型重新执行普通或 Token Plan 登录命令 |
|
||||
| 企业网络无法访问 npm | 代理 / 镜像 | 配置 registry 或代理后再装 |
|
||||
| 本机只有 pnpm、没有 npm | Agent 误用 pnpm 安装 | 先装/修好 **npm**,再用 `npm install -g bailian-cli`;勿用 pnpm |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -30,6 +30,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
|
||||
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
@@ -38,8 +39,8 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
|
||||
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
|
||||
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
|
||||
- **Fine-tuning & deployment** — Upload datasets, create SFT/LoRA/DPO/CPT jobs (`finetune create`), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy create`)
|
||||
- **Console capabilities** — Browse Bailian apps (`app list`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
|
||||
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
## Showcase: One-Sentence Cinematic Video
|
||||
@@ -80,7 +81,7 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -91,6 +92,12 @@ bl auth login --console
|
||||
# Or authenticate with an API key
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Or use Token Plan (Base URL built in; the key is tested during login)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# Configure a coding agent to use DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# Chat with Qwen
|
||||
bl text chat --message "What is DashScope?"
|
||||
|
||||
@@ -114,13 +121,15 @@ bl auth login --console
|
||||
|
||||
# Fine-tune & deploy — a one-shot train-to-serve workflow
|
||||
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
|
||||
bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
|
||||
bl finetune watch --job-id ft-xxx --output json # Non-blocking status probe (exit 0/1/3 = done/failed/running)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
|
||||
bl finetune watch --job-id ft-xxx --output json # Non-blocking probe (running/succeeded return 0; failed/canceled report an error)
|
||||
bl finetune capability --model qwen3-8b # Which training types a model supports
|
||||
bl deploy create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
|
||||
|
||||
# Browse apps / free-tier quota / usage statistics / workspaces
|
||||
# Browse models / apps / free-tier quota / usage statistics / workspaces
|
||||
bl model list # Browse model families and pricing
|
||||
bl app list
|
||||
bl usage summary # Unified view: free-tier quota + recent usage overview
|
||||
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
|
||||
bl workspace list # List all workspaces
|
||||
@@ -157,9 +166,18 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "Hello"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`app list`, `usage free`, `usage stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
@@ -207,6 +225,7 @@ Config file location: `~/.bailian/config.json`
|
||||
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## Changelog
|
||||
|
||||
+28
-9
@@ -5,7 +5,7 @@
|
||||
**阿里云百炼 (DashScope) AI 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -30,6 +30,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
@@ -38,8 +39,8 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
|
||||
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
|
||||
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
|
||||
- **微调与部署** — 上传数据集、创建 SFT/LoRA/DPO/CPT 调优任务(`finetune create`)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy create`)
|
||||
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
|
||||
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
## 示例:一句话生成一部电影短片
|
||||
@@ -78,7 +79,7 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 22.12。
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -89,6 +90,12 @@ bl auth login --console
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 或使用 Token Plan(已内置 Base URL,登录时自动测试 Key)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# 配置 Coding Agent 使用 DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# 和通义千问对话
|
||||
bl text chat --message "你好,介绍一下阿里云百炼平台"
|
||||
|
||||
@@ -112,13 +119,15 @@ bl auth login --console
|
||||
|
||||
# 微调与部署 — 从训练到服务的一站式流程
|
||||
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
|
||||
bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
|
||||
bl finetune watch --job-id ft-xxx --output json # 非阻塞状态探测(退出码 0/1/3 = 成功/失败/进行中)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
|
||||
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0;失败/取消报错)
|
||||
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
|
||||
bl deploy create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
|
||||
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
|
||||
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
|
||||
bl model list # 浏览模型系列与价格信息
|
||||
bl app list
|
||||
bl usage summary # 统一视图:免费额度 + 近期用量概览
|
||||
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
|
||||
bl workspace list # 列出所有业务空间
|
||||
@@ -155,9 +164,18 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "你好"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
|
||||
CLI 已内置 Token Plan 的默认 Base URL;登录命令会先测试 Key,通过后才保存并激活 `token-plan` 配置。
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`app list`、`usage free`、`usage stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
@@ -205,6 +223,7 @@ bl update
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
@@ -37,18 +37,21 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段
|
||||
- `bl auth login --open-api ...` 只更新 `access_key_id` / `access_key_secret`
|
||||
- `bl auth logout --console` 只清 `access_token`
|
||||
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret`
|
||||
- `bl auth logout` 清 `api_key` + `access_token` + `access_key_*`
|
||||
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token`
|
||||
- `bl auth logout` 清 `api_key` + `base_url` + `access_token` + `access_key_*`
|
||||
|
||||
解析分工:
|
||||
|
||||
- `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`
|
||||
- `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 <name>` 在凭证验证并落盘成功后自动激活目标 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_*`,新写入只写短字段
|
||||
- `describeAuthState()` — `auth status` / banner / telemetry 使用的只读快照
|
||||
|
||||
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore()` / `ctx.configStore()` 的窄接口操作落盘。
|
||||
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。
|
||||
|
||||
## 必查清单
|
||||
|
||||
@@ -87,7 +90,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
|
||||
- [ ] `packages/commands/src/commands/auth/login.ts`:
|
||||
- 新增/调整登录 flag 与流程
|
||||
- 持久化只走 `ctx.authStore().login(...)`
|
||||
- 持久化只走 `ctx.authStore.login(...)`
|
||||
- [ ] `packages/commands/src/commands/auth/status.ts`:
|
||||
- 分别显示 model / console / openapi 鉴权状态,并 mask token
|
||||
- [ ] `packages/commands/src/commands/auth/logout.ts`:
|
||||
|
||||
@@ -1,31 +1,58 @@
|
||||
# CLI E2E 测试规范
|
||||
|
||||
## 架构分层
|
||||
|
||||
| 层级 | 路径 | 测什么 |
|
||||
| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup(`private`,不发布) |
|
||||
| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、live(gated);每用例最小路由 |
|
||||
| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path `--help`、分组 help、根 help |
|
||||
| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | 从 `kscli/src/commands.ts` 推导 path/分组;identity(`--version`、`search --help` path) |
|
||||
| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 |
|
||||
|
||||
**依赖边界**:`e2e` → `core`;`commands/tests` → `e2e` + `commands/src`;产品 tests → `e2e` + 各自 `src`。**禁止**产品 import `commands/tests/**`(子进程 spawn harness 路径除外)。
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 新增/修改 `packages/commands/src/commands` 下的 command 实现
|
||||
- 新增/修改 `packages/cli/src/commands.ts` 的 `bl` 命令路径 map
|
||||
- 新建或扩展 `packages/cli/tests/e2e/*.e2e.test.ts` 用例
|
||||
- 为命令补 help / 缺参 / dry-run / 真实集成测试
|
||||
- 新建或扩展 `packages/commands/tests/e2e/<topic>.e2e.test.ts`
|
||||
- 新增 bl 产品 path → `registry.smoke` 自动覆盖 leaf path;commands topic 测试在 `topic-routes.ts` 补最小路由
|
||||
|
||||
以上情况必须同步维护 `packages/cli/tests/e2e/<topic>.e2e.test.ts`。跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。
|
||||
跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。
|
||||
|
||||
> **规则**:共享 command 行为在 `commands/tests/e2e`;产品 map、identity、CLI-only 命令留在对应产品 `tests/e2e`。
|
||||
|
||||
## 文件与工具
|
||||
|
||||
- 路径:`packages/cli/tests/e2e/<kebab-topic>.e2e.test.ts`
|
||||
- 框架:`vite-plus/test`;子进程跑 CLI:`runCli` from `./helpers.ts`
|
||||
### commands E2E
|
||||
|
||||
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`
|
||||
- 子进程:`runCommandE2e(routes, args)` from `./helpers.ts`(spawn `harness/main.ts`,`routes` 为本 topic 最小 path → export 映射)
|
||||
- fixtures:`packages/commands/tests/e2e/fixtures/`
|
||||
- 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map)
|
||||
|
||||
### 产品 smoke
|
||||
|
||||
- bl:`runCli` from `packages/cli/tests/e2e/helpers.ts`
|
||||
- kscli:`runKscli` from `packages/kscli/tests/e2e/helpers.ts`
|
||||
|
||||
### 共享
|
||||
|
||||
- gating / output / runner:`e2e/gating`、`e2e/output`、`e2e/runner`
|
||||
- globalSetup:根 `vite.config.ts` → `packages/e2e/src/global-setup.ts`
|
||||
- 解析 JSON stdout:`parseStdoutJson`;输出目录:`makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url))`
|
||||
- 长任务:`cliTimeoutPrefix()`;视频用例加 `test(..., 3_600_000)` 等显式超时
|
||||
|
||||
## 双层 describe(固定结构)
|
||||
|
||||
```ts
|
||||
// 1) 不 skip:分组 + --help,无密钥、无真实 API
|
||||
// 1) 不 skip:--help,无密钥、无真实 API(分组 help 由 bl registry.smoke 覆盖)
|
||||
describe("e2e: <topic>", () => {
|
||||
test("<group> 分组展示子命令帮助且成功退出", ...);
|
||||
test("<subcommand> --help 正常退出", ...);
|
||||
});
|
||||
|
||||
// 2) skipIf:缺参 / dry-run / 真实集成;原有集成用例放最后、勿改逻辑
|
||||
// 2) skipIf:缺参 / dry-run / 真实集成
|
||||
describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
|
||||
test("缺少 --<flag> 时退出为用法错误 (2)", ...);
|
||||
test("<cmd> --dry-run ...", ...); // 若适用
|
||||
@@ -33,51 +60,69 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
|
||||
});
|
||||
```
|
||||
|
||||
## skip 条件(helpers.ts)
|
||||
## skip 条件(`e2e/gating`,commands helpers re-export)
|
||||
|
||||
| 场景 | 条件 |
|
||||
| ------------------- | ----------------------------------------------------- |
|
||||
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
|
||||
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
|
||||
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
|
||||
| 知识库 | `isKnowledgeE2EReady()` |
|
||||
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
|
||||
| 场景 | 条件 |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
|
||||
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
|
||||
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
|
||||
| OpenAPI AK/SK | `isOpenApiE2EReady()`(`.env` 中必须同时提供完整 AK/SK) |
|
||||
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
|
||||
| 知识库 chat/search live | `isChatE2EReady()` / `isSearchE2EReady()`(`knowledge chat/search`,需 `BAILIAN_WORKSPACE_ID` + agent ID) |
|
||||
|
||||
## 用例类型
|
||||
|
||||
1. **分组 help**:`runCli(["image"])` → `exitCode === 0`,stdout+stderr 含子命令名
|
||||
2. **--help**:`runCli([..., "--help"])` → stderr 含主要 flags
|
||||
3. **缺参**:带一个无害全局 flag(如 `--quiet`)且不传 required flag → `exitCode === 2`,stderr 匹配 `--flag|Missing required argument`
|
||||
4. **--dry-run**:仅当实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本,不入网
|
||||
5. **真实集成**:保留既有用例名称与断言;放在 skip 块**末尾**
|
||||
1. **--help**:`runCommandE2e(ROUTES, [..., "--help"])` → stderr 含主要 flags
|
||||
2. **缺参**:带无害全局 flag(如 `--quiet`)且不传 required flag → `exitCode === 2`
|
||||
3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本
|
||||
4. **真实集成**:放在 skip 块**末尾**
|
||||
|
||||
## 增删命令同步
|
||||
|
||||
- **commands export** + **topic 路由**(`topic-routes.ts` 或测试文件内 `ROUTES`)+ **产品 map**(`cli/commands.ts` / `kscli/commands.ts`)
|
||||
- 分组 help 由产品 `registry.smoke` 负责,无需在 commands 重复
|
||||
|
||||
## 安全与例外
|
||||
|
||||
- **禁止真实破坏性操作**:`auth logout` 只用 `--dry-run`;`config set` 只用 `--dry-run`
|
||||
- **禁止破坏真实用户配置**:`auth logout` 默认只用 `--dry-run`;需要验证实际落盘时,必须通过
|
||||
`BAILIAN_CONFIG_DIR` 指向隔离 fixture;`config set` 只用 `--dry-run`
|
||||
- **不加 dry-run**:`dryRun` 在 `resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit`、`speech recognize` 带 `--url`)
|
||||
- **`--list-voices` 等旁路**:先于 `--text` 校验的 flag,缺参用例勿带该 flag
|
||||
- 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言
|
||||
|
||||
## 新增 command 检查清单
|
||||
|
||||
- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
|
||||
- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `topic-routes.ts` 补最小路由
|
||||
- [ ] `packages/commands/tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
|
||||
- [ ] 若改了 `usageArgs` / `flags` / `exampleArgs`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/` 并提交
|
||||
- [ ] 顶层:分组 help + 子命令 `--help`(多子命令则各一条 help)
|
||||
- [ ] 子命令 `--help`(分组 help 由 bl `registry.smoke` 覆盖)
|
||||
- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条
|
||||
- [ ] 至少一条真实集成(或说明为何仅 smoke);不破坏已有集成用例顺序
|
||||
- [ ] `pnpm test packages/cli/tests/e2e/<file>` 通过
|
||||
- [ ] `vp test packages/commands/tests/e2e/<file>` 通过
|
||||
|
||||
## 调试命令
|
||||
|
||||
```sh
|
||||
pnpm --filter bailian-cli-commands exec vp test packages/commands/tests/e2e/text-chat.e2e.test.ts
|
||||
pnpm --filter bailian-cli exec vp test packages/cli/tests/e2e/registry.smoke.e2e.test.ts
|
||||
pnpm --filter knowledge-studio-cli exec vp test packages/kscli/tests/e2e/registry.smoke.e2e.test.ts
|
||||
pnpm --filter bailian-cli-runtime exec vp test packages/runtime/tests/proxy.e2e.test.ts
|
||||
```
|
||||
|
||||
## 示例片段
|
||||
|
||||
```ts
|
||||
import { FOO_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
test("foo bar 缺少 --prompt 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["foo", "bar", "--quiet"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(FOO_ROUTES, ["foo", "bar", "--quiet"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--prompt|Missing required argument/i);
|
||||
});
|
||||
|
||||
test("foo bar --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(FOO_ROUTES, [
|
||||
"foo",
|
||||
"bar",
|
||||
"--dry-run",
|
||||
@@ -97,4 +142,4 @@ test("foo bar --dry-run 仅输出计划", async () => {
|
||||
- **E2E**:单条/少量调用、断言固定、可进 `vp test`(见上文 skip 条件)
|
||||
- **批量压测**:`packages/cli/tests/stress/run.mjs` + `targets/*.mjs`,并发 + 报告,**仅手动** `pnpm run test:stress -- <target>`
|
||||
|
||||
勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md)。
|
||||
勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md).
|
||||
|
||||
@@ -72,7 +72,8 @@ packages/commands/src/index.ts
|
||||
- `exampleArgs`(不含 bin/path 前缀)
|
||||
- `validate`(跨 flag 校验)
|
||||
- 普通业务命令的 `run(ctx)` 只读 `ctx.flags` / `ctx.settings` / `ctx.client`
|
||||
- `commands/auth/**` 可用 `ctx.authStore()`,`commands/config/**` 可用 `ctx.configStore()`;不要把这些 store accessor 扩散到普通业务命令
|
||||
- `commands/auth/**` 可用 `ctx.authStore`,`commands/config/**` 可用 `ctx.configStore`;不要把这些持久化能力扩散到普通业务命令
|
||||
- `commands/plugin/**` 可用 `ctx.commandPacks`;产品 policy 由 runtime 绑定,命令不要自行 import 产品入口
|
||||
- [ ] `packages/commands/src/index.ts`:新增或移除对应 export
|
||||
- [ ] 如果命令调用 Console Gateway,设置 `auth: "console"`;不要重复声明 console 凭证域 flags
|
||||
- [ ] 如果命令不需要网络或自己管理配置/登录,设置 `auth: "none"`;不要绕过 runtime auth stage
|
||||
@@ -92,15 +93,17 @@ packages/commands/src/index.ts
|
||||
|
||||
### D. 测试层
|
||||
|
||||
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/cli/tests/e2e/<topic>.e2e.test.ts`
|
||||
- [ ] 删除命令时一并删对应 e2e / README 示例 / reference 生成结果
|
||||
- [ ] 如果 shared command 在不同入口路径下复用,至少确保 `bl` 入口 e2e 覆盖;`kscli` 入口改动需补对应入口测试或手工 smoke
|
||||
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/commands/tests/e2e/<topic>.e2e.test.ts`
|
||||
- [ ] 同步 `packages/commands/tests/e2e/topic-routes.ts`(该 topic 的最小 path → export 映射)
|
||||
- [ ] bl 产品 path 变更由 `registry.smoke` 自动覆盖;kscli 变更同步 `kscli/src/commands.ts` 与 `registry.smoke`
|
||||
- [ ] 删除命令时一并删对应 commands e2e / README 示例 / reference / topic 路由条目
|
||||
- [ ] 如果 shared command 在不同入口路径下复用,至少确保 commands e2e 覆盖 `bl` path;`kscli` 入口改动需补对应 smoke 或说明不测 flat path live
|
||||
|
||||
### E. 重命名特殊处理
|
||||
|
||||
- [ ] 全仓 grep **旧命令名字符串**,确保以下位置全部更新:
|
||||
- `packages/cli/src/commands.ts` map key
|
||||
- `packages/kscli/src/main.ts` map key(如适用)
|
||||
- `packages/kscli/src/commands.ts` map key(如适用)
|
||||
- 用户可见 hint / README / tests
|
||||
- `skills/bailian-cli/reference/`(重建后检查并提交)
|
||||
- [ ] 检查 `usageArgs` / `exampleArgs` 没有硬编码旧的 `bl <path>` 前缀
|
||||
@@ -111,7 +114,7 @@ packages/commands/src/index.ts
|
||||
pnpm run sync:skill-assets
|
||||
pnpm -F bailian-cli exec tsx src/main.ts <new-command> --help
|
||||
pnpm -F bailian-cli exec tsx src/main.ts
|
||||
vp test packages/cli/tests/e2e/<topic>.e2e.test.ts
|
||||
vp test packages/commands/tests/e2e/<topic>.e2e.test.ts
|
||||
```
|
||||
|
||||
如改了 `kscli` 入口:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Command Pack 维护
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 新增或移除 Command Pack 包
|
||||
- 调整包白名单、允许的命令前缀或协议字段
|
||||
- 修改 `plugin install/link/list/remove`
|
||||
- 修改 Command Pack 加载、隔离、兼容性或独立安装目录
|
||||
|
||||
## 分层边界
|
||||
|
||||
- `packages/core/src/types/command-pack.ts`:稳定的协议元数据和导出类型,不知道具体产品或白名单。
|
||||
- `packages/runtime/src/command-packs/`:所有 CLI 共用的加载、校验、API 适配、产品隔离安装目录和 manager 实现。
|
||||
- `packages/runtime/src/create-cli.ts`:始终接收静态 command map,按 `CliOptions.commandPacks` 统一合并 pack,并把已绑定产品 identity/policy 的 manager 注入 `ctx.commandPacks`。
|
||||
- `packages/commands/src/commands/plugin/`:普通共享管理命令,只依赖 `ctx.commandPacks`,不 import 任何产品 policy。
|
||||
- `packages/cli/src/command-pack-policy.ts`:`bl` 支持的包、命令前缀和凭据授权。
|
||||
- `kscli` 当前不传 `commandPacks`,使用 runtime 的默认空 policy。
|
||||
- 当前只有 `bl` 从 `bailian-cli-commands` 导入并登记 `plugin *`;使用默认空 policy 的产品不提前暴露管理命令。
|
||||
|
||||
不要把产品白名单写进 core/runtime,也不要通过扫描全局 `node_modules` 自动发现包。通用机制放 runtime,产品差异只由 policy 表达。
|
||||
|
||||
## 安全与兼容性清单
|
||||
|
||||
- [ ] 包名必须精确命中当前产品 policy 的 `supported`,命令路径必须位于该包允许的前缀。
|
||||
- [ ] 正式安装只接受包名加 version/tag;本地目录只走 `plugin link`。
|
||||
- [ ] npm 使用独立安装目录和 `--ignore-scripts`,不污染 CLI 自身依赖树。
|
||||
- [ ] npm 子进程只继承明确允许的 registry/config/cache/proxy/TLS 配置,不通配透传 pnpm 注入的 `npm_config_*`。
|
||||
- [ ] 安装目录按 `identity.npmPackage` 隔离,不能让一个产品安装/删除另一个产品的 pack。
|
||||
- [ ] 安装目录只隔离依赖位置,不隔离执行权限;Command Pack 必须视为 CLI 进程内的完全可信代码。
|
||||
- [ ] 入口 realpath 不能逃逸包根目录。
|
||||
- [ ] 加载前检查 `type`、`apiVersion`、`minCliVersion`;报告状态只使用 `loaded/failed`,具体原因写入 `error`。
|
||||
- [ ] Command Pack 不能覆盖内置命令、其他 pack 命令或重声明保留 flag。
|
||||
- [ ] 普通网络请求走 `ctx.client`;基础 Context 提供 `identity/settings/flags/client/output/errors`,不提供原始凭据。
|
||||
- [ ] `ctx.credentials.apiKey()` 仅限 policy 显式声明 `credentialAccess: ["apiKey"]`,且命令自身为 `auth: "apiKey"`。
|
||||
- [ ] 不向 Command Pack 暴露原始 Console Token、OpenAPI AK/SK、`authStore` 或 `configStore`。
|
||||
- [ ] 不向 Command Pack 暴露宿主的 `commandPacks` manager,避免 pack 安装或删除其他 pack。
|
||||
- [ ] 单包失败必须 fail-open:保留内置命令和其他合法 pack。
|
||||
- [ ] 破坏协议前优先在适配层兼容;确实无法兼容时才提升 `apiVersion`。
|
||||
|
||||
## 测试与文档
|
||||
|
||||
- [ ] `packages/runtime/tests/command-packs.test.ts` 覆盖产品 policy、安装目录隔离、协议版本、前缀和导出契约。
|
||||
- [ ] `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 的包名必须在测试白名单内,且构建入口不依赖工作区运行时解析。
|
||||
- [ ] 更新生成的 `skills/bailian-cli/reference/plugin.md`;公开 `README.md` / `README.zh.md` 等正式对外发布时再补。
|
||||
|
||||
验证:
|
||||
|
||||
```sh
|
||||
vp test packages/runtime/tests/command-packs.test.ts
|
||||
vp test packages/cli/tests/e2e/command-packs.e2e.test.ts
|
||||
vp test packages/kscli/tests/e2e/command-packs.e2e.test.ts
|
||||
pnpm run sync:skill-assets
|
||||
vp check
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# Config Profile 与激活状态变更清单
|
||||
|
||||
适用于新增 Profile 预设、修改命名 Profile 选择规则、调整 `active_config`,或新增/修改 `bl config list/use/show/ui` 等 Profile 管理能力。
|
||||
|
||||
## 1. 保持存储边界
|
||||
|
||||
- Profile 业务字段继续由 `ConfigFile` / `CONFIG_FILE_KEYS` 管理。
|
||||
- `active_config` 是 `config.json` 顶层元数据,不得进入命名 Profile block,也不得被 `config set` 当作普通字段写入。
|
||||
- 识别命名 Profile 时必须排除业务字段和顶层元数据。
|
||||
- 旧配置缺少 `active_config` 时继续等价于激活 `default`。
|
||||
|
||||
## 2. 保持选择语义
|
||||
|
||||
```text
|
||||
显式 --config <name> > active_config > default
|
||||
```
|
||||
|
||||
- 解析阶段用局部变量保留“是否显式传入 `--config`”的信息;完成 Config 选择后不进入 `Settings`。
|
||||
- `--config default` 必须显式选择顶层配置并绕过命名激活项。
|
||||
- 普通命令的显式 `--config` 只覆盖本次选择,不修改持久化激活状态;例外是
|
||||
`auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。
|
||||
- 激活状态只选择配置 block,不改变字段优先级;字段仍为 flag > env > selected config > 默认值。
|
||||
- Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。
|
||||
- Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。
|
||||
|
||||
## 3. 保持读写命令交互一致
|
||||
|
||||
- `auth login`、`config set` 等写命令未传 `--config` 时修改当前激活项。
|
||||
- `auth login --config <name>` 显式指定不存在的 Profile 时,仅在凭证验证成功并实际落盘时
|
||||
创建和激活;`config set --config <name>` 可创建但不自动激活。
|
||||
- `config show`、`auth status` 和业务消费等读命令不得因为显式指定不存在的名称而创建 Profile。
|
||||
- `auth logout` 默认只清理当前激活项;显式 `--config` 只清理指定项。
|
||||
- 按凭证域退出时必须清理该域的完整字段集合,例如 OpenAPI 同时清理 AK、SK 和 STS `security_token`。
|
||||
- 所有生产代码读取“当前配置”时优先经过 `buildSources()` 或携带解析后的 `configName`;直接调用无名称的 `readConfigFile()` / `writeConfigFile()` 只适用于明确操作顶层 default 的底层能力。
|
||||
|
||||
## 4. 保持状态一致性
|
||||
|
||||
- `config use` 只能激活已经存在的命名 Profile;`default` 始终有效。
|
||||
- 配置文件中的 `active_config` 指向不存在的 Profile 时返回 usage error,不静默回退。
|
||||
- 删除当前激活的命名 Profile 时,同一次落盘切回 `default`,不得留下悬空引用。
|
||||
- 配置写入继续使用临时文件 + rename,避免中断后留下半写文件。
|
||||
|
||||
## 5. 命令与展示联动
|
||||
|
||||
- 新增/重命名命令时同步 `packages/commands/src/index.ts` 和产品入口 `packages/cli/src/commands.ts`。
|
||||
- `config list` 标识所有 Profile 与当前激活项。
|
||||
- `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。
|
||||
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。
|
||||
- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。
|
||||
- 同步 E2E topic routes、Skill setup 和自动生成 reference。
|
||||
|
||||
## 6. 最小测试矩阵
|
||||
|
||||
- 旧配置无 `active_config` -> `default`。
|
||||
- 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。
|
||||
- 显式命名 `--config` 和 `--config default` 均覆盖激活项且不修改磁盘状态。
|
||||
- 激活不存在的 Profile 失败且不写盘。
|
||||
- 悬空 `active_config` 明确失败。
|
||||
- 删除激活 Profile 后切回 `default`。
|
||||
- 登录、退出、`config set` 分别覆盖“当前激活项”和“显式不存在名称成功后创建”。
|
||||
- 显式 `auth login --config <name>` 成功后激活该 Profile,失败或 dry-run 不创建、不切换;
|
||||
`--config default` 成功后切回 `default`。
|
||||
- Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。
|
||||
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
|
||||
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。
|
||||
|
||||
## 7. 完成检查
|
||||
|
||||
```sh
|
||||
pnpm run sync:skill-assets
|
||||
vp check
|
||||
vp test
|
||||
```
|
||||
|
||||
命令 E2E 会启动本地子进程,Config UI 测试还会监听 `127.0.0.1` 临时端口;受限沙箱内出现 `EPERM` 时,需要在允许本地进程和端口的环境中复跑。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 安装文档变更
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 修改根目录 `INSTALL.md` 的安装、鉴权或验证流程
|
||||
- 修改发布包 Node.js 要求、全局 flag 或安装文档引用的命令
|
||||
- 同步或发布 `https://bailian.aliyun.com/cli/install.md`
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. CLI 契约
|
||||
|
||||
- [ ] `INSTALL.md` 中的 `bl` 命令路径存在于 `packages/cli/src/commands.ts`
|
||||
- [ ] 示例 flag 属于 `GLOBAL_FLAGS`、命令鉴权域 flag 或命令自身 `flags`
|
||||
- [ ] Node.js 用户安装要求与 `packages/cli/package.json` 的 `engines.node` 一致,不使用根 `package.json` 的开发环境要求
|
||||
- [ ] 鉴权流程与 `packages/commands/src/commands/auth/` 的实际校验、保存和 Profile 激活行为一致
|
||||
|
||||
### B. 静态副本
|
||||
|
||||
- [ ] 将 `INSTALL.md` 同步到 `bailian-cli-static-resources/public/install.txt`
|
||||
- [ ] 使用 `cmp -s` 确认两份文档逐字节一致
|
||||
- [ ] 静态资源仓库单独创建分支、提交和发布,不把跨仓库改动遗漏在 CLI PR 之外
|
||||
|
||||
### C. 线上验证
|
||||
|
||||
- [ ] 发布后读取 `https://bailian.aliyun.com/cli/install.md`,确认内容来自最新静态副本
|
||||
- [ ] 带随机 query 参数复查,区分 CDN 缓存与源站未更新
|
||||
- [ ] 验证线上文档中的安装命令、Node.js 要求和配置验证段落,不只检查页面可访问
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
pnpm -F bailian-cli test -- tests/install-doc.test.ts
|
||||
cmp -s INSTALL.md ../bailian-cli-static-resources/public/install.txt
|
||||
curl -L -s "https://bailian.aliyun.com/cli/install.md?verify=$(date +%s)"
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- `--non-interactive` 已从 CLI 移除,但旧安装文档和静态副本仍把它当作全局 flag
|
||||
- 根 `package.json` 是开发工具链 Node.js 要求;用户安装要求以 `packages/cli/package.json` 为准
|
||||
- 静态仓库文件名是 `public/install.txt`,线上稳定地址是 `/cli/install.md`;只更新其中一侧不会自动证明发布成功
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
### A. 版本一致性
|
||||
|
||||
- [ ] `package.json` 的 `engines.node` 与 README 的 Node.js 徽章一致
|
||||
- [ ] 发布包(`cli` 等)的 `engines.node` 与 README 的 Node.js 徽章一致;根/e2e 开发要求(`>=22.12`)与 CONTRIBUTING 一致
|
||||
- [ ] `pnpm-lock.yaml` 同步生成(运行 `pnpm install`)
|
||||
- [ ] 各源码包 `tsconfig.json`(根 + core + runtime + commands + cli + kscli)的 target / module 设置一致
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
- [ ] `.vite-hooks/pre-commit` 改动后,`pnpm install` 重新软链(走 `prepare: vp config`)
|
||||
- [ ] 增加 hook 时,确认在干净 clone 后能自动激活
|
||||
- [ ] pre-commit 会跑 `pnpm run sync:skill-assets`(先 build core,再 `generate:reference` + `sync:skill-version`)并 `git add` skill 资产,最后 `vp staged`
|
||||
- [ ] pre-commit 会跑 `pnpm run sync:skill-assets`(`generate:reference` 含格式化 + `sync:skill-version`,直接读源码、无需先 build)并 `git add` skill 资产,最后 `vp staged`
|
||||
|
||||
### F. CI / 发版工具
|
||||
|
||||
|
||||
+15
-13
@@ -29,8 +29,9 @@
|
||||
1. 确保当前 release tooling 覆盖的包(`tools/release/lib/packages.mjs`)已升到目标版本且一致;当前基础集合为 `packages/core` / `packages/runtime` / `packages/commands` / `packages/cli`,`knowledge-studio-cli` 发布会额外包含 `packages/kscli`
|
||||
2. 在 GitHub 触发 Publish workflow,package 选目标包集合,mode 选 `stable`
|
||||
3. 需要 production environment 审批人批准
|
||||
4. CI 自动:自检 → 构建 → 发布到 latest → 打 git tag
|
||||
5. 对应脚本:`tools/release/publish-stable.mjs`
|
||||
4. CI 自动:自检 → 构建 → 检查 npm 已发布版本 → 发布到 latest → 打 git tag
|
||||
5. 如果所选发布集合的当前版本已全部存在于 npm,stable 发布会失败并提示先升级版本号;如果只有部分包已发布,CI 会继续补发缺失包
|
||||
6. 对应脚本:`tools/release/publish-stable.mjs`
|
||||
|
||||
## 自检(`tools/release/check.mjs`)
|
||||
|
||||
@@ -92,14 +93,15 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
|
||||
|
||||
## 常见漏点(基于历史踩坑)
|
||||
|
||||
| 漏点 | 后果 |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
|
||||
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
|
||||
| cli 升版号但 core 没升 | check.mjs 会拦下 |
|
||||
| 发版漏更 CHANGELOG,或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
|
||||
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag,所有用户被强升,撤回成本极高 |
|
||||
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
|
||||
| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 上 `npm i` 被 engine 警告或直接失败 |
|
||||
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上,publish 报 404 |
|
||||
| CI 用 Node 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| 漏点 | 后果 |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
|
||||
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
|
||||
| cli 升版号但 core 没升 | check.mjs 会拦下 |
|
||||
| 发版漏更 CHANGELOG,或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
|
||||
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag,所有用户被强升,撤回成本极高 |
|
||||
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
|
||||
| Node 徽章与 `cli/package.json.engines` 不一致(当前应为 `>=18.17`) | 用户在声明外的 Node 上 `npm i` 被 engine 警告或直接失败 |
|
||||
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上,publish 报 404 |
|
||||
| CI 用 Node 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npm,CI 明确报错并要求先升级版本号 |
|
||||
|
||||
@@ -19,6 +19,7 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
|
||||
BAILIAN_CONSOLE_ROOT bailian.console.aliyun.com
|
||||
BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing
|
||||
API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key
|
||||
TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview
|
||||
|
||||
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
|
||||
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
"ready": "vp check && vp run -r test && vp run -r build",
|
||||
"prepare": "vp config",
|
||||
"check": "vp check",
|
||||
"sync:skill-assets": "pnpm --filter \"bailian-cli^...\" run build && pnpm --filter bailian-cli run generate:reference && pnpm --filter bailian-cli run sync:skill-version",
|
||||
"sync:skill-assets": "pnpm --filter bailian-cli run generate:reference && pnpm --filter bailian-cli run sync:skill-version",
|
||||
"dev": "pnpm -F bailian-cli-core dev",
|
||||
"bl": "pnpm -F bailian-cli dev",
|
||||
"kscli": "pnpm -F knowledge-studio-cli dev",
|
||||
|
||||
+28
-9
@@ -5,7 +5,7 @@
|
||||
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -30,6 +30,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
|
||||
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
@@ -38,8 +39,8 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
|
||||
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
|
||||
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
|
||||
- **Fine-tuning & deployment** — Upload datasets, create SFT/LoRA/DPO/CPT jobs (`finetune create`), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy create`)
|
||||
- **Console capabilities** — Browse Bailian apps (`app list`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
|
||||
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
## Showcase: One-Sentence Cinematic Video
|
||||
@@ -80,7 +81,7 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -91,6 +92,12 @@ bl auth login --console
|
||||
# Or authenticate with an API key
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Or use Token Plan (Base URL built in; the key is tested during login)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# Configure a coding agent to use DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# Chat with Qwen
|
||||
bl text chat --message "What is DashScope?"
|
||||
|
||||
@@ -114,13 +121,15 @@ bl auth login --console
|
||||
|
||||
# Fine-tune & deploy — a one-shot train-to-serve workflow
|
||||
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
|
||||
bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
|
||||
bl finetune watch --job-id ft-xxx --output json # Non-blocking status probe (exit 0/1/3 = done/failed/running)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
|
||||
bl finetune watch --job-id ft-xxx --output json # Non-blocking probe (running/succeeded return 0; failed/canceled report an error)
|
||||
bl finetune capability --model qwen3-8b # Which training types a model supports
|
||||
bl deploy create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
|
||||
|
||||
# Browse apps / free-tier quota / usage statistics / workspaces
|
||||
# Browse models / apps / free-tier quota / usage statistics / workspaces
|
||||
bl model list # Browse model families and pricing
|
||||
bl app list
|
||||
bl usage summary # Unified view: free-tier quota + recent usage overview
|
||||
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
|
||||
bl workspace list # List all workspaces
|
||||
@@ -157,9 +166,18 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "Hello"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`app list`, `usage free`, `usage stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
@@ -207,6 +225,7 @@ Config file location: `~/.bailian/config.json`
|
||||
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## Changelog
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**阿里云百炼 (DashScope) AI 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -30,6 +30,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
@@ -38,8 +39,8 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
|
||||
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
|
||||
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
|
||||
- **微调与部署** — 上传数据集、创建 SFT/LoRA/DPO/CPT 调优任务(`finetune create`)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy create`)
|
||||
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
|
||||
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
## 示例:一句话生成一部电影短片
|
||||
@@ -78,7 +79,7 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 22.12。
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -89,6 +90,12 @@ bl auth login --console
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 或使用 Token Plan(已内置 Base URL,登录时自动测试 Key)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# 配置 Coding Agent 使用 DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# 和通义千问对话
|
||||
bl text chat --message "你好,介绍一下阿里云百炼平台"
|
||||
|
||||
@@ -112,13 +119,15 @@ bl auth login --console
|
||||
|
||||
# 微调与部署 — 从训练到服务的一站式流程
|
||||
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
|
||||
bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
|
||||
bl finetune watch --job-id ft-xxx --output json # 非阻塞状态探测(退出码 0/1/3 = 成功/失败/进行中)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
|
||||
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0;失败/取消报错)
|
||||
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
|
||||
bl deploy create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
|
||||
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
|
||||
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
|
||||
bl model list # 浏览模型系列与价格信息
|
||||
bl app list
|
||||
bl usage summary # 统一视图:免费额度 + 近期用量概览
|
||||
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
|
||||
bl workspace list # 列出所有业务空间
|
||||
@@ -155,9 +164,18 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "你好"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
|
||||
CLI 已内置 Token Plan 的默认 Base URL;登录命令会先测试 Key,通过后才保存并激活 `token-plan` 配置。
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`app list`、`usage free`、`usage stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
@@ -205,6 +223,7 @@ bl update
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.7.0",
|
||||
"version": "1.10.1",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
@@ -59,12 +59,13 @@
|
||||
"ajv": "catalog:",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"e2e": "workspace:*",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "catalog:",
|
||||
"vite-plus": "0.1.22",
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CommandPackPolicy } from "bailian-cli-runtime";
|
||||
|
||||
/** Command Packs accepted by the bl product. */
|
||||
export const commandPackPolicy = {
|
||||
supported: {
|
||||
"@ali/bailian-plugin-agent": {
|
||||
commandPrefixes: ["agent"],
|
||||
credentialAccess: ["apiKey"],
|
||||
},
|
||||
"@ali/bailian-plugin-inner-console-call": {
|
||||
commandPrefixes: ["inner-console"],
|
||||
},
|
||||
},
|
||||
} as const satisfies CommandPackPolicy;
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
authLogin,
|
||||
authStatus,
|
||||
authLogout,
|
||||
authGenerateAccessToken,
|
||||
textChat,
|
||||
textOmni,
|
||||
imageGenerate,
|
||||
@@ -15,6 +16,10 @@ import {
|
||||
visionDescribe,
|
||||
configShow,
|
||||
configSet,
|
||||
configList,
|
||||
configUse,
|
||||
configUi,
|
||||
configAgent,
|
||||
update,
|
||||
appCall,
|
||||
appList,
|
||||
@@ -39,9 +44,11 @@ import {
|
||||
usageFree,
|
||||
usageFreetier,
|
||||
usageStats,
|
||||
usageSummary,
|
||||
pipelineRun,
|
||||
pipelineValidate,
|
||||
advisorRecommend,
|
||||
modelList,
|
||||
workspaceList,
|
||||
quotaList,
|
||||
quotaRequest,
|
||||
@@ -52,7 +59,9 @@ import {
|
||||
datasetGet,
|
||||
datasetDelete,
|
||||
datasetValidate,
|
||||
finetuneCreate,
|
||||
finetuneTextCreate,
|
||||
finetuneAudioCreate,
|
||||
finetuneImageCreate,
|
||||
finetuneList,
|
||||
finetuneGet,
|
||||
finetuneCancel,
|
||||
@@ -62,7 +71,9 @@ import {
|
||||
finetuneExport,
|
||||
finetuneWatch,
|
||||
finetuneCapability,
|
||||
deployCreate,
|
||||
deployTextCreate,
|
||||
deployAudioCreate,
|
||||
deployImageCreate,
|
||||
deployList,
|
||||
deployGet,
|
||||
deployModels,
|
||||
@@ -73,6 +84,11 @@ import {
|
||||
tokenPlanCreateKey,
|
||||
tokenPlanAssignSeats,
|
||||
tokenPlanAddMember,
|
||||
workspaceInit,
|
||||
pluginInstall,
|
||||
pluginLink,
|
||||
pluginList,
|
||||
pluginRemove,
|
||||
} from "bailian-cli-commands";
|
||||
|
||||
// Full bailian-cli product: every command, exposed under the `bl` binary.
|
||||
@@ -84,6 +100,7 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"auth login": authLogin,
|
||||
"auth status": authStatus,
|
||||
"auth logout": authLogout,
|
||||
"auth generate-access-token": authGenerateAccessToken,
|
||||
"text chat": textChat,
|
||||
omni: textOmni,
|
||||
"image generate": imageGenerate,
|
||||
@@ -96,6 +113,10 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"vision describe": visionDescribe,
|
||||
"config show": configShow,
|
||||
"config set": configSet,
|
||||
"config list": configList,
|
||||
"config use": configUse,
|
||||
"config ui": configUi,
|
||||
"config agent": configAgent,
|
||||
update,
|
||||
"app call": appCall,
|
||||
"app list": appList,
|
||||
@@ -120,9 +141,11 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"usage free": usageFree,
|
||||
"usage freetier": usageFreetier,
|
||||
"usage stats": usageStats,
|
||||
"usage summary": usageSummary,
|
||||
"pipeline run": pipelineRun,
|
||||
"pipeline validate": pipelineValidate,
|
||||
"advisor recommend": advisorRecommend,
|
||||
"model list": modelList,
|
||||
"workspace list": workspaceList,
|
||||
"quota list": quotaList,
|
||||
"quota request": quotaRequest,
|
||||
@@ -133,7 +156,9 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"dataset get": datasetGet,
|
||||
"dataset delete": datasetDelete,
|
||||
"dataset validate": datasetValidate,
|
||||
"finetune create": finetuneCreate,
|
||||
"finetune text create": finetuneTextCreate,
|
||||
"finetune audio create": finetuneAudioCreate,
|
||||
"finetune image create": finetuneImageCreate,
|
||||
"finetune list": finetuneList,
|
||||
"finetune get": finetuneGet,
|
||||
"finetune cancel": finetuneCancel,
|
||||
@@ -143,7 +168,9 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"finetune export": finetuneExport,
|
||||
"finetune watch": finetuneWatch,
|
||||
"finetune capability": finetuneCapability,
|
||||
"deploy create": deployCreate,
|
||||
"deploy text create": deployTextCreate,
|
||||
"deploy audio create": deployAudioCreate,
|
||||
"deploy image create": deployImageCreate,
|
||||
"deploy list": deployList,
|
||||
"deploy get": deployGet,
|
||||
"deploy models": deployModels,
|
||||
@@ -154,4 +181,9 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"token-plan create-key": tokenPlanCreateKey,
|
||||
"token-plan assign-seats": tokenPlanAssignSeats,
|
||||
"token-plan add-member": tokenPlanAddMember,
|
||||
"workspace init": workspaceInit,
|
||||
"plugin install": pluginInstall,
|
||||
"plugin link": pluginLink,
|
||||
"plugin list": pluginList,
|
||||
"plugin remove": pluginRemove,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createCli } from "bailian-cli-runtime";
|
||||
import { commands } from "./commands.ts";
|
||||
import { commandPackPolicy } from "./command-pack-policy.ts";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
|
||||
const quickStartTasks = [
|
||||
@@ -15,4 +16,5 @@ void createCli(commands, {
|
||||
clientName: "bailian-cli",
|
||||
npmPackage: "bailian-cli",
|
||||
quickStartTasks,
|
||||
commandPacks: commandPackPolicy,
|
||||
}).run();
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
/**
|
||||
* Auth 相关 E2E:只验证 CLI 进程能正常解析参数并退出。
|
||||
*/
|
||||
|
||||
describe("e2e: auth", () => {
|
||||
test("auth 分组展示子命令帮助且退出码为 0", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["auth"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const out = `${stdout}\n${stderr}`;
|
||||
expect(out).toMatch(/auth|Authentication|login|logout|status/i);
|
||||
});
|
||||
|
||||
test("auth login --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/login|api-key/i);
|
||||
expect(stderr).toMatch(/--console-site/);
|
||||
expect(stderr).toMatch(/--open-api/);
|
||||
});
|
||||
|
||||
test("auth logout --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "logout", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/logout|dry-run|yes/i);
|
||||
});
|
||||
|
||||
test("auth status --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "status", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/status|output/i);
|
||||
});
|
||||
|
||||
test("auth login 缺少 --api-key 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "login", "--quiet"]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/Choose exactly one login mode/);
|
||||
});
|
||||
|
||||
test("auth login 一次只能选择一种登录模式", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"login",
|
||||
"--console",
|
||||
"--api-key",
|
||||
"sk-e2e-placeholder",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Choose exactly one login mode/);
|
||||
});
|
||||
|
||||
test("auth login 模式专属参数不能脱离对应模式", async () => {
|
||||
const openApiFlagOnly = await runCli(["auth", "login", "--access-key-id", "LTAI-e2e"]);
|
||||
expect(openApiFlagOnly.exitCode).toBe(2);
|
||||
expect(openApiFlagOnly.stderr).toMatch(/Use --open-api with --access-key-id/);
|
||||
|
||||
const baseUrlWithoutApiKey = await runCli([
|
||||
"auth",
|
||||
"login",
|
||||
"--console",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com",
|
||||
]);
|
||||
expect(baseUrlWithoutApiKey.exitCode).toBe(2);
|
||||
expect(baseUrlWithoutApiKey.stderr).toMatch(/Use --base-url only with --api-key/);
|
||||
|
||||
const consoleSiteWithoutConsole = await runCli([
|
||||
"auth",
|
||||
"login",
|
||||
"--api-key",
|
||||
"sk-e2e-placeholder",
|
||||
"--console-site",
|
||||
"international",
|
||||
]);
|
||||
expect(consoleSiteWithoutConsole.exitCode).toBe(2);
|
||||
expect(consoleSiteWithoutConsole.stderr).toMatch(/Use --console-site only with --console/);
|
||||
});
|
||||
|
||||
test("auth login --open-api 要求 AK/SK 成对输入", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"login",
|
||||
"--open-api",
|
||||
"--access-key-id",
|
||||
"LTAI-e2e",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Provide --access-key-id and --access-key-secret with --open-api/);
|
||||
});
|
||||
|
||||
test("auth login --dry-run --api-key 不发起校验与落盘", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"login",
|
||||
"--dry-run",
|
||||
"--api-key",
|
||||
"sk-e2e-dry-run-placeholder",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Would validate and save API key.");
|
||||
});
|
||||
|
||||
test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"login",
|
||||
"--dry-run",
|
||||
"--api-key",
|
||||
"sk-e2e-dry-run-placeholder",
|
||||
"--output",
|
||||
"json",
|
||||
"--timeout",
|
||||
"120",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Would validate and save API key.");
|
||||
});
|
||||
|
||||
test("auth login 缺少密钥且 --output json 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "login", "--output", "json"]);
|
||||
expect(exitCode).toBe(2);
|
||||
const err = JSON.parse(stderr.trim()) as { error?: { code?: number; message?: string } };
|
||||
expect(err.error?.code).toBe(2);
|
||||
expect(err.error?.message).toMatch(/Choose exactly one login mode/);
|
||||
});
|
||||
|
||||
test("auth logout --dry-run 不写入配置", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No changes made.");
|
||||
expect(stderr).not.toContain("Cleared api_key");
|
||||
});
|
||||
|
||||
test("auth logout --dry-run --quiet", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run", "--quiet"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No changes made.");
|
||||
});
|
||||
|
||||
test("auth logout --dry-run --output json(不清除密钥)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"logout",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No changes made.");
|
||||
expect(stderr).not.toContain("Cleared api_key");
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "text"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toMatch(
|
||||
/Authentication Status|API key:|Console token:|DashScope API:|Console gateway:/,
|
||||
);
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())("auth status --output json", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "json"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
authenticated?: boolean;
|
||||
api_key?: { source?: string; masked?: string; base_url?: string };
|
||||
}>(stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.api_key?.source).toBeDefined();
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())(
|
||||
"auth status --output json --quiet(base_url 经 env 指定;凭证域 flag 对 status 不可见)",
|
||||
async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
["auth", "status", "--output", "json", "--quiet"],
|
||||
{ DASHSCOPE_BASE_URL: "https://dashscope.aliyuncs.com" },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ authenticated?: boolean; api_key?: unknown }>(stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.api_key).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
test("auth status 不接受凭证域覆盖 flag(--base-url 报 Unknown flag)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "status", "--base-url", "https://x.test"]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/Unknown flag.*--base-url/);
|
||||
});
|
||||
|
||||
test("auth status 展示 env OpenAPI AK/SK 且不接受 OpenAPI flag 覆盖", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "json"], {
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-e2e-placeholder",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-e2e-placeholder",
|
||||
});
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
authenticated?: boolean;
|
||||
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
|
||||
}>(stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.openapi?.source).toBe("env");
|
||||
expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-placeholder");
|
||||
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-placeholder");
|
||||
|
||||
const denied = await runCli(["auth", "status", "--access-key-id", "ak"]);
|
||||
expect(denied.exitCode).not.toBe(0);
|
||||
expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/);
|
||||
});
|
||||
|
||||
test("auth login --open-api 持久化 OpenAPI AK/SK 并支持单独 logout", async () => {
|
||||
const configDir = makeE2eOutputDir("auth-openapi-login");
|
||||
const env = {
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
};
|
||||
|
||||
const login = await runCli(
|
||||
[
|
||||
"auth",
|
||||
"login",
|
||||
"--open-api",
|
||||
"--access-key-id",
|
||||
"LTAI-e2e-login-placeholder",
|
||||
"--access-key-secret",
|
||||
"secret-e2e-login-placeholder",
|
||||
],
|
||||
env,
|
||||
);
|
||||
expect(login.exitCode, login.stderr).toBe(0);
|
||||
expect(login.stderr).toMatch(/OpenAPI credentials saved/);
|
||||
|
||||
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(config.access_key_id).toBe("LTAI-e2e-login-placeholder");
|
||||
expect(config.access_key_secret).toBe("secret-e2e-login-placeholder");
|
||||
expect(config.openapi_access_key_id).toBeUndefined();
|
||||
expect(config.openapi_access_key_secret).toBeUndefined();
|
||||
|
||||
const status = await runCli(["auth", "status", "--output", "json"], env);
|
||||
expect(status.exitCode, status.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
authenticated?: boolean;
|
||||
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
|
||||
}>(status.stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.openapi?.source).toBe("config");
|
||||
expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-login-placeholder");
|
||||
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-login-placeholder");
|
||||
|
||||
const logout = await runCli(["auth", "logout", "--open-api"], env);
|
||||
expect(logout.exitCode, logout.stderr).toBe(0);
|
||||
expect(logout.stderr).toMatch(/Cleared access_key_id/);
|
||||
|
||||
const after = await runCli(["auth", "status", "--output", "json"], env);
|
||||
expect(after.exitCode, after.stderr).toBe(0);
|
||||
const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(after.stdout);
|
||||
expect(afterData.openapi).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
const fixtureRoot = join(fileURLToPath(import.meta.url), "..", "..", "fixtures", "command-pack");
|
||||
let configDir: string;
|
||||
|
||||
function env(): NodeJS.ProcessEnv {
|
||||
return { BAILIAN_CONFIG_DIR: configDir, DO_NOT_TRACK: "1" };
|
||||
}
|
||||
|
||||
describe("e2e: Command Pack", () => {
|
||||
beforeAll(async () => {
|
||||
configDir = await mkdtemp(join(tmpdir(), "bl-command-pack-e2e-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("plugin 分组和管理命令 help 正常", async () => {
|
||||
const group = await runCli(["plugin"], env());
|
||||
expect(group.exitCode, group.stderr).toBe(0);
|
||||
expect(group.stderr).toContain("plugin install");
|
||||
expect(group.stderr).toContain("plugin link");
|
||||
expect(group.stderr).toContain("plugin list");
|
||||
expect(group.stderr).toContain("plugin remove");
|
||||
|
||||
const install = await runCli(["plugin", "install", "--help"], env());
|
||||
expect(install.exitCode, install.stderr).toBe(0);
|
||||
expect(install.stderr).toContain("--package");
|
||||
});
|
||||
|
||||
test("未 link 时插件命令不存在", async () => {
|
||||
const result = await runCli(["agent", "ping", "--message", "before"], env());
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/Unknown command/i);
|
||||
});
|
||||
|
||||
test("link 后命令进入原生 registry/help/执行链路", async () => {
|
||||
const linked = await runCli(
|
||||
["plugin", "link", "--path", fixtureRoot, "--output", "json"],
|
||||
env(),
|
||||
);
|
||||
expect(linked.exitCode, linked.stderr).toBe(0);
|
||||
const linkedJson = parseStdoutJson<{ linked: { name: string; commands: string[] } }>(
|
||||
linked.stdout,
|
||||
);
|
||||
expect(linkedJson.linked.name).toBe("@ali/bailian-plugin-agent");
|
||||
expect(linkedJson.linked.commands).toEqual([
|
||||
"agent credential",
|
||||
"agent credential-denied",
|
||||
"agent fail",
|
||||
"agent output",
|
||||
"agent ping",
|
||||
]);
|
||||
|
||||
const rootHelp = await runCli(["--help"], env());
|
||||
expect(rootHelp.exitCode, rootHelp.stderr).toBe(0);
|
||||
expect(rootHelp.stderr).toContain("agent ping");
|
||||
|
||||
const commandHelp = await runCli(["agent", "ping", "--help"], env());
|
||||
expect(commandHelp.exitCode, commandHelp.stderr).toBe(0);
|
||||
expect(commandHelp.stderr).toContain("Ping the Command Pack fixture");
|
||||
expect(commandHelp.stderr).toContain("--message");
|
||||
|
||||
const executed = await runCli(["agent", "ping", "--message", "hello"], env());
|
||||
expect(executed.exitCode, executed.stderr).toBe(0);
|
||||
expect(executed.stdout).toContain("command-pack:hello");
|
||||
|
||||
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");
|
||||
|
||||
const denied = await runCli(["agent", "credential-denied"], {
|
||||
...env(),
|
||||
DASHSCOPE_API_KEY: "fixture-key",
|
||||
});
|
||||
expect(denied.exitCode).toBe(1);
|
||||
expect(denied.stderr).toContain('must declare auth="apiKey"');
|
||||
|
||||
const outputText = await runCli(["agent", "output"], env());
|
||||
expect(outputText.exitCode, outputText.stderr).toBe(0);
|
||||
expect(outputText.stdout).toBe("command-pack-output\n");
|
||||
|
||||
const outputJson = await runCli(["agent", "output", "--output", "json"], env());
|
||||
expect(outputJson.exitCode, outputJson.stderr).toBe(0);
|
||||
expect(parseStdoutJson(outputJson.stdout)).toEqual({ source: "command-pack", ok: true });
|
||||
|
||||
const failed = await runCli(["agent", "fail", "--output", "text"], env());
|
||||
expect(failed.exitCode).toBe(2);
|
||||
expect(failed.stderr).toContain("Command Pack fixture usage error.");
|
||||
expect(failed.stderr).toContain("Use agent fail only in tests.");
|
||||
});
|
||||
|
||||
test("plugin list 输出加载状态", async () => {
|
||||
const result = await runCli(["plugin", "list", "--output", "json"], env());
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const json = parseStdoutJson<{
|
||||
command_packs: Array<{ name: string; status: string; commands: string[] }>;
|
||||
}>(result.stdout);
|
||||
expect(json.command_packs).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "@ali/bailian-plugin-agent",
|
||||
status: "loaded",
|
||||
commands: [
|
||||
"agent credential",
|
||||
"agent credential-denied",
|
||||
"agent fail",
|
||||
"agent output",
|
||||
"agent ping",
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("remove 后命令从下一进程消失", async () => {
|
||||
const removed = await runCli(
|
||||
["plugin", "remove", "--name", "@ali/bailian-plugin-agent", "--output", "json"],
|
||||
env(),
|
||||
);
|
||||
expect(removed.exitCode, removed.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ removed: string }>(removed.stdout).removed).toBe(
|
||||
"@ali/bailian-plugin-agent",
|
||||
);
|
||||
|
||||
const result = await runCli(["agent", "ping", "--message", "after"], env());
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/Unknown command/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
function withTempConfigDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-config-profile-"));
|
||||
return fn(dir).finally(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(dir: string, data: Record<string, unknown>): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(data, null, 2) + "\n");
|
||||
}
|
||||
|
||||
describe("e2e: named config", () => {
|
||||
test("根帮助展示 --config 全局标志", async () => {
|
||||
const { stderr, exitCode } = await runCli(["--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--config <name>/);
|
||||
});
|
||||
|
||||
test("config set --config 写入命名 block 且不影响默认配置", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, { output: "text", api_key: "sk-default" });
|
||||
|
||||
const setResult = await runCli(
|
||||
[
|
||||
"config",
|
||||
"set",
|
||||
"--config",
|
||||
"dev",
|
||||
"--key",
|
||||
"output",
|
||||
"--value",
|
||||
"json",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ BAILIAN_CONFIG_DIR: dir },
|
||||
);
|
||||
expect(setResult.exitCode, setResult.stderr).toBe(0);
|
||||
const setData = parseStdoutJson<{
|
||||
output?: string;
|
||||
config?: string;
|
||||
config_file?: string;
|
||||
}>(setResult.stdout);
|
||||
expect(setData.output).toBe("json");
|
||||
expect(setData.config).toBe("dev");
|
||||
expect(setData.config_file).toBe(join(dir, "config.json"));
|
||||
|
||||
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(raw.output).toBe("text");
|
||||
expect((raw.dev as Record<string, unknown>).output).toBe("json");
|
||||
});
|
||||
});
|
||||
|
||||
test("config show --config 只展示命名 block", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, {
|
||||
output: "text",
|
||||
api_key: "sk-default",
|
||||
dev: { output: "json", access_token: "tok-dev" },
|
||||
});
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
["config", "show", "--config", "dev", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: dir },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<Record<string, unknown>>(stdout);
|
||||
expect(data.config).toBe("dev");
|
||||
expect(data.config_file).toBe(join(dir, "config.json"));
|
||||
expect(data.output).toBe("json");
|
||||
expect(data.access_token).toBeDefined();
|
||||
expect(data.api_key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("auth status --config 不继承默认凭证", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, { api_key: "sk-default", dev: { output: "json" } });
|
||||
|
||||
const devStatus = await runCli(["auth", "status", "--config", "dev", "--output", "json"], {
|
||||
BAILIAN_CONFIG_DIR: dir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
});
|
||||
expect(devStatus.exitCode, devStatus.stderr).toBe(0);
|
||||
const devData = parseStdoutJson<Record<string, unknown>>(devStatus.stdout);
|
||||
expect(devData.authenticated).toBe(false);
|
||||
expect(devData.config).toBe("dev");
|
||||
|
||||
const defaultStatus = await runCli(["auth", "status", "--output", "json"], {
|
||||
BAILIAN_CONFIG_DIR: dir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
});
|
||||
expect(defaultStatus.exitCode, defaultStatus.stderr).toBe(0);
|
||||
const defaultData = parseStdoutJson<Record<string, unknown>>(defaultStatus.stdout);
|
||||
expect(defaultData.authenticated).toBe(true);
|
||||
expect(defaultData.config).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
test("--config default 等价默认配置", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, {
|
||||
active_config: "token-plan",
|
||||
output: "json",
|
||||
api_key: "sk-default",
|
||||
"token-plan": { output: "text", api_key: "sk-token" },
|
||||
});
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
["config", "show", "--config", "default", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: dir },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<Record<string, unknown>>(stdout);
|
||||
expect(data.config).toBe("default");
|
||||
expect(data.active).toBeUndefined();
|
||||
expect(data.api_key).toBeDefined();
|
||||
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(raw.active_config).toBe("token-plan");
|
||||
});
|
||||
});
|
||||
|
||||
test("非法 --config 名称报 usage error", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "status", "--config", "../evil"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid config name/);
|
||||
});
|
||||
|
||||
test("auth status 文本输出分行展示选中 Config 和配置文件", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, {
|
||||
active_config: "token-plan",
|
||||
"token-plan": { api_key: "sk-token" },
|
||||
});
|
||||
|
||||
const result = await runCli(["auth", "status", "--output", "text"], {
|
||||
BAILIAN_CONFIG_DIR: dir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("Config: token-plan\n");
|
||||
expect(result.stdout).toContain(`Config file: ${join(dir, "config.json")}\n`);
|
||||
expect(result.stdout).not.toContain("Active config:");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
/**
|
||||
* Config 相关 E2E
|
||||
*/
|
||||
|
||||
describe("e2e: config", () => {
|
||||
test("config 分组展示子命令帮助且成功退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["config"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const out = `${stdout}\n${stderr}`;
|
||||
expect(out).toMatch(/config|show|set/i);
|
||||
});
|
||||
|
||||
test("config show --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["config", "show", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/show|config/i);
|
||||
});
|
||||
|
||||
test("config set --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["config", "set", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/set|--key|--value/i);
|
||||
});
|
||||
|
||||
test("config show --output json", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["config", "show", "--output", "json"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
config_file?: string;
|
||||
base_url?: string;
|
||||
timeout?: number;
|
||||
}>(stdout);
|
||||
expect(data.config_file).toBeDefined();
|
||||
expect(data.base_url).toBeDefined();
|
||||
expect(data.timeout).toBeDefined();
|
||||
});
|
||||
|
||||
test("config show --output text", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["config", "show", "--output", "text"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toMatch(/config_file|timeout|base_url/i);
|
||||
});
|
||||
|
||||
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["config", "set", "--quiet"]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/--key|--value|Usage:/i);
|
||||
});
|
||||
|
||||
test("config set 非法 key 时退出为用法错误", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"not-a-real-key",
|
||||
"--value",
|
||||
"x",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid config key|not-a-real-key/i);
|
||||
});
|
||||
|
||||
test("config set 非法 output", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"output",
|
||||
"--value",
|
||||
"yaml",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid output|text, json/i);
|
||||
});
|
||||
|
||||
test("config set 非法 timeout", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"timeout",
|
||||
"--value",
|
||||
"0",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid timeout|positive/i);
|
||||
});
|
||||
|
||||
test("config set --dry-run 不落盘(仅输出 would_set)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"output",
|
||||
"--value",
|
||||
"json",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { output?: string } }>(stdout);
|
||||
expect(data.would_set?.output).toBe("json");
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持连字符别名 key", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"default-text-model",
|
||||
"--value",
|
||||
"qwen3.7-max",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout);
|
||||
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持 AccessKey 短字段别名", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"access-key-id",
|
||||
"--value",
|
||||
"LTAI-config-placeholder",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { access_key_id?: string } }>(stdout);
|
||||
expect(data.would_set?.access_key_id).toBe("LTAI-config-placeholder");
|
||||
});
|
||||
|
||||
test("config set 不接受旧 OpenAPI AccessKey 字段名", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"openapi_access_key_id",
|
||||
"--value",
|
||||
"LTAI-config-placeholder",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid config key|openapi_access_key_id/);
|
||||
});
|
||||
});
|
||||
@@ -1,215 +1,52 @@
|
||||
import { execFile } from "child_process";
|
||||
import { mkdirSync, readFileSync } from "fs";
|
||||
import { promisify } from "util";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { readConfigFile } from "bailian-cli-core";
|
||||
import {
|
||||
cliTimeoutPrefix,
|
||||
cliTimeoutSeconds,
|
||||
e2eLabelFromMetaUrl,
|
||||
isConsoleAuthFailure,
|
||||
makeE2eOutputDir,
|
||||
parseStdoutJson,
|
||||
} from "e2e/output";
|
||||
import { runNodeMain, type RunCliResult } from "e2e/runner";
|
||||
import {
|
||||
isBailianE2EEnabled,
|
||||
isBailianE2EMediaEnabled,
|
||||
isBailianE2EVideoEnabled,
|
||||
isChatE2EReady,
|
||||
isConsoleE2EReady,
|
||||
isDashScopeE2EReady,
|
||||
isSearchE2EReady,
|
||||
} from "e2e/gating";
|
||||
import { monorepoRoot } from "e2e/monorepo-root";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
export {
|
||||
cliTimeoutPrefix,
|
||||
cliTimeoutSeconds,
|
||||
e2eLabelFromMetaUrl,
|
||||
isBailianE2EEnabled,
|
||||
isBailianE2EMediaEnabled,
|
||||
isBailianE2EVideoEnabled,
|
||||
isChatE2EReady,
|
||||
isConsoleAuthFailure,
|
||||
isConsoleE2EReady,
|
||||
isDashScopeE2EReady,
|
||||
isSearchE2EReady,
|
||||
makeE2eOutputDir,
|
||||
monorepoRoot,
|
||||
parseStdoutJson,
|
||||
};
|
||||
export type { RunCliResult };
|
||||
|
||||
/**
|
||||
* Vitest `global-setup.ts` 写入 `test/output/` 下本文件名,供各 worker 进程读取同一会话 id。
|
||||
* (仅模块内变量无法跨 Vitest 多进程 worker 共享。)
|
||||
*/
|
||||
export const E2E_RUN_SESSION_FILENAME = ".e2e-run-session";
|
||||
|
||||
/**
|
||||
* 单次 `vp test` / Vitest 运行共用的 E2E 输出会话目录名(惰性缓存于当前进程)。
|
||||
*/
|
||||
let e2eOutputSessionId: string | undefined;
|
||||
|
||||
/** `packages/cli` 根目录(含 `src/main.ts`) */
|
||||
/** `packages/cli` 根目录 */
|
||||
export const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
const mainTs = join(cliPackageRoot, "src", "main.ts");
|
||||
|
||||
/** Monorepo 根(含根 `package.json`) */
|
||||
export function monorepoRoot(): string {
|
||||
return join(cliPackageRoot, "..", "..");
|
||||
}
|
||||
|
||||
export function localBin(name: string): string {
|
||||
return join(
|
||||
monorepoRoot(),
|
||||
"node_modules",
|
||||
".bin",
|
||||
process.platform === "win32" ? `${name}.cmd` : name,
|
||||
);
|
||||
}
|
||||
|
||||
function readE2eRunSessionFromOutputDir(): string | undefined {
|
||||
try {
|
||||
const p = join(monorepoRoot(), "test", "output", E2E_RUN_SESSION_FILENAME);
|
||||
const t = readFileSync(p, "utf8").trim();
|
||||
return t.length > 0 ? t : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getE2eOutputSessionId(): string {
|
||||
if (!e2eOutputSessionId) {
|
||||
const fromEnv = process.env.BAILIAN_E2E_RUN_ID?.trim();
|
||||
if (fromEnv) {
|
||||
e2eOutputSessionId = fromEnv.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
} else {
|
||||
const fromFile = readE2eRunSessionFromOutputDir();
|
||||
if (fromFile) {
|
||||
e2eOutputSessionId = fromFile.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
} else {
|
||||
e2eOutputSessionId = `e2e-run-${Date.now()}-${process.pid}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return e2eOutputSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 `test/output/<会话>/` 下创建用例子目录。
|
||||
* 会话 id 优先 `BAILIAN_E2E_RUN_ID`,否则读 Vitest globalSetup 写入的 `test/output/.e2e-run-session`,
|
||||
* 再否则回退为单进程 id(非 Vitest 直接跑用例时)。
|
||||
* 若已设 `BAILIAN_E2E_OUT` 则直接使用(不再套会话目录)。
|
||||
*/
|
||||
export function makeE2eOutputDir(label: string): string {
|
||||
const fromEnv = process.env.BAILIAN_E2E_OUT?.trim();
|
||||
if (fromEnv) {
|
||||
mkdirSync(fromEnv, { recursive: true });
|
||||
return fromEnv;
|
||||
}
|
||||
const safe = label.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
const sessionDir = join(monorepoRoot(), "test", "output", getE2eOutputSessionId());
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
const dir = join(sessionDir, `e2e-vp-${safe}-${Date.now()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** 全局 `--timeout` 秒数(视频等长任务) */
|
||||
export function cliTimeoutSeconds(): string {
|
||||
return process.env.BAILIAN_E2E_TIMEOUT_SEC?.trim() || "3600";
|
||||
}
|
||||
|
||||
export function cliTimeoutPrefix(): string[] {
|
||||
return ["--timeout", cliTimeoutSeconds()];
|
||||
}
|
||||
|
||||
/** 显式开启后才跑真实网络 E2E,避免默认 `vp test` 依赖密钥或打外网 */
|
||||
export function isBailianE2EEnabled(): boolean {
|
||||
return process.env.BAILIAN_E2E === "1";
|
||||
}
|
||||
|
||||
/** 可调 DashScope 的 API Key:环境变量优先,否则读 ~/.bailian/config.json */
|
||||
export function isDashScopeE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
if (process.env.DASHSCOPE_API_KEY?.trim()) return true;
|
||||
try {
|
||||
const f = readConfigFile();
|
||||
return typeof f.api_key === "string" && f.api_key.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console-gateway 命令(quota / usage free / usage stats)的 E2E 就绪检查:
|
||||
* 需 `BAILIAN_E2E=1` 且存在 console access_token(`~/.bailian/config.json` 的
|
||||
* `access_token`;凭证解析已集中到 authStage,不再读环境变量)。
|
||||
*
|
||||
* 仅检查 token 是否存在——无法本地判断是否过期。token 过期时 gated 用例仍会执行,
|
||||
* 但用 `isConsoleAuthFailure` 把“session 未登录/已过期”的优雅报错视为通过,保持
|
||||
* 与 deploy/dataset “无 key / 有效 key / 失效 key 均绿”的一致策略。
|
||||
*/
|
||||
export function isConsoleE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
try {
|
||||
const config = readConfigFile();
|
||||
return typeof config.access_token === "string" && config.access_token.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 在仅跑文本/记忆/知识库时跳过) */
|
||||
export function isBailianE2EMediaEnabled(): boolean {
|
||||
if (process.env.BAILIAN_E2E_MEDIA === "0") return false;
|
||||
return isBailianE2EEnabled();
|
||||
}
|
||||
|
||||
/** 文生视频 / 图生视频 / 参考视频 / 视频编辑(耗时长,默认关闭) */
|
||||
export function isBailianE2EVideoEnabled(): boolean {
|
||||
return isBailianE2EEnabled() && process.env.BAILIAN_E2E_VIDEO === "1";
|
||||
}
|
||||
|
||||
/** 从 `import.meta.url` 生成 OUT 子目录标签,避免并行用例目录冲突 */
|
||||
export function e2eLabelFromMetaUrl(metaUrl: string): string {
|
||||
return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, "");
|
||||
}
|
||||
|
||||
/** 知识库用例:须显式索引 ID + API-KEY */
|
||||
export function isKnowledgeE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
if (!process.env.BAILIAN_E2E_INDEX_ID) return false;
|
||||
return isDashScopeE2EReady();
|
||||
}
|
||||
|
||||
export interface RunCliResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子进程执行 CLI(等价于在 `packages/cli` 下 `tsx src/main.ts ...`)。
|
||||
* request_id 等诊断信息在 stderr;`--output json` 时 JSON 在 stdout。
|
||||
*/
|
||||
/** 子进程执行 bl CLI */
|
||||
export async function runCli(
|
||||
args: string[],
|
||||
envOverrides: NodeJS.ProcessEnv = {},
|
||||
): Promise<RunCliResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(localBin("tsx"), [mainTs, ...args], {
|
||||
cwd: cliPackageRoot,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_NO_WARNINGS: "1",
|
||||
DO_NOT_TRACK: "1",
|
||||
...envOverrides,
|
||||
},
|
||||
});
|
||||
return { stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 };
|
||||
} catch (err: unknown) {
|
||||
const e = err as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number;
|
||||
};
|
||||
return {
|
||||
stdout: e.stdout ?? "",
|
||||
stderr: e.stderr ?? "",
|
||||
exitCode: typeof e.code === "number" ? e.code : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStdoutJson<T = unknown>(stdout: string): T {
|
||||
const t = stdout.trim();
|
||||
// Extract JSON object — stdout may contain [perf] console.time lines before JSON
|
||||
const jsonMatch = t.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) throw new Error(`No JSON object found in stdout: ${t.slice(0, 200)}`);
|
||||
return JSON.parse(jsonMatch[0]) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一次 CLI 运行是否因 console session 未登录/已过期而失败。
|
||||
*
|
||||
* Console E2E 用例的 readiness 闸(`isConsoleE2EReady`)只能判断 token 是否存在,
|
||||
* 无法判断是否过期;token 失效时 gated 用例仍会执行并拿到鉴权错误。本函数让用例
|
||||
* 参考 deploy/dataset 的做法:只要 CLI 把鉴权错误优雅上抛(非零退出 + stderr 说明
|
||||
* session 失效),即视为通过,而不是强求 exit 0 的成功输出。
|
||||
*/
|
||||
export function isConsoleAuthFailure(result: RunCliResult): boolean {
|
||||
if (result.exitCode === 0) return false;
|
||||
return /not logged in|has expired|NotLogined|Run `bl auth login/i.test(result.stderr);
|
||||
return runNodeMain(mainTs, args, { cwd: cliPackageRoot, env: envOverrides });
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
interface DryRunBody {
|
||||
endpoint?: string;
|
||||
request?: {
|
||||
query?: string;
|
||||
agent_id?: string;
|
||||
images?: string[];
|
||||
query_history?: Array<{ role: string; content: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("e2e: knowledge search", () => {
|
||||
test("knowledge search --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["knowledge", "search", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--query/i);
|
||||
expect(stderr).toMatch(/--agent-id/i);
|
||||
expect(stderr).toMatch(/--workspace-id/i);
|
||||
expect(stderr).toMatch(/--image/i);
|
||||
expect(stderr).toMatch(/--query-history/i);
|
||||
});
|
||||
|
||||
test("缺少 --query 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["knowledge", "search", "--agent-id", "aid_test"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--query|Usage:/i);
|
||||
});
|
||||
|
||||
test("缺少 --agent-id 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["knowledge", "search", "--query", "test"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--agent-id|Usage:/i);
|
||||
});
|
||||
|
||||
test("缺少 --workspace-id 时非零退出并提示", async () => {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
// 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入
|
||||
[
|
||||
"knowledge",
|
||||
"search",
|
||||
"--query",
|
||||
"test",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--api-key",
|
||||
"sk-fake",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ BAILIAN_WORKSPACE_ID: "", BAILIAN_CONFIG_DIR: "/tmp" },
|
||||
);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/workspace.*required/i);
|
||||
});
|
||||
|
||||
test("--dry-run 输出 endpoint 和 request body", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"knowledge",
|
||||
"search",
|
||||
"--dry-run",
|
||||
"--query",
|
||||
"什么是RAG",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/ws_test\.cn-beijing\.maas\.aliyuncs\.com/);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/knowledge\/search/);
|
||||
expect(data.request?.query).toBe("什么是RAG");
|
||||
expect(data.request?.agent_id).toBe("aid_test");
|
||||
});
|
||||
|
||||
test("--dry-run + --image 输出 images", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"knowledge",
|
||||
"search",
|
||||
"--dry-run",
|
||||
"--query",
|
||||
"test",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--image",
|
||||
"https://example.com/a.jpg",
|
||||
"--image",
|
||||
"https://example.com/b.jpg",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.request?.images).toEqual([
|
||||
"https://example.com/a.jpg",
|
||||
"https://example.com/b.jpg",
|
||||
]);
|
||||
});
|
||||
|
||||
test("--dry-run + --query-history 输出用户对话历史", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"knowledge",
|
||||
"search",
|
||||
"--dry-run",
|
||||
"--query",
|
||||
"它怎么工作",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--query-history",
|
||||
'[{"role":"user","content":"什么是RAG"},{"role":"assistant","content":"RAG是检索增强生成"}]',
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.request?.query_history).toEqual([
|
||||
{ role: "user", content: "什么是RAG" },
|
||||
{ role: "assistant", content: "RAG是检索增强生成" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("--dry-run + --query-history 无效 JSON 非零退出", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"knowledge",
|
||||
"search",
|
||||
"--dry-run",
|
||||
"--query",
|
||||
"test",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--query-history",
|
||||
"not-valid-json",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/query-history.*valid JSON/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { deriveGroupPaths } from "e2e/registry-smoke";
|
||||
import { commands } from "../../src/commands.ts";
|
||||
import { runCli } from "./helpers.ts";
|
||||
|
||||
const commandPaths = Object.keys(commands).sort();
|
||||
const groupPaths = deriveGroupPaths(commandPaths);
|
||||
|
||||
describe("e2e: bl registry smoke", () => {
|
||||
test("根帮助展示 bl 与全局 flag", async () => {
|
||||
const { stderr, exitCode } = await runCli(["--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/\bbl\b/i);
|
||||
expect(stderr).toMatch(/--base-url/);
|
||||
expect(stderr).toMatch(/--console-region/);
|
||||
expect(stderr).toMatch(/--console-site/);
|
||||
expect(stderr).toMatch(/--console-switch-agent/);
|
||||
expect(stderr).not.toMatch(/^\s*--region\s/m);
|
||||
});
|
||||
|
||||
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/Global Flags:/);
|
||||
expect(stderr).toMatch(/--console-region <region>/);
|
||||
expect(stderr).toMatch(/--model <model>/);
|
||||
expect(stderr).toMatch(/--period <minutes>/);
|
||||
expect(stderr).toMatch(/--output <format>/);
|
||||
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
|
||||
});
|
||||
|
||||
test.each(commandPaths)("已注册命令 %s --help 成功", async (path) => {
|
||||
const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
});
|
||||
|
||||
test.each(groupPaths)("命令分组 %s --help 成功", async (path) => {
|
||||
const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
describe("e2e: usage summary", () => {
|
||||
test("usage summary --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["usage", "summary", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--days|summary|usage/i);
|
||||
});
|
||||
|
||||
test("usage summary --help 包含所有示例", async () => {
|
||||
const { stderr, exitCode } = await runCli(["usage", "summary", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("bl usage summary");
|
||||
expect(stderr).toContain("bl usage summary --days 30");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isConsoleE2EReady())("e2e: usage summary(Console)", () => {
|
||||
test("usage summary --dry-run 输出 free-tier 计划请求", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"usage",
|
||||
"summary",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ freeTier?: { api?: string }; usage?: unknown }>(stdout);
|
||||
expect(data.freeTier?.api).toContain("queryFreeTierQuota");
|
||||
});
|
||||
|
||||
test("usage summary --dry-run --workspace-id 附带用量概览计划请求", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"usage",
|
||||
"summary",
|
||||
"--dry-run",
|
||||
"--workspace-id",
|
||||
"ws-e2e-dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
usage?: { api?: string; data?: { reqDTO?: { filterWorkspaceId?: string } } };
|
||||
}>(stdout);
|
||||
expect(data.usage?.api).toContain("getModelUsageStatistic");
|
||||
expect(data.usage?.data?.reqDTO?.filterWorkspaceId).toBe("ws-e2e-dry-run");
|
||||
});
|
||||
|
||||
test("usage summary 文本输出正常返回", async () => {
|
||||
const result = await runCli(["usage", "summary", "--output", "text"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage summary JSON 输出包含 freeTier 字段", async () => {
|
||||
const result = await runCli(["usage", "summary", "--output", "json"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ freeTier?: unknown; period?: unknown }>(result.stdout);
|
||||
expect(data.period).toBeTypeOf("object");
|
||||
expect(Array.isArray(data.freeTier)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
cliTimeoutPrefix,
|
||||
e2eLabelFromMetaUrl,
|
||||
isBailianE2EVideoEnabled,
|
||||
isDashScopeE2EReady,
|
||||
makeE2eOutputDir,
|
||||
parseStdoutJson,
|
||||
runCli,
|
||||
} from "./helpers.ts";
|
||||
|
||||
/**
|
||||
* Video generate (i2v):help / 分组不依赖密钥;长任务需视频 E2E + DashScope。
|
||||
*/
|
||||
|
||||
describe("e2e: video generate (i2v)", () => {
|
||||
test("video 分组展示子命令帮助且成功退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["video"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i);
|
||||
});
|
||||
|
||||
test("video generate --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["video", "generate", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/generate|--prompt|--image|model/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"e2e: video generate (i2v)(DashScope 视频)",
|
||||
() => {
|
||||
test("video generate 缺少 --prompt 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"video",
|
||||
"generate",
|
||||
...cliTimeoutPrefix(),
|
||||
"--model",
|
||||
"happyhorse-1.1-i2v",
|
||||
"--image",
|
||||
"https://example.com/placeholder.png",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--prompt|Usage:/i);
|
||||
});
|
||||
|
||||
test("video generate --dry-run(无 --image)仅输出 request(t2v 路径不调上传)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"video",
|
||||
"generate",
|
||||
...cliTimeoutPrefix(),
|
||||
"--dry-run",
|
||||
"--model",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--prompt",
|
||||
"干跑无图",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ request?: { input?: { prompt?: string; media?: unknown } } }>(
|
||||
stdout,
|
||||
);
|
||||
expect(data.request?.input?.prompt).toBe("干跑无图");
|
||||
expect(data.request?.input?.media).toBeUndefined();
|
||||
});
|
||||
|
||||
test("【happyhorse-1.1-i2v】图片生成视频", async () => {
|
||||
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
|
||||
const png = join(outDir, "e2e-gen.png");
|
||||
const gen = await runCli([
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"--prompt",
|
||||
"一只简笔画小猫,白底",
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--out-prefix",
|
||||
"e2e-gen",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(gen.exitCode, gen.stderr).toBe(0);
|
||||
const genData = parseStdoutJson<{ saved?: string[] }>(gen.stdout);
|
||||
const imagePath = genData.saved?.[0] ?? png;
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"video",
|
||||
"generate",
|
||||
...cliTimeoutPrefix(),
|
||||
"--model",
|
||||
"happyhorse-1.1-i2v",
|
||||
"--image",
|
||||
imagePath,
|
||||
"--prompt",
|
||||
"镜头缓慢推进,小猫微微动一下",
|
||||
"--download",
|
||||
join(outDir, "e2e-video-i2v.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);
|
||||
}, 3_600_000);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
const ping = {
|
||||
description: "Ping the Command Pack fixture",
|
||||
auth: "none",
|
||||
flags: {
|
||||
message: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
required: true,
|
||||
description: "Message returned by the fixture",
|
||||
},
|
||||
},
|
||||
usageArgs: "--message <text>",
|
||||
exampleArgs: ['--message "hello"'],
|
||||
async run(ctx) {
|
||||
process.stdout.write(`command-pack:${ctx.flags.message}\n`);
|
||||
},
|
||||
};
|
||||
|
||||
const credential = {
|
||||
description: "Read an API key through the Command Pack host adapter",
|
||||
auth: "apiKey",
|
||||
async run(ctx) {
|
||||
const apiKey = ctx.credentials.apiKey();
|
||||
process.stdout.write(`credential-source:${apiKey.source}\n`);
|
||||
},
|
||||
};
|
||||
|
||||
const credentialDenied = {
|
||||
description: "Verify credential access also requires command auth",
|
||||
auth: "none",
|
||||
async run(ctx) {
|
||||
ctx.credentials.apiKey();
|
||||
},
|
||||
};
|
||||
|
||||
const output = {
|
||||
description: "Exercise the Command Pack output helper",
|
||||
auth: "none",
|
||||
async run(ctx) {
|
||||
ctx.output.result({ source: "command-pack", ok: true }, { text: "command-pack-output" });
|
||||
},
|
||||
};
|
||||
|
||||
const fail = {
|
||||
description: "Exercise the Command Pack semantic error helper",
|
||||
auth: "none",
|
||||
async run(ctx) {
|
||||
throw ctx.errors.usage("Command Pack fixture usage error.", "Use agent fail only in tests.");
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
"agent credential": credential,
|
||||
"agent credential-denied": credentialDenied,
|
||||
"agent fail": fail,
|
||||
"agent output": output,
|
||||
"agent ping": ping,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@ali/bailian-plugin-agent",
|
||||
"version": "0.0.0-test",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bailianCli": {
|
||||
"type": "command-pack",
|
||||
"apiVersion": 1,
|
||||
"entry": "./commands.mjs",
|
||||
"minCliVersion": "1.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { credentialFlagDefs, GLOBAL_FLAGS, type AnyCommand } from "bailian-cli-core";
|
||||
import { monorepoRoot } from "e2e/monorepo-root";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { commands } from "../src/commands.ts";
|
||||
|
||||
const repositoryRoot = monorepoRoot();
|
||||
const installGuide = readFileSync(join(repositoryRoot, "INSTALL.md"), "utf8");
|
||||
const cliPackage = JSON.parse(
|
||||
readFileSync(join(repositoryRoot, "packages/cli/package.json"), "utf8"),
|
||||
) as {
|
||||
engines?: { node?: string };
|
||||
};
|
||||
|
||||
function toFlagName(key: string): string {
|
||||
return `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
|
||||
}
|
||||
|
||||
function findDocumentedCommand(snippet: string): {
|
||||
commandPath?: string;
|
||||
command?: AnyCommand;
|
||||
} {
|
||||
const argumentText = snippet.slice("bl ".length).trim();
|
||||
const commandPath = Object.keys(commands)
|
||||
.sort((leftPath, rightPath) => rightPath.length - leftPath.length)
|
||||
.find((candidatePath) => {
|
||||
return argumentText === candidatePath || argumentText.startsWith(`${candidatePath} `);
|
||||
});
|
||||
|
||||
return commandPath ? { commandPath, command: commands[commandPath] } : {};
|
||||
}
|
||||
|
||||
function documentedCommandSnippets(): string[] {
|
||||
const fencedCommands = installGuide
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("bl "));
|
||||
const inlineCommands = Array.from(installGuide.matchAll(/`(bl [^`\n]+)`/g), (match) => match[1]);
|
||||
return [...new Set([...fencedCommands, ...inlineCommands])];
|
||||
}
|
||||
|
||||
describe("INSTALL.md", () => {
|
||||
test("发布包 Node.js 要求与安装文档一致", () => {
|
||||
const nodeEngine = cliPackage.engines?.node;
|
||||
expect(nodeEngine).toMatch(/^>=\d+\.\d+\.\d+$/);
|
||||
expect(installGuide).toContain(`要求 **≥ ${nodeEngine?.slice(2)}**`);
|
||||
});
|
||||
|
||||
test("示例只使用当前命令支持的 flags", () => {
|
||||
for (const snippet of documentedCommandSnippets()) {
|
||||
const { commandPath, command } = findDocumentedCommand(snippet);
|
||||
const argumentText = snippet.slice("bl ".length).trim();
|
||||
|
||||
if (!commandPath || !command) {
|
||||
expect(argumentText, `INSTALL.md 中存在未知命令:${snippet}`).toMatch(/^--/);
|
||||
}
|
||||
|
||||
const supportedFlags = {
|
||||
...GLOBAL_FLAGS,
|
||||
...(command ? credentialFlagDefs(command) : {}),
|
||||
...command?.flags,
|
||||
};
|
||||
const supportedFlagNames = new Set(Object.keys(supportedFlags).map(toFlagName));
|
||||
const usedFlagNames = Array.from(snippet.matchAll(/--[a-z0-9-]+/g), (match) => match[0]);
|
||||
const unsupportedFlagNames = usedFlagNames.filter(
|
||||
(flagName) => !supportedFlagNames.has(flagName),
|
||||
);
|
||||
|
||||
expect(unsupportedFlagNames, `INSTALL.md 命令使用了未声明的 flag:${snippet}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from "vite-plus";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globalSetup: "./tests/e2e/global-setup.ts",
|
||||
globalSetup: "../e2e/src/global-setup.ts",
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 60_000,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.7.0",
|
||||
"version": "1.10.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": {
|
||||
@@ -44,15 +44,17 @@
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"smol-toml": "catalog:",
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260328.1",
|
||||
"e2e": "workspace:*",
|
||||
"typescript": "^6.0.2",
|
||||
"vite-plus": "0.1.22"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,9 +272,7 @@ export default defineCommand({
|
||||
return result;
|
||||
});
|
||||
|
||||
const analyzeIntentPromise = analyzeIntent(ctx.client, userInput, {
|
||||
intentDetectBaseUrl: settings.intentDetectBaseUrl,
|
||||
}).then((result) => {
|
||||
const analyzeIntentPromise = analyzeIntent(ctx.client, userInput).then((result) => {
|
||||
intentReady = true;
|
||||
if (!modelsReady) {
|
||||
spinner.update("Agent: Intent analyzed, loading model data...");
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
generateCLIAccessToken,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const FLAGS = {
|
||||
accessKeyId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Alibaba Cloud Access Key ID",
|
||||
required: true,
|
||||
},
|
||||
accessKeySecret: {
|
||||
type: "string",
|
||||
valueHint: "<secret>",
|
||||
description: "Alibaba Cloud Access Key Secret",
|
||||
required: true,
|
||||
},
|
||||
securityToken: {
|
||||
type: "string",
|
||||
valueHint: "<token>",
|
||||
description: "Alibaba Cloud STS Security Token to store (optional)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Generate a CLI access token using OpenAPI AK/SK",
|
||||
auth: "none",
|
||||
usageArgs: "--access-key-id <id> --access-key-secret <secret> --security-token <token>",
|
||||
flags: FLAGS,
|
||||
exampleArgs: ["--access-key-id LTAIxxxxx --access-key-secret xxxxx --security-token <token>"],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const resp = await generateCLIAccessToken({
|
||||
identity,
|
||||
settings,
|
||||
baseUrl: ctx.client.baseUrl,
|
||||
accessKeyId: flags.accessKeyId,
|
||||
accessKeySecret: flags.accessKeySecret,
|
||||
securityToken: flags.securityToken || undefined,
|
||||
});
|
||||
|
||||
emitResult(resp, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
chatPath,
|
||||
requestJson,
|
||||
normalizeModelBaseUrl,
|
||||
type AuthPersistPatch,
|
||||
type AuthStore,
|
||||
type Identity,
|
||||
type Settings,
|
||||
} 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;
|
||||
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(
|
||||
deps: ApiKeyLoginDeps,
|
||||
key: string,
|
||||
profile: ApiKeyLoginProfile,
|
||||
): Promise<void> {
|
||||
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 = profile.defaultTextModel || "qwen3.7-max";
|
||||
const requestOpts = {
|
||||
url: baseUrl + chatPath(),
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
timeout: Math.min(deps.settings.timeout, 30),
|
||||
body: {
|
||||
model: validationModel,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
enable_thinking: validationModel === "qwen3.8-max-preview",
|
||||
},
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await requestJson<unknown>(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,
|
||||
base_url: persistBaseUrl,
|
||||
default_text_model: profile.defaultTextModel,
|
||||
default_video_model: profile.defaultVideoModel,
|
||||
default_image_to_video_model: profile.defaultImageToVideoModel,
|
||||
default_reference_to_video_model: profile.defaultReferenceToVideoModel,
|
||||
default_image_model: profile.defaultImageModel,
|
||||
});
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import http from "node:http";
|
||||
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
chatPath,
|
||||
getConfigPath,
|
||||
requestJson,
|
||||
type AuthPersistPatch,
|
||||
type AuthStore,
|
||||
type ConfigFile,
|
||||
type Identity,
|
||||
type Settings,
|
||||
} from "bailian-cli-core";
|
||||
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
|
||||
import { validateAndPersistApiKey } from "./login-api-key.ts";
|
||||
|
||||
/** 登录流程的能力面:身份(UA)、有效配置(timeout 等)、auth 域落盘。 */
|
||||
export interface LoginDeps {
|
||||
@@ -23,6 +22,9 @@ export interface LoginDeps {
|
||||
|
||||
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const MAX_AUTH_CALLBACK_BODY = 65536;
|
||||
// Regex for double newline (\r\n\r\n or \n\n); built via RegExp to avoid
|
||||
// literal multi-line splitting in source.
|
||||
const REGEX_DOUBLE_NEWLINE = new RegExp("\r\n\r\n|\n\n");
|
||||
|
||||
const CONSOLE_ORIGINS: Record<string, string> = {
|
||||
domestic: "https://bailian.console.aliyun.com",
|
||||
@@ -76,7 +78,7 @@ function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): stri
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const part = segments[i]!;
|
||||
if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue;
|
||||
const sep = part.match(/\r\n\r\n|\n\n/);
|
||||
const sep = part.match(REGEX_DOUBLE_NEWLINE);
|
||||
if (!sep || sep.index === undefined) continue;
|
||||
let value = part.slice(sep.index + sep[0].length);
|
||||
value = value
|
||||
@@ -359,90 +361,7 @@ async function extractCredentialsFromRequest(
|
||||
}
|
||||
|
||||
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onErr = (e: Error) => reject(e);
|
||||
server.once("error", onErr);
|
||||
server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => {
|
||||
server.off("error", onErr);
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
reject(new Error("Expected TCP socket address"));
|
||||
return;
|
||||
}
|
||||
resolve(addr.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openInBrowser(url: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { windowsHide: true }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const RETRY_DELAY_BASE_MS = 500;
|
||||
|
||||
function canRetry(err: unknown): boolean {
|
||||
if (err instanceof BailianError) {
|
||||
if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) return true;
|
||||
const status = err.api?.httpStatus;
|
||||
return status === 401 || (status !== undefined && status >= 500);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return (
|
||||
err.name === "AbortError" ||
|
||||
err.name === "TimeoutError" ||
|
||||
err.message.includes("timed out") ||
|
||||
err.message === "fetch failed"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function validateAndPersistApiKey(
|
||||
deps: LoginDeps,
|
||||
key: string,
|
||||
baseUrl: string,
|
||||
): Promise<void> {
|
||||
process.stderr.write("Testing key... ");
|
||||
const httpDeps = { identity: deps.identity, settings: deps.settings };
|
||||
const requestOpts = {
|
||||
url: baseUrl + chatPath(),
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
timeout: Math.min(deps.settings.timeout, 30),
|
||||
body: {
|
||||
model: "qwen3.7-max",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await requestJson<unknown>(httpDeps, requestOpts);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt >= 3 || !canRetry(err)) {
|
||||
process.stderr.write("Failed\n");
|
||||
throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
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({ api_key: key });
|
||||
return listenLocalServer(server);
|
||||
}
|
||||
|
||||
export async function runConsoleLogin(
|
||||
@@ -486,20 +405,27 @@ export async function runConsoleLogin(
|
||||
|
||||
if (hasConfig || apiKey) {
|
||||
try {
|
||||
if (hasConfig) {
|
||||
await deps.authStore.login({
|
||||
access_token: accessToken || undefined,
|
||||
base_url: baseUrl || undefined,
|
||||
console_site: (consoleSite || undefined) as ConfigFile["console_site"],
|
||||
console_region: consoleRegion || undefined,
|
||||
console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined,
|
||||
workspace_id: workspaceId || undefined,
|
||||
});
|
||||
process.stderr.write(`Config saved to ${getConfigPath()}\n`);
|
||||
}
|
||||
const callbackPatch: AuthPersistPatch = {
|
||||
access_token: accessToken || undefined,
|
||||
console_site: (consoleSite || undefined) as ConfigFile["console_site"],
|
||||
console_region: consoleRegion || undefined,
|
||||
console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined,
|
||||
workspace_id: workspaceId || undefined,
|
||||
};
|
||||
if (apiKey) {
|
||||
const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl();
|
||||
await validateAndPersistApiKey(deps, apiKey, testBaseUrl);
|
||||
await validateAndPersistApiKey(deps, apiKey, {
|
||||
baseUrl: testBaseUrl,
|
||||
persistBaseUrl: baseUrl || undefined,
|
||||
persistPatch: callbackPatch,
|
||||
});
|
||||
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
|
||||
} else if (hasConfig) {
|
||||
await deps.authStore.login({
|
||||
...callbackPatch,
|
||||
base_url: baseUrl || undefined,
|
||||
});
|
||||
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
callbackError = err;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { defineCommand, getConfigPath } from "bailian-cli-core";
|
||||
import { emitBare } from "bailian-cli-runtime";
|
||||
import {
|
||||
resolveConsoleOrigin,
|
||||
runConsoleLogin,
|
||||
validateAndPersistApiKey,
|
||||
} from "./login-console.ts";
|
||||
defineCommand,
|
||||
generateCLIAccessToken,
|
||||
getModelProfilePreset,
|
||||
normalizeModelBaseUrl,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare } from "bailian-cli-runtime";
|
||||
import { validateAndPersistApiKey } 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";
|
||||
|
||||
@@ -19,11 +21,11 @@ export default defineCommand({
|
||||
usageArgs:
|
||||
"--api-key <key> | --console | --open-api --access-key-id <id> --access-key-secret <secret>",
|
||||
flags: {
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "DashScope API key to store" },
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "Model API key to store" },
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
description: "DashScope API base URL (used with --api-key for validation)",
|
||||
description: "Model API base URL (used with --api-key for validation)",
|
||||
},
|
||||
console: {
|
||||
type: "switch",
|
||||
@@ -52,6 +54,7 @@ export default defineCommand({
|
||||
},
|
||||
exampleArgs: [
|
||||
"--api-key sk-xxxxx",
|
||||
"--config token-plan --api-key sk-sp-xxxxx",
|
||||
"--console",
|
||||
"--open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx",
|
||||
],
|
||||
@@ -87,10 +90,10 @@ export default defineCommand({
|
||||
},
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const store = ctx.authStore();
|
||||
const store = ctx.authStore;
|
||||
const deps = { identity, settings, authStore: store };
|
||||
const key = flags.apiKey;
|
||||
const baseUrl = flags.baseUrl || undefined;
|
||||
const baseUrl = flags.baseUrl ? normalizeModelBaseUrl(flags.baseUrl) : undefined;
|
||||
|
||||
if (flags.console) {
|
||||
if (settings.dryRun) {
|
||||
@@ -110,14 +113,25 @@ export default defineCommand({
|
||||
|
||||
if (flags.openApi) {
|
||||
if (settings.dryRun) {
|
||||
emitBare("Would save OpenAPI AK/SK credentials.");
|
||||
emitBare("Would save OpenAPI AK/SK credentials and generate CLI access token.");
|
||||
return;
|
||||
}
|
||||
const resolvedBaseUrl = store.resolveBaseUrl();
|
||||
process.stderr.write("Generating CLI access token... ");
|
||||
const resp = await generateCLIAccessToken({
|
||||
identity,
|
||||
settings,
|
||||
baseUrl: resolvedBaseUrl,
|
||||
accessKeyId: flags.accessKeyId!,
|
||||
accessKeySecret: flags.accessKeySecret!,
|
||||
});
|
||||
const accessToken = resp.cliAccessToken;
|
||||
await store.login({
|
||||
access_key_id: flags.accessKeyId,
|
||||
access_key_secret: flags.accessKeySecret,
|
||||
access_token: accessToken,
|
||||
});
|
||||
process.stderr.write(`OpenAPI credentials saved to ${getConfigPath()}\n`);
|
||||
process.stderr.write(`OpenAPI credentials saved to ${store.path}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,9 +142,18 @@ export default defineCommand({
|
||||
emitBare("Would validate and save API key.");
|
||||
return;
|
||||
}
|
||||
if (baseUrl) {
|
||||
await store.login({ base_url: baseUrl });
|
||||
}
|
||||
await validateAndPersistApiKey(deps, key, baseUrl || store.resolveBaseUrl());
|
||||
const profilePreset = getModelProfilePreset(settings.configName);
|
||||
const storedBaseUrl = store.stored().baseUrl;
|
||||
const resolvedBaseUrl = baseUrl || store.resolveBaseUrl(profilePreset?.baseUrl);
|
||||
const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined);
|
||||
await validateAndPersistApiKey(deps, key, {
|
||||
baseUrl: resolvedBaseUrl,
|
||||
persistBaseUrl,
|
||||
defaultTextModel: profilePreset?.defaultTextModel,
|
||||
defaultVideoModel: profilePreset?.defaultVideoModel,
|
||||
defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel,
|
||||
defaultReferenceToVideoModel: profilePreset?.defaultReferenceToVideoModel,
|
||||
defaultImageModel: profilePreset?.defaultImageModel,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { defineCommand, getConfigPath } from "bailian-cli-core";
|
||||
import { defineCommand } from "bailian-cli-core";
|
||||
import { emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Clear stored credentials",
|
||||
description: "Clear stored credentials; full logout also clears the model Base URL",
|
||||
auth: "none",
|
||||
usageArgs: "[--console | --open-api] [--dry-run]",
|
||||
flags: {
|
||||
@@ -12,7 +12,7 @@ export default defineCommand({
|
||||
},
|
||||
openApi: {
|
||||
type: "switch",
|
||||
description: "Only clear OpenAPI AK/SK credentials, keep other credentials intact",
|
||||
description: "Only clear OpenAPI AK/SK/STS credentials, keep other credentials intact",
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--console", "--open-api", "--dry-run"],
|
||||
@@ -20,18 +20,18 @@ export default defineCommand({
|
||||
f.console && f.openApi ? "Use only one scope: --console or --open-api" : undefined,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const store = ctx.authStore();
|
||||
const store = ctx.authStore;
|
||||
const stored = store.stored();
|
||||
|
||||
if (flags.console) {
|
||||
if (settings.dryRun) {
|
||||
if (stored.console) emitBare("Would clear access_token from ~/.bailian/config.json");
|
||||
if (stored.console) emitBare(`Would clear access_token from ${store.path}`);
|
||||
else emitBare("No console access_token to clear.");
|
||||
emitBare("No changes made.");
|
||||
return;
|
||||
}
|
||||
if (await store.logout("console")) {
|
||||
process.stderr.write(`Cleared access_token from ${getConfigPath()}\n`);
|
||||
process.stderr.write(`Cleared access_token from ${store.path}\n`);
|
||||
if (stored.apiKey) {
|
||||
process.stderr.write(
|
||||
"api_key is still configured and will be used for authentication.\n",
|
||||
@@ -46,13 +46,17 @@ export default defineCommand({
|
||||
if (flags.openApi) {
|
||||
if (settings.dryRun) {
|
||||
if (stored.openapi)
|
||||
emitBare("Would clear access_key_id / access_key_secret from ~/.bailian/config.json");
|
||||
emitBare(
|
||||
`Would clear access_key_id / access_key_secret / security_token from ${store.path}`,
|
||||
);
|
||||
else emitBare("No OpenAPI AK/SK credentials to clear.");
|
||||
emitBare("No changes made.");
|
||||
return;
|
||||
}
|
||||
if (await store.logout("openapi")) {
|
||||
process.stderr.write(`Cleared access_key_id / access_key_secret from ${getConfigPath()}\n`);
|
||||
process.stderr.write(
|
||||
`Cleared access_key_id / access_key_secret / security_token from ${store.path}\n`,
|
||||
);
|
||||
if (stored.apiKey || stored.console) {
|
||||
process.stderr.write(
|
||||
"Other credentials are still configured and will be used for authentication.\n",
|
||||
@@ -64,24 +68,24 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const hasKey = stored.apiKey || stored.console || stored.openapi;
|
||||
const hasStoredAuth = stored.apiKey || stored.console || stored.openapi || !!stored.baseUrl;
|
||||
|
||||
if (settings.dryRun) {
|
||||
if (hasKey)
|
||||
if (hasStoredAuth)
|
||||
emitBare(
|
||||
"Would clear api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json",
|
||||
`Would clear api_key / base_url / access_token / access_key_id / access_key_secret / security_token from ${store.path}`,
|
||||
);
|
||||
else emitBare("No credentials to clear.");
|
||||
else emitBare("No credentials or model Base URL to clear.");
|
||||
emitBare("No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await store.logout("all")) {
|
||||
process.stderr.write(
|
||||
"Cleared api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json\n",
|
||||
`Cleared api_key / base_url / access_token / access_key_id / access_key_secret / security_token from ${store.path}\n`,
|
||||
);
|
||||
} else {
|
||||
process.stderr.write("No credentials to clear.\n");
|
||||
process.stderr.write("No credentials or model Base URL to clear.\n");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { identity, settings } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const auth = ctx.authStore().describe();
|
||||
const auth = ctx.authStore.describe();
|
||||
|
||||
const apiKey = auth.apiKey
|
||||
? {
|
||||
@@ -35,11 +35,15 @@ export default defineCommand({
|
||||
: undefined;
|
||||
|
||||
const authenticated = !!(apiKey || consoleCred || openapi);
|
||||
const configName = settings.configName ?? "default";
|
||||
const configFile = ctx.authStore.path;
|
||||
|
||||
if (!authenticated) {
|
||||
emitResult(
|
||||
{
|
||||
authenticated: false,
|
||||
config: configName,
|
||||
config_file: configFile,
|
||||
message: "Not authenticated.",
|
||||
hint: [
|
||||
`API key (model): ${identity.binName} auth login --api-key <key> or DASHSCOPE_API_KEY`,
|
||||
@@ -54,10 +58,22 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
if (format !== "text") {
|
||||
emitResult({ authenticated: true, api_key: apiKey, console: consoleCred, openapi }, format);
|
||||
emitResult(
|
||||
{
|
||||
authenticated: true,
|
||||
config: configName,
|
||||
config_file: configFile,
|
||||
api_key: apiKey,
|
||||
console: consoleCred,
|
||||
openapi,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emitBare(`Config: ${configName}`);
|
||||
emitBare(`Config file: ${configFile}`);
|
||||
emitBare("Authentication Status:");
|
||||
if (apiKey) {
|
||||
emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { platform } from "os";
|
||||
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
|
||||
|
||||
const FLAGS = {
|
||||
agent: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: `Target agent: ${VALID_AGENT_NAMES.join(", ")}`,
|
||||
required: true,
|
||||
choices: VALID_AGENT_NAMES,
|
||||
},
|
||||
baseUrl: { type: "string", valueHint: "<url>", description: "API base URL", required: true },
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "API key", required: true },
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Default model name",
|
||||
required: true,
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Configure a coding agent to use DashScope API",
|
||||
auth: "none",
|
||||
usageArgs: "--agent <name> --base-url <url> --api-key <key> --model <model>",
|
||||
flags: FLAGS,
|
||||
exampleArgs: [
|
||||
"--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max",
|
||||
"--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
"--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const agentName = flags.agent;
|
||||
const { baseUrl, apiKey, model } = flags;
|
||||
const agentDef = AGENTS[agentName];
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Hermes has no native Windows support.
|
||||
if (agentName === "hermes" && platform() === "win32") {
|
||||
process.stderr.write(
|
||||
"Warning: Hermes Agent does not support native Windows. Please use WSL2.\n",
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
agent: agentName,
|
||||
label: agentDef.label,
|
||||
base_url: baseUrl,
|
||||
api_key: maskToken(apiKey),
|
||||
model,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const params: WriteParams = { baseUrl, apiKey, model };
|
||||
const summary = agentDef.write(params);
|
||||
|
||||
if (!settings.quiet) {
|
||||
emitBare(`${agentDef.label} configured successfully.`);
|
||||
for (const path of summary.paths) emitBare(` Written: ${path}`);
|
||||
emitBare(` ${summary.nextStep}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type { WriteParams, WriteSummary, AgentDef } from "./writers/utils.ts";
|
||||
|
||||
import type { AgentDef } from "./writers/utils.ts";
|
||||
import claudeCode from "./writers/claude-code.ts";
|
||||
import qwenCode from "./writers/qwen-code.ts";
|
||||
import opencode from "./writers/opencode.ts";
|
||||
import openclaw from "./writers/openclaw.ts";
|
||||
import hermes from "./writers/hermes.ts";
|
||||
import codex from "./writers/codex.ts";
|
||||
|
||||
export const AGENTS: Record<string, AgentDef> = {
|
||||
"claude-code": claudeCode,
|
||||
"qwen-code": qwenCode,
|
||||
opencode,
|
||||
openclaw,
|
||||
hermes,
|
||||
codex,
|
||||
};
|
||||
|
||||
export const VALID_AGENT_NAMES = Object.keys(AGENTS) as [string, ...string[]];
|
||||
@@ -0,0 +1,37 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "Claude Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".claude", "settings.json");
|
||||
const onboardingPath = join(homedir(), ".claude.json");
|
||||
|
||||
// settings.json — merge env. Base URL + auth token connect Claude Code to
|
||||
// the endpoint; the model tier vars force every tier onto the chosen model.
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
env.ANTHROPIC_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
|
||||
env.CLAUDE_CODE_SUBAGENT_MODEL = model;
|
||||
settings.env = env;
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
// .claude.json — skip the onboarding prompt on first launch.
|
||||
backup(onboardingPath);
|
||||
const onboarding = readJson(onboardingPath);
|
||||
onboarding.hasCompletedOnboarding = true;
|
||||
writeJsonAtomic(onboardingPath, onboarding);
|
||||
|
||||
return {
|
||||
paths: [settingsPath, onboardingPath],
|
||||
nextStep: "Run `claude` to start using Claude Code with DashScope.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import { backup, readJson, writeJsonAtomic, writeTextAtomic, type AgentDef } from "./utils.ts";
|
||||
|
||||
const PROVIDER_KEY = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Codex",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".codex", "config.toml");
|
||||
|
||||
// config.toml — merge into existing config so unrelated settings
|
||||
// (mcp_servers, approval_policy, other providers, ...) are preserved.
|
||||
backup(configPath);
|
||||
let config: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
config = parseToml(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
}
|
||||
|
||||
config.model_provider = PROVIDER_KEY;
|
||||
config.model = model;
|
||||
config.model_reasoning_effort = "high";
|
||||
config.disable_response_storage = true;
|
||||
|
||||
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
|
||||
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
|
||||
providers[PROVIDER_KEY] = {
|
||||
...existing,
|
||||
name: PROVIDER_KEY,
|
||||
base_url: baseUrl,
|
||||
wire_api: "responses",
|
||||
requires_openai_auth: true,
|
||||
};
|
||||
config.model_providers = providers;
|
||||
|
||||
writeTextAtomic(configPath, stringifyToml(config) + "\n");
|
||||
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here.
|
||||
const authPath = join(homedir(), ".codex", "auth.json");
|
||||
backup(authPath);
|
||||
const auth = readJson(authPath);
|
||||
auth.OPENAI_API_KEY = apiKey;
|
||||
writeJsonAtomic(authPath, auth);
|
||||
|
||||
return {
|
||||
paths: [configPath, authPath],
|
||||
nextStep: "Run `codex` to start using Codex with DashScope.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import yaml from "yaml";
|
||||
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const PROVIDER_NAME = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Hermes Agent",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".hermes", "config.yaml");
|
||||
|
||||
backup(configPath);
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
}
|
||||
|
||||
const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions";
|
||||
const providerEntry = {
|
||||
name: PROVIDER_NAME,
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
api_mode: apiMode,
|
||||
models: [{ id: model, name: model }],
|
||||
};
|
||||
|
||||
// custom_providers — upsert the bailian-cli entry by name.
|
||||
const providers = Array.isArray(config.custom_providers)
|
||||
? (config.custom_providers as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const index = providers.findIndex((entry) => entry.name === PROVIDER_NAME);
|
||||
if (index >= 0) providers[index] = providerEntry;
|
||||
else providers.push(providerEntry);
|
||||
config.custom_providers = providers;
|
||||
|
||||
// model — select the bailian-cli provider and default model.
|
||||
config.model = { default: model, provider: PROVIDER_NAME };
|
||||
|
||||
writeTextAtomic(configPath, yaml.stringify(config));
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: 'Run `hermes chat -q "hello"` to verify.',
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "OpenClaw",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
|
||||
// models.providers["bailian-cli"]
|
||||
const models = (config.models ?? {}) as Record<string, unknown>;
|
||||
models.mode = "merge";
|
||||
const providers = (models.providers ?? {}) as Record<string, unknown>;
|
||||
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
|
||||
providers["bailian-cli"] = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
api,
|
||||
models: [
|
||||
{
|
||||
id: model,
|
||||
name: model,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 0, output: 0 },
|
||||
},
|
||||
],
|
||||
};
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
|
||||
// agents.defaults
|
||||
const agents = (config.agents ?? {}) as Record<string, unknown>;
|
||||
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
|
||||
defaults.model = { primary: `bailian-cli/${model}` };
|
||||
agents.defaults = defaults;
|
||||
config.agents = agents;
|
||||
|
||||
writeJsonAtomic(configPath, config);
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: "Run `openclaw` to start using OpenClaw with DashScope.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "OpenCode",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
|
||||
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
|
||||
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
|
||||
|
||||
const provider = (config.provider ?? {}) as Record<string, unknown>;
|
||||
const npm = isAnthropicEndpoint(baseUrl) ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible";
|
||||
provider["bailian-cli"] = {
|
||||
npm,
|
||||
name: "Alibaba Cloud Model Studio",
|
||||
options: { baseURL: baseUrl, apiKey, setCacheKey: true },
|
||||
models: { [model]: { name: model } },
|
||||
};
|
||||
config.provider = provider;
|
||||
|
||||
writeJsonAtomic(configPath, config);
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: "Run `opencode` then type `/models` to select your model.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const ENV_KEY = "BAILIAN_CLI_API_KEY";
|
||||
|
||||
/**
|
||||
* Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK
|
||||
* protocol (an AuthType string), not by a free-form provider id — the runtime
|
||||
* resolver indexes credentials/defaults by protocol. The `bailian-cli` brand
|
||||
* therefore lives in the model entry `name` and the env var name.
|
||||
*/
|
||||
export default {
|
||||
label: "Qwen Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".qwen", "settings.json");
|
||||
const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai";
|
||||
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
|
||||
// env — API key read by the provider entry's envKey.
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env[ENV_KEY] = apiKey;
|
||||
settings.env = env;
|
||||
|
||||
// modelProviders[<protocol>] — upsert the bailian-cli model entry.
|
||||
const providers = (settings.modelProviders ?? {}) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
|
||||
const existing = entries.find(
|
||||
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
|
||||
);
|
||||
if (existing) {
|
||||
existing.name = "bailian-cli";
|
||||
existing.baseUrl = baseUrl;
|
||||
existing.envKey = ENV_KEY;
|
||||
} else {
|
||||
entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY });
|
||||
}
|
||||
providers[protocol] = entries;
|
||||
settings.modelProviders = providers;
|
||||
|
||||
// security.auth — select the protocol and carry the OpenAI-compatible creds.
|
||||
const security = (settings.security ?? {}) as Record<string, unknown>;
|
||||
security.auth = { selectedType: protocol, apiKey, baseUrl };
|
||||
settings.security = security;
|
||||
|
||||
// model — active model, disambiguated by baseUrl.
|
||||
settings.model = { name: model, baseUrl };
|
||||
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
return {
|
||||
paths: [settingsPath],
|
||||
nextStep: "Run `qwen` to start using Qwen Code with DashScope.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { dirname } from "path";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
|
||||
|
||||
/** Parameters shared by every agent writer. */
|
||||
export interface WriteParams {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
/** What a writer reports back after configuring an agent. */
|
||||
export interface WriteSummary {
|
||||
paths: string[];
|
||||
nextStep: string;
|
||||
}
|
||||
|
||||
/** An agent configuration writer: a human label plus a `write` that applies it. */
|
||||
export interface AgentDef {
|
||||
label: string;
|
||||
write(params: WriteParams): WriteSummary;
|
||||
}
|
||||
|
||||
/** Read a JSON object file, returning `{}` when missing or unparseable. */
|
||||
export function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write `data` as pretty JSON with owner-only permissions. */
|
||||
export function writeJsonAtomic(path: string, data: unknown): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = path + ".tmp";
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** Atomically write raw text with owner-only permissions. */
|
||||
export function writeTextAtomic(path: string, content: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = path + ".tmp";
|
||||
writeFileSync(tmp, content, { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** Copy an existing file to a timestamped `.bak.<epoch>` sibling. No-op if absent. */
|
||||
export function backup(path: string): void {
|
||||
if (!existsSync(path)) return;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
copyFileSync(path, `${path}.bak.${timestamp}`);
|
||||
}
|
||||
|
||||
/** Whether a base URL targets the Anthropic-messages compatible endpoint. */
|
||||
export function isAnthropicEndpoint(baseUrl: string): boolean {
|
||||
return baseUrl.includes("/apps/anthropic");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "List config profiles and show the active profile",
|
||||
auth: "none",
|
||||
exampleArgs: ["", "--output json"],
|
||||
async run(ctx) {
|
||||
const profiles = ctx.configStore.profiles();
|
||||
const names = ["default", ...Object.keys(profiles.named).sort()];
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(
|
||||
{
|
||||
active_config: profiles.active,
|
||||
profiles: names,
|
||||
config_file: ctx.configStore.path,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nameWidth = Math.max("NAME".length, ...names.map((name) => name.length));
|
||||
emitBare(`${"NAME".padEnd(nameWidth)} ACTIVE`);
|
||||
for (const name of names) {
|
||||
emitBare(`${name.padEnd(nameWidth)} ${name === profiles.active ? "*" : ""}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,50 +1,6 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type ConfigFile,
|
||||
} from "bailian-cli-core";
|
||||
import { defineCommand, detectOutputFormat, maskToken, type ConfigFile } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const VALID_KEYS = [
|
||||
"base_url",
|
||||
"output",
|
||||
"output_dir",
|
||||
"timeout",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"access_key_id",
|
||||
"access_key_secret",
|
||||
"default_text_model",
|
||||
"default_video_model",
|
||||
"default_image_model",
|
||||
"default_speech_model",
|
||||
"default_omni_model",
|
||||
"workspace_id",
|
||||
];
|
||||
|
||||
// Keys whose values are secrets. Their stored value must never be echoed back in
|
||||
// cleartext (CI logs, pipes, shared terminals); show a masked form instead — the
|
||||
// same policy `config show` and `auth status` already follow.
|
||||
const SECRET_KEYS = new Set(["api_key", "access_token", "access_key_id", "access_key_secret"]);
|
||||
|
||||
// Allow hyphen-style keys (e.g. default-text-model → default_text_model)
|
||||
const KEY_ALIASES: Record<string, string> = {
|
||||
"base-url": "base_url",
|
||||
"output-dir": "output_dir",
|
||||
"api-key": "api_key",
|
||||
"access-token": "access_token",
|
||||
"access-key-id": "access_key_id",
|
||||
"access-key-secret": "access_key_secret",
|
||||
"default-text-model": "default_text_model",
|
||||
"default-video-model": "default_video_model",
|
||||
"default-image-model": "default_image_model",
|
||||
"default-speech-model": "default_speech_model",
|
||||
"default-omni-model": "default_omni_model",
|
||||
"workspace-id": "workspace_id",
|
||||
};
|
||||
import { SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Set a config value",
|
||||
@@ -55,7 +11,7 @@ export default defineCommand({
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description:
|
||||
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, default_*_model, workspace_id)",
|
||||
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
|
||||
required: true,
|
||||
},
|
||||
value: { type: "string", valueHint: "<value>", description: "Value to set", required: true },
|
||||
@@ -70,47 +26,36 @@ export default defineCommand({
|
||||
const key = flags.key;
|
||||
const value = flags.value;
|
||||
|
||||
// Resolve hyphen aliases to underscore keys
|
||||
const resolvedKey: string = KEY_ALIASES[key] || key;
|
||||
|
||||
if (!VALID_KEYS.includes(resolvedKey)) {
|
||||
throw new BailianError(
|
||||
`Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate specific values
|
||||
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
|
||||
throw new BailianError(
|
||||
`Invalid output format "${value}". Valid values: text, json`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedKey === "timeout") {
|
||||
const num = Number(value);
|
||||
if (isNaN(num) || num <= 0) {
|
||||
throw new BailianError(
|
||||
`Invalid timeout "${value}". Must be a positive number.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Resolve hyphen aliases to underscore keys and validate/coerce the value.
|
||||
const resolvedKey: string = resolveKey(key);
|
||||
const coerced = validateAndCoerce(key, value);
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ would_set: { [resolvedKey]: value } }, format);
|
||||
emitResult(
|
||||
{
|
||||
would_set: { [resolvedKey]: coerced },
|
||||
config: settings.configName ?? "default",
|
||||
config_file: ctx.configStore.path,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const coerced = resolvedKey === "timeout" ? Number(value) : value;
|
||||
await ctx.configStore().write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
|
||||
await ctx.configStore.write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
|
||||
|
||||
if (!settings.quiet) {
|
||||
const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced;
|
||||
emitResult({ [resolvedKey]: shown }, format);
|
||||
emitResult(
|
||||
{
|
||||
[resolvedKey]: shown,
|
||||
config: settings.configName ?? "default",
|
||||
config_file: ctx.configStore.path,
|
||||
},
|
||||
format,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { BailianError, ExitCode, normalizeModelBaseUrl } from "bailian-cli-core";
|
||||
|
||||
/** Config keys that `config set` / `config ui` accept for read/write. */
|
||||
export const VALID_KEYS = [
|
||||
"base_url",
|
||||
"output",
|
||||
"output_dir",
|
||||
"timeout",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"access_key_id",
|
||||
"access_key_secret",
|
||||
"security_token",
|
||||
"default_text_model",
|
||||
"default_video_model",
|
||||
"default_image_to_video_model",
|
||||
"default_reference_to_video_model",
|
||||
"default_image_model",
|
||||
"default_speech_model",
|
||||
"default_omni_model",
|
||||
"workspace_id",
|
||||
] as const;
|
||||
|
||||
// Keys whose values are secrets. `config set` / `config show` mask these; the
|
||||
// web UI renders them as password fields (values are still sent in cleartext
|
||||
// over the token-gated localhost socket).
|
||||
export const SECRET_KEYS = new Set<string>([
|
||||
"api_key",
|
||||
"access_token",
|
||||
"access_key_id",
|
||||
"access_key_secret",
|
||||
"security_token",
|
||||
]);
|
||||
|
||||
// Allow hyphen-style keys (e.g. default-text-model → default_text_model).
|
||||
export const KEY_ALIASES: Record<string, string> = {
|
||||
"base-url": "base_url",
|
||||
"output-dir": "output_dir",
|
||||
"api-key": "api_key",
|
||||
"access-token": "access_token",
|
||||
"access-key-id": "access_key_id",
|
||||
"access-key-secret": "access_key_secret",
|
||||
"security-token": "security_token",
|
||||
"default-text-model": "default_text_model",
|
||||
"default-video-model": "default_video_model",
|
||||
"default-image-to-video-model": "default_image_to_video_model",
|
||||
"default-reference-to-video-model": "default_reference_to_video_model",
|
||||
"default-image-model": "default_image_model",
|
||||
"default-speech-model": "default_speech_model",
|
||||
"default-omni-model": "default_omni_model",
|
||||
"workspace-id": "workspace_id",
|
||||
};
|
||||
|
||||
/** Resolve a hyphen alias to its underscore config key. */
|
||||
export function resolveKey(key: string): string {
|
||||
return KEY_ALIASES[key] || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const resolvedKey = resolveKey(key);
|
||||
|
||||
if (!(VALID_KEYS as readonly string[]).includes(resolvedKey)) {
|
||||
throw new BailianError(
|
||||
`Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
|
||||
throw new BailianError(
|
||||
`Invalid output format "${value}". Valid values: text, json`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedKey === "timeout") {
|
||||
const num = Number(value);
|
||||
if (isNaN(num) || num <= 0) {
|
||||
throw new BailianError(
|
||||
`Invalid timeout "${value}". Must be a positive number.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
if (resolvedKey === "base_url") return normalizeModelBaseUrl(value);
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { SECRET_KEYS } from "./shared.ts";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Display current configuration",
|
||||
@@ -7,7 +8,7 @@ export default defineCommand({
|
||||
exampleArgs: ["", "--output json"],
|
||||
async run(ctx) {
|
||||
const { settings, client } = ctx;
|
||||
const store = ctx.configStore();
|
||||
const store = ctx.configStore;
|
||||
const file = store.read();
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
@@ -16,16 +17,13 @@ export default defineCommand({
|
||||
base_url: client.baseUrl,
|
||||
output: settings.output,
|
||||
timeout: settings.timeout,
|
||||
config: settings.configName ?? "default",
|
||||
config_file: store.path,
|
||||
};
|
||||
|
||||
if (typeof result.api_key === "string") result.api_key = maskToken(result.api_key);
|
||||
if (typeof result.access_token === "string")
|
||||
result.access_token = maskToken(result.access_token);
|
||||
if (typeof result.access_key_id === "string")
|
||||
result.access_key_id = maskToken(result.access_key_id);
|
||||
if (typeof result.access_key_secret === "string")
|
||||
result.access_key_secret = maskToken(result.access_key_secret);
|
||||
for (const key of SECRET_KEYS) {
|
||||
if (typeof result[key] === "string") result[key] = maskToken(result[key]);
|
||||
}
|
||||
|
||||
emitResult(result, format);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// Self-contained single-page web UI for managing config profiles. Served as a
|
||||
// string by `config ui`; no build step, no client dependencies. All fetches
|
||||
// carry the session token from the page URL.
|
||||
export const PAGE_HTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>bailian-cli config</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1f2328; background: #f6f8fa; }
|
||||
#app { display: flex; min-height: 100vh; }
|
||||
#sidebar { width: 240px; background: #fff; border-right: 1px solid #d0d7de; padding: 16px; }
|
||||
#sidebar h1 { font-size: 15px; margin: 0 0 12px; }
|
||||
#profileList { list-style: none; margin: 0 0 12px; padding: 0; }
|
||||
#profileList li { padding: 8px 10px; border-radius: 6px; cursor: pointer; word-break: break-all; }
|
||||
#profileList li:hover { background: #f0f3f6; }
|
||||
#profileList li.selected { background: #0969da; color: #fff; }
|
||||
main { flex: 1; padding: 24px 32px; max-width: 720px; }
|
||||
#editorHead { display: flex; align-items: center; justify-content: space-between; }
|
||||
h2 { font-size: 18px; margin: 0 0 4px; }
|
||||
.row { display: flex; flex-direction: column; margin: 12px 0; }
|
||||
.row label { font-weight: 600; margin-bottom: 4px; }
|
||||
.inputwrap { display: flex; gap: 6px; }
|
||||
input { flex: 1; padding: 7px 9px; border: 1px solid #d0d7de; border-radius: 6px; font: inherit; width: 100%; }
|
||||
button { padding: 7px 12px; border: 1px solid #d0d7de; border-radius: 6px; background: #f6f8fa; cursor: pointer; font: inherit; }
|
||||
button:hover { background: #eef1f4; }
|
||||
#saveBtn { background: #1f883d; color: #fff; border-color: #1f883d; }
|
||||
#saveBtn:hover { background: #1a7f37; }
|
||||
.danger { color: #cf222e; }
|
||||
.toggle { flex: none; }
|
||||
.actions { margin-top: 20px; display: flex; align-items: center; gap: 12px; }
|
||||
.muted { color: #656d76; font-size: 12px; word-break: break-all; }
|
||||
.err { color: #cf222e; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside id="sidebar">
|
||||
<h1>Config Profiles</h1>
|
||||
<ul id="profileList"></ul>
|
||||
<button id="newBtn">+ New profile</button>
|
||||
<p id="cfgFile" class="muted"></p>
|
||||
</aside>
|
||||
<main id="editor">
|
||||
<div id="editorHead">
|
||||
<h2 id="currentName"></h2>
|
||||
<button id="deleteBtn" class="danger">Delete</button>
|
||||
</div>
|
||||
<form id="form" onsubmit="return false"></form>
|
||||
<div class="actions">
|
||||
<button id="saveBtn">Save</button>
|
||||
<button id="useBtn">Save & Activate</button>
|
||||
<span id="status" class="muted"></span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
var token = new URLSearchParams(location.search).get('token') || '';
|
||||
var KEYS = [], SECRETS = [], DATA = { default: {}, named: {} }, CURRENT = '', ACTIVE = 'default';
|
||||
|
||||
function api(path, opts) {
|
||||
var sep = path.indexOf('?') >= 0 ? '&' : '?';
|
||||
return fetch(path + sep + 'token=' + encodeURIComponent(token), opts || {});
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
var el = document.getElementById('status');
|
||||
el.textContent = msg || '';
|
||||
el.className = isErr ? 'err' : 'muted';
|
||||
}
|
||||
|
||||
function profileData(name) {
|
||||
return name === '' ? DATA.default : (DATA.named[name] || {});
|
||||
}
|
||||
|
||||
function load() {
|
||||
api('/api/config').then(function (r) { return r.json(); }).then(function (j) {
|
||||
KEYS = j.keys || [];
|
||||
SECRETS = j.secretKeys || [];
|
||||
DATA = { default: j.default || {}, named: j.named || {} };
|
||||
ACTIVE = j.activeProfile || 'default';
|
||||
document.getElementById('cfgFile').textContent = j.configFile || '';
|
||||
CURRENT = ACTIVE === 'default' ? '' : ACTIVE;
|
||||
renderProfiles();
|
||||
renderForm();
|
||||
}).catch(function (e) { setStatus('Load failed: ' + e, true); });
|
||||
}
|
||||
|
||||
function renderProfiles() {
|
||||
var ul = document.getElementById('profileList');
|
||||
ul.innerHTML = '';
|
||||
var names = [''].concat(Object.keys(DATA.named));
|
||||
names.forEach(function (name) {
|
||||
var li = document.createElement('li');
|
||||
var displayName = name === '' ? 'default' : name;
|
||||
li.textContent = displayName + (displayName === ACTIVE ? ' *' : '');
|
||||
if (name === CURRENT) li.className = 'selected';
|
||||
li.onclick = function () { CURRENT = name; renderProfiles(); renderForm(); setStatus(''); };
|
||||
ul.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderForm() {
|
||||
var form = document.getElementById('form');
|
||||
form.innerHTML = '';
|
||||
document.getElementById('currentName').textContent = CURRENT === '' ? 'default (top-level)' : CURRENT;
|
||||
document.getElementById('deleteBtn').style.display = CURRENT === '' ? 'none' : '';
|
||||
var selectedName = CURRENT === '' ? 'default' : CURRENT;
|
||||
var useBtn = document.getElementById('useBtn');
|
||||
useBtn.disabled = selectedName === ACTIVE;
|
||||
useBtn.textContent = selectedName === ACTIVE ? 'Active' : 'Save & Activate';
|
||||
var data = profileData(CURRENT);
|
||||
KEYS.forEach(function (key) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
var label = document.createElement('label');
|
||||
label.textContent = key;
|
||||
label.htmlFor = 'f_' + key;
|
||||
var input = document.createElement('input');
|
||||
input.id = 'f_' + key;
|
||||
input.name = key;
|
||||
var val = data[key];
|
||||
input.value = (val === undefined || val === null) ? '' : String(val);
|
||||
if (SECRETS.indexOf(key) >= 0) {
|
||||
input.type = 'password';
|
||||
var toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'toggle';
|
||||
toggle.textContent = 'show';
|
||||
toggle.onclick = function () {
|
||||
if (input.type === 'password') { input.type = 'text'; toggle.textContent = 'hide'; }
|
||||
else { input.type = 'password'; toggle.textContent = 'show'; }
|
||||
};
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'inputwrap';
|
||||
wrap.appendChild(input);
|
||||
wrap.appendChild(toggle);
|
||||
row.appendChild(label);
|
||||
row.appendChild(wrap);
|
||||
} else {
|
||||
input.type = 'text';
|
||||
row.appendChild(label);
|
||||
row.appendChild(input);
|
||||
}
|
||||
form.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function collect() {
|
||||
var data = {};
|
||||
KEYS.forEach(function (key) {
|
||||
var el = document.getElementById('f_' + key);
|
||||
data[key] = el ? el.value : '';
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function saveProfile(name, data) {
|
||||
var profileData = data === undefined ? collect() : data;
|
||||
var body = JSON.stringify({ name: name, data: profileData });
|
||||
return api('/api/profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body
|
||||
})
|
||||
.then(function (response) {
|
||||
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
|
||||
})
|
||||
.then(function (result) {
|
||||
if (!result.ok) {
|
||||
throw new Error((result.json && result.json.error) || 'error');
|
||||
}
|
||||
var saved = result.json.saved || {};
|
||||
if (name === '') DATA.default = saved; else DATA.named[name] = saved;
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
function save() {
|
||||
var name = CURRENT;
|
||||
saveProfile(name)
|
||||
.then(function () {
|
||||
renderForm();
|
||||
setStatus('Saved.');
|
||||
})
|
||||
.catch(function (error) { setStatus('Save failed: ' + error.message, true); });
|
||||
}
|
||||
|
||||
function newProfile() {
|
||||
var name = prompt('New profile name (letters, numbers, - or _):');
|
||||
if (!name) return;
|
||||
if (DATA.named[name] !== undefined) {
|
||||
CURRENT = name;
|
||||
renderProfiles();
|
||||
renderForm();
|
||||
setStatus('Profile already exists.');
|
||||
return;
|
||||
}
|
||||
setStatus('Creating profile...');
|
||||
saveProfile(name, {})
|
||||
.then(function () {
|
||||
CURRENT = name;
|
||||
renderProfiles();
|
||||
renderForm();
|
||||
setStatus('Profile created and saved.');
|
||||
})
|
||||
.catch(function (error) { setStatus('Create failed: ' + error.message, true); });
|
||||
}
|
||||
|
||||
function saveAndActivateProfile() {
|
||||
var name = CURRENT === '' ? 'default' : CURRENT;
|
||||
var profileName = CURRENT;
|
||||
setStatus('Saving...');
|
||||
saveProfile(profileName)
|
||||
.then(function () {
|
||||
return api('/api/active', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name })
|
||||
});
|
||||
})
|
||||
.then(function (response) {
|
||||
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
|
||||
})
|
||||
.then(function (result) {
|
||||
if (!result.ok) {
|
||||
throw new Error('Saved, but activation failed: ' + ((result.json && result.json.error) || 'error'));
|
||||
}
|
||||
ACTIVE = result.json.activeProfile || 'default';
|
||||
renderProfiles();
|
||||
renderForm();
|
||||
setStatus('Saved and activated.');
|
||||
})
|
||||
.catch(function (error) { setStatus(error.message || String(error), true); });
|
||||
}
|
||||
|
||||
function deleteProfile() {
|
||||
if (CURRENT === '') return;
|
||||
if (!confirm('Delete profile "' + CURRENT + '"?')) return;
|
||||
api('/api/profile?name=' + encodeURIComponent(CURRENT), { method: 'DELETE' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || 'error'); });
|
||||
return r.json();
|
||||
})
|
||||
.then(function (result) {
|
||||
delete DATA.named[CURRENT];
|
||||
ACTIVE = result.activeProfile || 'default';
|
||||
CURRENT = '';
|
||||
renderProfiles();
|
||||
renderForm();
|
||||
setStatus('Deleted.');
|
||||
})
|
||||
.catch(function (e) { setStatus('Delete failed: ' + e, true); });
|
||||
}
|
||||
|
||||
document.getElementById('saveBtn').onclick = save;
|
||||
document.getElementById('newBtn').onclick = newProfile;
|
||||
document.getElementById('useBtn').onclick = saveAndActivateProfile;
|
||||
document.getElementById('deleteBtn').onclick = deleteProfile;
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
@@ -0,0 +1,264 @@
|
||||
import http from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
normalizeConfigName,
|
||||
readConfigFile,
|
||||
writeConfigFile,
|
||||
deleteConfigProfile,
|
||||
type ConfigStore,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
|
||||
import { PAGE_HTML } from "./ui-html.ts";
|
||||
import { VALID_KEYS, SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
|
||||
|
||||
const FLAGS = {
|
||||
port: {
|
||||
type: "number",
|
||||
valueHint: "<port>",
|
||||
description: "Port to listen on (default: random free port)",
|
||||
},
|
||||
noOpen: { type: "switch", description: "Do not open the browser automatically" },
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const MAX_BODY = 1 << 20; // 1 MiB
|
||||
|
||||
function errMessage(err: unknown): string {
|
||||
return err instanceof BailianError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
}
|
||||
|
||||
function sendJson(res: http.ServerResponse, status: number, obj: unknown): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) {
|
||||
reject(new Error("payload too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the request cleaned/validated config block from a posted `data` map. */
|
||||
function buildProfilePatch(data: Record<string, unknown>): Record<string, string | number> {
|
||||
const cleaned: Record<string, string | number> = {};
|
||||
for (const [k, v] 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 (value === "") continue;
|
||||
cleaned[resolveKey(k)] = validateAndCoerce(k, value);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** Preserve valid Config fields that the UI does not expose or manage. */
|
||||
function mergeUnmanagedProfileFields(
|
||||
existing: Record<string, unknown>,
|
||||
managedPatch: Record<string, string | number>,
|
||||
): Record<string, unknown> {
|
||||
const managedKeys = new Set<string>(VALID_KEYS);
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(existing)) {
|
||||
if (!managedKeys.has(key)) merged[key] = value;
|
||||
}
|
||||
return { ...merged, ...managedPatch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the config-UI http server. Exported for tests. The handler enforces:
|
||||
* - Host header must be a loopback name (anti DNS-rebinding).
|
||||
* - every request must carry `?token=` matching the session token.
|
||||
*/
|
||||
export function createConfigUiServer(token: string, configStore: ConfigStore): http.Server {
|
||||
return http.createServer(async (req, res) => {
|
||||
try {
|
||||
const host = (req.headers.host || "").split(":")[0];
|
||||
if (host !== "127.0.0.1" && host !== "localhost") {
|
||||
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("forbidden host\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (u.searchParams.get("token") !== token) {
|
||||
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("unauthorized\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const method = req.method ?? "GET";
|
||||
const path = u.pathname;
|
||||
|
||||
if (path === "/" && method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(PAGE_HTML);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/config" && method === "GET") {
|
||||
const profiles = configStore.profiles();
|
||||
sendJson(res, 200, {
|
||||
configFile: configStore.path,
|
||||
keys: VALID_KEYS,
|
||||
secretKeys: [...SECRET_KEYS],
|
||||
activeProfile: profiles.active,
|
||||
default: profiles.default,
|
||||
named: profiles.named,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/active" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
const body = parsed as { name?: unknown };
|
||||
try {
|
||||
const activeProfile = await configStore.activate(body.name);
|
||||
sendJson(res, 200, { activeProfile });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/profile" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
const body = parsed as { name?: unknown; data?: unknown };
|
||||
if (!body.data || typeof body.data !== "object" || Array.isArray(body.data)) {
|
||||
sendJson(res, 400, { error: "missing or invalid 'data'" });
|
||||
return;
|
||||
}
|
||||
let normalized: string | undefined;
|
||||
let cleaned: Record<string, string | number>;
|
||||
try {
|
||||
normalized = normalizeConfigName(body.name);
|
||||
cleaned = buildProfilePatch(body.data as Record<string, unknown>);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
return;
|
||||
}
|
||||
const existing = readConfigFile(normalized) as Record<string, unknown>;
|
||||
const saved = mergeUnmanagedProfileFields(existing, cleaned);
|
||||
await writeConfigFile(saved, normalized);
|
||||
sendJson(res, 200, { saved });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/profile" && method === "DELETE") {
|
||||
try {
|
||||
const deleted = await deleteConfigProfile(u.searchParams.get("name") ?? undefined);
|
||||
sendJson(res, 200, { deleted, activeProfile: configStore.profiles().active });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found\n");
|
||||
} catch {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Open a local web UI to manage config profiles",
|
||||
auth: "none",
|
||||
usageArgs: "[--port <port>] [--no-open]",
|
||||
flags: FLAGS,
|
||||
exampleArgs: ["", "--port 8787", "--no-open"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: flags.port ?? "random free port",
|
||||
config_file: ctx.configStore.path,
|
||||
routes: [
|
||||
"GET / -> web UI",
|
||||
"GET /api/config -> read all profiles",
|
||||
"POST /api/profile -> save a profile",
|
||||
"POST /api/active -> activate a profile",
|
||||
"DELETE /api/profile -> delete a named profile",
|
||||
],
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const server = createConfigUiServer(token, ctx.configStore);
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
port = await listenLocalServer(server, flags.port ?? 0);
|
||||
} catch (err) {
|
||||
throw new BailianError(
|
||||
`Could not bind to 127.0.0.1 (no free port or permission denied): ${errMessage(err)}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const url = `http://127.0.0.1:${port}/?token=${token}`;
|
||||
|
||||
if (!flags.noOpen) {
|
||||
try {
|
||||
await openInBrowser(url);
|
||||
emitBare("Opened the config UI in your default browser.");
|
||||
} catch {
|
||||
emitBare("Could not open the browser automatically. Open the URL below manually.");
|
||||
}
|
||||
}
|
||||
emitBare(`Config UI running at ${url}`);
|
||||
emitBare("Note: credentials are shown in cleartext in the browser (localhost only).");
|
||||
emitBare("Press Ctrl+C to stop.");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const shutdown = () => server.close(() => resolve());
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
server.once("close", () => resolve());
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Set the active config profile",
|
||||
auth: "none",
|
||||
usageArgs: "--name <name>",
|
||||
flags: {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Existing profile name, or default",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: ["--name token-plan", "--name default"],
|
||||
async run(ctx) {
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
if (ctx.settings.dryRun) {
|
||||
const activeConfig = ctx.configStore.validateActivation(ctx.flags.name);
|
||||
emitResult(
|
||||
{
|
||||
would_activate: activeConfig,
|
||||
config_file: ctx.configStore.path,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeConfig = await ctx.configStore.activate(ctx.flags.name);
|
||||
if (!ctx.settings.quiet) {
|
||||
emitResult(
|
||||
{
|
||||
active_config: activeConfig,
|
||||
config_file: ctx.configStore.path,
|
||||
},
|
||||
format,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseDatasetSchemaFlag,
|
||||
formatIssue,
|
||||
MAX_DATASET_BYTES,
|
||||
MAX_MEDIA_ZIP_BYTES,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type DatasetFile,
|
||||
@@ -17,7 +18,7 @@ const UPLOAD_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Local .jsonl dataset file (≤300MB)",
|
||||
description: "Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image)",
|
||||
required: true,
|
||||
},
|
||||
purpose: {
|
||||
@@ -29,7 +30,7 @@ const UPLOAD_FLAGS = {
|
||||
type: "string",
|
||||
valueHint: "<s>",
|
||||
description:
|
||||
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record.',
|
||||
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
|
||||
},
|
||||
noValidate: {
|
||||
type: "switch",
|
||||
@@ -42,42 +43,55 @@ const UPLOAD_FLAGS = {
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Upload a dataset file (.jsonl) to Bailian",
|
||||
description: "Upload a dataset file (.jsonl or .zip) to Bailian",
|
||||
auth: "apiKey",
|
||||
usageArgs:
|
||||
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt>] [--no-validate] [--full-validate]",
|
||||
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt|tts|image>] [--no-validate] [--full-validate]",
|
||||
flags: UPLOAD_FLAGS,
|
||||
exampleArgs: [
|
||||
"--file train.jsonl",
|
||||
"--file dpo.jsonl --schema dpo",
|
||||
"--file cpt.jsonl --schema cpt",
|
||||
"--file audio.zip --schema tts",
|
||||
"--file eval.jsonl --purpose evaluation",
|
||||
"--file train.jsonl --full-validate",
|
||||
"--file train.jsonl --no-validate",
|
||||
],
|
||||
notes: [
|
||||
"Only .jsonl is supported in this release. Three record schemas are",
|
||||
"recognized: chatml = {messages:[...]} (SFT); dpo = {messages:[...],",
|
||||
"chosen, rejected} where chosen/rejected are single assistant messages;",
|
||||
'cpt = {text:"..."} (continual pre-training, raw text). With no --schema,',
|
||||
"a record carrying chosen/rejected is validated as DPO, one with text (and",
|
||||
"no messages) as CPT, otherwise as ChatML. Pass --schema dpo / cpt to",
|
||||
"require that shape on every record, or --schema chatml to ignore the",
|
||||
"preference / text fields. Other purposes may carry a different schema in",
|
||||
"the future and would be served by a purpose-specific validator.",
|
||||
"The dataset upload cap is 300MB per file.",
|
||||
"Upload uses the OpenAI-compatible /compatible-mode/v1/files endpoint so",
|
||||
"the purpose tag is persisted (the DashScope-native /api/v1/files drops it).",
|
||||
"Supports .jsonl (text) and .zip (audio/image archives with a data.jsonl",
|
||||
"manifest). Five record schemas are recognized: chatml = {messages:[...]}",
|
||||
'(SFT); dpo = {messages:[...], chosen, rejected}; cpt = {text:"..."}',
|
||||
'(continual pre-training, raw text); tts = {wav_fn:"train/xxx.wav",',
|
||||
'text:"..."} (audio fine-tuning); image = {img_path:"..."} (image',
|
||||
"generation). With no --schema, a record carrying wav_fn is validated as",
|
||||
"TTS, img_path as image, chosen/rejected as DPO, text (no messages) as CPT,",
|
||||
"otherwise ChatML. Upload cap: 300MB text, 1GB image. Upload uses the",
|
||||
"OpenAI-compatible /compatible-mode/v1/files endpoint so the purpose tag is",
|
||||
"persisted (the DashScope-native /api/v1/files drops it).",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const filePath = flags.file;
|
||||
const purpose = flags.purpose || "fine-tune";
|
||||
const schema = parseDatasetSchemaFlag(flags.schema);
|
||||
if (schema === "video") {
|
||||
throw new BailianError(
|
||||
`--schema video is not supported.`,
|
||||
ExitCode.USAGE,
|
||||
`Supported schemas: chatml, dpo, cpt, tts, image.`,
|
||||
);
|
||||
}
|
||||
const format = detectOutputFormat(settings.output);
|
||||
// Image schema allows larger ZIPs (1 GB vs 300 MB for text).
|
||||
const isMediaSchema = schema === "image";
|
||||
|
||||
if (!flags.noValidate) {
|
||||
const result = await validateDataset(filePath, { fullValidate: flags.fullValidate, schema });
|
||||
const maxBytes = isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES;
|
||||
const result = await validateDataset(filePath, {
|
||||
fullValidate: flags.fullValidate,
|
||||
schema,
|
||||
maxBytes,
|
||||
});
|
||||
if (!result.valid) {
|
||||
const lines = [
|
||||
`Dataset validation failed for ${filePath}`,
|
||||
@@ -112,7 +126,7 @@ export default defineCommand({
|
||||
action: "dataset.upload",
|
||||
file: filePath,
|
||||
purpose,
|
||||
max_bytes: MAX_DATASET_BYTES,
|
||||
max_bytes: isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES,
|
||||
validate: !flags.noValidate,
|
||||
schema: schema ?? "auto",
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ const VALIDATE_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Local .jsonl dataset file",
|
||||
description: "Local dataset file (.jsonl or .zip)",
|
||||
required: true,
|
||||
},
|
||||
fullValidate: {
|
||||
@@ -36,20 +36,21 @@ const VALIDATE_FLAGS = {
|
||||
type: "string",
|
||||
valueHint: "<s>",
|
||||
description:
|
||||
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record.',
|
||||
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Locally validate a dataset file (.jsonl) without uploading",
|
||||
description: "Locally validate a dataset file (.jsonl or .zip) without uploading",
|
||||
// 纯本地校验,不触网、不需 API key(与 `pipeline validate` 一致)。
|
||||
auth: "none",
|
||||
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt>]",
|
||||
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt|tts|image>]",
|
||||
flags: VALIDATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--file train.jsonl",
|
||||
"--file dpo.jsonl --schema dpo",
|
||||
"--file cpt.jsonl --schema cpt",
|
||||
"--file audio.zip --schema tts",
|
||||
"--file eval.jsonl --full-validate",
|
||||
"--file train.jsonl --output json",
|
||||
],
|
||||
@@ -57,17 +58,27 @@ export default defineCommand({
|
||||
"Default scan: every line gets a structural check, then ~160 lines (front 50,",
|
||||
"evenly spaced 100, last 10) are JSON.parsed against the active schema.",
|
||||
"Schemas: chatml = {messages:[...]} (SFT); dpo = {messages:[...], chosen,",
|
||||
"rejected} where chosen/rejected are single assistant messages; cpt =",
|
||||
'{text:"..."} (continual pre-training, raw text). With no --schema, a',
|
||||
"record carrying chosen/rejected is validated as DPO, one with text (and no",
|
||||
"messages) as CPT, otherwise as ChatML. Pass --schema dpo / cpt to require",
|
||||
"that shape on every record (strict), or --schema chatml to ignore the",
|
||||
"preference / text fields. Use --full-validate to JSON.parse every line.",
|
||||
'rejected}; cpt = {text:"..."} (continual pre-training, raw text);',
|
||||
'tts = {wav_fn:"train/xxx.wav", text:"..."} (audio fine-tuning);',
|
||||
'image = {img_path:"..."} (image generation). With no --schema, a record',
|
||||
"carrying wav_fn is validated as TTS, img_path as image, chosen/rejected",
|
||||
"as DPO, text (no messages) as CPT, otherwise ChatML. Pass --schema to",
|
||||
"require a specific shape on every record. ZIP archives (.zip) are",
|
||||
"validated structurally (data.jsonl present, media references resolve) in",
|
||||
"addition to per-record content checks. Use --full-validate to JSON.parse",
|
||||
"every line.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const filePath = flags.file;
|
||||
const schema = parseDatasetSchemaFlag(flags.schema);
|
||||
if (schema === "video") {
|
||||
throw new BailianError(
|
||||
`--schema video is not supported.`,
|
||||
ExitCode.USAGE,
|
||||
`Supported schemas: chatml, dpo, cpt, tts, image.`,
|
||||
);
|
||||
}
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
|
||||
@@ -2,11 +2,16 @@ import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
createDeployment,
|
||||
pickPlanStrategy,
|
||||
STRATEGIES,
|
||||
defaultDeployPlan,
|
||||
type DeployModality,
|
||||
type CreateDeploymentRequest,
|
||||
type CreatePlanFlags,
|
||||
type CommandContext,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { pickPlanStrategy, STRATEGIES } from "./plans.ts";
|
||||
|
||||
const CREATE_FLAGS = {
|
||||
model: {
|
||||
@@ -26,10 +31,10 @@ const CREATE_FLAGS = {
|
||||
valueHint: "<plan>",
|
||||
description: "Billing plan: lora (default, Token-billed) | ptu (Token-billed) | mu",
|
||||
},
|
||||
templateId: {
|
||||
deploySpec: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Template id (only used by plan=mu; auto-picked if omitted)",
|
||||
description: "Deploy spec (only used by plan=mu; auto-picked if omitted)",
|
||||
},
|
||||
capacity: {
|
||||
type: "number",
|
||||
@@ -58,104 +63,156 @@ const CREATE_FLAGS = {
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const CREATE_USAGE =
|
||||
"--model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
|
||||
|
||||
const CREATE_NOTES = [
|
||||
"Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-",
|
||||
"billed) for audio (CosyVoice TTS). Pass --plan to override.",
|
||||
"For plan=ptu (Token-billed, provisioned throughput), --input-tpm and",
|
||||
"--output-tpm are required (the platform rejects creation without an",
|
||||
"explicit ptu_capacity despite the doc listing defaults).",
|
||||
"For plan=mu, `capacity`, `billing_method` and `deploy_spec` are required.",
|
||||
"billing_method defaults to POST_PAY (only supported value); deploy_spec",
|
||||
"and capacity are auto-picked from GET /deployments/models when omitted.",
|
||||
"Use `bl deploy models --source base` to inspect available templates.",
|
||||
"After creation, status starts at PENDING and transitions to RUNNING.",
|
||||
"Invoke the deployed model with: bl text chat --model <deployed_model>",
|
||||
"WARNING: --model is overloaded across commands and refers to DIFFERENT",
|
||||
"values. `bl deploy <modality> create --model` takes the exported model_name",
|
||||
"(e.g. `qwen3-8b-ft-...`), but the create response also returns a",
|
||||
"`deployed_model` field (the deployment instance id, e.g.",
|
||||
"`qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use",
|
||||
"the `deployed_model` from the create response — NOT the `model_name` you",
|
||||
"passed to `deploy <modality> create`. Do not reuse the value across the two",
|
||||
"commands.",
|
||||
];
|
||||
|
||||
/**
|
||||
* `bl deploy create` — create a model deployment.
|
||||
*
|
||||
* Plan-specific behaviour (required flags / body assembly / auto-pick) lives
|
||||
* in `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file only handles the
|
||||
* shared envelope: flag validation, dispatch, dry-run, and result
|
||||
* formatting. Adding a new plan = one entry in the strategy table;
|
||||
* nothing here changes.
|
||||
*
|
||||
* `--model` (model identifier) and `--name` (console display name) are required.
|
||||
* Shared `deploy <modality> create` flag validation. Plan support is
|
||||
* server-catalog-driven, so validation is identical for every modality: resolve
|
||||
* the effective plan (modality-specific default when --plan is omitted), reject
|
||||
* an unknown --plan, then defer to the plan strategy's required-flag check.
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Create a model deployment",
|
||||
function validateCreate(modality: DeployModality, flags: CreatePlanFlags): string | undefined {
|
||||
const plan = flags.plan || defaultDeployPlan(modality);
|
||||
const strategy = STRATEGIES[plan];
|
||||
if (!strategy) {
|
||||
return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`;
|
||||
}
|
||||
return strategy.validateFlags(flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `deploy <modality> create` implementation. deploy create takes a model
|
||||
* by name and a billing plan — it does NOT inspect data modality for the request
|
||||
* body, so the run logic is identical across text / audio / image. The modality
|
||||
* only fixes the default plan (audio → mu, text/image → lora) and the command
|
||||
* path / description / examples.
|
||||
*
|
||||
* Plan-specific behaviour (required flags / body assembly / auto-pick) lives in
|
||||
* core `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file only handles the
|
||||
* shared envelope: dispatch, dry-run, and result formatting.
|
||||
*/
|
||||
async function runCreate(
|
||||
modality: DeployModality,
|
||||
ctx: CommandContext<typeof CREATE_FLAGS>,
|
||||
): Promise<void> {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const model = flags.model as string;
|
||||
const name = flags.name as string;
|
||||
const plan = (flags.plan as string | undefined) || defaultDeployPlan(modality);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Plan-specific behaviour is owned by core `plans.ts`. The strategy resolves
|
||||
// the plan-specific body fragment (mu may auto-pick a template from the
|
||||
// deployable-models catalog). Anything outside the strategy table was
|
||||
// already rejected by `validate` above.
|
||||
const strategy = pickPlanStrategy(plan);
|
||||
|
||||
const resolved = await strategy.resolve({
|
||||
client: ctx.client,
|
||||
dryRun: settings.dryRun,
|
||||
binName: identity.binName,
|
||||
flags: flags as CreatePlanFlags,
|
||||
model,
|
||||
name,
|
||||
});
|
||||
const body: Record<string, unknown> = {
|
||||
model_name: model,
|
||||
name,
|
||||
plan,
|
||||
...resolved.body,
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.create", body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await createDeployment(ctx.client, body as CreateDeploymentRequest);
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(deployment?.deployed_model ?? "");
|
||||
} else if (format === "text") {
|
||||
emitBare(`Created deployment.`);
|
||||
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
|
||||
if (deployment?.status) emitBare(` status: ${deployment.status}`);
|
||||
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
|
||||
emitBare(
|
||||
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
|
||||
);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
}
|
||||
|
||||
/** `bl deploy text create` — deploy a text model. */
|
||||
export const deployTextCreate = defineCommand({
|
||||
description: "Create a text model deployment",
|
||||
auth: "apiKey",
|
||||
usageArgs:
|
||||
"--model <model_name> --name <display_name> [--plan <plan>] [--template-id <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]",
|
||||
usageArgs: CREATE_USAGE,
|
||||
flags: CREATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model my-qwen-sft --name my-sft-test",
|
||||
"--model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
|
||||
"--model qwen3-8b --name my-qwen3-mu --plan mu",
|
||||
"--model qwen3-8b --name my-qwen3 --plan mu --template-id MU1 --capacity 2",
|
||||
"--model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
|
||||
],
|
||||
notes: [
|
||||
"Plan defaults to `lora` (Token-billed). Pass --plan to override.",
|
||||
"For plan=ptu (Token-billed, provisioned throughput), --input-tpm and",
|
||||
"--output-tpm are required (the platform rejects creation without an",
|
||||
"explicit ptu_capacity despite the doc listing defaults).",
|
||||
"For plan=mu, `capacity`, `billing_method` and `template_id` are required.",
|
||||
"billing_method defaults to POST_PAY (only supported value); template_id",
|
||||
"and capacity are auto-picked from GET /deployments/models when omitted.",
|
||||
"Use `bl deploy models --source base` to inspect available templates.",
|
||||
"After creation, status starts at PENDING and transitions to RUNNING.",
|
||||
"Invoke the deployed model with: bl text chat --model <deployed_model>",
|
||||
"WARNING: --model is overloaded across commands and refers to DIFFERENT",
|
||||
"values. `bl deploy create --model` takes the exported model_name (e.g.",
|
||||
"`qwen3-8b-ft-...`), but the create response also returns a `deployed_model`",
|
||||
"field (the deployment instance id, e.g. `qwen3-8b-5ecb5f068d79`). The",
|
||||
"inference call `bl text chat --model` must use the `deployed_model` from",
|
||||
"the create response — NOT the `model_name` you passed to `deploy create`.",
|
||||
"Do not reuse the value across the two commands.",
|
||||
],
|
||||
validate: (flags) => {
|
||||
const plan = flags.plan || "lora";
|
||||
const strategy = STRATEGIES[plan];
|
||||
if (!strategy) {
|
||||
return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`;
|
||||
}
|
||||
return strategy.validateFlags(flags);
|
||||
},
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const model = flags.model;
|
||||
const name = flags.name;
|
||||
const plan = flags.plan || "lora";
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Plan-specific behaviour is owned by `plans.ts`. The strategy resolves
|
||||
// the plan-specific body fragment (mu may auto-pick a template from the
|
||||
// deployable-models catalog). Anything outside the strategy table was
|
||||
// already rejected by `validate` above.
|
||||
const strategy = pickPlanStrategy(plan);
|
||||
|
||||
const resolved = await strategy.resolve({
|
||||
client: ctx.client,
|
||||
dryRun: settings.dryRun,
|
||||
binName: identity.binName,
|
||||
flags,
|
||||
model,
|
||||
name,
|
||||
});
|
||||
const body: Record<string, unknown> = {
|
||||
model_name: model,
|
||||
name,
|
||||
plan,
|
||||
...resolved.body,
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.create", body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await createDeployment(ctx.client, body as CreateDeploymentRequest);
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(deployment?.deployed_model ?? "");
|
||||
} else if (format === "text") {
|
||||
emitBare(`Created deployment.`);
|
||||
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
|
||||
if (deployment?.status) emitBare(` status: ${deployment.status}`);
|
||||
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
|
||||
emitBare(
|
||||
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
|
||||
);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
notes: CREATE_NOTES,
|
||||
validate: (flags) => validateCreate("text", flags),
|
||||
run: (ctx) => runCreate("text", ctx),
|
||||
});
|
||||
|
||||
/** `bl deploy audio create` — deploy an audio (TTS) model. Defaults to plan=mu. */
|
||||
export const deployAudioCreate = defineCommand({
|
||||
description: "Create an audio (TTS) model deployment",
|
||||
auth: "apiKey",
|
||||
usageArgs: CREATE_USAGE,
|
||||
flags: CREATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model my-cosyvoice-ft --name my-tts",
|
||||
"--model my-cosyvoice-ft --name my-tts --deploy-spec dps-xxxx --capacity 1",
|
||||
"--model my-cosyvoice-ft --name my-tts --dry-run",
|
||||
],
|
||||
notes: CREATE_NOTES,
|
||||
validate: (flags) => validateCreate("audio", flags),
|
||||
run: (ctx) => runCreate("audio", ctx),
|
||||
});
|
||||
|
||||
/** `bl deploy image create` — deploy an image generation model. */
|
||||
export const deployImageCreate = defineCommand({
|
||||
description: "Create an image generation model deployment",
|
||||
auth: "apiKey",
|
||||
usageArgs: CREATE_USAGE,
|
||||
flags: CREATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model my-wan-ft --name my-wan",
|
||||
"--model my-wan-ft --name my-wan-mu --plan mu",
|
||||
"--model my-wan-ft --name my-wan --dry-run",
|
||||
],
|
||||
notes: CREATE_NOTES,
|
||||
validate: (flags) => validateCreate("image", flags),
|
||||
run: (ctx) => runCreate("image", ctx),
|
||||
});
|
||||
|
||||
@@ -59,8 +59,8 @@ export default defineCommand({
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof BailianError) throw e;
|
||||
} catch (error) {
|
||||
if (error instanceof BailianError) throw error;
|
||||
// If the get itself failed (e.g. not found), let the DELETE call surface the real error.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,13 +68,13 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
const headers = ["DEPLOYED_MODEL", "MODEL_NAME", "STATUS", "PLAN", "CAPACITY", "CREATED_AT"];
|
||||
const rows = items.map((i) => [
|
||||
i.deployed_model,
|
||||
i.model_name,
|
||||
i.status,
|
||||
i.plan,
|
||||
i.capacity,
|
||||
i.created_at,
|
||||
const rows = items.map((item) => [
|
||||
item.deployed_model,
|
||||
item.model_name,
|
||||
item.status,
|
||||
item.plan,
|
||||
item.capacity,
|
||||
item.created_at,
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
|
||||
@@ -73,47 +73,47 @@ export default defineCommand({
|
||||
// - custom (fine-tuned): top-level supported_plans: string[]
|
||||
// - base (catalog): plans: [{plan, templates?, cu_specs?}]
|
||||
// For json: surface the deployment-relevant fields preserved as a tree, so
|
||||
// downstream tooling can drive `bl deploy create --template-id <…>` without
|
||||
// a second round-trip. For text: keep the compact one-line summary.
|
||||
// downstream tooling can drive `bl deploy <modality> create --deploy-spec <…>`
|
||||
// without a second round-trip. For text: keep the compact one-line summary.
|
||||
if (format === "json") {
|
||||
const items = models.map((m) => {
|
||||
const items = models.map((model) => {
|
||||
const out: Record<string, unknown> = {
|
||||
model_name: m.model_name ?? "",
|
||||
model_name: model.model_name ?? "",
|
||||
};
|
||||
if (m.base_model) out.base_model = m.base_model;
|
||||
if (m.model_source) out.model_source = m.model_source;
|
||||
if (m.supported_plans && m.supported_plans.length > 0) {
|
||||
out.supported_plans = m.supported_plans;
|
||||
if (model.base_model) out.base_model = model.base_model;
|
||||
if (model.model_source) out.model_source = model.model_source;
|
||||
if (model.supported_plans && model.supported_plans.length > 0) {
|
||||
out.supported_plans = model.supported_plans;
|
||||
}
|
||||
if (m.plans && m.plans.length > 0) {
|
||||
out.plans = m.plans.map((p) => {
|
||||
const planEntry: Record<string, unknown> = { plan: p.plan ?? "" };
|
||||
if (p.cu_specs && p.cu_specs.length > 0) {
|
||||
planEntry.cu_specs = p.cu_specs;
|
||||
if (model.plans && model.plans.length > 0) {
|
||||
out.plans = model.plans.map((plan) => {
|
||||
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
|
||||
if (plan.cu_specs && plan.cu_specs.length > 0) {
|
||||
planEntry.cu_specs = plan.cu_specs;
|
||||
}
|
||||
if (p.templates && p.templates.length > 0) {
|
||||
// Pull the top 6 fields most useful for `bl deploy create`.
|
||||
if (plan.templates && plan.templates.length > 0) {
|
||||
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
|
||||
// Drop noisy/redundant: template_source, template_type,
|
||||
// template_version, deploy_spec (typically == template_id).
|
||||
planEntry.templates = p.templates.map((t) => {
|
||||
planEntry.templates = plan.templates.map((template) => {
|
||||
const tpl: Record<string, unknown> = {};
|
||||
if (t.template_id) tpl.template_id = t.template_id;
|
||||
if (t.template_name) tpl.template_name = t.template_name;
|
||||
if (t.charge_type) tpl.charge_type = t.charge_type;
|
||||
if (template.template_id) tpl.template_id = template.template_id;
|
||||
if (template.template_name) tpl.template_name = template.template_name;
|
||||
if (template.charge_type) tpl.charge_type = template.charge_type;
|
||||
// Flatten roles.unified for the common COUPLED case.
|
||||
const unified = t.roles?.unified;
|
||||
const unified = template.roles?.unified;
|
||||
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
|
||||
if (unified?.capacity_unit_per_instance !== undefined)
|
||||
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
|
||||
// Preserve split-role configs (SEPERATED) as-is so callers
|
||||
// can still drive prefill/decode sizing.
|
||||
if (t.roles?.prefill || t.roles?.decode) {
|
||||
if (template.roles?.prefill || template.roles?.decode) {
|
||||
tpl.roles = {
|
||||
prefill: t.roles?.prefill,
|
||||
decode: t.roles?.decode,
|
||||
prefill: template.roles?.prefill,
|
||||
decode: template.roles?.decode,
|
||||
};
|
||||
}
|
||||
if (t.template_desc) tpl.template_desc = t.template_desc;
|
||||
if (template.template_desc) tpl.template_desc = template.template_desc;
|
||||
return tpl;
|
||||
});
|
||||
}
|
||||
@@ -127,19 +127,19 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
// text / quiet — keep the compact single-line summary table.
|
||||
const textItems = models.map((m) => {
|
||||
const textItems = models.map((model) => {
|
||||
let plansSummary = "";
|
||||
if (m.supported_plans && m.supported_plans.length > 0) {
|
||||
plansSummary = m.supported_plans.join(",");
|
||||
} else if (m.plans && m.plans.length > 0) {
|
||||
plansSummary = m.plans
|
||||
.map((p) => {
|
||||
const planName = p.plan ?? "?";
|
||||
if (p.templates && p.templates.length > 0) {
|
||||
return `${planName}(${p.templates.length}t)`;
|
||||
if (model.supported_plans && model.supported_plans.length > 0) {
|
||||
plansSummary = model.supported_plans.join(",");
|
||||
} else if (model.plans && model.plans.length > 0) {
|
||||
plansSummary = model.plans
|
||||
.map((plan) => {
|
||||
const planName = plan.plan ?? "?";
|
||||
if (plan.templates && plan.templates.length > 0) {
|
||||
return `${planName}(${plan.templates.length}t)`;
|
||||
}
|
||||
if (p.cu_specs && p.cu_specs.length > 0) {
|
||||
return `${planName}(${p.cu_specs.join("/")})`;
|
||||
if (plan.cu_specs && plan.cu_specs.length > 0) {
|
||||
return `${planName}(${plan.cu_specs.join("/")})`;
|
||||
}
|
||||
return planName;
|
||||
})
|
||||
@@ -148,9 +148,9 @@ export default defineCommand({
|
||||
plansSummary = "-";
|
||||
}
|
||||
return {
|
||||
model_name: m.model_name ?? "",
|
||||
base_model: m.base_model ?? "",
|
||||
source: m.model_source ?? "",
|
||||
model_name: model.model_name ?? "",
|
||||
base_model: model.base_model ?? "",
|
||||
source: model.model_source ?? "",
|
||||
plans: plansSummary,
|
||||
};
|
||||
});
|
||||
@@ -160,7 +160,12 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
const headers = ["MODEL_NAME", "BASE_MODEL", "SOURCE", "PLANS"];
|
||||
const rows = textItems.map((i) => [i.model_name, i.base_model, i.source, i.plans]);
|
||||
const rows = textItems.map((item) => [
|
||||
item.model_name,
|
||||
item.base_model,
|
||||
item.source,
|
||||
item.plans,
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
},
|
||||
|
||||
@@ -4,12 +4,12 @@ import {
|
||||
createFineTune,
|
||||
getDataset,
|
||||
uploadDataset,
|
||||
validateDataset,
|
||||
detectModality,
|
||||
getProfile,
|
||||
fetchModelCapability,
|
||||
listSupportedTrainingTypes,
|
||||
preflightBatchSizeGate,
|
||||
isTrainingTypeCli,
|
||||
toServerTrainingType,
|
||||
TRAINING_TYPES_CLI,
|
||||
DEFAULT_TRAINING_TYPE,
|
||||
formatIssue,
|
||||
@@ -17,10 +17,12 @@ import {
|
||||
ExitCode,
|
||||
type Client,
|
||||
type Settings,
|
||||
type CommandContext,
|
||||
type CreateFineTuneRequest,
|
||||
type FineTuneHyperParameters,
|
||||
type DatasetFile,
|
||||
type DatasetSchema,
|
||||
type TrainingProfile,
|
||||
type DataModality,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { existsSync, statSync } from "fs";
|
||||
@@ -77,7 +79,11 @@ async function analyzeDatasetTokens(
|
||||
binName: string,
|
||||
raw: string,
|
||||
label: string,
|
||||
schema?: DatasetSchema,
|
||||
profile: TrainingProfile,
|
||||
modality: DataModality,
|
||||
model: string,
|
||||
/** Pre-detected modality for a known path (avoids re-opening the file). */
|
||||
knownModality?: { path: string; modality: DataModality },
|
||||
): Promise<ResolvedDataset> {
|
||||
const tokens = raw
|
||||
.split(",")
|
||||
@@ -109,11 +115,18 @@ async function analyzeDatasetTokens(
|
||||
|
||||
if (settings.dryRun) continue;
|
||||
|
||||
// Local path → validate (same checks as `dataset upload`). Upload is
|
||||
// deferred to `uploadResolvedLocal` so the gate can run first. The schema
|
||||
// (SFT vs DPO) is derived from --training-type so a DPO job validates the
|
||||
// chosen/rejected preference pairs here, not on the platform.
|
||||
const result = await validateDataset(token, { schema });
|
||||
// The command's modality is authoritative; each local file is validated
|
||||
// under that modality's schema. Reuse the caller's pre-detected modality
|
||||
// when available to avoid opening the same file twice (matters for large
|
||||
// ZIPs).
|
||||
const tokenModality =
|
||||
knownModality && knownModality.path === token ? knownModality.modality : modality;
|
||||
|
||||
// Local path → validate through the profile. The profile internally routes
|
||||
// to the correct validator based on modality. Upload is deferred to
|
||||
// `uploadResolvedLocal` so the gate can run first. `model` is forwarded for
|
||||
// schema-agnostic cross-checks.
|
||||
const result = await profile.validate(token, tokenModality, { model });
|
||||
if (!result.valid) {
|
||||
const lines = [
|
||||
`Dataset validation failed for ${token}`,
|
||||
@@ -194,25 +207,33 @@ async function uploadResolvedLocal(
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
const CREATE_FLAGS = {
|
||||
/** The modality a `finetune <modality> create` subcommand is bound to. */
|
||||
type CommandModality = "text" | "audio" | "image";
|
||||
|
||||
/**
|
||||
* Flags shared by every `finetune <modality> create` subcommand: what to train
|
||||
* (model), what data to train on (datasets / validations), and how to name the
|
||||
* output. Every modality's model consumes these.
|
||||
*/
|
||||
const COMMON_FLAGS = {
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Base model to fine-tune (e.g. qwen3-8b, qwen3-14b)",
|
||||
description: "Base model to fine-tune",
|
||||
required: true,
|
||||
},
|
||||
datasets: {
|
||||
type: "string",
|
||||
valueHint: "<ids|paths>",
|
||||
description:
|
||||
"Comma-separated dataset file IDs or local .jsonl paths. Local paths are uploaded (validated) first, then their file-ids are used.",
|
||||
"Comma-separated dataset file IDs or local paths (.jsonl for text, .zip for audio/image). Local paths are uploaded (validated) first, then their file-ids are used.",
|
||||
required: true,
|
||||
},
|
||||
validations: {
|
||||
type: "string",
|
||||
valueHint: "<ids|paths>",
|
||||
description:
|
||||
"Comma-separated validation dataset file IDs or local .jsonl paths (auto-uploaded like --datasets).",
|
||||
"Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets).",
|
||||
},
|
||||
modelName: {
|
||||
type: "string",
|
||||
@@ -224,6 +245,16 @@ const CREATE_FLAGS = {
|
||||
valueHint: "<text>",
|
||||
description: "Output suffix appended by the platform (finetuned_output_suffix)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/**
|
||||
* Text flags: text models consume the full hyper-parameter surface — training
|
||||
* type selection plus n_epochs / batch_size / learning_rate / max_length (see
|
||||
* resolveTextHyperParameters). Only text exposes --training-type because only
|
||||
* text models support types other than the sft-lora default.
|
||||
*/
|
||||
const TEXT_FLAGS = {
|
||||
...COMMON_FLAGS,
|
||||
trainingType: {
|
||||
type: "string",
|
||||
valueHint: "<t>",
|
||||
@@ -252,12 +283,368 @@ const CREATE_FLAGS = {
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Create a fine-tune job (sft | sft-lora | dpo | dpo-lora | cpt)",
|
||||
/**
|
||||
* Audio (CosyVoice TTS) flags: the audio model runs sft-lora with a fully fixed
|
||||
* hyper-parameter set (AUDIO_HYPER_PARAMS). No --training-type or hyper-parameter
|
||||
* flag is honored by resolveHyperParameters, so none are exposed.
|
||||
*/
|
||||
const AUDIO_FLAGS = {
|
||||
...COMMON_FLAGS,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/**
|
||||
* Image (Wan generation) flags: the image model runs sft-lora with fixed
|
||||
* defaults; resolveHyperParameters only honors learning_rate, so --learning-rate
|
||||
* is the sole extra numeric knob. --generation-type declares T2I vs I2I
|
||||
* explicitly (the platform expects generation_type as a request field); it is
|
||||
* required to reach I2I from a bare file-id or in --dry-run, where the data
|
||||
* cannot be inspected.
|
||||
*/
|
||||
const IMAGE_FLAGS = {
|
||||
...COMMON_FLAGS,
|
||||
generationType: {
|
||||
type: "string",
|
||||
choices: ["t2i", "i2i"] as const,
|
||||
valueHint: "<t2i|i2i>",
|
||||
description:
|
||||
"Generation type: t2i (default) | i2i. Sets generation_type/max_pixels. Required to train I2I from a file-id or with --dry-run (local data auto-detects input_img).",
|
||||
},
|
||||
learningRate: {
|
||||
type: "string",
|
||||
valueHint: "<str>",
|
||||
description: 'Learning rate as a string to preserve precision (e.g. "3e-5")',
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const TEXT_USAGE =
|
||||
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]";
|
||||
|
||||
const AUDIO_USAGE =
|
||||
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
|
||||
|
||||
const IMAGE_USAGE =
|
||||
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
|
||||
|
||||
const COMMON_NOTES = [
|
||||
"Creating a job uploads any local datasets and consumes training quota.",
|
||||
"Use --dry-run to preview the request body without submitting.",
|
||||
"--datasets / --validations accept either file-ids (from `dataset upload`)",
|
||||
"or local paths. Local paths are validated and uploaded first, then their",
|
||||
"file-ids are submitted — a one-step upload-and-train.",
|
||||
];
|
||||
|
||||
const TEXT_NOTES = [
|
||||
...COMMON_NOTES,
|
||||
"Training-type values use the `<method>` / `<method>-lora` convention:",
|
||||
"sft (full) | sft-lora (LoRA) | dpo (full) | dpo-lora (LoRA) | cpt. These map",
|
||||
"to the server's training_type at the interface boundary, so the rest of the",
|
||||
"CLI never sees the raw server strings.",
|
||||
"Before submitting (non dry-run) the job, the model's training capability is",
|
||||
"checked via listFoundationModels (no console login required); an unsupported",
|
||||
"training type fails fast with the list the model actually supports.",
|
||||
"n_epochs defaults to 3. Other hyper-parameters are platform defaults unless set.",
|
||||
"Learning rate is forwarded as a string to avoid JSON-number precision loss.",
|
||||
"Pre-submit gate: if the training dataset's sample count is not greater",
|
||||
"than batch_size, the job is rejected before upload or quota consumption",
|
||||
"(the platform would otherwise fail ~10 min in, after data processing).",
|
||||
];
|
||||
|
||||
const AUDIO_NOTES = [
|
||||
...COMMON_NOTES,
|
||||
"Audio TTS training runs sft-lora (efficient_sft) with fixed CosyVoice",
|
||||
"hyper-parameter defaults; there are no training-type or hyper-parameter",
|
||||
"knobs to set.",
|
||||
];
|
||||
|
||||
const IMAGE_NOTES = [
|
||||
...COMMON_NOTES,
|
||||
"Image generation training runs sft-lora (efficient_sft) with fixed defaults;",
|
||||
"only --learning-rate is overridable. T2I vs I2I is declared with",
|
||||
"--generation-type (default t2i), which sets generation_type/max_pixels. For",
|
||||
"local data the type is auto-detected (records with input_img train I2I);",
|
||||
"pass --generation-type explicitly to train I2I from a file-id or in --dry-run.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared `finetune <modality> create` implementation. The parameter surface and
|
||||
* run logic are identical to the previous single `finetune create`; the ONLY
|
||||
* change is that the data modality is fixed by the subcommand instead of being
|
||||
* detected from data content. This is what lets file-id datasets (which have no
|
||||
* local file to inspect) train the correct model — the old command silently
|
||||
* defaulted a file-id to "text".
|
||||
*
|
||||
* Image is the only modality with a sub-variant (T2I vs I2I). It is upgraded to
|
||||
* `image-i2i` only when a local file's first record carries `input_img`; a bare
|
||||
* file-id defaults to plain "image" (T2I), matching the old detection fallback.
|
||||
*/
|
||||
async function runCreate<F extends FlagsDef>(
|
||||
commandModality: CommandModality,
|
||||
ctx: CommandContext<F>,
|
||||
): Promise<void> {
|
||||
const { identity, settings } = ctx;
|
||||
const flags = ctx.flags as Record<string, unknown>;
|
||||
const model = flags.model as string;
|
||||
const datasetsRaw = flags.datasets as string;
|
||||
|
||||
// CosyVoice audio fine-tuning accepts exactly one training file
|
||||
// (`training_file_ids` supports a single ID per the speech-synthesis
|
||||
// contract). Reject a multi-token --datasets up-front so the job isn't
|
||||
// rejected server-side after an upload.
|
||||
if (commandModality === "audio") {
|
||||
const audioTokens = datasetsRaw
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
if (audioTokens.length > 1) {
|
||||
throw new BailianError(
|
||||
`Audio (TTS) fine-tuning accepts exactly one training file, got ${audioTokens.length}.`,
|
||||
ExitCode.USAGE,
|
||||
"Merge your recordings into a single .zip (or pass one file-id).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the training type before analyzing datasets so the validator can
|
||||
// enforce the right record schema (DPO jobs require chosen/rejected on
|
||||
// every record). Whitelist is the single source of truth in core
|
||||
// (TRAINING_TYPES_CLI); any other value is rejected up-front.
|
||||
const trainingType = (flags.trainingType as string | undefined) || DEFAULT_TRAINING_TYPE;
|
||||
if (!isTrainingTypeCli(trainingType)) {
|
||||
throw new BailianError(
|
||||
`--training-type "${trainingType}" is not supported.`,
|
||||
ExitCode.USAGE,
|
||||
`Supported values: ${TRAINING_TYPES_CLI.join(", ")} (default: ${DEFAULT_TRAINING_TYPE}).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Profile: single source of truth for how this training type behaves
|
||||
// (validation rules, hyper-parameters, gates, capability check).
|
||||
const profile = getProfile(trainingType);
|
||||
|
||||
// Modality is fixed by the subcommand (no content-based detection) — this is
|
||||
// the sole behavioural change of the modality split. Image alone has a T2I/I2I
|
||||
// sub-variant: an explicit --generation-type is authoritative (the only way to
|
||||
// reach I2I from a bare file-id or in --dry-run, where data can't be
|
||||
// inspected); otherwise a local file is probed to upgrade T2I → I2I, and a
|
||||
// bare file-id stays "image" (T2I).
|
||||
const firstLocalPath = datasetsRaw
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.find((token) => isLocalPath(token));
|
||||
let modality: DataModality = commandModality;
|
||||
if (commandModality === "image") {
|
||||
const generationType = flags.generationType as "t2i" | "i2i" | undefined;
|
||||
if (generationType === "i2i") {
|
||||
modality = "image-i2i";
|
||||
} else if (!generationType && firstLocalPath && !settings.dryRun) {
|
||||
const detected = await detectModality(firstLocalPath);
|
||||
if (detected === "image-i2i") modality = "image-i2i";
|
||||
}
|
||||
}
|
||||
|
||||
const training = await analyzeDatasetTokens(
|
||||
settings,
|
||||
identity.binName,
|
||||
datasetsRaw,
|
||||
"datasets",
|
||||
profile,
|
||||
modality,
|
||||
model,
|
||||
firstLocalPath ? { path: firstLocalPath, modality } : undefined,
|
||||
);
|
||||
const trainingFileIds = training.fileIds;
|
||||
|
||||
const validation = flags.validations
|
||||
? await analyzeDatasetTokens(
|
||||
settings,
|
||||
identity.binName,
|
||||
flags.validations as string,
|
||||
"validations",
|
||||
profile,
|
||||
modality,
|
||||
model,
|
||||
)
|
||||
: undefined;
|
||||
const validationFileIds = validation?.fileIds;
|
||||
|
||||
const modelName = flags.modelName as string | undefined;
|
||||
const suffix = flags.suffix as string | undefined;
|
||||
|
||||
// Hyper-parameters: the profile resolves modality-specific defaults
|
||||
// (text: n_epochs/batch_size/learning_rate; audio: lm_max_epoch/fm_max_epoch/...).
|
||||
const hp = profile.resolveHyperParameters(
|
||||
modality,
|
||||
flags as Record<string, unknown>,
|
||||
) as FineTuneHyperParameters;
|
||||
|
||||
// Restore the batch-size clamping warning that was lost when the logic moved
|
||||
// into profiles. The profile silently clamps to [8, 1024]; surface it here
|
||||
// so the user has an audit trail. Skip modalities that bypass the batch_size
|
||||
// gate (image): their batch_size is a fixed model-family default, not a
|
||||
// clamp of the user's value, so the [8, 1024] "clamped" message would be
|
||||
// self-contradictory and misleading.
|
||||
if (
|
||||
flags.batchSize !== undefined &&
|
||||
hp.batch_size !== undefined &&
|
||||
!settings.quiet &&
|
||||
!profile.shouldSkipGate("batch_size", modality)
|
||||
) {
|
||||
const requested = flags.batchSize as number;
|
||||
if (hp.batch_size !== requested) {
|
||||
process.stderr.write(
|
||||
`warning: --batch-size ${requested} clamped to ${hp.batch_size} ` +
|
||||
`(server range [8, 1024] for the common training types).\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// For modalities that skip the batch_size gate, warn the user that their
|
||||
// explicit --batch-size was discarded (model uses a fixed batch_size).
|
||||
if (
|
||||
flags.batchSize !== undefined &&
|
||||
!settings.quiet &&
|
||||
profile.shouldSkipGate("batch_size", modality)
|
||||
) {
|
||||
const requested = flags.batchSize as number;
|
||||
if (hp.batch_size !== undefined && hp.batch_size !== requested) {
|
||||
process.stderr.write(
|
||||
`warning: --batch-size ${requested} ignored for ${modality} training ` +
|
||||
`(model uses a fixed batch_size of ${hp.batch_size}).\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto batch_size for small datasets — only for text data. Audio/image
|
||||
// profiles already set their own batch parameters.
|
||||
if (modality === "text" && hp.batch_size === undefined && !settings.dryRun) {
|
||||
let sizeBytes = training.firstSize ?? 0;
|
||||
if (sizeBytes === 0) {
|
||||
try {
|
||||
const fileInfo = await getDataset(ctx.client, trainingFileIds[0]);
|
||||
sizeBytes = fileInfo.data?.size ?? 0;
|
||||
} catch {
|
||||
// If we can't fetch file info, skip auto-adjustment; platform will use default.
|
||||
}
|
||||
}
|
||||
if (sizeBytes > 0 && sizeBytes < 100 * 1024) {
|
||||
hp.batch_size = 8;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-submit batch-size gate: the platform rejects a job whose number of
|
||||
// training samples is not greater than batch_size, but only surfaces that
|
||||
// ~10 minutes into the run (after data processing). Fail fast here, before
|
||||
// burning quota. `recordCount` is only known when every --datasets token
|
||||
// was a local file we validated; file-id tokens fall through to the
|
||||
// platform rather than risk a false positive from an undercount.
|
||||
if (
|
||||
!settings.dryRun &&
|
||||
training.recordCount !== undefined &&
|
||||
!profile.shouldSkipGate("batch_size", modality)
|
||||
) {
|
||||
// 16 is the platform default when neither the user nor the small-file
|
||||
// auto-adjust set a batch_size (see the auto-adjust comment above).
|
||||
const effectiveBatchSize = hp.batch_size ?? 16;
|
||||
const gate = preflightBatchSizeGate({
|
||||
recordCount: training.recordCount,
|
||||
batchSize: effectiveBatchSize,
|
||||
});
|
||||
if (!gate.ok && gate.issue) {
|
||||
throw new BailianError(gate.issue.message, ExitCode.GENERAL, gate.hint);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight capability check: confirm the model actually supports the
|
||||
// requested training type BEFORE any upload, so a wrong --model /
|
||||
// --training-type combo doesn't burn storage on datasets that will never
|
||||
// be trained against. listFoundationModels is a public API (no console
|
||||
// login required); on lookup failure (network / 401 / etc.) we fall back
|
||||
// to letting the server decide rather than blocking the submit.
|
||||
if (!settings.dryRun && !profile.shouldSkipCapabilityCheck(modality)) {
|
||||
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
|
||||
try {
|
||||
capability = await fetchModelCapability(settings, model);
|
||||
} catch (error) {
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(
|
||||
`warning: model capability lookup failed (${(error as Error).message}); ` +
|
||||
"proceeding without local pre-flight.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (capability && !listSupportedTrainingTypes(capability).includes(trainingType)) {
|
||||
const supported = listSupportedTrainingTypes(capability);
|
||||
throw new BailianError(
|
||||
`Model "${model}" does not support training type "${trainingType}".`,
|
||||
ExitCode.USAGE,
|
||||
supported.length
|
||||
? `This model supports: ${supported.join(", ")}.`
|
||||
: "This model reports no supported training types.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload local paths now that pre-flight (validation, batch-size gate,
|
||||
// capability check) has cleared them. This swaps the placeholder path
|
||||
// entries in `training.fileIds` / `validation?.fileIds` for real file-ids.
|
||||
if (!settings.dryRun) {
|
||||
await uploadResolvedLocal(ctx.client, settings, training, "fine-tune", "datasets");
|
||||
if (validation) {
|
||||
await uploadResolvedLocal(ctx.client, settings, validation, "fine-tune", "validations");
|
||||
}
|
||||
}
|
||||
|
||||
const body: CreateFineTuneRequest = {
|
||||
model,
|
||||
training_file_ids: trainingFileIds,
|
||||
// Profile maps the CLI training type to the server value at the boundary.
|
||||
training_type: profile.serverTrainingType,
|
||||
hyper_parameters: hp,
|
||||
};
|
||||
if (validationFileIds && validationFileIds.length > 0) {
|
||||
body.validation_file_ids = validationFileIds;
|
||||
}
|
||||
if (modelName) body.model_name = modelName;
|
||||
if (suffix) body.finetuned_output_suffix = suffix;
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
const pending = [
|
||||
...training.localPaths.map((path) => ({ field: "datasets", path })),
|
||||
...(validation?.localPaths ?? []).map((path) => ({ field: "validations", path })),
|
||||
];
|
||||
emitResult(
|
||||
pending.length > 0
|
||||
? { action: "finetune.create", body, pending_uploads: pending }
|
||||
: { action: "finetune.create", body },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await createFineTune(ctx.client, body);
|
||||
const job = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
if (job?.job_id) emitBare(job.job_id);
|
||||
} else if (format === "text") {
|
||||
if (job?.job_id) {
|
||||
emitBare(`Created fine-tune job: ${job.job_id}`);
|
||||
if (job.status) emitBare(`Status: ${job.status}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
}
|
||||
|
||||
/** `bl finetune text create` — fine-tune a text model. Datasets are `.jsonl`. */
|
||||
export const finetuneTextCreate = defineCommand({
|
||||
description: "Create a text model fine-tune job (sft | sft-lora | dpo | dpo-lora | cpt)",
|
||||
auth: "apiKey",
|
||||
usageArgs:
|
||||
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]",
|
||||
flags: CREATE_FLAGS,
|
||||
usageArgs: TEXT_USAGE,
|
||||
flags: TEXT_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model qwen3-8b --datasets file-xxx",
|
||||
"--model qwen3-8b --datasets ./train.jsonl",
|
||||
@@ -268,231 +655,41 @@ export default defineCommand({
|
||||
"--model qwen3-8b --datasets file-xxx --output json",
|
||||
"--model qwen3-8b --datasets file-xxx --dry-run",
|
||||
],
|
||||
notes: [
|
||||
"Creating a job uploads any local datasets and consumes training quota.",
|
||||
"Use --dry-run to preview the request body without submitting.",
|
||||
"Training-type values use the `<method>` / `<method>-lora` convention:",
|
||||
"sft (full) | sft-lora (LoRA) | dpo (full) | dpo-lora (LoRA) | cpt. These map",
|
||||
"to the server's training_type at the interface boundary, so the rest of the",
|
||||
"CLI never sees the raw server strings.",
|
||||
"Before submitting (non dry-run) the job, the model's training capability is",
|
||||
"checked via listFoundationModels (no console login required); an unsupported",
|
||||
"training type fails fast with the list the model actually supports.",
|
||||
"n_epochs defaults to 3. Other hyper-parameters are platform defaults unless set.",
|
||||
"Learning rate is forwarded as a string to avoid JSON-number precision loss.",
|
||||
"--datasets / --validations accept either file-ids (from `dataset upload`)",
|
||||
"or local .jsonl paths. Local paths are validated and uploaded first, then",
|
||||
"their file-ids are submitted — a one-step upload-and-train.",
|
||||
"Dataset record schema is chosen from --training-type: dpo* → {messages,",
|
||||
"chosen, rejected}; cpt → {text} (raw pre-training text); else {messages}.",
|
||||
"Pre-submit gate: if the training dataset's sample count is not greater",
|
||||
"than batch_size, the job is rejected before upload or quota consumption",
|
||||
"(the platform would otherwise fail ~10 min in, after data processing).",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const model = flags.model;
|
||||
const datasetsRaw = flags.datasets;
|
||||
|
||||
// Resolve the training type before analyzing datasets so the validator can
|
||||
// enforce the right record schema (DPO jobs require chosen/rejected on
|
||||
// every record). Whitelist is the single source of truth in core
|
||||
// (TRAINING_TYPES_CLI); any other value is rejected up-front.
|
||||
const trainingType = flags.trainingType || DEFAULT_TRAINING_TYPE;
|
||||
if (!isTrainingTypeCli(trainingType)) {
|
||||
throw new BailianError(
|
||||
`--training-type "${trainingType}" is not supported.`,
|
||||
ExitCode.USAGE,
|
||||
`Supported values: ${TRAINING_TYPES_CLI.join(", ")} (default: ${DEFAULT_TRAINING_TYPE}).`,
|
||||
);
|
||||
}
|
||||
// dpo / dpo-lora → "dpo" schema (strict chosen/rejected); cpt → "cpt"
|
||||
// (raw {text} records); else ChatML ({messages}).
|
||||
const datasetSchema: DatasetSchema = trainingType.startsWith("dpo")
|
||||
? "dpo"
|
||||
: trainingType === "cpt"
|
||||
? "cpt"
|
||||
: "chatml";
|
||||
|
||||
const training = await analyzeDatasetTokens(
|
||||
settings,
|
||||
identity.binName,
|
||||
datasetsRaw,
|
||||
"datasets",
|
||||
datasetSchema,
|
||||
);
|
||||
const trainingFileIds = training.fileIds;
|
||||
|
||||
const validation = flags.validations
|
||||
? await analyzeDatasetTokens(
|
||||
settings,
|
||||
identity.binName,
|
||||
flags.validations,
|
||||
"validations",
|
||||
datasetSchema,
|
||||
)
|
||||
: undefined;
|
||||
const validationFileIds = validation?.fileIds;
|
||||
|
||||
const modelName = flags.modelName;
|
||||
const suffix = flags.suffix;
|
||||
|
||||
// Hyper-parameters: inject n_epochs=3 default unless overridden.
|
||||
const hp: FineTuneHyperParameters = {};
|
||||
hp.n_epochs = flags.nEpochs ?? 3;
|
||||
if (flags.learningRate !== undefined) hp.learning_rate = flags.learningRate;
|
||||
if (flags.maxLength !== undefined) hp.max_length = flags.maxLength;
|
||||
|
||||
// batch_size: clamp to [8, 1024] (server hard constraint, undocumented).
|
||||
// Surface the clamp on stderr instead of silently rewriting the user's
|
||||
// value — otherwise the submitted body would carry a number the user never
|
||||
// typed, with no audit trail. (Range observed on common SFT / SFT-LoRA
|
||||
// training types; some bases like qwen3.6-flash report a wider range, so
|
||||
// the warning explicitly mentions "server range".)
|
||||
if (flags.batchSize !== undefined) {
|
||||
const requested = flags.batchSize;
|
||||
let batchSize = requested;
|
||||
if (batchSize < 8) batchSize = 8;
|
||||
if (batchSize > 1024) batchSize = 1024;
|
||||
if (batchSize !== requested && !settings.quiet) {
|
||||
process.stderr.write(
|
||||
`warning: --batch-size ${requested} clamped to ${batchSize} ` +
|
||||
`(server range [8, 1024] for the common training types).\n`,
|
||||
);
|
||||
}
|
||||
hp.batch_size = batchSize;
|
||||
}
|
||||
|
||||
// Auto batch_size for small datasets: fetch first training file size.
|
||||
// With default split=0.9, validation_set = 0.1 * rows.
|
||||
// Platform default batch_size=16 needs rows > 160; batch_size=8 needs rows > 80.
|
||||
// Files < 100KB are conservatively estimated to have < 200 rows.
|
||||
// If the first file was just uploaded we already hold its size; otherwise
|
||||
// fall back to getDataset.
|
||||
if (hp.batch_size === undefined && !settings.dryRun) {
|
||||
let sizeBytes = training.firstSize ?? 0;
|
||||
if (sizeBytes === 0) {
|
||||
try {
|
||||
const fileInfo = await getDataset(ctx.client, trainingFileIds[0]);
|
||||
sizeBytes = fileInfo.data?.size ?? 0;
|
||||
} catch {
|
||||
// If we can't fetch file info, skip auto-adjustment; platform will use default.
|
||||
}
|
||||
}
|
||||
if (sizeBytes > 0 && sizeBytes < 100 * 1024) {
|
||||
hp.batch_size = 8;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-submit batch-size gate: the platform rejects a job whose number of
|
||||
// training samples is not greater than batch_size, but only surfaces that
|
||||
// ~10 minutes into the run (after data processing). Fail fast here, before
|
||||
// burning quota. `recordCount` is only known when every --datasets token
|
||||
// was a local file we validated; file-id tokens fall through to the
|
||||
// platform rather than risk a false positive from an undercount.
|
||||
//
|
||||
// The decision lives in core (`preflightBatchSizeGate`) — a structured,
|
||||
// job-level pre-flight that returns a `ValidationIssue` (same shape / stable
|
||||
// code as `validateDataset`) so the failure surfaces through the same
|
||||
// `BailianError` + issue convention used by `dataset upload`/`validate`.
|
||||
// ExitCode.GENERAL matches the existing validation-failed exit code.
|
||||
if (!settings.dryRun && training.recordCount !== undefined) {
|
||||
// 16 is the platform default when neither the user nor the small-file
|
||||
// auto-adjust set a batch_size (see the auto-adjust comment above).
|
||||
const effectiveBatchSize = hp.batch_size ?? 16;
|
||||
const gate = preflightBatchSizeGate({
|
||||
recordCount: training.recordCount,
|
||||
batchSize: effectiveBatchSize,
|
||||
});
|
||||
if (!gate.ok && gate.issue) {
|
||||
throw new BailianError(gate.issue.message, ExitCode.GENERAL, gate.hint);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight capability check: confirm the model actually supports the
|
||||
// requested training type BEFORE any upload, so a wrong --model /
|
||||
// --training-type combo doesn't burn storage on datasets that will never
|
||||
// be trained against. listFoundationModels is a public API (no console
|
||||
// login required); on lookup failure (network / 401 / etc.) we fall back
|
||||
// to letting the server decide rather than blocking the submit.
|
||||
if (!settings.dryRun) {
|
||||
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
|
||||
try {
|
||||
capability = await fetchModelCapability(settings, model);
|
||||
} catch (error) {
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(
|
||||
`warning: model capability lookup failed (${(error as Error).message}); ` +
|
||||
"proceeding without local pre-flight.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (capability && !listSupportedTrainingTypes(capability).includes(trainingType)) {
|
||||
const supported = listSupportedTrainingTypes(capability);
|
||||
throw new BailianError(
|
||||
`Model "${model}" does not support training type "${trainingType}".`,
|
||||
ExitCode.USAGE,
|
||||
supported.length
|
||||
? `This model supports: ${supported.join(", ")}.`
|
||||
: "This model reports no supported training types.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload local paths now that pre-flight (validation, batch-size gate,
|
||||
// capability check) has cleared them. This swaps the
|
||||
// placeholder path entries in `training.fileIds` / `validation?.fileIds`
|
||||
// for real file-ids, so the body below sees ids.
|
||||
if (!settings.dryRun) {
|
||||
await uploadResolvedLocal(ctx.client, settings, training, "fine-tune", "datasets");
|
||||
if (validation) {
|
||||
await uploadResolvedLocal(ctx.client, settings, validation, "fine-tune", "validations");
|
||||
}
|
||||
}
|
||||
|
||||
const body: CreateFineTuneRequest = {
|
||||
model,
|
||||
training_file_ids: trainingFileIds,
|
||||
// Map the CLI training type to the server value at the interface boundary.
|
||||
training_type: toServerTrainingType(trainingType),
|
||||
hyper_parameters: hp,
|
||||
};
|
||||
if (validationFileIds && validationFileIds.length > 0) {
|
||||
body.validation_file_ids = validationFileIds;
|
||||
}
|
||||
if (modelName) body.model_name = modelName;
|
||||
if (suffix) body.finetuned_output_suffix = suffix;
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
const pending = [
|
||||
...training.localPaths.map((path) => ({ field: "datasets", path })),
|
||||
...(validation?.localPaths ?? []).map((path) => ({ field: "validations", path })),
|
||||
];
|
||||
emitResult(
|
||||
pending.length > 0
|
||||
? { action: "finetune.create", body, pending_uploads: pending }
|
||||
: { action: "finetune.create", body },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await createFineTune(ctx.client, body);
|
||||
const job = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
if (job?.job_id) emitBare(job.job_id);
|
||||
} else if (format === "text") {
|
||||
if (job?.job_id) {
|
||||
emitBare(`Created fine-tune job: ${job.job_id}`);
|
||||
if (job.status) emitBare(`Status: ${job.status}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
notes: TEXT_NOTES,
|
||||
run: (ctx) => runCreate("text", ctx),
|
||||
});
|
||||
|
||||
/** `bl finetune audio create` — fine-tune an audio TTS model. Datasets are `.zip`. */
|
||||
export const finetuneAudioCreate = defineCommand({
|
||||
description: "Create an audio TTS model fine-tune job (sft-lora)",
|
||||
auth: "apiKey",
|
||||
usageArgs: AUDIO_USAGE,
|
||||
flags: AUDIO_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model cosyvoice-v3-flash --datasets ./audio.zip",
|
||||
"--model cosyvoice-v3-flash --datasets file-xxx",
|
||||
"--model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
|
||||
"--model cosyvoice-v3-flash --datasets file-xxx --output json",
|
||||
"--model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
|
||||
],
|
||||
notes: AUDIO_NOTES,
|
||||
run: (ctx) => runCreate("audio", ctx),
|
||||
});
|
||||
|
||||
/** `bl finetune image create` — fine-tune an image generation model. Datasets are `.zip`. */
|
||||
export const finetuneImageCreate = defineCommand({
|
||||
description: "Create an image generation model fine-tune job (sft-lora)",
|
||||
auth: "apiKey",
|
||||
usageArgs: IMAGE_USAGE,
|
||||
flags: IMAGE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model wan2.7-image-pro --datasets ./images.zip",
|
||||
"--model wan2.7-image-pro --datasets file-xxx",
|
||||
"--model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
|
||||
"--model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
|
||||
"--model wan2.7-image-pro --datasets file-xxx --output json",
|
||||
"--model wan2.7-image-pro --datasets ./images.zip --dry-run",
|
||||
],
|
||||
notes: IMAGE_NOTES,
|
||||
run: (ctx) => runCreate("image", ctx),
|
||||
});
|
||||
|
||||
@@ -34,9 +34,9 @@ export default defineCommand({
|
||||
flags: EXPORT_FLAGS,
|
||||
exampleArgs: ["--job-id ft-xxx --checkpoint ckpt-3 --model-name my-qwen-sft"],
|
||||
notes: [
|
||||
"Required before `deploy create` can target a checkpoint. The platform",
|
||||
"may auto-export the best checkpoint when a job reaches SUCCEEDED — explicit",
|
||||
"export is the canonical path for non-best checkpoints.",
|
||||
"Required before `deploy <modality> create` can target a checkpoint. The",
|
||||
"platform may auto-export the best checkpoint when a job reaches SUCCEEDED —",
|
||||
"explicit export is the canonical path for non-best checkpoints.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
@@ -66,7 +66,9 @@ export default defineCommand({
|
||||
emitBare(exported);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Exported ${jobId} / ${checkpoint} → model_name=${exported}`);
|
||||
emitBare(`Next: ${identity.binName} deploy create --model ${exported} --name <display-name>`);
|
||||
emitBare(
|
||||
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
|
||||
);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export default defineCommand({
|
||||
if (item.hyper_params) emitBare(`hyper_params: ${item.hyper_params}`);
|
||||
if (item.output_model)
|
||||
emitBare(
|
||||
`output_model: ${item.output_model} (→ ${identity.binName} deploy create --model)`,
|
||||
`output_model: ${item.output_model} (→ ${identity.binName} deploy text create --model)`,
|
||||
);
|
||||
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
|
||||
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
|
||||
|
||||
@@ -75,6 +75,8 @@ export default defineCommand({
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitBare(`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy create --model\``);
|
||||
emitBare(
|
||||
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getFineTune,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const DEFAULT_INTERVAL_SEC = 10;
|
||||
const MIN_INTERVAL_SEC = 1;
|
||||
const TERMINAL_STATUSES = new Set(["SUCCEEDED", "FAILED", "CANCELED"]);
|
||||
/** SIGINT exit code (128 + signal 2). */
|
||||
const EXIT_INTERRUPTED = 130;
|
||||
const EXIT_FAILED = 1;
|
||||
const EXIT_TIMEOUT = 2;
|
||||
/** Non-terminal status: the job is still running. Distinct from failure. */
|
||||
const EXIT_RUNNING = 3;
|
||||
|
||||
function nowStamp(): string {
|
||||
const date = new Date();
|
||||
@@ -25,18 +26,6 @@ function formatElapsed(milliseconds: number): string {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit code for a status value:
|
||||
* SUCCEEDED -> 0
|
||||
* FAILED / CANCELED -> 1
|
||||
* anything else -> 3 (still running)
|
||||
*/
|
||||
function exitCodeForStatus(status: string): number {
|
||||
if (status === "SUCCEEDED") return 0;
|
||||
if (TERMINAL_STATUSES.has(status)) return EXIT_FAILED;
|
||||
return EXIT_RUNNING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve after `milliseconds`, rejecting early if `signal` aborts (Ctrl-C).
|
||||
* Cleans up its timer + listener so nothing leaks between polls.
|
||||
@@ -101,9 +90,9 @@ export default defineCommand({
|
||||
"Default (no --follow) is a NON-BLOCKING single status probe: one fetch, then",
|
||||
"return immediately. This is the mode meant for agents / scripts — the caller",
|
||||
"owns the polling cadence, so the CLI never holds the terminal.",
|
||||
"Exit codes (both modes): 0 SUCCEEDED | 1 FAILED/CANCELED | 2 --poll-timeout",
|
||||
"exceeded (--follow) | 3 still running (non-terminal, default mode) | 130",
|
||||
"interrupted (Ctrl-C).",
|
||||
"A terminal FAILED/CANCELED status raises a normal CLI error (non-zero exit);",
|
||||
"a SUCCEEDED or still-running status returns 0. With --follow, exceeding",
|
||||
"--poll-timeout raises a timeout error.",
|
||||
"Use --follow for the blocking, human-terminal-follow experience; use the",
|
||||
"default mode when driving the loop yourself (e.g. from an agent).",
|
||||
"For per-step training output (not status), use `finetune logs`.",
|
||||
@@ -130,32 +119,34 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit codes here are a public probe contract (0 succeeded / 1 failed / 2
|
||||
// timeout / 3 still running / 130 interrupted) — deliberately routed via
|
||||
// process.exit instead of the central error handler.
|
||||
|
||||
// ---- Default: non-blocking single status probe -------------------------
|
||||
// A terminal FAILED/CANCELED status is surfaced as a BailianError (the
|
||||
// central handler prints it and exits non-zero); SUCCEEDED and still-running
|
||||
// both return normally. No process.exit / custom exit-code contract.
|
||||
if (!follow) {
|
||||
const response = await getFineTune(ctx.client, jobId);
|
||||
const job = response.output ?? response.data;
|
||||
const status = String(job?.status ?? "").toUpperCase();
|
||||
const terminal = TERMINAL_STATUSES.has(status);
|
||||
const code = exitCodeForStatus(status);
|
||||
|
||||
if (settings.quiet) {
|
||||
// Just the status word — ideal for `status=$(... finetune watch ... --quiet)`.
|
||||
emitBare(status || "UNKNOWN");
|
||||
} else if (format === "text") {
|
||||
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
|
||||
if (terminal) {
|
||||
const mark = status === "SUCCEEDED" ? "✓" : "✗";
|
||||
emitBare(`${mark} ${jobId} ${status}`);
|
||||
}
|
||||
if (status === "SUCCEEDED") emitBare(`✓ ${jobId} ${status}`);
|
||||
} else {
|
||||
// json: a compact, purpose-built status probe.
|
||||
emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format);
|
||||
}
|
||||
process.exit(code);
|
||||
|
||||
if (terminal && status !== "SUCCEEDED") {
|
||||
throw new BailianError(
|
||||
`Fine-tune job ${jobId} ended in status ${status}.`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- --follow: blocking poll loop (legacy behavior) -------------------
|
||||
@@ -182,28 +173,35 @@ export default defineCommand({
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (format !== "text" || settings.quiet) {
|
||||
emitResult(response, format);
|
||||
} else {
|
||||
const mark = status === "SUCCEEDED" ? "✓" : "✗";
|
||||
emitBare(`\n${mark} ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
|
||||
} else if (status === "SUCCEEDED") {
|
||||
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
|
||||
}
|
||||
process.exit(exitCodeForStatus(status));
|
||||
if (status !== "SUCCEEDED") {
|
||||
throw new BailianError(
|
||||
`Fine-tune job ${jobId} ended in status ${status} (elapsed ${formatElapsed(elapsed)}).`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pollTimeoutSec !== undefined && (Date.now() - startedAt) / 1000 >= pollTimeoutSec) {
|
||||
if (format === "text" && !settings.quiet) {
|
||||
emitBare(
|
||||
`\n⏼ ${jobId} timed out after ${formatElapsed(Date.now() - startedAt)} (last status: ${status || "UNKNOWN"})`,
|
||||
);
|
||||
}
|
||||
process.exit(EXIT_TIMEOUT);
|
||||
throw new BailianError(
|
||||
`Watching fine-tune job ${jobId} timed out after ` +
|
||||
`${formatElapsed(Date.now() - startedAt)} (last status: ${status || "UNKNOWN"}).`,
|
||||
ExitCode.TIMEOUT,
|
||||
);
|
||||
}
|
||||
|
||||
await sleep(intervalSec * 1000, controller.signal);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ctrl-C aborts the poll loop: report and return normally (no custom code).
|
||||
// Any other error (including the BailianError thrown above) propagates to
|
||||
// the central handler.
|
||||
if (controller.signal.aborted) {
|
||||
emitBare("\nInterrupted.");
|
||||
process.exit(EXIT_INTERRUPTED);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
resolveWatermark,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
redactDataUri,
|
||||
} from "bailian-cli-core";
|
||||
import { poll } from "bailian-cli-runtime";
|
||||
import { downloadFile } from "bailian-cli-runtime";
|
||||
@@ -31,10 +32,15 @@ import { resolveImageSize } from "bailian-cli-runtime";
|
||||
import { join } from "path";
|
||||
import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime";
|
||||
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
|
||||
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
|
||||
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
function enablesPromptExtendByDefault(model: string): boolean {
|
||||
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
const EDIT_FLAGS = {
|
||||
@@ -98,7 +104,7 @@ const EDIT_FLAGS = {
|
||||
type EditFlags = ParsedFlags<typeof EDIT_FLAGS>;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Edit an existing image with text instructions (Qwen-Image)",
|
||||
description: "Edit an existing image with text instructions (Qwen-Image / Wan 2.7)",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--image <url> --prompt <text> [flags]",
|
||||
flags: EDIT_FLAGS,
|
||||
@@ -107,6 +113,7 @@ export default defineCommand({
|
||||
'--image https://example.com/logo.png --prompt "Change color to blue" --n 3',
|
||||
'--image ./a.png --image ./b.png --prompt "Merge two images into one collage"',
|
||||
'--image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro',
|
||||
'--image ./photo.png --prompt "Change the style" --model wan2.7-image',
|
||||
'--image ./photo.png --prompt "Replace the background with a beach" --watermark false',
|
||||
],
|
||||
async run(ctx) {
|
||||
@@ -125,13 +132,13 @@ export default defineCommand({
|
||||
|
||||
// Auto-upload local files (resolve all images in parallel)
|
||||
const resolvedImages = await Promise.all(
|
||||
rawImages.map((img) => ctx.client.uploadFile(img, model)),
|
||||
rawImages.map((image) => ctx.client.resolveImageInput(image, model)),
|
||||
);
|
||||
const n = flags.n ?? 1;
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
useSync ? true : undefined,
|
||||
enablesPromptExtendByDefault(model) ? true : undefined,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
@@ -169,7 +176,18 @@ export default defineCommand({
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
|
||||
const previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
messages: body.input.messages.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((item) =>
|
||||
item.image ? { ...item, image: redactDataUri(item.image) } : item,
|
||||
),
|
||||
})),
|
||||
},
|
||||
};
|
||||
emitResult({ request: previewBody, mode: useSync ? "sync" : "async" }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,16 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai
|
||||
|
||||
import { join } from "path";
|
||||
|
||||
// qwen-image-2.0 series uses the sync multimodal-generation endpoint
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
// Qwen-Image 2.0 and Wan 2.7 use the sync multimodal-generation endpoint.
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
|
||||
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
|
||||
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
function enablesPromptExtendByDefault(model: string): boolean {
|
||||
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
const GENERATE_FLAGS = {
|
||||
@@ -121,7 +126,7 @@ export default defineCommand({
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
useSync ? true : undefined,
|
||||
enablesPromptExtendByDefault(model) ? true : undefined,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
fetchModelDetail,
|
||||
fetchModelGroups,
|
||||
fetchPredictConfig,
|
||||
type FlagsDef,
|
||||
type ModelGroup,
|
||||
type ModelGroupItem,
|
||||
type ModelPriceInfo,
|
||||
type PredictConfigEntry,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const DATE_SUFFIX_RE = /-\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const LIST_FLAGS = {
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Show full details of a specific model family (switches to detail mode)",
|
||||
},
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
pageSize: { type: "number", valueHint: "<n>", description: "Results per page (default: 10)" },
|
||||
provider: {
|
||||
type: "array",
|
||||
valueHint: "<p>",
|
||||
description: "Filter by provider (repeatable, e.g. --provider alibaba --provider deepseek)",
|
||||
},
|
||||
capability: {
|
||||
type: "array",
|
||||
valueHint: "<c>",
|
||||
description: "Filter by capability code (TG, Reasoning, VU, IG, VG, TTS, ASR, …)",
|
||||
},
|
||||
feature: {
|
||||
type: "array",
|
||||
valueHint: "<f>",
|
||||
description: "Filter by feature (function-calling, web-search, structured-outputs, …)",
|
||||
},
|
||||
contextWindow: {
|
||||
type: "array",
|
||||
valueHint: "<w>",
|
||||
description: "Filter by context window range bucket",
|
||||
},
|
||||
enrich: {
|
||||
type: "switch",
|
||||
description:
|
||||
"Also fetch input parameter schema (predictConfig) for trunk models (detail mode only)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Strip group- prefix from the family model key. */
|
||||
function familySlug(group: ModelGroup): string {
|
||||
return (group.model ?? "").replace(/^group-/, "") || group.name || "unknown";
|
||||
}
|
||||
|
||||
/** Format a single price entry compactly for table display. */
|
||||
function formatPriceCompact(prices: ModelPriceInfo[] | undefined): string {
|
||||
if (!prices?.length) return "-";
|
||||
const inputPrice =
|
||||
prices.find(
|
||||
(priceEntry) => typeof priceEntry.type === "string" && priceEntry.type.includes("input"),
|
||||
) ?? prices[0];
|
||||
const typeLabel = inputPrice.type ?? "";
|
||||
const priceStr = String(inputPrice.price ?? "?");
|
||||
const unit = inputPrice.priceUnit ?? "";
|
||||
return `${typeLabel}:${priceStr}/${unit}`;
|
||||
}
|
||||
|
||||
/** Aggregate metadata from a group's items. */
|
||||
function aggregateGroupMeta(group: ModelGroup) {
|
||||
const providers = new Set<string>();
|
||||
const capabilities = new Set<string>();
|
||||
let modelCount = 0;
|
||||
let maxContext = 0;
|
||||
let representativePrice: ModelPriceInfo | undefined;
|
||||
const allPrices: ModelPriceInfo[] = [];
|
||||
|
||||
for (const item of group.items ?? []) {
|
||||
if (item.provider) providers.add(item.provider);
|
||||
for (const cap of item.capabilities ?? []) capabilities.add(cap);
|
||||
modelCount++;
|
||||
if (typeof item.contextWindow === "number" && item.contextWindow > maxContext) {
|
||||
maxContext = item.contextWindow;
|
||||
}
|
||||
if (item.prices?.length) {
|
||||
allPrices.push(...item.prices);
|
||||
if (!representativePrice) {
|
||||
representativePrice =
|
||||
item.prices.find(
|
||||
(priceEntry) =>
|
||||
typeof priceEntry.type === "string" && priceEntry.type.includes("input"),
|
||||
) ?? item.prices[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
providers: [...providers].join(","),
|
||||
capabilities: [...capabilities].join(","),
|
||||
modelCount,
|
||||
maxContext: maxContext > 0 ? maxContext : undefined,
|
||||
price: representativePrice ? formatPriceCompact([representativePrice]) : undefined,
|
||||
prices: allPrices,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pick trunk items: exclude date-suffixed snapshots; if all snapshots, keep the latest. */
|
||||
function pickTrunkItems(items: ModelGroupItem[]): ModelGroupItem[] {
|
||||
if (items.length === 0) return [];
|
||||
const trunk = items.filter((item) => !DATE_SUFFIX_RE.test(item.model ?? ""));
|
||||
if (trunk.length > 0) return trunk;
|
||||
return [...items]
|
||||
.sort((first, second) => String(second.model).localeCompare(String(first.model)))
|
||||
.slice(0, 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browse mode — family-level paginated listing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function printBrowseText(groups: ModelGroup[], total: number): void {
|
||||
if (groups.length === 0) {
|
||||
emitBare("No model families found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ["NAME", "PROVIDER", "CAPABILITIES", "MODELS", "MAX_CONTEXT", "PRICE"];
|
||||
const rows = groups.map((group) => {
|
||||
const meta = aggregateGroupMeta(group);
|
||||
return [
|
||||
group.name || familySlug(group),
|
||||
meta.providers || "-",
|
||||
meta.capabilities || "-",
|
||||
String(meta.modelCount),
|
||||
meta.maxContext ? String(meta.maxContext) : "-",
|
||||
meta.price ?? "-",
|
||||
];
|
||||
});
|
||||
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
emitBare(`\nTotal: ${total}`);
|
||||
}
|
||||
|
||||
function formatBrowseJson(groups: ModelGroup[], total: number) {
|
||||
const items = groups.map((group) => {
|
||||
const meta = aggregateGroupMeta(group);
|
||||
const firstItem = group.items?.[0];
|
||||
return {
|
||||
model: familySlug(group),
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
provider: meta.providers,
|
||||
capabilities: [...new Set((group.items ?? []).flatMap((item) => item.capabilities ?? []))],
|
||||
modelCount: meta.modelCount,
|
||||
maxContextWindow: meta.maxContext,
|
||||
prices: meta.prices.length > 0 ? meta.prices : undefined,
|
||||
qpmInfo: firstItem?.qpmInfo,
|
||||
};
|
||||
});
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail mode — single family with full enrichment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function printDetailText(detail: ModelGroup, includePredictConfig: boolean): void {
|
||||
const items = detail.items ?? [];
|
||||
const trunkItems = pickTrunkItems(items);
|
||||
const snapshotCount = items.length - trunkItems.length;
|
||||
|
||||
emitBare(detail.name ?? detail.model);
|
||||
if (detail.description) emitBare(` ${detail.description}`);
|
||||
if (detail.updateAt) emitBare(` Updated: ${detail.updateAt}`);
|
||||
emitBare("");
|
||||
|
||||
// Items table
|
||||
const headers = ["MODEL", "PROVIDER", "CAPABILITIES", "CONTEXT", "OUTPUT", "CATEGORY", "PRICE"];
|
||||
const rows = items.map((item) => [
|
||||
item.model ?? "-",
|
||||
item.provider ?? "-",
|
||||
(item.capabilities ?? []).join(",") || "-",
|
||||
item.contextWindow ? String(item.contextWindow) : "-",
|
||||
item.maxOutputTokens ? String(item.maxOutputTokens) : "-",
|
||||
item.category ?? "-",
|
||||
formatPriceCompact(item.prices),
|
||||
]);
|
||||
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
emitBare(
|
||||
`\nTotal: ${items.length} models (${trunkItems.length} trunk, ${snapshotCount} snapshots)`,
|
||||
);
|
||||
|
||||
// Pricing details per trunk item
|
||||
for (const item of trunkItems) {
|
||||
if (!item.prices?.length) continue;
|
||||
emitBare(`\n── Pricing: ${item.model} ──`);
|
||||
const priceHeaders = ["TYPE", "PRICE", "UNIT"];
|
||||
const priceRows = item.prices.map((priceEntry) => [
|
||||
priceEntry.type ?? "-",
|
||||
String(priceEntry.price ?? "-"),
|
||||
priceEntry.priceUnit ?? "-",
|
||||
]);
|
||||
for (const line of formatTable(priceHeaders, priceRows)) emitBare(line);
|
||||
}
|
||||
|
||||
// QPM details per trunk item
|
||||
for (const item of trunkItems) {
|
||||
if (!item.qpmInfo || Object.keys(item.qpmInfo).length === 0) continue;
|
||||
emitBare(`\n── Rate Limits: ${item.model} ──`);
|
||||
const qpmHeaders = ["TIER", "RPM", "TPM", "TPM_FIELD"];
|
||||
const qpmRows = Object.entries(item.qpmInfo).map(([tier, limits]) => {
|
||||
const period = (limits.count_limit_period as number) || 60;
|
||||
const rpm = period ? Math.floor((((limits.count_limit as number) || 0) * 60) / period) : 0;
|
||||
const usagePeriod = (limits.usage_limit_period as number) || 60;
|
||||
const tpm = usagePeriod
|
||||
? Math.floor((((limits.usage_limit as number) || 0) * 60) / usagePeriod)
|
||||
: 0;
|
||||
return [
|
||||
tier,
|
||||
rpm > 0 ? String(rpm) : "-",
|
||||
tpm > 0 ? String(tpm) : "-",
|
||||
(limits.usage_limit_field as string) ?? "-",
|
||||
];
|
||||
});
|
||||
for (const line of formatTable(qpmHeaders, qpmRows)) emitBare(line);
|
||||
}
|
||||
|
||||
// PredictConfig sections
|
||||
if (includePredictConfig) {
|
||||
for (const item of trunkItems) {
|
||||
if (!item.predictConfig || item.predictConfig.length === 0) continue;
|
||||
emitBare(`\n── predictConfig: ${item.model} ──`);
|
||||
printPredictConfigTable(item.predictConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDetailJson(detail: ModelGroup, includePredictConfig: boolean) {
|
||||
const items = detail.items ?? [];
|
||||
return {
|
||||
model: detail.model,
|
||||
name: detail.name,
|
||||
description: detail.description,
|
||||
updateAt: detail.updateAt,
|
||||
items: items.map((item) => {
|
||||
const entry: Record<string, unknown> = {
|
||||
model: item.model,
|
||||
name: item.name,
|
||||
provider: item.provider,
|
||||
capabilities: item.capabilities,
|
||||
features: item.features,
|
||||
contextWindow: item.contextWindow,
|
||||
maxOutputTokens: item.maxOutputTokens,
|
||||
maxInputTokens: item.maxInputTokens,
|
||||
category: item.category,
|
||||
openSource: item.openSource,
|
||||
docUrl: item.docUrl,
|
||||
versionTag: item.versionTag,
|
||||
};
|
||||
if (item.prices) entry.prices = item.prices;
|
||||
if (item.qpmInfo) entry.qpmInfo = item.qpmInfo;
|
||||
if (includePredictConfig && item.predictConfig) {
|
||||
entry.predictConfig = item.predictConfig;
|
||||
}
|
||||
return entry;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function printPredictConfigTable(entries: PredictConfigEntry[]): void {
|
||||
const headers = ["PARAM", "KEY", "DEFAULT", "RANGE"];
|
||||
const rows = entries.map((entry) => [
|
||||
entry.name ?? "-",
|
||||
entry.key ?? "-",
|
||||
entry.default !== undefined ? JSON.stringify(entry.default) : "-",
|
||||
entry.range !== undefined ? JSON.stringify(entry.range) : "-",
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default defineCommand({
|
||||
description: "Browse model families or show detailed model info in the Bailian model marketplace",
|
||||
auth: "console",
|
||||
usageArgs:
|
||||
"[--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]",
|
||||
flags: LIST_FLAGS,
|
||||
exampleArgs: [
|
||||
"",
|
||||
"--provider alibaba",
|
||||
"--capability TG --capability Reasoning",
|
||||
"--model qwen-max",
|
||||
"--model qwen-max --enrich --output json",
|
||||
"--feature function-calling --output json",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
|
||||
const modelKey = flags.model;
|
||||
|
||||
// ── Detail mode ──
|
||||
if (modelKey) {
|
||||
const shouldEnrich = Boolean(flags.enrich);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "model.detail", model: modelKey, enrich: shouldEnrich }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = await fetchModelDetail(ctx.client.console.bind(ctx.client), modelKey);
|
||||
|
||||
if (!detail) {
|
||||
emitBare(`Model "${modelKey}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldEnrich) {
|
||||
const trunkItems = pickTrunkItems(detail.items ?? []);
|
||||
await Promise.all(
|
||||
trunkItems.map(async (item) => {
|
||||
if (!item.model) return;
|
||||
const config = await fetchPredictConfig(
|
||||
ctx.client.console.bind(ctx.client),
|
||||
item.model,
|
||||
);
|
||||
if (config) item.predictConfig = config;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(formatDetailJson(detail, shouldEnrich), format);
|
||||
return;
|
||||
}
|
||||
|
||||
printDetailText(detail, shouldEnrich);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Browse mode ──
|
||||
const params = {
|
||||
pageNo: flags.page,
|
||||
pageSize: flags.pageSize ?? 10,
|
||||
providers: flags.provider?.length ? flags.provider : undefined,
|
||||
capabilities: flags.capability?.length ? flags.capability : undefined,
|
||||
features: flags.feature?.length ? flags.feature : undefined,
|
||||
contextWindows: flags.contextWindow?.length ? flags.contextWindow : undefined,
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "model.list", ...params }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const { total, groups } = await fetchModelGroups(ctx.client.console.bind(ctx.client), params);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(formatBrowseJson(groups, total), format);
|
||||
return;
|
||||
}
|
||||
|
||||
printBrowseText(groups, total);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Install or upgrade an allowlisted Command Pack",
|
||||
auth: "none",
|
||||
usageArgs: "--package <name[@version]>",
|
||||
flags: {
|
||||
package: {
|
||||
type: "string",
|
||||
valueHint: "<name[@version]>",
|
||||
description: "Allowlisted Command Pack package and optional version or tag",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: ["--package @ali/bailian-plugin-agent", "--package @ali/bailian-plugin-agent@beta"],
|
||||
async run(ctx) {
|
||||
const installed = await ctx.commandPacks.install(ctx.flags.package);
|
||||
emitResult({ installed }, detectOutputFormat(ctx.settings.output));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Link an allowlisted local Command Pack for development",
|
||||
auth: "none",
|
||||
usageArgs: "--path <directory>",
|
||||
flags: {
|
||||
path: {
|
||||
type: "string",
|
||||
valueHint: "<directory>",
|
||||
description: "Local Command Pack package directory",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: ["--path ../bailian-plugin-agent"],
|
||||
async run(ctx) {
|
||||
const linked = await ctx.commandPacks.link(ctx.flags.path);
|
||||
emitResult({ linked }, detectOutputFormat(ctx.settings.output));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "List installed Command Packs and their load status",
|
||||
auth: "none",
|
||||
exampleArgs: ["", "--output json"],
|
||||
async run(ctx) {
|
||||
const reports = await ctx.commandPacks.list();
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
if (format === "json") {
|
||||
emitResult({ command_packs: reports }, format);
|
||||
return;
|
||||
}
|
||||
if (reports.length === 0) {
|
||||
emitBare("No Command Packs installed.");
|
||||
return;
|
||||
}
|
||||
const rows = reports.map((report) => [
|
||||
report.name,
|
||||
report.version ?? "-",
|
||||
report.source,
|
||||
report.status,
|
||||
report.commands.join(", ") || report.error || "-",
|
||||
]);
|
||||
for (const line of formatTable(
|
||||
["NAME", "VERSION", "SOURCE", "STATUS", "COMMANDS / ERROR"],
|
||||
rows,
|
||||
)) {
|
||||
emitBare(line);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Remove an installed Command Pack",
|
||||
auth: "none",
|
||||
usageArgs: "--name <package>",
|
||||
flags: {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<package>",
|
||||
description: "Allowlisted Command Pack package name",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: ["--name @ali/bailian-plugin-agent"],
|
||||
async run(ctx) {
|
||||
await ctx.commandPacks.remove(ctx.flags.name);
|
||||
emitResult({ removed: ctx.flags.name }, detectOutputFormat(ctx.settings.output));
|
||||
},
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import {
|
||||
defineCommand,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
effectiveConsoleGatewayConfig,
|
||||
detectOutputFormat,
|
||||
unwrapResponse,
|
||||
MODEL_LIST_API,
|
||||
type Client,
|
||||
} from "bailian-cli-core";
|
||||
import { ansi, emitResult } from "bailian-cli-runtime";
|
||||
import { displayWidth, padEnd } from "bailian-cli-runtime";
|
||||
import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime";
|
||||
|
||||
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
|
||||
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
|
||||
|
||||
interface QpmInfoItem {
|
||||
@@ -49,46 +51,6 @@ function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): n
|
||||
return Math.floor((item.usage_limit * 60) / period);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function formatRatio(usage: number, limit: number): string {
|
||||
if (limit <= 0) return "-";
|
||||
const pct = Math.round((usage / limit) * 100);
|
||||
return `${formatNumber(usage)}/${formatNumber(limit)} (${pct}%)`;
|
||||
}
|
||||
|
||||
function getStatus(usage: number, limit: number): string {
|
||||
if (limit <= 0) return "-";
|
||||
const pct = (usage / limit) * 100;
|
||||
if (pct >= 100) return "Rate Limited";
|
||||
if (pct >= 80) return "Near limit";
|
||||
return "Normal";
|
||||
}
|
||||
|
||||
function getNestedRecord(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
|
||||
const data = getNestedRecord(result, "data");
|
||||
if (!data) return result;
|
||||
const dataV2 = getNestedRecord(data, "DataV2");
|
||||
if (dataV2) {
|
||||
const inner = getNestedRecord(dataV2, "data");
|
||||
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
|
||||
return innerData ?? inner ?? dataV2;
|
||||
}
|
||||
const direct = getNestedRecord(data, "data");
|
||||
return direct ?? data;
|
||||
}
|
||||
|
||||
async function fetchAllModelsWithQpm(client: Client): Promise<ModelWithQpm[]> {
|
||||
const allModels: ModelWithQpm[] = [];
|
||||
let pageNo = 1;
|
||||
@@ -105,7 +67,7 @@ async function fetchAllModelsWithQpm(client: Client): Promise<ModelWithQpm[]> {
|
||||
},
|
||||
});
|
||||
|
||||
const resp = extractResponseData(raw as Record<string, unknown>);
|
||||
const resp = unwrapResponse(raw as Record<string, unknown>);
|
||||
const list = (resp.list as ModelWithQpm[]) ?? [];
|
||||
const total = (resp.total as number) ?? 0;
|
||||
|
||||
@@ -142,7 +104,7 @@ async function fetchMonitorData(
|
||||
},
|
||||
});
|
||||
|
||||
const resp = extractResponseData(raw as Record<string, unknown>);
|
||||
const resp = unwrapResponse(raw as Record<string, unknown>);
|
||||
const metrics = (resp.data ?? resp) as MonitorMetric[] | Record<string, unknown>;
|
||||
if (!Array.isArray(metrics)) return { rpm: 0, tpm: 0 };
|
||||
|
||||
@@ -157,7 +119,12 @@ async function fetchMonitorData(
|
||||
}
|
||||
|
||||
return { rpm, tpm };
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Re-throw authentication errors (BailianError with ExitCode.AUTH);
|
||||
// other errors are treated as "no data" and show "-" in the table.
|
||||
if (error instanceof BailianError && error.exitCode === ExitCode.AUTH) {
|
||||
throw error;
|
||||
}
|
||||
return { rpm: -1, tpm: -1 };
|
||||
}
|
||||
}
|
||||
@@ -168,16 +135,24 @@ interface CheckRow {
|
||||
rpmLimit: number;
|
||||
tpmUsage: number;
|
||||
tpmLimit: number;
|
||||
rpmQuotaLeft: number | null;
|
||||
tpmQuotaLeft: number | null;
|
||||
rpmQuotaLabel: string | null;
|
||||
tpmQuotaLabel: string | null;
|
||||
}
|
||||
|
||||
function printTable(rows: CheckRow[]): void {
|
||||
const color = ansi(process.stdout);
|
||||
const headers = ["Model", "RPM Used/Limit", "TPM Used/Limit", "Status", "RPM Left", "TPM Left"];
|
||||
|
||||
const headers = ["Model", "RPM Usage/Limit", "TPM Usage/Limit", "Status"];
|
||||
const rpmPercents = rows.map((r) => r.rpmQuotaLeft);
|
||||
const rpmLabels = rows.map((r) => r.rpmQuotaLabel);
|
||||
const tpmPercents = rows.map((r) => r.tpmQuotaLeft);
|
||||
const tpmLabels = rows.map((r) => r.tpmQuotaLabel);
|
||||
|
||||
const tableRows = rows.map((r) => {
|
||||
const rpmStr = r.rpmUsage < 0 ? "-" : formatRatio(r.rpmUsage, r.rpmLimit);
|
||||
const tpmStr = r.tpmUsage < 0 ? "-" : formatRatio(r.tpmUsage, r.tpmLimit);
|
||||
const rpmStr = r.rpmUsage < 0 ? "-" : `${r.rpmUsage}/${r.rpmLimit}`;
|
||||
const tpmStr = r.tpmUsage < 0 ? "-" : `${r.tpmUsage}/${r.tpmLimit}`;
|
||||
const maxPct = Math.max(
|
||||
r.rpmLimit > 0 ? (r.rpmUsage / r.rpmLimit) * 100 : 0,
|
||||
r.tpmLimit > 0 ? (r.tpmUsage / r.tpmLimit) * 100 : 0,
|
||||
@@ -185,39 +160,34 @@ function printTable(rows: CheckRow[]): void {
|
||||
const status =
|
||||
r.rpmUsage < 0
|
||||
? "-"
|
||||
: getStatus(Math.max(r.rpmUsage, r.tpmUsage), Math.max(r.rpmLimit, r.tpmLimit));
|
||||
return { cells: [r.model, rpmStr, tpmStr, status], maxPct };
|
||||
: maxPct >= 100
|
||||
? "Rate Limited"
|
||||
: maxPct >= 80
|
||||
? "Near limit"
|
||||
: "Normal";
|
||||
return [r.model, rpmStr, tpmStr, status, "", ""];
|
||||
});
|
||||
|
||||
if (tableRows.length === 0) {
|
||||
process.stdout.write("No models found.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const widths = headers.map((label, col) =>
|
||||
Math.max(displayWidth(label), ...tableRows.map((r) => displayWidth(r.cells[col]))),
|
||||
);
|
||||
|
||||
const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" ");
|
||||
const separator = widths.map((w) => color.dim("─".repeat(w))).join("──");
|
||||
|
||||
process.stdout.write(headerLine + "\n");
|
||||
process.stdout.write(separator + "\n");
|
||||
|
||||
const statusCol = 3;
|
||||
for (const r of tableRows) {
|
||||
const cells = r.cells.map((cell, col) => {
|
||||
if (col === statusCol) {
|
||||
if (cell === "Rate Limited") return color.red(padEnd(cell, widths[col]));
|
||||
if (cell === "Near limit") return color.yellow(padEnd(cell, widths[col]));
|
||||
if (cell === "Normal") return color.green(padEnd(cell, widths[col]));
|
||||
const lines = renderBoxTable({
|
||||
headers,
|
||||
rows: tableRows,
|
||||
align: ["left", "right", "right", "left", "left", "left"],
|
||||
barColumns: [
|
||||
{ index: 4, percents: rpmPercents, labels: rpmLabels, width: 15 },
|
||||
{ index: 5, percents: tpmPercents, labels: tpmLabels, width: 15 },
|
||||
],
|
||||
cellColor: (rowIndex, colIndex, value) => {
|
||||
if (colIndex === 3) {
|
||||
// Status 列着色
|
||||
if (value === "Rate Limited") return color.red(value);
|
||||
if (value === "Near limit") return color.yellow(value);
|
||||
if (value === "Normal") return color.green(value);
|
||||
}
|
||||
return padEnd(cell, widths[col]);
|
||||
});
|
||||
process.stdout.write(cells.join(" ") + "\n");
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
process.stdout.write(color.dim(`\nTotal: ${rows.length} models`) + "\n");
|
||||
for (const line of lines) process.stdout.write(line + "\n");
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
@@ -295,12 +265,35 @@ export default defineCommand({
|
||||
const tpmLimit =
|
||||
calculateTPM(userSpec, modelDefault?.usage_limit_period) || calculateTPM(modelDefault);
|
||||
|
||||
const rpmUsage = monitorResults[idx].rpm;
|
||||
const tpmUsage = monitorResults[idx].tpm;
|
||||
|
||||
// RPM Quota Left = 1 - (rpmUsage / rpmLimit) in percentage
|
||||
let rpmQuotaPercent: number | null = null;
|
||||
let rpmQuotaLabel: string | null = null;
|
||||
if (rpmUsage >= 0 && rpmLimit > 0) {
|
||||
rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / rpmLimit) * 100);
|
||||
rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%";
|
||||
}
|
||||
|
||||
// TPM Quota Left = 1 - (tpmUsage / tpmLimit) in percentage
|
||||
let tpmQuotaPercent: number | null = null;
|
||||
let tpmQuotaLabel: string | null = null;
|
||||
if (tpmUsage >= 0 && tpmLimit > 0) {
|
||||
tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / tpmLimit) * 100);
|
||||
tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%";
|
||||
}
|
||||
|
||||
return {
|
||||
model: m.model,
|
||||
rpmUsage: monitorResults[idx].rpm,
|
||||
rpmUsage,
|
||||
rpmLimit,
|
||||
tpmUsage: monitorResults[idx].tpm,
|
||||
tpmUsage,
|
||||
tpmLimit,
|
||||
rpmQuotaLeft: rpmQuotaPercent,
|
||||
tpmQuotaLeft: tpmQuotaPercent,
|
||||
rpmQuotaLabel,
|
||||
tpmQuotaLabel,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { defineCommand, BailianError, detectOutputFormat, type Client } from "bailian-cli-core";
|
||||
import { ansi, emitResult } from "bailian-cli-runtime";
|
||||
import { displayWidth, padEnd } from "bailian-cli-runtime";
|
||||
import {
|
||||
defineCommand,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
detectOutputFormat,
|
||||
unwrapResponse,
|
||||
MODEL_LIST_API,
|
||||
type Client,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, renderBoxTable } from "bailian-cli-runtime";
|
||||
|
||||
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
|
||||
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
|
||||
|
||||
interface QpmInfoItem {
|
||||
count_limit: number;
|
||||
@@ -18,6 +25,17 @@ interface ModelWithQpm {
|
||||
qpmInfo?: Record<string, QpmInfoItem>;
|
||||
}
|
||||
|
||||
interface MonitorPoint {
|
||||
value: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface MonitorMetric {
|
||||
aggMethod: string;
|
||||
metricName: string;
|
||||
points: MonitorPoint[];
|
||||
}
|
||||
|
||||
function calculateRPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
|
||||
if (!item) return 0;
|
||||
const period = item.count_limit_period || fallbackPeriod;
|
||||
@@ -36,32 +54,59 @@ function formatNumber(num: number): string {
|
||||
return num.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function getNestedRecord(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
|
||||
const data = getNestedRecord(result, "data");
|
||||
if (!data) return result;
|
||||
const dataV2 = getNestedRecord(data, "DataV2");
|
||||
if (dataV2) {
|
||||
const inner = getNestedRecord(dataV2, "data");
|
||||
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
|
||||
return innerData ?? inner ?? dataV2;
|
||||
}
|
||||
const direct = getNestedRecord(data, "data");
|
||||
return direct ?? data;
|
||||
}
|
||||
|
||||
async function fetchAllModelsWithQpm(
|
||||
async function fetchMonitorData(
|
||||
client: Client,
|
||||
onlySelfService: boolean,
|
||||
): Promise<ModelWithQpm[]> {
|
||||
modelName: string,
|
||||
windowMinutes: number,
|
||||
): Promise<{ rpm: number; tpm: number }> {
|
||||
const now = Date.now();
|
||||
const startTime = now - windowMinutes * 60 * 1000;
|
||||
|
||||
try {
|
||||
const raw = await client.console(MONITOR_API, {
|
||||
reqDTO: {
|
||||
monitorType: "Advanced",
|
||||
metricFilters: [
|
||||
{ aggMethod: "sum_pm", metricName: "model_total_amount" },
|
||||
{ aggMethod: "sum_pm", metricName: "model_call_count" },
|
||||
],
|
||||
labelFilters: {
|
||||
resourceId: modelName,
|
||||
resourceType: "model",
|
||||
},
|
||||
startTime,
|
||||
endTime: now,
|
||||
},
|
||||
});
|
||||
|
||||
const resp = unwrapResponse(raw as Record<string, unknown>);
|
||||
const metrics = (resp.data ?? resp) as MonitorMetric[] | Record<string, unknown>;
|
||||
if (!Array.isArray(metrics)) {
|
||||
return { rpm: 0, tpm: 0 };
|
||||
}
|
||||
|
||||
let rpm = 0;
|
||||
let tpm = 0;
|
||||
|
||||
for (const metric of metrics) {
|
||||
if (metric.aggMethod !== "sum_pm" || !metric.points?.length) continue;
|
||||
const lastValue = metric.points[metric.points.length - 1].value ?? 0;
|
||||
if (metric.metricName === "model_call_count") rpm = Math.round(lastValue);
|
||||
if (metric.metricName === "model_total_amount") tpm = Math.round(lastValue);
|
||||
}
|
||||
|
||||
return { rpm, tpm };
|
||||
} catch (error) {
|
||||
// Re-throw authentication errors (BailianError with ExitCode.AUTH);
|
||||
// other errors are treated as "no data" and show "-" in the table.
|
||||
if (error instanceof BailianError && error.exitCode === ExitCode.AUTH) {
|
||||
throw error;
|
||||
}
|
||||
return { rpm: -1, tpm: -1 };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllModelsWithQpm(client: Client): Promise<ModelWithQpm[]> {
|
||||
const allModels: ModelWithQpm[] = [];
|
||||
let pageNo = 1;
|
||||
|
||||
@@ -72,14 +117,12 @@ async function fetchAllModelsWithQpm(
|
||||
group: false,
|
||||
queryQpmInfo: true,
|
||||
ignoreWorkspaceServiceSite: true,
|
||||
supports: { selfServiceLimitIncrease: true },
|
||||
};
|
||||
if (onlySelfService) {
|
||||
input.supports = { selfServiceLimitIncrease: true };
|
||||
}
|
||||
|
||||
const raw = await client.console(MODEL_LIST_API, { input });
|
||||
|
||||
const resp = extractResponseData(raw as Record<string, unknown>);
|
||||
const resp = unwrapResponse(raw as Record<string, unknown>);
|
||||
const list = (resp.list as ModelWithQpm[]) ?? [];
|
||||
const total = (resp.total as number) ?? 0;
|
||||
|
||||
@@ -91,50 +134,37 @@ async function fetchAllModelsWithQpm(
|
||||
return allModels;
|
||||
}
|
||||
|
||||
function printTable(models: ModelWithQpm[]): void {
|
||||
const color = ansi(process.stdout);
|
||||
interface ListRow {
|
||||
model: string;
|
||||
rpm: string;
|
||||
tpm: string;
|
||||
rpmQuotaLeft: number | null;
|
||||
tpmQuotaLeft: number | null;
|
||||
rpmQuotaLabel: string | null;
|
||||
tpmQuotaLabel: string | null;
|
||||
}
|
||||
|
||||
const headers = ["Model", "Req/min", "Token/min", "Max TPM"];
|
||||
function printTable(rows: ListRow[]): void {
|
||||
const headers = ["Model", "Req/min", "Token/min", "RPM Left", "TPM Left"];
|
||||
|
||||
const rows = models.map((m) => {
|
||||
const qpm = m.qpmInfo;
|
||||
const modelDefault = qpm?.["model-default"];
|
||||
const userSpec = qpm?.["user-spec"];
|
||||
const rpmPercents = rows.map((r) => r.rpmQuotaLeft);
|
||||
const rpmLabels = rows.map((r) => r.rpmQuotaLabel);
|
||||
const tpmPercents = rows.map((r) => r.tpmQuotaLeft);
|
||||
const tpmLabels = rows.map((r) => r.tpmQuotaLabel);
|
||||
|
||||
const defaultRPM = calculateRPM(modelDefault);
|
||||
const defaultTPM = calculateTPM(modelDefault);
|
||||
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
|
||||
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
|
||||
const maxTPM = defaultTPM * 2;
|
||||
const tableRows = rows.map((r) => [r.model, r.rpm, r.tpm, "", ""]);
|
||||
|
||||
return [
|
||||
m.model,
|
||||
currentRPM > 0 ? formatNumber(currentRPM) : "-",
|
||||
currentTPM > 0 ? formatNumber(currentTPM) : "-",
|
||||
maxTPM > 0 ? formatNumber(maxTPM) : "-",
|
||||
];
|
||||
const lines = renderBoxTable({
|
||||
headers,
|
||||
rows: tableRows,
|
||||
align: ["left", "right", "right", "left", "left"],
|
||||
barColumns: [
|
||||
{ index: 3, percents: rpmPercents, labels: rpmLabels, width: 15 },
|
||||
{ index: 4, percents: tpmPercents, labels: tpmLabels, width: 15 },
|
||||
],
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("No models found.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const widths = headers.map((label, col) =>
|
||||
Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))),
|
||||
);
|
||||
|
||||
const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" ");
|
||||
const separator = widths.map((w) => color.dim("─".repeat(w))).join("──");
|
||||
|
||||
process.stdout.write(headerLine + "\n");
|
||||
process.stdout.write(separator + "\n");
|
||||
|
||||
for (const row of rows) {
|
||||
process.stdout.write(row.map((cell, col) => padEnd(cell, widths[col])).join(" ") + "\n");
|
||||
}
|
||||
|
||||
process.stdout.write(color.dim(`\nTotal: ${models.length} models`) + "\n");
|
||||
for (const line of lines) process.stdout.write(line + "\n");
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
@@ -147,22 +177,11 @@ export default defineCommand({
|
||||
valueHint: "<model>",
|
||||
description: "Model name(s), comma-separated",
|
||||
},
|
||||
all: {
|
||||
type: "switch",
|
||||
description: "Show all models, not just self-service ones",
|
||||
},
|
||||
},
|
||||
exampleArgs: [
|
||||
"",
|
||||
"--model qwen3.6-plus",
|
||||
"--model qwen3.6-plus,qwen-turbo",
|
||||
"--all",
|
||||
"--output json",
|
||||
],
|
||||
exampleArgs: ["", "--model qwen3.6-plus", "--model qwen3.6-plus,qwen-turbo", "--output json"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const modelFlag = flags.model || undefined;
|
||||
const showAll = Boolean(flags.all);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
@@ -172,13 +191,22 @@ export default defineCommand({
|
||||
group: false,
|
||||
queryQpmInfo: true,
|
||||
ignoreWorkspaceServiceSite: true,
|
||||
supports: { selfServiceLimitIncrease: true },
|
||||
};
|
||||
if (!showAll) input.supports = { selfServiceLimitIncrease: true };
|
||||
emitResult({ api: MODEL_LIST_API, data: { input } }, format);
|
||||
emitResult(
|
||||
{
|
||||
apis: [
|
||||
MODEL_LIST_API,
|
||||
{ api: MONITOR_API, note: "called per-model for text output with gauges" },
|
||||
],
|
||||
modelListInput: { input },
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let models = await fetchAllModelsWithQpm(ctx.client, !showAll);
|
||||
let models = await fetchAllModelsWithQpm(ctx.client);
|
||||
|
||||
if (modelFlag) {
|
||||
const names = new Set(
|
||||
@@ -203,19 +231,67 @@ export default defineCommand({
|
||||
const defaultTPM = calculateTPM(modelDefault);
|
||||
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
|
||||
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
|
||||
const maxTPM = defaultTPM * 2;
|
||||
|
||||
return {
|
||||
model: m.model,
|
||||
rpm: currentRPM > 0 ? currentRPM : null,
|
||||
tpm: currentTPM > 0 ? currentTPM : null,
|
||||
maxTPM: maxTPM > 0 ? maxTPM : null,
|
||||
};
|
||||
});
|
||||
emitResult(items, format);
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(models);
|
||||
// For text output with gauges, we need monitor data
|
||||
const monitorResults = await Promise.all(
|
||||
models.map((m) => fetchMonitorData(ctx.client, m.model, 2)),
|
||||
);
|
||||
|
||||
const rows: ListRow[] = models.map((m, idx) => {
|
||||
const qpm = m.qpmInfo;
|
||||
const modelDefault = qpm?.["model-default"];
|
||||
const userSpec = qpm?.["user-spec"];
|
||||
|
||||
const defaultRPM = calculateRPM(modelDefault);
|
||||
const defaultTPM = calculateTPM(modelDefault);
|
||||
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
|
||||
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
|
||||
|
||||
const rpmUsage = monitorResults[idx].rpm;
|
||||
const tpmUsage = monitorResults[idx].tpm;
|
||||
|
||||
// RPM Quota Left = 1 - (rpmUsage / currentRPM) in percentage
|
||||
let rpmQuotaPercent: number | null = null;
|
||||
let rpmQuotaLabel: string | null = null;
|
||||
if (rpmUsage >= 0 && currentRPM > 0) {
|
||||
rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / currentRPM) * 100);
|
||||
rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%";
|
||||
}
|
||||
|
||||
// TPM Quota Left = 1 - (tpmUsage / currentTPM) in percentage
|
||||
let tpmQuotaPercent: number | null = null;
|
||||
let tpmQuotaLabel: string | null = null;
|
||||
if (tpmUsage >= 0 && currentTPM > 0) {
|
||||
tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / currentTPM) * 100);
|
||||
tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%";
|
||||
}
|
||||
|
||||
return {
|
||||
model: m.model,
|
||||
rpm: currentRPM > 0 ? formatNumber(currentRPM) : "-",
|
||||
tpm: currentTPM > 0 ? formatNumber(currentTPM) : "-",
|
||||
rpmQuotaLeft: rpmQuotaPercent,
|
||||
tpmQuotaLeft: tpmQuotaPercent,
|
||||
rpmQuotaLabel,
|
||||
tpmQuotaLabel,
|
||||
};
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("No models found.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(rows);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import http from "node:http";
|
||||
|
||||
/**
|
||||
* Bind an http server to a loopback-only TCP port and resolve the chosen port.
|
||||
* `port = 0` (default) lets the OS pick a free port. Always binds 127.0.0.1 so
|
||||
* the server is never reachable off the local machine.
|
||||
*/
|
||||
export function listenLocalServer(server: http.Server, port = 0): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onErr = (e: Error) => reject(e);
|
||||
server.once("error", onErr);
|
||||
server.listen({ port, host: "127.0.0.1", exclusive: true }, () => {
|
||||
server.off("error", onErr);
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
reject(new Error("Expected TCP socket address"));
|
||||
return;
|
||||
}
|
||||
resolve(addr.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
||||
export function openInBrowser(url: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { windowsHide: true }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
chatPath,
|
||||
parseSSE,
|
||||
detectOutputFormat,
|
||||
readTextFromPathOrStdin,
|
||||
type ChatMessage,
|
||||
type ChatRequest,
|
||||
type ChatResponse,
|
||||
@@ -69,9 +70,7 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
|
||||
}
|
||||
|
||||
if (flags.messagesFile) {
|
||||
const filePath = flags.messagesFile;
|
||||
const raw =
|
||||
filePath === "-" ? readFileSync("/dev/stdin", "utf-8") : readFileSync(filePath, "utf-8");
|
||||
const raw = readTextFromPathOrStdin(flags.messagesFile);
|
||||
const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>;
|
||||
for (const m of parsed) {
|
||||
if (m.role === "system") {
|
||||
|
||||
@@ -1,177 +1,20 @@
|
||||
import { defineCommand, detectOutputFormat, fetchModelList, type Client } from "bailian-cli-core";
|
||||
import { ansi, emitResult } from "bailian-cli-runtime";
|
||||
import { displayWidth, padEnd } from "bailian-cli-runtime";
|
||||
import { defineCommand, detectOutputFormat, fetchModelList } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import {
|
||||
FREE_TIER_API,
|
||||
FREE_TIER_ONLY_STATUS_API,
|
||||
extractQuotas,
|
||||
extractFreeTierOnlyStatuses,
|
||||
fetchAllModels,
|
||||
resolveModelType,
|
||||
formatDate,
|
||||
printFreeTierTable,
|
||||
quotaRemainingPercent,
|
||||
type FreeTierQuota,
|
||||
} from "./shared.ts";
|
||||
|
||||
const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
|
||||
const FREE_TIER_ONLY_STATUS_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
|
||||
|
||||
interface FreeTierQuota {
|
||||
model: string;
|
||||
quotaInitTotal: number;
|
||||
quotaTotal: number;
|
||||
quotaValidityPeriod: number;
|
||||
quotaStatus: string;
|
||||
}
|
||||
|
||||
interface FreeTierOnlyStatus {
|
||||
model: string;
|
||||
freeTierOnly: boolean;
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatUsage(quota: FreeTierQuota): string {
|
||||
if (!quota.quotaInitTotal) return "-";
|
||||
const used = quota.quotaInitTotal - quota.quotaTotal;
|
||||
const percent = (used / quota.quotaInitTotal) * 100;
|
||||
return `${percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const CAPABILITY_TO_TYPE: Record<string, string> = {
|
||||
Reasoning: "Text",
|
||||
TG: "Text",
|
||||
VU: "Text",
|
||||
IG: "Vision",
|
||||
VG: "Vision",
|
||||
"Realtime-Omni": "Multimodal",
|
||||
"Multimodal-Omni": "Multimodal",
|
||||
ASR: "Audio",
|
||||
TTS: "Audio",
|
||||
"Voice-Replication": "Audio",
|
||||
"Realtime-Text-to-Speech": "Audio",
|
||||
"Realtime-Voice-Replication": "Audio",
|
||||
"Realtime-ASR": "Audio",
|
||||
"Realtime-Audio-Translate": "Audio",
|
||||
ME: "Embedding",
|
||||
TR: "Embedding",
|
||||
};
|
||||
|
||||
function resolveModelType(capabilities: string[]): string {
|
||||
for (const cap of capabilities) {
|
||||
const type = CAPABILITY_TO_TYPE[cap];
|
||||
if (type) return type;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function printTable(
|
||||
quotas: FreeTierQuota[],
|
||||
stopMap: Map<string, boolean>,
|
||||
typeMap: Map<string, string>,
|
||||
): void {
|
||||
const color = ansi(process.stdout);
|
||||
const headers = ["Model", "Type", "Remaining/Total", "Usage", "Expires", "Auto-Stop"];
|
||||
|
||||
const rows = quotas.map((quota) => {
|
||||
const hasQuota = quota.quotaInitTotal != null && quota.quotaTotal != null;
|
||||
const remaining = hasQuota ? formatNumber(quota.quotaTotal) : "-";
|
||||
const total = hasQuota ? formatNumber(quota.quotaInitTotal) : "-";
|
||||
const stopStatus = stopMap.get(quota.model);
|
||||
return [
|
||||
quota.model,
|
||||
typeMap.get(quota.model) || "-",
|
||||
hasQuota ? `${remaining} / ${total}` : "-",
|
||||
formatUsage(quota),
|
||||
quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : "-",
|
||||
quota.quotaStatus === "UNKNOWN"
|
||||
? "Unsupported"
|
||||
: stopStatus === true
|
||||
? "ON"
|
||||
: stopStatus === false
|
||||
? "OFF"
|
||||
: "-",
|
||||
];
|
||||
});
|
||||
|
||||
const widths = headers.map((label, col) =>
|
||||
Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))),
|
||||
);
|
||||
|
||||
const autoStopCol = headers.length - 1;
|
||||
const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" ");
|
||||
const separator = widths.map((width) => color.dim("─".repeat(width))).join("──");
|
||||
|
||||
process.stdout.write(headerLine + "\n");
|
||||
process.stdout.write(separator + "\n");
|
||||
|
||||
for (const row of rows) {
|
||||
const cells = row.map((cell, col) => {
|
||||
if (col === autoStopCol) {
|
||||
if (cell === "ON") return color.green(padEnd(cell, widths[col]));
|
||||
if (cell === "OFF") return color.yellow(padEnd(cell, widths[col]));
|
||||
}
|
||||
return padEnd(cell, widths[col]);
|
||||
});
|
||||
process.stdout.write(cells.join(" ") + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
function extractQuotas(result: unknown): FreeTierQuota[] {
|
||||
const root = result as Record<string, unknown>;
|
||||
const data = root.data as Record<string, unknown> | undefined;
|
||||
if (!data) return [];
|
||||
|
||||
const dataV2 = data.DataV2 as Record<string, unknown> | undefined;
|
||||
if (dataV2) {
|
||||
const inner = dataV2.data as Record<string, unknown> | undefined;
|
||||
const innerData = inner?.data as Record<string, unknown> | undefined;
|
||||
return (innerData?.freeTierQuotas as FreeTierQuota[]) || [];
|
||||
}
|
||||
|
||||
const direct = data.data as Record<string, unknown> | undefined;
|
||||
return (direct?.freeTierQuotas as FreeTierQuota[]) || [];
|
||||
}
|
||||
|
||||
function extractFreeTierOnlyStatuses(result: unknown): FreeTierOnlyStatus[] {
|
||||
const root = result as Record<string, unknown>;
|
||||
const data = root.data as Record<string, unknown> | undefined;
|
||||
if (!data) return [];
|
||||
|
||||
const dataV2 = data.DataV2 as Record<string, unknown> | undefined;
|
||||
if (dataV2) {
|
||||
const inner = dataV2.data as Record<string, unknown> | undefined;
|
||||
const innerData = inner?.data as Record<string, unknown> | undefined;
|
||||
return (innerData?.freeTierOnlyStatuses as FreeTierOnlyStatus[]) || [];
|
||||
}
|
||||
|
||||
const direct = data.data as Record<string, unknown> | undefined;
|
||||
return (direct?.freeTierOnlyStatuses as FreeTierOnlyStatus[]) || [];
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
|
||||
const allModels: Record<string, unknown>[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const result = await fetchModelList((api, data) => client.console(api, data), {
|
||||
pageNo: page,
|
||||
pageSize: 50,
|
||||
});
|
||||
allModels.push(...result.models);
|
||||
if (allModels.length >= result.total) break;
|
||||
page++;
|
||||
}
|
||||
return allModels
|
||||
.filter((item) => typeof item.model === "string" && item.model)
|
||||
.map((item) => ({
|
||||
name: item.model as string,
|
||||
type: resolveModelType((item.capabilities as string[]) || []),
|
||||
}));
|
||||
}
|
||||
/** Rows shown by default when listing all models; the rest go behind a hint. */
|
||||
const TOP_N = 15;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Query free-tier quota for models (all models if --model is omitted)",
|
||||
@@ -194,6 +37,10 @@ export default defineCommand({
|
||||
description: "Sort by: remaining (ascending), expires (ascending)",
|
||||
choices: ["remaining", "expires"] as const,
|
||||
},
|
||||
all: {
|
||||
type: "switch",
|
||||
description: "Show all models instead of the top rows",
|
||||
},
|
||||
},
|
||||
exampleArgs: [
|
||||
"",
|
||||
@@ -201,14 +48,16 @@ export default defineCommand({
|
||||
"--model qwen3-max,qwen-turbo",
|
||||
"--expiring 30",
|
||||
"--sort remaining",
|
||||
"--all",
|
||||
"--model qwen-turbo --output json",
|
||||
"--model qwen3-max --console-region cn-beijing",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const { identity, settings, flags } = ctx;
|
||||
const modelFlag = flags.model || undefined;
|
||||
const expiringDays = Number(flags.expiring) || 0;
|
||||
const sortField = flags.sort || undefined;
|
||||
const showAll = Boolean(flags.all);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
let models: string[];
|
||||
@@ -232,13 +81,7 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
api: FREE_TIER_API,
|
||||
data: requestData,
|
||||
},
|
||||
format,
|
||||
);
|
||||
emitResult({ api: FREE_TIER_API, data: requestData }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,24 +120,30 @@ export default defineCommand({
|
||||
|
||||
if (expiringDays > 0) {
|
||||
const cutoff = Date.now() + expiringDays * 24 * 60 * 60 * 1000;
|
||||
quotas = quotas.filter((q) => q.quotaValidityPeriod > 0 && q.quotaValidityPeriod <= cutoff);
|
||||
quotas = quotas.filter(
|
||||
(quota) => quota.quotaValidityPeriod > 0 && quota.quotaValidityPeriod <= cutoff,
|
||||
);
|
||||
}
|
||||
|
||||
if (sortField === "remaining") {
|
||||
quotas.sort((a, b) => {
|
||||
const pctA = a.quotaInitTotal ? a.quotaTotal / a.quotaInitTotal : 0;
|
||||
const pctB = b.quotaInitTotal ? b.quotaTotal / b.quotaInitTotal : 0;
|
||||
return pctA - pctB;
|
||||
// Default to urgency ordering (least remaining first) when browsing all models.
|
||||
const effectiveSort = sortField ?? (modelFlag ? undefined : "remaining");
|
||||
if (effectiveSort === "remaining") {
|
||||
quotas.sort((left, right) => {
|
||||
const pctLeft = left.quotaInitTotal ? left.quotaTotal / left.quotaInitTotal : 0;
|
||||
const pctRight = right.quotaInitTotal ? right.quotaTotal / right.quotaInitTotal : 0;
|
||||
return pctLeft - pctRight;
|
||||
});
|
||||
} else if (sortField === "expires") {
|
||||
quotas.sort((a, b) => (a.quotaValidityPeriod ?? 0) - (b.quotaValidityPeriod ?? 0));
|
||||
} else if (effectiveSort === "expires") {
|
||||
quotas.sort(
|
||||
(left, right) => (left.quotaValidityPeriod ?? 0) - (right.quotaValidityPeriod ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
const stopStatuses = extractFreeTierOnlyStatuses(stopResult);
|
||||
const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
|
||||
|
||||
if (format === "json") {
|
||||
const items = quotas.map((quota) => {
|
||||
const items = quotas.map((quota: FreeTierQuota) => {
|
||||
const hasQuota = quota.quotaInitTotal != null && quota.quotaTotal != null;
|
||||
const used = hasQuota ? quota.quotaInitTotal - quota.quotaTotal : 0;
|
||||
const stopStatus = stopMap.get(quota.model);
|
||||
@@ -315,6 +164,7 @@ export default defineCommand({
|
||||
hasQuota && quota.quotaInitTotal > 0
|
||||
? Math.round((used / quota.quotaInitTotal) * 1000) / 10
|
||||
: null,
|
||||
remainingPercent: quotaRemainingPercent(quota),
|
||||
expires: quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : null,
|
||||
autoStop,
|
||||
};
|
||||
@@ -328,6 +178,20 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(quotas, stopMap, typeMap);
|
||||
const browseAll = !modelFlag && !showAll;
|
||||
const visible = browseAll ? quotas.slice(0, TOP_N) : quotas;
|
||||
const remaining = quotas.length - visible.length;
|
||||
|
||||
const titleSuffix = effectiveSort === "remaining" ? " · sorted by urgency" : "";
|
||||
printFreeTierTable({
|
||||
quotas: visible,
|
||||
stopMap,
|
||||
typeMap,
|
||||
title: `Free Tier Quota · ${quotas.length} models${titleSuffix}`,
|
||||
moreHint:
|
||||
remaining > 0
|
||||
? `+ ${remaining} more · ${identity.binName} usage free --all to browse all`
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import {
|
||||
fetchModelList,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
unwrapResponse,
|
||||
type Client,
|
||||
type Settings,
|
||||
} from "bailian-cli-core";
|
||||
import { ansi, renderBoxTable, displayWidth, padEnd } from "bailian-cli-runtime";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Common formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatNumber(num: number): string {
|
||||
return num.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
export function formatDate(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function requireWorkspaceId(settings: Settings, binName: string): string {
|
||||
if (settings.workspaceId) return settings.workspaceId;
|
||||
|
||||
throw new BailianError(
|
||||
`workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${binName} config set workspace_id <id>\`.`,
|
||||
ExitCode.GENERAL,
|
||||
`Run \`${binName} workspace list\` to view available workspaces.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `unwrapResponse` from bailian-cli-core instead.
|
||||
* Kept here only for internal backward-compat with callers that already import this.
|
||||
*/
|
||||
export function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
|
||||
return unwrapResponse(result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model catalog + capability → type mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CAPABILITY_TO_TYPE: Record<string, string> = {
|
||||
Reasoning: "Text",
|
||||
TG: "Text",
|
||||
VU: "Text",
|
||||
IG: "Vision",
|
||||
VG: "Vision",
|
||||
"Realtime-Omni": "Multimodal",
|
||||
"Multimodal-Omni": "Multimodal",
|
||||
ASR: "Audio",
|
||||
TTS: "Audio",
|
||||
"Voice-Replication": "Audio",
|
||||
"Realtime-Text-to-Speech": "Audio",
|
||||
"Realtime-Voice-Replication": "Audio",
|
||||
"Realtime-ASR": "Audio",
|
||||
"Realtime-Audio-Translate": "Audio",
|
||||
ME: "Embedding",
|
||||
TR: "Embedding",
|
||||
};
|
||||
|
||||
export function resolveModelType(capabilities: string[]): string {
|
||||
for (const capability of capabilities) {
|
||||
const type = CAPABILITY_TO_TYPE[capability];
|
||||
if (type) return type;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
|
||||
const allModels: Record<string, unknown>[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const result = await fetchModelList((api, data) => client.console(api, data), {
|
||||
pageNo: page,
|
||||
pageSize: 50,
|
||||
});
|
||||
allModels.push(...result.models);
|
||||
if (allModels.length >= result.total) break;
|
||||
page++;
|
||||
}
|
||||
return allModels
|
||||
.filter((item) => typeof item.model === "string" && item.model)
|
||||
.map((item) => ({
|
||||
name: item.model as string,
|
||||
type: resolveModelType((item.capabilities as string[]) || []),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Free-tier quota
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
|
||||
export const FREE_TIER_ONLY_STATUS_API =
|
||||
"zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
|
||||
|
||||
export interface FreeTierQuota {
|
||||
model: string;
|
||||
quotaInitTotal: number;
|
||||
quotaTotal: number;
|
||||
quotaValidityPeriod: number;
|
||||
quotaStatus: string;
|
||||
}
|
||||
|
||||
export interface FreeTierOnlyStatus {
|
||||
model: string;
|
||||
freeTierOnly: boolean;
|
||||
}
|
||||
|
||||
function extractFreeTierField<T>(result: unknown, field: string): T[] {
|
||||
const root = result as Record<string, unknown>;
|
||||
const data = root.data as Record<string, unknown> | undefined;
|
||||
if (!data) return [];
|
||||
|
||||
const dataV2 = data.DataV2 as Record<string, unknown> | undefined;
|
||||
if (dataV2) {
|
||||
const inner = dataV2.data as Record<string, unknown> | undefined;
|
||||
const innerData = inner?.data as Record<string, unknown> | undefined;
|
||||
return (innerData?.[field] as T[]) || [];
|
||||
}
|
||||
|
||||
const direct = data.data as Record<string, unknown> | undefined;
|
||||
return (direct?.[field] as T[]) || [];
|
||||
}
|
||||
|
||||
export function extractQuotas(result: unknown): FreeTierQuota[] {
|
||||
return extractFreeTierField<FreeTierQuota>(result, "freeTierQuotas");
|
||||
}
|
||||
|
||||
export function extractFreeTierOnlyStatuses(result: unknown): FreeTierOnlyStatus[] {
|
||||
return extractFreeTierField<FreeTierOnlyStatus>(result, "freeTierOnlyStatuses");
|
||||
}
|
||||
|
||||
/** Remaining percentage for a quota row, or null when it is unavailable/expired. */
|
||||
export function quotaRemainingPercent(quota: FreeTierQuota): number | null {
|
||||
if (isExpired(quota)) return null;
|
||||
if (quota.quotaInitTotal == null || quota.quotaInitTotal <= 0) return null;
|
||||
return Math.round((quota.quotaTotal / quota.quotaInitTotal) * 1000) / 10;
|
||||
}
|
||||
|
||||
function isExpired(quota: FreeTierQuota): boolean {
|
||||
return quota.quotaValidityPeriod > 0 && quota.quotaValidityPeriod < Date.now();
|
||||
}
|
||||
|
||||
export interface FreeTierTableOptions {
|
||||
quotas: FreeTierQuota[];
|
||||
stopMap: Map<string, boolean>;
|
||||
typeMap: Map<string, string>;
|
||||
/** Optional section title, e.g. "Free Tier Quota · 225 models". */
|
||||
title?: string;
|
||||
/** Optional dim hint printed below the table, e.g. "+ 215 more · bl usage free". */
|
||||
moreHint?: string;
|
||||
}
|
||||
|
||||
/** Print a free-tier quota table with a highlighted header and Quota Left gauge. */
|
||||
export function printFreeTierTable(options: FreeTierTableOptions): void {
|
||||
const { quotas, stopMap, typeMap } = options;
|
||||
const color = ansi(process.stdout);
|
||||
|
||||
if (options.title) {
|
||||
process.stdout.write(color.purple(options.title) + "\n");
|
||||
}
|
||||
|
||||
const headers = ["Model", "Type", "Remaining", "Total", "Quota Left", "Auto-Stop"];
|
||||
const percents: (number | null)[] = [];
|
||||
const barLabels: (string | null)[] = [];
|
||||
const autoStopCol = headers.length - 1;
|
||||
|
||||
const rows = quotas.map((quota) => {
|
||||
const hasQuota = quota.quotaInitTotal != null && quota.quotaTotal != null;
|
||||
const expired = isExpired(quota);
|
||||
const remaining = hasQuota
|
||||
? `${formatNumber(quota.quotaTotal)}${expired ? " (expired)" : ""}`
|
||||
: "-";
|
||||
const total = hasQuota ? formatNumber(quota.quotaInitTotal) : "-";
|
||||
|
||||
const percent = quotaRemainingPercent(quota);
|
||||
percents.push(percent);
|
||||
barLabels.push(expired ? "expired" : percent == null ? "-" : null);
|
||||
|
||||
const stopStatus = stopMap.get(quota.model);
|
||||
const autoStop =
|
||||
quota.quotaStatus === "UNKNOWN"
|
||||
? "Unsupported"
|
||||
: stopStatus === true
|
||||
? "ON"
|
||||
: stopStatus === false
|
||||
? "OFF"
|
||||
: "-";
|
||||
|
||||
return [quota.model, typeMap.get(quota.model) || "-", remaining, total, "", autoStop];
|
||||
});
|
||||
|
||||
const lines = renderBoxTable({
|
||||
headers,
|
||||
rows,
|
||||
align: ["left", "left", "right", "right", "left", "left"],
|
||||
barColumns: [{ index: 4, percents, labels: barLabels }],
|
||||
cellColor: (_rowIndex, colIndex, value) => {
|
||||
if (colIndex !== autoStopCol) return undefined;
|
||||
if (value === "ON") return color.green(value);
|
||||
if (value === "OFF") return color.yellow(value);
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
for (const line of lines) process.stdout.write(line + "\n");
|
||||
|
||||
if (options.moreHint) {
|
||||
process.stdout.write(color.dim(options.moreHint) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Usage statistics (telemetry)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OVERVIEW_API = "zeldaEasy.bailian-telemetry.model.getModelUsageStatistic";
|
||||
export const LIST_API = "zeldaEasy.bailian-telemetry.model.listModelUsageStatisticData";
|
||||
|
||||
export interface UsageItem {
|
||||
key: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface OverviewStatistic {
|
||||
callCount: number;
|
||||
modelCount: number;
|
||||
callSuccessCount: number;
|
||||
usages: UsageItem[];
|
||||
}
|
||||
|
||||
export interface ModelStatisticItem {
|
||||
model: string;
|
||||
callSuccessCount: number;
|
||||
usages?: UsageItem[];
|
||||
usage?: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
export interface ListStatisticResponse {
|
||||
list: ModelStatisticItem[];
|
||||
totalCount: number;
|
||||
maxResults: number;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const MAX_POLLS = 30;
|
||||
|
||||
export async function pollTelemetryApi(
|
||||
client: Client,
|
||||
api: string,
|
||||
reqDTO: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
let nextTaskId: string | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
|
||||
const requestData = nextTaskId
|
||||
? { reqDTO: { ...reqDTO, asyncTaskId: nextTaskId } }
|
||||
: { reqDTO };
|
||||
|
||||
const raw = await client.console(api, requestData);
|
||||
const resp = extractResponseData(raw as Record<string, unknown>);
|
||||
|
||||
if (resp.taskId && Object.keys(resp).length === 1) {
|
||||
nextTaskId = resp.taskId as string;
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractOverviewData(result: unknown): OverviewStatistic | undefined {
|
||||
const resp = extractResponseData(result as Record<string, unknown>);
|
||||
if (resp.callSuccessCount !== undefined || resp.usages !== undefined) {
|
||||
return resp as unknown as OverviewStatistic;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractListData(result: unknown): ListStatisticResponse {
|
||||
const resp = extractResponseData(result as Record<string, unknown>);
|
||||
const list = (resp.list as ModelStatisticItem[]) ?? [];
|
||||
const totalCount = (resp.totalCount as number) ?? 0;
|
||||
const maxResults = (resp.maxResults as number) ?? 0;
|
||||
return { list, totalCount, maxResults };
|
||||
}
|
||||
|
||||
export function resolveUsageMap(item: ModelStatisticItem): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
if (item.usages && Array.isArray(item.usages)) {
|
||||
for (const entry of item.usages) {
|
||||
if (entry.key && entry.value != null) out[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
if (item.usage && typeof item.usage === "object") {
|
||||
for (const [key, val] of Object.entries(item.usage)) {
|
||||
if (val != null) out[key] = val;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface UsageLabel {
|
||||
en: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export const USAGE_KEY_LABELS: Record<string, UsageLabel> = {
|
||||
total_token: { en: "Total Tokens", unit: "tokens" },
|
||||
input_token: { en: "Input Tokens", unit: "tokens" },
|
||||
output_token: { en: "Output Tokens", unit: "tokens" },
|
||||
input_token_cache: { en: "Cached Tokens", unit: "tokens" },
|
||||
input_token_cache_read: { en: "Cache Read", unit: "tokens" },
|
||||
input_token_cache_creation: { en: "Cache Creation", unit: "tokens" },
|
||||
thinking_input_token: { en: "Thinking Input", unit: "tokens" },
|
||||
thinking_output_token: { en: "Thinking Output", unit: "tokens" },
|
||||
text_input_token: { en: "Text Input", unit: "tokens" },
|
||||
purein_text_output_token: { en: "Text Output", unit: "tokens" },
|
||||
embedding_token: { en: "Embedding", unit: "tokens" },
|
||||
image_number: { en: "Images", unit: "images" },
|
||||
video_duration: { en: "Video Duration", unit: "sec" },
|
||||
content_duration: { en: "Audio Duration", unit: "sec" },
|
||||
tts_text_number: { en: "TTS Chars", unit: "chars" },
|
||||
total_token_avg: { en: "Avg Tokens/Req" },
|
||||
};
|
||||
|
||||
export function formatUsageLabel(key: string): string {
|
||||
const label = USAGE_KEY_LABELS[key];
|
||||
if (!label) return key;
|
||||
const unitSuffix = label.unit ? ` [${label.unit}]` : "";
|
||||
return `${label.en}${unitSuffix}`;
|
||||
}
|
||||
|
||||
/** Print the account usage overview as an aligned key-value block. */
|
||||
export function printUsageOverview(
|
||||
stat: OverviewStatistic,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
days: number,
|
||||
title = "Usage Overview",
|
||||
): void {
|
||||
const color = ansi(process.stdout);
|
||||
|
||||
process.stdout.write(
|
||||
`${color.purple(title)} ${color.dim("·")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${color.dim(`(${days} days)`)}\n`,
|
||||
);
|
||||
|
||||
const rows: [string, string][] = [
|
||||
["Models Called", formatNumber(stat.modelCount ?? 0)],
|
||||
["Successful Calls", formatNumber(stat.callSuccessCount ?? 0)],
|
||||
];
|
||||
|
||||
for (const usage of stat.usages ?? []) {
|
||||
rows.push([formatUsageLabel(usage.key), formatNumber(usage.value)]);
|
||||
}
|
||||
|
||||
const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label)));
|
||||
for (const [label, value] of rows) {
|
||||
process.stdout.write(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { defineCommand, BailianError, ExitCode, detectOutputFormat } from "bailian-cli-core";
|
||||
import { ansi, emitResult } from "bailian-cli-runtime";
|
||||
import {
|
||||
FREE_TIER_API,
|
||||
FREE_TIER_ONLY_STATUS_API,
|
||||
OVERVIEW_API,
|
||||
USAGE_KEY_LABELS,
|
||||
extractFreeTierOnlyStatuses,
|
||||
extractOverviewData,
|
||||
extractQuotas,
|
||||
fetchAllModels,
|
||||
formatDate,
|
||||
pollTelemetryApi,
|
||||
printFreeTierTable,
|
||||
printUsageOverview,
|
||||
quotaRemainingPercent,
|
||||
type FreeTierQuota,
|
||||
type OverviewStatistic,
|
||||
} from "./shared.ts";
|
||||
|
||||
/** Free-tier rows shown in the summary before the "+N more" hint. */
|
||||
const TOP_N = 10;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show a unified usage summary: free-tier quota and recent usage overview",
|
||||
auth: "console",
|
||||
usageArgs: "[--days <days>] [flags]",
|
||||
flags: {
|
||||
days: {
|
||||
type: "string",
|
||||
valueHint: "<days>",
|
||||
description: "Number of days for the usage overview (default: 7)",
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--days 30", "--output json"],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const daysFlag = Number(flags.days) || 7;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const workspaceId = settings.workspaceId;
|
||||
|
||||
const endTime = Date.now();
|
||||
const startTime = endTime - daysFlag * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
freeTier: { api: FREE_TIER_API },
|
||||
usage: workspaceId
|
||||
? {
|
||||
api: OVERVIEW_API,
|
||||
data: {
|
||||
reqDTO: {
|
||||
startTime,
|
||||
endTime,
|
||||
modelCallSource: "Online",
|
||||
filterWorkspaceId: workspaceId,
|
||||
},
|
||||
},
|
||||
}
|
||||
: null,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Free-tier quota (all models, urgency-sorted) ---
|
||||
const modelInfos = await fetchAllModels(ctx.client);
|
||||
const models = modelInfos.map((info) => info.name);
|
||||
const typeMap = new Map(modelInfos.map((info) => [info.name, info.type]));
|
||||
|
||||
const [quotaResult, stopResult] = await Promise.all([
|
||||
ctx.client.console(FREE_TIER_API, { queryFreeTierQuotaRequest: { models } }),
|
||||
ctx.client.console(FREE_TIER_ONLY_STATUS_API, {
|
||||
queryFreeTierOnlyStatusRequest: { models },
|
||||
}),
|
||||
]);
|
||||
|
||||
const quotas = extractQuotas(quotaResult)
|
||||
.filter((quota) => quota.quotaStatus === "VALID" && quota.quotaInitTotal > 0)
|
||||
.sort((left, right) => {
|
||||
const pctLeft = left.quotaInitTotal ? left.quotaTotal / left.quotaInitTotal : 0;
|
||||
const pctRight = right.quotaInitTotal ? right.quotaTotal / right.quotaInitTotal : 0;
|
||||
return pctLeft - pctRight;
|
||||
});
|
||||
|
||||
const stopStatuses = extractFreeTierOnlyStatuses(stopResult);
|
||||
const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
|
||||
|
||||
// --- Usage overview (requires workspace) ---
|
||||
let overview: OverviewStatistic | undefined;
|
||||
if (workspaceId) {
|
||||
const result = await pollTelemetryApi(ctx.client, OVERVIEW_API, {
|
||||
startTime,
|
||||
endTime,
|
||||
modelCallSource: "Online",
|
||||
filterWorkspaceId: workspaceId,
|
||||
});
|
||||
if (!result) throw new BailianError("Request timed out.", ExitCode.TIMEOUT);
|
||||
overview = extractOverviewData(result);
|
||||
}
|
||||
|
||||
if (format === "json") {
|
||||
const freeTier = quotas.map((quota: FreeTierQuota) => ({
|
||||
model: quota.model,
|
||||
type: typeMap.get(quota.model) || null,
|
||||
remaining: quota.quotaTotal,
|
||||
total: quota.quotaInitTotal,
|
||||
remainingPercent: quotaRemainingPercent(quota),
|
||||
expires: quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : null,
|
||||
}));
|
||||
emitResult(
|
||||
{
|
||||
period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag },
|
||||
freeTier,
|
||||
usage: overview
|
||||
? {
|
||||
modelsCalled: overview.modelCount ?? 0,
|
||||
successfulCalls: overview.callSuccessCount ?? 0,
|
||||
usages: (overview.usages ?? []).map((usage) => ({
|
||||
key: usage.key,
|
||||
value: usage.value,
|
||||
unit: usage.unit,
|
||||
label: USAGE_KEY_LABELS[usage.key]?.en ?? usage.key,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const color = ansi(process.stdout);
|
||||
process.stdout.write(
|
||||
`${color.bold("Usage Summary")} ${color.dim("·")} ${formatDate(endTime)}\n\n`,
|
||||
);
|
||||
|
||||
if (quotas.length === 0) {
|
||||
process.stdout.write(color.dim("No active free-tier quota.") + "\n");
|
||||
} else {
|
||||
const visible = quotas.slice(0, TOP_N);
|
||||
const remaining = quotas.length - visible.length;
|
||||
printFreeTierTable({
|
||||
quotas: visible,
|
||||
stopMap,
|
||||
typeMap,
|
||||
title: `Free Tier Quota · ${quotas.length} models · sorted by urgency`,
|
||||
moreHint:
|
||||
remaining > 0
|
||||
? `+ ${remaining} more · ${identity.binName} usage free to browse all`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
process.stdout.write("\n");
|
||||
|
||||
if (!workspaceId) {
|
||||
process.stdout.write(
|
||||
color.dim(
|
||||
`Usage overview skipped · set --workspace-id or \`${identity.binName} config set workspace_id <id>\` to include it.`,
|
||||
) + "\n",
|
||||
);
|
||||
} else if (!overview) {
|
||||
process.stdout.write(color.dim("No usage data in this period.") + "\n");
|
||||
} else {
|
||||
printUsageOverview(overview, startTime, endTime, daysFlag);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveWatermark,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
redactDataUri,
|
||||
} from "bailian-cli-core";
|
||||
import { poll } from "bailian-cli-runtime";
|
||||
import { downloadFile, formatBytes } from "bailian-cli-runtime";
|
||||
@@ -103,8 +104,9 @@ export default defineCommand({
|
||||
|
||||
const model =
|
||||
flags.model ||
|
||||
settings.defaultVideoModel ||
|
||||
(flags.image ? "happyhorse-1.1-i2v" : "happyhorse-1.1-t2v");
|
||||
(flags.image
|
||||
? settings.defaultImageToVideoModel || "happyhorse-1.1-i2v"
|
||||
: settings.defaultVideoModel || "happyhorse-1.1-t2v");
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const imageUrl = flags.image;
|
||||
@@ -112,7 +114,7 @@ export default defineCommand({
|
||||
// Auto-upload local image file for i2v
|
||||
let resolvedImageUrl: string | undefined;
|
||||
if (imageUrl) {
|
||||
resolvedImageUrl = await ctx.client.uploadFile(imageUrl, model);
|
||||
resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model);
|
||||
}
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
@@ -139,7 +141,16 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body }, format);
|
||||
const previewBody = resolvedImageUrl
|
||||
? {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
media: [{ type: "first_frame" as const, url: redactDataUri(resolvedImageUrl) }],
|
||||
},
|
||||
}
|
||||
: body;
|
||||
emitResult({ request: previewBody }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveWatermark,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
redactDataUri,
|
||||
} from "bailian-cli-core";
|
||||
import { poll } from "bailian-cli-runtime";
|
||||
import { downloadFile, formatBytes } from "bailian-cli-runtime";
|
||||
@@ -117,23 +118,23 @@ export default defineCommand({
|
||||
const imageVoices = flags.imageVoice || [];
|
||||
const videoVoices = flags.videoVoice || [];
|
||||
|
||||
const model = flags.model || "happyhorse-1.1-r2v";
|
||||
const model = flags.model || settings.defaultReferenceToVideoModel || "happyhorse-1.1-r2v";
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// --- Resolve file URLs (auto-upload local files) ---
|
||||
const media: DashScopeVideoRefRequest["input"]["media"] = [];
|
||||
|
||||
// Add reference images
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const resolved = await ctx.client.uploadFile(images[i]!, model);
|
||||
for (let imageIndex = 0; imageIndex < images.length; imageIndex++) {
|
||||
const resolved = await ctx.client.resolveImageInput(images[imageIndex]!, model);
|
||||
const entry: DashScopeVideoRefRequest["input"]["media"][number] = {
|
||||
type: "reference_image",
|
||||
url: resolved,
|
||||
};
|
||||
|
||||
// Pair voice by position
|
||||
if (imageVoices[i]) {
|
||||
const resolvedVoice = await ctx.client.uploadFile(imageVoices[i]!, model);
|
||||
if (imageVoices[imageIndex]) {
|
||||
const resolvedVoice = await ctx.client.uploadFile(imageVoices[imageIndex]!, model);
|
||||
entry.reference_voice = resolvedVoice;
|
||||
}
|
||||
|
||||
@@ -141,16 +142,16 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
// Add reference videos
|
||||
for (let i = 0; i < refVideos.length; i++) {
|
||||
const resolved = await ctx.client.uploadFile(refVideos[i]!, model);
|
||||
for (let videoIndex = 0; videoIndex < refVideos.length; videoIndex++) {
|
||||
const resolved = await ctx.client.uploadFile(refVideos[videoIndex]!, model);
|
||||
const entry: DashScopeVideoRefRequest["input"]["media"][number] = {
|
||||
type: "reference_video",
|
||||
url: resolved,
|
||||
};
|
||||
|
||||
// Pair voice by position
|
||||
if (videoVoices[i]) {
|
||||
const resolvedVoice = await ctx.client.uploadFile(videoVoices[i]!, model);
|
||||
if (videoVoices[videoIndex]) {
|
||||
const resolvedVoice = await ctx.client.uploadFile(videoVoices[videoIndex]!, model);
|
||||
entry.reference_voice = resolvedVoice;
|
||||
}
|
||||
|
||||
@@ -178,7 +179,18 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body }, format);
|
||||
const previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
media: body.input.media.map((item) => ({
|
||||
...item,
|
||||
url: redactDataUri(item.url),
|
||||
reference_voice: item.reference_voice ? redactDataUri(item.reference_voice) : undefined,
|
||||
})),
|
||||
},
|
||||
};
|
||||
emitResult({ request: previewBody }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -233,11 +245,11 @@ export default defineCommand({
|
||||
);
|
||||
|
||||
const videos: Array<{ taskId: string; videoUrl: string }> = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i]!;
|
||||
for (let resultIndex = 0; resultIndex < results.length; resultIndex++) {
|
||||
const result = results[resultIndex]!;
|
||||
const videoUrl =
|
||||
result.output.video_url || (result.output.results && result.output.results[0]?.url);
|
||||
if (videoUrl) videos.push({ taskId: taskIds[i]!, videoUrl });
|
||||
if (videoUrl) videos.push({ taskId: taskIds[resultIndex]!, videoUrl });
|
||||
}
|
||||
|
||||
if (videos.length === 0) {
|
||||
|
||||
@@ -8,18 +8,13 @@ import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
isLocalFile,
|
||||
imageFileToDataUri,
|
||||
redactDataUri,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { existsSync, statSync } from "fs";
|
||||
import { extname } from "path";
|
||||
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]);
|
||||
|
||||
function isVideoInput(input: string): boolean {
|
||||
@@ -35,18 +30,7 @@ async function toImageUrl(image: string): Promise<string> {
|
||||
if (image.startsWith("data:")) return image;
|
||||
if (image.startsWith("http://") || image.startsWith("https://")) return image;
|
||||
if (image.startsWith("oss://")) return image;
|
||||
|
||||
// Local file → data URI (for small files < 10MB, fallback)
|
||||
if (!existsSync(image)) throw new BailianError(`File not found: ${image}`, ExitCode.USAGE);
|
||||
const ext = extname(image).toLowerCase();
|
||||
const mime = IMAGE_MIME_TYPES[ext];
|
||||
if (!mime)
|
||||
throw new BailianError(
|
||||
`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
const buf = readFileSync(image);
|
||||
return `data:${mime};base64,${buf.toString("base64")}`;
|
||||
return imageFileToDataUri(image);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
@@ -86,7 +70,10 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
let image = flags.image;
|
||||
const videoInputs = flags.video ?? [];
|
||||
const model = flags.model || "qwen3-vl-plus";
|
||||
const model =
|
||||
flags.model ||
|
||||
(ctx.client.usesTokenPlanEndpoint() ? settings.defaultTextModel : undefined) ||
|
||||
"qwen3-vl-plus";
|
||||
|
||||
// Auto-detect: if --image was given a video file, treat it as --video
|
||||
if (image && isVideoInput(image)) {
|
||||
@@ -102,7 +89,14 @@ export default defineCommand({
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{ request: { prompt, image, video: videoInputs.length ? videoInputs : undefined, model } },
|
||||
{
|
||||
request: {
|
||||
prompt,
|
||||
image: image ? redactDataUri(image) : undefined,
|
||||
video: videoInputs.length ? videoInputs.map(redactDataUri) : undefined,
|
||||
model,
|
||||
},
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
@@ -132,10 +126,9 @@ export default defineCommand({
|
||||
|
||||
let finalImageUrl = imageUrl;
|
||||
if (isLocalFile(image) && imageUrl.startsWith("data:")) {
|
||||
const { statSync } = await import("fs");
|
||||
const fileSize = statSync(image).size;
|
||||
if (fileSize > 5 * 1024 * 1024) {
|
||||
finalImageUrl = await ctx.client.uploadFile(image, model);
|
||||
finalImageUrl = await ctx.client.resolveImageInput(image, model);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
generateCLIAccessToken,
|
||||
callConsoleGateway,
|
||||
effectiveConsoleGatewayConfig,
|
||||
createBailianControlUser,
|
||||
listBailianControlWorkspaces,
|
||||
resetBailianControlPolicies4Agent,
|
||||
type ConsoleGatewayTarget,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const API = {
|
||||
loginInfo: "zeldaEasy.cornerstone-portal.cs-console.loginInfo",
|
||||
initSpace: "zeldaEasy.bailian-dash-workspace.space.initSpace",
|
||||
queryBuyResult: "zeldaEasy.bailian-commerce.bill.queryBuyPostpaidResult",
|
||||
commodityOrderInfo: "zeldaEasy.bailian-commerce.bill.postpaidCommodityOrderInfo",
|
||||
buyCommodity: "zeldaEasy.bailian-commerce.bill.buyPostpaidCommodity",
|
||||
} as const;
|
||||
|
||||
const FLAGS = {
|
||||
accessKeyId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Alibaba Cloud Access Key ID",
|
||||
},
|
||||
accessKeySecret: {
|
||||
type: "string",
|
||||
valueHint: "<secret>",
|
||||
description: "Alibaba Cloud Access Key Secret",
|
||||
},
|
||||
securityToken: {
|
||||
type: "string",
|
||||
valueHint: "<token>",
|
||||
description: "Alibaba Cloud STS Security Token (optional)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
const MAX_POLL_ATTEMPTS = 20;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function extractData(resp: any): any {
|
||||
return resp?.data?.DataV2?.data?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the agent id from a ListWorkspaces response. Workspaces live under
|
||||
* `data.data`; the agent id is the workspace's `tenantId`. Prefer the default
|
||||
* workspace (`defaultAgent`), else fall back to the first one.
|
||||
*/
|
||||
function extractAgentId(resp: any): number | undefined {
|
||||
const workspaces = resp?.data?.data;
|
||||
if (!Array.isArray(workspaces) || workspaces.length === 0) return undefined;
|
||||
const chosen = workspaces.find((workspace) => workspace?.defaultAgent === true) ?? workspaces[0];
|
||||
const tenantId = chosen?.tenantId;
|
||||
const agentId = typeof tenantId === "string" ? Number(tenantId) : tenantId;
|
||||
return typeof agentId === "number" && Number.isFinite(agentId) ? agentId : undefined;
|
||||
}
|
||||
|
||||
interface CommodityItem {
|
||||
commodityCode?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Initialize Bailian workspace and activate postpaid services",
|
||||
auth: "none",
|
||||
usageArgs: "--access-key-id <id> --access-key-secret <secret> [--security-token <token>]",
|
||||
flags: FLAGS,
|
||||
exampleArgs: ["--access-key-id LTAIxxxxx --access-key-secret xxxxx"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
apis: [
|
||||
{
|
||||
step: 0,
|
||||
api: "GenerateCLIAccessToken",
|
||||
description: "Generate CLI access token from AK/SK",
|
||||
},
|
||||
{
|
||||
step: 1,
|
||||
api: API.loginInfo,
|
||||
description: "Check login & workspace status",
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
api: API.initSpace,
|
||||
description: "Initialize workspace (if needed)",
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
api: "CreateUser",
|
||||
description: "Create console user via BailianControl OpenAPI (CreateUser)",
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
api: "ListWorkspaces",
|
||||
description: "List workspaces to resolve agentId",
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
api: "ResetPolicies4Agent",
|
||||
description: "Authorize user permissions",
|
||||
},
|
||||
{
|
||||
step: 6,
|
||||
api: API.queryBuyResult,
|
||||
description: "Query postpaid order status",
|
||||
},
|
||||
{
|
||||
step: 7,
|
||||
api: API.commodityOrderInfo,
|
||||
description: "Query commodity activation status",
|
||||
},
|
||||
{
|
||||
step: 8,
|
||||
api: API.buyCommodity,
|
||||
description: "Activate postpaid commodities (if needed)",
|
||||
},
|
||||
],
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { accessKeyId, accessKeySecret } = flags;
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new BailianError(
|
||||
"workspace init requires --access-key-id and --access-key-secret.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
const securityToken = flags.securityToken || undefined;
|
||||
|
||||
// Step 0: Exchange AK/SK for a temporary CLI access token used by console calls.
|
||||
const tokenResp = await generateCLIAccessToken({
|
||||
identity: ctx.identity,
|
||||
settings,
|
||||
baseUrl: ctx.client.baseUrl,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
});
|
||||
const accessToken: string | undefined = tokenResp.cliAccessToken;
|
||||
if (!accessToken) {
|
||||
throw new BailianError("Failed to generate CLI access token from AK/SK.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
const gateway = effectiveConsoleGatewayConfig(settings);
|
||||
const target: ConsoleGatewayTarget = {
|
||||
region: gateway.consoleRegion,
|
||||
site: gateway.consoleSite,
|
||||
...(gateway.consoleSwitchAgent != null ? { switchAgent: gateway.consoleSwitchAgent } : {}),
|
||||
token: accessToken,
|
||||
};
|
||||
|
||||
const verbose = settings.verbose;
|
||||
const callApi = async (api: string, data: Record<string, unknown> = {}) => {
|
||||
if (verbose) process.stderr.write(`> ${api}\n`);
|
||||
try {
|
||||
const resp = await callConsoleGateway(target, settings.timeout, { api, data }, settings);
|
||||
if (verbose) process.stderr.write(`< ${JSON.stringify(resp)}\n`);
|
||||
return resp;
|
||||
} catch (err) {
|
||||
if (verbose) {
|
||||
const message =
|
||||
err instanceof BailianError ? (err.rawResponse ?? err.message) : String(err);
|
||||
process.stderr.write(`< ERROR: ${message}\n`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Step 1: Check login info
|
||||
emitBare("Checking workspace status...");
|
||||
const loginResp = await callApi(API.loginInfo);
|
||||
const loginData = extractData(loginResp);
|
||||
const spaceInited = loginData?.spaceInited === true;
|
||||
|
||||
// Step 2: Init space if needed
|
||||
if (!spaceInited) {
|
||||
emitBare("Initializing workspace...");
|
||||
await callApi(API.initSpace);
|
||||
emitBare("Workspace initialized.");
|
||||
} else {
|
||||
emitBare("Workspace already initialized.");
|
||||
}
|
||||
|
||||
// Step 3: Create console user via BailianControl OpenAPI (AK/SK signed)
|
||||
const uid = loginData?.aliyun?.uid;
|
||||
if (typeof uid !== "string" || uid.length === 0) {
|
||||
throw new BailianError("Console login info did not include aliyun.uid.", ExitCode.GENERAL);
|
||||
}
|
||||
const bailianControlAuth = {
|
||||
identity: ctx.identity,
|
||||
settings,
|
||||
baseUrl: ctx.client.baseUrl,
|
||||
regionId: gateway.consoleRegion,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
};
|
||||
|
||||
try {
|
||||
await createBailianControlUser({
|
||||
...bailianControlAuth,
|
||||
reqDTO: {
|
||||
outerKey: uid,
|
||||
nickName: uid,
|
||||
userName: uid,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Re-running workspace init is idempotent: an already-existing user is
|
||||
// not fatal, so swallow it and continue with the remaining steps.
|
||||
if (!(err instanceof BailianError) || !/already exists/i.test(err.message)) {
|
||||
throw err;
|
||||
}
|
||||
emitBare("Console user already exists, continuing.");
|
||||
}
|
||||
|
||||
// Step 4-5: Resolve the workspace agent id, then authorize user permissions.
|
||||
emitBare("Resolving workspace agent...");
|
||||
const workspacesResp = await listBailianControlWorkspaces(bailianControlAuth);
|
||||
const agentId = extractAgentId(workspacesResp);
|
||||
if (agentId == null) {
|
||||
throw new BailianError(
|
||||
"Could not resolve agentId from ListWorkspaces response.",
|
||||
ExitCode.GENERAL,
|
||||
"Re-run with --verbose to inspect the ListWorkspaces response body.",
|
||||
);
|
||||
}
|
||||
|
||||
emitBare("Authorizing user permissions...");
|
||||
await resetBailianControlPolicies4Agent({
|
||||
...bailianControlAuth,
|
||||
outerKey: uid,
|
||||
agentId,
|
||||
policyIndexList: [1],
|
||||
});
|
||||
|
||||
// Step 6-8: Order & commodity flow
|
||||
await ensureCommoditiesActive(callApi, format);
|
||||
},
|
||||
});
|
||||
|
||||
type ApiCall = (api: string, data?: Record<string, unknown>) => Promise<any>;
|
||||
|
||||
async function ensureCommoditiesActive(call: ApiCall, format: "text" | "json"): Promise<void> {
|
||||
emitBare("Checking service activation status...");
|
||||
let buyResult: string | undefined;
|
||||
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
|
||||
const resp = await call(API.queryBuyResult);
|
||||
const data = extractData(resp);
|
||||
buyResult = typeof data === "string" ? data : data?.result;
|
||||
if (buyResult !== "buying") break;
|
||||
if (i === 0) emitBare("Service activation in progress, polling...");
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (buyResult === "fail") {
|
||||
throw new BailianError("Service activation failed.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
if (buyResult === "success") {
|
||||
await pollCommoditiesUntilActive(call, format);
|
||||
return;
|
||||
}
|
||||
|
||||
await checkAndActivateCommodities(call, format);
|
||||
}
|
||||
|
||||
function extractCommodities(resp: any): CommodityItem[] {
|
||||
const data = extractData(resp);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function checkAndActivateCommodities(call: ApiCall, format: "text" | "json"): Promise<void> {
|
||||
const resp = await call(API.commodityOrderInfo);
|
||||
const items = extractCommodities(resp);
|
||||
|
||||
const overdue = items.filter((c) => c.status === 11);
|
||||
if (overdue.length > 0) {
|
||||
emitBare("Warning: Some services are overdue:");
|
||||
for (const c of overdue) emitBare(` - ${c.commodityCode}`);
|
||||
}
|
||||
|
||||
const notActivated = items.filter((c) => c.status === 1);
|
||||
if (notActivated.length > 0) {
|
||||
emitBare(`Activating ${notActivated.length} postpaid services...`);
|
||||
await call(API.buyCommodity);
|
||||
await pollCommoditiesUntilActive(call, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const active = items.filter((c) => c.status === 10);
|
||||
emitResult({ status: "ready", activeServices: active.map((c) => c.commodityCode) }, format);
|
||||
}
|
||||
|
||||
async function pollCommoditiesUntilActive(call: ApiCall, format: "text" | "json"): Promise<void> {
|
||||
emitBare("Waiting for services to activate...");
|
||||
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
|
||||
const resp = await call(API.commodityOrderInfo);
|
||||
const items = extractCommodities(resp);
|
||||
|
||||
const pending = items.filter((c) => c.status !== 10 && c.status !== 11);
|
||||
if (pending.length === 0) {
|
||||
const overdue = items.filter((c) => c.status === 11);
|
||||
if (overdue.length > 0) {
|
||||
emitBare("Warning: Some services are overdue:");
|
||||
for (const c of overdue) emitBare(` - ${c.commodityCode}`);
|
||||
}
|
||||
const active = items.filter((c) => c.status === 10);
|
||||
emitResult({ status: "ready", activeServices: active.map((c) => c.commodityCode) }, format);
|
||||
return;
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new BailianError("Timed out waiting for services to activate.", ExitCode.TIMEOUT);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
export { default as authLogin } from "./commands/auth/login.ts";
|
||||
export { default as authStatus } from "./commands/auth/status.ts";
|
||||
export { default as authLogout } from "./commands/auth/logout.ts";
|
||||
export { default as authGenerateAccessToken } from "./commands/auth/generate-access-token.ts";
|
||||
export { default as textChat } from "./commands/text/chat.ts";
|
||||
export { default as textOmni } from "./commands/omni/chat.ts";
|
||||
export { default as imageGenerate } from "./commands/image/generate.ts";
|
||||
@@ -18,6 +19,10 @@ export { default as videoDownload } from "./commands/video/download.ts";
|
||||
export { default as visionDescribe } from "./commands/vision/describe.ts";
|
||||
export { default as configShow } from "./commands/config/show.ts";
|
||||
export { default as configSet } from "./commands/config/set.ts";
|
||||
export { default as configList } from "./commands/config/list.ts";
|
||||
export { default as configUse } from "./commands/config/use.ts";
|
||||
export { default as configUi } from "./commands/config/ui.ts";
|
||||
export { default as configAgent } from "./commands/config/agent/index.ts";
|
||||
export { default as update } from "./commands/update.ts";
|
||||
export { default as appCall } from "./commands/app/call.ts";
|
||||
export { default as appList } from "./commands/app/list.ts";
|
||||
@@ -42,9 +47,11 @@ export { default as consoleCall } from "./commands/console/call.ts";
|
||||
export { default as usageFree } from "./commands/usage/free.ts";
|
||||
export { default as usageFreetier } from "./commands/usage/freetier.ts";
|
||||
export { default as usageStats } from "./commands/usage/stats.ts";
|
||||
export { default as usageSummary } from "./commands/usage/summary.ts";
|
||||
export { default as pipelineRun } from "./commands/pipeline/run.ts";
|
||||
export { default as pipelineValidate } from "./commands/pipeline/validate.ts";
|
||||
export { default as advisorRecommend } from "./commands/advisor/recommend.ts";
|
||||
export { default as modelList } from "./commands/model/list.ts";
|
||||
export { default as workspaceList } from "./commands/workspace/list.ts";
|
||||
export { default as quotaList } from "./commands/quota/list.ts";
|
||||
export { default as quotaRequest } from "./commands/quota/request.ts";
|
||||
@@ -55,7 +62,11 @@ export { default as datasetList } from "./commands/dataset/list.ts";
|
||||
export { default as datasetGet } from "./commands/dataset/get.ts";
|
||||
export { default as datasetDelete } from "./commands/dataset/delete.ts";
|
||||
export { default as datasetValidate } from "./commands/dataset/validate.ts";
|
||||
export { default as finetuneCreate } from "./commands/finetune/create.ts";
|
||||
export {
|
||||
finetuneTextCreate,
|
||||
finetuneAudioCreate,
|
||||
finetuneImageCreate,
|
||||
} from "./commands/finetune/create.ts";
|
||||
export { default as finetuneList } from "./commands/finetune/list.ts";
|
||||
export { default as finetuneGet } from "./commands/finetune/get.ts";
|
||||
export { default as finetuneCancel } from "./commands/finetune/cancel.ts";
|
||||
@@ -65,7 +76,11 @@ export { default as finetuneCheckpoints } from "./commands/finetune/checkpoints.
|
||||
export { default as finetuneExport } from "./commands/finetune/export.ts";
|
||||
export { default as finetuneWatch } from "./commands/finetune/watch.ts";
|
||||
export { default as finetuneCapability } from "./commands/finetune/capability.ts";
|
||||
export { default as deployCreate } from "./commands/deploy/create.ts";
|
||||
export {
|
||||
deployTextCreate,
|
||||
deployAudioCreate,
|
||||
deployImageCreate,
|
||||
} from "./commands/deploy/create.ts";
|
||||
export { default as deployList } from "./commands/deploy/list.ts";
|
||||
export { default as deployGet } from "./commands/deploy/get.ts";
|
||||
export { default as deployModels } from "./commands/deploy/models.ts";
|
||||
@@ -76,3 +91,8 @@ export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.
|
||||
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
|
||||
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
|
||||
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
|
||||
export { default as workspaceInit } from "./commands/workspace/init.ts";
|
||||
export { default as pluginInstall } from "./commands/plugin/install.ts";
|
||||
export { default as pluginLink } from "./commands/plugin/link.ts";
|
||||
export { default as pluginList } from "./commands/plugin/list.ts";
|
||||
export { default as pluginRemove } from "./commands/plugin/remove.ts";
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { readdirSync, readFileSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
// 能力面边界(重构 §6 的 lint 规则):configStore() 仅 config 命令族、authStore()
|
||||
// 仅 auth 命令族可用;业务命令只依赖 settings/flags/client。
|
||||
|
||||
const ROOT = join(import.meta.dirname, "../src/commands");
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) out.push(...walk(p));
|
||||
else if (p.endsWith(".ts")) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test("configStore() 仅在 commands/config/** 使用", () => {
|
||||
for (const file of walk(ROOT)) {
|
||||
if (file.includes("/config/")) continue;
|
||||
expect(readFileSync(file, "utf8").includes("configStore("), file).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("authStore() 仅在 commands/auth/** 使用", () => {
|
||||
for (const file of walk(ROOT)) {
|
||||
if (file.includes("/auth/")) continue;
|
||||
expect(readFileSync(file, "utf8").includes("authStore("), file).toBe(false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
||||
import { tmpdir, homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
|
||||
import claudeCode from "../src/commands/config/agent/writers/claude-code.ts";
|
||||
import qwenCode from "../src/commands/config/agent/writers/qwen-code.ts";
|
||||
import opencode from "../src/commands/config/agent/writers/opencode.ts";
|
||||
import openclaw from "../src/commands/config/agent/writers/openclaw.ts";
|
||||
import hermes from "../src/commands/config/agent/writers/hermes.ts";
|
||||
import codex from "../src/commands/config/agent/writers/codex.ts";
|
||||
import yaml from "yaml";
|
||||
|
||||
/**
|
||||
* Agent writer 单元测试:直接调用 writer,用临时 HOME 隔离文件系统。
|
||||
* writer 是纯文件 I/O,在进程内测试比 e2e 子进程更快、覆盖更全。
|
||||
*/
|
||||
|
||||
const OAI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
const ANTHROPIC_URL = "https://dashscope.aliyuncs.com/apps/anthropic";
|
||||
|
||||
let home = "";
|
||||
let prevHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "bl-agent-writer-"));
|
||||
prevHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
// homedir() 在 POSIX 读 $HOME;断言隔离生效。
|
||||
expect(homedir()).toBe(home);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function readJsonAt(...segments: string[]): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(home, ...segments), "utf8"));
|
||||
}
|
||||
|
||||
describe("config agent writers", () => {
|
||||
test("claude-code 写入 env 与 onboarding,并合并已有 env", () => {
|
||||
// 预置一个无关 env 键,验证合并保留
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".claude", "settings.json"),
|
||||
JSON.stringify({ env: { KEEP_ME: "1" }, other: true }),
|
||||
);
|
||||
|
||||
const summary = claudeCode.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-a",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
expect(summary.paths).toHaveLength(2);
|
||||
|
||||
const settings = readJsonAt(".claude", "settings.json");
|
||||
const env = settings.env as Record<string, string>;
|
||||
expect(env.KEEP_ME).toBe("1");
|
||||
expect(settings.other).toBe(true);
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe(ANTHROPIC_URL);
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-a");
|
||||
expect(env.ANTHROPIC_MODEL).toBe("qwen3-max");
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3-max");
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3-max");
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("qwen3-max");
|
||||
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3-max");
|
||||
|
||||
expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
test("qwen-code compatible-mode 走 openai 协议", () => {
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" });
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
const security = settings.security as { auth: Record<string, string> };
|
||||
expect(security.auth.selectedType).toBe("openai");
|
||||
expect(security.auth.apiKey).toBe("sk-q");
|
||||
expect(security.auth.baseUrl).toBe(OAI_URL);
|
||||
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-q");
|
||||
expect((settings.model as Record<string, string>).name).toBe("qwen3-coder-plus");
|
||||
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
|
||||
expect(providers.openai[0]).toMatchObject({
|
||||
id: "qwen3-coder-plus",
|
||||
name: "bailian-cli",
|
||||
baseUrl: OAI_URL,
|
||||
envKey: "BAILIAN_CLI_API_KEY",
|
||||
});
|
||||
});
|
||||
|
||||
test("qwen-code anthropic 端点走 anthropic 协议", () => {
|
||||
qwenCode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-q", model: "qwen3-max" });
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
expect((settings.security as { auth: { selectedType: string } }).auth.selectedType).toBe(
|
||||
"anthropic",
|
||||
);
|
||||
const providers = settings.modelProviders as Record<string, unknown>;
|
||||
expect(Array.isArray(providers.anthropic)).toBe(true);
|
||||
expect(providers.openai).toBeUndefined();
|
||||
});
|
||||
|
||||
test("qwen-code 对相同 id+baseUrl 的 provider 项做 upsert 而非追加", () => {
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-1", model: "qwen3-coder-plus" });
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-2", model: "qwen3-coder-plus" });
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>).openai;
|
||||
expect(openaiEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("opencode 按端点选 npm,含 setCacheKey,合并保留其它 provider", () => {
|
||||
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".config", "opencode", "opencode.json"),
|
||||
JSON.stringify({ provider: { other: { name: "Other" } } }),
|
||||
);
|
||||
|
||||
opencode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
const config = readJsonAt(".config", "opencode", "opencode.json");
|
||||
const provider = config.provider as Record<string, Record<string, unknown>>;
|
||||
expect(provider.other).toBeDefined();
|
||||
expect(provider["bailian-cli"].npm).toBe("@ai-sdk/anthropic");
|
||||
const options = provider["bailian-cli"].options as Record<string, unknown>;
|
||||
expect(options.baseURL).toBe(ANTHROPIC_URL);
|
||||
expect(options.apiKey).toBe("sk-o");
|
||||
expect(options.setCacheKey).toBe(true);
|
||||
expect((provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"]).toBeDefined();
|
||||
|
||||
// 非 anthropic 端点用 openai-compatible
|
||||
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
expect(
|
||||
(
|
||||
readJsonAt(".config", "opencode", "opencode.json").provider as Record<
|
||||
string,
|
||||
{ npm: string }
|
||||
>
|
||||
)["bailian-cli"].npm,
|
||||
).toBe("@ai-sdk/openai-compatible");
|
||||
});
|
||||
|
||||
test("openclaw 写入 provider、api 与 primary", () => {
|
||||
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" });
|
||||
const config = readJsonAt(".openclaw", "openclaw.json");
|
||||
const models = config.models as Record<string, unknown>;
|
||||
expect(models.mode).toBe("merge");
|
||||
const bailian = (models.providers as Record<string, Record<string, unknown>>)["bailian-cli"];
|
||||
expect(bailian.api).toBe("openai-completions");
|
||||
expect((bailian.models as Array<{ id: string }>)[0].id).toBe("qwen3-coder-plus");
|
||||
const agents = config.agents as { defaults: { model: { primary: string } } };
|
||||
expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus");
|
||||
|
||||
// anthropic 端点用 anthropic-messages
|
||||
openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" });
|
||||
const config2 = readJsonAt(".openclaw", "openclaw.json");
|
||||
expect(
|
||||
((config2.models as Record<string, unknown>).providers as Record<string, { api: string }>)[
|
||||
"bailian-cli"
|
||||
].api,
|
||||
).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => {
|
||||
mkdirSync(join(home, ".hermes"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".hermes", "config.yaml"),
|
||||
yaml.stringify({ custom_providers: [{ name: "other", base_url: "https://x" }] }),
|
||||
);
|
||||
|
||||
hermes.write({ baseUrl: OAI_URL, apiKey: "sk-h", model: "qwen3-coder-plus" });
|
||||
const config = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
|
||||
expect(config.model).toEqual({ default: "qwen3-coder-plus", provider: "bailian-cli" });
|
||||
const names = (config.custom_providers as Array<{ name: string }>).map((p) => p.name);
|
||||
expect(names).toContain("other");
|
||||
const entry = (config.custom_providers as Array<Record<string, unknown>>).find(
|
||||
(provider) => provider.name === "bailian-cli",
|
||||
)!;
|
||||
expect(entry.base_url).toBe(OAI_URL);
|
||||
expect(entry.api_key).toBe("sk-h");
|
||||
expect(entry.api_mode).toBe("chat_completions");
|
||||
|
||||
// anthropic 端点用 anthropic_messages
|
||||
hermes.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-h", model: "qwen3-max" });
|
||||
const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
|
||||
const entry2 = (config2.custom_providers as Array<Record<string, unknown>>).find(
|
||||
(provider) => provider.name === "bailian-cli",
|
||||
)!;
|
||||
expect(entry2.api_mode).toBe("anthropic_messages");
|
||||
// upsert:bailian-cli 项不重复
|
||||
expect(
|
||||
(config2.custom_providers as Array<{ name: string }>).filter((p) => p.name === "bailian-cli"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("codex 写入 config.toml 与 auth.json(cc-switch 对齐结构,合并保留)", () => {
|
||||
// 预置 config.toml 无关顶层键与另一个 provider,验证非破坏性合并
|
||||
mkdirSync(join(home, ".codex"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".codex", "config.toml"),
|
||||
[
|
||||
'approval_policy = "on-request"',
|
||||
"",
|
||||
"[model_providers.other]",
|
||||
'name = "other"',
|
||||
'base_url = "https://other.example.com/v1"',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
// 预置 auth.json 无关键,验证合并保留
|
||||
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
|
||||
|
||||
codex.write({ baseUrl: OAI_URL, apiKey: "sk-x", model: "qwen3-coder-plus" });
|
||||
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
|
||||
expect(toml).toContain('model_provider = "bailian-cli"');
|
||||
expect(toml).toContain('model = "qwen3-coder-plus"');
|
||||
expect(toml).toContain('model_reasoning_effort = "high"');
|
||||
expect(toml).toContain("disable_response_storage = true");
|
||||
expect(toml).toContain("[model_providers.bailian-cli]");
|
||||
expect(toml).toContain(`base_url = "${OAI_URL}"`);
|
||||
expect(toml).toContain('wire_api = "responses"');
|
||||
expect(toml).toContain("requires_openai_auth = true");
|
||||
// 合并:保留用户已有的无关配置
|
||||
expect(toml).toContain('approval_policy = "on-request"');
|
||||
expect(toml).toContain("[model_providers.other]");
|
||||
|
||||
const auth = readJsonAt(".codex", "auth.json");
|
||||
expect(auth.OPENAI_API_KEY).toBe("sk-x");
|
||||
expect(auth.EXISTING).toBe("keep");
|
||||
});
|
||||
|
||||
test("已存在的配置文件会被备份为 .bak.<epoch>", () => {
|
||||
mkdirSync(join(home, ".openclaw"), { recursive: true });
|
||||
writeFileSync(join(home, ".openclaw", "openclaw.json"), JSON.stringify({ pre: 1 }));
|
||||
|
||||
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" });
|
||||
const backups = readdirSync(join(home, ".openclaw")).filter((name) =>
|
||||
name.startsWith("openclaw.json.bak."),
|
||||
);
|
||||
expect(backups).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import http from "node:http";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
activateConfigProfile,
|
||||
getConfigPath,
|
||||
makeConfigStore,
|
||||
writeConfigFile,
|
||||
readConfigFile,
|
||||
readConfigProfiles,
|
||||
} from "bailian-cli-core";
|
||||
import { createConfigUiServer } from "../src/commands/config/ui.ts";
|
||||
|
||||
const TOKEN = "test-token";
|
||||
|
||||
interface HttpResult {
|
||||
status: number;
|
||||
json: any;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function httpJson(
|
||||
port: number,
|
||||
method: string,
|
||||
path: string,
|
||||
opts?: { body?: unknown; headers?: Record<string, string> },
|
||||
): Promise<HttpResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = opts?.body !== undefined ? JSON.stringify(opts.body) : undefined;
|
||||
const headers: Record<string, string> = { ...opts?.headers };
|
||||
if (payload) headers["Content-Type"] = "application/json";
|
||||
const req = http.request({ host: "127.0.0.1", port, method, path, headers }, (res) => {
|
||||
let d = "";
|
||||
res.on("data", (c) => (d += c));
|
||||
res.on("end", () => {
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = d ? JSON.parse(d) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, json, text: d });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** 隔离临时配置目录 + 启动 UI server,跑完清理。 */
|
||||
async function withServer(fn: (port: number) => Promise<void>): Promise<void> {
|
||||
const saved = process.env.BAILIAN_CONFIG_DIR;
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-ui-"));
|
||||
process.env.BAILIAN_CONFIG_DIR = dir;
|
||||
const server = createConfigUiServer(TOKEN, makeConfigStore());
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
||||
const addr = server.address();
|
||||
const port = addr && typeof addr === "object" ? addr.port : 0;
|
||||
try {
|
||||
await fn(port);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = saved;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", async () => {
|
||||
await withServer(async (port) => {
|
||||
await writeConfigFile({ api_key: "sk-default", output: "json" });
|
||||
await writeConfigFile({ api_key: "sk-dev", access_token: "tok-dev" }, "dev");
|
||||
await activateConfigProfile("dev");
|
||||
|
||||
const res = await httpJson(port, "GET", `/api/config?token=${TOKEN}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.json.activeProfile).toBe("dev");
|
||||
expect(res.json.default).toMatchObject({ api_key: "sk-default", output: "json" });
|
||||
expect(res.json.named.dev).toMatchObject({ api_key: "sk-dev", access_token: "tok-dev" });
|
||||
expect(res.json.secretKeys).toContain("api_key");
|
||||
expect(res.json.keys).toContain("default_image_to_video_model");
|
||||
expect(res.json.keys).toContain("default_reference_to_video_model");
|
||||
});
|
||||
});
|
||||
|
||||
test("鉴权:错误 token 401、非 loopback Host 403", async () => {
|
||||
await withServer(async (port) => {
|
||||
const bad = await httpJson(port, "GET", `/api/config?token=wrong`);
|
||||
expect(bad.status).toBe(401);
|
||||
|
||||
const badHost = await httpJson(port, "GET", `/api/config?token=${TOKEN}`, {
|
||||
headers: { Host: "evil.com" },
|
||||
});
|
||||
expect(badHost.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /api/profile 写命名 profile(timeout 强制为 number),空串清除键", async () => {
|
||||
await withServer(async (port) => {
|
||||
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: {
|
||||
name: "stage",
|
||||
data: {
|
||||
api_key: "sk-stage",
|
||||
timeout: "90",
|
||||
base_url: "https://proxy.example.com/team/compatible-mode/v1/?x=1#fragment",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(save.status).toBe(200);
|
||||
expect(readConfigFile("stage")).toMatchObject({
|
||||
api_key: "sk-stage",
|
||||
timeout: 90,
|
||||
base_url: "https://proxy.example.com",
|
||||
});
|
||||
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
|
||||
expect(rawConfig.stage.base_url).toBe("https://proxy.example.com");
|
||||
|
||||
// 空串清除 api_key(整块替换)
|
||||
const clear = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { api_key: "", timeout: "120" } },
|
||||
});
|
||||
expect(clear.status).toBe(200);
|
||||
const after = readConfigFile("stage");
|
||||
expect(after.api_key).toBeUndefined();
|
||||
expect(after.timeout).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /api/profile 保留 UI 未管理字段,同时替换 UI 管理字段", async () => {
|
||||
await withServer(async (port) => {
|
||||
await writeConfigFile(
|
||||
{
|
||||
api_key: "sk-old",
|
||||
output: "json",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
},
|
||||
"stage",
|
||||
);
|
||||
|
||||
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { api_key: "sk-new" } },
|
||||
});
|
||||
expect(save.status).toBe(200);
|
||||
|
||||
const profile = readConfigFile("stage");
|
||||
expect(profile).toMatchObject({
|
||||
api_key: "sk-new",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
});
|
||||
expect(profile.output).toBeUndefined();
|
||||
|
||||
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
|
||||
expect(rawConfig.stage).toMatchObject({
|
||||
api_key: "sk-new",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
});
|
||||
expect(rawConfig.stage.output).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("New profile 立即保存空 Profile,其他配置读取可以看到", async () => {
|
||||
await withServer(async (port) => {
|
||||
const create = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "new-profile", data: {} },
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
expect(create.json.saved).toEqual({});
|
||||
expect(readConfigProfiles().named["new-profile"]).toEqual({});
|
||||
|
||||
const list = await httpJson(port, "GET", `/api/config?token=${TOKEN}`);
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.json.named["new-profile"]).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /api/profile 非法 key 返回 400", async () => {
|
||||
await withServer(async (port) => {
|
||||
const res = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { not_a_key: "x" } },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.json.error)).toMatch(/Invalid config key/);
|
||||
});
|
||||
});
|
||||
|
||||
test("DELETE /api/profile 删命名 profile;缺 name 返回 400", async () => {
|
||||
await withServer(async (port) => {
|
||||
await writeConfigFile({ api_key: "sk-stage" }, "stage");
|
||||
const del = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`);
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.json.deleted).toBe(true);
|
||||
expect(readConfigProfiles().named.stage).toBeUndefined();
|
||||
|
||||
const noName = await httpJson(port, "DELETE", `/api/profile?token=${TOKEN}`);
|
||||
expect(noName.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test("Save & Activate 创建并激活 Profile;删除激活项后切回 default", async () => {
|
||||
await withServer(async (port) => {
|
||||
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { api_key: "sk-stage" } },
|
||||
});
|
||||
expect(save.status).toBe(200);
|
||||
|
||||
const activate = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, {
|
||||
body: { name: "stage" },
|
||||
});
|
||||
expect(activate.status).toBe(200);
|
||||
expect(activate.json.activeProfile).toBe("stage");
|
||||
expect(readConfigProfiles().active).toBe("stage");
|
||||
|
||||
const missing = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, {
|
||||
body: { name: "missing" },
|
||||
});
|
||||
expect(missing.status).toBe(400);
|
||||
expect(readConfigProfiles().active).toBe("stage");
|
||||
|
||||
const deleted = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`);
|
||||
expect(deleted.status).toBe(200);
|
||||
expect(deleted.json.activeProfile).toBe("default");
|
||||
expect(readConfigProfiles().active).toBe("default");
|
||||
});
|
||||
});
|
||||
+58
-38
@@ -1,15 +1,14 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { ADVISOR_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
describe("e2e: advisor recommend", () => {
|
||||
test("advisor shows subcommand groups and exits successfully", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["advisor"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/advisor|recommend/i);
|
||||
});
|
||||
|
||||
test("advisor recommend --help exits successfully", async () => {
|
||||
const { stderr, exitCode } = await runCli(["advisor", "recommend", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/recommend|--message|dry-run/i);
|
||||
});
|
||||
@@ -17,13 +16,17 @@ describe("e2e: advisor recommend", () => {
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () => {
|
||||
test("advisor recommend without --message errors as usage error (2)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["advisor", "recommend", "--quiet"]);
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/--message|Usage:/i);
|
||||
});
|
||||
|
||||
test("advisor recommend --dry-run outputs intent analysis and candidates", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
@@ -63,7 +66,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
}, 60_000);
|
||||
|
||||
test("advisor recommend full flow returns results", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--message",
|
||||
@@ -93,10 +96,10 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
expect(data.result?.recommendations?.[0]?.highlights?.length).toBeGreaterThan(0);
|
||||
}, 120_000);
|
||||
|
||||
// ---- Model preference: positive cases ----
|
||||
// ---- Mode coverage: all 4 modes ----
|
||||
|
||||
test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
test("mode: scoped — family-scoped query sets mode=scoped with targets", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
@@ -109,19 +112,20 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
// Model preference detection depends on LLM interpretation
|
||||
// Accept either "scoped" or "unconstrained" as valid
|
||||
const mode = data.intent?.modelPreference?.mode;
|
||||
expect(mode === "scoped" || mode === "unconstrained" || mode === undefined).toBe(true);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref?.mode).toBe("scoped");
|
||||
expect(pref?.targets?.length).toBeGreaterThan(0);
|
||||
// Should contain "deepseek" (case-insensitive substring)
|
||||
expect(pref?.targets?.some((t) => t.toLowerCase().includes("deepseek"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
test("mode: comparison — comparing two models sets mode=comparison with both targets", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"Which is better for code generation, qwen-max or deepseek-v3?",
|
||||
"Compare qwen-max and deepseek-v3 for legal contract review, high precision required",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
@@ -129,19 +133,41 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
// Model preference detection depends on LLM interpretation
|
||||
// Accept either "comparison" or "unconstrained" as valid
|
||||
const mode = data.intent?.modelPreference?.mode;
|
||||
expect(mode === "comparison" || mode === "unconstrained" || mode === undefined).toBe(true);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref?.mode).toBe("comparison");
|
||||
expect(pref?.targets?.length).toBeGreaterThanOrEqual(2);
|
||||
const targetsLower = pref?.targets?.map((t) => t.toLowerCase()) ?? [];
|
||||
expect(targetsLower.some((t) => t.includes("qwen"))).toBe(true);
|
||||
expect(targetsLower.some((t) => t.includes("deepseek"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("excludes preference — intent detects modelPreference when excluding models", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
test("mode: alternative — reference model query sets mode=alternative with target", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"Not qwen, recommend a model suitable for text generation",
|
||||
"Something like qwen-max but cheaper, for text summarization",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref?.mode).toBe("alternative");
|
||||
expect(pref?.targets?.length).toBeGreaterThan(0);
|
||||
expect(pref?.targets?.some((t) => t.toLowerCase().includes("qwen"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("mode: excludes — excluding a family populates excludes array", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"Recommend a model for text generation, but not qwen",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
@@ -154,19 +180,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
};
|
||||
};
|
||||
}>(stdout);
|
||||
// Model preference detection depends on LLM interpretation
|
||||
// If excludes is detected, verify it contains qwen; otherwise accept as valid
|
||||
const pref = data.intent?.modelPreference;
|
||||
if (pref?.excludes && pref.excludes.length > 0) {
|
||||
expect(pref.excludes.some((e) => e.toLowerCase().includes("qwen"))).toBe(true);
|
||||
}
|
||||
// Test passes if exit code is 0, regardless of whether excludes was detected
|
||||
expect(pref?.excludes?.length).toBeGreaterThan(0);
|
||||
expect(pref?.excludes?.some((e) => e.toLowerCase().includes("qwen"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
// ---- Model preference: negative cases ----
|
||||
|
||||
test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
test("mode: unconstrained — generic query has no modelPreference", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
@@ -0,0 +1,670 @@
|
||||
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";
|
||||
import {
|
||||
isDashScopeE2EReady,
|
||||
isOpenApiE2EReady,
|
||||
makeE2eOutputDir,
|
||||
parseStdoutJson,
|
||||
runCommandE2e,
|
||||
} from "./helpers.ts";
|
||||
import { AUTH_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
interface ValidationServer {
|
||||
baseUrl: string;
|
||||
requests: Array<{
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
authorization?: string;
|
||||
sourceConfig?: string;
|
||||
}>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function startValidationServer(statusCode = 200): Promise<ValidationServer> {
|
||||
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<string, unknown>) : {},
|
||||
authorization: request.headers.authorization,
|
||||
sourceConfig: request.headers["x-dashscope-source-config"] 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<void>((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<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
/** Auth E2E:本地参数/持久化契约默认执行;真实鉴权请求按对应 readiness gate 执行。 */
|
||||
|
||||
describe("e2e: auth", () => {
|
||||
test("auth login --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "login", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/login|api-key/i);
|
||||
expect(stderr).toMatch(/--console-site/);
|
||||
expect(stderr).toMatch(/--open-api/);
|
||||
});
|
||||
|
||||
test("auth login 一次只能选择一种登录模式", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--console",
|
||||
"--api-key",
|
||||
"sk-e2e-placeholder",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Choose exactly one login mode/);
|
||||
});
|
||||
|
||||
test("auth login 模式专属参数不能脱离对应模式", async () => {
|
||||
const openApiFlagOnly = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--access-key-id",
|
||||
"LTAI-e2e",
|
||||
]);
|
||||
expect(openApiFlagOnly.exitCode).toBe(2);
|
||||
expect(openApiFlagOnly.stderr).toMatch(/Use --open-api with --access-key-id/);
|
||||
|
||||
const baseUrlWithoutApiKey = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--console",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com",
|
||||
]);
|
||||
expect(baseUrlWithoutApiKey.exitCode).toBe(2);
|
||||
expect(baseUrlWithoutApiKey.stderr).toMatch(/Use --base-url only with --api-key/);
|
||||
|
||||
const consoleSiteWithoutConsole = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--api-key",
|
||||
"sk-e2e-placeholder",
|
||||
"--console-site",
|
||||
"international",
|
||||
]);
|
||||
expect(consoleSiteWithoutConsole.exitCode).toBe(2);
|
||||
expect(consoleSiteWithoutConsole.stderr).toMatch(/Use --console-site only with --console/);
|
||||
});
|
||||
|
||||
test("auth login --open-api 要求 AK/SK 成对输入", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--open-api",
|
||||
"--access-key-id",
|
||||
"LTAI-e2e",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Provide --access-key-id and --access-key-secret with --open-api/);
|
||||
});
|
||||
|
||||
test("auth login --open-api --dry-run 使用 placeholder 时不请求服务端、不写配置", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-auth-openapi-dry-run-"));
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
[
|
||||
"auth",
|
||||
"login",
|
||||
"--open-api",
|
||||
"--access-key-id",
|
||||
"LTAI-e2e-placeholder",
|
||||
"--access-key-secret",
|
||||
"secret-e2e-placeholder",
|
||||
"--dry-run",
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
},
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Would save OpenAPI AK/SK credentials");
|
||||
expect(existsSync(join(configDir, "config.json"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("auth logout --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/logout|dry-run|yes/i);
|
||||
});
|
||||
|
||||
test("auth status --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/status|output/i);
|
||||
});
|
||||
|
||||
test("auth login 缺少 --api-key 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "login", "--quiet"]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/Choose exactly one login mode/);
|
||||
});
|
||||
|
||||
test("auth login --dry-run --api-key 不发起校验与落盘", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--dry-run",
|
||||
"--api-key",
|
||||
"sk-e2e-dry-run-placeholder",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Would validate and save API key.");
|
||||
});
|
||||
|
||||
test("auth login --dry-run 仍校验显式 Base URL", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--dry-run",
|
||||
"--api-key",
|
||||
"sk-e2e-dry-run-placeholder",
|
||||
"--base-url",
|
||||
"ftp://example.com/models",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid model base URL/);
|
||||
});
|
||||
|
||||
test("auth login --api-key 验证后原子保存凭证和 Base URL", async () => {
|
||||
const validationServer = await startValidationServer();
|
||||
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),
|
||||
body: {
|
||||
model: "qwen3.7-max",
|
||||
stream: false,
|
||||
enable_thinking: false,
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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 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-preview",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_image_to_video_model: "happyhorse-1.1-i2v",
|
||||
default_reference_to_video_model: "happyhorse-1.1-r2v",
|
||||
default_image_model: "wan2.7-image",
|
||||
});
|
||||
} finally {
|
||||
await validationServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth login --config token-plan 物化并重置内置预设", async () => {
|
||||
const validationServer = await startValidationServer();
|
||||
const configDir = makeE2eOutputDir("auth-token-plan-preset-login");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"token-plan": {
|
||||
default_text_model: "custom-text-model",
|
||||
default_video_model: "custom-video-model",
|
||||
default_image_to_video_model: "custom-image-to-video-model",
|
||||
default_reference_to_video_model: "custom-reference-to-video-model",
|
||||
default_image_model: "custom-image-model",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
try {
|
||||
const login = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "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),
|
||||
body: {
|
||||
model: "qwen3.8-max-preview",
|
||||
stream: false,
|
||||
enable_thinking: true,
|
||||
},
|
||||
});
|
||||
|
||||
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-preview",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_image_to_video_model: "happyhorse-1.1-i2v",
|
||||
default_reference_to_video_model: "happyhorse-1.1-r2v",
|
||||
default_image_model: "wan2.7-image",
|
||||
});
|
||||
expect((config["token-plan"] as Record<string, unknown>).base_url).not.toBe(
|
||||
validationServer.baseUrl,
|
||||
);
|
||||
expect((config["token-plan"] as Record<string, unknown>).api_key).not.toBe(
|
||||
"sk-env-must-not-be-persisted",
|
||||
);
|
||||
} finally {
|
||||
await validationServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth login 未传 --config 时写当前激活 Config", async () => {
|
||||
const validationServer = await startValidationServer();
|
||||
const configDir = makeE2eOutputDir("auth-active-profile-login");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
active_config: "dev",
|
||||
dev: { base_url: validationServer.baseUrl },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
const env = {
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
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);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth login --api-key 验证失败不留下半配置", async () => {
|
||||
const validationServer = await startValidationServer(400);
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--dry-run",
|
||||
"--api-key",
|
||||
"sk-e2e-dry-run-placeholder",
|
||||
"--output",
|
||||
"json",
|
||||
"--timeout",
|
||||
"120",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Would validate and save API key.");
|
||||
});
|
||||
|
||||
test("auth login 缺少密钥且 --output json 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
const err = JSON.parse(stderr.trim()) as { error?: { code?: number; message?: string } };
|
||||
expect(err.error?.code).toBe(2);
|
||||
expect(err.error?.message).toMatch(/Choose exactly one login mode/);
|
||||
});
|
||||
|
||||
test("auth logout --dry-run 不写入配置", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"logout",
|
||||
"--dry-run",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No changes made.");
|
||||
expect(stderr).not.toContain("Cleared api_key");
|
||||
});
|
||||
|
||||
test("auth logout --dry-run --quiet", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"logout",
|
||||
"--dry-run",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No changes made.");
|
||||
});
|
||||
|
||||
test("auth logout --dry-run --output json(不清除密钥)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"logout",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No changes made.");
|
||||
expect(stderr).not.toContain("Cleared api_key");
|
||||
});
|
||||
|
||||
test("auth logout 清除当前 Config 的全部凭证和 Base URL,保留普通配置", async () => {
|
||||
const configDir = makeE2eOutputDir("auth-logout-all");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
api_key: "sk-e2e-placeholder",
|
||||
base_url: "https://model.example.com",
|
||||
access_token: "console-token-placeholder",
|
||||
access_key_id: "LTAI-e2e-placeholder",
|
||||
access_key_secret: "secret-e2e-placeholder",
|
||||
security_token: "sts-e2e-placeholder",
|
||||
output: "json",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "logout"], {
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
});
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("api_key / base_url / access_token");
|
||||
|
||||
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(config).toEqual({ output: "json" });
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"status",
|
||||
"--output",
|
||||
"text",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toMatch(
|
||||
/Authentication Status|API key:|Console token:|DashScope API:|Console gateway:/,
|
||||
);
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())("auth status --output json", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"status",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
authenticated?: boolean;
|
||||
api_key?: { source?: string; masked?: string; base_url?: string };
|
||||
}>(stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.api_key?.source).toBeDefined();
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())(
|
||||
"auth status --output json --quiet(base_url 经 env 指定;凭证域 flag 对 status 不可见)",
|
||||
async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
["auth", "status", "--output", "json", "--quiet"],
|
||||
{ DASHSCOPE_BASE_URL: "https://dashscope.aliyuncs.com" },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ authenticated?: boolean; api_key?: unknown }>(stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.api_key).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
test("auth status 不接受凭证域覆盖 flag(--base-url 报 Unknown flag)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"status",
|
||||
"--base-url",
|
||||
"https://x.test",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/Unknown flag.*--base-url/);
|
||||
});
|
||||
|
||||
test("auth status 展示 env OpenAPI AK/SK 且不接受 OpenAPI flag 覆盖", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
["auth", "status", "--output", "json"],
|
||||
{
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-e2e-placeholder",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-e2e-placeholder",
|
||||
},
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
authenticated?: boolean;
|
||||
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
|
||||
}>(stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.openapi?.source).toBe("env");
|
||||
expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-placeholder");
|
||||
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-placeholder");
|
||||
|
||||
const denied = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--access-key-id", "ak"]);
|
||||
expect(denied.exitCode).not.toBe(0);
|
||||
expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/);
|
||||
});
|
||||
|
||||
test.skipIf(!isOpenApiE2EReady())(
|
||||
"auth login --open-api 使用环境中的真实 AK/SK,持久化后支持单独 logout",
|
||||
async () => {
|
||||
const accessKeyId = process.env.ALIBABA_CLOUD_ACCESS_KEY_ID!.trim();
|
||||
const accessKeySecret = process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET!.trim();
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-auth-openapi-login-"));
|
||||
const env = {
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
};
|
||||
|
||||
try {
|
||||
const login = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
[
|
||||
"auth",
|
||||
"login",
|
||||
"--open-api",
|
||||
"--access-key-id",
|
||||
accessKeyId,
|
||||
"--access-key-secret",
|
||||
accessKeySecret,
|
||||
],
|
||||
env,
|
||||
);
|
||||
expect(login.exitCode, login.stderr).toBe(0);
|
||||
expect(login.stderr).toMatch(/OpenAPI credentials saved/);
|
||||
|
||||
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
// 只断言布尔结果,避免失败 diff 把真实凭证打印到测试日志。
|
||||
expect(config.access_key_id === accessKeyId).toBe(true);
|
||||
expect(config.access_key_secret === accessKeySecret).toBe(true);
|
||||
expect(config.openapi_access_key_id).toBeUndefined();
|
||||
expect(config.openapi_access_key_secret).toBeUndefined();
|
||||
|
||||
const status = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
["auth", "status", "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(status.exitCode, status.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
authenticated?: boolean;
|
||||
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
|
||||
}>(status.stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.openapi?.source).toBe("config");
|
||||
expect(data.openapi?.access_key_id === accessKeyId).toBe(false);
|
||||
expect(data.openapi?.access_key_secret === accessKeySecret).toBe(false);
|
||||
|
||||
const logout = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--open-api"], env);
|
||||
expect(logout.exitCode, logout.stderr).toBe(0);
|
||||
expect(logout.stderr).toMatch(/Cleared access_key_id/);
|
||||
|
||||
const after = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
|
||||
expect(after.exitCode, after.stderr).toBe(0);
|
||||
const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(
|
||||
after.stdout,
|
||||
);
|
||||
expect(afterData.openapi).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { CONFIG_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
/**
|
||||
* Config 相关 E2E
|
||||
*/
|
||||
|
||||
describe("e2e: config", () => {
|
||||
test("config show --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "show", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/show|config/i);
|
||||
});
|
||||
|
||||
test("config set --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/set|--key|--value/i);
|
||||
});
|
||||
|
||||
test("config list/use --help 正常退出", async () => {
|
||||
const listResult = await runCommandE2e(CONFIG_ROUTES, ["config", "list", "--help"]);
|
||||
expect(listResult.exitCode, listResult.stderr).toBe(0);
|
||||
expect(listResult.stderr).toMatch(/list|active|profile/i);
|
||||
|
||||
const useResult = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--help"]);
|
||||
expect(useResult.exitCode, useResult.stderr).toBe(0);
|
||||
expect(useResult.stderr).toMatch(/use|--name|active/i);
|
||||
});
|
||||
|
||||
test("config ui --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "ui", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/ui|--port|--no-open|web/i);
|
||||
});
|
||||
|
||||
test("config ui --dry-run 打印计划不起服务", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"ui",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ host?: string; routes?: string[] }>(stdout);
|
||||
expect(data.host).toBe("127.0.0.1");
|
||||
expect(Array.isArray(data.routes)).toBe(true);
|
||||
});
|
||||
|
||||
test("config show --output json", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"show",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
config_file?: string;
|
||||
base_url?: string;
|
||||
timeout?: number;
|
||||
}>(stdout);
|
||||
expect(data.config_file).toBeDefined();
|
||||
expect(data.base_url).toBeDefined();
|
||||
expect(data.timeout).toBeDefined();
|
||||
});
|
||||
|
||||
test("config show --output text", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"show",
|
||||
"--output",
|
||||
"text",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toMatch(/config_file|timeout|base_url/i);
|
||||
});
|
||||
|
||||
test("config show 脱敏 security_token", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-show-secret-"));
|
||||
try {
|
||||
const securityToken = "sts-sensitive-token";
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify({ security_token: securityToken }, null, 2) + "\n",
|
||||
);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "show", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ security_token?: string }>(stdout);
|
||||
expect(data.security_token).toBe("sts-...oken");
|
||||
expect(stdout).not.toContain(securityToken);
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/--key|--value|Usage:/i);
|
||||
});
|
||||
|
||||
test("config use 缺少 --name 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--quiet"]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/--name|Usage:/i);
|
||||
});
|
||||
|
||||
test("config use 持久化激活项,config list 展示激活状态", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
|
||||
const env = { BAILIAN_CONFIG_DIR: configDir };
|
||||
|
||||
const useResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "use", "--name", "dev", "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(useResult.exitCode, useResult.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ active_config?: string }>(useResult.stdout).active_config).toBe(
|
||||
"dev",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("dev");
|
||||
|
||||
const listResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "list", "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(listResult.exitCode, listResult.stderr).toBe(0);
|
||||
const listData = parseStdoutJson<{
|
||||
active_config?: string;
|
||||
profiles?: string[];
|
||||
}>(listResult.stdout);
|
||||
expect(listData.active_config).toBe("dev");
|
||||
expect(listData.profiles).toEqual(["default", "dev"]);
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config use --dry-run 校验目标但不修改激活项", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-dry-run-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
|
||||
const result = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "use", "--name", "dev", "--dry-run", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ would_activate?: string }>(result.stdout).would_activate).toBe(
|
||||
"dev",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config use 拒绝不存在的 Profile 且不写入状态", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-missing-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ output: "text" }, null, 2) + "\n");
|
||||
const result = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "use", "--name", "missing", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stderr).toMatch(/does not exist/);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config set 非法 key 时退出为用法错误", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"not-a-real-key",
|
||||
"--value",
|
||||
"x",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid config key|not-a-real-key/i);
|
||||
});
|
||||
|
||||
test("config set 非法 output", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"output",
|
||||
"--value",
|
||||
"yaml",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid output|text, json/i);
|
||||
});
|
||||
|
||||
test("config set 非法 timeout", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"timeout",
|
||||
"--value",
|
||||
"0",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid timeout|positive/i);
|
||||
});
|
||||
|
||||
test("config set 归一化 Base URL 并拒绝非法协议", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-base-url-"));
|
||||
try {
|
||||
const setResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
[
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"base_url",
|
||||
"--value",
|
||||
"https://proxy.example.com/bailian/compatible-mode/v1/?x=1#fragment",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(setResult.exitCode, setResult.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe(
|
||||
"https://proxy.example.com",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe(
|
||||
"https://proxy.example.com",
|
||||
);
|
||||
|
||||
const invalidResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "set", "--key", "base_url", "--value", "ftp://example.com/models"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(invalidResult.exitCode).toBe(2);
|
||||
expect(invalidResult.stderr).toMatch(/Invalid model base URL/);
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config set --dry-run 不落盘(仅输出 would_set)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"output",
|
||||
"--value",
|
||||
"json",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { output?: string } }>(stdout);
|
||||
expect(data.would_set?.output).toBe("json");
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持连字符别名 key", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"default-text-model",
|
||||
"--value",
|
||||
"qwen3.7-max",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout);
|
||||
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持图生视频默认模型别名", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"default-image-to-video-model",
|
||||
"--value",
|
||||
"happyhorse-1.1-i2v",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
would_set?: { default_image_to_video_model?: string };
|
||||
}>(stdout);
|
||||
expect(data.would_set?.default_image_to_video_model).toBe("happyhorse-1.1-i2v");
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持参考生视频默认模型别名", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"default-reference-to-video-model",
|
||||
"--value",
|
||||
"happyhorse-1.1-r2v",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
would_set?: { default_reference_to_video_model?: string };
|
||||
}>(stdout);
|
||||
expect(data.would_set?.default_reference_to_video_model).toBe("happyhorse-1.1-r2v");
|
||||
});
|
||||
|
||||
test("config set --dry-run 展示归一化后的 Base URL", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"base-url",
|
||||
"--value",
|
||||
"https://proxy.example.com/apps/anthropic/?x=1#fragment",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { base_url?: string } }>(stdout);
|
||||
expect(data.would_set?.base_url).toBe("https://proxy.example.com");
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持 AccessKey 短字段别名", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"access-key-id",
|
||||
"--value",
|
||||
"LTAI-config-placeholder",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { access_key_id?: string } }>(stdout);
|
||||
expect(data.would_set?.access_key_id).toBe("LTAI-config-placeholder");
|
||||
});
|
||||
|
||||
test("config set 不接受旧 OpenAPI AccessKey 字段名", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"openapi_access_key_id",
|
||||
"--value",
|
||||
"LTAI-config-placeholder",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid config key|openapi_access_key_id/);
|
||||
});
|
||||
|
||||
test("config agent --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "agent", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/agent|--base-url|--model/i);
|
||||
});
|
||||
|
||||
test("config agent 缺少 --api-key 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"claude-code",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--api-key|Usage:/i);
|
||||
});
|
||||
|
||||
test("config agent 非法 --agent 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"not-an-agent",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"--api-key",
|
||||
"sk-placeholder",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/not-an-agent|claude-code|agent/i);
|
||||
});
|
||||
|
||||
test("config agent --dry-run 输出脱敏信息且不写盘", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-dry-"));
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
[
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"claude-code",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
"--api-key",
|
||||
"sk-secret-placeholder",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
agent?: string;
|
||||
base_url?: string;
|
||||
model?: string;
|
||||
api_key?: string;
|
||||
}>(stdout);
|
||||
expect(data.agent).toBe("claude-code");
|
||||
expect(data.base_url).toBe("https://dashscope.aliyuncs.com/apps/anthropic");
|
||||
expect(data.model).toBe("qwen3-max");
|
||||
expect(stdout).not.toContain("sk-secret-placeholder");
|
||||
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent codex 写入 config.toml 与 auth.json(cc-switch 对齐结构)", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-codex-"));
|
||||
try {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
[
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"codex",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"--api-key",
|
||||
"sk-codex-placeholder",
|
||||
"--model",
|
||||
"qwen3-coder-plus",
|
||||
],
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
|
||||
expect(toml).toContain('model_provider = "bailian-cli"');
|
||||
expect(toml).toContain("requires_openai_auth = true");
|
||||
expect(toml).toContain('wire_api = "responses"');
|
||||
const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8"));
|
||||
expect(auth.OPENAI_API_KEY).toBe("sk-codex-placeholder");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent hermes 写入 custom_providers 结构", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-hermes-"));
|
||||
try {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
[
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"hermes",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"--api-key",
|
||||
"sk-hermes-placeholder",
|
||||
"--model",
|
||||
"qwen3-coder-plus",
|
||||
],
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8");
|
||||
expect(yamlText).toContain("custom_providers");
|
||||
expect(yamlText).toContain("bailian-cli");
|
||||
expect(yamlText).toContain("api_mode: chat_completions");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+18
-55
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { CONSOLE_FLAGS_DRY_RUN_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
type ConsoleDryRunMeta = {
|
||||
consoleRegion?: string;
|
||||
@@ -7,55 +8,13 @@ type ConsoleDryRunMeta = {
|
||||
consoleSwitchAgent?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* E2E for global console flags (`--console-region`, `--console-site`,
|
||||
* `--console-switch-agent`) and DashScope `--base-url`.
|
||||
*/
|
||||
|
||||
describe("e2e: console global flags", () => {
|
||||
test("根帮助展示 --base-url 与 console 全局标志", async () => {
|
||||
const { stderr, exitCode } = await runCli(["--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--base-url/);
|
||||
expect(stderr).toMatch(/--console-region/);
|
||||
expect(stderr).toMatch(/--console-site/);
|
||||
expect(stderr).toMatch(/--console-switch-agent/);
|
||||
expect(stderr).not.toMatch(/^\s*--region\s/m);
|
||||
});
|
||||
|
||||
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/Global Flags:/);
|
||||
expect(stderr).toMatch(/--console-region <region>/);
|
||||
expect(stderr).toMatch(/--model <model>/);
|
||||
expect(stderr).toMatch(/--period <minutes>/);
|
||||
expect(stderr).toMatch(/--output <format>/);
|
||||
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
|
||||
});
|
||||
|
||||
test("跨域 flag 拒绝:model 命令传 --console-region 报 Unknown flag", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"text",
|
||||
"chat",
|
||||
"--message",
|
||||
"hi",
|
||||
"--console-region",
|
||||
"cn-hangzhou",
|
||||
"--dry-run",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/Unknown flag.*--console-region/);
|
||||
});
|
||||
|
||||
test("跨域 flag 拒绝:console 命令传 --api-key 报 Unknown flag", async () => {
|
||||
const { stderr, exitCode } = await runCli(["mcp", "list", "--api-key", "sk-test", "--dry-run"]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/Unknown flag.*--api-key/);
|
||||
});
|
||||
|
||||
describe("e2e: console global flags (dry-run)", () => {
|
||||
test("console call --help 不暴露命令级 region/site,示例使用 --console-region", async () => {
|
||||
const { stderr, exitCode } = await runCli(["console", "call", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"console",
|
||||
"call",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--api <api>/);
|
||||
expect(stderr).toMatch(/--data <json>/);
|
||||
@@ -65,7 +24,11 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("auth login --help:自有 flag 含 --console-site,不含其余 console 域 flag", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--api-key <key>/);
|
||||
expect(stderr).toMatch(/--base-url <url>/);
|
||||
@@ -75,7 +38,7 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("console call --dry-run 默认 consoleRegion 为 cn-beijing", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"console",
|
||||
"call",
|
||||
"--api",
|
||||
@@ -93,7 +56,7 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("console call --dry-run --console-region / --console-site / --console-switch-agent 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"console",
|
||||
"call",
|
||||
"--api",
|
||||
@@ -118,7 +81,7 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("console call 拒绝未知全局 flag --region", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"console",
|
||||
"call",
|
||||
"--api",
|
||||
@@ -134,7 +97,7 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("mcp list --dry-run --console-region 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"mcp",
|
||||
"list",
|
||||
"--dry-run",
|
||||
@@ -149,7 +112,7 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("quota check --dry-run --console-region 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"quota",
|
||||
"check",
|
||||
"--dry-run",
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user