mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8652f350d | |||
| b1a0c0005d | |||
| f597b94c46 | |||
| 750641dd0e | |||
| 11ed19723a | |||
| 848e44eb44 | |||
| 07875c309e | |||
| 7d05649f8d | |||
| fd9bba77a9 | |||
| 0593c7eb28 | |||
| c303b51b9e | |||
| 780ca6addb | |||
| 3375fca2f8 | |||
| a966b4077f | |||
| 9c8fe96a1f | |||
| 18d5c420df | |||
| 9ad85b6278 | |||
| 82bdf9ed78 | |||
| 4383eeb416 | |||
| 46d8474ec1 | |||
| 1851ec85f0 | |||
| 0797b0767f | |||
| 17f4454df4 | |||
| e67615eabd | |||
| 39513200bc | |||
| 7b5bb1c341 | |||
| e0a7c86f05 | |||
| 908439e3f9 | |||
| 61689ec0da | |||
| ba78d13a52 | |||
| 24abdbf450 | |||
| cbffe6c541 | |||
| d567d6af0c | |||
| 33b1df01cc | |||
| 0ba705f194 | |||
| 23d409f9ff | |||
| 70ccc6a447 | |||
| b7fba7679e | |||
| 88e5b903bc | |||
| ba1661356f | |||
| 60c49ec1ac | |||
| 07c71412cf | |||
| e2c4935e84 | |||
| 4f10b7f50c | |||
| dc5a535bf3 | |||
| ad236e9b11 | |||
| 14105547e8 | |||
| d971a04fb8 | |||
| 1590e69d67 | |||
| fd36db5cab | |||
| d74686f09f | |||
| 30a0bbbc87 | |||
| 9761932b4c | |||
| c0d30fee3d | |||
| 9cbd4aab85 | |||
| 19c4f5f2ab | |||
| af524e5487 | |||
| 4e025dda8d | |||
| 1ffcbdd80c | |||
| 8aedeca4ac | |||
| f5a7dd494b | |||
| 07dafc0fd4 | |||
| 0cd0daa18d | |||
| 5e39d1abc3 | |||
| c3df659ef0 | |||
| 1d803bb4b9 | |||
| 847b291ccc | |||
| 0ceb15b0be | |||
| dc3c02f68c | |||
| f1eeeff682 | |||
| f184c60357 | |||
| 8906af8ad1 | |||
| 3b705f2b0d | |||
| 2260c51c7e | |||
| 8c893a59ef | |||
| 611ecc1e68 | |||
| 17c1f5f7b2 | |||
| 7593baf4f6 | |||
| edb34658a1 | |||
| cbeb2bf071 | |||
| 1fd08fe1c8 | |||
| 8cb24b719b | |||
| d4c0809951 | |||
| b01d35c246 | |||
| 67ff4d2811 | |||
| a830965feb | |||
| 14583cf6d1 | |||
| c32f03e8dc | |||
| e9feb380f5 | |||
| 62ad8768e3 | |||
| e8a40cdad0 | |||
| 9bcb66b1ff | |||
| 60cad1001c | |||
| a5d078b8a0 | |||
| 685c0176ff | |||
| d24b41d452 | |||
| d320d36ba7 | |||
| f816d5cb1f | |||
| 93cecc35de | |||
| a5d055f45a | |||
| 631a9c1818 | |||
| 682321247b | |||
| d82f334a99 | |||
| f7c18276bc | |||
| c54f6a64d7 | |||
| 73143dbae2 | |||
| cb25bc4149 | |||
| 35d681f0c7 | |||
| a16afb3f0b | |||
| 2ed513124e | |||
| 062bbd4052 | |||
| f847476016 | |||
| 83ea0dfd03 | |||
| 9a5797da1e | |||
| 8c398bae57 | |||
| 14fc293ef6 | |||
| 2bcbf56282 | |||
| ce64d628bb | |||
| 3ca8da8e75 | |||
| 6c4ac80882 | |||
| bd4644448a | |||
| a0ab35acf1 | |||
| 3f29f93ef5 | |||
| e5abd1b554 | |||
| 4749b493da | |||
| 7a65fb850c | |||
| f45b19c261 | |||
| 270412d146 | |||
| 36a60a2848 | |||
| 6c716e5120 | |||
| 2182a2239f | |||
| 51d9833a3d | |||
| e68abb6975 | |||
| 067e96689a |
@@ -3,6 +3,13 @@ name: Publish
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Which package set to publish"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- bailian-cli
|
||||
- knowledge-studio-cli
|
||||
mode:
|
||||
description: "Publish mode"
|
||||
required: true
|
||||
@@ -16,13 +23,13 @@ on:
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: publish-${{ inputs.mode }}-${{ inputs.channel }}
|
||||
group: publish-${{ inputs.package }}-${{ inputs.mode }}-${{ inputs.channel }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-stable:
|
||||
if: inputs.mode == 'stable'
|
||||
name: publish stable to npm + tag
|
||||
name: publish stable (${{ inputs.package }}) to npm + tag
|
||||
runs-on: ubuntu-latest
|
||||
environment: production # Required Reviewers gate
|
||||
permissions:
|
||||
@@ -51,11 +58,11 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: publish-stable
|
||||
run: node tools/release/publish-stable.mjs
|
||||
run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }}
|
||||
|
||||
publish-channel:
|
||||
if: inputs.mode == 'channel'
|
||||
name: publish beta to npm
|
||||
name: publish channel (${{ inputs.package }}) to npm
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # no tag, no Release; just publish
|
||||
@@ -83,4 +90,4 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: publish-channel
|
||||
run: node tools/release/publish-channel.mjs --channel "${{ inputs.channel }}"
|
||||
run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}"
|
||||
|
||||
@@ -12,6 +12,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
tools/generated
|
||||
.node-version
|
||||
|
||||
*.local
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -93,6 +93,18 @@ CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的
|
||||
|
||||
不要扮演服务端错误的翻译官——我们没有最新的错误码体系认知,二次包装只会撒谎(详见 `docs/agents/error-hint-change.md` 中的反面 case)。
|
||||
|
||||
### 4. Console Gateway 命令必须声明 console 全局 flags
|
||||
|
||||
如果新命令使用了 `callConsoleGateway`,必须在 `options` 中添加以下三个全局 flag 的说明,以便 `--help` 中展示:
|
||||
|
||||
```ts
|
||||
{ flag: "--console-region <region>", description: "Console region" },
|
||||
{ flag: "--console-site <site>", description: "Console site: domestic, international" },
|
||||
{ flag: "--console-switch-agent <uid>", description: "Switch agent UID", type: "number" },
|
||||
```
|
||||
|
||||
这些 flag 已在 `GLOBAL_OPTIONS`(`packages/core/src/types/command.ts`)中注册,由 `loadConfig` 写入 `config.consoleRegion` / `config.consoleSite` / `config.consoleSwitchAgent`,`callConsoleGateway` 自动读取——命令无需手动提取或传递。
|
||||
|
||||
## 完成改动后的快速验证
|
||||
|
||||
```sh
|
||||
|
||||
+80
-1
@@ -2,10 +2,89 @@
|
||||
|
||||
All notable changes to `bailian-cli` and `bailian-cli-core` are documented here.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The two packages share a single version number — they are always released together.
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The `bailian-cli`, `bailian-cli-core`, `bailian-cli-runtime`, and `bailian-cli-commands` packages share a single version number — they are always released together.
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.5.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Model fine-tuning — `bl finetune`: create, list, get, watch, and cancel jobs; fetch training logs; list checkpoints; export a checkpoint as a deployable model; and query training capability (by model or by training type). Supports `sft`, `sft-lora`, `dpo`, `dpo-lora`, and `cpt` training types.
|
||||
- Model deployment — `bl deploy`: create, list, get, update (rate limits), scale, and delete deployments; list deployable models and plans.
|
||||
- Dataset management — `bl dataset`: upload, list, get, and delete dataset files, plus `bl dataset validate` to check a local `.jsonl` before uploading (ChatML / DPO / CPT formats).
|
||||
- Token Plan management — `bl token-plan`: list subscription seats, add members, batch-assign seats, and create a per-seat API key.
|
||||
- Automatic update check: after a command finishes, the CLI checks npm for a newer release (throttled) and shows an `Update available` hint; a major stable-version gap upgrades itself automatically. Skipped with `--quiet` or when running `bl update`.
|
||||
- Composable packages: `bailian-cli-runtime` (CLI framework) and `bailian-cli-commands` (command library) are now published alongside `bailian-cli-core`, and a new sibling CLI `knowledge-studio-cli` (`kscli`) ships on top of them. `bl` behavior is unchanged.
|
||||
|
||||
### Removed
|
||||
|
||||
- `bl config export-schema` (exported CLI commands as Anthropic/OpenAI-compatible JSON tool schemas) has been removed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Console gateway commands (`bl console call`, etc.) now surface a readable message when the gateway returns a non-string `errorCode`, instead of `[object Object]`.
|
||||
|
||||
## [1.4.2] - 2026-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- `bl omni --list-voices` prints the built-in output voices (ID, name, description, language) and exits without needing an API key. The built-in voice table is expanded from 6 to 17 voices, including dialect voices such as Dylan, Sunny, and Kiki.
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl omni` default `--voice` is now `Tina` (previously `Cherry`). The `--voice` help points at `--list-voices` instead of listing every option inline.
|
||||
- `bl speech synthesize --list-voices` and its missing-`--voice` hint now include a link to the official CosyVoice voice documentation.
|
||||
- Agent skill setup guidance now covers console site selection (`--console-site domestic` / `international`) for console login and gateway commands.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `bl speech synthesize` corrects the `cosyvoice-v3-flash` built-in voice ID from `longanhuan` to `longanhuan_v3`.
|
||||
|
||||
## [1.4.1] - 2026-06-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Video generation now defaults to the upgraded HappyHorse 1.1 model for better quality. The 1.0 models are still available via `--model`.
|
||||
- `bl update` now keeps the agent skill in sync across all your agent apps (Claude Code, Cursor, etc.), and refreshes it even when the CLI is already up to date.
|
||||
|
||||
## [1.4.0] - 2026-06-17
|
||||
|
||||
### Added
|
||||
|
||||
- Console gateway now supports multiple regions and sites: `cn-beijing` and `ap-southeast-1`, each with domestic and international variants, plus `switchAgent` for delegated access.
|
||||
- New global flags `--console-region`, `--console-site`, and `--console-switch-agent`; `bl console call` also gains `--site` and `--switch-agent`.
|
||||
- `bl auth login --base-url <url>` to specify the base URL when logging in with an API key.
|
||||
- `bl omni` gains a `--voice` option (Chelsie, Cherry, Ethan, Serena, Sunny, Tina; default Cherry).
|
||||
|
||||
### Changed
|
||||
|
||||
- All user-facing CLI text is now standardized to English.
|
||||
- `bl advisor recommend` internal intent/ranking model upgraded from `qwen-turbo` to `qwen-flash`.
|
||||
- Cleaner JSON output for `usage`, `quota`, and `workspace` commands.
|
||||
- `base_url` from the config file now takes priority over the `DASHSCOPE_BASE_URL` environment variable.
|
||||
- `bl config show` now displays all fields from `config.json`, with sensitive values masked.
|
||||
|
||||
### Removed
|
||||
|
||||
- The legacy `region` config field and its related options.
|
||||
- Invalid leftover code for the removed `model list` command.
|
||||
|
||||
### Fixed
|
||||
|
||||
- When the console session is not logged in or has expired, the CLI now shows a clear sign-in prompt instead of a generic gateway error.
|
||||
- Corrected `--resolution` / `--ratio` / `--duration` flag descriptions for `bl video` commands.
|
||||
|
||||
## [1.3.3] - 2026-06-16
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl knowledge retrieve --help` now clearly indicates that `--api-key` is the recommended authentication method; AK/SK flags are explicitly marked as deprecated with guidance to use `--api-key` instead.
|
||||
|
||||
### Added
|
||||
|
||||
- `notes` field for command definitions — commands can now include contextual notes (auth requirements, deprecation notices, etc.) that are displayed in both `--help` output and the generated reference docs.
|
||||
|
||||
## [1.3.2] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
+80
-1
@@ -2,10 +2,89 @@
|
||||
|
||||
`bailian-cli` 和 `bailian-cli-core` 的所有重要变更都记录在此。
|
||||
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。两个包共享一个版本号,总是一起发布。
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。`bailian-cli`、`bailian-cli-core`、`bailian-cli-runtime`、`bailian-cli-commands` 共享一个版本号,总是一起发布。
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.5.0] - 2026-07-01
|
||||
|
||||
### 新增
|
||||
|
||||
- 模型精调 —— `bl finetune`:创建、列出、查询、观察、取消训练任务;拉取训练日志;列出 checkpoint;将 checkpoint 导出为可部署模型;查询训练能力(按模型或按训练类型)。支持 `sft`、`sft-lora`、`dpo`、`dpo-lora`、`cpt` 训练类型。
|
||||
- 模型部署 —— `bl deploy`:创建、列出、查询、更新(限流)、扩缩容、删除部署;列出可部署模型与套餐。
|
||||
- 数据集管理 —— `bl dataset`:上传、列出、查询、删除数据集文件,并新增 `bl dataset validate` 在上传前本地校验 `.jsonl`(ChatML / DPO / CPT 格式)。
|
||||
- Token Plan 管理 —— `bl token-plan`:列出订阅座位、添加成员、批量分配座位、为座位创建 API Key。
|
||||
- 自动更新检查:命令执行完成后,CLI 会(节流地)检查 npm 上是否有新版本并提示 `Update available`;若与稳定版存在大版本差距则自动升级。`--quiet` 或执行 `bl update` 时跳过。
|
||||
- 可组合包:`bailian-cli-runtime`(CLI 框架)与 `bailian-cli-commands`(命令库)现在与 `bailian-cli-core` 一起发布,并在其之上新增了同家族 CLI `knowledge-studio-cli`(`kscli`)。`bl` 行为保持不变。
|
||||
|
||||
### 已移除
|
||||
|
||||
- 移除 `bl config export-schema` 命令(原用于把 CLI 命令导出为 Anthropic/OpenAI 兼容的 JSON tool schema)。
|
||||
|
||||
### 修复
|
||||
|
||||
- 控制台网关类命令(`bl console call` 等)在网关返回非字符串 `errorCode` 时,现在会给出可读的错误信息,而不是 `[object Object]`。
|
||||
|
||||
## [1.4.2] - 2026-06-24
|
||||
|
||||
### 新增
|
||||
|
||||
- `bl omni --list-voices` 无需 API key 即可打印内置输出音色列表(ID、名称、描述、语言)并退出。内置音色表从 6 个扩展到 17 个,新增 Dylan、Sunny、Kiki 等方言音色。
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl omni` 默认 `--voice` 改为 `Tina`(原为 `Cherry`)。`--voice` 帮助文案改为指向 `--list-voices`,不再内联列出全部音色。
|
||||
- `bl speech synthesize --list-voices` 输出及缺少 `--voice` 时的提示中,新增官方 CosyVoice 音色文档链接。
|
||||
- Agent skill 配置指引新增 console 站点选择说明(`--console-site domestic` / `international`),适用于 console 登录与网关类命令。
|
||||
|
||||
### 修复
|
||||
|
||||
- `bl speech synthesize` 修正 `cosyvoice-v3-flash` 内置音色 ID,由 `longanhuan` 改为 `longanhuan_v3`。
|
||||
|
||||
## [1.4.1] - 2026-06-22
|
||||
|
||||
### 变更
|
||||
|
||||
- 视频生成默认升级到 HappyHorse 1.1 模型,画面质量更佳。如需使用 1.0 模型,可通过 `--model` 指定。
|
||||
- `bl update` 现在会把 agent skill 同步更新到所有 agent 应用(Claude Code、Cursor 等),即使 CLI 已是最新版本也会刷新 skill。
|
||||
|
||||
## [1.4.0] - 2026-06-17
|
||||
|
||||
### 新增
|
||||
|
||||
- 控制台网关支持多 region 与多站点:`cn-beijing` 与 `ap-southeast-1`,各含国内站 / 国际站变体,并新增 `switchAgent` 委托访问。
|
||||
- 新增全局标志 `--console-region`、`--console-site`、`--console-switch-agent`;`bl console call` 另外新增 `--site` 与 `--switch-agent`。
|
||||
- `bl auth login --base-url <url>`:使用 API Key 登录时可指定 base URL。
|
||||
- `bl omni` 新增 `--voice` 选项(Chelsie、Cherry、Ethan、Serena、Sunny、Tina,默认 Cherry)。
|
||||
|
||||
### 变更
|
||||
|
||||
- 所有面向用户的 CLI 文案统一为英文。
|
||||
- `bl advisor recommend` 内部意图 / 排序模型由 `qwen-turbo` 升级为 `qwen-flash`。
|
||||
- 优化 `usage`、`quota`、`workspace` 命令的 JSON 输出。
|
||||
- 配置文件中的 `base_url` 现在优先级高于环境变量 `DASHSCOPE_BASE_URL`。
|
||||
- `bl config show` 现在展示 `config.json` 中的全部字段(敏感值已脱敏)。
|
||||
|
||||
### 移除
|
||||
|
||||
- 移除遗留的 `region` 配置字段及其相关选项。
|
||||
- 清理 `model list` 命令移除后遗留的无效代码。
|
||||
|
||||
### 修复
|
||||
|
||||
- 当控制台会话未登录或已过期时,CLI 现在会给出明确的登录提示,不再是笼统的网关错误。
|
||||
- 修正 `bl video` 命令 `--resolution` / `--ratio` / `--duration` 的帮助文案。
|
||||
|
||||
## [1.3.3] - 2026-06-16
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl knowledge retrieve --help` 现在明确指出 `--api-key` 是推荐的鉴权方式;AK/SK 相关选项已标注废弃并引导用户使用 `--api-key`。
|
||||
|
||||
### 新增
|
||||
|
||||
- 命令定义新增 `notes` 字段 — 命令可以附带上下文说明(鉴权要求、废弃提示等),同时展示在 `--help` 输出和生成的命令手册中。
|
||||
|
||||
## [1.3.2] - 2026-06-12
|
||||
|
||||
### 修复
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ bl auth status --output json
|
||||
bl text chat --message "ping" --non-interactive --output json
|
||||
```
|
||||
|
||||
若失败:根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、region 等)。全局 region:`--region cn|us|intl`,默认 `cn`。
|
||||
若失败:根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、`base_url` 等)。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,14 +27,18 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
|
||||
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Video generation & editing** — HappyHorse-1.0 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **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
|
||||
|
||||
> **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.
|
||||
|
||||
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
|
||||
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
|
||||
- **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`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
@@ -51,7 +55,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.0**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
|
||||
|
||||
### The single prompt
|
||||
@@ -64,7 +68,7 @@ A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from
|
||||
|
||||
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
|
||||
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.0** in parallel.
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
|
||||
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
|
||||
|
||||
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
|
||||
@@ -108,22 +112,30 @@ bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
|
||||
# Browser login (required for console capability commands)
|
||||
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 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
|
||||
|
||||
# Browse apps / free-tier quota / usage statistics / workspaces
|
||||
bl app list
|
||||
bl usage free --model qwen3-max
|
||||
bl usage free --expiring 30 # Quotas expiring within 30 days
|
||||
bl usage free --sort remaining # Sort by remaining % ascending
|
||||
bl usage stats --workspace-id <id> # Usage overview for a workspace
|
||||
bl usage stats --model qwen-turbo --workspace-id <id> # Per-model usage
|
||||
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
|
||||
|
||||
# Rate limit management
|
||||
bl quota list # View RPM/TPM limits for all models
|
||||
bl quota list --model qwen3.6-plus # View limits for a specific model
|
||||
bl quota check # Current usage vs rate limits
|
||||
bl quota check --model qwen3.6-plus --period 5 # Check usage over last 5 minutes
|
||||
# Rate limit management (list / check / request / history)
|
||||
bl quota list # View RPM/TPM limits (add --model to filter)
|
||||
bl quota check # Current usage vs rate limits (add --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
|
||||
bl quota history # View quota change history
|
||||
bl quota history # View quota-change history
|
||||
|
||||
# Token Plan team management (requires AK/SK, see auth below)
|
||||
bl token-plan list-seats # View subscription seat details
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
|
||||
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
@@ -153,9 +165,9 @@ Required for console capability commands (`app list`, `usage free`, `usage stats
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### Alibaba Cloud AK/SK (Knowledge Base only)
|
||||
### Alibaba Cloud AK/SK (Knowledge Base & Token Plan)
|
||||
|
||||
Required for `knowledge retrieve`. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
Required for `knowledge retrieve` and the `token-plan` command group. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
|
||||
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
|
||||
|
||||
@@ -172,7 +184,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
bl config show
|
||||
|
||||
# Set defaults
|
||||
bl config set --key region --value us
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
|
||||
+32
-17
@@ -27,14 +27,18 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
|
||||
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
|
||||
- **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`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
@@ -51,7 +55,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.0**,百炼的文生/图生/参考生视频模型
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
|
||||
|
||||
### 唯一的提示词
|
||||
@@ -62,7 +66,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
|
||||
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.0**。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**。
|
||||
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
|
||||
|
||||
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
|
||||
@@ -79,7 +83,10 @@ npx skills add modelstudioai/cli --all -g
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 认证
|
||||
# 认证(推荐浏览器登录)
|
||||
bl auth login --console
|
||||
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 和通义千问对话
|
||||
@@ -103,22 +110,30 @@ bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代
|
||||
# 浏览器登录(控制台能力相关命令需要)
|
||||
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 capability --model qwen3-8b # 查询模型支持哪些训练方式
|
||||
bl deploy create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
|
||||
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
|
||||
bl app list
|
||||
bl usage free --model qwen3-max
|
||||
bl usage free --expiring 30 # 30 天内过期的额度
|
||||
bl usage free --sort remaining # 按剩余百分比升序排列
|
||||
bl usage stats --workspace-id <id> # 指定空间的用量概览
|
||||
bl usage stats --model qwen-turbo --workspace-id <id> # 指定模型用量
|
||||
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
|
||||
bl workspace list # 列出所有业务空间
|
||||
|
||||
# 限流管理与提额
|
||||
bl quota list # 查看所有模型的 RPM/TPM 限额
|
||||
bl quota list --model qwen3.6-plus # 查看指定模型限额
|
||||
bl quota check # 查看当前用量 vs 限流阈值
|
||||
bl quota check --model qwen3.6-plus --period 5 # 查看最近 5 分钟用量
|
||||
# 限流管理与提额(list / check / request / history)
|
||||
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
|
||||
bl quota check # 当前用量 vs 限流阈值(加 --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
|
||||
bl quota history # 查看提额历史记录
|
||||
|
||||
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
|
||||
bl token-plan list-seats # 查看订阅席位明细
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
|
||||
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
@@ -148,9 +163,9 @@ bl text chat --api-key sk-xxxxx --message "你好"
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### 阿里云 AK/SK(仅知识库检索)
|
||||
### 阿里云 AK/SK(知识库检索与 Token Plan)
|
||||
|
||||
`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
`knowledge retrieve` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
|
||||
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
|
||||
|
||||
@@ -167,7 +182,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
bl config show
|
||||
|
||||
# 设置默认值
|
||||
bl config set --key region --value us
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
|
||||
@@ -78,9 +78,7 @@ flag 优先 ─→ config 文件 ─→ env var
|
||||
|
||||
### D. main 启动逻辑
|
||||
|
||||
- [ ] `packages/cli/src/main.ts:NO_AUTH_SETUP` 列表:
|
||||
- 如果新增的命令"自己管鉴权或不需要鉴权",加进去绕开 ensureApiKey 拦截
|
||||
- 当前清单以 `main.ts:NO_AUTH_SETUP` 为准
|
||||
- [ ] 若新增命令**自行处理鉴权**或**不应在入口触发默认 API key 引导**,在对应 `defineCommand` 上设 `skipDefaultApiKeySetup: true`(见 `packages/core/src/types/command.ts`;`packages/cli/src/main.ts` 在 `registry.resolve` 后读取 `command.skipDefaultApiKeySetup`)
|
||||
|
||||
### E. 错误文案
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ git diff --name-only <base>...<head>
|
||||
- [ ] **`package.json` 没破坏发布元数据**:`bin` / `exports` / `files` / `inlinedDependencies` 字段任何删除或改名都要单独评估
|
||||
- [ ] **公共依赖没被悄悄升级**:catalog / 根 lockfile 改动要列出来
|
||||
- [ ] **`package.json` version 没倒退**:目标分支已经更高时(如 main 1.0.3 vs head 1.0.0-beta.1),手动对齐版本号,不要被 head 覆盖
|
||||
- [ ] **全局表没冲突**:`registry.ts`、`NO_AUTH_SETUP`(`packages/cli/src/main.ts`)、`ExitCode` 三个全局表新增项不和现有项冲突
|
||||
- [ ] **全局表没冲突**:`registry.ts`、`defineCommand` 的 `skipDefaultApiKeySetup`(见 `packages/core/src/types/command.ts`)、`ExitCode` 三处新增项不和现有项冲突
|
||||
|
||||
## 清单 B:用户透出(用户可见的新东西必看)
|
||||
|
||||
@@ -80,7 +80,7 @@ git diff --name-only <base>...<head>
|
||||
解冲突要点(merge 时不要漏):
|
||||
- <冲突文件> + <字段/段落> + <怎么取舍>
|
||||
↑ 放"合并那一刻才会出现"的细节,例如 package.json 的 files/scripts/devDependencies 各取并集、
|
||||
NO_AUTH_SETUP 这种全局表两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
|
||||
`skipDefaultApiKeySetup` 这类命令元数据两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
|
||||
建议修(可后置):
|
||||
- ...
|
||||
仅信息(无需动作,告知即可):
|
||||
@@ -94,11 +94,11 @@ git diff --name-only <base>...<head>
|
||||
|
||||
## 常见漏点(基于历史踩坑)
|
||||
|
||||
| 漏点 | 后果 |
|
||||
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| `pnpm-workspace.yaml` 把 `packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
|
||||
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
|
||||
| `registry.ts` 注册新命令但忘了 [README](README.md) / [README.zh](README.zh.md) | 用户完全感知不到新功能 |
|
||||
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
|
||||
| `NO_AUTH_SETUP` 加了不该免登录的命令 | 安全风险,用户没登录也能调付费 API |
|
||||
| `NO_AUTH_SETUP` / `registry.ts` 这类全局表两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
|
||||
| 漏点 | 后果 |
|
||||
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `pnpm-workspace.yaml` 把 `packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
|
||||
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
|
||||
| `registry.ts` 注册新命令但忘了 [README](README.md) / [README.zh](README.zh.md) | 用户完全感知不到新功能 |
|
||||
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
|
||||
| 不该跳过默认 API key 引导的命令误设 `skipDefaultApiKeySetup: true` | 安全风险,用户没配置 key 也能调付费 API |
|
||||
| `catalog.ts` / `skipDefaultApiKeySetup` 这类元数据两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
|
||||
|
||||
@@ -53,7 +53,7 @@ registry.ts main.ts tools/generate-reference.ts export-schema.ts
|
||||
- 增删 `import xxx from "./.../xxx.ts"`
|
||||
- 在 `export const commands` 里增删 `"<group> <action>": xxx`(key 与 `defineCommand({ name })` 一致)
|
||||
- [ ] **不要**在 `registry.ts` 里重复登记命令(已从 catalog 读取)
|
||||
- [ ] 如果命令需要鉴权之外的特殊路径,看 `packages/cli/src/main.ts` 的 `NO_AUTH_SETUP`
|
||||
- [ ] 如果命令需要跳过入口的默认 DashScope API key 引导(`ensureApiKey`),在对应 `defineCommand` 上设 `skipDefaultApiKeySetup: true`(字段定义见 `packages/core/src/types/command.ts`;`main.ts` 根据已解析的 `command` 读取)
|
||||
- [ ] **`config/export-schema.ts`**: 若新命令不适合作为 agent tool,评估是否加入 `SKIP_PREFIXES`;该文件在 `run()` 内 `import("../catalog.ts")`,勿顶层 import catalog 以免循环依赖
|
||||
|
||||
### B. 文档层
|
||||
|
||||
+15
-8
@@ -67,6 +67,12 @@ node tools/release/publish-channel.mjs --channel test --dry-run
|
||||
- [ ] `packages/cli/package.json` 和 `packages/core/package.json` 已升到目标版本
|
||||
- [ ] pre-release 格式正确(`1.0.0-beta.0` / `1.0.0-rc.1`,**不要直接用 `1.0.0` 当 beta**)
|
||||
|
||||
### CHANGELOG(仅 stable)
|
||||
|
||||
- [ ] `CHANGELOG.md` 和 `CHANGELOG.zh.md` 都已新增目标版本条目,中英文一一对应
|
||||
- [ ] 分类标题用 Keep a Changelog 规范的 `Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security`(中文版对应 `新增` / `变更` / `已弃用` / `已移除` / `修复` / `安全`),**不要自创 `Improved` / `优化` 等规范外分类**
|
||||
- [ ] 条目日期与发版日期一致
|
||||
|
||||
### 用户面文档
|
||||
|
||||
- [ ] `README.md` / `README.zh.md` 的 Quick Start 命令仍能跑通
|
||||
@@ -81,11 +87,12 @@ node tools/release/publish-channel.mjs --channel test --dry-run
|
||||
|
||||
## 常见漏点(基于历史踩坑)
|
||||
|
||||
| 漏点 | 后果 |
|
||||
| ----------------------------------------------------- | -------------------------------------------------- |
|
||||
| cli 升版号但 core 没升 | check.mjs 会拦下 |
|
||||
| `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 |
|
||||
| 漏点 | 后果 |
|
||||
| -------------------------------------------------------- | -------------------------------------------------- |
|
||||
| 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 |
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
# 模型训练 + 数据集 + 部署:最小闭环 CLI 设计
|
||||
|
||||
> 目标:一个 Qwen 文本模型 SFT 训练、数据集上传、模型部署的端到端最小链路。
|
||||
|
||||
---
|
||||
|
||||
## 一、命令概览
|
||||
|
||||
| 优先级 | 命令 | 映射 API | 用途 |
|
||||
| ------ | ----------------------------------- | --------------------------------------------- | ------------------------------- |
|
||||
| P0 | `bl dataset upload <path>` | `POST /api/v1/files` | 上传训练数据(含本地格式校验) |
|
||||
| P0 | `bl finetune create` | `POST /api/v1/fine-tunes` | 创建 SFT 训练任务(预填默认超参) |
|
||||
| P0 | `bl finetune status <job_id>` | `GET /api/v1/fine-tunes/{job_id}` | 查询训练状态 |
|
||||
| P0 | `bl deploy create` | `POST /api/v1/deployments` | 部署训练好的模型 |
|
||||
| P1 | `bl finetune logs <job_id>` | `GET /api/v1/fine-tunes/{job_id}/logs` | 拉取训练日志 |
|
||||
| P1 | `bl finetune checkpoints <job_id>` | `GET /api/v1/fine-tunes/{job_id}/checkpoints` | 查看/挑选 Checkpoint |
|
||||
| P1 | `bl deploy status <deployed_model>` | `GET /api/v1/deployments/{deployed_model}` | 查询部署状态 |
|
||||
| P1 | `bl deploy delete <deployed_model>` | `DELETE /api/v1/deployments/{deployed_model}` | 下线部署 |
|
||||
| P1 | `bl infer --model <deployed_model>` | 复用 `text chat` 通路 | 调用已部署模型 |
|
||||
|
||||
---
|
||||
|
||||
## 二、P0 命令详细设计
|
||||
|
||||
### 2.1 `bl dataset upload`
|
||||
|
||||
**定位:** 上传训练数据文件到百炼平台,获取 `file_id` 供训练任务引用。
|
||||
|
||||
#### CLI 签名
|
||||
|
||||
```
|
||||
bl dataset upload <path> [--purpose fine-tune] [--validate] [--no-validate]
|
||||
```
|
||||
|
||||
| Flag | 必填 | 默认值 | 说明 |
|
||||
| --------------- | ---- | ----------- | ------------------------------ |
|
||||
| `<path>` | 是 | — | 本地文件路径(.jsonl 或 .zip) |
|
||||
| `--purpose` | 否 | `fine-tune` | 文件用途标签 |
|
||||
| `--validate` | 否 | `true` | 上传前执行本地格式校验 |
|
||||
| `--no-validate` | 否 | — | 跳过本地校验 |
|
||||
|
||||
#### 本地格式校验规则(提交前拦截)
|
||||
|
||||
校验逻辑在 `packages/core` 实现(纯函数),CLI 调用后展示错误:
|
||||
|
||||
1. **文件格式检查**:仅允许 `.jsonl` 和 `.zip`(zip 内根目录必须有 `data.jsonl`)
|
||||
2. **JSONL 逐行校验**:
|
||||
- 每行可被 `JSON.parse`
|
||||
- 顶层必须包含 `messages` 数组
|
||||
- `messages` 中每项必须包含 `role`(枚举:`system` | `user` | `assistant`)和 `content`(非空字符串)
|
||||
- 至少包含一条 `user` + 一条 `assistant` 消息
|
||||
3. **数量校验**:SFT 训练至少需要上千条数据(给出 warning 而非 hard fail,阈值建议 ≥ 10 条 hard fail)
|
||||
4. **文件体积**:≤ 300MB
|
||||
|
||||
#### 校验失败输出示例
|
||||
|
||||
```
|
||||
✗ Validation failed:
|
||||
|
||||
Line 3: missing "messages" field
|
||||
Line 7: role "bot" is not valid (expected: system | user | assistant)
|
||||
Line 12: "content" is empty string
|
||||
|
||||
Fix 3 errors above and retry.
|
||||
```
|
||||
|
||||
#### API 调用
|
||||
|
||||
```
|
||||
POST https://dashscope.aliyuncs.com/api/v1/files
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer <api-key>
|
||||
|
||||
Body:
|
||||
files: <binary>
|
||||
purpose: "fine-tune"
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"id": "file-xxxx",
|
||||
"bytes": 12345,
|
||||
"filename": "train.jsonl",
|
||||
"purpose": "fine-tune",
|
||||
"created_at": 1700000000
|
||||
}
|
||||
```
|
||||
|
||||
#### 输出
|
||||
|
||||
- 默认 text:`✓ Uploaded file-xxxx (12.3 KB) — use this ID in bl finetune create`
|
||||
- `--output json`:完整 response body
|
||||
- `--quiet`:仅输出 `file-xxxx`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `bl finetune create`
|
||||
|
||||
**定位:** 创建一个 SFT 训练任务。核心设计原则——**预填合理默认超参 + 提交前二次确认**,降低 OOM/超参不合理导致的训练失败率。
|
||||
|
||||
#### CLI 签名
|
||||
|
||||
```
|
||||
bl finetune create --model <model> --data <file_id> [hyperparams...]
|
||||
```
|
||||
|
||||
| Flag | 必填 | 默认值 | 说明 |
|
||||
| ------------------- | ---- | ------------ | -------------------------------------------- |
|
||||
| `--model` | 是 | — | 基座模型(如 `qwen3-8b`, `qwen3-14b`) |
|
||||
| `--data` | 是 | — | 训练数据 file_id(bl dataset upload 返回值) |
|
||||
| `--validation-data` | 否 | — | 验证数据 file_id |
|
||||
| `--epochs` | 否 | 3 | 训练轮次 (n_epochs) |
|
||||
| `--batch-size` | 否 | 按模型自动选 | 批大小 |
|
||||
| `--lr` | 否 | 按模型自动选 | 学习率 (learning_rate_multiplier) |
|
||||
| `--warmup-ratio` | 否 | 0.1 | warmup 比例 |
|
||||
| `--suffix` | 否 | — | 输出模型后缀名 |
|
||||
| `--yes` / `-y` | 否 | — | 跳过确认直接提交 |
|
||||
|
||||
#### 预填默认超参策略
|
||||
|
||||
| 基座模型 | batch_size | lr_multiplier | n_epochs | 备注 |
|
||||
| ---------- | ---------- | ------------- | -------- | ---------------- |
|
||||
| qwen3-8b | 4 | 1e-5 | 3 | 小模型可大 batch |
|
||||
| qwen3-14b | 2 | 5e-6 | 3 | 中模型防 OOM |
|
||||
| qwen3-32b+ | 1 | 2e-6 | 2 | 大模型保守设置 |
|
||||
|
||||
> 以上为建议默认值,用户显式传参时覆盖。具体映射表在 `packages/core/src/finetune/defaults.ts` 维护。
|
||||
|
||||
#### 提交前交互确认
|
||||
|
||||
非 `--yes` 模式下,显示任务摘要等待确认:
|
||||
|
||||
```
|
||||
┌─ Fine-tune Job Summary ──────────────────────┐
|
||||
│ Model: qwen3-8b │
|
||||
│ Training: file-abc123 (2,048 samples) │
|
||||
│ Validation: (none) │
|
||||
│ Epochs: 3 │
|
||||
│ Batch size: 4 │
|
||||
│ LR: 1e-5 │
|
||||
│ Warmup: 0.1 │
|
||||
│ Suffix: my-assistant │
|
||||
│ │
|
||||
│ Estimated cost: ~¥XX (based on token count) │
|
||||
└───────────────────────────────────────────────┘
|
||||
Proceed? [Y/n]
|
||||
```
|
||||
|
||||
#### API 调用
|
||||
|
||||
```
|
||||
POST https://dashscope.aliyuncs.com/api/v1/fine-tunes
|
||||
Authorization: Bearer <api-key>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "qwen3-8b",
|
||||
"training_file_ids": ["file-abc123"],
|
||||
"validation_file_ids": [],
|
||||
"hyper_parameters": {
|
||||
"n_epochs": 3,
|
||||
"batch_size": 4,
|
||||
"learning_rate": "1e-5",
|
||||
"warmup_ratio": 0.1
|
||||
},
|
||||
"suffix": "my-assistant"
|
||||
}
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"job_id": "ft-xxxx",
|
||||
"status": "PENDING",
|
||||
"model": "qwen3-8b",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"training_file_ids": ["file-abc123"],
|
||||
"hyper_parameters": {...},
|
||||
"trained_model": null
|
||||
}
|
||||
```
|
||||
|
||||
#### 输出
|
||||
|
||||
- text:`✓ Fine-tune job ft-xxxx created (PENDING). Track with: bl finetune status ft-xxxx`
|
||||
- json:完整 response body
|
||||
- quiet:`ft-xxxx`
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `bl finetune status`
|
||||
|
||||
**定位:** 查询训练任务状态,支持 `--wait` 轮询模式。
|
||||
|
||||
#### CLI 签名
|
||||
|
||||
```
|
||||
bl finetune status <job_id> [--wait] [--interval <seconds>]
|
||||
```
|
||||
|
||||
| Flag | 必填 | 默认值 | 说明 |
|
||||
| ------------ | ---- | ------ | ---------------- |
|
||||
| `<job_id>` | 是 | — | 任务 ID |
|
||||
| `--wait` | 否 | — | 持续轮询直到终态 |
|
||||
| `--interval` | 否 | 30 | 轮询间隔(秒) |
|
||||
|
||||
#### 状态机
|
||||
|
||||
```
|
||||
PENDING → RUNNING → SUCCEEDED
|
||||
↘ FAILED
|
||||
```
|
||||
|
||||
#### 输出(text 模式)
|
||||
|
||||
单次查询:
|
||||
|
||||
```
|
||||
Job: ft-xxxx
|
||||
Status: RUNNING (elapsed 12m)
|
||||
Model: qwen3-8b
|
||||
Output: (pending)
|
||||
```
|
||||
|
||||
`--wait` 模式(spinner + 实时刷新):
|
||||
|
||||
```
|
||||
⠋ ft-xxxx RUNNING [14:32 elapsed]
|
||||
✓ ft-xxxx SUCCEEDED — trained model: qwen3-8b:ft-xxxx-20250101
|
||||
Deploy with: bl deploy create --model qwen3-8b:ft-xxxx-20250101
|
||||
```
|
||||
|
||||
失败时:
|
||||
|
||||
```
|
||||
✗ ft-xxxx FAILED
|
||||
Error: OutOfMemory — try reducing --batch-size or using a smaller model
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 `bl deploy create`
|
||||
|
||||
**定位:** 将训练好的模型(或 checkpoint)部署为可调用的推理服务。
|
||||
|
||||
#### CLI 签名
|
||||
|
||||
```
|
||||
bl deploy create --model <model_name> [--plan <plan>] [--capacity <n>]
|
||||
```
|
||||
|
||||
| Flag | 必填 | 默认值 | 说明 |
|
||||
| ------------ | ---- | ---------- | ----------------------------------------------- |
|
||||
| `--model` | 是 | — | 待部署模型名称(finetune 产出的 trained_model) |
|
||||
| `--plan` | 否 | `standard` | 部署方案 |
|
||||
| `--capacity` | 否 | 依 plan | 并发容量 |
|
||||
| `--wait` | 否 | — | 等待部署就绪 |
|
||||
|
||||
#### API 调用
|
||||
|
||||
```
|
||||
POST https://dashscope.aliyuncs.com/api/v1/deployments
|
||||
Authorization: Bearer <api-key>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model_name": "qwen3-8b:ft-xxxx-20250101",
|
||||
"plan": "standard",
|
||||
"capacity": 2
|
||||
}
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"deployed_model": "qwen3-8b-ft-xxxx",
|
||||
"model_name": "qwen3-8b:ft-xxxx-20250101",
|
||||
"status": "PENDING",
|
||||
"created_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
#### 输出
|
||||
|
||||
```
|
||||
✓ Deployment created: qwen3-8b-ft-xxxx (PENDING)
|
||||
Once RUNNING, call with: bl text chat --model qwen3-8b-ft-xxxx
|
||||
Check status: bl deploy status qwen3-8b-ft-xxxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、P1 命令简要设计
|
||||
|
||||
### 3.1 `bl finetune logs <job_id>`
|
||||
|
||||
流式输出训练日志,支持 `--follow`(类似 `tail -f`)。输出 loss/step/epoch 信息。
|
||||
|
||||
### 3.2 `bl finetune checkpoints <job_id>`
|
||||
|
||||
列出可选 checkpoint(step, loss, eval metrics),支持 `--output json` 供脚本使用。可配合 `bl deploy create --model <checkpoint_model>` 部署指定 checkpoint。
|
||||
|
||||
### 3.3 `bl deploy status <deployed_model>`
|
||||
|
||||
查询部署状态及资源信息(PENDING → RUNNING → STOPPED/FAILED)。
|
||||
|
||||
### 3.4 `bl deploy delete <deployed_model>`
|
||||
|
||||
下线部署。需部署处于 RUNNING/STOPPED/FAILED 状态。交互确认或 `--yes` 跳过。
|
||||
|
||||
### 3.5 `bl infer --model <deployed_model>`
|
||||
|
||||
实际可复用已有 `bl text chat --model <deployed_model>` 通路,作为别名/快捷方式。P1 考虑是否有独立存在必要。
|
||||
|
||||
---
|
||||
|
||||
## 四、代码架构方案
|
||||
|
||||
按照 monorepo 分层约定(core 纯逻辑 / cli 是 UI):
|
||||
|
||||
### packages/core 新增模块
|
||||
|
||||
```
|
||||
packages/core/src/
|
||||
├── finetune/
|
||||
│ ├── index.ts # re-export
|
||||
│ ├── api.ts # createFineTune, getFineTune, getFineTuneLogs, getCheckpoints
|
||||
│ ├── defaults.ts # 模型 → 默认超参映射表
|
||||
│ └── types.ts # FineTuneJob, HyperParameters, CheckpointInfo 类型
|
||||
├── dataset/
|
||||
│ ├── index.ts
|
||||
│ ├── upload.ts # uploadDataset (multipart)
|
||||
│ ├── validate.ts # validateJsonl (纯函数,逐行校验)
|
||||
│ └── types.ts # DatasetFile, ValidationError 类型
|
||||
└── deploy/
|
||||
├── index.ts
|
||||
├── api.ts # createDeployment, getDeployment, deleteDeployment
|
||||
└── types.ts # Deployment, DeploymentStatus 类型
|
||||
```
|
||||
|
||||
### packages/cli 新增命令
|
||||
|
||||
```
|
||||
packages/cli/src/commands/
|
||||
├── dataset/
|
||||
│ └── upload.ts # bl dataset upload
|
||||
├── finetune/
|
||||
│ ├── create.ts # bl finetune create
|
||||
│ ├── status.ts # bl finetune status
|
||||
│ ├── logs.ts # bl finetune logs
|
||||
│ └── checkpoints.ts # bl finetune checkpoints
|
||||
└── deploy/
|
||||
├── create.ts # bl deploy create
|
||||
├── status.ts # bl deploy status
|
||||
└── delete.ts # bl deploy delete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、关键设计决策
|
||||
|
||||
### 5.1 数据格式校验放在 CLI 侧(提交前拦截)
|
||||
|
||||
训练失败 TOP 原因中"数据格式错误"占比高。与其等服务端 10 分钟后返回 FAILED,不如 CLI 本地秒级校验:
|
||||
|
||||
- **validate.ts** 是纯函数,接收 ReadableStream/Buffer,返回 `ValidationError[]`
|
||||
- CLI 在 `dataset upload` 默认执行校验,`--no-validate` 允许跳过
|
||||
- 未来可扩展为独立命令 `bl dataset validate <path>`
|
||||
|
||||
### 5.2 超参预填 + 确认而非强制
|
||||
|
||||
- core 维护 `defaults.ts` 映射:`model → { batch_size, lr, epochs }`
|
||||
- CLI `finetune create` 未指定超参时自动填入
|
||||
- 提交前展示完整参数面板(非 --yes 模式),避免"我以为用了默认但其实没传"
|
||||
|
||||
### 5.3 费用感知(P1+)
|
||||
|
||||
- 图像/语音/视频训练费用远高于文本。MVP 阶段(Qwen 文本 SFT)费用可控
|
||||
- 后续扩展多模态时,在 confirm panel 中强化费用估算提示
|
||||
- `bl quota check` 已存在,可在 `finetune create` 内部集成余额预检
|
||||
|
||||
### 5.4 `bl infer` 是否独立存在
|
||||
|
||||
建议 P1 阶段**不新增** `bl infer`,而是让 `bl text chat --model <deployed_model>` 直接工作。部署完成后的引导文案中指明这个用法即可。减少命令膨胀。
|
||||
|
||||
---
|
||||
|
||||
## 六、最小闭环用户操作流
|
||||
|
||||
```bash
|
||||
# 1. 准备数据 → 上传(含校验)
|
||||
bl dataset upload ./train.jsonl
|
||||
# ✓ Uploaded file-abc123 (5.2 MB)
|
||||
|
||||
# 2. 创建训练任务(自动预填超参)
|
||||
bl finetune create --model qwen3-8b --data file-abc123
|
||||
# Shows summary panel → confirm → ✓ Job ft-xxxx created
|
||||
|
||||
# 3. 等待训练完成
|
||||
bl finetune status ft-xxxx --wait
|
||||
# ⠋ RUNNING [23:15] → ✓ SUCCEEDED: qwen3-8b:ft-xxxx-20250601
|
||||
|
||||
# 4. 部署模型
|
||||
bl deploy create --model qwen3-8b:ft-xxxx-20250601 --wait
|
||||
# ✓ Deployed: qwen3-8b-ft-xxxx (RUNNING)
|
||||
|
||||
# 5. 调用模型
|
||||
bl text chat --model qwen3-8b-ft-xxxx "你好,介绍一下你自己"
|
||||
# (正常推理输出)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、实现顺序建议
|
||||
|
||||
```
|
||||
Phase 1 (P0 — 最小闭环):
|
||||
core: dataset/validate.ts → dataset/upload.ts → finetune/api.ts → deploy/api.ts
|
||||
cli: dataset upload → finetune create → finetune status → deploy create
|
||||
测试: 单元测试 validate.ts + e2e dry-run + 真实 API 端到端一次
|
||||
|
||||
Phase 2 (P1 — 可观测性):
|
||||
finetune logs → finetune checkpoints → deploy status → deploy delete
|
||||
费用估算集成
|
||||
|
||||
Phase 3 (后续):
|
||||
bl dataset validate (独立命令)
|
||||
bl dataset list (查看已上传)
|
||||
bl finetune list (查看历史任务)
|
||||
多模态 SFT 支持(图像/视频数据格式校验扩展)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、风险与 TODO
|
||||
|
||||
| 风险点 | 影响 | 缓解措施 |
|
||||
| ----------------- | ----------------- | --------------------------------------------- |
|
||||
| OOM 训练失败 | 用户浪费时间/金钱 | 保守默认超参 + batch_size 自适应模型大小 |
|
||||
| 数据格式错误 | 训练启动后才失败 | 本地校验拦截,启动秒级反馈 |
|
||||
| 部署等待时间长 | 用户困惑 | `--wait` + 预估时间提示 |
|
||||
| 费用超预期 | 账号欠费 | confirm panel 预估费用(P1 集成 quota check) |
|
||||
| API endpoint 变动 | 调用失败 | 端点集中管理在 core/client/endpoints.ts |
|
||||
+2
-1
@@ -16,9 +16,10 @@
|
||||
"ready": "vp check && vp run -r test && vp run -r build",
|
||||
"prepare": "vp config",
|
||||
"check": "vp check",
|
||||
"sync:skill-assets": "pnpm --filter bailian-cli-core 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 build && 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",
|
||||
"test": "vp test",
|
||||
"release:check": "node tools/release/check.mjs",
|
||||
"wiki:crawl": "node tools/wiki-crawler/index.mjs",
|
||||
|
||||
+29
-17
@@ -27,14 +27,18 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
|
||||
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Video generation & editing** — HappyHorse-1.0 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **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
|
||||
|
||||
> **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.
|
||||
|
||||
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
|
||||
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
|
||||
- **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`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
@@ -51,7 +55,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.0**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
|
||||
|
||||
### The single prompt
|
||||
@@ -64,7 +68,7 @@ A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from
|
||||
|
||||
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
|
||||
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.0** in parallel.
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
|
||||
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
|
||||
|
||||
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
|
||||
@@ -108,22 +112,30 @@ bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
|
||||
# Browser login (required for console capability commands)
|
||||
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 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
|
||||
|
||||
# Browse apps / free-tier quota / usage statistics / workspaces
|
||||
bl app list
|
||||
bl usage free --model qwen3-max
|
||||
bl usage free --expiring 30 # Quotas expiring within 30 days
|
||||
bl usage free --sort remaining # Sort by remaining % ascending
|
||||
bl usage stats --workspace-id <id> # Usage overview for a workspace
|
||||
bl usage stats --model qwen-turbo --workspace-id <id> # Per-model usage
|
||||
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
|
||||
|
||||
# Rate limit management
|
||||
bl quota list # View RPM/TPM limits for all models
|
||||
bl quota list --model qwen3.6-plus # View limits for a specific model
|
||||
bl quota check # Current usage vs rate limits
|
||||
bl quota check --model qwen3.6-plus --period 5 # Check usage over last 5 minutes
|
||||
# Rate limit management (list / check / request / history)
|
||||
bl quota list # View RPM/TPM limits (add --model to filter)
|
||||
bl quota check # Current usage vs rate limits (add --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
|
||||
bl quota history # View quota change history
|
||||
bl quota history # View quota-change history
|
||||
|
||||
# Token Plan team management (requires AK/SK, see auth below)
|
||||
bl token-plan list-seats # View subscription seat details
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
|
||||
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
@@ -153,9 +165,9 @@ Required for console capability commands (`app list`, `usage free`, `usage stats
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### Alibaba Cloud AK/SK (Knowledge Base only)
|
||||
### Alibaba Cloud AK/SK (Knowledge Base & Token Plan)
|
||||
|
||||
Required for `knowledge retrieve`. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
Required for `knowledge retrieve` and the `token-plan` command group. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
|
||||
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
|
||||
|
||||
@@ -172,7 +184,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
bl config show
|
||||
|
||||
# Set defaults
|
||||
bl config set --key region --value us
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
|
||||
+32
-17
@@ -27,14 +27,18 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
|
||||
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
|
||||
- **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`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
@@ -51,7 +55,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.0**,百炼的文生/图生/参考生视频模型
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
|
||||
|
||||
### 唯一的提示词
|
||||
@@ -62,7 +66,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
|
||||
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.0**。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**。
|
||||
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
|
||||
|
||||
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
|
||||
@@ -79,7 +83,10 @@ npx skills add modelstudioai/cli --all -g
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 认证
|
||||
# 认证(推荐浏览器登录)
|
||||
bl auth login --console
|
||||
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 和通义千问对话
|
||||
@@ -103,22 +110,30 @@ bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代
|
||||
# 浏览器登录(控制台能力相关命令需要)
|
||||
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 capability --model qwen3-8b # 查询模型支持哪些训练方式
|
||||
bl deploy create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
|
||||
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
|
||||
bl app list
|
||||
bl usage free --model qwen3-max
|
||||
bl usage free --expiring 30 # 30 天内过期的额度
|
||||
bl usage free --sort remaining # 按剩余百分比升序排列
|
||||
bl usage stats --workspace-id <id> # 指定空间的用量概览
|
||||
bl usage stats --model qwen-turbo --workspace-id <id> # 指定模型用量
|
||||
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
|
||||
bl workspace list # 列出所有业务空间
|
||||
|
||||
# 限流管理与提额
|
||||
bl quota list # 查看所有模型的 RPM/TPM 限额
|
||||
bl quota list --model qwen3.6-plus # 查看指定模型限额
|
||||
bl quota check # 查看当前用量 vs 限流阈值
|
||||
bl quota check --model qwen3.6-plus --period 5 # 查看最近 5 分钟用量
|
||||
# 限流管理与提额(list / check / request / history)
|
||||
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
|
||||
bl quota check # 当前用量 vs 限流阈值(加 --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
|
||||
bl quota history # 查看提额历史记录
|
||||
|
||||
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
|
||||
bl token-plan list-seats # 查看订阅席位明细
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
|
||||
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
@@ -148,9 +163,9 @@ bl text chat --api-key sk-xxxxx --message "你好"
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### 阿里云 AK/SK(仅知识库检索)
|
||||
### 阿里云 AK/SK(知识库检索与 Token Plan)
|
||||
|
||||
`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
`knowledge retrieve` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
|
||||
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
|
||||
|
||||
@@ -167,7 +182,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
bl config show
|
||||
|
||||
# 设置默认值
|
||||
bl config set --key region --value us
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
|
||||
+11
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.3.2",
|
||||
"version": "1.5.0",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
@@ -33,6 +33,10 @@
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": "./dist/bailian.mjs",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -44,32 +48,23 @@
|
||||
"check": "vp check"
|
||||
},
|
||||
"dependencies": {
|
||||
"bailian-cli-commands": "workspace:*",
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"undici": "catalog:"
|
||||
"bailian-cli-runtime": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260328.1",
|
||||
"ajv": "catalog:",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"typescript": "^6.0.2",
|
||||
"vite-plus": "catalog:",
|
||||
"undici": "catalog:",
|
||||
"vite-plus": "0.1.22",
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"inlinedDependencies": {
|
||||
"@clack/core": "0.3.5",
|
||||
"@clack/prompts": "0.7.0",
|
||||
"ajv": "8.20.0",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fast-uri": "3.1.2",
|
||||
"json-schema-traverse": "1.0.0",
|
||||
"picocolors": "1.1.1",
|
||||
"sisteransi": "1.0.5",
|
||||
"yaml": "2.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { Command } from "bailian-cli-core";
|
||||
import {
|
||||
authLogin,
|
||||
authStatus,
|
||||
authLogout,
|
||||
textChat,
|
||||
textOmni,
|
||||
imageGenerate,
|
||||
imageEdit,
|
||||
videoGenerate,
|
||||
videoEdit,
|
||||
videoRef,
|
||||
videoTaskGet,
|
||||
videoDownload,
|
||||
visionDescribe,
|
||||
configShow,
|
||||
configSet,
|
||||
update,
|
||||
appCall,
|
||||
appList,
|
||||
memoryAdd,
|
||||
memorySearch,
|
||||
memoryList,
|
||||
memoryUpdate,
|
||||
memoryDelete,
|
||||
memoryProfileCreate,
|
||||
memoryProfileGet,
|
||||
knowledgeRetrieve,
|
||||
mcpCall,
|
||||
mcpList,
|
||||
mcpTools,
|
||||
searchWeb,
|
||||
speechSynthesize,
|
||||
speechRecognize,
|
||||
fileUpload,
|
||||
consoleCall,
|
||||
usageFree,
|
||||
usageFreetier,
|
||||
usageStats,
|
||||
pipelineRun,
|
||||
pipelineValidate,
|
||||
advisorRecommend,
|
||||
workspaceList,
|
||||
quotaList,
|
||||
quotaRequest,
|
||||
quotaHistory,
|
||||
quotaCheck,
|
||||
datasetUpload,
|
||||
datasetList,
|
||||
datasetGet,
|
||||
datasetDelete,
|
||||
datasetValidate,
|
||||
finetuneCreate,
|
||||
finetuneList,
|
||||
finetuneGet,
|
||||
finetuneCancel,
|
||||
finetuneDelete,
|
||||
finetuneLogs,
|
||||
finetuneCheckpoints,
|
||||
finetuneExport,
|
||||
finetuneWatch,
|
||||
finetuneCapability,
|
||||
deployCreate,
|
||||
deployList,
|
||||
deployGet,
|
||||
deployModels,
|
||||
deployScale,
|
||||
deployUpdate,
|
||||
deployDelete,
|
||||
tokenPlanListSeats,
|
||||
tokenPlanCreateKey,
|
||||
tokenPlanAssignSeats,
|
||||
tokenPlanAddMember,
|
||||
} from "bailian-cli-commands";
|
||||
|
||||
// Full bailian-cli product: every command, exposed under the `bl` binary.
|
||||
// The command paths below are this product's decision — the command library
|
||||
// ships no presets, so the map is spelled out here. Kept in its own module
|
||||
// (no side effects) so tools like generate-reference.ts can import it without
|
||||
// starting the CLI.
|
||||
export const commands: Record<string, Command> = {
|
||||
"auth login": authLogin,
|
||||
"auth status": authStatus,
|
||||
"auth logout": authLogout,
|
||||
"text chat": textChat,
|
||||
omni: textOmni,
|
||||
"image generate": imageGenerate,
|
||||
"image edit": imageEdit,
|
||||
"video generate": videoGenerate,
|
||||
"video edit": videoEdit,
|
||||
"video ref": videoRef,
|
||||
"video task get": videoTaskGet,
|
||||
"video download": videoDownload,
|
||||
"vision describe": visionDescribe,
|
||||
"config show": configShow,
|
||||
"config set": configSet,
|
||||
update,
|
||||
"app call": appCall,
|
||||
"app list": appList,
|
||||
"memory add": memoryAdd,
|
||||
"memory search": memorySearch,
|
||||
"memory list": memoryList,
|
||||
"memory update": memoryUpdate,
|
||||
"memory delete": memoryDelete,
|
||||
"memory profile create": memoryProfileCreate,
|
||||
"memory profile get": memoryProfileGet,
|
||||
"knowledge retrieve": knowledgeRetrieve,
|
||||
"mcp call": mcpCall,
|
||||
"mcp list": mcpList,
|
||||
"mcp tools": mcpTools,
|
||||
"search web": searchWeb,
|
||||
"speech synthesize": speechSynthesize,
|
||||
"speech recognize": speechRecognize,
|
||||
"file upload": fileUpload,
|
||||
"console call": consoleCall,
|
||||
"usage free": usageFree,
|
||||
"usage freetier": usageFreetier,
|
||||
"usage stats": usageStats,
|
||||
"pipeline run": pipelineRun,
|
||||
"pipeline validate": pipelineValidate,
|
||||
"advisor recommend": advisorRecommend,
|
||||
"workspace list": workspaceList,
|
||||
"quota list": quotaList,
|
||||
"quota request": quotaRequest,
|
||||
"quota history": quotaHistory,
|
||||
"quota check": quotaCheck,
|
||||
"dataset upload": datasetUpload,
|
||||
"dataset list": datasetList,
|
||||
"dataset get": datasetGet,
|
||||
"dataset delete": datasetDelete,
|
||||
"dataset validate": datasetValidate,
|
||||
"finetune create": finetuneCreate,
|
||||
"finetune list": finetuneList,
|
||||
"finetune get": finetuneGet,
|
||||
"finetune cancel": finetuneCancel,
|
||||
"finetune delete": finetuneDelete,
|
||||
"finetune logs": finetuneLogs,
|
||||
"finetune checkpoints": finetuneCheckpoints,
|
||||
"finetune export": finetuneExport,
|
||||
"finetune watch": finetuneWatch,
|
||||
"finetune capability": finetuneCapability,
|
||||
"deploy create": deployCreate,
|
||||
"deploy list": deployList,
|
||||
"deploy get": deployGet,
|
||||
"deploy models": deployModels,
|
||||
"deploy scale": deployScale,
|
||||
"deploy update": deployUpdate,
|
||||
"deploy delete": deployDelete,
|
||||
"token-plan list-seats": tokenPlanListSeats,
|
||||
"token-plan create-key": tokenPlanCreateKey,
|
||||
"token-plan assign-seats": tokenPlanAssignSeats,
|
||||
"token-plan add-member": tokenPlanAddMember,
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
chatEndpoint,
|
||||
defineCommand,
|
||||
getConfigPath,
|
||||
isInteractive,
|
||||
maskToken,
|
||||
readConfigFile,
|
||||
requestJson,
|
||||
writeConfigFile,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { printQuickStart } from "../../output/banner.ts";
|
||||
import { emitBare } from "../../output/output.ts";
|
||||
import { promptConfirm } from "../../output/prompt.ts";
|
||||
import { printCurrentCommandHelp } from "../../utils/command-help.ts";
|
||||
import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function validateKeyAndPersist(config: Config, key: string): Promise<void> {
|
||||
process.stderr.write("Testing key... ");
|
||||
const testConfig = { ...config, apiKey: key };
|
||||
const requestOpts = {
|
||||
url: chatEndpoint(testConfig.baseUrl),
|
||||
method: "POST",
|
||||
timeout: Math.min(config.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>(testConfig, requestOpts);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt >= 3 || !canRetry(err)) {
|
||||
process.stderr.write("\n");
|
||||
throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
// retry delay: 500ms, 1000ms, 2000ms
|
||||
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write("Valid\n");
|
||||
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
existing.api_key = key;
|
||||
await writeConfigFile(existing);
|
||||
process.stderr.write(`Saved to ${getConfigPath()}\n`);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "auth login",
|
||||
description: "Authenticate with API key or console browser login (credentials can coexist)",
|
||||
usage: "bl auth login --api-key <key> | bl auth login --console",
|
||||
options: [
|
||||
{ flag: "--api-key <key>", description: "DashScope API key to store" },
|
||||
{
|
||||
flag: "--console",
|
||||
description: "Sign in via browser; opens the console login URL in your default browser",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
examples: ["bl auth login --api-key sk-xxxxx", "bl auth login --console"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
if (flags.console) {
|
||||
if (config.dryRun) {
|
||||
emitBare(
|
||||
"Would bind a free port on 127.0.0.1 and open the console login URL in your browser.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const hasApiKey = !!(config.apiKey || config.fileApiKey);
|
||||
await runConsoleLogin(resolveConsoleOrigin(), {
|
||||
needApiKey: !hasApiKey,
|
||||
onApiKey: (key) => validateKeyAndPersist(config, key),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const envKey = process.env.DASHSCOPE_API_KEY;
|
||||
if (envKey && !flags.apiKey) {
|
||||
const maskedEnvKey = maskToken(envKey);
|
||||
if (isInteractive({ nonInteractive: config.nonInteractive })) {
|
||||
const proceed = await promptConfirm({
|
||||
message: `Detected DASHSCOPE_API_KEY in environment (${maskedEnvKey}).\nYou are already authenticated via env.\nDo you still want to configure local persistent credentials?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (!proceed) {
|
||||
process.stdout.write("Login skipped. Using environment variables.\n");
|
||||
process.exit(0);
|
||||
}
|
||||
} else {
|
||||
process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const key = (flags.apiKey as string) || config.apiKey;
|
||||
if (!key) {
|
||||
printCurrentCommandHelp(process.stderr);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!config.dryRun) {
|
||||
await validateKeyAndPersist(config, key);
|
||||
printQuickStart();
|
||||
} else {
|
||||
emitBare("Would validate and save API key.");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { Command } from "bailian-cli-core";
|
||||
|
||||
import authLogin from "./auth/login.ts";
|
||||
import authStatus from "./auth/status.ts";
|
||||
import authLogout from "./auth/logout.ts";
|
||||
import textChat from "./text/chat.ts";
|
||||
import textOmni from "./omni/chat.ts";
|
||||
import imageGenerate from "./image/generate.ts";
|
||||
import imageEdit from "./image/edit.ts";
|
||||
import videoGenerate from "./video/generate.ts";
|
||||
import videoEdit from "./video/edit.ts";
|
||||
import videoRef from "./video/ref.ts";
|
||||
import videoTaskGet from "./video/task-get.ts";
|
||||
import videoDownload from "./video/download.ts";
|
||||
import visionDescribe from "./vision/describe.ts";
|
||||
import configShow from "./config/show.ts";
|
||||
import configSet from "./config/set.ts";
|
||||
import configExportSchema from "./config/export-schema.ts";
|
||||
import update from "./update.ts";
|
||||
import appCall from "./app/call.ts";
|
||||
import appList from "./app/list.ts";
|
||||
import memoryAdd from "./memory/add.ts";
|
||||
import memorySearch from "./memory/search.ts";
|
||||
import memoryList from "./memory/list.ts";
|
||||
import memoryUpdate from "./memory/update.ts";
|
||||
import memoryDelete from "./memory/delete.ts";
|
||||
import memoryProfileCreate from "./memory/profile-create.ts";
|
||||
import memoryProfileGet from "./memory/profile-get.ts";
|
||||
import knowledgeRetrieve from "./knowledge/retrieve.ts";
|
||||
import mcpCall from "./mcp/call.ts";
|
||||
import mcpList from "./mcp/list.ts";
|
||||
import mcpTools from "./mcp/tools.ts";
|
||||
import searchWeb from "./search/web.ts";
|
||||
import speechSynthesize from "./speech/synthesize.ts";
|
||||
import speechRecognize from "./speech/recognize.ts";
|
||||
import fileUpload from "./file/upload.ts";
|
||||
import consoleCall from "./console/call.ts";
|
||||
import usageFree from "./usage/free.ts";
|
||||
import usageFreetier from "./usage/freetier.ts";
|
||||
import usageStats from "./usage/stats.ts";
|
||||
import pipelineRun from "./pipeline/run.ts";
|
||||
import pipelineValidate from "./pipeline/validate.ts";
|
||||
import advisorRecommend from "./advisor/recommend.ts";
|
||||
import workspaceList from "./workspace/list.ts";
|
||||
import quotaList from "./quota/list.ts";
|
||||
import quotaRequest from "./quota/request.ts";
|
||||
import quotaHistory from "./quota/history.ts";
|
||||
import quotaCheck from "./quota/check.ts";
|
||||
|
||||
/** Command registry map (no dependency on registry.ts — safe for build-time import). */
|
||||
export const commands: Record<string, Command> = {
|
||||
"auth login": authLogin,
|
||||
"auth status": authStatus,
|
||||
"auth logout": authLogout,
|
||||
"text chat": textChat,
|
||||
omni: textOmni,
|
||||
"image generate": imageGenerate,
|
||||
"image edit": imageEdit,
|
||||
"video generate": videoGenerate,
|
||||
"video edit": videoEdit,
|
||||
"video ref": videoRef,
|
||||
"video task get": videoTaskGet,
|
||||
"video download": videoDownload,
|
||||
"vision describe": visionDescribe,
|
||||
"app call": appCall,
|
||||
"app list": appList,
|
||||
"memory add": memoryAdd,
|
||||
"memory search": memorySearch,
|
||||
"memory list": memoryList,
|
||||
"memory update": memoryUpdate,
|
||||
"memory delete": memoryDelete,
|
||||
"memory profile create": memoryProfileCreate,
|
||||
"memory profile get": memoryProfileGet,
|
||||
"knowledge retrieve": knowledgeRetrieve,
|
||||
"mcp list": mcpList,
|
||||
"mcp tools": mcpTools,
|
||||
"mcp call": mcpCall,
|
||||
"search web": searchWeb,
|
||||
"speech synthesize": speechSynthesize,
|
||||
"speech recognize": speechRecognize,
|
||||
"file upload": fileUpload,
|
||||
"console call": consoleCall,
|
||||
"usage free": usageFree,
|
||||
"usage freetier": usageFreetier,
|
||||
"usage stats": usageStats,
|
||||
"pipeline run": pipelineRun,
|
||||
"pipeline validate": pipelineValidate,
|
||||
"config show": configShow,
|
||||
"config set": configSet,
|
||||
"config export-schema": configExportSchema,
|
||||
"advisor recommend": advisorRecommend,
|
||||
"workspace list": workspaceList,
|
||||
"quota list": quotaList,
|
||||
"quota request": quotaRequest,
|
||||
"quota history": quotaHistory,
|
||||
"quota check": quotaCheck,
|
||||
update: update,
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { defineCommand, generateToolSchema } from "bailian-cli-core";
|
||||
import type { Config } from "bailian-cli-core";
|
||||
import type { GlobalFlags } from "bailian-cli-core";
|
||||
import { BailianError } from "bailian-cli-core";
|
||||
import { ExitCode } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* Commands that are infrastructure/auth-related and not suitable as Agent tools.
|
||||
*/
|
||||
const SKIP_PREFIXES = ["auth ", "config ", "update"];
|
||||
|
||||
export default defineCommand({
|
||||
name: "config export-schema",
|
||||
description:
|
||||
"Export all (or one) CLI command(s) as Anthropic/OpenAI-compatible JSON tool schemas",
|
||||
usage: 'bl config export-schema [--command "<name>"]',
|
||||
options: [
|
||||
{
|
||||
flag: "--command <name>",
|
||||
description: 'Export schema for a specific command only (e.g. "image generate")',
|
||||
},
|
||||
],
|
||||
examples: ["bl config export-schema", 'bl config export-schema --command "video generate"'],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const { commands } = await import("../catalog.ts");
|
||||
const targetCommand = flags.command as string | undefined;
|
||||
|
||||
if (targetCommand) {
|
||||
const command = commands[targetCommand];
|
||||
if (!command) {
|
||||
throw new BailianError(`Command "${targetCommand}" not found.`, ExitCode.USAGE);
|
||||
}
|
||||
const schema = generateToolSchema(command);
|
||||
process.stdout.write(JSON.stringify(schema, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Export all suitable commands
|
||||
const allCommands = Object.values(commands);
|
||||
const schemas = allCommands
|
||||
.filter((c) => !SKIP_PREFIXES.some((p) => c.name.startsWith(p)))
|
||||
.map((c) => generateToolSchema(c));
|
||||
|
||||
process.stdout.write(JSON.stringify(schemas, null, 2) + "\n");
|
||||
},
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import {
|
||||
defineCommand,
|
||||
readConfigFile as loadConfigFile,
|
||||
getConfigPath,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "../../output/output.ts";
|
||||
|
||||
export default defineCommand({
|
||||
name: "config show",
|
||||
description: "Display current configuration",
|
||||
usage: "bl config show",
|
||||
examples: ["bl config show", "bl config show --output json"],
|
||||
async run(config: Config, _flags: GlobalFlags) {
|
||||
const file = loadConfigFile();
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
region: config.region,
|
||||
base_url: config.baseUrl,
|
||||
output: config.output,
|
||||
timeout: config.timeout,
|
||||
config_file: getConfigPath(),
|
||||
};
|
||||
|
||||
// Mask API key if present
|
||||
if (file.api_key) {
|
||||
result.api_key = maskToken(file.api_key);
|
||||
}
|
||||
if (file.access_token) {
|
||||
result.access_token = maskToken(file.access_token);
|
||||
}
|
||||
|
||||
// Default models
|
||||
if (file.default_text_model) result.default_text_model = file.default_text_model;
|
||||
if (file.default_video_model) result.default_video_model = file.default_video_model;
|
||||
if (file.default_image_model) result.default_image_model = file.default_image_model;
|
||||
|
||||
emitResult(result, format);
|
||||
},
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { commands } from "./catalog.ts";
|
||||
+9
-177
@@ -1,178 +1,10 @@
|
||||
import { scanCommandPath, parseFlags } from "./args.ts";
|
||||
import { registry } from "./registry.ts";
|
||||
import {
|
||||
GLOBAL_OPTIONS,
|
||||
loadConfig,
|
||||
resolveCredential,
|
||||
trackCommandExecution,
|
||||
flushTelemetry,
|
||||
} from "bailian-cli-core";
|
||||
import { ensureApiKey } from "./utils/ensure-key.ts";
|
||||
import { setupProxyFromEnv } from "./proxy.ts";
|
||||
import { handleError } from "./error-handler.ts";
|
||||
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
|
||||
import { maybeShowStatusBar } from "./output/status-bar.ts";
|
||||
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
|
||||
import { CLI_VERSION } from "./version.ts";
|
||||
import {
|
||||
printCurrentCommandHelp,
|
||||
registerCommandHelpPrinter,
|
||||
setExecutingCommandPath,
|
||||
} from "./utils/command-help.ts";
|
||||
import { createCli } from "bailian-cli-runtime";
|
||||
import { commands } from "./commands.ts";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
|
||||
// 必须在任何 fetch 发起前安装(含 update-checker / telemetry)
|
||||
try {
|
||||
setupProxyFromEnv();
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
|
||||
registerCommandHelpPrinter((commandPath, out) => {
|
||||
registry.printHelp(commandPath, out);
|
||||
});
|
||||
|
||||
// 优雅处理 Ctrl+C
|
||||
// 退出前尝试 best-effort 刷出埋点,让去抖队列中 / 在途的 fetch 请求有机会
|
||||
// 落网络;flush 与较短超时 race,保证 SIGINT 仍然响应及时。
|
||||
process.on("SIGINT", () => {
|
||||
process.stderr.write("\nInterrupted. Exiting.\n");
|
||||
void flushTelemetry(500).finally(() => process.exit(130));
|
||||
});
|
||||
|
||||
// 优雅处理 stdout EPIPE(例如管道到提前退出的 `mpv`)
|
||||
process.stdout.on("error", (e: NodeJS.ErrnoException) => {
|
||||
if (e.code === "EPIPE") process.exit(0);
|
||||
else throw e;
|
||||
});
|
||||
|
||||
// 自己接管鉴权 或 根本不需要 API key 的命令
|
||||
const NO_AUTH_SETUP = [
|
||||
["auth", "login"],
|
||||
["auth", "logout"],
|
||||
["config", "show"],
|
||||
["config", "set"],
|
||||
["config", "export-schema"],
|
||||
["update"],
|
||||
["knowledge", "retrieve"],
|
||||
["pipeline", "run"],
|
||||
["pipeline", "validate"],
|
||||
["model", "list"],
|
||||
["app", "list"],
|
||||
["console", "call"],
|
||||
["usage", "free"],
|
||||
["usage", "freetier"],
|
||||
["usage", "stats"],
|
||||
["mcp", "list"],
|
||||
["mcp", "tools"],
|
||||
["mcp", "call"],
|
||||
["workspace", "list"],
|
||||
["quota", "list"],
|
||||
["quota", "request"],
|
||||
["quota", "history"],
|
||||
["quota", "check"],
|
||||
];
|
||||
|
||||
async function main() {
|
||||
let argv = process.argv.slice(2);
|
||||
if (argv[0] === "--") argv = argv.slice(1);
|
||||
|
||||
if (argv.includes("--version") || argv.includes("-v")) {
|
||||
process.stdout.write(`bl ${CLI_VERSION}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const commandPath = scanCommandPath(argv, GLOBAL_OPTIONS);
|
||||
|
||||
if (argv.includes("--help") || argv.includes("-h")) {
|
||||
registry.printHelp(commandPath, process.stderr);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 未传任何命令:展示帮助信息与登录引导
|
||||
if (commandPath.length === 0) {
|
||||
registry.printHelp([], process.stderr);
|
||||
|
||||
const flags = parseFlags(argv, GLOBAL_OPTIONS);
|
||||
const config = loadConfig(flags);
|
||||
config.clientName = "bailian-cli";
|
||||
config.clientVersion = CLI_VERSION;
|
||||
|
||||
const hasKey = !!(
|
||||
config.apiKey ||
|
||||
config.fileApiKey ||
|
||||
config.fileAccessToken ||
|
||||
config.accessTokenEnv
|
||||
);
|
||||
if (hasKey) printQuickStart();
|
||||
else printWelcomeBanner();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 组路径(例如 `bl speech` 未接子命令):展示帮助后干净退出
|
||||
if (registry.isGroupPath(commandPath)) {
|
||||
registry.printHelp(commandPath, process.stderr);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { command, extra } = registry.resolve(commandPath);
|
||||
const flags = parseFlags(argv, [...GLOBAL_OPTIONS, ...(command.options ?? [])]);
|
||||
|
||||
if (extra.length > 0) (flags as Record<string, unknown>)._positional = extra;
|
||||
|
||||
const config = loadConfig(flags);
|
||||
config.clientName = "bailian-cli";
|
||||
config.clientVersion = CLI_VERSION;
|
||||
|
||||
const needsAuthSetup = !NO_AUTH_SETUP.some((cmd) => cmd.every((c, i) => commandPath[i] === c));
|
||||
if (needsAuthSetup) {
|
||||
await ensureApiKey(config);
|
||||
try {
|
||||
const credential = await resolveCredential(config);
|
||||
maybeShowStatusBar(config, credential.token, credential);
|
||||
} catch {
|
||||
/* 没有凭证,不展示状态栏 */
|
||||
}
|
||||
}
|
||||
|
||||
const updateCheckPromise = checkForUpdate(CLI_VERSION).catch(() => {});
|
||||
|
||||
setExecutingCommandPath(commandPath);
|
||||
|
||||
if (
|
||||
commandPath[0] === "auth" &&
|
||||
commandPath[1] === "login" &&
|
||||
!flags.console &&
|
||||
!String((flags.apiKey as string | undefined) ?? "").trim() &&
|
||||
!String(config.apiKey ?? "").trim() &&
|
||||
!process.env.DASHSCOPE_API_KEY?.trim()
|
||||
) {
|
||||
printCurrentCommandHelp(process.stderr);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await trackCommandExecution(config, commandPath, flags, () => command.execute(config, flags));
|
||||
|
||||
await updateCheckPromise;
|
||||
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
|
||||
const newVersion = getPendingUpdateNotification();
|
||||
if (newVersion && !config.quiet && !isUpdateCommand) {
|
||||
const isTTY = process.stderr.isTTY;
|
||||
const yellow = isTTY ? "\x1b[33m" : "";
|
||||
const cyan = isTTY ? "\x1b[36m" : "";
|
||||
const reset = isTTY ? "\x1b[0m" : "";
|
||||
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION} → ${newVersion}${reset}\n`);
|
||||
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
|
||||
}
|
||||
|
||||
// 进程退出前尽力等待在途的埋点完成。
|
||||
// 使用较短超时兜底,避免慢网拖慢用户感知。
|
||||
await flushTelemetry(1000);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// 在 handleError() 调用 process.exit() 之前刷出在途埋点。
|
||||
// 命令抛出的错误已被 trackCommandExecution 的 finally 块记录,
|
||||
// 但底层 tracker 有 ~500ms 的发送去抖。不主动 flush 的话,
|
||||
// 错误事件会随进程退出丢掉。
|
||||
void flushTelemetry(1000).finally(() => handleError(err));
|
||||
});
|
||||
void createCli(commands, {
|
||||
binName: "bl",
|
||||
version: pkg.version,
|
||||
clientName: "bailian-cli",
|
||||
npmPackage: "bailian-cli",
|
||||
}).run();
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { join } from "path";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { getConfigDir, trackingHeaders } from "bailian-cli-core";
|
||||
|
||||
export const NPM_REGISTRY = "https://registry.npmjs.org";
|
||||
export const NPM_PACKAGE = "bailian-cli";
|
||||
|
||||
const STATE_FILE = () => join(getConfigDir(), "update-state.json");
|
||||
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
|
||||
const FETCH_TIMEOUT_MS = 3000;
|
||||
|
||||
/**
|
||||
* Simple semver comparison: returns true if a > b.
|
||||
* Supports standard x.y.z format.
|
||||
*/
|
||||
function isNewerVersion(a: string, b: string): boolean {
|
||||
const pa = a.split(".").map(Number);
|
||||
const pb = b.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
||||
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
||||
}
|
||||
return false; // equal
|
||||
}
|
||||
|
||||
interface UpdateState {
|
||||
lastChecked: number;
|
||||
latestVersion: string;
|
||||
}
|
||||
|
||||
function readState(): UpdateState | null {
|
||||
try {
|
||||
const raw = readFileSync(STATE_FILE(), "utf-8");
|
||||
return JSON.parse(raw) as UpdateState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(state: UpdateState): void {
|
||||
try {
|
||||
writeFileSync(STATE_FILE(), JSON.stringify(state));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLatestVersion(
|
||||
timeoutMs: number = FETCH_TIMEOUT_MS,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const encoded = NPM_PACKAGE.replace("/", "%2f");
|
||||
const res = await fetch(`${NPM_REGISTRY}/${encoded}/latest`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...trackingHeaders(),
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { version?: string };
|
||||
return data.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let pendingNotification: string | null = null;
|
||||
|
||||
export function getPendingUpdateNotification(): string | null {
|
||||
return pendingNotification;
|
||||
}
|
||||
|
||||
export async function checkForUpdate(currentVersion: string): Promise<void> {
|
||||
// Skip in CI / non-TTY environments
|
||||
if (process.env.CI || !process.stderr.isTTY) return;
|
||||
|
||||
const state = readState();
|
||||
const now = Date.now();
|
||||
|
||||
// Throttle: skip if checked within the last 4 hours
|
||||
if (state && now - state.lastChecked < CHECK_INTERVAL_MS) {
|
||||
if (state.latestVersion && isNewerVersion(state.latestVersion, currentVersion)) {
|
||||
pendingNotification = state.latestVersion;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = await fetchLatestVersion();
|
||||
if (!latest) return;
|
||||
|
||||
writeState({ lastChecked: now, latestVersion: latest });
|
||||
|
||||
if (latest && isNewerVersion(latest, currentVersion)) {
|
||||
pendingNotification = latest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"text":"大型语言模型(LLM)是深度学习领域中近年来最受关注的方向之一。"}
|
||||
{"text":"持续预训练(CPT)旨在已有模型的基础上,注入领域语料以提升下游能力。"}
|
||||
@@ -0,0 +1 @@
|
||||
{"messages":[{"role":"user","content":"hi"}],"chosen":{"role":"assistant","content":"good"}}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"messages":[{"role":"user","content":"你能帮我写一篇文章吗?"}],"chosen":{"role":"assistant","content":"当然可以,请告诉我具体方向。"},"rejected":{"role":"assistant","content":"可以。"}}
|
||||
{"messages":[{"role":"user","content":"安排一下明天的日程?"}],"chosen":{"role":"assistant","content":"当然,请告诉我具体事项。"},"rejected":{"role":"assistant","content":"好的。"}}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"messages": [
|
||||
{ "role": "user", "content": "this is pretty-printed JSON, not JSONL" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Hi"},{"role":"assistant","content":"Hello!"}]}
|
||||
{"messages":[{"role":"user","content":"What is 1+1?"},{"role":"assistant","content":"2"}]}
|
||||
{"messages":[{"role":"user","content":"Bye"},{"role":"assistant","content":"Goodbye."}]}
|
||||
@@ -2,21 +2,21 @@ import { describe, expect, test } from "vite-plus/test";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
describe("e2e: advisor recommend", () => {
|
||||
test("advisor 分组展示子命令帮助且成功退出", async () => {
|
||||
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 正常退出", async () => {
|
||||
test("advisor recommend --help exits successfully", async () => {
|
||||
const { stderr, exitCode } = await runCli(["advisor", "recommend", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/recommend|--message|dry-run/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend(DashScope)", () => {
|
||||
test("advisor recommend 缺少 --message 时打印帮助并退出 (0)", async () => {
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () => {
|
||||
test("advisor recommend without --message prints help and exits", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
@@ -26,13 +26,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend(DashScope)",
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/--message|Usage:/i);
|
||||
});
|
||||
|
||||
test("advisor recommend --dry-run 输出意图分析和候选列表", async () => {
|
||||
test("advisor recommend --dry-run outputs intent analysis and candidates", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"我想做一个能理解图片的客服机器人",
|
||||
"I want to build a customer service bot that understands images",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
@@ -44,7 +44,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend(DashScope)",
|
||||
candidateCount?: number;
|
||||
candidates?: Array<{ model?: string; score?: number }>;
|
||||
}>(stdout);
|
||||
expect(data.userInput).toBe("我想做一个能理解图片的客服机器人");
|
||||
expect(data.userInput).toBe("I want to build a customer service bot that understands images");
|
||||
expect(data.intent?.requiredCapabilities).toContain("VU");
|
||||
expect(data.intent?.inputModality).toContain("Image");
|
||||
expect(data.candidateCount).toBeGreaterThan(0);
|
||||
@@ -52,40 +52,44 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend(DashScope)",
|
||||
expect(data.candidates?.[0]?.score).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
test("advisor recommend 完整推荐流程返回结果", async () => {
|
||||
test("advisor recommend full flow returns results", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--message",
|
||||
"低成本高并发的在线客服",
|
||||
"low-cost high-concurrency online customer service",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
type?: string;
|
||||
recommendations?: Array<{
|
||||
model?: string;
|
||||
name?: string;
|
||||
reason?: string;
|
||||
}>;
|
||||
intent?: { taskSummary?: string };
|
||||
result?: {
|
||||
type?: string;
|
||||
recommendations?: Array<{
|
||||
model?: string;
|
||||
name?: string;
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
candidates?: number;
|
||||
}>(stdout);
|
||||
expect(data.type).toBe("single");
|
||||
expect(data.recommendations?.length).toBeGreaterThan(0);
|
||||
expect(data.recommendations?.[0]?.model).toBeDefined();
|
||||
expect(data.recommendations?.[0]?.reason).toBeDefined();
|
||||
expect(data.result?.type).toBe("single");
|
||||
expect(data.result?.recommendations?.length).toBeGreaterThan(0);
|
||||
expect(data.result?.recommendations?.[0]?.model).toBeDefined();
|
||||
expect(data.result?.recommendations?.[0]?.reason).toBeDefined();
|
||||
}, 120_000);
|
||||
|
||||
// ---- 模型偏好:正例 ----
|
||||
// ---- Model preference: positive cases ----
|
||||
|
||||
test("scoped 偏好 — 限定系列时 intent 含 modelPreference.mode=scoped", async () => {
|
||||
test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"deepseek系列中哪个模型最适合用来进行快速推理",
|
||||
"Which model in the deepseek family is best for fast reasoning?",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
@@ -103,13 +107,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend(DashScope)",
|
||||
).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("comparison 偏好 — 对比模型时 intent 含 modelPreference.mode=comparison", async () => {
|
||||
test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"qwen-max和deepseek-v3哪个更适合做代码生成",
|
||||
"Which is better for code generation, qwen-max or deepseek-v3?",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
@@ -122,40 +126,29 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend(DashScope)",
|
||||
expect(data.intent?.modelPreference?.targets?.length).toBeGreaterThanOrEqual(2);
|
||||
}, 60_000);
|
||||
|
||||
test("excludes 偏好 — 排除模型时 intent 识别出 modelPreference", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
test("excludes preference — intent detects modelPreference when excluding models", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"不要qwen,推荐一个适合文本生成的模型",
|
||||
"Not qwen, recommend a model suitable for text generation",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
intent?: {
|
||||
modelPreference?: { mode?: string; excludes?: string[]; targets?: string[] };
|
||||
};
|
||||
}>(stdout);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref).toBeDefined();
|
||||
const hasExcludes =
|
||||
(pref?.excludes?.length ?? 0) > 0 ||
|
||||
(pref?.mode !== "unconstrained" && pref?.mode !== undefined);
|
||||
expect(hasExcludes).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
// ---- 模型偏好:反例 ----
|
||||
// ---- Model preference: negative cases ----
|
||||
|
||||
test("无偏好 — 普通需求查询时 intent 不含 modelPreference 或 mode=unconstrained", async () => {
|
||||
test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"我要做一个能理解图片的客服机器人",
|
||||
"I want to build a customer service bot that understands images",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("e2e: auth", () => {
|
||||
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/);
|
||||
});
|
||||
|
||||
test("auth logout --help 正常退出", async () => {
|
||||
@@ -159,20 +160,25 @@ describe("e2e: auth", () => {
|
||||
expect(data.dashscope_commands?.method).toBeDefined();
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())("auth status --output json --quiet --region cn", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"status",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--quiet",
|
||||
"--region",
|
||||
"cn",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ authenticated?: boolean; dashscope_commands?: unknown }>(stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.dashscope_commands).toBeDefined();
|
||||
});
|
||||
test.skipIf(!isDashScopeE2EReady())(
|
||||
"auth status --output json --quiet --base-url 国内",
|
||||
async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"status",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--quiet",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ authenticated?: boolean; dashscope_commands?: unknown }>(
|
||||
stdout,
|
||||
);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.dashscope_commands).toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,12 +25,6 @@ describe("e2e: config", () => {
|
||||
expect(stderr).toMatch(/set|--key|--value/i);
|
||||
});
|
||||
|
||||
test("config export-schema --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["config", "export-schema", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/export-schema|--command/i);
|
||||
});
|
||||
|
||||
test("config show --output json", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
@@ -41,12 +35,10 @@ describe("e2e: config", () => {
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
region?: string;
|
||||
config_file?: string;
|
||||
base_url?: string;
|
||||
timeout?: number;
|
||||
}>(stdout);
|
||||
expect(data.region).toBeDefined();
|
||||
expect(data.config_file).toBeDefined();
|
||||
expect(data.base_url).toBeDefined();
|
||||
expect(data.timeout).toBeDefined();
|
||||
@@ -62,7 +54,7 @@ describe("e2e: config", () => {
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toMatch(/region|config_file|timeout|base_url/i);
|
||||
expect(stdout).toMatch(/config_file|timeout|base_url/i);
|
||||
});
|
||||
|
||||
test("config set 缺少 --key / --value 时退出为用法错误 (2)", async () => {
|
||||
@@ -85,20 +77,6 @@ describe("e2e: config", () => {
|
||||
expect(stderr).toMatch(/Invalid config key|not-a-real-key/i);
|
||||
});
|
||||
|
||||
test("config set 非法 region", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--non-interactive",
|
||||
"--key",
|
||||
"region",
|
||||
"--value",
|
||||
"invalid-region",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid region|cn, us, intl/i);
|
||||
});
|
||||
|
||||
test("config set 非法 output", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
@@ -162,46 +140,4 @@ describe("e2e: config", () => {
|
||||
const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout);
|
||||
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
|
||||
});
|
||||
|
||||
test("config export-schema --command 导出单条工具 JSON", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"export-schema",
|
||||
"--command",
|
||||
"text chat",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const schema = parseStdoutJson<{ name?: string; input_schema?: { type?: string } }>(stdout);
|
||||
expect(schema.name).toMatch(/bailian_text_chat/);
|
||||
expect(schema.input_schema?.type).toBe("object");
|
||||
});
|
||||
|
||||
test("config export-schema 不存在的子命令时报错", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"export-schema",
|
||||
"--command",
|
||||
"this-command-does-not-exist-xyz",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
const err = JSON.parse(stderr.trim()) as { error?: { message?: string } };
|
||||
expect(err.error?.message).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
test("config export-schema 导出全部为 JSON 数组", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"export-schema",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const arr = parseStdoutJson<Array<{ name?: string }>>(stdout);
|
||||
expect(Array.isArray(arr)).toBe(true);
|
||||
expect(arr.length).toBeGreaterThan(0);
|
||||
expect(arr[0]?.name).toMatch(/^bailian_/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
type ConsoleDryRunMeta = {
|
||||
consoleRegion?: string;
|
||||
consoleSite?: string;
|
||||
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 不重复命令级 region,并提示全局 flags", async () => {
|
||||
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/Global flags.*always available/i);
|
||||
expect(stderr).toMatch(/--model <model>/);
|
||||
expect(stderr).toMatch(/--period <minutes>/);
|
||||
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
|
||||
});
|
||||
|
||||
test("console call --help 不暴露命令级 region/site,示例使用 --console-region", async () => {
|
||||
const { stderr, exitCode } = await runCli(["console", "call", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--api <api>/);
|
||||
expect(stderr).toMatch(/--data <json>/);
|
||||
expect(stderr).not.toMatch(/^\s*--region\s/m);
|
||||
expect(stderr).not.toMatch(/^\s*--site\s/m);
|
||||
expect(stderr).toMatch(/--console-region cn-beijing/);
|
||||
});
|
||||
|
||||
test("auth login --help 描述 --console 与 --console-site 配合", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--console-site/);
|
||||
expect(stderr).toMatch(/--console.*console-site|console-site.*domestic|international/i);
|
||||
});
|
||||
|
||||
test("console call --dry-run 默认 consoleRegion 为 cn-beijing", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"console",
|
||||
"call",
|
||||
"--api",
|
||||
"some.api.name",
|
||||
"--data",
|
||||
"{}",
|
||||
"--dry-run",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
|
||||
expect(data.consoleRegion).toBe("cn-beijing");
|
||||
expect(data.consoleSite).toBe("domestic");
|
||||
});
|
||||
|
||||
test("console call --dry-run --console-region / --console-site / --console-switch-agent 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"console",
|
||||
"call",
|
||||
"--api",
|
||||
"some.api.name",
|
||||
"--data",
|
||||
"{}",
|
||||
"--dry-run",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--console-region",
|
||||
"ap-southeast-1",
|
||||
"--console-site",
|
||||
"international",
|
||||
"--console-switch-agent",
|
||||
"12345",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
|
||||
expect(data.consoleRegion).toBe("ap-southeast-1");
|
||||
expect(data.consoleSite).toBe("international");
|
||||
expect(data.consoleSwitchAgent).toBe(12345);
|
||||
});
|
||||
|
||||
test("console call 拒绝未知全局 flag --region", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"console",
|
||||
"call",
|
||||
"--api",
|
||||
"some.api.name",
|
||||
"--data",
|
||||
"{}",
|
||||
"--dry-run",
|
||||
"--non-interactive",
|
||||
"--region",
|
||||
"cn",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/Unknown flag.*--region/);
|
||||
});
|
||||
|
||||
test("mcp list --dry-run --console-region 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"mcp",
|
||||
"list",
|
||||
"--dry-run",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--console-region",
|
||||
"cn-hangzhou",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
|
||||
expect(data.consoleRegion).toBe("cn-hangzhou");
|
||||
});
|
||||
|
||||
test("quota check --dry-run --console-region 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"quota",
|
||||
"check",
|
||||
"--dry-run",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--console-region",
|
||||
"cn-hangzhou",
|
||||
"--console-site",
|
||||
"international",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
|
||||
expect(data.consoleRegion).toBe("cn-hangzhou");
|
||||
expect(data.consoleSite).toBe("international");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Dataset (fine-tune file) E2E.
|
||||
*
|
||||
* The suite exercises command discovery, help text, local dataset validation,
|
||||
* and the `--dry-run` upload preview with no network dependency. Because
|
||||
* `ensureApiKey` runs before every command (see main.ts), these cases are
|
||||
* gated by isDashScopeE2EReady() — they are skipped when no DashScope
|
||||
* credential is present (e.g. on CI) and run offline when one is. (`dataset
|
||||
* validate` itself is keyless via skipDefaultApiKeySetup, but the rest of the
|
||||
* suite needs a key, so the whole offline block is gated together.) The
|
||||
* remote list test is also gated.
|
||||
*/
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
|
||||
test("dataset --help 列出子命令", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["dataset"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const out = `${stdout}\n${stderr}`;
|
||||
expect(out).toMatch(/upload|list|get|delete|validate/);
|
||||
});
|
||||
|
||||
test("dataset upload --help 正常退出并展示 --file", async () => {
|
||||
const { stderr, exitCode } = await runCli(["dataset", "upload", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--file|jsonl/i);
|
||||
});
|
||||
|
||||
test("dataset validate 通过合法 JSONL", async () => {
|
||||
const file = join(__dirname, ".dataset-valid.jsonl");
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"validate",
|
||||
"--file",
|
||||
file,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ valid: boolean; format: string }>(stdout);
|
||||
expect(data.valid).toBe(true);
|
||||
expect(data.format).toBe("jsonl");
|
||||
});
|
||||
|
||||
test("dataset validate 拒绝 pretty-printed JSON 并以非零码退出", async () => {
|
||||
const file = join(__dirname, ".dataset-invalid.jsonl");
|
||||
const { stdout, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"validate",
|
||||
"--file",
|
||||
file,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
// The structured result is still emitted to stdout before the error throws.
|
||||
if (stdout.trim().length > 0) {
|
||||
const data = parseStdoutJson<{ valid: boolean; errors: unknown[] }>(stdout);
|
||||
expect(data.valid).toBe(false);
|
||||
expect(Array.isArray(data.errors)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("dataset upload --no-validate --dry-run 跳过本地校验", async () => {
|
||||
const file = join(__dirname, ".dataset-invalid.jsonl");
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"upload",
|
||||
"--file",
|
||||
file,
|
||||
"--no-validate",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ action: string; validate: boolean }>(stdout);
|
||||
expect(data.action).toBe("dataset.upload");
|
||||
expect(data.validate).toBe(false);
|
||||
});
|
||||
|
||||
test("dataset validate 自动识别 DPO 并校验 chosen/rejected", async () => {
|
||||
// No --schema: a record carrying chosen/rejected is auto-detected as DPO
|
||||
// and the valid fixture passes.
|
||||
const file = join(__dirname, ".dataset-dpo-valid.jsonl");
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"validate",
|
||||
"--file",
|
||||
file,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ valid: boolean; stats: { totalRecords?: number } }>(stdout);
|
||||
expect(data.valid).toBe(true);
|
||||
expect(data.stats.totalRecords).toBe(2);
|
||||
});
|
||||
|
||||
test("dataset validate 自动识别 CPT 并校验 {text} 记录", async () => {
|
||||
// No --schema: a record carrying `text` (and no `messages`) is auto-detected
|
||||
// as CPT and the valid fixture passes.
|
||||
const file = join(__dirname, ".dataset-cpt-valid.jsonl");
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"validate",
|
||||
"--file",
|
||||
file,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ valid: boolean; stats: { totalRecords?: number } }>(stdout);
|
||||
expect(data.valid).toBe(true);
|
||||
expect(data.stats.totalRecords).toBe(2);
|
||||
});
|
||||
|
||||
test("dataset validate --schema cpt 拒绝缺失 text 的记录", async () => {
|
||||
const file = join(__dirname, ".dataset-valid.jsonl"); // SFT {messages}, no text
|
||||
const { stdout, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"validate",
|
||||
"--file",
|
||||
file,
|
||||
"--schema",
|
||||
"cpt",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
const data = parseStdoutJson<{ valid: boolean; errors: { code: string; path?: string }[] }>(
|
||||
stdout,
|
||||
);
|
||||
expect(data.valid).toBe(false);
|
||||
expect(data.errors.map((e) => e.code)).toContain("MISSING_TEXT");
|
||||
});
|
||||
|
||||
test("dataset validate --schema dpo 拒绝缺失 rejected 的记录", async () => {
|
||||
const file = join(__dirname, ".dataset-dpo-invalid.jsonl");
|
||||
const { stdout, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"validate",
|
||||
"--file",
|
||||
file,
|
||||
"--schema",
|
||||
"dpo",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
const data = parseStdoutJson<{ valid: boolean; errors: { code: string; path?: string }[] }>(
|
||||
stdout,
|
||||
);
|
||||
expect(data.valid).toBe(false);
|
||||
expect(data.errors.map((e) => e.code)).toContain("MISSING_REJECTED");
|
||||
});
|
||||
|
||||
test("dataset validate --schema chatml 忽略 chosen/rejected(不报 DPO 错误)", async () => {
|
||||
// Same invalid-DPO file, but --schema chatml must not run DPO checks.
|
||||
const file = join(__dirname, ".dataset-dpo-invalid.jsonl");
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"validate",
|
||||
"--file",
|
||||
file,
|
||||
"--schema",
|
||||
"chatml",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ valid: boolean; errors: { code: string }[] }>(stdout);
|
||||
expect(data.valid).toBe(true);
|
||||
expect(data.errors.filter((c) => c.code.startsWith("MISSING_"))).toEqual([]);
|
||||
});
|
||||
|
||||
test("dataset validate --schema <bad> 以非零码退出", async () => {
|
||||
const file = join(__dirname, ".dataset-valid.jsonl");
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"validate",
|
||||
"--file",
|
||||
file,
|
||||
"--schema",
|
||||
"sft",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/Unsupported --schema/);
|
||||
});
|
||||
|
||||
test("dataset upload --dry-run 转发 --schema", async () => {
|
||||
const file = join(__dirname, ".dataset-dpo-valid.jsonl");
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"upload",
|
||||
"--file",
|
||||
file,
|
||||
"--schema",
|
||||
"dpo",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ action: string; schema: string }>(stdout);
|
||||
expect(data.action).toBe("dataset.upload");
|
||||
expect(data.schema).toBe("dpo");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (DashScope)", () => {
|
||||
test("dataset list --output json 返回结构化结果", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"dataset",
|
||||
"list",
|
||||
"--page-size",
|
||||
"5",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ data?: { files?: unknown[] } }>(stdout);
|
||||
expect(data).toBeTruthy();
|
||||
if (data.data?.files) {
|
||||
expect(Array.isArray(data.data.files)).toBe(true);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
/**
|
||||
* Deploy E2E.
|
||||
*
|
||||
* The suite exercises command discovery, help text, and the `--dry-run`
|
||||
* structured-output path (arg parsing + body construction) with no network
|
||||
* dependency. Because `ensureApiKey` runs before every command (see main.ts),
|
||||
* these cases are gated by isDashScopeE2EReady() — they are skipped when no
|
||||
* DashScope credential is present (e.g. on CI) and run offline when one is.
|
||||
* The remote list test is also gated and tolerates both empty accounts and
|
||||
* auth/permission failures (see the test comment).
|
||||
*/
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
|
||||
test("deploy 列出子命令", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["deploy"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const out = `${stdout}\n${stderr}`;
|
||||
expect(out).toMatch(/create|list|get|delete|update|scale|models/);
|
||||
});
|
||||
|
||||
test("deploy create --help 正常退出并展示必填项", async () => {
|
||||
const { stderr, exitCode } = await runCli(["deploy", "create", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--model|--name/i);
|
||||
});
|
||||
|
||||
test("deploy create --dry-run 构造 lora 部署请求体", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"deploy",
|
||||
"create",
|
||||
"--model",
|
||||
"qwen-plus-2025-12-01",
|
||||
"--name",
|
||||
"my-qwen-plus",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
action: string;
|
||||
body: {
|
||||
model_name: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
capacity: number;
|
||||
};
|
||||
}>(stdout);
|
||||
expect(data.action).toBe("deploy.create");
|
||||
expect(data.body.model_name).toBe("qwen-plus-2025-12-01");
|
||||
expect(data.body.name).toBe("my-qwen-plus");
|
||||
expect(data.body.plan).toBe("lora");
|
||||
expect(data.body.capacity).toBe(1);
|
||||
});
|
||||
|
||||
test("deploy scale --dry-run 转发 capacity", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"deploy",
|
||||
"scale",
|
||||
"--deployed-model",
|
||||
"dep-xxx",
|
||||
"--capacity",
|
||||
"8",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
action: string;
|
||||
deployed_model: string;
|
||||
body: { capacity: number };
|
||||
}>(stdout);
|
||||
expect(data.action).toBe("deploy.scale");
|
||||
expect(data.deployed_model).toBe("dep-xxx");
|
||||
expect(data.body.capacity).toBe(8);
|
||||
});
|
||||
|
||||
test("deploy update --dry-run 转发 rate limits", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"deploy",
|
||||
"update",
|
||||
"--deployed-model",
|
||||
"dep-xxx",
|
||||
"--rpm-limit",
|
||||
"1000",
|
||||
"--tpm-limit",
|
||||
"200000",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
action: string;
|
||||
body: { rpm_limit: number; tpm_limit: number };
|
||||
}>(stdout);
|
||||
expect(data.action).toBe("deploy.update");
|
||||
expect(data.body.rpm_limit).toBe(1000);
|
||||
expect(data.body.tpm_limit).toBe(200000);
|
||||
});
|
||||
|
||||
test("deploy scale --dry-run 缺少 capacity/input-tpm/output-tpm 时报错", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"deploy",
|
||||
"scale",
|
||||
"--deployed-model",
|
||||
"dep-xxx",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).not.toBe(0);
|
||||
// Nothing useful emitted to stdout on a usage error.
|
||||
expect(stdout.trim()).toBe("");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["list", ["--status", "RUNNING"]],
|
||||
["get", ["--deployed-model", "dep-xxx"]],
|
||||
["models", ["--source", "custom"]],
|
||||
["delete", ["--deployed-model", "dep-xxx"]],
|
||||
])("deploy %s --dry-run 发出结构化动作", async (sub, extra) => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"deploy",
|
||||
sub,
|
||||
...extra,
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ action: string }>(stdout);
|
||||
expect(data.action).toBe(`deploy.${sub}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (DashScope)", () => {
|
||||
/**
|
||||
* 不同开发者的 key 状态不一:可能鉴权失败、可能账号下没有任何部署记录、
|
||||
* 也可能受区域/权限限制。因此本用例不假设"有数据"或"调用成功":
|
||||
* - 成功(exit 0):响应必须可解析;deployments 可能为空数组或不存在。
|
||||
* - 失败(非零退出):只要 CLI 把服务端/鉴权错误优雅上抛(stderr 有内容、
|
||||
* 而非进程崩溃),即视为通过。
|
||||
*/
|
||||
test("deploy list --output json 优雅返回(空账号或鉴权失败均通过)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"deploy",
|
||||
"list",
|
||||
"--page-size",
|
||||
"5",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
if (exitCode === 0) {
|
||||
const data = parseStdoutJson<{ data?: { deployments?: unknown[] } }>(stdout);
|
||||
expect(data).toBeTruthy();
|
||||
if (data.data?.deployments) {
|
||||
expect(Array.isArray(data.data.deployments)).toBe(true);
|
||||
}
|
||||
} else {
|
||||
expect(stderr.length).toBeGreaterThan(0);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { join } from "path";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCli, cliPackageRoot } from "./helpers.ts";
|
||||
|
||||
/**
|
||||
* Fine-tune E2E.
|
||||
*
|
||||
* The suite exercises command discovery, help text, and the `--dry-run`
|
||||
* structured-output path (arg parsing + body construction) with no network
|
||||
* dependency. Because `ensureApiKey` runs before every command (see main.ts),
|
||||
* these cases are gated by isDashScopeE2EReady() — they are skipped when no
|
||||
* DashScope credential is present (e.g. on CI) and run offline when one is.
|
||||
* The remote list test is also gated and tolerates both empty accounts and
|
||||
* auth/permission failures (see the test comment).
|
||||
*/
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
|
||||
test("finetune 列出子命令", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["finetune"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const out = `${stdout}\n${stderr}`;
|
||||
expect(out).toMatch(/create|list|get|cancel|delete|logs|checkpoints|export|watch|capability/);
|
||||
});
|
||||
|
||||
test("finetune create --help 正常退出并展示必填项", async () => {
|
||||
const { stderr, exitCode } = await runCli(["finetune", "create", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--model|--datasets/i);
|
||||
});
|
||||
|
||||
test("finetune create --dry-run 构造 SFT 默认请求体", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"finetune",
|
||||
"create",
|
||||
"--model",
|
||||
"qwen3-8b",
|
||||
"--datasets",
|
||||
"file-aaa,file-bbb",
|
||||
"--validations",
|
||||
"file-ccc",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
action: string;
|
||||
body: {
|
||||
model: string;
|
||||
training_file_ids: string[];
|
||||
validation_file_ids: string[];
|
||||
training_type: string;
|
||||
hyper_parameters: { n_epochs: number };
|
||||
};
|
||||
}>(stdout);
|
||||
expect(data.action).toBe("finetune.create");
|
||||
expect(data.body.model).toBe("qwen3-8b");
|
||||
expect(data.body.training_file_ids).toEqual(["file-aaa", "file-bbb"]);
|
||||
expect(data.body.validation_file_ids).toEqual(["file-ccc"]);
|
||||
expect(data.body.training_type).toBe("efficient_sft");
|
||||
expect(data.body.hyper_parameters.n_epochs).toBe(3);
|
||||
});
|
||||
|
||||
test("finetune create --dry-run 转发训练类型与超参", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"finetune",
|
||||
"create",
|
||||
"--model",
|
||||
"qwen3-8b",
|
||||
"--datasets",
|
||||
"file-aaa",
|
||||
"--training-type",
|
||||
"sft-lora",
|
||||
"--n-epochs",
|
||||
"5",
|
||||
"--batch-size",
|
||||
"16",
|
||||
"--learning-rate",
|
||||
"1.6e-5",
|
||||
"--max-length",
|
||||
"4096",
|
||||
"--model-name",
|
||||
"my-qwen-sft",
|
||||
"--suffix",
|
||||
"v1",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
action: string;
|
||||
body: {
|
||||
training_type: string;
|
||||
model_name: string;
|
||||
finetuned_output_suffix: string;
|
||||
hyper_parameters: {
|
||||
n_epochs: number;
|
||||
batch_size: number;
|
||||
learning_rate: string;
|
||||
max_length: number;
|
||||
};
|
||||
};
|
||||
}>(stdout);
|
||||
expect(data.body.training_type).toBe("efficient_sft");
|
||||
expect(data.body.model_name).toBe("my-qwen-sft");
|
||||
expect(data.body.finetuned_output_suffix).toBe("v1");
|
||||
// batch_size is forwarded verbatim when within the [8, 1024] server range.
|
||||
expect(data.body.hyper_parameters).toEqual({
|
||||
n_epochs: 5,
|
||||
batch_size: 16,
|
||||
learning_rate: "1.6e-5",
|
||||
max_length: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
test("finetune create --training-type 拒绝不支持的训练类型值", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"finetune",
|
||||
"create",
|
||||
"--model",
|
||||
"qwen3-8b",
|
||||
"--datasets",
|
||||
"file-aaa",
|
||||
"--training-type",
|
||||
"cpt-lora",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stdout + stderr).not.toBe(0);
|
||||
});
|
||||
|
||||
test("finetune create --dry-run 把本地路径标记为 pending 上传且不发起网络请求", async () => {
|
||||
const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl");
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"finetune",
|
||||
"create",
|
||||
"--model",
|
||||
"qwen3-8b",
|
||||
"--datasets",
|
||||
`${localPath},file-bbb`,
|
||||
"--validations",
|
||||
localPath,
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
action: string;
|
||||
body: { training_file_ids: string[]; validation_file_ids: string[] };
|
||||
pending_uploads: { field: string; path: string }[];
|
||||
}>(stdout);
|
||||
expect(data.action).toBe("finetune.create");
|
||||
// Local path preserved verbatim in the body (no upload in dry-run).
|
||||
expect(data.body.training_file_ids[0]).toBe(localPath);
|
||||
expect(data.body.training_file_ids[1]).toBe("file-bbb");
|
||||
expect(data.body.validation_file_ids).toEqual([localPath]);
|
||||
// Two pending uploads: training (1 local) + validation (1 local).
|
||||
expect(data.pending_uploads).toHaveLength(2);
|
||||
expect(data.pending_uploads.map((p) => p.field).sort()).toEqual(["datasets", "validations"]);
|
||||
});
|
||||
|
||||
test("finetune create --datasets 为空时拒绝", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"finetune",
|
||||
"create",
|
||||
"--model",
|
||||
"qwen3-8b",
|
||||
"--datasets",
|
||||
" , ",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stdout + stderr).not.toBe(0);
|
||||
});
|
||||
|
||||
test("finetune create 样本数 <= batch_size 时提交前快速失败且不上传", async () => {
|
||||
// The fixture has 3 records; the small-file auto-adjust sets batch_size=8,
|
||||
// so 3 <= 8 trips the pre-submit gate. The gate fires before any upload,
|
||||
// so this is fully offline (no key, no network) — the proof is that the
|
||||
// error is the gate message AND no "Uploaded …" line ever appears.
|
||||
const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl");
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"finetune",
|
||||
"create",
|
||||
"--model",
|
||||
"qwen3-8b",
|
||||
"--datasets",
|
||||
localPath,
|
||||
"--yes",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stdout + stderr).not.toBe(0);
|
||||
const combined = `${stdout}\n${stderr}`;
|
||||
expect(combined).toMatch(/not greater than batch_size/i);
|
||||
// Crucially, no upload happened — the gate must fire before the upload step.
|
||||
expect(combined).not.toMatch(/Uploaded .* → file-/);
|
||||
});
|
||||
|
||||
test("finetune create --batch-size 过小仍按 8 下限比较(不绕过卡口)", async () => {
|
||||
// Even with --batch-size 1 (server clamps to 8), 3 samples <= 8 still trips
|
||||
// the gate — confirms the gate uses the clamped/effective batch, not the raw.
|
||||
const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl");
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"finetune",
|
||||
"create",
|
||||
"--model",
|
||||
"qwen3-8b",
|
||||
"--datasets",
|
||||
localPath,
|
||||
"--batch-size",
|
||||
"1",
|
||||
"--yes",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stdout + stderr).not.toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/batch_size \(8\)/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["list", ["--status", "RUNNING"]],
|
||||
["get", ["--job-id", "ft-xxx"]],
|
||||
["checkpoints", ["--job-id", "ft-xxx"]],
|
||||
["logs", ["--job-id", "ft-xxx", "--page-size", "50"]],
|
||||
["export", ["--job-id", "ft-xxx", "--checkpoint", "ckpt-3", "--model-name", "m"]],
|
||||
["cancel", ["--job-id", "ft-xxx"]],
|
||||
["delete", ["--job-id", "ft-xxx"]],
|
||||
["watch", ["--job-id", "ft-xxx"]],
|
||||
["capability", ["--model", "qwen3-8b"]],
|
||||
])("finetune %s --dry-run 发出结构化动作", async (sub, extra) => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"finetune",
|
||||
sub,
|
||||
...extra,
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ action: string }>(stdout);
|
||||
expect(data.action).toBe(`finetune.${sub}`);
|
||||
});
|
||||
|
||||
test("finetune create --dry-run 解析多 datasets 中的空白", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"finetune",
|
||||
"create",
|
||||
"--model",
|
||||
"qwen3-8b",
|
||||
"--datasets",
|
||||
" file-a , ,file-b ",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
body: { training_file_ids: string[] };
|
||||
}>(stdout);
|
||||
expect(data.body.training_file_ids).toEqual(["file-a", "file-b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => {
|
||||
/**
|
||||
* 不同开发者的 key 状态不一:可能鉴权失败、可能账号下没有任何微调记录、
|
||||
* 也可能受区域/权限限制。因此本用例不假设"有数据"或"调用成功":
|
||||
* - 成功(exit 0):响应必须可解析;jobs 可能为空数组或不存在。
|
||||
* - 失败(非零退出):只要 CLI 把服务端/鉴权错误优雅上抛(stderr 有内容、
|
||||
* 而非进程崩溃),即视为通过。
|
||||
*/
|
||||
test("finetune list --output json 优雅返回(空账号或鉴权失败均通过)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"finetune",
|
||||
"list",
|
||||
"--page-size",
|
||||
"5",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
if (exitCode === 0) {
|
||||
const data = parseStdoutJson<{ data?: { jobs?: unknown[] } }>(stdout);
|
||||
expect(data).toBeTruthy();
|
||||
if (data.data?.jobs) {
|
||||
expect(Array.isArray(data.data.jobs)).toBe(true);
|
||||
}
|
||||
} else {
|
||||
expect(stderr.length).toBeGreaterThan(0);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -101,6 +101,26 @@ export function isDashScopeE2EReady(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console-gateway 命令(quota / usage free / usage stats)的 E2E 就绪检查:
|
||||
* 需 `BAILIAN_E2E=1` 且存在 console access_token(环境变量 `DASHSCOPE_ACCESS_TOKEN`
|
||||
* 或 `~/.bailian/config.json` 的 `access_token`)。
|
||||
*
|
||||
* 仅检查 token 是否存在——无法本地判断是否过期。token 过期时 gated 用例仍会执行,
|
||||
* 但用 `isConsoleAuthFailure` 把“session 未登录/已过期”的优雅报错视为通过,保持
|
||||
* 与 deploy/dataset “无 key / 有效 key / 失效 key 均绿”的一致策略。
|
||||
*/
|
||||
export function isConsoleE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
|
||||
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;
|
||||
@@ -181,3 +201,16 @@ export function parseStdoutJson<T = unknown>(stdout: string): T {
|
||||
const t = stdout.trim();
|
||||
return JSON.parse(t) 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);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("e2e: mcp", () => {
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
api?: string;
|
||||
region?: string;
|
||||
consoleRegion?: string;
|
||||
data?: {
|
||||
reqDTO?: {
|
||||
type?: string;
|
||||
@@ -79,7 +79,6 @@ describe("e2e: mcp", () => {
|
||||
};
|
||||
}>(stdout);
|
||||
expect(data.api).toBe("zeldaEasy.broadscope-bailian.mcp-server.PageList");
|
||||
expect(data.region).toBe("cn-beijing");
|
||||
expect(data.data?.reqDTO?.activated).toBe(1);
|
||||
expect(data.data?.reqDTO?.displayTools).toBe(false);
|
||||
expect(data.data?.reqDTO?.type).toBe("OFFICIAL");
|
||||
@@ -88,7 +87,7 @@ describe("e2e: mcp", () => {
|
||||
expect(data.data?.reqDTO?.pageSize).toBe(5);
|
||||
});
|
||||
|
||||
test("mcp list --dry-run 自定义 --region 透传", async () => {
|
||||
test("mcp list --dry-run 自定义 --console-region 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"mcp",
|
||||
"list",
|
||||
@@ -96,12 +95,12 @@ describe("e2e: mcp", () => {
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--region",
|
||||
"--console-region",
|
||||
"cn-hangzhou",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ region?: string }>(stdout);
|
||||
expect(data.region).toBe("cn-hangzhou");
|
||||
const data = parseStdoutJson<{ consoleRegion?: string }>(stdout);
|
||||
expect(data.consoleRegion).toBe("cn-hangzhou");
|
||||
});
|
||||
|
||||
test("mcp tools <server-code> --dry-run 输出 /api/v1/mcps/<code>/mcp 形态 URL", async () => {
|
||||
|
||||
@@ -20,6 +20,14 @@ describe("e2e: omni", () => {
|
||||
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
|
||||
"e2e: omni(DashScope 媒体)",
|
||||
() => {
|
||||
test("omni --list-voices 输出音色列表并退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["omni", "--list-voices"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toMatch(/Omni output voices:/);
|
||||
expect(stdout).toMatch(/Tina/);
|
||||
expect(stdout).toMatch(/Dylan/);
|
||||
expect(stdout).toMatch(/Total: 13 voices/);
|
||||
});
|
||||
test("omni 缺少 --message 时打印子命令帮助并退出 (0)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"omni",
|
||||
|
||||
@@ -28,7 +28,7 @@ const FAKE_URL = `https://${FAKE_HOST}/probe`;
|
||||
* 代理行为由进程环境变量决定,正是被测对象;fetch 成败不重要,我们只看代理是否收到 CONNECT。
|
||||
*/
|
||||
const PROBE_SCRIPT = `
|
||||
import { setupProxyFromEnv } from ${JSON.stringify(join(cliPackageRoot, "src", "proxy.ts"))};
|
||||
import { setupProxyFromEnv } from ${JSON.stringify(join(cliPackageRoot, "..", "runtime", "src", "proxy.ts"))};
|
||||
setupProxyFromEnv();
|
||||
try {
|
||||
await fetch(${JSON.stringify(FAKE_URL)}, { signal: AbortSignal.timeout(5000) });
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
import { readConfigFile } from "bailian-cli-core";
|
||||
|
||||
function isConsoleE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
|
||||
try {
|
||||
const config = readConfigFile();
|
||||
return typeof config.access_token === "string" && config.access_token.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
describe("e2e: quota", () => {
|
||||
test("quota list --help 正常退出", async () => {
|
||||
@@ -96,25 +84,14 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
|
||||
expect(data.data?.input?.supports).toBeUndefined();
|
||||
});
|
||||
|
||||
test("quota list 文本输出包含双行表头", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"quota",
|
||||
"list",
|
||||
"--output",
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("模型");
|
||||
expect(stdout).toContain("Model");
|
||||
expect(stdout).toContain("RPM");
|
||||
expect(stdout).toContain("TPM");
|
||||
expect(stdout).toContain("可设上限 TPM");
|
||||
expect(stdout).toContain("Max TPM");
|
||||
test("quota list 文本输出包含英文表头", async () => {
|
||||
const result = await runCli(["quota", "list", "--output", "text", "--no-color"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("quota list --model 指定模型返回结果", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"quota",
|
||||
"list",
|
||||
"--model",
|
||||
@@ -123,13 +100,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("qwen3.6-plus");
|
||||
expect(stdout).toMatch(/共 1 个模型/);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("quota list --model 不存在的模型报错", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"quota",
|
||||
"list",
|
||||
"--model",
|
||||
@@ -137,18 +113,15 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
|
||||
"--output",
|
||||
"text",
|
||||
]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("no matching models found");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("no matching models found");
|
||||
});
|
||||
|
||||
test("quota list JSON 输出包含 qpmInfo", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["quota", "list", "--output", "json"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<Array<{ model?: string; qpmInfo?: unknown }>>(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBeGreaterThan(0);
|
||||
expect(data[0].model).toBeTypeOf("string");
|
||||
expect(data[0].qpmInfo).toBeDefined();
|
||||
test("quota list JSON 输出包含 model/rpm/tpm/maxTPM", async () => {
|
||||
const result = await runCli(["quota", "list", "--output", "json"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("quota request --dry-run 输出请求参数", async () => {
|
||||
@@ -174,22 +147,16 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
|
||||
});
|
||||
|
||||
test("quota request TPM 超范围报错", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"quota",
|
||||
"request",
|
||||
"--model",
|
||||
"qwen3.6-plus",
|
||||
"--tpm",
|
||||
"999",
|
||||
]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("out of range");
|
||||
expect(stderr).toContain("Current");
|
||||
expect(stderr).toContain("Range");
|
||||
const result = await runCli(["quota", "request", "--model", "qwen3.6-plus", "--tpm", "999"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("out of range");
|
||||
expect(result.stderr).toContain("Current");
|
||||
expect(result.stderr).toContain("Range");
|
||||
});
|
||||
|
||||
test("quota request 不支持提额的模型报错", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"quota",
|
||||
"request",
|
||||
"--model",
|
||||
@@ -197,8 +164,9 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
|
||||
"--tpm",
|
||||
"100000",
|
||||
]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("not found");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("not found");
|
||||
});
|
||||
|
||||
test("quota history --dry-run 输出请求参数", async () => {
|
||||
@@ -228,32 +196,38 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ apis?: string[] }>(stdout);
|
||||
const data = parseStdoutJson<{ apis?: string[]; consoleRegion?: string }>(stdout);
|
||||
expect(data.apis).toContain(
|
||||
"zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels",
|
||||
);
|
||||
expect(data.apis).toContain("zeldaEasy.bailian-telemetry.monitor.getMonitorData");
|
||||
expect(data.consoleRegion).toBe("cn-beijing");
|
||||
});
|
||||
|
||||
test("quota check 文本输出包含双行表头", async () => {
|
||||
test("quota check --dry-run --console-region 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"quota",
|
||||
"check",
|
||||
"--dry-run",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"text",
|
||||
"--no-color",
|
||||
"json",
|
||||
"--console-region",
|
||||
"cn-hangzhou",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("模型");
|
||||
expect(stdout).toContain("Model");
|
||||
expect(stdout).toContain("RPM 用量/限额");
|
||||
expect(stdout).toContain("RPM Usage/Limit");
|
||||
expect(stdout).toContain("TPM 用量/限额");
|
||||
expect(stdout).toContain("状态");
|
||||
const data = parseStdoutJson<{ consoleRegion?: string }>(stdout);
|
||||
expect(data.consoleRegion).toBe("cn-hangzhou");
|
||||
});
|
||||
|
||||
test("quota check 文本输出包含英文表头", async () => {
|
||||
const result = await runCli(["quota", "check", "--output", "text", "--no-color"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("quota check --model 指定单模型", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"quota",
|
||||
"check",
|
||||
"--model",
|
||||
@@ -262,13 +236,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("qwen3.6-plus");
|
||||
expect(stdout).toMatch(/共 1 个模型/);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("quota check --model 逗号分隔多模型", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"quota",
|
||||
"check",
|
||||
"--model",
|
||||
@@ -277,54 +250,14 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("qwen3.6-plus");
|
||||
expect(stdout).toContain("qwen-plus");
|
||||
expect(stdout).toMatch(/共 2 个模型/);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("quota check JSON 输出包含用量和限额字段", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"quota",
|
||||
"check",
|
||||
"--model",
|
||||
"qwen3.6-plus",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<
|
||||
Array<{
|
||||
model?: string;
|
||||
rpmUsage?: number;
|
||||
rpmLimit?: number;
|
||||
tpmUsage?: number;
|
||||
tpmLimit?: number;
|
||||
}>
|
||||
>(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBe(1);
|
||||
expect(data[0].model).toBe("qwen3.6-plus");
|
||||
expect(data[0].rpmUsage).toBeTypeOf("number");
|
||||
expect(data[0].rpmLimit).toBeTypeOf("number");
|
||||
expect(data[0].tpmUsage).toBeTypeOf("number");
|
||||
expect(data[0].tpmLimit).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
test("quota check 状态列显示正常/接近限流/已限流之一", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"quota",
|
||||
"check",
|
||||
"--model",
|
||||
"qwen3.6-plus",
|
||||
"--output",
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const hasStatus =
|
||||
stdout.includes("正常") || stdout.includes("接近限流") || stdout.includes("已限流");
|
||||
expect(hasStatus).toBe(true);
|
||||
const result = await runCli(["quota", "check", "--model", "qwen3.6-plus", "--output", "json"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("quota history --dry-run --page 2 --page-size 20", async () => {
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
import { readConfigFile } from "bailian-cli-core";
|
||||
|
||||
function isConsoleE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
|
||||
try {
|
||||
const config = readConfigFile();
|
||||
return typeof config.access_token === "string" && config.access_token.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
describe("e2e: usage free", () => {
|
||||
test("usage 分组展示子命令帮助且退出码为 0", async () => {
|
||||
@@ -108,41 +96,18 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
|
||||
});
|
||||
|
||||
test("usage free --dry-run 不指定 --model 传全量模型列表", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
const { stderr, exitCode } = await runCli(["usage", "free", "--dry-run", "--output", "json"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
data?: { queryFreeTierQuotaRequest?: { models?: string[] } };
|
||||
}>(stdout);
|
||||
const models = data.data?.queryFreeTierQuotaRequest?.models ?? [];
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("usage free --model 单模型查询返回 JSON 结果", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
code?: string;
|
||||
successResponse?: boolean;
|
||||
}>(stdout);
|
||||
expect(data.code).toBe("200");
|
||||
expect(data.successResponse).toBe(true);
|
||||
const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "json"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage free --model 单模型文本输出包含表头", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--model",
|
||||
@@ -151,17 +116,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Model");
|
||||
expect(stdout).toContain("Type");
|
||||
expect(stdout).toContain("Remaining/Total");
|
||||
expect(stdout).toContain("Usage");
|
||||
expect(stdout).toContain("Expires");
|
||||
expect(stdout).toContain("Auto-Stop");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage free --model 文本输出包含模型名", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--model",
|
||||
@@ -170,12 +130,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("qwen3-max");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage free --model 逗号分隔多模型文本输出包含所有模型", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--model",
|
||||
@@ -184,13 +144,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("qwen3-max");
|
||||
expect(stdout).toContain("qwen-turbo");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage free --model 文本输出包含正确的 Type 列", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--model",
|
||||
@@ -199,12 +158,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Text");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage free --model quotaStatus 为 UNKNOWN 时 Auto-Stop 显示 Unsupported", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--model",
|
||||
@@ -213,12 +172,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Unsupported");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage free --model quotaStatus 为 UNKNOWN 时额度显示为 -", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--model",
|
||||
@@ -227,15 +186,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const lines = stdout.split("\n").filter((line) => line.includes("wan2.7-image"));
|
||||
expect(lines.length).toBe(1);
|
||||
expect(lines[0]).toContain("Vision");
|
||||
expect(lines[0]).toContain("Unsupported");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage free --model 不存在的模型仍返回表格行", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--model",
|
||||
@@ -244,12 +200,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("nonexistent-model-xyz-12345");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage free --model Auto-Stop 显示 ON、OFF 或 Unsupported", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--model",
|
||||
@@ -258,25 +214,22 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const hasAutoStop =
|
||||
stdout.includes("ON") || stdout.includes("OFF") || stdout.includes("Unsupported");
|
||||
expect(hasAutoStop).toBe(true);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage free --model --region cn-beijing 指定区域查询", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
test("usage free --model --console-region cn-beijing 指定区域查询", async () => {
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"free",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
"--region",
|
||||
"--console-region",
|
||||
"cn-beijing",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ code?: string }>(stdout);
|
||||
expect(data.code).toBe("200");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
import { readConfigFile } from "bailian-cli-core";
|
||||
|
||||
function isConsoleE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
|
||||
try {
|
||||
const config = readConfigFile();
|
||||
return typeof config.access_token === "string" && config.access_token.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getStaticWorkspaceId(): string | undefined {
|
||||
if (process.env.BAILIAN_WORKSPACE_ID?.trim()) return process.env.BAILIAN_WORKSPACE_ID.trim();
|
||||
try {
|
||||
@@ -22,17 +11,27 @@ function getStaticWorkspaceId(): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 当无静态 workspace-id 且 console 未登录/已过期时返回占位符,避免下游 dry-run
|
||||
// 用例因 `--workspace-id undefined` 而崩溃;live 用例各自用 isConsoleAuthFailure
|
||||
// 容忍鉴权失败。参考 deploy/dataset “无 key / 有效 / 失效 均绿”的策略。
|
||||
const FALLBACK_WORKSPACE_ID = "ws-e2e-unavailable";
|
||||
|
||||
async function fetchDefaultWorkspaceId(): Promise<string> {
|
||||
const staticId = getStaticWorkspaceId();
|
||||
if (staticId) return staticId;
|
||||
|
||||
const { stdout } = await runCli(["workspace", "list", "--output", "json"]);
|
||||
const result = JSON.parse(stdout);
|
||||
const data = result?.data?.DataV2?.data?.data?.data ?? [];
|
||||
const defaultWs = data.find((ws: { defaultAgent?: boolean }) => ws.defaultAgent);
|
||||
if (defaultWs?.workspaceId) return defaultWs.workspaceId;
|
||||
if (data.length > 0 && data[0].workspaceId) return data[0].workspaceId;
|
||||
throw new Error("No workspace found for e2e tests");
|
||||
const result = await runCli(["workspace", "list", "--output", "json"]);
|
||||
if (isConsoleAuthFailure(result) || result.exitCode !== 0) return FALLBACK_WORKSPACE_ID;
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
const data = parsed?.data?.DataV2?.data?.data?.data ?? [];
|
||||
const defaultWs = data.find((ws: { defaultAgent?: boolean }) => ws.defaultAgent);
|
||||
if (defaultWs?.workspaceId) return defaultWs.workspaceId;
|
||||
if (data.length > 0 && data[0].workspaceId) return data[0].workspaceId;
|
||||
} catch {
|
||||
/* fall through to placeholder */
|
||||
}
|
||||
return FALLBACK_WORKSPACE_ID;
|
||||
}
|
||||
|
||||
describe("e2e: usage stats", () => {
|
||||
@@ -159,25 +158,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
|
||||
});
|
||||
|
||||
test("usage stats 概览模式返回 JSON 结果", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"usage",
|
||||
"stats",
|
||||
"--workspace-id",
|
||||
wsId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
code?: string;
|
||||
successResponse?: boolean;
|
||||
}>(stdout);
|
||||
expect(data.code).toBe("200");
|
||||
expect(data.successResponse).toBe(true);
|
||||
const result = await runCli(["usage", "stats", "--workspace-id", wsId, "--output", "json"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage stats 概览文本输出包含中英文表头", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
test("usage stats 概览文本输出包含英文标签", async () => {
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"stats",
|
||||
"--workspace-id",
|
||||
@@ -186,11 +173,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage stats 概览文本输出包含 Token 用量", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"stats",
|
||||
"--workspace-id",
|
||||
@@ -199,11 +187,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage stats --model 单模型文本输出包含双行表头", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
test("usage stats --model 单模型文本输出包含英文表头", async () => {
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"stats",
|
||||
"--workspace-id",
|
||||
@@ -214,11 +203,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage stats --model 逗号分隔多模型返回多行", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"stats",
|
||||
"--workspace-id",
|
||||
@@ -229,11 +219,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage stats --model 不存在的模型返回空表格", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"stats",
|
||||
"--workspace-id",
|
||||
@@ -244,12 +235,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No usage data found");
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage stats --days 1 短时间范围正常返回", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"stats",
|
||||
"--workspace-id",
|
||||
@@ -260,11 +251,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage stats --type Vision 按类型过滤", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
const result = await runCli([
|
||||
"usage",
|
||||
"stats",
|
||||
"--workspace-id",
|
||||
@@ -275,6 +267,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--duration",
|
||||
"3",
|
||||
"--prompt",
|
||||
|
||||
@@ -37,7 +37,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-i2v",
|
||||
"happyhorse-1.1-i2v",
|
||||
"--image",
|
||||
"https://example.com/placeholder.png",
|
||||
"--non-interactive",
|
||||
@@ -53,7 +53,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"generate",
|
||||
"--dry-run",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--prompt",
|
||||
"干跑无图",
|
||||
"--non-interactive",
|
||||
@@ -68,7 +68,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
expect(data.request?.input?.media).toBeUndefined();
|
||||
});
|
||||
|
||||
test("【happyhorse-1.0-i2v】图片生成视频", async () => {
|
||||
test("【happyhorse-1.1-i2v】图片生成视频", async () => {
|
||||
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
|
||||
const png = join(outDir, "e2e-gen.png");
|
||||
const gen = await runCli([
|
||||
@@ -95,7 +95,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-i2v",
|
||||
"happyhorse-1.1-i2v",
|
||||
"--image",
|
||||
imagePath,
|
||||
"--prompt",
|
||||
|
||||
@@ -37,7 +37,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
@@ -51,7 +51,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"generate",
|
||||
"--dry-run",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--prompt",
|
||||
"干跑校验",
|
||||
"--non-interactive",
|
||||
@@ -62,18 +62,18 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
const data = parseStdoutJson<{ request?: { model?: string; input?: { prompt?: string } } }>(
|
||||
stdout,
|
||||
);
|
||||
expect(data.request?.model).toBe("happyhorse-1.0-t2v");
|
||||
expect(data.request?.model).toBe("happyhorse-1.1-t2v");
|
||||
expect(data.request?.input?.prompt).toBe("干跑校验");
|
||||
});
|
||||
|
||||
test("【happyhorse-1.0-t2v】文本生成视频", async () => {
|
||||
test("【happyhorse-1.1-t2v】文本生成视频", async () => {
|
||||
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
...cliTimeoutPrefix(),
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--prompt",
|
||||
"夕阳下海面波光,远景静态镜头",
|
||||
"--download",
|
||||
|
||||
@@ -37,7 +37,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"video",
|
||||
"ref",
|
||||
"--model",
|
||||
"happyhorse-1.0-r2v",
|
||||
"happyhorse-1.1-r2v",
|
||||
"--image",
|
||||
"https://example.com/x.png",
|
||||
"--non-interactive",
|
||||
@@ -52,7 +52,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"video",
|
||||
"ref",
|
||||
"--model",
|
||||
"happyhorse-1.0-r2v",
|
||||
"happyhorse-1.1-r2v",
|
||||
"--prompt",
|
||||
"仅有描述无素材",
|
||||
"--non-interactive",
|
||||
@@ -61,7 +61,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
expect(stderr).toMatch(/--image|ref-video|At least one|required/i);
|
||||
});
|
||||
|
||||
test("【happyhorse-1.0-r2v】视频参考生成", async () => {
|
||||
test("【happyhorse-1.1-r2v】视频参考生成", async () => {
|
||||
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
|
||||
const gen = await runCli([
|
||||
"image",
|
||||
@@ -88,7 +88,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"video",
|
||||
"ref",
|
||||
"--model",
|
||||
"happyhorse-1.0-r2v",
|
||||
"happyhorse-1.1-r2v",
|
||||
"--prompt",
|
||||
"图1在画面中心轻微晃动",
|
||||
"--image",
|
||||
|
||||
@@ -180,7 +180,7 @@ export async function ensurePrerequisites(ctx) {
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--prompt",
|
||||
"压测前置短视频:海浪与静态远景,无明显人物。",
|
||||
"--duration",
|
||||
|
||||
@@ -132,7 +132,7 @@ export async function generateCombinedFixtures({ suiteRoot, cliPackage }) {
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--prompt",
|
||||
"压测前置短视频:海浪与静态远景,无明显人物。",
|
||||
"--duration",
|
||||
|
||||
@@ -16,7 +16,7 @@ const motions = [
|
||||
|
||||
export const runStress = defineStressTarget({
|
||||
canonical: "video-i2v",
|
||||
defaultModel: "happyhorse-1.0-i2v",
|
||||
defaultModel: "happyhorse-1.1-i2v",
|
||||
batchDirPrefix: "video-i2v-batch",
|
||||
helpText: "pnpm run test:stress -- video-i2v [--reuse-fixtures] -- --count 5 -c 2",
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const prompts = [
|
||||
|
||||
export const runStress = defineStressTarget({
|
||||
canonical: "video-ref",
|
||||
defaultModel: "happyhorse-1.0-r2v",
|
||||
defaultModel: "happyhorse-1.1-r2v",
|
||||
batchDirPrefix: "video-ref-batch",
|
||||
helpText: "pnpm run test:stress -- video-ref [--reuse-fixtures] -- --count 5 -c 2",
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
export const runStress = defineStressTarget({
|
||||
canonical: "video-t2v",
|
||||
defaultModel: "happyhorse-1.0-t2v",
|
||||
defaultModel: "happyhorse-1.1-t2v",
|
||||
batchDirPrefix: "video-t2v-batch",
|
||||
helpText: `用法:pnpm run test:stress -- video-t2v -- --concurrency 1 --count 3
|
||||
详见 docs/agents/stress-batch-tests.md`,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"moduleDetection": "force",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"customConditions": ["@bailian-cli/source"],
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.5.0",
|
||||
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
"url": "https://github.com/modelstudioai/cli/issues"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": "Aliyun Model Studio",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/modelstudioai/cli.git",
|
||||
"directory": "packages/commands"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"types": "./dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"@bailian-cli/source": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": "./dist/index.mjs",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack",
|
||||
"dev": "vp pack --watch",
|
||||
"test": "vp test",
|
||||
"check": "vp check"
|
||||
},
|
||||
"dependencies": {
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260328.1",
|
||||
"typescript": "^6.0.2",
|
||||
"vite-plus": "0.1.22"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
+77
-59
@@ -17,9 +17,9 @@ import {
|
||||
} from "bailian-cli-core";
|
||||
import boxen from "boxen";
|
||||
import chalk, { Chalk, type ChalkInstance } from "chalk";
|
||||
import { emitBare, emitResult } from "../../output/output.ts";
|
||||
import { createSpinner } from "../../output/progress.ts";
|
||||
import { failIfMissing, promptText } from "../../output/prompt.ts";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { createSpinner } from "bailian-cli-runtime";
|
||||
import { failIfMissing, promptText, cmdUsage } from "bailian-cli-runtime";
|
||||
|
||||
function formatContextWindow(tokens: number): string {
|
||||
if (tokens >= 1_000_000)
|
||||
@@ -29,41 +29,41 @@ function formatContextWindow(tokens: number): string {
|
||||
}
|
||||
|
||||
const MODALITY_LABELS: Record<string, string> = {
|
||||
Text: "文本",
|
||||
Image: "图片",
|
||||
Video: "视频",
|
||||
Audio: "音频",
|
||||
Text: "Text",
|
||||
Image: "Image",
|
||||
Video: "Video",
|
||||
Audio: "Audio",
|
||||
};
|
||||
const CAPABILITY_LABELS: Record<string, string> = {
|
||||
TG: "文本生成",
|
||||
VU: "视觉理解",
|
||||
IG: "图像生成",
|
||||
VG: "视频生成",
|
||||
TTS: "语音合成",
|
||||
ASR: "语音识别",
|
||||
Reasoning: "推理",
|
||||
TG: "Text Gen",
|
||||
VU: "Vision",
|
||||
IG: "Image Gen",
|
||||
VG: "Video Gen",
|
||||
TTS: "Text-to-Speech",
|
||||
ASR: "Speech-to-Text",
|
||||
Reasoning: "Reasoning",
|
||||
};
|
||||
const BUDGET_LABELS: Record<string, string> = {
|
||||
low: "低成本优先",
|
||||
medium: "适中",
|
||||
high: "高投入",
|
||||
low: "Cost-Effective",
|
||||
medium: "Balanced",
|
||||
high: "High Investment",
|
||||
};
|
||||
const QUALITY_LABELS: Record<string, string> = {
|
||||
flagship: "旗舰优先",
|
||||
balanced: "均衡",
|
||||
"cost-optimized": "性价比优先",
|
||||
flagship: "Flagship",
|
||||
balanced: "Balanced",
|
||||
"cost-optimized": "Value",
|
||||
};
|
||||
const PREFERENCE_MODE_LABELS: Record<string, string> = {
|
||||
scoped: "限定范围",
|
||||
comparison: "对比评估",
|
||||
alternative: "替代推荐",
|
||||
scoped: "Scoped",
|
||||
comparison: "Comparison",
|
||||
alternative: "Alternative",
|
||||
};
|
||||
|
||||
function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
|
||||
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(colorize.cyan.bold("需求理解"));
|
||||
lines.push(colorize.cyan.bold("Intent Analysis"));
|
||||
|
||||
if (intent.taskSummary) {
|
||||
lines.push("");
|
||||
@@ -72,7 +72,7 @@ function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
|
||||
|
||||
if (intent.scenarioHints.length) {
|
||||
lines.push("");
|
||||
lines.push(`${colorize.dim("场景特征")} ${intent.scenarioHints.join(" · ")}`);
|
||||
lines.push(`${colorize.dim("Scenario")} ${intent.scenarioHints.join(" · ")}`);
|
||||
}
|
||||
|
||||
const inputLabels = intent.inputModality.map((mod) => MODALITY_LABELS[mod] ?? mod);
|
||||
@@ -80,40 +80,40 @@ function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
|
||||
if (inputLabels.length || outputLabels.length) {
|
||||
lines.push("");
|
||||
const parts: string[] = [];
|
||||
if (inputLabels.length) parts.push(`${colorize.dim("输入")} ${inputLabels.join(", ")}`);
|
||||
if (outputLabels.length) parts.push(`${colorize.dim("输出")} ${outputLabels.join(", ")}`);
|
||||
if (inputLabels.length) parts.push(`${colorize.dim("Input")} ${inputLabels.join(", ")}`);
|
||||
if (outputLabels.length) parts.push(`${colorize.dim("Output")} ${outputLabels.join(", ")}`);
|
||||
lines.push(parts.join(" "));
|
||||
}
|
||||
|
||||
const capLabels = intent.requiredCapabilities.map((cap) => CAPABILITY_LABELS[cap] ?? cap);
|
||||
if (capLabels.length) {
|
||||
lines.push(`${colorize.dim("所需能力")} ${capLabels.join(", ")}`);
|
||||
lines.push(`${colorize.dim("Capabilities")} ${capLabels.join(", ")}`);
|
||||
}
|
||||
|
||||
const budgetLabel = BUDGET_LABELS[intent.budget] ?? intent.budget;
|
||||
const qualityLabel = QUALITY_LABELS[intent.qualityPreference] ?? intent.qualityPreference;
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`${colorize.dim("预算倾向")} ${budgetLabel} ${colorize.dim("质量偏好")} ${qualityLabel}`,
|
||||
`${colorize.dim("Budget")} ${budgetLabel} ${colorize.dim("Quality")} ${qualityLabel}`,
|
||||
);
|
||||
|
||||
const preference = intent.modelPreference;
|
||||
if (preference && preference.mode !== "unconstrained") {
|
||||
lines.push("");
|
||||
const modeLabel = PREFERENCE_MODE_LABELS[preference.mode] ?? preference.mode;
|
||||
const prefParts = [colorize.dim("推荐模式") + ` ${colorize.yellow(modeLabel)}`];
|
||||
const prefParts = [colorize.dim("Mode") + ` ${colorize.yellow(modeLabel)}`];
|
||||
if (preference.targets?.length) {
|
||||
prefParts.push(colorize.dim("目标") + ` ${preference.targets.join(", ")}`);
|
||||
prefParts.push(colorize.dim("Targets") + ` ${preference.targets.join(", ")}`);
|
||||
}
|
||||
if (preference.excludes?.length) {
|
||||
prefParts.push(colorize.dim("排除") + ` ${preference.excludes.join(", ")}`);
|
||||
prefParts.push(colorize.dim("Excludes") + ` ${preference.excludes.join(", ")}`);
|
||||
}
|
||||
lines.push(prefParts.join(" "));
|
||||
}
|
||||
|
||||
if (intent.segments?.length) {
|
||||
lines.push("");
|
||||
lines.push(colorize.dim("任务拆解"));
|
||||
lines.push(colorize.dim("Pipeline"));
|
||||
for (const [idx, segment] of intent.segments.entries()) {
|
||||
const outMods = segment.outputModality.map((mod) => MODALITY_LABELS[mod] ?? mod).join(", ");
|
||||
lines.push(
|
||||
@@ -131,19 +131,19 @@ function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
|
||||
});
|
||||
}
|
||||
|
||||
const RECOMMEND_LABELS = ["最佳推荐", "次优选择", "备选参考"];
|
||||
const RECOMMEND_LABELS = ["Best Pick", "Runner-Up", "Alternative"];
|
||||
|
||||
function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstance): string {
|
||||
const labelColors = [colorize.green.bold, colorize.blue.bold, colorize.magenta.bold];
|
||||
const colorFn = labelColors[index] ?? colorize.white.bold;
|
||||
const label = RECOMMEND_LABELS[index] ?? `推荐 #${index + 1}`;
|
||||
const label = RECOMMEND_LABELS[index] ?? `#${index + 1}`;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(colorFn(`⬢ 推荐 #${index + 1} — ${label}`));
|
||||
lines.push(colorFn(`⬢ #${index + 1} — ${label}`));
|
||||
lines.push("");
|
||||
lines.push(`${colorize.bold(rec.name)} ${colorize.dim(`(${rec.model})`)}`);
|
||||
lines.push("");
|
||||
lines.push(`${colorize.cyan("推荐理由")} ${rec.reason}`);
|
||||
lines.push(`${colorize.cyan("Why")} ${rec.reason}`);
|
||||
|
||||
if (rec.highlights.length) {
|
||||
lines.push("");
|
||||
@@ -153,8 +153,8 @@ function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstanc
|
||||
}
|
||||
|
||||
const meta: string[] = [];
|
||||
if (rec.contextWindow) meta.push(`上下文 ${formatContextWindow(rec.contextWindow)}`);
|
||||
if (rec.maxOutputTokens) meta.push(`最大输出 ${formatContextWindow(rec.maxOutputTokens)}`);
|
||||
if (rec.contextWindow) meta.push(`Context ${formatContextWindow(rec.contextWindow)}`);
|
||||
if (rec.maxOutputTokens) meta.push(`Max Output ${formatContextWindow(rec.maxOutputTokens)}`);
|
||||
if (meta.length) {
|
||||
lines.push("");
|
||||
lines.push(colorize.dim(meta.join(" · ")));
|
||||
@@ -163,7 +163,7 @@ function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstanc
|
||||
const docLink = buildDocLink(rec.docUrl);
|
||||
if (docLink) {
|
||||
lines.push("");
|
||||
lines.push(colorize.dim(`文档 ${docLink}`));
|
||||
lines.push(colorize.dim(`Docs ${docLink}`));
|
||||
}
|
||||
|
||||
return boxen(lines.join("\n"), {
|
||||
@@ -183,7 +183,7 @@ function formatSingleResult(results: RecommendedModel[], noColor: boolean): stri
|
||||
function formatPipelineResult(summary: string, steps: PipelineStep[], noColor: boolean): string {
|
||||
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
|
||||
const lines: string[] = [];
|
||||
lines.push(` ${colorize.yellow.bold("⚡ 组合方案")} ${summary}`);
|
||||
lines.push(` ${colorize.yellow.bold("⚡ Pipeline")} ${summary}`);
|
||||
|
||||
for (const [stepIdx, { step, recommendations, warnings }] of steps.entries()) {
|
||||
lines.push("");
|
||||
@@ -215,10 +215,9 @@ function isEmptyResult(result: RecommendResult): boolean {
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "advisor recommend",
|
||||
description:
|
||||
"Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking)",
|
||||
usage: "bl advisor recommend <prompt> [flags]",
|
||||
usageArgs: "<prompt> [flags]",
|
||||
options: [
|
||||
{
|
||||
flag: "--message <text>",
|
||||
@@ -233,13 +232,13 @@ export default defineCommand({
|
||||
description: "Output format: text (default in TTY), json, yaml",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
'bl advisor recommend --message "I need a visual-understanding chatbot"',
|
||||
'bl advisor recommend --message "Build an Agent that auto-generates animations"',
|
||||
'bl advisor recommend --message "Legal contract review, high precision required"',
|
||||
'bl advisor recommend --message "Low-cost high-concurrency online customer service" --output json',
|
||||
'bl advisor recommend --message "Long document summarization" --dry-run',
|
||||
"bl advisor recommend # Interactive input",
|
||||
exampleArgs: [
|
||||
'--message "I need a visual-understanding chatbot"',
|
||||
'--message "Build an Agent that auto-generates animations"',
|
||||
'--message "Legal contract review, high precision required"',
|
||||
'--message "Low-cost high-concurrency online customer service" --output json',
|
||||
'--message "Long document summarization" --dry-run',
|
||||
" # Interactive input",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const positional = ((flags as Record<string, unknown>)._positional as string[]) ?? [];
|
||||
@@ -247,14 +246,14 @@ export default defineCommand({
|
||||
|
||||
if (!userInput.trim()) {
|
||||
if (isInteractive({ nonInteractive: config.nonInteractive })) {
|
||||
const hint = await promptText({ message: "描述你的需求:" });
|
||||
const hint = await promptText({ message: "Describe your requirement:" });
|
||||
if (!hint) {
|
||||
process.stderr.write("已取消。\n");
|
||||
process.stderr.write("Cancelled.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
userInput = hint;
|
||||
} else {
|
||||
failIfMissing("message", 'bl advisor recommend "你的需求"');
|
||||
failIfMissing("message", cmdUsage(config, '"your requirement"'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,16 +261,16 @@ export default defineCommand({
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
const modelsOptions: GetModelsOptions = {
|
||||
onPrepareStart: () => process.stderr.write("初始化中...\n"),
|
||||
onPrepareStart: () => process.stderr.write("Initializing model data...\n"),
|
||||
};
|
||||
process.stderr.write("正在分析需求...\n");
|
||||
process.stderr.write("Analyzing your request...\n");
|
||||
const [allModels, intent] = await Promise.all([
|
||||
getModels(config, modelsOptions),
|
||||
analyzeIntent(config, userInput),
|
||||
]);
|
||||
|
||||
if (intent.confidence === 0) {
|
||||
process.stderr.write("需求分析超时,使用默认参数继续...\n");
|
||||
process.stderr.write("Intent analysis timed out, using defaults...\n");
|
||||
} else {
|
||||
process.stderr.write("\n");
|
||||
}
|
||||
@@ -297,7 +296,7 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
// Stage 3: LLM Ranking
|
||||
const spinner = createSpinner("正在推荐最佳模型...");
|
||||
const spinner = createSpinner("Recommending best models...");
|
||||
spinner.start();
|
||||
|
||||
const result = await rankModels(config, candidates, intent, userInput, top);
|
||||
@@ -305,12 +304,31 @@ export default defineCommand({
|
||||
spinner.stop();
|
||||
|
||||
if (isEmptyResult(result)) {
|
||||
emitBare("暂无满足该需求的模型。");
|
||||
emitBare("No suitable models found for this request.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (format !== "text") {
|
||||
emitResult(result, format);
|
||||
emitResult(
|
||||
{
|
||||
intent: {
|
||||
taskSummary: intent.taskSummary,
|
||||
scenarioHints: intent.scenarioHints,
|
||||
complexity: intent.complexity,
|
||||
inputModality: intent.inputModality,
|
||||
outputModality: intent.outputModality,
|
||||
requiredCapabilities: intent.requiredCapabilities,
|
||||
budget: intent.budget,
|
||||
qualityPreference: intent.qualityPreference,
|
||||
modelPreference:
|
||||
intent.modelPreference?.mode !== "unconstrained" ? intent.modelPreference : undefined,
|
||||
segments: intent.segments,
|
||||
},
|
||||
result,
|
||||
candidates: candidates.length,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
+12
-13
@@ -11,13 +11,12 @@ import {
|
||||
type AppStreamChunk,
|
||||
type AppCompletionResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
name: "app call",
|
||||
description: "Call a Bailian application (agent or workflow)",
|
||||
usage: "bl app call --app-id <id> --prompt <text> [flags]",
|
||||
usageArgs: "--app-id <id> --prompt <text> [flags]",
|
||||
options: [
|
||||
{ flag: "--app-id <id>", description: "Application ID (required)", required: true },
|
||||
{ flag: "--prompt <text>", description: "Input prompt text", required: true },
|
||||
@@ -34,20 +33,20 @@ export default defineCommand({
|
||||
{ flag: "--biz-params <json>", description: "Business parameters JSON (workflow variables)" },
|
||||
{ flag: "--has-thoughts", description: "Show agent thinking process" },
|
||||
],
|
||||
examples: [
|
||||
'bl app call --app-id abc123 --prompt "你好"',
|
||||
'bl app call --app-id abc123 --prompt "描述这张图片" --image https://example.com/photo.jpg',
|
||||
'bl app call --app-id abc123 --prompt "分析图片" --image img1.jpg --image img2.jpg',
|
||||
'bl app call --app-id abc123 --prompt "继续" --session-id sess_xxx --stream',
|
||||
'bl app call --app-id abc123 --prompt "搜索资料" --pipeline-ids pipe1,pipe2',
|
||||
'bl app call --app-id abc123 --prompt "开始" --biz-params \'{"key":"value"}\'',
|
||||
exampleArgs: [
|
||||
'--app-id abc123 --prompt "Hello"',
|
||||
'--app-id abc123 --prompt "Describe this image" --image https://example.com/photo.jpg',
|
||||
'--app-id abc123 --prompt "Analyze the image" --image img1.jpg --image img2.jpg',
|
||||
'--app-id abc123 --prompt "Continue" --session-id sess_xxx --stream',
|
||||
'--app-id abc123 --prompt "Search for materials" --pipeline-ids pipe1,pipe2',
|
||||
'--app-id abc123 --prompt "Start" --biz-params \'{"key":"value"}\'',
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const appId = flags.appId as string;
|
||||
if (!appId) failIfMissing("app-id", "bl app call --app-id <id> --prompt <text>");
|
||||
if (!appId) failIfMissing("app-id", cmdUsage(config, "--app-id <id> --prompt <text>"));
|
||||
|
||||
const prompt = flags.prompt as string;
|
||||
if (!prompt) failIfMissing("prompt", "bl app call --app-id <id> --prompt <text>");
|
||||
if (!prompt) failIfMissing("prompt", cmdUsage(config, "--app-id <id> --prompt <text>"));
|
||||
|
||||
const shouldStream =
|
||||
flags.stream === true || (flags.stream === undefined && process.stdout.isTTY);
|
||||
+13
-17
@@ -6,14 +6,14 @@ import {
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "../../output/output.ts";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const APP_LIST_API = "zeldaEasy.broadscope-bailian.app-control.list";
|
||||
|
||||
export default defineCommand({
|
||||
name: "app list",
|
||||
description: "List Bailian applications",
|
||||
usage: "bl app list [flags]",
|
||||
skipDefaultApiKeySetup: true,
|
||||
usageArgs: "[flags]",
|
||||
options: [
|
||||
{
|
||||
flag: "--name <name>",
|
||||
@@ -29,22 +29,22 @@ export default defineCommand({
|
||||
description: "Results per page (default: 30)",
|
||||
type: "number",
|
||||
},
|
||||
{ flag: "--console-region <region>", description: "Console region" },
|
||||
{
|
||||
flag: "--region <region>",
|
||||
description: "API region (default: cn-beijing)",
|
||||
flag: "--console-site <site>",
|
||||
description: "Console site: domestic, international",
|
||||
},
|
||||
{
|
||||
flag: "--console-switch-agent <uid>",
|
||||
description: "Switch agent UID",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"bl app list",
|
||||
"bl app list --name 客服",
|
||||
"bl app list --page 2 --page-size 10",
|
||||
"bl app list --output json",
|
||||
],
|
||||
exampleArgs: ["", "--name customer service", "--page 2 --page-size 10", "--output json"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const name = (flags.name as string) || "";
|
||||
const pageNo = (flags.page as number) || 1;
|
||||
const pageSize = (flags.pageSize as number) || 30;
|
||||
const region = (flags.region as string) || "cn-beijing";
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
const credential = await resolveConsoleGatewayCredential(config);
|
||||
@@ -61,17 +61,13 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{ api: APP_LIST_API, data, region, token: credential.token.slice(0, 8) + "..." },
|
||||
format,
|
||||
);
|
||||
emitResult({ api: APP_LIST_API, data, token: credential.token.slice(0, 8) + "..." }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (await callConsoleGateway(config, credential.token, {
|
||||
api: APP_LIST_API,
|
||||
data,
|
||||
region,
|
||||
})) as any;
|
||||
|
||||
const list: unknown[] = result?.data?.DataV2?.data?.data?.list ?? [];
|
||||
+193
-13
@@ -5,18 +5,24 @@ import http from "node:http";
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
chatEndpoint,
|
||||
getConfigPath,
|
||||
readConfigFile,
|
||||
requestJson,
|
||||
writeConfigFile,
|
||||
type Config,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const MAX_AUTH_CALLBACK_BODY = 65536;
|
||||
|
||||
const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com";
|
||||
const CONSOLE_ORIGINS: Record<string, string> = {
|
||||
domestic: "https://bailian.console.aliyun.com",
|
||||
international: "https://modelstudio.console.alibabacloud.com",
|
||||
};
|
||||
|
||||
export function resolveConsoleOrigin(): string {
|
||||
return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN;
|
||||
export function resolveConsoleOrigin(site?: string): string {
|
||||
return (site && CONSOLE_ORIGINS[site]) || CONSOLE_ORIGINS.domestic!;
|
||||
}
|
||||
|
||||
function readBodyBounded(req: http.IncomingMessage): Promise<string> {
|
||||
@@ -210,9 +216,76 @@ function parseApiKeyFromRawBody(raw: string, contentType: string): string | null
|
||||
return null;
|
||||
}
|
||||
|
||||
type CallbackExtras = Pick<
|
||||
CallbackCredentials,
|
||||
"baseUrl" | "consoleSite" | "consoleRegion" | "consoleSwitchAgent" | "workspaceId"
|
||||
>;
|
||||
|
||||
function stringField(o: Record<string, unknown>, ...keys: string[]): string | null {
|
||||
for (const k of keys) {
|
||||
const v = o[k];
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseExtrasFromRawBody(raw: string, contentType: string): CallbackExtras {
|
||||
const empty: CallbackExtras = {
|
||||
baseUrl: null,
|
||||
consoleSite: null,
|
||||
consoleRegion: null,
|
||||
consoleSwitchAgent: null,
|
||||
workspaceId: null,
|
||||
};
|
||||
if (!raw.trim()) return empty;
|
||||
|
||||
let obj: Record<string, unknown> | null = null;
|
||||
|
||||
const ct = contentType.toLowerCase();
|
||||
if (ct.includes("application/json") || ct.includes("text/json")) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw.trim());
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) obj = parsed;
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
if (!obj && ct.includes("application/x-www-form-urlencoded")) {
|
||||
try {
|
||||
const params = new URLSearchParams(raw.trim());
|
||||
obj = Object.fromEntries(params);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
if (!obj) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw.trim());
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) obj = parsed;
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
if (!obj) return empty;
|
||||
|
||||
return {
|
||||
baseUrl: stringField(obj, "base_url", "baseUrl"),
|
||||
consoleSite: stringField(obj, "console_site", "consoleSite"),
|
||||
consoleRegion: stringField(obj, "console_region", "consoleRegion"),
|
||||
consoleSwitchAgent: stringField(obj, "console_switch_agent", "consoleSwitchAgent"),
|
||||
workspaceId: stringField(obj, "workspace_id", "workspaceId"),
|
||||
};
|
||||
}
|
||||
|
||||
interface CallbackCredentials {
|
||||
accessToken: string | null;
|
||||
apiKey: string | null;
|
||||
baseUrl: string | null;
|
||||
consoleSite: string | null;
|
||||
consoleRegion: string | null;
|
||||
consoleSwitchAgent: string | null;
|
||||
workspaceId: string | null;
|
||||
}
|
||||
|
||||
async function extractCredentialsFromRequest(
|
||||
@@ -222,12 +295,30 @@ async function extractCredentialsFromRequest(
|
||||
const accessTokenFromQuery =
|
||||
u.searchParams.get("access_token") ?? u.searchParams.get("accessToken");
|
||||
const apiKeyFromQuery = u.searchParams.get("api_key") ?? u.searchParams.get("apiKey");
|
||||
const baseUrlFromQuery = u.searchParams.get("base_url") ?? u.searchParams.get("baseUrl");
|
||||
const consoleSiteFromQuery =
|
||||
u.searchParams.get("console_site") ?? u.searchParams.get("consoleSite");
|
||||
const consoleRegionFromQuery =
|
||||
u.searchParams.get("console_region") ?? u.searchParams.get("consoleRegion");
|
||||
const consoleSwitchAgentFromQuery =
|
||||
u.searchParams.get("console_switch_agent") ?? u.searchParams.get("consoleSwitchAgent");
|
||||
const workspaceIdFromQuery =
|
||||
u.searchParams.get("workspace_id") ?? u.searchParams.get("workspaceId");
|
||||
|
||||
const extras = {
|
||||
baseUrl: baseUrlFromQuery?.trim() || null,
|
||||
consoleSite: consoleSiteFromQuery?.trim() || null,
|
||||
consoleRegion: consoleRegionFromQuery?.trim() || null,
|
||||
consoleSwitchAgent: consoleSwitchAgentFromQuery?.trim() || null,
|
||||
workspaceId: workspaceIdFromQuery?.trim() || null,
|
||||
};
|
||||
|
||||
const m = req.method ?? "GET";
|
||||
if (m !== "POST" && m !== "PUT" && m !== "PATCH") {
|
||||
return {
|
||||
accessToken: accessTokenFromQuery?.trim() || null,
|
||||
apiKey: apiKeyFromQuery?.trim() || null,
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,12 +330,24 @@ async function extractCredentialsFromRequest(
|
||||
return {
|
||||
accessToken: accessTokenFromQuery?.trim() || null,
|
||||
apiKey: apiKeyFromQuery?.trim() || null,
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = accessTokenFromQuery?.trim() || parseAccessTokenFromRawBody(raw, contentType);
|
||||
const apiKey = apiKeyFromQuery?.trim() || parseApiKeyFromRawBody(raw, contentType);
|
||||
return { accessToken, apiKey };
|
||||
|
||||
const bodyExtras = parseExtrasFromRawBody(raw, contentType);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
apiKey,
|
||||
baseUrl: extras.baseUrl || bodyExtras.baseUrl,
|
||||
consoleSite: extras.consoleSite || bodyExtras.consoleSite,
|
||||
consoleRegion: extras.consoleRegion || bodyExtras.consoleRegion,
|
||||
consoleSwitchAgent: extras.consoleSwitchAgent || bodyExtras.consoleSwitchAgent,
|
||||
workspaceId: extras.workspaceId || bodyExtras.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
|
||||
@@ -276,9 +379,69 @@ function openInBrowser(url: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
config: Config,
|
||||
key: string,
|
||||
baseUrl: string,
|
||||
): Promise<void> {
|
||||
process.stderr.write("Testing key... ");
|
||||
const testConfig = { ...config, apiKey: key, baseUrl };
|
||||
const requestOpts = {
|
||||
url: chatEndpoint(testConfig.baseUrl),
|
||||
method: "POST",
|
||||
timeout: Math.min(config.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>(testConfig, 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");
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
existing.api_key = key;
|
||||
await writeConfigFile(existing);
|
||||
}
|
||||
|
||||
export async function runConsoleLogin(
|
||||
consoleOrigin: string,
|
||||
opts?: { needApiKey?: boolean; onApiKey?: (key: string) => Promise<void> },
|
||||
config: Config,
|
||||
opts?: { needApiKey?: boolean },
|
||||
): Promise<void> {
|
||||
const state = randomBytes(16).toString("hex");
|
||||
let callbackError: unknown;
|
||||
@@ -301,18 +464,35 @@ export async function runConsoleLogin(
|
||||
return;
|
||||
}
|
||||
|
||||
const { accessToken, apiKey } = await extractCredentialsFromRequest(req);
|
||||
const {
|
||||
accessToken,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
consoleSite,
|
||||
consoleRegion,
|
||||
consoleSwitchAgent,
|
||||
workspaceId,
|
||||
} = await extractCredentialsFromRequest(req);
|
||||
|
||||
if (accessToken || apiKey) {
|
||||
const hasConfig =
|
||||
accessToken || baseUrl || consoleSite || consoleRegion || consoleSwitchAgent || workspaceId;
|
||||
|
||||
if (hasConfig || apiKey) {
|
||||
try {
|
||||
if (accessToken) {
|
||||
if (hasConfig) {
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
existing.access_token = accessToken;
|
||||
if (accessToken) existing.access_token = accessToken;
|
||||
if (baseUrl) existing.base_url = baseUrl;
|
||||
if (consoleSite) existing.console_site = consoleSite;
|
||||
if (consoleRegion) existing.console_region = consoleRegion;
|
||||
if (consoleSwitchAgent) existing.console_switch_agent = Number(consoleSwitchAgent);
|
||||
if (workspaceId) existing.workspace_id = workspaceId;
|
||||
await writeConfigFile(existing);
|
||||
process.stderr.write(`access_token saved to ${getConfigPath()}\n`);
|
||||
process.stderr.write(`Config saved to ${getConfigPath()}\n`);
|
||||
}
|
||||
if (apiKey && opts?.onApiKey) {
|
||||
await opts.onApiKey(apiKey);
|
||||
if (apiKey) {
|
||||
const testBaseUrl = baseUrl || config.baseUrl;
|
||||
await validateAndPersistApiKey(config, apiKey, testBaseUrl);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
callbackError = err;
|
||||
@@ -329,7 +509,7 @@ export async function runConsoleLogin(
|
||||
});
|
||||
res.end("OK\n");
|
||||
|
||||
if (accessToken || apiKey) {
|
||||
if (hasConfig || apiKey) {
|
||||
server.close();
|
||||
}
|
||||
} catch {
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
defineCommand,
|
||||
isInteractive,
|
||||
maskToken,
|
||||
readConfigFile,
|
||||
writeConfigFile,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { printQuickStart } from "bailian-cli-runtime";
|
||||
import { emitBare } from "bailian-cli-runtime";
|
||||
import { promptConfirm } from "bailian-cli-runtime";
|
||||
import { printCurrentCommandHelp } from "bailian-cli-runtime";
|
||||
import {
|
||||
resolveConsoleOrigin,
|
||||
runConsoleLogin,
|
||||
validateAndPersistApiKey,
|
||||
} from "./login-console.ts";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Authenticate with API key or console browser login (credentials can coexist)",
|
||||
skipDefaultApiKeySetup: true,
|
||||
usageArgs: "--api-key <key> | --console",
|
||||
options: [
|
||||
{ flag: "--api-key <key>", description: "DashScope API key to store" },
|
||||
{
|
||||
flag: "--base-url <url>",
|
||||
description: "DashScope API base URL (used with --api-key for validation)",
|
||||
},
|
||||
{
|
||||
flag: "--console",
|
||||
description:
|
||||
"Sign in via browser; use --console-site to choose domestic (default) or international",
|
||||
},
|
||||
],
|
||||
exampleArgs: ["--api-key sk-xxxxx", "--console"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
if (flags.console) {
|
||||
if (config.dryRun) {
|
||||
emitBare(
|
||||
"Would bind a free port on 127.0.0.1 and open the console login URL in your browser.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const hasApiKey = !!(config.apiKey || config.fileApiKey);
|
||||
await runConsoleLogin(resolveConsoleOrigin(config.consoleSite || "domestic"), config, {
|
||||
needApiKey: !hasApiKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const envKey = process.env.DASHSCOPE_API_KEY;
|
||||
if (envKey && !flags.apiKey) {
|
||||
const maskedEnvKey = maskToken(envKey);
|
||||
if (isInteractive({ nonInteractive: config.nonInteractive })) {
|
||||
const proceed = await promptConfirm({
|
||||
message: `Detected DASHSCOPE_API_KEY in environment (${maskedEnvKey}).\nYou are already authenticated via env.\nDo you still want to configure local persistent credentials?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (!proceed) {
|
||||
process.stdout.write("Login skipped. Using environment variables.\n");
|
||||
process.exit(0);
|
||||
}
|
||||
} else {
|
||||
process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const key = (flags.apiKey as string) || config.apiKey;
|
||||
if (!key) {
|
||||
printCurrentCommandHelp(process.stderr);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const baseUrl = (flags.baseUrl as string) || undefined;
|
||||
const effectiveConfig = baseUrl ? { ...config, baseUrl } : config;
|
||||
|
||||
if (!config.dryRun) {
|
||||
if (baseUrl) {
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
existing.base_url = baseUrl;
|
||||
await writeConfigFile(existing);
|
||||
}
|
||||
await validateAndPersistApiKey(effectiveConfig, key, effectiveConfig.baseUrl);
|
||||
printQuickStart();
|
||||
} else {
|
||||
emitBare("Would validate and save API key.");
|
||||
}
|
||||
},
|
||||
});
|
||||
+4
-9
@@ -7,7 +7,7 @@ import {
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare } from "../../output/output.ts";
|
||||
import { emitBare } from "bailian-cli-runtime";
|
||||
|
||||
async function clearConsoleToken(): Promise<boolean> {
|
||||
const file = readConfigFile() as Record<string, unknown>;
|
||||
@@ -18,9 +18,9 @@ async function clearConsoleToken(): Promise<boolean> {
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "auth logout",
|
||||
description: "Clear stored credentials",
|
||||
usage: "bl auth logout [--console] [--yes] [--dry-run]",
|
||||
skipDefaultApiKeySetup: true,
|
||||
usageArgs: "[--console] [--yes] [--dry-run]",
|
||||
options: [
|
||||
{
|
||||
flag: "--console",
|
||||
@@ -29,12 +29,7 @@ export default defineCommand({
|
||||
},
|
||||
{ flag: "--yes", description: "Skip confirmation prompt" },
|
||||
],
|
||||
examples: [
|
||||
"bl auth logout",
|
||||
"bl auth logout --console",
|
||||
"bl auth logout --dry-run",
|
||||
"bl auth logout --yes",
|
||||
],
|
||||
exampleArgs: ["", "--console", "--dry-run", "--yes"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const file = readConfigFile();
|
||||
|
||||
+20
-10
@@ -8,8 +8,8 @@ import {
|
||||
type GlobalFlags,
|
||||
type ResolvedCredential,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { API_KEY_PAGE } from "../../urls.ts";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { API_KEY_PAGE } from "bailian-cli-runtime";
|
||||
|
||||
interface StoredCredential {
|
||||
configured: boolean;
|
||||
@@ -108,7 +108,7 @@ function hasAnyAuth(status: AuthStatusPayload): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function emitTextStatus(status: AuthStatusPayload): void {
|
||||
function emitTextStatus(status: AuthStatusPayload, config: Config): void {
|
||||
emitBare("Authentication Status:");
|
||||
emitBare(" Stored credentials (can coexist):");
|
||||
if (status.api_key.configured) {
|
||||
@@ -134,15 +134,25 @@ function emitTextStatus(status: AuthStatusPayload): void {
|
||||
` Console gateway: ${status.console_gateway_commands.method} (${status.console_gateway_commands.source}) ${status.console_gateway_commands.masked}`,
|
||||
);
|
||||
} else {
|
||||
emitBare(" Console gateway: unavailable (run bl auth login --console)");
|
||||
emitBare(` Console gateway: unavailable (run ${config.binName} auth login --console)`);
|
||||
}
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "auth status",
|
||||
description: "Show current authentication state",
|
||||
usage: "bl auth status",
|
||||
examples: ["bl auth status", "bl auth status --output json"],
|
||||
options: [
|
||||
{ flag: "--console-region <region>", description: "Console region" },
|
||||
{
|
||||
flag: "--console-site <site>",
|
||||
description: "Console site: domestic, international",
|
||||
},
|
||||
{
|
||||
flag: "--console-switch-agent <uid>",
|
||||
description: "Switch agent UID",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
exampleArgs: ["", "--output json"],
|
||||
async run(config: Config, _flags: GlobalFlags) {
|
||||
const format = detectOutputFormat(config.output);
|
||||
const status = await buildStatus(config);
|
||||
@@ -152,8 +162,8 @@ export default defineCommand({
|
||||
authenticated: false,
|
||||
message: "Not authenticated.",
|
||||
hint: [
|
||||
"DashScope API: bl auth login --api-key <key> or DASHSCOPE_API_KEY",
|
||||
"Console gateway: bl auth login --console or DASHSCOPE_ACCESS_TOKEN",
|
||||
`DashScope API: ${config.binName} auth login --api-key <key> or DASHSCOPE_API_KEY`,
|
||||
`Console gateway: ${config.binName} auth login --console or DASHSCOPE_ACCESS_TOKEN`,
|
||||
`Get API Key: ${API_KEY_PAGE}`,
|
||||
].join("\n"),
|
||||
...status,
|
||||
@@ -167,6 +177,6 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
emitTextStatus(status);
|
||||
emitTextStatus(status, config);
|
||||
},
|
||||
});
|
||||
+9
-17
@@ -9,10 +9,9 @@ import {
|
||||
type GlobalFlags,
|
||||
ExitCode,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "../../output/output.ts";
|
||||
import { emitResult, cmdUsage } from "bailian-cli-runtime";
|
||||
|
||||
const VALID_KEYS = [
|
||||
"region",
|
||||
"base_url",
|
||||
"output",
|
||||
"output_dir",
|
||||
@@ -51,21 +50,21 @@ const KEY_ALIASES: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default defineCommand({
|
||||
name: "config set",
|
||||
description: "Set a config value",
|
||||
usage: "bl config set --key <key> --value <value>",
|
||||
skipDefaultApiKeySetup: true,
|
||||
usageArgs: "--key <key> --value <value>",
|
||||
options: [
|
||||
{
|
||||
flag: "--key <key>",
|
||||
description:
|
||||
"Config key (region, base_url, output, output_dir, timeout, api_key, access_token, default_*_model, access_key_id, access_key_secret, workspace_id)",
|
||||
"Config key (base_url, output, output_dir, timeout, api_key, access_token, default_*_model, access_key_id, access_key_secret, workspace_id)",
|
||||
},
|
||||
{ flag: "--value <value>", description: "Value to set" },
|
||||
],
|
||||
examples: [
|
||||
"bl config set --key output --value json",
|
||||
"bl config set --key timeout --value 600",
|
||||
"bl config set --key base_url --value https://dashscope.aliyuncs.com",
|
||||
exampleArgs: [
|
||||
"--key output --value json",
|
||||
"--key timeout --value 600",
|
||||
"--key base_url --value https://dashscope.aliyuncs.com",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const key = flags.key as string | undefined;
|
||||
@@ -75,7 +74,7 @@ export default defineCommand({
|
||||
throw new BailianError(
|
||||
"--key and --value are required.",
|
||||
ExitCode.USAGE,
|
||||
"bl config set --key <key> --value <value>",
|
||||
cmdUsage(config, "--key <key> --value <value>"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,13 +89,6 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
// Validate specific values
|
||||
if (resolvedKey === "region" && !["cn", "us", "intl"].includes(value)) {
|
||||
throw new BailianError(
|
||||
`Invalid region "${value}". Valid values: cn, us, intl`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
|
||||
throw new BailianError(
|
||||
`Invalid output format "${value}". Valid values: text, json`,
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
defineCommand,
|
||||
readConfigFile as loadConfigFile,
|
||||
getConfigPath,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Display current configuration",
|
||||
skipDefaultApiKeySetup: true,
|
||||
exampleArgs: ["", "--output json"],
|
||||
async run(config: Config, _flags: GlobalFlags) {
|
||||
const file = loadConfigFile();
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
...file,
|
||||
base_url: config.baseUrl,
|
||||
output: config.output,
|
||||
timeout: config.timeout,
|
||||
config_file: getConfigPath(),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
emitResult(result, format);
|
||||
},
|
||||
});
|
||||
+27
-14
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
defineCommand,
|
||||
callConsoleGateway,
|
||||
effectiveConsoleGatewayConfig,
|
||||
resolveConsoleGatewayCredential,
|
||||
CONSOLE_GATEWAY_NO_TOKEN_MESSAGE,
|
||||
BailianError,
|
||||
@@ -8,13 +9,13 @@ import {
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult } from "../../output/output.ts";
|
||||
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
name: "console call",
|
||||
description: "Call a Bailian console API via the CLI gateway",
|
||||
usage: "bl console call --api <api> --data <json> [flags]",
|
||||
skipDefaultApiKeySetup: true,
|
||||
usageArgs: "--api <api> --data <json> [flags]",
|
||||
options: [
|
||||
{
|
||||
flag: "--api <api>",
|
||||
@@ -26,21 +27,27 @@ export default defineCommand({
|
||||
description: "Request data as JSON string",
|
||||
required: true,
|
||||
},
|
||||
{ flag: "--console-region <region>", description: "Console region" },
|
||||
{
|
||||
flag: "--region <region>",
|
||||
description: "API region (default: cn-beijing)",
|
||||
flag: "--console-site <site>",
|
||||
description: "Console site: domestic, international",
|
||||
},
|
||||
{
|
||||
flag: "--console-switch-agent <uid>",
|
||||
description: "Switch agent UID",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
`bl console call --api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
|
||||
`bl console call --api some.api.name --data '{"key":"value"}' --region cn-beijing`,
|
||||
exampleArgs: [
|
||||
`--api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
|
||||
`--api some.api.name --data '{"key":"value"}' --console-region cn-beijing`,
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const api = flags.api as string;
|
||||
if (!api) failIfMissing("api", "bl console call --api <api> --data <json>");
|
||||
if (!api) failIfMissing("api", cmdUsage(config, "--api <api> --data <json>"));
|
||||
|
||||
const dataRaw = flags.data as string;
|
||||
if (!dataRaw) failIfMissing("data", "bl console call --api <api> --data <json>");
|
||||
if (!dataRaw) failIfMissing("data", cmdUsage(config, "--api <api> --data <json>"));
|
||||
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
@@ -50,7 +57,6 @@ export default defineCommand({
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const region = (flags.region as string) || "cn-beijing";
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
let token: string | undefined;
|
||||
@@ -63,14 +69,21 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ api, data, region, token: token ? token.slice(0, 8) + "..." : null }, format);
|
||||
emitResult(
|
||||
{
|
||||
api,
|
||||
data,
|
||||
token: token ? token.slice(0, 8) + "..." : null,
|
||||
...effectiveConsoleGatewayConfig(config),
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await callConsoleGateway(config, token, {
|
||||
api,
|
||||
data,
|
||||
region,
|
||||
});
|
||||
|
||||
emitResult(result, format);
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
deleteDataset,
|
||||
isInteractive,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Delete a dataset file by ID",
|
||||
usageArgs: "--file-id <id> [--yes]",
|
||||
options: [
|
||||
{ flag: "--file-id <id>", description: "Dataset file ID (required)", required: true },
|
||||
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
|
||||
],
|
||||
exampleArgs: ["--file-id file-id-xxx", "--file-id file-id-xxx --yes"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const fileId = flags.fileId as string | undefined;
|
||||
if (!fileId) failIfMissing("file-id", "bl dataset delete --file-id <id>");
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
const yes = Boolean(flags.yes);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "dataset.delete", file_id: fileId }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!yes) {
|
||||
if (isInteractive({ nonInteractive: config.nonInteractive })) {
|
||||
const ok = await promptConfirm({
|
||||
message: `Permanently delete dataset file ${fileId}? This cannot be undone.`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (!ok) {
|
||||
emitBare("Aborted.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
throw new BailianError(
|
||||
`Refusing to delete ${fileId} without --yes in non-interactive mode.`,
|
||||
ExitCode.USAGE,
|
||||
"Pass --yes to skip the confirmation prompt.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await deleteDataset(config, fileId!);
|
||||
|
||||
if (config.quiet || format === "text") {
|
||||
emitBare(`Deleted ${fileId}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getDataset,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Get details of a single dataset file",
|
||||
usageArgs: "--file-id <id>",
|
||||
options: [{ flag: "--file-id <id>", description: "Dataset file ID (required)", required: true }],
|
||||
exampleArgs: ["--file-id file-xxx", "--file-id file-xxx --output json"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const fileId = flags.fileId as string | undefined;
|
||||
if (!fileId) failIfMissing("file-id", "bl dataset get --file-id <id>");
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "dataset.get", file_id: fileId }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getDataset(config, fileId!);
|
||||
const file = response.data;
|
||||
|
||||
if (!file) {
|
||||
emitBare(`No data returned for ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeKb = file.size !== undefined ? `${(file.size / 1024).toFixed(1)} KB` : "?";
|
||||
const item = {
|
||||
file_id: file.file_id ?? fileId,
|
||||
name: file.name ?? "",
|
||||
size: sizeKb,
|
||||
md5: file.md5 ?? "",
|
||||
purpose: file.purpose ?? "",
|
||||
created_at: file.gmt_create ?? "",
|
||||
description: file.description ?? "",
|
||||
};
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
emitBare(`file_id: ${item.file_id}`);
|
||||
emitBare(`name: ${item.name}`);
|
||||
emitBare(`size: ${item.size}`);
|
||||
if (item.md5) emitBare(`md5: ${item.md5}`);
|
||||
if (item.purpose) emitBare(`purpose: ${item.purpose}`);
|
||||
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
|
||||
if (item.description) emitBare(`description: ${item.description}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
listDatasets,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { formatTable } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "List uploaded dataset files",
|
||||
usageArgs: "[--page <n>] [--page-size <n>] [--purpose <name>]",
|
||||
options: [
|
||||
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
|
||||
{
|
||||
flag: "--page-size <n>",
|
||||
description: "Results per page (default: 10, max 100)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--purpose <name>",
|
||||
description: 'Filter by purpose (e.g. "fine-tune", "evaluation"). Omit to list all.',
|
||||
},
|
||||
],
|
||||
exampleArgs: ["", "--purpose fine-tune", "--purpose evaluation --page-size 20", "--output json"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const format = detectOutputFormat(config.output);
|
||||
const pageNo = flags.page !== undefined ? (flags.page as number) : undefined;
|
||||
const pageSize = flags.pageSize !== undefined ? (flags.pageSize as number) : undefined;
|
||||
const purpose = (flags.purpose as string | undefined) || undefined;
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "dataset.list", page: pageNo, page_size: pageSize, purpose }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await listDatasets(config, { pageNo, pageSize, purpose });
|
||||
const files = response.data?.files ?? [];
|
||||
const total = response.data?.total;
|
||||
|
||||
// Normalize to consistent structure for both text/json output.
|
||||
const items = files.map((item) => ({
|
||||
file_id: item.file_id ?? "",
|
||||
name: item.name ?? "",
|
||||
size: item.size !== undefined ? `${(item.size / 1024).toFixed(1)} KB` : "?",
|
||||
purpose: item.purpose ?? "",
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
if (items.length === 0) {
|
||||
emitBare("No dataset files found.");
|
||||
return;
|
||||
}
|
||||
const headers = ["FILE_ID", "NAME", "SIZE", "PURPOSE"];
|
||||
const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
uploadDataset,
|
||||
validateDataset,
|
||||
parseDatasetSchemaFlag,
|
||||
formatIssue,
|
||||
MAX_DATASET_BYTES,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type DatasetFile,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Upload a dataset file (.jsonl) to Bailian",
|
||||
usageArgs:
|
||||
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt>] [--no-validate] [--full-validate]",
|
||||
options: [
|
||||
{
|
||||
flag: "--file <path>",
|
||||
description: "Local .jsonl dataset file (≤300MB)",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
flag: "--purpose <name>",
|
||||
description: 'Dataset purpose tag (default: "fine-tune"; e.g. "evaluation")',
|
||||
},
|
||||
{
|
||||
flag: "--schema <s>",
|
||||
description:
|
||||
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record.',
|
||||
},
|
||||
{
|
||||
flag: "--no-validate",
|
||||
description: "Skip the local JSONL pre-flight check (not recommended)",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
flag: "--full-validate",
|
||||
description: "JSON.parse every line instead of sampling (slower)",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
exampleArgs: [
|
||||
"--file train.jsonl",
|
||||
"--file dpo.jsonl --schema dpo",
|
||||
"--file cpt.jsonl --schema cpt",
|
||||
"--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).",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const filePath = flags.file as string | undefined;
|
||||
if (!filePath) failIfMissing("file", "bl dataset upload --file <path>");
|
||||
|
||||
const purpose = (flags.purpose as string | undefined) || "fine-tune";
|
||||
const skipValidate = Boolean(flags.noValidate);
|
||||
const fullValidate = Boolean(flags.fullValidate);
|
||||
const schema = parseDatasetSchemaFlag(flags.schema as string | undefined);
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (!skipValidate) {
|
||||
const result = await validateDataset(filePath!, { fullValidate, schema });
|
||||
if (!result.valid) {
|
||||
const lines = [
|
||||
`Dataset validation failed for ${filePath}`,
|
||||
...result.errors.slice(0, 10).map(formatIssue),
|
||||
];
|
||||
if (result.errors.length > 10) {
|
||||
lines.push(` … and ${result.errors.length - 10} more error(s).`);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
"Hint: re-run `bl dataset validate --file <path>` for the full report,",
|
||||
" or pass --no-validate to skip this check at your own risk.",
|
||||
);
|
||||
throw new BailianError(lines.join("\n"), ExitCode.GENERAL);
|
||||
}
|
||||
// Surface warnings to stderr but keep going.
|
||||
if (result.warnings.length > 0 && !config.quiet) {
|
||||
process.stderr.write(
|
||||
`Dataset validation passed with ${result.warnings.length} warning(s):\n`,
|
||||
);
|
||||
for (const warning of result.warnings.slice(0, 5))
|
||||
process.stderr.write(`${formatIssue(warning)}\n`);
|
||||
if (result.warnings.length > 5) {
|
||||
process.stderr.write(` … and ${result.warnings.length - 5} more.\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
action: "dataset.upload",
|
||||
file: filePath,
|
||||
purpose,
|
||||
max_bytes: MAX_DATASET_BYTES,
|
||||
validate: !skipValidate,
|
||||
schema: schema ?? "auto",
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaded: DatasetFile = await uploadDataset(config, {
|
||||
filePath: filePath!,
|
||||
purpose,
|
||||
});
|
||||
|
||||
if (config.quiet) {
|
||||
emitBare(uploaded.file_id);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Uploaded ${uploaded.name} → file_id=${uploaded.file_id}`);
|
||||
} else {
|
||||
emitResult(uploaded, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
validateDataset,
|
||||
parseDatasetSchemaFlag,
|
||||
formatIssue,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type ValidationResult,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
function formatStats(result: ValidationResult): string[] {
|
||||
const out: string[] = [];
|
||||
if (result.stats.totalRecords !== undefined) out.push(`records: ${result.stats.totalRecords}`);
|
||||
if (result.stats.sampledRecords !== undefined)
|
||||
out.push(`sampled: ${result.stats.sampledRecords}`);
|
||||
if (result.stats.bytes !== undefined) out.push(`bytes: ${result.stats.bytes}`);
|
||||
if (result.stats.durationMs !== undefined) out.push(`took: ${result.stats.durationMs}ms`);
|
||||
return out;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Locally validate a dataset file (.jsonl) without uploading",
|
||||
// 纯本地校验,不触网、不需 API key(与 `pipeline validate` 一致)。
|
||||
skipDefaultApiKeySetup: true,
|
||||
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt>]",
|
||||
options: [
|
||||
{ flag: "--file <path>", description: "Local .jsonl dataset file", required: true },
|
||||
{
|
||||
flag: "--full-validate",
|
||||
description: "JSON.parse every line instead of sampling (slower)",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
flag: "--schema <s>",
|
||||
description:
|
||||
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record.',
|
||||
},
|
||||
],
|
||||
exampleArgs: [
|
||||
"--file train.jsonl",
|
||||
"--file dpo.jsonl --schema dpo",
|
||||
"--file cpt.jsonl --schema cpt",
|
||||
"--file eval.jsonl --full-validate",
|
||||
"--file train.jsonl --output json",
|
||||
],
|
||||
notes: [
|
||||
"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.",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const filePath = flags.file as string | undefined;
|
||||
if (!filePath) failIfMissing("file", "bl dataset validate --file <path>");
|
||||
|
||||
const fullValidate = Boolean(flags.fullValidate);
|
||||
const schema = parseDatasetSchemaFlag(flags.schema as string | undefined);
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
action: "dataset.validate",
|
||||
file: filePath,
|
||||
full: fullValidate,
|
||||
schema: schema ?? "auto",
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await validateDataset(filePath!, { fullValidate, schema });
|
||||
|
||||
if (format === "json") {
|
||||
// For json output we always emit the structured result, exit code conveys validity.
|
||||
emitResult(result, format);
|
||||
} else if (config.quiet) {
|
||||
emitBare(result.valid ? "ok" : "fail");
|
||||
} else {
|
||||
const status = result.valid ? "PASSED" : "FAILED";
|
||||
emitBare(`Dataset validation ${status} for ${result.filePath}`);
|
||||
const stats = formatStats(result);
|
||||
if (stats.length) emitBare(` ${stats.join(" · ")}`);
|
||||
|
||||
if (result.errors.length) {
|
||||
emitBare(`Errors (${result.errors.length}):`);
|
||||
for (const error of result.errors.slice(0, 20)) emitBare(formatIssue(error));
|
||||
if (result.errors.length > 20) {
|
||||
emitBare(` … and ${result.errors.length - 20} more.`);
|
||||
}
|
||||
}
|
||||
if (result.warnings.length) {
|
||||
emitBare(`Warnings (${result.warnings.length}):`);
|
||||
for (const warning of result.warnings.slice(0, 10)) emitBare(formatIssue(warning));
|
||||
if (result.warnings.length > 10) {
|
||||
emitBare(` … and ${result.warnings.length - 10} more.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.valid) {
|
||||
// Match the upload command's exit-code convention; details already printed.
|
||||
throw new BailianError(
|
||||
`Dataset validation failed: ${result.errors.length} error(s).`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
createDeployment,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { pickPlanStrategy } from "./plans.ts";
|
||||
|
||||
/**
|
||||
* `bl deploy create` — create a model deployment.
|
||||
*
|
||||
* Plan-specific behaviour (required flags / body assembly / confirm rows /
|
||||
* auto-pick) lives in `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file
|
||||
* only handles the shared envelope: argument parsing, dispatch, dry-run,
|
||||
* confirmation prompt, 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.
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Create a model deployment",
|
||||
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>] [--yes]",
|
||||
options: [
|
||||
{
|
||||
flag: "--model <name>",
|
||||
description: "Model name (catalog model or fine-tuned output) (required)",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
flag: "--name <display_name>",
|
||||
description: "Console display name for the deployment (required)",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
flag: "--plan <plan>",
|
||||
description: "Billing plan: lora (default, Token-billed) | ptu (Token-billed) | mu",
|
||||
},
|
||||
{
|
||||
flag: "--template-id <id>",
|
||||
description: "Template id (only used by plan=mu; auto-picked if omitted)",
|
||||
},
|
||||
{
|
||||
flag: "--capacity <n>",
|
||||
description:
|
||||
"Resource units (plan=mu only; required by API; defaults to the template's unit)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--billing-method <m>",
|
||||
description: 'Billing method (plan=mu only; default "POST_PAY", the only supported value)',
|
||||
},
|
||||
{
|
||||
flag: "--input-tpm <n>",
|
||||
description: "PTU max input tokens/min (required for plan=ptu)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--output-tpm <n>",
|
||||
description: "PTU max output tokens/min (required for plan=ptu)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--thinking-output-tpm <n>",
|
||||
description: "PTU max thinking-output tokens/min (optional, some models)",
|
||||
type: "number",
|
||||
},
|
||||
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
|
||||
],
|
||||
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 --yes",
|
||||
],
|
||||
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.",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const model = flags.model as string | undefined;
|
||||
const name = flags.name as string | undefined;
|
||||
if (!model)
|
||||
failIfMissing("model", "bl deploy create --model <model_name> --name <display_name>");
|
||||
if (!name) failIfMissing("name", "bl deploy create --model <model_name> --name <display_name>");
|
||||
|
||||
const plan = (flags.plan as string | undefined) || "lora";
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
// Plan-specific behaviour is owned by `plans.ts`. The strategy:
|
||||
// 1. Validates required flags (USAGE error if missing).
|
||||
// 2. Resolves the body fragment + confirm rows (mu may auto-pick a
|
||||
// template from the deployable-models catalog).
|
||||
// Anything outside the strategy table is rejected with a USAGE error.
|
||||
const strategy = pickPlanStrategy(plan);
|
||||
strategy.validateFlags(flags);
|
||||
const resolved = await strategy.resolve({ config, flags, model: model!, name: name! });
|
||||
const body: Record<string, unknown> = {
|
||||
model_name: model!,
|
||||
name: name!,
|
||||
plan,
|
||||
...resolved.body,
|
||||
};
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "deploy.create", body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flags.yes && !config.nonInteractive && !config.quiet) {
|
||||
const lines = [
|
||||
"Create deployment:",
|
||||
` model: ${model}`,
|
||||
` name: ${name}`,
|
||||
` plan: ${plan}${resolved.planLabelSuffix ?? ""}`,
|
||||
...resolved.confirmRows,
|
||||
];
|
||||
process.stderr.write(lines.join("\n") + "\n");
|
||||
const ok = await promptConfirm({ message: "Proceed?", initialValue: true });
|
||||
if (!ok) {
|
||||
emitBare("Cancelled.");
|
||||
return;
|
||||
}
|
||||
} else if (!flags.yes && config.nonInteractive) {
|
||||
throw new BailianError(
|
||||
"Pass --yes to confirm deployment creation in non-interactive mode.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await createDeployment(config, body as never);
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (config.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: bl deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
|
||||
);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
deleteDeployment,
|
||||
getDeployment,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
/**
|
||||
* `bl deploy delete` — destroy a deployment.
|
||||
*
|
||||
* Server-side precondition: status must be STOPPED or FAILED. We surface a
|
||||
* clear local hint for RUNNING / PENDING deployments before issuing the
|
||||
* DELETE call.
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Delete a model deployment (must be STOPPED or FAILED)",
|
||||
usageArgs: "--deployed-model <id> [--yes] [--skip-precheck]",
|
||||
options: [
|
||||
{
|
||||
flag: "--deployed-model <id>",
|
||||
description: "Deployed model identifier (required)",
|
||||
required: true,
|
||||
},
|
||||
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
|
||||
{
|
||||
flag: "--skip-precheck",
|
||||
description: "Skip the local STOPPED/FAILED status precheck",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
exampleArgs: ["--deployed-model dep-...", "--deployed-model dep-... --yes"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const deployedModel = flags.deployedModel as string | undefined;
|
||||
if (!deployedModel) failIfMissing("deployed-model", "bl deploy delete --deployed-model <id>");
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "deploy.delete", deployed_model: deployedModel }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// Precheck status unless skipped — surface a clear hint instead of letting
|
||||
// the server return a generic precondition error.
|
||||
if (!flags.skipPrecheck) {
|
||||
try {
|
||||
const get = await getDeployment(config, deployedModel!);
|
||||
const deployment = get.output ?? get.data;
|
||||
const status = (deployment?.status ?? "").toUpperCase();
|
||||
if (status && status !== "STOPPED" && status !== "FAILED") {
|
||||
throw new BailianError(
|
||||
`Deployment ${deployedModel} is ${status}. Only STOPPED / FAILED deployments can be deleted. ` +
|
||||
`Stop it first via the platform console, or pass --skip-precheck to attempt deletion anyway.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof BailianError) throw e;
|
||||
// If the get itself failed (e.g. not found), let the DELETE call surface the real error.
|
||||
}
|
||||
}
|
||||
|
||||
if (!flags.yes && !config.nonInteractive && !config.quiet) {
|
||||
process.stderr.write(`Delete deployment ${deployedModel}?\n`);
|
||||
const ok = await promptConfirm({ message: "Proceed?", initialValue: false });
|
||||
if (!ok) {
|
||||
emitBare("Cancelled.");
|
||||
return;
|
||||
}
|
||||
} else if (!flags.yes && config.nonInteractive) {
|
||||
throw new BailianError(
|
||||
"Pass --yes to confirm deletion in non-interactive mode.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await deleteDeployment(config, deployedModel!);
|
||||
|
||||
if (config.quiet) {
|
||||
emitBare(deployedModel!);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Deleted ${deployedModel}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getDeployment,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Get details of a single model deployment",
|
||||
usageArgs: "--deployed-model <id>",
|
||||
options: [
|
||||
{
|
||||
flag: "--deployed-model <id>",
|
||||
description: "Deployed model identifier (required)",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
exampleArgs: [
|
||||
"--deployed-model qwen-plus-2025-12-01-b6d61c71",
|
||||
"--deployed-model qwen-plus-2025-12-01-b6d61c71 --output json",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const deployedModel = flags.deployedModel as string | undefined;
|
||||
if (!deployedModel) failIfMissing("deployed-model", "bl deploy get --deployed-model <id>");
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "deploy.get", deployed_model: deployedModel }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getDeployment(config, deployedModel!);
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (!deployment) {
|
||||
emitBare(`No data returned for ${deployedModel}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const item: Record<string, unknown> = {
|
||||
deployed_model: deployment.deployed_model ?? deployedModel,
|
||||
deployed_name: deployment.name ?? "",
|
||||
model_name: deployment.model_name ?? "",
|
||||
base_model: deployment.base_model ?? "",
|
||||
status: deployment.status ?? "",
|
||||
plan: deployment.plan ?? "",
|
||||
};
|
||||
if (deployment.model_unit_spec) item.model_unit_spec = deployment.model_unit_spec;
|
||||
if (deployment.charge_type) item.charge_type = deployment.charge_type;
|
||||
if (deployment.capacity !== undefined) item.capacity = deployment.capacity;
|
||||
if (deployment.base_capacity !== undefined) item.base_capacity = deployment.base_capacity;
|
||||
if (deployment.ready_capacity !== undefined) item.ready_capacity = deployment.ready_capacity;
|
||||
if (deployment.rpm_limit !== undefined) item.rpm_limit = deployment.rpm_limit;
|
||||
if (deployment.tpm_limit !== undefined) item.tpm_limit = deployment.tpm_limit;
|
||||
if (deployment.input_tpm !== undefined) item.input_tpm = deployment.input_tpm;
|
||||
if (deployment.output_tpm !== undefined) item.output_tpm = deployment.output_tpm;
|
||||
if (deployment.gmt_create) item.created_at = deployment.gmt_create;
|
||||
if (deployment.gmt_modified) item.updated_at = deployment.gmt_modified;
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet — fixed-width label column for alignment
|
||||
const label = (key: string) => `${key}:`.padEnd(18);
|
||||
for (const [key, value] of Object.entries(item)) {
|
||||
if (value === "" || value === undefined) continue;
|
||||
const display = typeof value === "string" ? value : JSON.stringify(value);
|
||||
emitBare(`${label(key)}${display}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
listDeployments,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { formatTable } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "List model deployments",
|
||||
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>]",
|
||||
options: [
|
||||
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
|
||||
{
|
||||
flag: "--page-size <n>",
|
||||
description: "Results per page (default: 10, max 100)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--status <s>",
|
||||
description: "Filter by status (PENDING / RUNNING / STOPPED / FAILED)",
|
||||
},
|
||||
],
|
||||
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const format = detectOutputFormat(config.output);
|
||||
const pageNo = flags.page !== undefined ? (flags.page as number) : undefined;
|
||||
const pageSize = flags.pageSize !== undefined ? (flags.pageSize as number) : undefined;
|
||||
const status = (flags.status as string | undefined) || undefined;
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "deploy.list", page: pageNo, page_size: pageSize, status }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await listDeployments(config, { pageNo, pageSize, status });
|
||||
const payload = response.output ?? response.data;
|
||||
const deployments = payload?.deployments ?? [];
|
||||
const total = payload?.total;
|
||||
|
||||
const items = deployments.map((item) => ({
|
||||
deployed_model: item.deployed_model ?? "",
|
||||
model_name: item.model_name ?? "",
|
||||
status: item.status ?? "",
|
||||
plan: item.plan ?? "",
|
||||
capacity: item.capacity !== undefined ? String(item.capacity) : "",
|
||||
created_at: item.gmt_create ?? "",
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
if (items.length === 0) {
|
||||
emitBare("No deployments found.");
|
||||
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,
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
listDeployableModels,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { formatTable } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "List models available for deployment",
|
||||
usageArgs: "[--page <n>] [--page-size <n>] [--version <v>] [--source <custom|public>]",
|
||||
options: [
|
||||
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
|
||||
{
|
||||
flag: "--page-size <n>",
|
||||
description: "Results per page (default: 100)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--version <v>",
|
||||
description: "Catalog version filter (default: v1.0; required for new catalog models)",
|
||||
},
|
||||
{
|
||||
flag: "--source <s>",
|
||||
description: "Model source filter: custom (fine-tuned) | base (catalog) | public",
|
||||
},
|
||||
],
|
||||
exampleArgs: [
|
||||
"",
|
||||
"--source base",
|
||||
"--source custom --page-size 50",
|
||||
"--version v1.0 --output json",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const format = detectOutputFormat(config.output);
|
||||
const pageNo = flags.page !== undefined ? (flags.page as number) : undefined;
|
||||
const pageSize = flags.pageSize !== undefined ? (flags.pageSize as number) : undefined;
|
||||
// Default version to v1.0 — without it, the API returns the legacy catalog
|
||||
// (only old fine-tune outputs). Pass --version "" to opt out.
|
||||
const version =
|
||||
flags.version === "" ? undefined : ((flags.version as string | undefined) ?? "v1.0");
|
||||
const modelSource = (flags.source as string | undefined) || undefined;
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
action: "deploy.models",
|
||||
page: pageNo,
|
||||
page_size: pageSize,
|
||||
version,
|
||||
model_source: modelSource,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await listDeployableModels(config, {
|
||||
pageNo,
|
||||
pageSize,
|
||||
version,
|
||||
modelSource,
|
||||
});
|
||||
const payload = response.output ?? response.data;
|
||||
const models = payload?.models ?? [];
|
||||
const total = payload?.total;
|
||||
|
||||
// Two response shapes:
|
||||
// - 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.
|
||||
if (format === "json") {
|
||||
const items = models.map((m) => {
|
||||
const out: Record<string, unknown> = {
|
||||
model_name: m.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 (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 (p.templates && p.templates.length > 0) {
|
||||
// Pull the top 6 fields most useful for `bl deploy create`.
|
||||
// Drop noisy/redundant: template_source, template_type,
|
||||
// template_version, deploy_spec (typically == template_id).
|
||||
planEntry.templates = p.templates.map((t) => {
|
||||
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;
|
||||
// Flatten roles.unified for the common COUPLED case.
|
||||
const unified = t.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) {
|
||||
tpl.roles = {
|
||||
prefill: t.roles?.prefill,
|
||||
decode: t.roles?.decode,
|
||||
};
|
||||
}
|
||||
if (t.template_desc) tpl.template_desc = t.template_desc;
|
||||
return tpl;
|
||||
});
|
||||
}
|
||||
return planEntry;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
emitResult({ items, total }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet — keep the compact single-line summary table.
|
||||
const textItems = models.map((m) => {
|
||||
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 (p.cu_specs && p.cu_specs.length > 0) {
|
||||
return `${planName}(${p.cu_specs.join("/")})`;
|
||||
}
|
||||
return planName;
|
||||
})
|
||||
.join(",");
|
||||
} else {
|
||||
plansSummary = "-";
|
||||
}
|
||||
return {
|
||||
model_name: m.model_name ?? "",
|
||||
base_model: m.base_model ?? "",
|
||||
source: m.model_source ?? "",
|
||||
plans: plansSummary,
|
||||
};
|
||||
});
|
||||
|
||||
if (textItems.length === 0) {
|
||||
emitBare("No deployable models found.");
|
||||
return;
|
||||
}
|
||||
const headers = ["MODEL_NAME", "BASE_MODEL", "SOURCE", "PLANS"];
|
||||
const rows = textItems.map((i) => [i.model_name, i.base_model, i.source, i.plans]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Per-plan strategy table for `bl deploy create`.
|
||||
*
|
||||
* Each PlanStrategy owns one slice of plan-specific behaviour:
|
||||
* - required-flag checks (USAGE errors when the user is missing something)
|
||||
* - any pre-flight side-effects (e.g. mu auto-picks a template from the
|
||||
* catalog; lora/ptu are pure)
|
||||
* - the plan-specific body fragment for POST /api/v1/deployments
|
||||
* - the plan-specific confirmation-panel rows
|
||||
*
|
||||
* The dispatcher in `create.ts` only knows about `STRATEGIES[plan]`. Adding a
|
||||
* new plan = one new strategy object + one line in `STRATEGIES`. Nothing in
|
||||
* `create.ts` needs to change. This collapses the 5 places where lora / ptu /
|
||||
* mu used to be hard-coded (default value list / required-flag checks /
|
||||
* auto-pick / body assembly / confirm rows) into one strategy entry per plan.
|
||||
*/
|
||||
import {
|
||||
listDeployableModels,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
|
||||
export interface PlanContext {
|
||||
config: Config;
|
||||
flags: GlobalFlags;
|
||||
/** Underlying model identifier (`--model`). */
|
||||
model: string;
|
||||
/** Console display name (`--name`). */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PlanResolved {
|
||||
/**
|
||||
* Plan-specific fields to merge into the request body. The shared envelope
|
||||
* (`{model_name, name, plan}`) is added by the caller.
|
||||
*/
|
||||
body: Record<string, unknown>;
|
||||
/**
|
||||
* Lines to append to the confirmation panel — each already formatted like
|
||||
* ` key: value`.
|
||||
*/
|
||||
confirmRows: string[];
|
||||
/**
|
||||
* Suffix appended to the `plan: <name>` confirm row, e.g.
|
||||
* ` (Token-billed)`. Empty / undefined when no annotation is needed.
|
||||
*/
|
||||
planLabelSuffix?: string;
|
||||
}
|
||||
|
||||
export interface PlanStrategy {
|
||||
/** Plan id, matches `--plan` CLI value. */
|
||||
name: string;
|
||||
/** Throws USAGE-coded BailianError when required flags are missing. */
|
||||
validateFlags(flags: GlobalFlags): void;
|
||||
/**
|
||||
* Resolve plan-specific bits to a body fragment + confirm rows. May call
|
||||
* into the API (e.g. mu auto-picks a template from the deployable-models
|
||||
* catalog).
|
||||
*/
|
||||
resolve(ctx: PlanContext): Promise<PlanResolved>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `lora` (Token-billed) — the CLI default. The API requires `capacity` even
|
||||
* though it is ignored for token-billed plans (per the working example), so
|
||||
* the CLI injects `1` as a placeholder.
|
||||
*/
|
||||
const loraStrategy: PlanStrategy = {
|
||||
name: "lora",
|
||||
validateFlags() {
|
||||
/* no required flags */
|
||||
},
|
||||
async resolve(): Promise<PlanResolved> {
|
||||
return {
|
||||
body: { capacity: 1 },
|
||||
confirmRows: [],
|
||||
planLabelSuffix: " (Token-billed)",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `ptu` (Token-billed, provisioned throughput). The platform rejects creation
|
||||
* without `ptu_capacity.input_tpm` / `output_tpm` ("Miss ptu capacity info")
|
||||
* even though the doc lists 10000/1000 defaults — so the CLI treats them as
|
||||
* required.
|
||||
*/
|
||||
const ptuStrategy: PlanStrategy = {
|
||||
name: "ptu",
|
||||
validateFlags(flags: GlobalFlags): void {
|
||||
const usage =
|
||||
"bl deploy create --plan ptu --model <m> --name <n> --input-tpm <n> --output-tpm <n>";
|
||||
if (flags.inputTpm === undefined) failIfMissing("input-tpm", usage);
|
||||
if (flags.outputTpm === undefined) failIfMissing("output-tpm", usage);
|
||||
},
|
||||
async resolve(ctx: PlanContext): Promise<PlanResolved> {
|
||||
const inputTpm = ctx.flags.inputTpm as number;
|
||||
const outputTpm = ctx.flags.outputTpm as number;
|
||||
const thinkingOutputTpm = ctx.flags.thinkingOutputTpm as number | undefined;
|
||||
const ptuCapacity: Record<string, number> = {
|
||||
input_tpm: inputTpm,
|
||||
output_tpm: outputTpm,
|
||||
};
|
||||
if (thinkingOutputTpm !== undefined) ptuCapacity.thinking_output_tpm = thinkingOutputTpm;
|
||||
|
||||
const rows = [` input_tpm: ${inputTpm}`, ` output_tpm: ${outputTpm}`];
|
||||
if (thinkingOutputTpm !== undefined) rows.push(` thinking_output_tpm: ${thinkingOutputTpm}`);
|
||||
|
||||
return {
|
||||
body: { ptu_capacity: ptuCapacity },
|
||||
confirmRows: rows,
|
||||
planLabelSuffix: " (Token-billed, provisioned throughput)",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `mu` (model-unit-billed). `capacity`, `billing_method` and `template_id` are
|
||||
* all required by the API but every one has a CLI-side default:
|
||||
* - billing_method defaults to POST_PAY (the only supported value).
|
||||
* - template_id auto-picks from GET /deployments/models — the one whose
|
||||
* `charge_type` matches `billing_method`, else the first available.
|
||||
* - capacity defaults to the template's `capacity_unit_per_instance` (the
|
||||
* smallest valid multiple of base_capacity).
|
||||
*
|
||||
* The catalog lookup is skipped when `--template-id` is supplied explicitly:
|
||||
* fine-tuned custom models may not appear in the `source=base` catalog, and
|
||||
* forcing the lookup would otherwise raise a spurious "no template" error.
|
||||
* It is also skipped in dry-run mode to keep `--dry-run` side-effect-free.
|
||||
*/
|
||||
const muStrategy: PlanStrategy = {
|
||||
name: "mu",
|
||||
validateFlags() {
|
||||
/* every required field has a default — nothing to assert up-front */
|
||||
},
|
||||
async resolve(ctx: PlanContext): Promise<PlanResolved> {
|
||||
const billingMethod = (ctx.flags.billingMethod as string | undefined) || "POST_PAY";
|
||||
let templateId = ctx.flags.templateId as string | undefined;
|
||||
let capacity = ctx.flags.capacity as number | undefined;
|
||||
let autoPickedTemplate = false;
|
||||
|
||||
if (!ctx.config.dryRun && !templateId) {
|
||||
try {
|
||||
const resp = await listDeployableModels(ctx.config, {
|
||||
modelSource: "base",
|
||||
pageSize: 100,
|
||||
version: "v1.0",
|
||||
});
|
||||
const payload = resp.output ?? resp.data;
|
||||
const target = (payload?.models ?? []).find((m) => m.model_name === ctx.model);
|
||||
const muPlan = target?.plans?.find((p) => p.plan === "mu");
|
||||
const templates = muPlan?.templates ?? [];
|
||||
if (templates.length === 0) {
|
||||
throw new BailianError(
|
||||
`No mu-plan template found for model "${ctx.model}". ` +
|
||||
`Run \`bl deploy models --source base\` to inspect available models, ` +
|
||||
`or pass --template-id explicitly.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
// POST_PAY → post_paid template; fall back to the first available.
|
||||
const wantChargeType = billingMethod === "POST_PAY" ? "post_paid" : "pre_paid";
|
||||
const picked = templates.find((t) => t.charge_type === wantChargeType) ?? templates[0];
|
||||
if (!picked?.template_id) {
|
||||
throw new BailianError(
|
||||
`No mu-plan template found for model "${ctx.model}". ` +
|
||||
`Run \`bl deploy models --source base\` to inspect available models, ` +
|
||||
`or pass --template-id explicitly.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
templateId = picked.template_id;
|
||||
autoPickedTemplate = true;
|
||||
if (capacity === undefined) {
|
||||
capacity = picked.roles?.unified?.capacity_unit_per_instance ?? 1;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof BailianError) throw e;
|
||||
throw new BailianError(
|
||||
`Failed to auto-pick template for plan=mu: ${(e as Error).message}. ` +
|
||||
`Pass --template-id explicitly.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
capacity: capacity ?? 1,
|
||||
billing_method: billingMethod,
|
||||
};
|
||||
if (templateId) body.template_id = templateId;
|
||||
|
||||
const rows: string[] = [];
|
||||
if (templateId) {
|
||||
const hint = autoPickedTemplate ? " (auto-picked)" : "";
|
||||
rows.push(` template_id: ${templateId}${hint}`);
|
||||
}
|
||||
rows.push(` capacity: ${capacity ?? 1}`);
|
||||
rows.push(` billing_method: ${billingMethod}`);
|
||||
|
||||
return { body, confirmRows: rows };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Registry of supported plans. Adding a new plan = one entry here. The
|
||||
* catalog lists some additional plan names (e.g. `ptu_v2`) that are NOT
|
||||
* accepted by the create endpoint, so the dispatcher in `create.ts` will
|
||||
* reject anything outside this table with a clear USAGE error.
|
||||
*/
|
||||
export const STRATEGIES: Record<string, PlanStrategy> = {
|
||||
lora: loraStrategy,
|
||||
ptu: ptuStrategy,
|
||||
mu: muStrategy,
|
||||
};
|
||||
|
||||
/** Throws USAGE if `plan` is not in the strategy table. */
|
||||
export function pickPlanStrategy(plan: string): PlanStrategy {
|
||||
const s = STRATEGIES[plan];
|
||||
if (!s) {
|
||||
throw new BailianError(
|
||||
`Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
scaleDeployment,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
/**
|
||||
* `bl deploy scale` — adjust capacity (and optional PTU input/output token rates).
|
||||
*
|
||||
* Server-side capacity constraint: positive integer, < 1000, must be an
|
||||
* integer multiple of `base_capacity` (visible via `bl deploy get`).
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Scale a deployment's capacity",
|
||||
usageArgs: "--deployed-model <id> --capacity <n> [--input-tpm <n>] [--output-tpm <n>] [--yes]",
|
||||
options: [
|
||||
{
|
||||
flag: "--deployed-model <id>",
|
||||
description: "Deployed model identifier (required)",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
flag: "--capacity <n>",
|
||||
description: "New capacity in plan units (must be a multiple of base_capacity)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--input-tpm <n>",
|
||||
description: "PTU only — input tokens per minute",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--output-tpm <n>",
|
||||
description: "PTU only — output tokens per minute",
|
||||
type: "number",
|
||||
},
|
||||
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
|
||||
],
|
||||
exampleArgs: [
|
||||
"--deployed-model qwen-plus-...-b6d61c71 --capacity 8",
|
||||
"--deployed-model dep-... --capacity 2 --yes",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const deployedModel = flags.deployedModel as string | undefined;
|
||||
if (!deployedModel)
|
||||
failIfMissing("deployed-model", "bl deploy scale --deployed-model <id> --capacity <n>");
|
||||
|
||||
const capacity = flags.capacity !== undefined ? (flags.capacity as number) : undefined;
|
||||
const inputTpm = flags.inputTpm !== undefined ? (flags.inputTpm as number) : undefined;
|
||||
const outputTpm = flags.outputTpm !== undefined ? (flags.outputTpm as number) : undefined;
|
||||
|
||||
if (capacity === undefined && inputTpm === undefined && outputTpm === undefined) {
|
||||
throw new BailianError(
|
||||
"Provide at least one of --capacity / --input-tpm / --output-tpm.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
const body: Record<string, unknown> = {};
|
||||
if (capacity !== undefined) body.capacity = capacity;
|
||||
if (inputTpm !== undefined) body.input_tpm = inputTpm;
|
||||
if (outputTpm !== undefined) body.output_tpm = outputTpm;
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flags.yes && !config.nonInteractive && !config.quiet) {
|
||||
const parts: string[] = [];
|
||||
if (capacity !== undefined) parts.push(`capacity=${capacity}`);
|
||||
if (inputTpm !== undefined) parts.push(`input_tpm=${inputTpm}`);
|
||||
if (outputTpm !== undefined) parts.push(`output_tpm=${outputTpm}`);
|
||||
process.stderr.write(`Scale deployment ${deployedModel} (${parts.join(", ")})?\n`);
|
||||
const ok = await promptConfirm({ message: "Proceed?", initialValue: false });
|
||||
if (!ok) {
|
||||
emitBare("Cancelled.");
|
||||
return;
|
||||
}
|
||||
} else if (!flags.yes && config.nonInteractive) {
|
||||
throw new BailianError(
|
||||
"Pass --yes to confirm scaling in non-interactive mode.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await scaleDeployment(config, deployedModel!, body);
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (config.quiet) {
|
||||
emitBare(deployedModel!);
|
||||
} else if (format === "text") {
|
||||
const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : "";
|
||||
emitBare(`Scaled ${deployedModel}${cap}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
updateDeployment,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
/**
|
||||
* `bl deploy update` — update deployment rate limits.
|
||||
*
|
||||
* PUT /api/v1/deployments/{deployed_model}
|
||||
* Body: at least one of `rpm_limit` (requests/min) or `tpm_limit` (tokens/min).
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Update a deployment's rate limits (rpm_limit / tpm_limit)",
|
||||
usageArgs: "--deployed-model <id> [--rpm-limit <n>] [--tpm-limit <n>] [--yes]",
|
||||
options: [
|
||||
{
|
||||
flag: "--deployed-model <id>",
|
||||
description: "Deployed model identifier (required)",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
flag: "--rpm-limit <n>",
|
||||
description: "Requests per minute",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--tpm-limit <n>",
|
||||
description: "Tokens per minute",
|
||||
type: "number",
|
||||
},
|
||||
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
|
||||
],
|
||||
exampleArgs: [
|
||||
"--deployed-model dep-... --rpm-limit 1000",
|
||||
"--deployed-model dep-... --rpm-limit 1000 --tpm-limit 200000 --yes",
|
||||
],
|
||||
notes: ["At least one of --rpm-limit / --tpm-limit must be provided."],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const deployedModel = flags.deployedModel as string | undefined;
|
||||
if (!deployedModel)
|
||||
failIfMissing("deployed-model", "--deployed-model <id> [--rpm-limit <n>] [--tpm-limit <n>]");
|
||||
|
||||
const rpmLimit = flags.rpmLimit !== undefined ? (flags.rpmLimit as number) : undefined;
|
||||
const tpmLimit = flags.tpmLimit !== undefined ? (flags.tpmLimit as number) : undefined;
|
||||
|
||||
if (rpmLimit === undefined && tpmLimit === undefined) {
|
||||
throw new BailianError("Provide at least one of --rpm-limit / --tpm-limit.", ExitCode.USAGE);
|
||||
}
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
const body: Record<string, unknown> = {};
|
||||
if (rpmLimit !== undefined) body.rpm_limit = rpmLimit;
|
||||
if (tpmLimit !== undefined) body.tpm_limit = tpmLimit;
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "deploy.update", deployed_model: deployedModel, body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flags.yes && !config.nonInteractive && !config.quiet) {
|
||||
const parts: string[] = [];
|
||||
if (rpmLimit !== undefined) parts.push(`rpm_limit=${rpmLimit}`);
|
||||
if (tpmLimit !== undefined) parts.push(`tpm_limit=${tpmLimit}`);
|
||||
process.stderr.write(`Update rate limits for ${deployedModel} (${parts.join(", ")})?\n`);
|
||||
const ok = await promptConfirm({ message: "Proceed?", initialValue: false });
|
||||
if (!ok) {
|
||||
emitBare("Cancelled.");
|
||||
return;
|
||||
}
|
||||
} else if (!flags.yes && config.nonInteractive) {
|
||||
throw new BailianError(
|
||||
"Pass --yes to confirm rate-limit update in non-interactive mode.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await updateDeployment(config, deployedModel!, body);
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (config.quiet) {
|
||||
emitBare(deployedModel!);
|
||||
} else if (format === "text") {
|
||||
const parts: string[] = [];
|
||||
if (deployment?.rpm_limit !== undefined) parts.push(`rpm_limit=${deployment.rpm_limit}`);
|
||||
if (deployment?.tpm_limit !== undefined) parts.push(`tpm_limit=${deployment.tpm_limit}`);
|
||||
const summary = parts.length ? ` (${parts.join(", ")})` : "";
|
||||
emitBare(`Updated ${deployedModel}${summary}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
+10
-11
@@ -6,13 +6,12 @@ import {
|
||||
type GlobalFlags,
|
||||
uploadFile,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
name: "file upload",
|
||||
description: "Upload a local file to DashScope temporary storage (48h)",
|
||||
usage: "bl file upload --file <path> --model <model>",
|
||||
usageArgs: "--file <path> --model <model>",
|
||||
options: [
|
||||
{
|
||||
flag: "--file <path>",
|
||||
@@ -25,21 +24,21 @@ export default defineCommand({
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"bl file upload --file photo.jpg --model qwen3-vl-plus",
|
||||
"bl file upload --file video.mp4 --model wan2.1-t2v-plus",
|
||||
"bl file upload --file audio.wav --model qwen3-asr-flash",
|
||||
"bl file upload --file cat.png --model qwen-image-2.0",
|
||||
exampleArgs: [
|
||||
"--file photo.jpg --model qwen3-vl-plus",
|
||||
"--file video.mp4 --model wan2.1-t2v-plus",
|
||||
"--file audio.wav --model qwen3-asr-flash",
|
||||
"--file cat.png --model qwen-image-2.0",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const filePath = flags.file as string | undefined;
|
||||
if (!filePath) {
|
||||
failIfMissing("file", "bl file upload --file <path> --model <model>");
|
||||
failIfMissing("file", cmdUsage(config, "--file <path> --model <model>"));
|
||||
}
|
||||
|
||||
const model = flags.model as string | undefined;
|
||||
if (!model) {
|
||||
failIfMissing("model", "bl file upload --file <path> --model <model>");
|
||||
failIfMissing("model", cmdUsage(config, "--file <path> --model <model>"));
|
||||
}
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
cancelFineTune,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Cancel a running fine-tune job",
|
||||
usageArgs: "--job-id <id> [--yes]",
|
||||
options: [
|
||||
{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true },
|
||||
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
|
||||
],
|
||||
exampleArgs: ["bl finetune cancel --job-id ft-xxx", "bl finetune cancel --job-id ft-xxx --yes"],
|
||||
notes: [
|
||||
"Only PENDING / RUNNING jobs can be cancelled. Completed / failed / already-",
|
||||
"cancelled jobs return a server-side error (passed through verbatim).",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const jobId = flags.jobId as string | undefined;
|
||||
if (!jobId) failIfMissing("job-id", "bl finetune cancel --job-id <id>");
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "finetune.cancel", job_id: jobId }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flags.yes && !config.nonInteractive && !config.quiet) {
|
||||
process.stderr.write(`Cancel fine-tune job ${jobId}?\n`);
|
||||
const ok = await promptConfirm({ message: "Proceed?", initialValue: false });
|
||||
if (!ok) {
|
||||
emitBare("Cancelled.");
|
||||
return;
|
||||
}
|
||||
} else if (!flags.yes && config.nonInteractive) {
|
||||
throw new BailianError(
|
||||
"Pass --yes to confirm cancellation in non-interactive mode.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await cancelFineTune(config, jobId!);
|
||||
const job = response.output ?? response.data;
|
||||
|
||||
if (config.quiet) {
|
||||
emitBare(jobId!);
|
||||
} else if (format === "text") {
|
||||
const status = job?.status ? ` (status=${job.status})` : "";
|
||||
emitBare(`Cancelled ${jobId}${status}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
fetchModelList,
|
||||
fetchModelCapability,
|
||||
listSupportedTrainingTypes,
|
||||
modelSupportsTrainingType,
|
||||
isTrainingTypeCli,
|
||||
trainingTypeMethodVariant,
|
||||
TRAINING_TYPES_CLI,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type ModelCapability,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Page through every foundation-model page (listFoundationModels, public — no
|
||||
* console login needed). Returns raw records so capability fields
|
||||
* (`supports` / `trainingTypes`) are preserved for filtering.
|
||||
*/
|
||||
async function fetchAllFoundationModels(config: Config): Promise<ModelCapability[]> {
|
||||
const first = await fetchModelList(config, "", { pageNo: 1, pageSize: PAGE_SIZE });
|
||||
const all = [...first.models];
|
||||
const totalPages = Math.ceil(first.total / PAGE_SIZE);
|
||||
for (let pageNo = 2; pageNo <= totalPages; pageNo++) {
|
||||
const result = await fetchModelList(config, "", { pageNo, pageSize: PAGE_SIZE });
|
||||
all.push(...result.models);
|
||||
}
|
||||
return all as ModelCapability[];
|
||||
}
|
||||
|
||||
const VARIANT_LABEL: Record<string, string> = {
|
||||
full: "full-parameter",
|
||||
lora: "LoRA",
|
||||
};
|
||||
|
||||
function describeTrainingType(value: string): string {
|
||||
if (!isTrainingTypeCli(value)) return value;
|
||||
const { method, variant } = trainingTypeMethodVariant(value);
|
||||
return `${VARIANT_LABEL[variant] ?? variant} ${method.toUpperCase()}`;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description:
|
||||
"Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it)",
|
||||
usageArgs: "--model <m> | --training-type <t>",
|
||||
options: [
|
||||
{
|
||||
flag: "--model <m>",
|
||||
description: "List training types supported by this base model.",
|
||||
},
|
||||
{
|
||||
flag: "--training-type <t>",
|
||||
description: `List models supporting this training type: ${TRAINING_TYPES_CLI.join(" | ")}.`,
|
||||
},
|
||||
],
|
||||
exampleArgs: [
|
||||
"--model qwen3-8b",
|
||||
"--training-type sft-lora",
|
||||
"--training-type cpt --output json",
|
||||
"--training-type sft --quiet",
|
||||
],
|
||||
notes: [
|
||||
"Exactly one of --model / --training-type is required.",
|
||||
"Training-type values use the `<method>` / `<method>-lora` convention:",
|
||||
"sft | sft-lora | dpo | dpo-lora | cpt. (cpt has no -lora variant server-side.)",
|
||||
"Queries listFoundationModels, a public API — no console login needed.",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const model = (flags.model as string | undefined) || undefined;
|
||||
const trainingType = (flags.trainingType as string | undefined) || undefined;
|
||||
|
||||
if (model && trainingType) {
|
||||
throw new Error("--model and --training-type are mutually exclusive; pass one.");
|
||||
}
|
||||
if (!model && !trainingType) {
|
||||
failIfMissing("model or training-type", "--model <m> | --training-type <t>");
|
||||
}
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
action: "finetune.capability",
|
||||
model,
|
||||
training_type: trainingType,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Direction 1: by model → which training types it supports.
|
||||
if (model) {
|
||||
const capability = await fetchModelCapability(config, model);
|
||||
if (!capability) {
|
||||
emitBare(`No foundation model found matching "${model}".`);
|
||||
return;
|
||||
}
|
||||
const supported = listSupportedTrainingTypes(capability);
|
||||
if (config.quiet) {
|
||||
for (const value of supported) emitBare(value);
|
||||
return;
|
||||
}
|
||||
if (format !== "text") {
|
||||
emitResult(
|
||||
{
|
||||
model: capability.model ?? model,
|
||||
supported,
|
||||
supports: capability.supports,
|
||||
trainingTypes: capability.trainingTypes,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
emitBare(`${capability.model ?? model}`);
|
||||
emitBare(supported.length ? "Supported training types:" : "No supported training types.");
|
||||
for (const value of supported) {
|
||||
emitBare(` ${value.padEnd(10)} ${describeTrainingType(value)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Direction 2: by training type → which models support it.
|
||||
if (!isTrainingTypeCli(trainingType!)) {
|
||||
throw new Error(
|
||||
`--training-type "${trainingType}" is not supported. Valid: ${TRAINING_TYPES_CLI.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const { method, variant } = trainingTypeMethodVariant(
|
||||
trainingType as Parameters<typeof trainingTypeMethodVariant>[0],
|
||||
);
|
||||
const all = await fetchAllFoundationModels(config);
|
||||
const matched = all
|
||||
.filter((record) =>
|
||||
modelSupportsTrainingType(
|
||||
record,
|
||||
trainingType as Parameters<typeof modelSupportsTrainingType>[1],
|
||||
),
|
||||
)
|
||||
.map((record) => ({
|
||||
model: record.model as string,
|
||||
name: (record.name as string | undefined) ?? (record.model as string),
|
||||
}))
|
||||
.filter((entry) => Boolean(entry.model))
|
||||
.sort((left, right) => left.model.localeCompare(right.model));
|
||||
|
||||
if (config.quiet) {
|
||||
for (const entry of matched) emitBare(entry.model);
|
||||
return;
|
||||
}
|
||||
if (format !== "text") {
|
||||
emitResult(
|
||||
{
|
||||
training_type: trainingType,
|
||||
method,
|
||||
variant,
|
||||
count: matched.length,
|
||||
models: matched,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
emitBare(`Models supporting ${trainingType} (${method} / ${variant}): ${matched.length}`);
|
||||
for (const entry of matched) emitBare(` ${entry.model}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
listCheckpoints,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { formatTable } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "List checkpoints produced by a fine-tune job",
|
||||
usageArgs: "--job-id <id>",
|
||||
options: [{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true }],
|
||||
exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"],
|
||||
notes: [
|
||||
"Use the returned `checkpoint` value with `bl finetune export` to publish",
|
||||
"a deployable model.",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const jobId = flags.jobId as string | undefined;
|
||||
if (!jobId) failIfMissing("job-id", "bl finetune checkpoints --job-id <id>");
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "finetune.checkpoints", job_id: jobId }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await listCheckpoints(config, jobId!);
|
||||
const payload = response.output ?? response.data;
|
||||
const ckpts = Array.isArray(payload) ? payload : (payload?.checkpoints ?? []);
|
||||
const total = Array.isArray(payload) ? payload.length : (payload?.total ?? ckpts.length);
|
||||
|
||||
const items = ckpts.map((item) => ({
|
||||
checkpoint: item.checkpoint ?? item.checkpoint_id ?? "",
|
||||
step: item.step !== undefined ? String(item.step) : "",
|
||||
status: item.status ?? "",
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
if (items.length === 0) {
|
||||
emitBare("No checkpoints found.");
|
||||
return;
|
||||
}
|
||||
const headers = ["CHECKPOINT", "STEP", "STATUS"];
|
||||
const rows = items.map((i) => [i.checkpoint, i.step, i.status]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
emitBare(`\nTotal: ${total}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,532 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
createFineTune,
|
||||
getDataset,
|
||||
uploadDataset,
|
||||
validateDataset,
|
||||
fetchModelCapability,
|
||||
listSupportedTrainingTypes,
|
||||
preflightBatchSizeGate,
|
||||
isTrainingTypeCli,
|
||||
toServerTrainingType,
|
||||
TRAINING_TYPES_CLI,
|
||||
DEFAULT_TRAINING_TYPE,
|
||||
formatIssue,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type CreateFineTuneRequest,
|
||||
type FineTuneHyperParameters,
|
||||
type DatasetFile,
|
||||
type DatasetSchema,
|
||||
} from "bailian-cli-core";
|
||||
import { existsSync, statSync } from "fs";
|
||||
import { basename } from "path";
|
||||
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
/**
|
||||
* A `--datasets` / `--validations` token is treated as a local file to upload
|
||||
* when it resolves to an existing file on disk; otherwise it is forwarded
|
||||
* verbatim as a previously-uploaded file-id (the `file-xxx` shape returned by
|
||||
* `bl dataset upload`). This lets users skip the manual upload step:
|
||||
* `--datasets ./train.jsonl` uploads then trains in one shot.
|
||||
*/
|
||||
function isLocalPath(token: string): boolean {
|
||||
return existsSync(token) && statSync(token).isFile();
|
||||
}
|
||||
|
||||
interface ResolvedDataset {
|
||||
/**
|
||||
* Tokens in input order. Local paths are kept as-is here (a placeholder
|
||||
* until `uploadResolvedLocal` swaps them for real file-ids); bare file-ids
|
||||
* pass through untouched. In dry-run the paths stay (the previewed body
|
||||
* reflects exactly what the user typed).
|
||||
*/
|
||||
fileIds: string[];
|
||||
/** Local paths in input order, for the deferred upload step. */
|
||||
localPaths: string[];
|
||||
/** In-hand size for the first local token, if known (local statSync). */
|
||||
firstSize?: number;
|
||||
/**
|
||||
* Total training-sample count across local tokens, when known. Sourced from
|
||||
* `validateDataset`'s `stats.totalRecords` (summed per token). Undefined when
|
||||
* any token is a bare file-id (no local file to count) or in dry-run — the
|
||||
* pre-submit batch-size gate only fires when this is known, so file-id flows
|
||||
* fall through to the platform rather than risk a false positive.
|
||||
*/
|
||||
recordCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a comma-separated `--datasets` / `--validations` value WITHOUT
|
||||
* uploading: bare file-ids pass through; local paths are validated through the
|
||||
* same pipeline as `bl dataset upload` (so structural errors surface here),
|
||||
* their sample count and size are captured for the pre-submit gate, and the
|
||||
* path itself is recorded in `localPaths` for a later, deferred upload.
|
||||
*
|
||||
* Splitting analysis from upload lets the batch-size gate fire before any
|
||||
* network call — a doomed job (too few samples) is rejected without burning an
|
||||
* upload, and is offline-testable. In dry-run mode local paths are not
|
||||
* validated (the preview never touches the network or the disk beyond stat).
|
||||
*/
|
||||
async function analyzeDatasetTokens(
|
||||
config: Config,
|
||||
raw: string,
|
||||
label: string,
|
||||
schema?: DatasetSchema,
|
||||
): Promise<ResolvedDataset> {
|
||||
const tokens = raw
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
if (tokens.length === 0) {
|
||||
throw new BailianError(`--${label} must contain at least one entry.`, ExitCode.USAGE);
|
||||
}
|
||||
|
||||
const fileIds: string[] = [];
|
||||
const localPaths: string[] = [];
|
||||
let firstSize: number | undefined;
|
||||
let recordCount: number | undefined;
|
||||
// A file-id token has no local file to count, so the total sample count is
|
||||
// only knowable when every token is a local path. Once any file-id is seen,
|
||||
// flip to unknown and stop accumulating to avoid an undercount that could
|
||||
// trip the batch-size gate falsely.
|
||||
let recordCountKnown = true;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!isLocalPath(token)) {
|
||||
fileIds.push(token);
|
||||
recordCountKnown = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
fileIds.push(token);
|
||||
localPaths.push(token);
|
||||
|
||||
if (config.dryRun) continue;
|
||||
|
||||
// Local path → validate (same checks as `bl 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 });
|
||||
if (!result.valid) {
|
||||
const lines = [
|
||||
`Dataset validation failed for ${token}`,
|
||||
...result.errors.slice(0, 10).map(formatIssue),
|
||||
];
|
||||
if (result.errors.length > 10) {
|
||||
lines.push(` … and ${result.errors.length - 10} more error(s).`);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
"Hint: re-run `bl dataset validate --file <path>` for the full report,",
|
||||
" or upload manually with `bl dataset upload --no-validate` and",
|
||||
" pass the resulting file-id here.",
|
||||
);
|
||||
throw new BailianError(lines.join("\n"), ExitCode.GENERAL);
|
||||
}
|
||||
if (result.warnings.length > 0 && !config.quiet) {
|
||||
process.stderr.write(
|
||||
`Dataset validation passed with ${result.warnings.length} warning(s) for ${token}:\n`,
|
||||
);
|
||||
for (const warning of result.warnings.slice(0, 5)) {
|
||||
process.stderr.write(`${formatIssue(warning)}\n`);
|
||||
}
|
||||
if (result.warnings.length > 5) {
|
||||
process.stderr.write(` … and ${result.warnings.length - 5} more.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate the sample count so the caller can pre-flight the batch-size
|
||||
// gate before submitting. `totalRecords` is set by the jsonl validator as
|
||||
// (non-blank lines); undefined stats fall back to "unknown" (no gate).
|
||||
const tokenRecords = result.stats.totalRecords;
|
||||
if (typeof tokenRecords === "number") {
|
||||
recordCount = (recordCount ?? 0) + tokenRecords;
|
||||
}
|
||||
if (firstSize === undefined) firstSize = statSync(token).size;
|
||||
}
|
||||
|
||||
return {
|
||||
fileIds,
|
||||
localPaths,
|
||||
firstSize,
|
||||
recordCount: recordCountKnown ? recordCount : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload each local path recorded in `resolved.localPaths`, swapping the
|
||||
* placeholder path entries in `resolved.fileIds` for the returned file-ids.
|
||||
* Returns the uploaded file records (for the confirmation panel). No-op in
|
||||
* dry-run. Validation already happened in `analyzeDatasetTokens`, so this is
|
||||
* pure upload.
|
||||
*/
|
||||
async function uploadResolvedLocal(
|
||||
config: Config,
|
||||
resolved: ResolvedDataset,
|
||||
purpose: string,
|
||||
label: string,
|
||||
): Promise<DatasetFile[]> {
|
||||
const uploaded: DatasetFile[] = [];
|
||||
for (const [index, token] of resolved.fileIds.entries()) {
|
||||
if (!isLocalPath(token)) continue;
|
||||
const file: DatasetFile = await uploadDataset(config, { filePath: token, purpose });
|
||||
if (!file.file_id) {
|
||||
throw new BailianError(
|
||||
`Upload of ${token} succeeded but no file_id was returned.`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
uploaded.push(file);
|
||||
resolved.fileIds[index] = file.file_id;
|
||||
if (!config.quiet) {
|
||||
process.stderr.write(
|
||||
`Uploaded ${basename(token)} → ${file.file_id} (auto from --${label})\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Create a fine-tune job (sft | sft-lora | dpo | dpo-lora | cpt)",
|
||||
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>] [--yes]",
|
||||
options: [
|
||||
{
|
||||
flag: "--model <model>",
|
||||
description: "Base model to fine-tune (e.g. qwen3-8b, qwen3-14b)",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
flag: "--datasets <ids|paths>",
|
||||
description:
|
||||
"Comma-separated dataset file IDs or local .jsonl paths. Local paths are uploaded (validated) first, then their file-ids are used.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
flag: "--validations <ids|paths>",
|
||||
description:
|
||||
"Comma-separated validation dataset file IDs or local .jsonl paths (auto-uploaded like --datasets).",
|
||||
},
|
||||
{
|
||||
flag: "--model-name <name>",
|
||||
description: "Output model name (after training)",
|
||||
},
|
||||
{
|
||||
flag: "--suffix <text>",
|
||||
description: "Output suffix appended by the platform (finetuned_output_suffix)",
|
||||
},
|
||||
{
|
||||
flag: "--training-type <t>",
|
||||
description: `Training type: ${TRAINING_TYPES_CLI.join(" | ")} (default: ${DEFAULT_TRAINING_TYPE}). Mapping to the server happens at the interface boundary (e.g. sft-lora -> efficient_sft, dpo -> dpo_full).`,
|
||||
},
|
||||
{
|
||||
flag: "--n-epochs <n>",
|
||||
description: "Number of epochs (default: 3)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--batch-size <n>",
|
||||
description:
|
||||
"Per-device batch size (clamped to [8, 1024]). Auto-set to 8 for small datasets (<100KB)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--learning-rate <str>",
|
||||
description: 'Learning rate as a string to preserve precision (e.g. "1.6e-5")',
|
||||
},
|
||||
{
|
||||
flag: "--max-length <n>",
|
||||
description: "Max sequence length",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--yes",
|
||||
description: "Skip the confirmation prompt",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
exampleArgs: [
|
||||
"--model qwen3-8b --datasets file-xxx",
|
||||
"--model qwen3-8b --datasets ./train.jsonl",
|
||||
"--model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl",
|
||||
"--model qwen3-8b --datasets file-aaa,./extra.jsonl",
|
||||
"--model qwen3-8b --datasets ./train.jsonl --training-type sft",
|
||||
'bl finetune create --model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4',
|
||||
"--model qwen3-8b --datasets file-xxx --yes --output json",
|
||||
],
|
||||
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.",
|
||||
"--datasets / --validations accept either file-ids (from `bl 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(config: Config, flags: GlobalFlags) {
|
||||
const model = flags.model as string | undefined;
|
||||
if (!model) failIfMissing("model", "bl finetune create --model <model>");
|
||||
|
||||
const datasetsRaw = flags.datasets as string | undefined;
|
||||
if (!datasetsRaw) failIfMissing("datasets", "bl finetune create --datasets <ids|paths>");
|
||||
|
||||
// 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}).`,
|
||||
);
|
||||
}
|
||||
// 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(config, datasetsRaw!, "datasets", datasetSchema);
|
||||
const trainingFileIds = training.fileIds;
|
||||
|
||||
const validationsRaw = flags.validations as string | undefined;
|
||||
const validation = validationsRaw
|
||||
? await analyzeDatasetTokens(config, validationsRaw, "validations", datasetSchema)
|
||||
: undefined;
|
||||
const validationFileIds = validation?.fileIds;
|
||||
|
||||
const modelName = flags.modelName as string | undefined;
|
||||
const suffix = flags.suffix as string | undefined;
|
||||
|
||||
// Hyper-parameters: inject n_epochs=3 default unless overridden.
|
||||
const hp: FineTuneHyperParameters = {};
|
||||
hp.n_epochs = flags.nEpochs !== undefined ? (flags.nEpochs as number) : 3;
|
||||
if (flags.learningRate !== undefined) hp.learning_rate = flags.learningRate as string;
|
||||
if (flags.maxLength !== undefined) hp.max_length = flags.maxLength as number;
|
||||
|
||||
// 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 confirmation panel below would show 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 as number;
|
||||
let batchSize = requested;
|
||||
if (batchSize < 8) batchSize = 8;
|
||||
if (batchSize > 1024) batchSize = 1024;
|
||||
if (batchSize !== requested && !config.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.
|
||||
let batchSizeAutoAdjusted = false;
|
||||
if (hp.batch_size === undefined && !config.dryRun) {
|
||||
let sizeBytes = training.firstSize ?? 0;
|
||||
if (sizeBytes === 0) {
|
||||
try {
|
||||
const fileInfo = await getDataset(config, 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;
|
||||
batchSizeAutoAdjusted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 `bl dataset upload`/`validate`.
|
||||
// ExitCode.GENERAL matches the existing validation-failed exit code.
|
||||
if (!config.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 (!config.dryRun) {
|
||||
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
|
||||
try {
|
||||
capability = await fetchModelCapability(config, model!);
|
||||
} catch (error) {
|
||||
if (!config.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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-interactive guard — moved BEFORE upload. In CI / scripted mode the
|
||||
// user must opt in via --yes; otherwise we must not silently consume quota
|
||||
// OR upload any file. (Local validation is still allowed to run.)
|
||||
if (!config.dryRun && !flags.yes && config.nonInteractive) {
|
||||
throw new BailianError(
|
||||
"Pass --yes to confirm fine-tune creation in non-interactive mode.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
// Upload local paths now that pre-flight (validation, batch-size gate,
|
||||
// capability check, non-interactive guard) has cleared them. This swaps
|
||||
// the placeholder path entries in `training.fileIds` / `validation?.fileIds`
|
||||
// for real file-ids, so the body and confirmation panel below see ids.
|
||||
let uploadedTraining: DatasetFile[] = [];
|
||||
let uploadedValidation: DatasetFile[] = [];
|
||||
if (!config.dryRun) {
|
||||
uploadedTraining = await uploadResolvedLocal(config, training, "fine-tune", "datasets");
|
||||
if (validation) {
|
||||
uploadedValidation = await uploadResolvedLocal(
|
||||
config,
|
||||
validation,
|
||||
"fine-tune",
|
||||
"validations",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const body: CreateFineTuneRequest = {
|
||||
model: 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(config.output);
|
||||
|
||||
if (config.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;
|
||||
}
|
||||
|
||||
// Confirmation panel — destructive in the sense that it consumes quota.
|
||||
// (Capability check and non-interactive guard already ran pre-upload.)
|
||||
if (!flags.yes && !config.nonInteractive && !config.quiet) {
|
||||
process.stderr.write("Create fine-tune job:\n");
|
||||
process.stderr.write(` Model: ${body.model}\n`);
|
||||
process.stderr.write(` Training type: ${trainingType}\n`);
|
||||
process.stderr.write(` Training files: ${trainingFileIds.join(", ")}\n`);
|
||||
if (validationFileIds) {
|
||||
process.stderr.write(` Validation: ${validationFileIds.join(", ")}\n`);
|
||||
}
|
||||
for (const file of uploadedTraining) {
|
||||
process.stderr.write(` Uploaded: ${file.name} → ${file.file_id}\n`);
|
||||
}
|
||||
for (const file of uploadedValidation) {
|
||||
process.stderr.write(` Uploaded: ${file.name} → ${file.file_id} (validation)\n`);
|
||||
}
|
||||
process.stderr.write(` n_epochs: ${hp.n_epochs}\n`);
|
||||
if (hp.batch_size !== undefined) {
|
||||
const hint = batchSizeAutoAdjusted ? " (auto: small dataset)" : "";
|
||||
process.stderr.write(` batch_size: ${hp.batch_size}${hint}\n`);
|
||||
}
|
||||
if (hp.learning_rate !== undefined)
|
||||
process.stderr.write(` learning_rate: ${hp.learning_rate}\n`);
|
||||
if (hp.max_length !== undefined) process.stderr.write(` max_length: ${hp.max_length}\n`);
|
||||
if (modelName) process.stderr.write(` model_name: ${modelName}\n`);
|
||||
if (suffix) process.stderr.write(` suffix: ${suffix}\n`);
|
||||
const ok = await promptConfirm({ message: "Submit this job?", initialValue: false });
|
||||
if (!ok) {
|
||||
emitBare("Cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await createFineTune(config, body);
|
||||
const job = response.output ?? response.data;
|
||||
|
||||
if (config.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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
deleteFineTune,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Delete a fine-tune job record",
|
||||
usageArgs: "--job-id <id> [--yes]",
|
||||
options: [
|
||||
{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true },
|
||||
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
|
||||
],
|
||||
exampleArgs: ["bl finetune delete --job-id ft-xxx", "bl finetune delete --job-id ft-xxx --yes"],
|
||||
notes: [
|
||||
"Cancel a RUNNING job first via `bl finetune cancel` — the platform refuses",
|
||||
"to delete jobs that are still in flight.",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const jobId = flags.jobId as string | undefined;
|
||||
if (!jobId) failIfMissing("job-id", "bl finetune delete --job-id <id>");
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "finetune.delete", job_id: jobId }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flags.yes && !config.nonInteractive && !config.quiet) {
|
||||
process.stderr.write(`Permanently delete fine-tune job ${jobId}?\n`);
|
||||
const ok = await promptConfirm({ message: "Proceed?", initialValue: false });
|
||||
if (!ok) {
|
||||
emitBare("Cancelled.");
|
||||
return;
|
||||
}
|
||||
} else if (!flags.yes && config.nonInteractive) {
|
||||
throw new BailianError(
|
||||
"Pass --yes to confirm deletion in non-interactive mode.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await deleteFineTune(config, jobId!);
|
||||
|
||||
if (config.quiet) {
|
||||
emitBare(jobId!);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Deleted ${jobId}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
exportCheckpoint,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Publish a checkpoint as a deployable model",
|
||||
usageArgs: "--job-id <id> --checkpoint <name> --model-name <name>",
|
||||
options: [
|
||||
{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true },
|
||||
{
|
||||
flag: "--checkpoint <name>",
|
||||
description: "Checkpoint identifier from `bl finetune checkpoints`",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
flag: "--model-name <name>",
|
||||
description: "Deployable model name (required)",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
exampleArgs: ["bl finetune export --job-id ft-xxx --checkpoint ckpt-3 --model-name my-qwen-sft"],
|
||||
notes: [
|
||||
"Required before `bl 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.",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const jobId = flags.jobId as string | undefined;
|
||||
if (!jobId) failIfMissing("job-id", "bl finetune export --job-id <id>");
|
||||
const checkpoint = flags.checkpoint as string | undefined;
|
||||
if (!checkpoint) failIfMissing("checkpoint", "bl finetune export --checkpoint <name>");
|
||||
const modelName = flags.modelName as string | undefined;
|
||||
if (!modelName) failIfMissing("model-name", "bl finetune export --model-name <name>");
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
action: "finetune.export",
|
||||
job_id: jobId,
|
||||
checkpoint,
|
||||
model_name: modelName,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await exportCheckpoint(config, jobId!, checkpoint!, modelName!);
|
||||
const payload = response.output ?? response.data;
|
||||
const exported = payload?.model_name ?? modelName;
|
||||
|
||||
if (config.quiet) {
|
||||
emitBare(exported!);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Exported ${jobId} / ${checkpoint} → model_name=${exported}`);
|
||||
emitBare("Next: bl deploy create --model " + exported + " --name <display-name>");
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getFineTune,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Get details of a single fine-tune job",
|
||||
usageArgs: "--job-id <id>",
|
||||
options: [{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true }],
|
||||
exampleArgs: ["bl finetune get --job-id ft-xxx", "bl finetune get --job-id ft-xxx --output json"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const jobId = flags.jobId as string | undefined;
|
||||
if (!jobId) failIfMissing("job-id", "bl finetune get --job-id <id>");
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "finetune.get", job_id: jobId }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getFineTune(config, jobId!);
|
||||
const job = response.output ?? response.data;
|
||||
|
||||
if (!job) {
|
||||
emitBare(`No data returned for ${jobId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hp = job.hyper_parameters;
|
||||
const hyperParts: string[] = [];
|
||||
if (hp?.n_epochs !== undefined) hyperParts.push(`n_epochs=${hp.n_epochs}`);
|
||||
if (hp?.batch_size !== undefined) hyperParts.push(`batch_size=${hp.batch_size}`);
|
||||
if (hp?.learning_rate !== undefined) hyperParts.push(`learning_rate=${hp.learning_rate}`);
|
||||
if (hp?.max_length !== undefined) hyperParts.push(`max_length=${hp.max_length}`);
|
||||
|
||||
const item = {
|
||||
job_id: job.job_id ?? jobId,
|
||||
base_model: job.model ?? "",
|
||||
status: job.status ?? "",
|
||||
training_type: job.training_type ?? "",
|
||||
training_files: job.training_file_ids ?? [],
|
||||
validation_files: job.validation_file_ids ?? [],
|
||||
hyper_params: hyperParts.length ? hyperParts.join(" · ") : "",
|
||||
output_model: job.finetuned_output ?? "",
|
||||
model_name: job.model_name ?? "",
|
||||
created_at: job.create_time ?? job.gmt_create ?? "",
|
||||
updated_at: job.end_time ?? job.gmt_modified ?? "",
|
||||
};
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
emitBare(`job_id: ${item.job_id}`);
|
||||
if (item.base_model) emitBare(`base_model: ${item.base_model}`);
|
||||
if (item.status) emitBare(`status: ${item.status}`);
|
||||
if (item.training_type) emitBare(`training_type: ${item.training_type}`);
|
||||
if (item.training_files.length) emitBare(`training_files: ${item.training_files.join(", ")}`);
|
||||
if (item.validation_files.length)
|
||||
emitBare(`validation_files: ${item.validation_files.join(", ")}`);
|
||||
if (item.hyper_params) emitBare(`hyper_params: ${item.hyper_params}`);
|
||||
if (item.output_model)
|
||||
emitBare(`output_model: ${item.output_model} (→ bl deploy create --model)`);
|
||||
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
|
||||
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
|
||||
if (item.updated_at) emitBare(`updated_at: ${item.updated_at}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
listFineTunes,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { formatTable } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "List fine-tune jobs",
|
||||
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>]",
|
||||
options: [
|
||||
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
|
||||
{
|
||||
flag: "--page-size <n>",
|
||||
description: "Results per page (default: 10, max 100)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--status <s>",
|
||||
description: "Filter by status (PENDING / RUNNING / SUCCEEDED / FAILED / CANCELED)",
|
||||
},
|
||||
],
|
||||
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const format = detectOutputFormat(config.output);
|
||||
const pageNo = flags.page !== undefined ? (flags.page as number) : undefined;
|
||||
const pageSize = flags.pageSize !== undefined ? (flags.pageSize as number) : undefined;
|
||||
const status = (flags.status as string | undefined) || undefined;
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ action: "finetune.list", page: pageNo, page_size: pageSize, status }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await listFineTunes(config, { pageNo, pageSize, status });
|
||||
const payload = response.output ?? response.data;
|
||||
const jobs = payload?.jobs ?? [];
|
||||
const total = payload?.total;
|
||||
|
||||
const items = jobs.map((item) => ({
|
||||
job_id: item.job_id ?? "",
|
||||
base_model: item.model ?? "",
|
||||
status: item.status ?? "",
|
||||
training_type: item.training_type ?? "",
|
||||
output_model: item.finetuned_output ?? "",
|
||||
created_at: item.create_time ?? item.gmt_create ?? "",
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
if (items.length === 0) {
|
||||
emitBare("No fine-tune jobs found.");
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
"JOB_ID",
|
||||
"BASE_MODEL",
|
||||
"STATUS",
|
||||
"TRAINING_TYPE",
|
||||
"OUTPUT_MODEL",
|
||||
"CREATED_AT",
|
||||
];
|
||||
const rows = items.map((i) => [
|
||||
i.job_id,
|
||||
i.base_model,
|
||||
i.status,
|
||||
i.training_type,
|
||||
i.output_model,
|
||||
i.created_at,
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitBare("Tip: OUTPUT_MODEL is the input for `bl deploy create --model`");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getFineTuneLogs,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type FineTuneLogEntry,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
/**
|
||||
* Render a single log entry as a single line (mirrors the flatten logic used
|
||||
* for non-search text output: prefer common fields, fall back to JSON).
|
||||
*/
|
||||
function renderEntry(entry: FineTuneLogEntry | string): string {
|
||||
if (typeof entry === "string") return entry;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const ts = (record.timestamp ?? record.time ?? record.create_time ?? "") as string;
|
||||
const level = (record.level ?? "") as string;
|
||||
const msg = (record.message ?? record.msg ?? record.log ?? "") as string;
|
||||
if (msg || ts || level) {
|
||||
return [ts, level, msg].filter(Boolean).join("\t");
|
||||
}
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring match. String entries match against themselves;
|
||||
* object entries match against their rendered form (so timestamp / level /
|
||||
* message are all searchable).
|
||||
*/
|
||||
function entryMatches(entry: FineTuneLogEntry | string, keywordLower: string): boolean {
|
||||
return renderEntry(entry).toLowerCase().includes(keywordLower);
|
||||
}
|
||||
|
||||
/**
|
||||
* Page through every log page for a job (server reports `total`), returning
|
||||
* the full ordered entry list. Used when filtering by `--search` across the
|
||||
* complete log rather than a single page.
|
||||
*/
|
||||
async function fetchAllLogs(
|
||||
config: Config,
|
||||
jobId: string,
|
||||
pageSize: number,
|
||||
): Promise<{ entries: Array<FineTuneLogEntry | string>; total: number }> {
|
||||
const entries: Array<FineTuneLogEntry | string> = [];
|
||||
let pageNo = 1;
|
||||
let total = 0;
|
||||
// Hard cap to avoid an unbounded loop if the server misreports `total`.
|
||||
const maxPages = 200;
|
||||
for (let i = 0; i < maxPages; i++) {
|
||||
const response = await getFineTuneLogs(config, jobId, { pageNo, pageSize });
|
||||
const payload = response.output ?? response.data;
|
||||
const page = payload?.logs ?? [];
|
||||
total = payload?.total ?? total;
|
||||
if (page.length === 0) break;
|
||||
entries.push(...page);
|
||||
// Stop once we've collected everything the server claims exists.
|
||||
if (total && entries.length >= total) break;
|
||||
if (page.length < pageSize) break;
|
||||
pageNo++;
|
||||
}
|
||||
return { entries, total };
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Fetch training logs for a fine-tune job",
|
||||
usageArgs: "--job-id <id> [--page <n>] [--page-size <n>] [--search <keyword>] [--tail <n>]",
|
||||
options: [
|
||||
{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true },
|
||||
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
|
||||
{
|
||||
flag: "--page-size <n>",
|
||||
description: "Lines per page (default: server-defined)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--search <keyword>",
|
||||
description:
|
||||
"Case-insensitive substring filter. When set, all log pages are fetched and filtered client-side (--page is ignored).",
|
||||
},
|
||||
{
|
||||
flag: "--tail <n>",
|
||||
description:
|
||||
"Keep only the last N entries. When set, all log pages are fetched and the trailing N are kept (--page is ignored).",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
exampleArgs: [
|
||||
"--job-id ft-xxx",
|
||||
"--job-id ft-xxx --page-size 100 --output json",
|
||||
"--job-id ft-xxx --search checkpoint",
|
||||
"--job-id ft-xxx --search error --output json",
|
||||
"--job-id ft-xxx --tail 20",
|
||||
"--job-id ft-xxx --search checkpoint --tail 5",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const jobId = flags.jobId as string | undefined;
|
||||
if (!jobId) failIfMissing("job-id", "bl finetune logs --job-id <id>");
|
||||
|
||||
const pageNo = flags.page !== undefined ? (flags.page as number) : undefined;
|
||||
const pageSize = flags.pageSize !== undefined ? (flags.pageSize as number) : undefined;
|
||||
const search = (flags.search as string | undefined) || undefined;
|
||||
const tail = flags.tail !== undefined ? (flags.tail as number) : undefined;
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
action: "finetune.logs",
|
||||
job_id: jobId,
|
||||
page: pageNo,
|
||||
page_size: pageSize,
|
||||
search,
|
||||
tail,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// --search / --tail both need the full log: fan out across every page,
|
||||
// then filter (search) and/or take the trailing N (tail) client-side.
|
||||
if (search || tail !== undefined) {
|
||||
const { entries, total } = await fetchAllLogs(config, jobId!, pageSize ?? 100);
|
||||
|
||||
// Apply --search first: narrow to the matching entries.
|
||||
let scanned = entries;
|
||||
let matched: number | undefined;
|
||||
if (search) {
|
||||
const keywordLower = search.toLowerCase();
|
||||
scanned = entries.filter((entry) => entryMatches(entry, keywordLower));
|
||||
matched = scanned.length;
|
||||
}
|
||||
|
||||
// Then apply --tail: keep the trailing N of whatever remains.
|
||||
const tailApplied =
|
||||
tail !== undefined && tail >= 0 ? Math.min(tail, scanned.length) : undefined;
|
||||
const result =
|
||||
tailApplied !== undefined ? scanned.slice(scanned.length - tailApplied) : scanned;
|
||||
|
||||
if (config.quiet || format === "text") {
|
||||
if (result.length === 0) {
|
||||
emitBare(search ? `No logs matched "${search}".` : "No logs returned.");
|
||||
return;
|
||||
}
|
||||
for (const entry of result) emitBare(renderEntry(entry));
|
||||
const parts: string[] = [`${result.length} shown`];
|
||||
if (matched !== undefined) parts.push(`matched ${matched}`);
|
||||
parts.push(`of ${entries.length}` + (total ? ` (total ${total})` : ""));
|
||||
emitBare(`\n${parts.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
emitResult(
|
||||
{
|
||||
...(matched !== undefined ? { matched } : {}),
|
||||
scanned: entries.length,
|
||||
total: total || entries.length,
|
||||
...(search ? { search } : {}),
|
||||
...(tailApplied !== undefined ? { tail: tailApplied } : {}),
|
||||
logs: result,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: single page, verbatim response.
|
||||
const response = await getFineTuneLogs(config, jobId!, { pageNo, pageSize });
|
||||
const payload = response.output ?? response.data;
|
||||
const logs = payload?.logs ?? [];
|
||||
|
||||
if (config.quiet || format === "text") {
|
||||
if (logs.length === 0) {
|
||||
emitBare("No logs returned.");
|
||||
return;
|
||||
}
|
||||
for (const entry of logs) {
|
||||
emitBare(renderEntry(entry));
|
||||
}
|
||||
if (payload?.total !== undefined) emitBare(`\nTotal: ${payload.total}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getFineTune,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "bailian-cli-runtime";
|
||||
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();
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function formatElapsed(milliseconds: number): string {
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes === 0) return `${seconds}s`;
|
||||
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.
|
||||
*/
|
||||
function sleep(milliseconds: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description:
|
||||
"Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal.",
|
||||
usageArgs: "--job-id <id> [--follow] [--interval <sec>] [--timeout <sec>]",
|
||||
options: [
|
||||
{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true },
|
||||
{
|
||||
flag: "--follow",
|
||||
description:
|
||||
"Block and poll until a terminal state (the legacy behavior). Without it, a single status probe is performed and the command returns immediately.",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
flag: "--interval <sec>",
|
||||
description: `Seconds between polls with --follow (default: ${DEFAULT_INTERVAL_SEC}, min: ${MIN_INTERVAL_SEC}). Ignored without --follow.`,
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
flag: "--timeout <sec>",
|
||||
description:
|
||||
"With --follow, stop polling after this many seconds (default: no limit). Ignored without --follow.",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
exampleArgs: [
|
||||
"--job-id ft-xxx # single probe, returns immediately",
|
||||
"--job-id ft-xxx --output json # status probe for agents",
|
||||
"--job-id ft-xxx --follow # block until terminal",
|
||||
"--job-id ft-xxx --follow --interval 5",
|
||||
"--job-id ft-xxx --follow --timeout 3600",
|
||||
],
|
||||
notes: [
|
||||
"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 --follow timeout",
|
||||
"| 3 still running (non-terminal, default mode) | 130 interrupted (Ctrl-C).",
|
||||
"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 `bl finetune logs`.",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const jobId = flags.jobId as string | undefined;
|
||||
if (!jobId) failIfMissing("job-id", "bl finetune watch --job-id <id>");
|
||||
|
||||
const follow = Boolean(flags.follow);
|
||||
const intervalSec = Math.max(
|
||||
MIN_INTERVAL_SEC,
|
||||
flags.interval !== undefined ? (flags.interval as number) : DEFAULT_INTERVAL_SEC,
|
||||
);
|
||||
const timeoutSec = flags.timeout !== undefined ? (flags.timeout as number) : undefined;
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
action: "finetune.watch",
|
||||
job_id: jobId,
|
||||
follow,
|
||||
interval: intervalSec,
|
||||
timeout: timeoutSec,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Default: non-blocking single status probe -------------------------
|
||||
if (!follow) {
|
||||
const response = await getFineTune(config, jobId!);
|
||||
const job = response.output ?? response.data;
|
||||
const status = String(job?.status ?? "").toUpperCase();
|
||||
const terminal = TERMINAL_STATUSES.has(status);
|
||||
const code = exitCodeForStatus(status);
|
||||
|
||||
if (config.quiet) {
|
||||
// Just the status word — ideal for `status=$(bl 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}`);
|
||||
}
|
||||
} else {
|
||||
// json / yaml: a compact, purpose-built status probe.
|
||||
emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format);
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
// ---- --follow: blocking poll loop (legacy behavior) -------------------
|
||||
const controller = new AbortController();
|
||||
const onSigint = () => controller.abort();
|
||||
process.on("SIGINT", onSigint);
|
||||
|
||||
try {
|
||||
let lastStatus = "";
|
||||
const startedAt = Date.now();
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const response = await getFineTune(config, jobId!, controller.signal);
|
||||
const job = response.output ?? response.data;
|
||||
const status = String(job?.status ?? "").toUpperCase();
|
||||
|
||||
if (format === "text" && !config.quiet && status !== lastStatus) {
|
||||
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
|
||||
lastStatus = status;
|
||||
}
|
||||
|
||||
if (TERMINAL_STATUSES.has(status)) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (format !== "text" || config.quiet) {
|
||||
emitResult(response, format);
|
||||
} else {
|
||||
const mark = status === "SUCCEEDED" ? "✓" : "✗";
|
||||
emitBare(`\n${mark} ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
|
||||
}
|
||||
process.exit(exitCodeForStatus(status));
|
||||
}
|
||||
|
||||
if (timeoutSec !== undefined && (Date.now() - startedAt) / 1000 >= timeoutSec) {
|
||||
if (format === "text" && !config.quiet) {
|
||||
emitBare(
|
||||
`\n⏼ ${jobId} timed out after ${formatElapsed(Date.now() - startedAt)} (last status: ${status || "UNKNOWN"})`,
|
||||
);
|
||||
}
|
||||
process.exit(EXIT_TIMEOUT);
|
||||
}
|
||||
|
||||
await sleep(intervalSec * 1000, controller.signal);
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
emitBare("\nInterrupted.");
|
||||
process.exit(EXIT_INTERRUPTED);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
process.off("SIGINT", onSigint);
|
||||
}
|
||||
},
|
||||
});
|
||||
+15
-19
@@ -18,21 +18,17 @@ import {
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
} from "bailian-cli-core";
|
||||
import { downloadFile } from "../../utils/download.ts";
|
||||
import { runConcurrent, downloadParallel, getConcurrency } from "../../utils/concurrent.ts";
|
||||
import { promptText, failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { resolveImageSize } from "../../utils/image-size.ts";
|
||||
import { downloadFile } from "bailian-cli-runtime";
|
||||
import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime";
|
||||
import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveImageSize } from "bailian-cli-runtime";
|
||||
import { join } from "path";
|
||||
import {
|
||||
BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE,
|
||||
BOOL_FLAG_WATERMARK,
|
||||
} from "../../utils/flag-descriptions.ts";
|
||||
import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
name: "image edit",
|
||||
description: "Edit an existing image with text instructions (Qwen-Image)",
|
||||
usage: "bl image edit --image <url> --prompt <text> [flags]",
|
||||
usageArgs: "--image <url> --prompt <text> [flags]",
|
||||
options: [
|
||||
{
|
||||
flag: "--image <url>",
|
||||
@@ -63,12 +59,12 @@ export default defineCommand({
|
||||
{ flag: "--out-dir <dir>", description: "Download images to directory" },
|
||||
{ flag: "--out-prefix <prefix>", description: "Filename prefix (default: edited)" },
|
||||
],
|
||||
examples: [
|
||||
'bl image edit --image ./photo.png --prompt "把背景换成海滩"',
|
||||
'bl image edit --image https://example.com/logo.png --prompt "Change color to blue" --n 3',
|
||||
'bl image edit --image ./a.png --image ./b.png --prompt "把两张图合并成一张拼图"',
|
||||
'bl image edit --image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro',
|
||||
'bl image edit --image ./photo.png --prompt "把背景换成海滩" --watermark false',
|
||||
exampleArgs: [
|
||||
'--image ./photo.png --prompt "Replace the background with a beach"',
|
||||
'--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 "Replace the background with a beach" --watermark false',
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
// Normalize --image to string array (supports both single and repeated flags)
|
||||
@@ -79,7 +75,7 @@ export default defineCommand({
|
||||
rawImages = [flags.image];
|
||||
}
|
||||
if (rawImages.length === 0) {
|
||||
failIfMissing("image", "bl image edit --image <url> --prompt <text>");
|
||||
failIfMissing("image", cmdUsage(config, "--image <url> --prompt <text>"));
|
||||
}
|
||||
|
||||
let prompt = flags.prompt as string | undefined;
|
||||
@@ -94,7 +90,7 @@ export default defineCommand({
|
||||
}
|
||||
prompt = hint;
|
||||
} else {
|
||||
failIfMissing("prompt", "bl image edit --image <url> --prompt <text>");
|
||||
failIfMissing("prompt", cmdUsage(config, "--image <url> --prompt <text>"));
|
||||
}
|
||||
}
|
||||
|
||||
+19
-23
@@ -20,16 +20,13 @@ import {
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
} from "bailian-cli-core";
|
||||
import { poll } from "../../utils/polling.ts";
|
||||
import { downloadFile } from "../../utils/download.ts";
|
||||
import { runConcurrent, downloadParallel, getConcurrency } from "../../utils/concurrent.ts";
|
||||
import { promptText, failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { resolveImageSize } from "../../utils/image-size.ts";
|
||||
import {
|
||||
BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE,
|
||||
BOOL_FLAG_WATERMARK,
|
||||
} from "../../utils/flag-descriptions.ts";
|
||||
import { poll } from "bailian-cli-runtime";
|
||||
import { downloadFile } from "bailian-cli-runtime";
|
||||
import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime";
|
||||
import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveImageSize } from "bailian-cli-runtime";
|
||||
import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime";
|
||||
|
||||
import { join } from "path";
|
||||
|
||||
@@ -41,9 +38,8 @@ function isSyncModel(model: string): boolean {
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "image generate",
|
||||
description: "Generate images (Qwen-Image / wan2.x)",
|
||||
usage: "bl image generate --prompt <text> [flags]",
|
||||
usageArgs: "--prompt <text> [flags]",
|
||||
options: [
|
||||
{ flag: "--prompt <text>", description: "Image description", required: true },
|
||||
{ flag: "--model <model>", description: "Model ID (default: qwen-image-2.0)" },
|
||||
@@ -81,16 +77,16 @@ export default defineCommand({
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
'bl image generate --prompt "一只穿太空服的猫在火星上"',
|
||||
'bl image generate --prompt "Logo design" --n 3 --out-dir ./generated/',
|
||||
'bl image generate --prompt "Mountain landscape" --size 2688*1536',
|
||||
'bl image generate --prompt "A castle" --seed 42 --prompt-extend false',
|
||||
'bl image generate --prompt "Logo" --watermark false',
|
||||
'bl image generate --prompt "An alien in the space" --watermark false',
|
||||
'bl image generate --prompt "sunset" --model wan2.6-t2i --no-wait --quiet',
|
||||
'bl image generate --prompt "Pro quality" --model qwen-image-2.0-pro',
|
||||
'bl image generate --prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel',
|
||||
exampleArgs: [
|
||||
'--prompt "A cat in a spacesuit on Mars"',
|
||||
'--prompt "Logo design" --n 3 --out-dir ./generated/',
|
||||
'--prompt "Mountain landscape" --size 2688*1536',
|
||||
'--prompt "A castle" --seed 42 --prompt-extend false',
|
||||
'--prompt "Logo" --watermark false',
|
||||
'--prompt "An alien in the space" --watermark false',
|
||||
'--prompt "sunset" --model wan2.6-t2i --no-wait --quiet',
|
||||
'--prompt "Pro quality" --model qwen-image-2.0-pro',
|
||||
'--prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel',
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
let prompt = (flags.prompt ?? (flags._positional as string[] | undefined)?.[0]) as
|
||||
@@ -108,7 +104,7 @@ export default defineCommand({
|
||||
}
|
||||
prompt = hint;
|
||||
} else {
|
||||
failIfMissing("prompt", "bl image generate --prompt <text>");
|
||||
failIfMissing("prompt", cmdUsage(config, "--prompt <text>"));
|
||||
}
|
||||
}
|
||||
|
||||
+20
-13
@@ -17,15 +17,15 @@ import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const BAILIAN_HOST = "bailian.cn-beijing.aliyuncs.com";
|
||||
|
||||
export default defineCommand({
|
||||
name: "knowledge retrieve",
|
||||
description: "Retrieve from a Bailian knowledge base",
|
||||
usage: "bl knowledge retrieve --index-id <id> --query <text> [flags]",
|
||||
skipDefaultApiKeySetup: true,
|
||||
usageArgs: "--index-id <id> --query <text> [flags]",
|
||||
options: [
|
||||
{ flag: "--index-id <id>", description: "Knowledge base index ID (required)", required: true },
|
||||
{ flag: "--query <text>", description: "Search query (required)", required: true },
|
||||
@@ -60,24 +60,31 @@ export default defineCommand({
|
||||
},
|
||||
{
|
||||
flag: "--workspace-id <id>",
|
||||
description: "Bailian workspace ID (required for AK/SK auth)",
|
||||
description: "Bailian workspace ID (only needed for deprecated AK/SK auth)",
|
||||
},
|
||||
{
|
||||
flag: "--access-key-id <key>",
|
||||
description: "Deprecated: use global --api-key instead",
|
||||
},
|
||||
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
|
||||
{
|
||||
flag: "--access-key-secret <key>",
|
||||
description: "Alibaba Cloud Access Key Secret (deprecated)",
|
||||
description: "Deprecated: use global --api-key instead",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
'bl knowledge retrieve --index-id idx_xxx --query "如何使用阿里云百炼"',
|
||||
'bl knowledge retrieve --index-id idx_xxx --query "API限流" --rerank --rerank-model qwen3-rerank-hybrid',
|
||||
notes: [
|
||||
"Authentication: pass `--api-key <key>`. AK/SK auth is deprecated and will be removed in a future version.",
|
||||
"`--workspace-id` is NOT required when using --api-key.",
|
||||
],
|
||||
exampleArgs: [
|
||||
'--index-id idx_xxx --query "How to use Alibaba Cloud Bailian"',
|
||||
'--api-key $DASHSCOPE_API_KEY --index-id idx_xxx --query "RAG retrieval" --rerank --rerank-model qwen3-rerank-hybrid',
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const indexId = flags.indexId as string;
|
||||
if (!indexId) failIfMissing("index-id", "bl knowledge retrieve --index-id <id> --query <text>");
|
||||
if (!indexId) failIfMissing("index-id", cmdUsage(config, "--index-id <id> --query <text>"));
|
||||
|
||||
const query = flags.query as string;
|
||||
if (!query) failIfMissing("query", "bl knowledge retrieve --index-id <id> --query <text>");
|
||||
if (!query) failIfMissing("query", cmdUsage(config, "--index-id <id> --query <text>"));
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
@@ -188,7 +195,7 @@ async function runWithAkSk(
|
||||
if (!workspaceId) {
|
||||
throw new BailianError(
|
||||
"Knowledge retrieve requires a workspace ID.\n" +
|
||||
"Set via: --workspace-id flag, or env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
|
||||
`Set via: --workspace-id flag, or env: BAILIAN_WORKSPACE_ID, or config: ${config.binName} config set workspace_id <id>`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
+10
-10
@@ -6,9 +6,9 @@ import {
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult } from "../../output/output.ts";
|
||||
import { ensureApiKey } from "../../utils/ensure-key.ts";
|
||||
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { ensureApiKey } from "bailian-cli-runtime";
|
||||
|
||||
function parseArgFlags(raw: string[]): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -30,9 +30,9 @@ function parseArgFlags(raw: string[]): Record<string, unknown> {
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "mcp call",
|
||||
description: "Call a tool on an MCP server (tools/call)",
|
||||
usage: "bl mcp call <server-code>.<tool> [--arg k=v ...] [--json '{...}'] [--url <url>]",
|
||||
skipDefaultApiKeySetup: true,
|
||||
usageArgs: "<server-code>.<tool> [--arg k=v ...] [--json '{...}'] [--url <url>]",
|
||||
options: [
|
||||
{
|
||||
flag: "<server-code>.<tool>",
|
||||
@@ -55,16 +55,16 @@ export default defineCommand({
|
||||
},
|
||||
{ flag: "--url <url>", description: "Override the MCP endpoint URL (for non-Bailian servers)" },
|
||||
],
|
||||
examples: [
|
||||
'bl mcp call market-cmapi00073529.SmartStockSelection --query "筛选ROE>15%的消费股"',
|
||||
'bl mcp call market-cmapi00073529.FinQuery --json \'{"q":"贵州茅台","limit":5}\'',
|
||||
"bl mcp call market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10",
|
||||
exampleArgs: [
|
||||
'market-cmapi00073529.SmartStockSelection --query "Screen consumer stocks with ROE > 15%"',
|
||||
'market-cmapi00073529.FinQuery --json \'{"q":"Guizhou Maotai","limit":5}\'',
|
||||
"market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const positional =
|
||||
((flags as Record<string, unknown>)._positional as string[] | undefined) ?? [];
|
||||
const target = positional[0];
|
||||
if (!target) failIfMissing("<server-code>.<tool>", "bl mcp call <server-code>.<tool>");
|
||||
if (!target) failIfMissing("<server-code>.<tool>", cmdUsage(config, "<server-code>.<tool>"));
|
||||
|
||||
const dot = target!.indexOf(".");
|
||||
if (dot <= 0 || dot === target!.length - 1) {
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
defineCommand,
|
||||
callConsoleGateway,
|
||||
effectiveConsoleGatewayConfig,
|
||||
resolveConsoleGatewayCredential,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "../../output/output.ts";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const MCP_LIST_API = "zeldaEasy.broadscope-bailian.mcp-server.PageList";
|
||||
|
||||
@@ -24,9 +25,9 @@ interface ServerSummary {
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "mcp list",
|
||||
description: "List MCP servers activated under your Bailian account",
|
||||
usage: "bl mcp list [flags]",
|
||||
skipDefaultApiKeySetup: true,
|
||||
usageArgs: "[flags]",
|
||||
options: [
|
||||
{ flag: "--name <text>", description: "Filter by server name (substring match)" },
|
||||
{
|
||||
@@ -35,15 +36,23 @@ export default defineCommand({
|
||||
},
|
||||
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
|
||||
{ flag: "--page-size <n>", description: "Results per page (default: 30)", type: "number" },
|
||||
{ flag: "--region <region>", description: "API region (default: cn-beijing)" },
|
||||
{ flag: "--console-region <region>", description: "Console region" },
|
||||
{
|
||||
flag: "--console-site <site>",
|
||||
description: "Console site: domestic, international",
|
||||
},
|
||||
{
|
||||
flag: "--console-switch-agent <uid>",
|
||||
description: "Switch agent UID",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
examples: ["bl mcp list", "bl mcp list --name 金融", "bl mcp list --output json"],
|
||||
exampleArgs: ["", "--name finance", "--output json"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const serverName = (flags.name as string) || "";
|
||||
const type = (flags.type as string) || "OFFICIAL";
|
||||
const pageNo = (flags.page as number) || 1;
|
||||
const pageSize = (flags.pageSize as number) || 30;
|
||||
const region = (flags.region as string) || "cn-beijing";
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
const data = {
|
||||
@@ -58,7 +67,7 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ api: MCP_LIST_API, data, region }, format);
|
||||
emitResult({ api: MCP_LIST_API, data, ...effectiveConsoleGatewayConfig(config) }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,7 +76,6 @@ export default defineCommand({
|
||||
const result = (await callConsoleGateway(config, credential.token, {
|
||||
api: MCP_LIST_API,
|
||||
data,
|
||||
region,
|
||||
})) as Record<string, unknown>;
|
||||
|
||||
const dataField = (result?.data as Record<string, unknown> | undefined) ?? {};
|
||||
@@ -76,7 +84,7 @@ export default defineCommand({
|
||||
const msg = (dataField.errorMsg as string | undefined) ?? code;
|
||||
const hint =
|
||||
code === "BailianGateway.Login.NotLogined"
|
||||
? "Run `bl auth login --console` to refresh your console session."
|
||||
? `Run \`${config.binName} auth login --console\` to refresh your console session.`
|
||||
: undefined;
|
||||
throw new BailianError(`Console gateway: ${msg}`, ExitCode.AUTH, hint);
|
||||
}
|
||||
+11
-11
@@ -6,32 +6,32 @@ import {
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult } from "../../output/output.ts";
|
||||
import { ensureApiKey } from "../../utils/ensure-key.ts";
|
||||
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { ensureApiKey } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
name: "mcp tools",
|
||||
description: "List tools exposed by an MCP server (tools/list)",
|
||||
usage: "bl mcp tools <server-code> [--url <url>]",
|
||||
skipDefaultApiKeySetup: true,
|
||||
usageArgs: "<server-code> [--url <url>]",
|
||||
options: [
|
||||
{
|
||||
flag: "<server-code>",
|
||||
description: "Server code from `bl mcp list` (e.g. market-cmapi00073529)",
|
||||
description: "Server code from `mcp list` (e.g. market-cmapi00073529)",
|
||||
required: true,
|
||||
},
|
||||
{ flag: "--url <url>", description: "Override the MCP endpoint URL (for non-Bailian servers)" },
|
||||
],
|
||||
examples: [
|
||||
"bl mcp tools market-cmapi00073529",
|
||||
"bl mcp tools market-cmapi00073529 --output json",
|
||||
"bl mcp tools my-server --url https://example.com/mcp",
|
||||
exampleArgs: [
|
||||
"market-cmapi00073529",
|
||||
"market-cmapi00073529 --output json",
|
||||
"my-server --url https://example.com/mcp",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const positional =
|
||||
((flags as Record<string, unknown>)._positional as string[] | undefined) ?? [];
|
||||
const code = positional[0];
|
||||
if (!code) failIfMissing("server-code", "bl mcp tools <server-code>");
|
||||
if (!code) failIfMissing("server-code", cmdUsage(config, "<server-code>"));
|
||||
|
||||
const url = (flags.url as string) || bailianMcpUrl(config.baseUrl, code!);
|
||||
const format = detectOutputFormat(config.output);
|
||||
+8
-9
@@ -8,13 +8,12 @@ import {
|
||||
type MemoryAddRequest,
|
||||
type MemoryAddResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
name: "memory add",
|
||||
description: "Add memory from messages or custom content",
|
||||
usage: "bl memory add --user-id <id> [--messages <json>] [--content <text>] [flags]",
|
||||
usageArgs: "--user-id <id> [--messages <json>] [--content <text>] [flags]",
|
||||
options: [
|
||||
{ flag: "--user-id <id>", description: "User ID (required)", required: true },
|
||||
{
|
||||
@@ -25,14 +24,14 @@ export default defineCommand({
|
||||
{ flag: "--profile-schema <id>", description: "Profile schema ID for user profiling" },
|
||||
{ flag: "--memory-library-id <id>", description: "Memory library ID (isolate memory space)" },
|
||||
],
|
||||
examples: [
|
||||
'bl memory add --user-id user1 --content "用户喜欢Python编程"',
|
||||
'bl memory add --user-id user1 --messages \'[{"role":"user","content":"我喜欢旅行"}]\'',
|
||||
'bl memory add --user-id user1 --content "住在北京" --profile-schema schema_xxx',
|
||||
exampleArgs: [
|
||||
'--user-id user1 --content "The user likes Python programming"',
|
||||
'--user-id user1 --messages \'[{"role":"user","content":"I like traveling"}]\'',
|
||||
'--user-id user1 --content "Lives in Beijing" --profile-schema schema_xxx',
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const userId = flags.userId as string;
|
||||
if (!userId) failIfMissing("user-id", "bl memory add --user-id <id>");
|
||||
if (!userId) failIfMissing("user-id", cmdUsage(config, "--user-id <id>"));
|
||||
|
||||
const body: MemoryAddRequest = { user_id: userId };
|
||||
|
||||
+6
-7
@@ -6,25 +6,24 @@ import {
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
name: "memory delete",
|
||||
description: "Delete a memory node",
|
||||
usage: "bl memory delete --node-id <id> --user-id <id>",
|
||||
usageArgs: "--node-id <id> --user-id <id>",
|
||||
options: [
|
||||
{ flag: "--node-id <id>", description: "Memory node ID (required)", required: true },
|
||||
{ flag: "--user-id <id>", description: "User ID (required)", required: true },
|
||||
{ flag: "--memory-library-id <id>", description: "Memory library ID (non-default library)" },
|
||||
],
|
||||
examples: ["bl memory delete --node-id node_xxx --user-id user1"],
|
||||
exampleArgs: ["--node-id node_xxx --user-id user1"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const nodeId = flags.nodeId as string;
|
||||
if (!nodeId) failIfMissing("node-id", "bl memory delete --node-id <id> --user-id <id>");
|
||||
if (!nodeId) failIfMissing("node-id", cmdUsage(config, "--node-id <id> --user-id <id>"));
|
||||
|
||||
const userId = flags.userId as string;
|
||||
if (!userId) failIfMissing("user-id", "bl memory delete --node-id <id> --user-id <id>");
|
||||
if (!userId) failIfMissing("user-id", cmdUsage(config, "--node-id <id> --user-id <id>"));
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
const params = new URLSearchParams({ user_id: userId });
|
||||
+5
-9
@@ -7,26 +7,22 @@ import {
|
||||
type GlobalFlags,
|
||||
type MemoryNodeListResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult, emitBare } from "../../output/output.ts";
|
||||
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
name: "memory list",
|
||||
description: "List memory nodes for a user",
|
||||
usage: "bl memory list --user-id <id> [flags]",
|
||||
usageArgs: "--user-id <id> [flags]",
|
||||
options: [
|
||||
{ flag: "--user-id <id>", description: "User ID (required)", required: true },
|
||||
{ flag: "--page-size <n>", description: "Results per page (default: 10)", type: "number" },
|
||||
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
|
||||
{ flag: "--memory-library-id <id>", description: "Memory library ID" },
|
||||
],
|
||||
examples: [
|
||||
"bl memory list --user-id user1",
|
||||
"bl memory list --user-id user1 --page-size 20 --page 2",
|
||||
],
|
||||
exampleArgs: ["--user-id user1", "--user-id user1 --page-size 20 --page 2"],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const userId = flags.userId as string;
|
||||
if (!userId) failIfMissing("user-id", "bl memory list --user-id <id>");
|
||||
if (!userId) failIfMissing("user-id", cmdUsage(config, "--user-id <id>"));
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
const params = new URLSearchParams();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user