Merge branch 'main' of github.com:modelstudioai/cli into feat/cli-access-token

This commit is contained in:
lisheng.lisheng
2026-07-13 13:13:29 +08:00
140 changed files with 5393 additions and 2576 deletions
+11
View File
@@ -104,6 +104,17 @@ CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的
如果命令调用 Console Gateway,`defineCommand` 必须设置 `auth: "console"`。runtime 会基于 `CONSOLE_AUTH_FLAGS` 自动在 help 中展示 `--console-region`、`--console-site`、`--console-switch-agent`、`--workspace-id`,并由 `authStage` 解析/注入 console credential。命令不要重复声明这些凭证域 flag,也不要手动从 env/config 解析 token。
### 5. 禁止单字母变量命名
所有变量、参数、回调形参必须使用有语义的命名,不允许单字母(如 `i`、`m`、`p`、`t`、`e`、`s`)。具体表现:
- 回调参数: `.map((m) => ...)` → `.map((model) => ...)`, `.find((t) => ...)` → `.find((template) => ...)`
- catch 变量: `catch (e)` → `catch (error)`
- for-of 循环: `for (const i of items)` → `for (const item of items)`
- 临时变量: `const s = ...` → `const strategy = ...`
例外: 仅当作用域极小(≤3 行)且语义从上下文完全明确时,可使用 `k`/`v`(Object.entries 的 key/value)。
## 完成改动后的快速验证
```sh
+3 -3
View File
@@ -38,7 +38,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **Fine-tuning & deployment** — Upload datasets, create SFT/LoRA/DPO/CPT jobs (`finetune create`), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy create`)
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
- **Console capabilities** — Browse 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
@@ -114,10 +114,10 @@ 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 text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
bl finetune watch --job-id ft-xxx --output json # Non-blocking 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
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
# Browse apps / free-tier quota / usage statistics / workspaces
bl app list
+3 -3
View File
@@ -38,7 +38,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **微调与部署** — 上传数据集、创建 SFT/LoRA/DPO/CPT 调优任务(`finetune create`)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy create`)
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
@@ -112,10 +112,10 @@ 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 text 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 deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
bl app list
-11
View File
@@ -1,11 +0,0 @@
## 登录
https://signin.aliyun.com/1062516667359476.onaliyun.com/login.htm
lisheng@1062516667359476.onaliyun.com
app$$5%%%Ehiliao
## 获得 AK SK STS 三元组
```
pnpm bl auth generate-access-token --access-key-id STS.NXsfUgEqhDQBJTz1V5JJSMRDw --access-key-secret CmJU9so7yMTjZ3mpVF9eMqFSZXkEn2LhFMogi1hfn8rk --security-token CAIS1gJ1q6Ft5B2yfSjIr5vGLe/TqK5J85OpSHLL1VZgRsV/opfvlTz2IHhMe3BtAuwXtvQ1mG9R7/0ZlqBpR4RIXlfFas0oFyyqTp/6MeT7oMWQweEuqv/MQBq+aXPS2MvVfJ+KLrf0ceusbFbpjzJ6xaCAGxypQ12iN+/i6/clFKN1ODO1dj1bHtxbCxJ/ocsBTxvrOO2qLwThjxi7biMqmHIl2T8ns/vlnpbHs0KP0gWq8IJP+dSteKrDRtJ3IZJyX+2y2OFLbafb2EdSkUMSrPgv0fcYqG+X5I3CWgAKuA/MKefP9cB1JwJ1Z7I3ELJDtun1nvZ/p+rPno/8xg1WJ+ZRXjRD7XJMD2hdcQnAF6HaFd6TUxylurgExgnkPL5jz1gvlRKYWhvQG45hiCZWPhXwAIHJtv6kTMnd5abLPm9I37QLATeM356+Q3LrJRHx74QEOMJUBysagAFIsU+wgReSHvEEXx5y3qyr3JB3t7tY9nI0FrIx0GudXEjl2vE4sD7aTxleD1Weqlq8XAzIu8DrdU8tkNdQ0rGricEZd9DY2WWDer1eD7IdoLuavwGHXyLaJkw1c1UPq2O3fKp2EQ8b5zei/Ep+MGfMCYDHyVqDPAPLD5yTtN6eOCAA
```
+70 -27
View File
@@ -1,31 +1,58 @@
# CLI E2E 测试规范
## 架构分层
| 层级 | 路径 | 测什么 |
| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup(`private`,不发布) |
| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、live(gated);每用例最小路由 |
| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path `--help`、分组 help、根 help |
| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | 从 `kscli/src/commands.ts` 推导 path/分组;identity(`--version`、`search --help` path) |
| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 |
**依赖边界**:`e2e` → `core`;`commands/tests` → `e2e` + `commands/src`;产品 tests → `e2e` + 各自 `src`。**禁止**产品 import `commands/tests/**`(子进程 spawn harness 路径除外)。
## 触发条件
- 新增/修改 `packages/commands/src/commands` 下的 command 实现
- 新增/修改 `packages/cli/src/commands.ts` 的 `bl` 命令路径 map
- 新建或扩展 `packages/cli/tests/e2e/*.e2e.test.ts` 用例
- 为命令补 help / 缺参 / dry-run / 真实集成测试
- 新建或扩展 `packages/commands/tests/e2e/<topic>.e2e.test.ts`
- 新增 bl 产品 path → `registry.smoke` 自动覆盖 leaf path;commands topic 测试在 `topic-routes.ts` 补最小路由
以上情况必须同步维护 `packages/cli/tests/e2e/<topic>.e2e.test.ts`。跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。
跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。
> **规则**:共享 command 行为在 `commands/tests/e2e`;产品 map、identity、CLI-only 命令留在对应产品 `tests/e2e`。
## 文件与工具
- 路径:`packages/cli/tests/e2e/<kebab-topic>.e2e.test.ts`
- 框架:`vite-plus/test`;子进程跑 CLI:`runCli` from `./helpers.ts`
### commands E2E
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`
- 子进程:`runCommandE2e(routes, args)` from `./helpers.ts`(spawn `harness/main.ts`,`routes` 为本 topic 最小 path → export 映射)
- fixtures:`packages/commands/tests/e2e/fixtures/`
- 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map)
### 产品 smoke
- bl:`runCli` from `packages/cli/tests/e2e/helpers.ts`
- kscli:`runKscli` from `packages/kscli/tests/e2e/helpers.ts`
### 共享
- gating / output / runner:`e2e/gating`、`e2e/output`、`e2e/runner`
- globalSetup:根 `vite.config.ts` → `packages/e2e/src/global-setup.ts`
- 解析 JSON stdout:`parseStdoutJson`;输出目录:`makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url))`
- 长任务:`cliTimeoutPrefix()`;视频用例加 `test(..., 3_600_000)` 等显式超时
## 双层 describe(固定结构)
```ts
// 1) 不 skip:分组 + --help,无密钥、无真实 API
// 1) 不 skip:--help,无密钥、无真实 API(分组 help 由 bl registry.smoke 覆盖)
describe("e2e: <topic>", () => {
test("<group> 分组展示子命令帮助且成功退出", ...);
test("<subcommand> --help 正常退出", ...);
});
// 2) skipIf:缺参 / dry-run / 真实集成;原有集成用例放最后、勿改逻辑
// 2) skipIf:缺参 / dry-run / 真实集成
describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
test("缺少 --<flag> 时退出为用法错误 (2)", ...);
test("<cmd> --dry-run ...", ...); // 若适用
@@ -33,23 +60,27 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
});
```
## skip 条件(helpers.ts)
## skip 条件(`e2e/gating`,commands helpers re-export)
| 场景 | 条件 |
| ------------------- | ----------------------------------------------------- |
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
| 知识库 | `isKnowledgeE2EReady()` |
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
| 场景 | 条件 |
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
| 知识库 chat/search live | `isChatE2EReady()` / `isSearchE2EReady()`(`knowledge chat/search`,需 `BAILIAN_WORKSPACE_ID` + agent ID) |
## 用例类型
1. **分组 help**:`runCli(["image"])` → `exitCode === 0`,stdout+stderr 含子命令名
2. **--help**:`runCli([..., "--help"])` → stderr 含主要 flags
3. **缺参**:带一个无害全局 flag(如 `--quiet`)且不传 required flag → `exitCode === 2`,stderr 匹配 `--flag|Missing required argument`
4. **--dry-run**:仅当实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本,不入网
5. **真实集成**:保留既有用例名称与断言;放在 skip 块**末尾**
1. **--help**:`runCommandE2e(ROUTES, [..., "--help"])` → stderr 含主要 flags
2. **缺参**:带无害全局 flag(如 `--quiet`)且不传 required flag → `exitCode === 2`
3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本
4. **真实集成**:放在 skip 块**末尾**
## 增删命令同步
- **commands export** + **topic 路由**(`topic-routes.ts` 或测试文件内 `ROUTES`)+ **产品 map**(`cli/commands.ts` / `kscli/commands.ts`)
- 分组 help 由产品 `registry.smoke` 负责,无需在 commands 重复
## 安全与例外
@@ -60,24 +91,36 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
## 新增 command 检查清单
- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `topic-routes.ts` 补最小路由
- [ ] `packages/commands/tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
- [ ] 若改了 `usageArgs` / `flags` / `exampleArgs`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/` 并提交
- [ ] 顶层:分组 help + 子命令 `--help`(多子命令则各一条 help)
- [ ] 子命令 `--help`(分组 help 由 bl `registry.smoke` 覆盖)
- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条
- [ ] 至少一条真实集成(或说明为何仅 smoke);不破坏已有集成用例顺序
- [ ] `pnpm test packages/cli/tests/e2e/<file>` 通过
- [ ] `vp test packages/commands/tests/e2e/<file>` 通过
## 调试命令
```sh
pnpm --filter bailian-cli-commands exec vp test packages/commands/tests/e2e/text-chat.e2e.test.ts
pnpm --filter bailian-cli exec vp test packages/cli/tests/e2e/registry.smoke.e2e.test.ts
pnpm --filter knowledge-studio-cli exec vp test packages/kscli/tests/e2e/registry.smoke.e2e.test.ts
pnpm --filter bailian-cli-runtime exec vp test packages/runtime/tests/proxy.e2e.test.ts
```
## 示例片段
```ts
import { FOO_ROUTES } from "./topic-routes.ts";
test("foo bar 缺少 --prompt 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli(["foo", "bar", "--quiet"]);
const { stderr, exitCode } = await runCommandE2e(FOO_ROUTES, ["foo", "bar", "--quiet"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prompt|Missing required argument/i);
});
test("foo bar --dry-run 仅输出计划", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(FOO_ROUTES, [
"foo",
"bar",
"--dry-run",
@@ -97,4 +140,4 @@ test("foo bar --dry-run 仅输出计划", async () => {
- **E2E**:单条/少量调用、断言固定、可进 `vp test`(见上文 skip 条件)
- **批量压测**:`packages/cli/tests/stress/run.mjs` + `targets/*.mjs`,并发 + 报告,**仅手动** `pnpm run test:stress -- <target>`
勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md)。
勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md).
+7 -5
View File
@@ -92,15 +92,17 @@ packages/commands/src/index.ts
### D. 测试层
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/cli/tests/e2e/<topic>.e2e.test.ts`
- [ ] 删除命令时一并删对应 e2e / README 示例 / reference 生成结果
- [ ] 如果 shared command 在不同入口路径下复用,至少确保 `bl` 入口 e2e 覆盖;`kscli` 入口改动需补对应入口测试或手工 smoke
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/commands/tests/e2e/<topic>.e2e.test.ts`
- [ ] 同步 `packages/commands/tests/e2e/topic-routes.ts`(该 topic 的最小 path → export 映射)
- [ ] bl 产品 path 变更由 `registry.smoke` 自动覆盖;kscli 变更同步 `kscli/src/commands.ts` 与 `registry.smoke`
- [ ] 删除命令时一并删对应 commands e2e / README 示例 / reference / topic 路由条目
- [ ] 如果 shared command 在不同入口路径下复用,至少确保 commands e2e 覆盖 `bl` path;`kscli` 入口改动需补对应 smoke 或说明不测 flat path live
### E. 重命名特殊处理
- [ ] 全仓 grep **旧命令名字符串**,确保以下位置全部更新:
- `packages/cli/src/commands.ts` map key
- `packages/kscli/src/main.ts` map key(如适用)
- `packages/kscli/src/commands.ts` map key(如适用)
- 用户可见 hint / README / tests
- `skills/bailian-cli/reference/`(重建后检查并提交)
- [ ] 检查 `usageArgs` / `exampleArgs` 没有硬编码旧的 `bl <path>` 前缀
@@ -111,7 +113,7 @@ packages/commands/src/index.ts
pnpm run sync:skill-assets
pnpm -F bailian-cli exec tsx src/main.ts <new-command> --help
pnpm -F bailian-cli exec tsx src/main.ts
vp test packages/cli/tests/e2e/<topic>.e2e.test.ts
vp test packages/commands/tests/e2e/<topic>.e2e.test.ts
```
如改了 `kscli` 入口:
+3 -3
View File
@@ -38,7 +38,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **Fine-tuning & deployment** — Upload datasets, create SFT/LoRA/DPO/CPT jobs (`finetune create`), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy create`)
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
- **Console capabilities** — Browse 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
@@ -114,10 +114,10 @@ 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 text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
bl finetune watch --job-id ft-xxx --output json # Non-blocking 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
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
# Browse apps / free-tier quota / usage statistics / workspaces
bl app list
+3 -3
View File
@@ -38,7 +38,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **微调与部署** — 上传数据集、创建 SFT/LoRA/DPO/CPT 调优任务(`finetune create`)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy create`)
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
@@ -112,10 +112,10 @@ 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 text 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 deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
bl app list
+1
View File
@@ -59,6 +59,7 @@
"ajv": "catalog:",
"boxen": "catalog:",
"chalk": "catalog:",
"e2e": "workspace:*",
"typescript": "^6.0.2",
"undici": "catalog:",
"vite-plus": "0.1.22",
+12 -4
View File
@@ -53,7 +53,9 @@ import {
datasetGet,
datasetDelete,
datasetValidate,
finetuneCreate,
finetuneTextCreate,
finetuneAudioCreate,
finetuneImageCreate,
finetuneList,
finetuneGet,
finetuneCancel,
@@ -63,7 +65,9 @@ import {
finetuneExport,
finetuneWatch,
finetuneCapability,
deployCreate,
deployTextCreate,
deployAudioCreate,
deployImageCreate,
deployList,
deployGet,
deployModels,
@@ -136,7 +140,9 @@ export const commands: Record<string, AnyCommand> = {
"dataset get": datasetGet,
"dataset delete": datasetDelete,
"dataset validate": datasetValidate,
"finetune create": finetuneCreate,
"finetune text create": finetuneTextCreate,
"finetune audio create": finetuneAudioCreate,
"finetune image create": finetuneImageCreate,
"finetune list": finetuneList,
"finetune get": finetuneGet,
"finetune cancel": finetuneCancel,
@@ -146,7 +152,9 @@ export const commands: Record<string, AnyCommand> = {
"finetune export": finetuneExport,
"finetune watch": finetuneWatch,
"finetune capability": finetuneCapability,
"deploy create": deployCreate,
"deploy text create": deployTextCreate,
"deploy audio create": deployAudioCreate,
"deploy image create": deployImageCreate,
"deploy list": deployList,
"deploy get": deployGet,
"deploy models": deployModels,
+40 -203
View File
@@ -1,215 +1,52 @@
import { execFile } from "child_process";
import { mkdirSync, readFileSync } from "fs";
import { promisify } from "util";
import { basename, dirname, join } from "path";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { readConfigFile } from "bailian-cli-core";
import {
cliTimeoutPrefix,
cliTimeoutSeconds,
e2eLabelFromMetaUrl,
isConsoleAuthFailure,
makeE2eOutputDir,
parseStdoutJson,
} from "e2e/output";
import { runNodeMain, type RunCliResult } from "e2e/runner";
import {
isBailianE2EEnabled,
isBailianE2EMediaEnabled,
isBailianE2EVideoEnabled,
isChatE2EReady,
isConsoleE2EReady,
isDashScopeE2EReady,
isSearchE2EReady,
} from "e2e/gating";
import { monorepoRoot } from "e2e/monorepo-root";
const execFileAsync = promisify(execFile);
export {
cliTimeoutPrefix,
cliTimeoutSeconds,
e2eLabelFromMetaUrl,
isBailianE2EEnabled,
isBailianE2EMediaEnabled,
isBailianE2EVideoEnabled,
isChatE2EReady,
isConsoleAuthFailure,
isConsoleE2EReady,
isDashScopeE2EReady,
isSearchE2EReady,
makeE2eOutputDir,
monorepoRoot,
parseStdoutJson,
};
export type { RunCliResult };
/**
* Vitest `global-setup.ts` 写入 `test/output/` 下本文件名,供各 worker 进程读取同一会话 id。
* (仅模块内变量无法跨 Vitest 多进程 worker 共享。)
*/
export const E2E_RUN_SESSION_FILENAME = ".e2e-run-session";
/**
* 单次 `vp test` / Vitest 运行共用的 E2E 输出会话目录名(惰性缓存于当前进程)。
*/
let e2eOutputSessionId: string | undefined;
/** `packages/cli` 根目录(含 `src/main.ts`) */
/** `packages/cli` 根目录 */
export const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const mainTs = join(cliPackageRoot, "src", "main.ts");
/** Monorepo 根(含根 `package.json`) */
export function monorepoRoot(): string {
return join(cliPackageRoot, "..", "..");
}
export function localBin(name: string): string {
return join(
monorepoRoot(),
"node_modules",
".bin",
process.platform === "win32" ? `${name}.cmd` : name,
);
}
function readE2eRunSessionFromOutputDir(): string | undefined {
try {
const p = join(monorepoRoot(), "test", "output", E2E_RUN_SESSION_FILENAME);
const t = readFileSync(p, "utf8").trim();
return t.length > 0 ? t : undefined;
} catch {
return undefined;
}
}
function getE2eOutputSessionId(): string {
if (!e2eOutputSessionId) {
const fromEnv = process.env.BAILIAN_E2E_RUN_ID?.trim();
if (fromEnv) {
e2eOutputSessionId = fromEnv.replace(/[^a-zA-Z0-9._-]+/g, "-");
} else {
const fromFile = readE2eRunSessionFromOutputDir();
if (fromFile) {
e2eOutputSessionId = fromFile.replace(/[^a-zA-Z0-9._-]+/g, "-");
} else {
e2eOutputSessionId = `e2e-run-${Date.now()}-${process.pid}`;
}
}
}
return e2eOutputSessionId;
}
/**
* 在 `test/output/<会话>/` 下创建用例子目录。
* 会话 id 优先 `BAILIAN_E2E_RUN_ID`,否则读 Vitest globalSetup 写入的 `test/output/.e2e-run-session`,
* 再否则回退为单进程 id(非 Vitest 直接跑用例时)。
* 若已设 `BAILIAN_E2E_OUT` 则直接使用(不再套会话目录)。
*/
export function makeE2eOutputDir(label: string): string {
const fromEnv = process.env.BAILIAN_E2E_OUT?.trim();
if (fromEnv) {
mkdirSync(fromEnv, { recursive: true });
return fromEnv;
}
const safe = label.replace(/[^a-zA-Z0-9._-]+/g, "-");
const sessionDir = join(monorepoRoot(), "test", "output", getE2eOutputSessionId());
mkdirSync(sessionDir, { recursive: true });
const dir = join(sessionDir, `e2e-vp-${safe}-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
/** 全局 `--timeout` 秒数(视频等长任务) */
export function cliTimeoutSeconds(): string {
return process.env.BAILIAN_E2E_TIMEOUT_SEC?.trim() || "3600";
}
export function cliTimeoutPrefix(): string[] {
return ["--timeout", cliTimeoutSeconds()];
}
/** 显式开启后才跑真实网络 E2E,避免默认 `vp test` 依赖密钥或打外网 */
export function isBailianE2EEnabled(): boolean {
return process.env.BAILIAN_E2E === "1";
}
/** 可调 DashScope 的 API Key:环境变量优先,否则读 ~/.bailian/config.json */
export function isDashScopeE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_API_KEY?.trim()) return true;
try {
const f = readConfigFile();
return typeof f.api_key === "string" && f.api_key.length > 0;
} catch {
return false;
}
}
/**
* Console-gateway 命令(quota / usage free / usage stats)的 E2E 就绪检查:
* 需 `BAILIAN_E2E=1` 且存在 console access_token(`~/.bailian/config.json` 的
* `access_token`;凭证解析已集中到 authStage,不再读环境变量)。
*
* 仅检查 token 是否存在——无法本地判断是否过期。token 过期时 gated 用例仍会执行,
* 但用 `isConsoleAuthFailure` 把“session 未登录/已过期”的优雅报错视为通过,保持
* 与 deploy/dataset “无 key / 有效 key / 失效 key 均绿”的一致策略。
*/
export function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 在仅跑文本/记忆/知识库时跳过) */
export function isBailianE2EMediaEnabled(): boolean {
if (process.env.BAILIAN_E2E_MEDIA === "0") return false;
return isBailianE2EEnabled();
}
/** 文生视频 / 图生视频 / 参考视频 / 视频编辑(耗时长,默认关闭) */
export function isBailianE2EVideoEnabled(): boolean {
return isBailianE2EEnabled() && process.env.BAILIAN_E2E_VIDEO === "1";
}
/** 从 `import.meta.url` 生成 OUT 子目录标签,避免并行用例目录冲突 */
export function e2eLabelFromMetaUrl(metaUrl: string): string {
return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, "");
}
/** 知识库用例:须显式索引 ID + API-KEY */
export function isKnowledgeE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (!process.env.BAILIAN_E2E_INDEX_ID) return false;
return isDashScopeE2EReady();
}
export interface RunCliResult {
stdout: string;
stderr: string;
exitCode: number;
}
/**
* 子进程执行 CLI(等价于在 `packages/cli` 下 `tsx src/main.ts ...`)。
* request_id 等诊断信息在 stderr;`--output json` 时 JSON 在 stdout。
*/
/** 子进程执行 bl CLI */
export async function runCli(
args: string[],
envOverrides: NodeJS.ProcessEnv = {},
): Promise<RunCliResult> {
try {
const { stdout, stderr } = await execFileAsync(localBin("tsx"), [mainTs, ...args], {
cwd: cliPackageRoot,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
env: {
...process.env,
NODE_NO_WARNINGS: "1",
DO_NOT_TRACK: "1",
...envOverrides,
},
});
return { stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 };
} catch (err: unknown) {
const e = err as {
stdout?: string;
stderr?: string;
code?: number;
};
return {
stdout: e.stdout ?? "",
stderr: e.stderr ?? "",
exitCode: typeof e.code === "number" ? e.code : 1,
};
}
}
export function parseStdoutJson<T = unknown>(stdout: string): T {
const t = stdout.trim();
// Extract JSON object — stdout may contain [perf] console.time lines before JSON
const jsonMatch = t.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error(`No JSON object found in stdout: ${t.slice(0, 200)}`);
return JSON.parse(jsonMatch[0]) as T;
}
/**
* 判断一次 CLI 运行是否因 console session 未登录/已过期而失败。
*
* Console E2E 用例的 readiness 闸(`isConsoleE2EReady`)只能判断 token 是否存在,
* 无法判断是否过期;token 失效时 gated 用例仍会执行并拿到鉴权错误。本函数让用例
* 参考 deploy/dataset 的做法:只要 CLI 把鉴权错误优雅上抛(非零退出 + stderr 说明
* session 失效),即视为通过,而不是强求 exit 0 的成功输出。
*/
export function isConsoleAuthFailure(result: RunCliResult): boolean {
if (result.exitCode === 0) return false;
return /not logged in|has expired|NotLogined|Run `bl auth login/i.test(result.stderr);
return runNodeMain(mainTs, args, { cwd: cliPackageRoot, env: envOverrides });
}
@@ -1,149 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
interface DryRunBody {
endpoint?: string;
request?: {
query?: string;
agent_id?: string;
images?: string[];
query_history?: Array<{ role: string; content: string }>;
};
}
describe("e2e: knowledge search", () => {
test("knowledge search --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "search", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--query/i);
expect(stderr).toMatch(/--agent-id/i);
expect(stderr).toMatch(/--workspace-id/i);
expect(stderr).toMatch(/--image/i);
expect(stderr).toMatch(/--query-history/i);
});
test("缺少 --query 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "search", "--agent-id", "aid_test"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--query|Usage:/i);
});
test("缺少 --agent-id 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "search", "--query", "test"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--agent-id|Usage:/i);
});
test("缺少 --workspace-id 时非零退出并提示", async () => {
const { stderr, exitCode } = await runCli(
// 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入
[
"knowledge",
"search",
"--query",
"test",
"--agent-id",
"aid_test",
"--api-key",
"sk-fake",
"--output",
"json",
],
{ BAILIAN_WORKSPACE_ID: "", BAILIAN_CONFIG_DIR: "/tmp" },
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/workspace.*required/i);
});
test("--dry-run 输出 endpoint 和 request body", async () => {
const { stdout, stderr, exitCode } = await runCli([
"knowledge",
"search",
"--dry-run",
"--query",
"什么是RAG",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/ws_test\.cn-beijing\.maas\.aliyuncs\.com/);
expect(data.endpoint).toMatch(/api\/v1\/indices\/knowledge\/search/);
expect(data.request?.query).toBe("什么是RAG");
expect(data.request?.agent_id).toBe("aid_test");
});
test("--dry-run + --image 输出 images", async () => {
const { stdout, stderr, exitCode } = await runCli([
"knowledge",
"search",
"--dry-run",
"--query",
"test",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--image",
"https://example.com/a.jpg",
"--image",
"https://example.com/b.jpg",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.images).toEqual([
"https://example.com/a.jpg",
"https://example.com/b.jpg",
]);
});
test("--dry-run + --query-history 输出用户对话历史", async () => {
const { stdout, stderr, exitCode } = await runCli([
"knowledge",
"search",
"--dry-run",
"--query",
"它怎么工作",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--query-history",
'[{"role":"user","content":"什么是RAG"},{"role":"assistant","content":"RAG是检索增强生成"}]',
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.query_history).toEqual([
{ role: "user", content: "什么是RAG" },
{ role: "assistant", content: "RAG是检索增强生成" },
]);
});
test("--dry-run + --query-history 无效 JSON 非零退出", async () => {
const { stderr, exitCode } = await runCli([
"knowledge",
"search",
"--dry-run",
"--query",
"test",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--query-history",
"not-valid-json",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/query-history.*valid JSON/i);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, test } from "vite-plus/test";
import { deriveGroupPaths } from "e2e/registry-smoke";
import { commands } from "../../src/commands.ts";
import { runCli } from "./helpers.ts";
const commandPaths = Object.keys(commands).sort();
const groupPaths = deriveGroupPaths(commandPaths);
describe("e2e: bl registry smoke", () => {
test("根帮助展示 bl 与全局 flag", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/\bbl\b/i);
expect(stderr).toMatch(/--base-url/);
expect(stderr).toMatch(/--console-region/);
expect(stderr).toMatch(/--console-site/);
expect(stderr).toMatch(/--console-switch-agent/);
expect(stderr).not.toMatch(/^\s*--region\s/m);
});
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Global Flags:/);
expect(stderr).toMatch(/--console-region <region>/);
expect(stderr).toMatch(/--model <model>/);
expect(stderr).toMatch(/--period <minutes>/);
expect(stderr).toMatch(/--output <format>/);
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
});
test.each(commandPaths)("已注册命令 %s --help 成功", async (path) => {
const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]);
expect(exitCode, stderr).toBe(0);
});
test.each(groupPaths)("命令分组 %s --help 成功", async (path) => {
const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]);
expect(exitCode, stderr).toBe(0);
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "vite-plus";
export default defineConfig({
test: {
globalSetup: "./tests/e2e/global-setup.ts",
globalSetup: "../e2e/src/global-setup.ts",
testTimeout: 60_000,
hookTimeout: 60_000,
},
+1
View File
@@ -49,6 +49,7 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "7.0.0-dev.20260328.1",
"e2e": "workspace:*",
"typescript": "^6.0.2",
"vite-plus": "0.1.22"
},
@@ -272,9 +272,7 @@ export default defineCommand({
return result;
});
const analyzeIntentPromise = analyzeIntent(ctx.client, userInput, {
intentDetectBaseUrl: settings.intentDetectBaseUrl,
}).then((result) => {
const analyzeIntentPromise = analyzeIntent(ctx.client, userInput).then((result) => {
intentReady = true;
if (!modelsReady) {
spinner.update("Agent: Intent analyzed, loading model data...");
@@ -6,6 +6,7 @@ import {
parseDatasetSchemaFlag,
formatIssue,
MAX_DATASET_BYTES,
MAX_MEDIA_ZIP_BYTES,
BailianError,
ExitCode,
type DatasetFile,
@@ -17,7 +18,7 @@ const UPLOAD_FLAGS = {
file: {
type: "string",
valueHint: "<path>",
description: "Local .jsonl dataset file (≤300MB)",
description: "Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image)",
required: true,
},
purpose: {
@@ -29,7 +30,7 @@ const UPLOAD_FLAGS = {
type: "string",
valueHint: "<s>",
description:
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record.',
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
},
noValidate: {
type: "switch",
@@ -42,42 +43,55 @@ const UPLOAD_FLAGS = {
} satisfies FlagsDef;
export default defineCommand({
description: "Upload a dataset file (.jsonl) to Bailian",
description: "Upload a dataset file (.jsonl or .zip) to Bailian",
auth: "apiKey",
usageArgs:
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt>] [--no-validate] [--full-validate]",
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt|tts|image>] [--no-validate] [--full-validate]",
flags: UPLOAD_FLAGS,
exampleArgs: [
"--file train.jsonl",
"--file dpo.jsonl --schema dpo",
"--file cpt.jsonl --schema cpt",
"--file audio.zip --schema tts",
"--file eval.jsonl --purpose evaluation",
"--file train.jsonl --full-validate",
"--file train.jsonl --no-validate",
],
notes: [
"Only .jsonl is supported in this release. Three record schemas are",
"recognized: chatml = {messages:[...]} (SFT); dpo = {messages:[...],",
"chosen, rejected} where chosen/rejected are single assistant messages;",
'cpt = {text:"..."} (continual pre-training, raw text). With no --schema,',
"a record carrying chosen/rejected is validated as DPO, one with text (and",
"no messages) as CPT, otherwise as ChatML. Pass --schema dpo / cpt to",
"require that shape on every record, or --schema chatml to ignore the",
"preference / text fields. Other purposes may carry a different schema in",
"the future and would be served by a purpose-specific validator.",
"The dataset upload cap is 300MB per file.",
"Upload uses the OpenAI-compatible /compatible-mode/v1/files endpoint so",
"the purpose tag is persisted (the DashScope-native /api/v1/files drops it).",
"Supports .jsonl (text) and .zip (audio/image archives with a data.jsonl",
"manifest). Five record schemas are recognized: chatml = {messages:[...]}",
'(SFT); dpo = {messages:[...], chosen, rejected}; cpt = {text:"..."}',
'(continual pre-training, raw text); tts = {wav_fn:"train/xxx.wav",',
'text:"..."} (audio fine-tuning); image = {img_path:"..."} (image',
"generation). With no --schema, a record carrying wav_fn is validated as",
"TTS, img_path as image, chosen/rejected as DPO, text (no messages) as CPT,",
"otherwise ChatML. Upload cap: 300MB text, 1GB image. Upload uses the",
"OpenAI-compatible /compatible-mode/v1/files endpoint so the purpose tag is",
"persisted (the DashScope-native /api/v1/files drops it).",
],
async run(ctx) {
const { identity, settings, flags } = ctx;
const filePath = flags.file;
const purpose = flags.purpose || "fine-tune";
const schema = parseDatasetSchemaFlag(flags.schema);
if (schema === "video") {
throw new BailianError(
`--schema video is not supported.`,
ExitCode.USAGE,
`Supported schemas: chatml, dpo, cpt, tts, image.`,
);
}
const format = detectOutputFormat(settings.output);
// Image schema allows larger ZIPs (1 GB vs 300 MB for text).
const isMediaSchema = schema === "image";
if (!flags.noValidate) {
const result = await validateDataset(filePath, { fullValidate: flags.fullValidate, schema });
const maxBytes = isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES;
const result = await validateDataset(filePath, {
fullValidate: flags.fullValidate,
schema,
maxBytes,
});
if (!result.valid) {
const lines = [
`Dataset validation failed for ${filePath}`,
@@ -112,7 +126,7 @@ export default defineCommand({
action: "dataset.upload",
file: filePath,
purpose,
max_bytes: MAX_DATASET_BYTES,
max_bytes: isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES,
validate: !flags.noValidate,
schema: schema ?? "auto",
},
@@ -25,7 +25,7 @@ const VALIDATE_FLAGS = {
file: {
type: "string",
valueHint: "<path>",
description: "Local .jsonl dataset file",
description: "Local dataset file (.jsonl or .zip)",
required: true,
},
fullValidate: {
@@ -36,20 +36,21 @@ const VALIDATE_FLAGS = {
type: "string",
valueHint: "<s>",
description:
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record.',
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
},
} satisfies FlagsDef;
export default defineCommand({
description: "Locally validate a dataset file (.jsonl) without uploading",
description: "Locally validate a dataset file (.jsonl or .zip) without uploading",
// 纯本地校验,不触网、不需 API key(与 `pipeline validate` 一致)。
auth: "none",
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt>]",
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt|tts|image>]",
flags: VALIDATE_FLAGS,
exampleArgs: [
"--file train.jsonl",
"--file dpo.jsonl --schema dpo",
"--file cpt.jsonl --schema cpt",
"--file audio.zip --schema tts",
"--file eval.jsonl --full-validate",
"--file train.jsonl --output json",
],
@@ -57,17 +58,27 @@ export default defineCommand({
"Default scan: every line gets a structural check, then ~160 lines (front 50,",
"evenly spaced 100, last 10) are JSON.parsed against the active schema.",
"Schemas: chatml = {messages:[...]} (SFT); dpo = {messages:[...], chosen,",
"rejected} where chosen/rejected are single assistant messages; cpt =",
'{text:"..."} (continual pre-training, raw text). With no --schema, a',
"record carrying chosen/rejected is validated as DPO, one with text (and no",
"messages) as CPT, otherwise as ChatML. Pass --schema dpo / cpt to require",
"that shape on every record (strict), or --schema chatml to ignore the",
"preference / text fields. Use --full-validate to JSON.parse every line.",
'rejected}; cpt = {text:"..."} (continual pre-training, raw text);',
'tts = {wav_fn:"train/xxx.wav", text:"..."} (audio fine-tuning);',
'image = {img_path:"..."} (image generation). With no --schema, a record',
"carrying wav_fn is validated as TTS, img_path as image, chosen/rejected",
"as DPO, text (no messages) as CPT, otherwise ChatML. Pass --schema to",
"require a specific shape on every record. ZIP archives (.zip) are",
"validated structurally (data.jsonl present, media references resolve) in",
"addition to per-record content checks. Use --full-validate to JSON.parse",
"every line.",
],
async run(ctx) {
const { settings, flags } = ctx;
const filePath = flags.file;
const schema = parseDatasetSchemaFlag(flags.schema);
if (schema === "video") {
throw new BailianError(
`--schema video is not supported.`,
ExitCode.USAGE,
`Supported schemas: chatml, dpo, cpt, tts, image.`,
);
}
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
+151 -94
View File
@@ -2,11 +2,16 @@ import {
defineCommand,
detectOutputFormat,
createDeployment,
pickPlanStrategy,
STRATEGIES,
defaultDeployPlan,
type DeployModality,
type CreateDeploymentRequest,
type CreatePlanFlags,
type CommandContext,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { pickPlanStrategy, STRATEGIES } from "./plans.ts";
const CREATE_FLAGS = {
model: {
@@ -26,10 +31,10 @@ const CREATE_FLAGS = {
valueHint: "<plan>",
description: "Billing plan: lora (default, Token-billed) | ptu (Token-billed) | mu",
},
templateId: {
deploySpec: {
type: "string",
valueHint: "<id>",
description: "Template id (only used by plan=mu; auto-picked if omitted)",
description: "Deploy spec (only used by plan=mu; auto-picked if omitted)",
},
capacity: {
type: "number",
@@ -58,104 +63,156 @@ const CREATE_FLAGS = {
},
} satisfies FlagsDef;
const CREATE_USAGE =
"--model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
const CREATE_NOTES = [
"Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-",
"billed) for audio (CosyVoice TTS). Pass --plan to override.",
"For plan=ptu (Token-billed, provisioned throughput), --input-tpm and",
"--output-tpm are required (the platform rejects creation without an",
"explicit ptu_capacity despite the doc listing defaults).",
"For plan=mu, `capacity`, `billing_method` and `deploy_spec` are required.",
"billing_method defaults to POST_PAY (only supported value); deploy_spec",
"and capacity are auto-picked from GET /deployments/models when omitted.",
"Use `bl deploy models --source base` to inspect available templates.",
"After creation, status starts at PENDING and transitions to RUNNING.",
"Invoke the deployed model with: bl text chat --model <deployed_model>",
"WARNING: --model is overloaded across commands and refers to DIFFERENT",
"values. `bl deploy <modality> create --model` takes the exported model_name",
"(e.g. `qwen3-8b-ft-...`), but the create response also returns a",
"`deployed_model` field (the deployment instance id, e.g.",
"`qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use",
"the `deployed_model` from the create response — NOT the `model_name` you",
"passed to `deploy <modality> create`. Do not reuse the value across the two",
"commands.",
];
/**
* `bl deploy create` — create a model deployment.
*
* Plan-specific behaviour (required flags / body assembly / auto-pick) lives
* in `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file only handles the
* shared envelope: flag validation, dispatch, dry-run, and result
* formatting. Adding a new plan = one entry in the strategy table;
* nothing here changes.
*
* `--model` (model identifier) and `--name` (console display name) are required.
* Shared `deploy <modality> create` flag validation. Plan support is
* server-catalog-driven, so validation is identical for every modality: resolve
* the effective plan (modality-specific default when --plan is omitted), reject
* an unknown --plan, then defer to the plan strategy's required-flag check.
*/
export default defineCommand({
description: "Create a model deployment",
function validateCreate(modality: DeployModality, flags: CreatePlanFlags): string | undefined {
const plan = flags.plan || defaultDeployPlan(modality);
const strategy = STRATEGIES[plan];
if (!strategy) {
return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`;
}
return strategy.validateFlags(flags);
}
/**
* Shared `deploy <modality> create` implementation. deploy create takes a model
* by name and a billing plan — it does NOT inspect data modality for the request
* body, so the run logic is identical across text / audio / image. The modality
* only fixes the default plan (audio → mu, text/image → lora) and the command
* path / description / examples.
*
* Plan-specific behaviour (required flags / body assembly / auto-pick) lives in
* core `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file only handles the
* shared envelope: dispatch, dry-run, and result formatting.
*/
async function runCreate(
modality: DeployModality,
ctx: CommandContext<typeof CREATE_FLAGS>,
): Promise<void> {
const { identity, settings, flags } = ctx;
const model = flags.model as string;
const name = flags.name as string;
const plan = (flags.plan as string | undefined) || defaultDeployPlan(modality);
const format = detectOutputFormat(settings.output);
// Plan-specific behaviour is owned by core `plans.ts`. The strategy resolves
// the plan-specific body fragment (mu may auto-pick a template from the
// deployable-models catalog). Anything outside the strategy table was
// already rejected by `validate` above.
const strategy = pickPlanStrategy(plan);
const resolved = await strategy.resolve({
client: ctx.client,
dryRun: settings.dryRun,
binName: identity.binName,
flags: flags as CreatePlanFlags,
model,
name,
});
const body: Record<string, unknown> = {
model_name: model,
name,
plan,
...resolved.body,
};
if (settings.dryRun) {
emitResult({ action: "deploy.create", body }, format);
return;
}
const response = await createDeployment(ctx.client, body as CreateDeploymentRequest);
const deployment = response.output ?? response.data;
if (settings.quiet) {
emitBare(deployment?.deployed_model ?? "");
} else if (format === "text") {
emitBare(`Created deployment.`);
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
if (deployment?.status) emitBare(` status: ${deployment.status}`);
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
emitBare(
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
);
} else {
emitResult(response, format);
}
}
/** `bl deploy text create` — deploy a text model. */
export const deployTextCreate = defineCommand({
description: "Create a text model deployment",
auth: "apiKey",
usageArgs:
"--model <model_name> --name <display_name> [--plan <plan>] [--template-id <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]",
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-qwen-sft --name my-sft-test",
"--model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
"--model qwen3-8b --name my-qwen3-mu --plan mu",
"--model qwen3-8b --name my-qwen3 --plan mu --template-id MU1 --capacity 2",
"--model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
],
notes: [
"Plan defaults to `lora` (Token-billed). Pass --plan to override.",
"For plan=ptu (Token-billed, provisioned throughput), --input-tpm and",
"--output-tpm are required (the platform rejects creation without an",
"explicit ptu_capacity despite the doc listing defaults).",
"For plan=mu, `capacity`, `billing_method` and `template_id` are required.",
"billing_method defaults to POST_PAY (only supported value); template_id",
"and capacity are auto-picked from GET /deployments/models when omitted.",
"Use `bl deploy models --source base` to inspect available templates.",
"After creation, status starts at PENDING and transitions to RUNNING.",
"Invoke the deployed model with: bl text chat --model <deployed_model>",
"WARNING: --model is overloaded across commands and refers to DIFFERENT",
"values. `bl deploy create --model` takes the exported model_name (e.g.",
"`qwen3-8b-ft-...`), but the create response also returns a `deployed_model`",
"field (the deployment instance id, e.g. `qwen3-8b-5ecb5f068d79`). The",
"inference call `bl text chat --model` must use the `deployed_model` from",
"the create response — NOT the `model_name` you passed to `deploy create`.",
"Do not reuse the value across the two commands.",
],
validate: (flags) => {
const plan = flags.plan || "lora";
const strategy = STRATEGIES[plan];
if (!strategy) {
return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`;
}
return strategy.validateFlags(flags);
},
async run(ctx) {
const { identity, settings, flags } = ctx;
const model = flags.model;
const name = flags.name;
const plan = flags.plan || "lora";
const format = detectOutputFormat(settings.output);
// Plan-specific behaviour is owned by `plans.ts`. The strategy resolves
// the plan-specific body fragment (mu may auto-pick a template from the
// deployable-models catalog). Anything outside the strategy table was
// already rejected by `validate` above.
const strategy = pickPlanStrategy(plan);
const resolved = await strategy.resolve({
client: ctx.client,
dryRun: settings.dryRun,
binName: identity.binName,
flags,
model,
name,
});
const body: Record<string, unknown> = {
model_name: model,
name,
plan,
...resolved.body,
};
if (settings.dryRun) {
emitResult({ action: "deploy.create", body }, format);
return;
}
const response = await createDeployment(ctx.client, body as CreateDeploymentRequest);
const deployment = response.output ?? response.data;
if (settings.quiet) {
emitBare(deployment?.deployed_model ?? "");
} else if (format === "text") {
emitBare(`Created deployment.`);
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
if (deployment?.status) emitBare(` status: ${deployment.status}`);
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
emitBare(
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
);
} else {
emitResult(response, format);
}
},
notes: CREATE_NOTES,
validate: (flags) => validateCreate("text", flags),
run: (ctx) => runCreate("text", ctx),
});
/** `bl deploy audio create` — deploy an audio (TTS) model. Defaults to plan=mu. */
export const deployAudioCreate = defineCommand({
description: "Create an audio (TTS) model deployment",
auth: "apiKey",
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-cosyvoice-ft --name my-tts",
"--model my-cosyvoice-ft --name my-tts --deploy-spec dps-xxxx --capacity 1",
"--model my-cosyvoice-ft --name my-tts --dry-run",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("audio", flags),
run: (ctx) => runCreate("audio", ctx),
});
/** `bl deploy image create` — deploy an image generation model. */
export const deployImageCreate = defineCommand({
description: "Create an image generation model deployment",
auth: "apiKey",
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-wan-ft --name my-wan",
"--model my-wan-ft --name my-wan-mu --plan mu",
"--model my-wan-ft --name my-wan --dry-run",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("image", flags),
run: (ctx) => runCreate("image", ctx),
});
@@ -59,8 +59,8 @@ export default defineCommand({
ExitCode.USAGE,
);
}
} catch (e) {
if (e instanceof BailianError) throw e;
} catch (error) {
if (error instanceof BailianError) throw error;
// If the get itself failed (e.g. not found), let the DELETE call surface the real error.
}
}
@@ -68,13 +68,13 @@ export default defineCommand({
return;
}
const headers = ["DEPLOYED_MODEL", "MODEL_NAME", "STATUS", "PLAN", "CAPACITY", "CREATED_AT"];
const rows = items.map((i) => [
i.deployed_model,
i.model_name,
i.status,
i.plan,
i.capacity,
i.created_at,
const rows = items.map((item) => [
item.deployed_model,
item.model_name,
item.status,
item.plan,
item.capacity,
item.created_at,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
+44 -39
View File
@@ -73,47 +73,47 @@ export default defineCommand({
// - custom (fine-tuned): top-level supported_plans: string[]
// - base (catalog): plans: [{plan, templates?, cu_specs?}]
// For json: surface the deployment-relevant fields preserved as a tree, so
// downstream tooling can drive `bl deploy create --template-id <…>` without
// a second round-trip. For text: keep the compact one-line summary.
// downstream tooling can drive `bl deploy <modality> create --deploy-spec <…>`
// without a second round-trip. For text: keep the compact one-line summary.
if (format === "json") {
const items = models.map((m) => {
const items = models.map((model) => {
const out: Record<string, unknown> = {
model_name: m.model_name ?? "",
model_name: model.model_name ?? "",
};
if (m.base_model) out.base_model = m.base_model;
if (m.model_source) out.model_source = m.model_source;
if (m.supported_plans && m.supported_plans.length > 0) {
out.supported_plans = m.supported_plans;
if (model.base_model) out.base_model = model.base_model;
if (model.model_source) out.model_source = model.model_source;
if (model.supported_plans && model.supported_plans.length > 0) {
out.supported_plans = model.supported_plans;
}
if (m.plans && m.plans.length > 0) {
out.plans = m.plans.map((p) => {
const planEntry: Record<string, unknown> = { plan: p.plan ?? "" };
if (p.cu_specs && p.cu_specs.length > 0) {
planEntry.cu_specs = p.cu_specs;
if (model.plans && model.plans.length > 0) {
out.plans = model.plans.map((plan) => {
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
if (plan.cu_specs && plan.cu_specs.length > 0) {
planEntry.cu_specs = plan.cu_specs;
}
if (p.templates && p.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy create`.
if (plan.templates && plan.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
// Drop noisy/redundant: template_source, template_type,
// template_version, deploy_spec (typically == template_id).
planEntry.templates = p.templates.map((t) => {
planEntry.templates = plan.templates.map((template) => {
const tpl: Record<string, unknown> = {};
if (t.template_id) tpl.template_id = t.template_id;
if (t.template_name) tpl.template_name = t.template_name;
if (t.charge_type) tpl.charge_type = t.charge_type;
if (template.template_id) tpl.template_id = template.template_id;
if (template.template_name) tpl.template_name = template.template_name;
if (template.charge_type) tpl.charge_type = template.charge_type;
// Flatten roles.unified for the common COUPLED case.
const unified = t.roles?.unified;
const unified = template.roles?.unified;
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
if (unified?.capacity_unit_per_instance !== undefined)
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
// Preserve split-role configs (SEPERATED) as-is so callers
// can still drive prefill/decode sizing.
if (t.roles?.prefill || t.roles?.decode) {
if (template.roles?.prefill || template.roles?.decode) {
tpl.roles = {
prefill: t.roles?.prefill,
decode: t.roles?.decode,
prefill: template.roles?.prefill,
decode: template.roles?.decode,
};
}
if (t.template_desc) tpl.template_desc = t.template_desc;
if (template.template_desc) tpl.template_desc = template.template_desc;
return tpl;
});
}
@@ -127,19 +127,19 @@ export default defineCommand({
}
// text / quiet — keep the compact single-line summary table.
const textItems = models.map((m) => {
const textItems = models.map((model) => {
let plansSummary = "";
if (m.supported_plans && m.supported_plans.length > 0) {
plansSummary = m.supported_plans.join(",");
} else if (m.plans && m.plans.length > 0) {
plansSummary = m.plans
.map((p) => {
const planName = p.plan ?? "?";
if (p.templates && p.templates.length > 0) {
return `${planName}(${p.templates.length}t)`;
if (model.supported_plans && model.supported_plans.length > 0) {
plansSummary = model.supported_plans.join(",");
} else if (model.plans && model.plans.length > 0) {
plansSummary = model.plans
.map((plan) => {
const planName = plan.plan ?? "?";
if (plan.templates && plan.templates.length > 0) {
return `${planName}(${plan.templates.length}t)`;
}
if (p.cu_specs && p.cu_specs.length > 0) {
return `${planName}(${p.cu_specs.join("/")})`;
if (plan.cu_specs && plan.cu_specs.length > 0) {
return `${planName}(${plan.cu_specs.join("/")})`;
}
return planName;
})
@@ -148,9 +148,9 @@ export default defineCommand({
plansSummary = "-";
}
return {
model_name: m.model_name ?? "",
base_model: m.base_model ?? "",
source: m.model_source ?? "",
model_name: model.model_name ?? "",
base_model: model.base_model ?? "",
source: model.model_source ?? "",
plans: plansSummary,
};
});
@@ -160,7 +160,12 @@ export default defineCommand({
return;
}
const headers = ["MODEL_NAME", "BASE_MODEL", "SOURCE", "PLANS"];
const rows = textItems.map((i) => [i.model_name, i.base_model, i.source, i.plans]);
const rows = textItems.map((item) => [
item.model_name,
item.base_model,
item.source,
item.plans,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
},
+442 -245
View File
@@ -4,12 +4,12 @@ import {
createFineTune,
getDataset,
uploadDataset,
validateDataset,
detectModality,
getProfile,
fetchModelCapability,
listSupportedTrainingTypes,
preflightBatchSizeGate,
isTrainingTypeCli,
toServerTrainingType,
TRAINING_TYPES_CLI,
DEFAULT_TRAINING_TYPE,
formatIssue,
@@ -17,10 +17,12 @@ import {
ExitCode,
type Client,
type Settings,
type CommandContext,
type CreateFineTuneRequest,
type FineTuneHyperParameters,
type DatasetFile,
type DatasetSchema,
type TrainingProfile,
type DataModality,
type FlagsDef,
} from "bailian-cli-core";
import { existsSync, statSync } from "fs";
@@ -77,7 +79,11 @@ async function analyzeDatasetTokens(
binName: string,
raw: string,
label: string,
schema?: DatasetSchema,
profile: TrainingProfile,
modality: DataModality,
model: string,
/** Pre-detected modality for a known path (avoids re-opening the file). */
knownModality?: { path: string; modality: DataModality },
): Promise<ResolvedDataset> {
const tokens = raw
.split(",")
@@ -109,11 +115,18 @@ async function analyzeDatasetTokens(
if (settings.dryRun) continue;
// Local path → validate (same checks as `dataset upload`). Upload is
// deferred to `uploadResolvedLocal` so the gate can run first. The schema
// (SFT vs DPO) is derived from --training-type so a DPO job validates the
// chosen/rejected preference pairs here, not on the platform.
const result = await validateDataset(token, { schema });
// The command's modality is authoritative; each local file is validated
// under that modality's schema. Reuse the caller's pre-detected modality
// when available to avoid opening the same file twice (matters for large
// ZIPs).
const tokenModality =
knownModality && knownModality.path === token ? knownModality.modality : modality;
// Local path → validate through the profile. The profile internally routes
// to the correct validator based on modality. Upload is deferred to
// `uploadResolvedLocal` so the gate can run first. `model` is forwarded for
// schema-agnostic cross-checks.
const result = await profile.validate(token, tokenModality, { model });
if (!result.valid) {
const lines = [
`Dataset validation failed for ${token}`,
@@ -194,25 +207,33 @@ async function uploadResolvedLocal(
return uploaded;
}
const CREATE_FLAGS = {
/** The modality a `finetune <modality> create` subcommand is bound to. */
type CommandModality = "text" | "audio" | "image";
/**
* Flags shared by every `finetune <modality> create` subcommand: what to train
* (model), what data to train on (datasets / validations), and how to name the
* output. Every modality's model consumes these.
*/
const COMMON_FLAGS = {
model: {
type: "string",
valueHint: "<model>",
description: "Base model to fine-tune (e.g. qwen3-8b, qwen3-14b)",
description: "Base model to fine-tune",
required: true,
},
datasets: {
type: "string",
valueHint: "<ids|paths>",
description:
"Comma-separated dataset file IDs or local .jsonl paths. Local paths are uploaded (validated) first, then their file-ids are used.",
"Comma-separated dataset file IDs or local paths (.jsonl for text, .zip for audio/image). Local paths are uploaded (validated) first, then their file-ids are used.",
required: true,
},
validations: {
type: "string",
valueHint: "<ids|paths>",
description:
"Comma-separated validation dataset file IDs or local .jsonl paths (auto-uploaded like --datasets).",
"Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets).",
},
modelName: {
type: "string",
@@ -224,6 +245,16 @@ const CREATE_FLAGS = {
valueHint: "<text>",
description: "Output suffix appended by the platform (finetuned_output_suffix)",
},
} satisfies FlagsDef;
/**
* Text flags: text models consume the full hyper-parameter surface — training
* type selection plus n_epochs / batch_size / learning_rate / max_length (see
* resolveTextHyperParameters). Only text exposes --training-type because only
* text models support types other than the sft-lora default.
*/
const TEXT_FLAGS = {
...COMMON_FLAGS,
trainingType: {
type: "string",
valueHint: "<t>",
@@ -252,12 +283,368 @@ const CREATE_FLAGS = {
},
} satisfies FlagsDef;
export default defineCommand({
description: "Create a fine-tune job (sft | sft-lora | dpo | dpo-lora | cpt)",
/**
* Audio (CosyVoice TTS) flags: the audio model runs sft-lora with a fully fixed
* hyper-parameter set (AUDIO_HYPER_PARAMS). No --training-type or hyper-parameter
* flag is honored by resolveHyperParameters, so none are exposed.
*/
const AUDIO_FLAGS = {
...COMMON_FLAGS,
} satisfies FlagsDef;
/**
* Image (Wan generation) flags: the image model runs sft-lora with fixed
* defaults; resolveHyperParameters only honors learning_rate, so --learning-rate
* is the sole extra numeric knob. --generation-type declares T2I vs I2I
* explicitly (the platform expects generation_type as a request field); it is
* required to reach I2I from a bare file-id or in --dry-run, where the data
* cannot be inspected.
*/
const IMAGE_FLAGS = {
...COMMON_FLAGS,
generationType: {
type: "string",
choices: ["t2i", "i2i"] as const,
valueHint: "<t2i|i2i>",
description:
"Generation type: t2i (default) | i2i. Sets generation_type/max_pixels. Required to train I2I from a file-id or with --dry-run (local data auto-detects input_img).",
},
learningRate: {
type: "string",
valueHint: "<str>",
description: 'Learning rate as a string to preserve precision (e.g. "3e-5")',
},
} satisfies FlagsDef;
const TEXT_USAGE =
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]";
const AUDIO_USAGE =
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
const IMAGE_USAGE =
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
const COMMON_NOTES = [
"Creating a job uploads any local datasets and consumes training quota.",
"Use --dry-run to preview the request body without submitting.",
"--datasets / --validations accept either file-ids (from `dataset upload`)",
"or local paths. Local paths are validated and uploaded first, then their",
"file-ids are submitted — a one-step upload-and-train.",
];
const TEXT_NOTES = [
...COMMON_NOTES,
"Training-type values use the `<method>` / `<method>-lora` convention:",
"sft (full) | sft-lora (LoRA) | dpo (full) | dpo-lora (LoRA) | cpt. These map",
"to the server's training_type at the interface boundary, so the rest of the",
"CLI never sees the raw server strings.",
"Before submitting (non dry-run) the job, the model's training capability is",
"checked via listFoundationModels (no console login required); an unsupported",
"training type fails fast with the list the model actually supports.",
"n_epochs defaults to 3. Other hyper-parameters are platform defaults unless set.",
"Learning rate is forwarded as a string to avoid JSON-number precision loss.",
"Pre-submit gate: if the training dataset's sample count is not greater",
"than batch_size, the job is rejected before upload or quota consumption",
"(the platform would otherwise fail ~10 min in, after data processing).",
];
const AUDIO_NOTES = [
...COMMON_NOTES,
"Audio TTS training runs sft-lora (efficient_sft) with fixed CosyVoice",
"hyper-parameter defaults; there are no training-type or hyper-parameter",
"knobs to set.",
];
const IMAGE_NOTES = [
...COMMON_NOTES,
"Image generation training runs sft-lora (efficient_sft) with fixed defaults;",
"only --learning-rate is overridable. T2I vs I2I is declared with",
"--generation-type (default t2i), which sets generation_type/max_pixels. For",
"local data the type is auto-detected (records with input_img train I2I);",
"pass --generation-type explicitly to train I2I from a file-id or in --dry-run.",
];
/**
* Shared `finetune <modality> create` implementation. The parameter surface and
* run logic are identical to the previous single `finetune create`; the ONLY
* change is that the data modality is fixed by the subcommand instead of being
* detected from data content. This is what lets file-id datasets (which have no
* local file to inspect) train the correct model — the old command silently
* defaulted a file-id to "text".
*
* Image is the only modality with a sub-variant (T2I vs I2I). It is upgraded to
* `image-i2i` only when a local file's first record carries `input_img`; a bare
* file-id defaults to plain "image" (T2I), matching the old detection fallback.
*/
async function runCreate<F extends FlagsDef>(
commandModality: CommandModality,
ctx: CommandContext<F>,
): Promise<void> {
const { identity, settings } = ctx;
const flags = ctx.flags as Record<string, unknown>;
const model = flags.model as string;
const datasetsRaw = flags.datasets as string;
// CosyVoice audio fine-tuning accepts exactly one training file
// (`training_file_ids` supports a single ID per the speech-synthesis
// contract). Reject a multi-token --datasets up-front so the job isn't
// rejected server-side after an upload.
if (commandModality === "audio") {
const audioTokens = datasetsRaw
.split(",")
.map((token) => token.trim())
.filter(Boolean);
if (audioTokens.length > 1) {
throw new BailianError(
`Audio (TTS) fine-tuning accepts exactly one training file, got ${audioTokens.length}.`,
ExitCode.USAGE,
"Merge your recordings into a single .zip (or pass one file-id).",
);
}
}
// Resolve the training type before analyzing datasets so the validator can
// enforce the right record schema (DPO jobs require chosen/rejected on
// every record). Whitelist is the single source of truth in core
// (TRAINING_TYPES_CLI); any other value is rejected up-front.
const trainingType = (flags.trainingType as string | undefined) || DEFAULT_TRAINING_TYPE;
if (!isTrainingTypeCli(trainingType)) {
throw new BailianError(
`--training-type "${trainingType}" is not supported.`,
ExitCode.USAGE,
`Supported values: ${TRAINING_TYPES_CLI.join(", ")} (default: ${DEFAULT_TRAINING_TYPE}).`,
);
}
// Profile: single source of truth for how this training type behaves
// (validation rules, hyper-parameters, gates, capability check).
const profile = getProfile(trainingType);
// Modality is fixed by the subcommand (no content-based detection) — this is
// the sole behavioural change of the modality split. Image alone has a T2I/I2I
// sub-variant: an explicit --generation-type is authoritative (the only way to
// reach I2I from a bare file-id or in --dry-run, where data can't be
// inspected); otherwise a local file is probed to upgrade T2I → I2I, and a
// bare file-id stays "image" (T2I).
const firstLocalPath = datasetsRaw
.split(",")
.map((token) => token.trim())
.find((token) => isLocalPath(token));
let modality: DataModality = commandModality;
if (commandModality === "image") {
const generationType = flags.generationType as "t2i" | "i2i" | undefined;
if (generationType === "i2i") {
modality = "image-i2i";
} else if (!generationType && firstLocalPath && !settings.dryRun) {
const detected = await detectModality(firstLocalPath);
if (detected === "image-i2i") modality = "image-i2i";
}
}
const training = await analyzeDatasetTokens(
settings,
identity.binName,
datasetsRaw,
"datasets",
profile,
modality,
model,
firstLocalPath ? { path: firstLocalPath, modality } : undefined,
);
const trainingFileIds = training.fileIds;
const validation = flags.validations
? await analyzeDatasetTokens(
settings,
identity.binName,
flags.validations as string,
"validations",
profile,
modality,
model,
)
: undefined;
const validationFileIds = validation?.fileIds;
const modelName = flags.modelName as string | undefined;
const suffix = flags.suffix as string | undefined;
// Hyper-parameters: the profile resolves modality-specific defaults
// (text: n_epochs/batch_size/learning_rate; audio: lm_max_epoch/fm_max_epoch/...).
const hp = profile.resolveHyperParameters(
modality,
flags as Record<string, unknown>,
) as FineTuneHyperParameters;
// Restore the batch-size clamping warning that was lost when the logic moved
// into profiles. The profile silently clamps to [8, 1024]; surface it here
// so the user has an audit trail. Skip modalities that bypass the batch_size
// gate (image): their batch_size is a fixed model-family default, not a
// clamp of the user's value, so the [8, 1024] "clamped" message would be
// self-contradictory and misleading.
if (
flags.batchSize !== undefined &&
hp.batch_size !== undefined &&
!settings.quiet &&
!profile.shouldSkipGate("batch_size", modality)
) {
const requested = flags.batchSize as number;
if (hp.batch_size !== requested) {
process.stderr.write(
`warning: --batch-size ${requested} clamped to ${hp.batch_size} ` +
`(server range [8, 1024] for the common training types).\n`,
);
}
}
// For modalities that skip the batch_size gate, warn the user that their
// explicit --batch-size was discarded (model uses a fixed batch_size).
if (
flags.batchSize !== undefined &&
!settings.quiet &&
profile.shouldSkipGate("batch_size", modality)
) {
const requested = flags.batchSize as number;
if (hp.batch_size !== undefined && hp.batch_size !== requested) {
process.stderr.write(
`warning: --batch-size ${requested} ignored for ${modality} training ` +
`(model uses a fixed batch_size of ${hp.batch_size}).\n`,
);
}
}
// Auto batch_size for small datasets — only for text data. Audio/image
// profiles already set their own batch parameters.
if (modality === "text" && hp.batch_size === undefined && !settings.dryRun) {
let sizeBytes = training.firstSize ?? 0;
if (sizeBytes === 0) {
try {
const fileInfo = await getDataset(ctx.client, trainingFileIds[0]);
sizeBytes = fileInfo.data?.size ?? 0;
} catch {
// If we can't fetch file info, skip auto-adjustment; platform will use default.
}
}
if (sizeBytes > 0 && sizeBytes < 100 * 1024) {
hp.batch_size = 8;
}
}
// Pre-submit batch-size gate: the platform rejects a job whose number of
// training samples is not greater than batch_size, but only surfaces that
// ~10 minutes into the run (after data processing). Fail fast here, before
// burning quota. `recordCount` is only known when every --datasets token
// was a local file we validated; file-id tokens fall through to the
// platform rather than risk a false positive from an undercount.
if (
!settings.dryRun &&
training.recordCount !== undefined &&
!profile.shouldSkipGate("batch_size", modality)
) {
// 16 is the platform default when neither the user nor the small-file
// auto-adjust set a batch_size (see the auto-adjust comment above).
const effectiveBatchSize = hp.batch_size ?? 16;
const gate = preflightBatchSizeGate({
recordCount: training.recordCount,
batchSize: effectiveBatchSize,
});
if (!gate.ok && gate.issue) {
throw new BailianError(gate.issue.message, ExitCode.GENERAL, gate.hint);
}
}
// Pre-flight capability check: confirm the model actually supports the
// requested training type BEFORE any upload, so a wrong --model /
// --training-type combo doesn't burn storage on datasets that will never
// be trained against. listFoundationModels is a public API (no console
// login required); on lookup failure (network / 401 / etc.) we fall back
// to letting the server decide rather than blocking the submit.
if (!settings.dryRun && !profile.shouldSkipCapabilityCheck(modality)) {
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
try {
capability = await fetchModelCapability(settings, model);
} catch (error) {
if (!settings.quiet) {
process.stderr.write(
`warning: model capability lookup failed (${(error as Error).message}); ` +
"proceeding without local pre-flight.\n",
);
}
}
if (capability && !listSupportedTrainingTypes(capability).includes(trainingType)) {
const supported = listSupportedTrainingTypes(capability);
throw new BailianError(
`Model "${model}" does not support training type "${trainingType}".`,
ExitCode.USAGE,
supported.length
? `This model supports: ${supported.join(", ")}.`
: "This model reports no supported training types.",
);
}
}
// Upload local paths now that pre-flight (validation, batch-size gate,
// capability check) has cleared them. This swaps the placeholder path
// entries in `training.fileIds` / `validation?.fileIds` for real file-ids.
if (!settings.dryRun) {
await uploadResolvedLocal(ctx.client, settings, training, "fine-tune", "datasets");
if (validation) {
await uploadResolvedLocal(ctx.client, settings, validation, "fine-tune", "validations");
}
}
const body: CreateFineTuneRequest = {
model,
training_file_ids: trainingFileIds,
// Profile maps the CLI training type to the server value at the boundary.
training_type: profile.serverTrainingType,
hyper_parameters: hp,
};
if (validationFileIds && validationFileIds.length > 0) {
body.validation_file_ids = validationFileIds;
}
if (modelName) body.model_name = modelName;
if (suffix) body.finetuned_output_suffix = suffix;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const pending = [
...training.localPaths.map((path) => ({ field: "datasets", path })),
...(validation?.localPaths ?? []).map((path) => ({ field: "validations", path })),
];
emitResult(
pending.length > 0
? { action: "finetune.create", body, pending_uploads: pending }
: { action: "finetune.create", body },
format,
);
return;
}
const response = await createFineTune(ctx.client, body);
const job = response.output ?? response.data;
if (settings.quiet) {
if (job?.job_id) emitBare(job.job_id);
} else if (format === "text") {
if (job?.job_id) {
emitBare(`Created fine-tune job: ${job.job_id}`);
if (job.status) emitBare(`Status: ${job.status}`);
} else {
emitResult(response, format);
}
} else {
emitResult(response, format);
}
}
/** `bl finetune text create` — fine-tune a text model. Datasets are `.jsonl`. */
export const finetuneTextCreate = defineCommand({
description: "Create a text model fine-tune job (sft | sft-lora | dpo | dpo-lora | cpt)",
auth: "apiKey",
usageArgs:
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]",
flags: CREATE_FLAGS,
usageArgs: TEXT_USAGE,
flags: TEXT_FLAGS,
exampleArgs: [
"--model qwen3-8b --datasets file-xxx",
"--model qwen3-8b --datasets ./train.jsonl",
@@ -268,231 +655,41 @@ export default defineCommand({
"--model qwen3-8b --datasets file-xxx --output json",
"--model qwen3-8b --datasets file-xxx --dry-run",
],
notes: [
"Creating a job uploads any local datasets and consumes training quota.",
"Use --dry-run to preview the request body without submitting.",
"Training-type values use the `<method>` / `<method>-lora` convention:",
"sft (full) | sft-lora (LoRA) | dpo (full) | dpo-lora (LoRA) | cpt. These map",
"to the server's training_type at the interface boundary, so the rest of the",
"CLI never sees the raw server strings.",
"Before submitting (non dry-run) the job, the model's training capability is",
"checked via listFoundationModels (no console login required); an unsupported",
"training type fails fast with the list the model actually supports.",
"n_epochs defaults to 3. Other hyper-parameters are platform defaults unless set.",
"Learning rate is forwarded as a string to avoid JSON-number precision loss.",
"--datasets / --validations accept either file-ids (from `dataset upload`)",
"or local .jsonl paths. Local paths are validated and uploaded first, then",
"their file-ids are submitted — a one-step upload-and-train.",
"Dataset record schema is chosen from --training-type: dpo* → {messages,",
"chosen, rejected}; cpt → {text} (raw pre-training text); else {messages}.",
"Pre-submit gate: if the training dataset's sample count is not greater",
"than batch_size, the job is rejected before upload or quota consumption",
"(the platform would otherwise fail ~10 min in, after data processing).",
],
async run(ctx) {
const { identity, settings, flags } = ctx;
const model = flags.model;
const datasetsRaw = flags.datasets;
// Resolve the training type before analyzing datasets so the validator can
// enforce the right record schema (DPO jobs require chosen/rejected on
// every record). Whitelist is the single source of truth in core
// (TRAINING_TYPES_CLI); any other value is rejected up-front.
const trainingType = flags.trainingType || DEFAULT_TRAINING_TYPE;
if (!isTrainingTypeCli(trainingType)) {
throw new BailianError(
`--training-type "${trainingType}" is not supported.`,
ExitCode.USAGE,
`Supported values: ${TRAINING_TYPES_CLI.join(", ")} (default: ${DEFAULT_TRAINING_TYPE}).`,
);
}
// dpo / dpo-lora → "dpo" schema (strict chosen/rejected); cpt → "cpt"
// (raw {text} records); else ChatML ({messages}).
const datasetSchema: DatasetSchema = trainingType.startsWith("dpo")
? "dpo"
: trainingType === "cpt"
? "cpt"
: "chatml";
const training = await analyzeDatasetTokens(
settings,
identity.binName,
datasetsRaw,
"datasets",
datasetSchema,
);
const trainingFileIds = training.fileIds;
const validation = flags.validations
? await analyzeDatasetTokens(
settings,
identity.binName,
flags.validations,
"validations",
datasetSchema,
)
: undefined;
const validationFileIds = validation?.fileIds;
const modelName = flags.modelName;
const suffix = flags.suffix;
// Hyper-parameters: inject n_epochs=3 default unless overridden.
const hp: FineTuneHyperParameters = {};
hp.n_epochs = flags.nEpochs ?? 3;
if (flags.learningRate !== undefined) hp.learning_rate = flags.learningRate;
if (flags.maxLength !== undefined) hp.max_length = flags.maxLength;
// batch_size: clamp to [8, 1024] (server hard constraint, undocumented).
// Surface the clamp on stderr instead of silently rewriting the user's
// value — otherwise the submitted body would carry a number the user never
// typed, with no audit trail. (Range observed on common SFT / SFT-LoRA
// training types; some bases like qwen3.6-flash report a wider range, so
// the warning explicitly mentions "server range".)
if (flags.batchSize !== undefined) {
const requested = flags.batchSize;
let batchSize = requested;
if (batchSize < 8) batchSize = 8;
if (batchSize > 1024) batchSize = 1024;
if (batchSize !== requested && !settings.quiet) {
process.stderr.write(
`warning: --batch-size ${requested} clamped to ${batchSize} ` +
`(server range [8, 1024] for the common training types).\n`,
);
}
hp.batch_size = batchSize;
}
// Auto batch_size for small datasets: fetch first training file size.
// With default split=0.9, validation_set = 0.1 * rows.
// Platform default batch_size=16 needs rows > 160; batch_size=8 needs rows > 80.
// Files < 100KB are conservatively estimated to have < 200 rows.
// If the first file was just uploaded we already hold its size; otherwise
// fall back to getDataset.
if (hp.batch_size === undefined && !settings.dryRun) {
let sizeBytes = training.firstSize ?? 0;
if (sizeBytes === 0) {
try {
const fileInfo = await getDataset(ctx.client, trainingFileIds[0]);
sizeBytes = fileInfo.data?.size ?? 0;
} catch {
// If we can't fetch file info, skip auto-adjustment; platform will use default.
}
}
if (sizeBytes > 0 && sizeBytes < 100 * 1024) {
hp.batch_size = 8;
}
}
// Pre-submit batch-size gate: the platform rejects a job whose number of
// training samples is not greater than batch_size, but only surfaces that
// ~10 minutes into the run (after data processing). Fail fast here, before
// burning quota. `recordCount` is only known when every --datasets token
// was a local file we validated; file-id tokens fall through to the
// platform rather than risk a false positive from an undercount.
//
// The decision lives in core (`preflightBatchSizeGate`) — a structured,
// job-level pre-flight that returns a `ValidationIssue` (same shape / stable
// code as `validateDataset`) so the failure surfaces through the same
// `BailianError` + issue convention used by `dataset upload`/`validate`.
// ExitCode.GENERAL matches the existing validation-failed exit code.
if (!settings.dryRun && training.recordCount !== undefined) {
// 16 is the platform default when neither the user nor the small-file
// auto-adjust set a batch_size (see the auto-adjust comment above).
const effectiveBatchSize = hp.batch_size ?? 16;
const gate = preflightBatchSizeGate({
recordCount: training.recordCount,
batchSize: effectiveBatchSize,
});
if (!gate.ok && gate.issue) {
throw new BailianError(gate.issue.message, ExitCode.GENERAL, gate.hint);
}
}
// Pre-flight capability check: confirm the model actually supports the
// requested training type BEFORE any upload, so a wrong --model /
// --training-type combo doesn't burn storage on datasets that will never
// be trained against. listFoundationModels is a public API (no console
// login required); on lookup failure (network / 401 / etc.) we fall back
// to letting the server decide rather than blocking the submit.
if (!settings.dryRun) {
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
try {
capability = await fetchModelCapability(settings, model);
} catch (error) {
if (!settings.quiet) {
process.stderr.write(
`warning: model capability lookup failed (${(error as Error).message}); ` +
"proceeding without local pre-flight.\n",
);
}
}
if (capability && !listSupportedTrainingTypes(capability).includes(trainingType)) {
const supported = listSupportedTrainingTypes(capability);
throw new BailianError(
`Model "${model}" does not support training type "${trainingType}".`,
ExitCode.USAGE,
supported.length
? `This model supports: ${supported.join(", ")}.`
: "This model reports no supported training types.",
);
}
}
// Upload local paths now that pre-flight (validation, batch-size gate,
// capability check) has cleared them. This swaps the
// placeholder path entries in `training.fileIds` / `validation?.fileIds`
// for real file-ids, so the body below sees ids.
if (!settings.dryRun) {
await uploadResolvedLocal(ctx.client, settings, training, "fine-tune", "datasets");
if (validation) {
await uploadResolvedLocal(ctx.client, settings, validation, "fine-tune", "validations");
}
}
const body: CreateFineTuneRequest = {
model,
training_file_ids: trainingFileIds,
// Map the CLI training type to the server value at the interface boundary.
training_type: toServerTrainingType(trainingType),
hyper_parameters: hp,
};
if (validationFileIds && validationFileIds.length > 0) {
body.validation_file_ids = validationFileIds;
}
if (modelName) body.model_name = modelName;
if (suffix) body.finetuned_output_suffix = suffix;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const pending = [
...training.localPaths.map((path) => ({ field: "datasets", path })),
...(validation?.localPaths ?? []).map((path) => ({ field: "validations", path })),
];
emitResult(
pending.length > 0
? { action: "finetune.create", body, pending_uploads: pending }
: { action: "finetune.create", body },
format,
);
return;
}
const response = await createFineTune(ctx.client, body);
const job = response.output ?? response.data;
if (settings.quiet) {
if (job?.job_id) emitBare(job.job_id);
} else if (format === "text") {
if (job?.job_id) {
emitBare(`Created fine-tune job: ${job.job_id}`);
if (job.status) emitBare(`Status: ${job.status}`);
} else {
emitResult(response, format);
}
} else {
emitResult(response, format);
}
},
notes: TEXT_NOTES,
run: (ctx) => runCreate("text", ctx),
});
/** `bl finetune audio create` — fine-tune an audio TTS model. Datasets are `.zip`. */
export const finetuneAudioCreate = defineCommand({
description: "Create an audio TTS model fine-tune job (sft-lora)",
auth: "apiKey",
usageArgs: AUDIO_USAGE,
flags: AUDIO_FLAGS,
exampleArgs: [
"--model cosyvoice-v3-flash --datasets ./audio.zip",
"--model cosyvoice-v3-flash --datasets file-xxx",
"--model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
"--model cosyvoice-v3-flash --datasets file-xxx --output json",
"--model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
],
notes: AUDIO_NOTES,
run: (ctx) => runCreate("audio", ctx),
});
/** `bl finetune image create` — fine-tune an image generation model. Datasets are `.zip`. */
export const finetuneImageCreate = defineCommand({
description: "Create an image generation model fine-tune job (sft-lora)",
auth: "apiKey",
usageArgs: IMAGE_USAGE,
flags: IMAGE_FLAGS,
exampleArgs: [
"--model wan2.7-image-pro --datasets ./images.zip",
"--model wan2.7-image-pro --datasets file-xxx",
"--model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
"--model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
"--model wan2.7-image-pro --datasets file-xxx --output json",
"--model wan2.7-image-pro --datasets ./images.zip --dry-run",
],
notes: IMAGE_NOTES,
run: (ctx) => runCreate("image", ctx),
});
@@ -34,9 +34,9 @@ export default defineCommand({
flags: EXPORT_FLAGS,
exampleArgs: ["--job-id ft-xxx --checkpoint ckpt-3 --model-name my-qwen-sft"],
notes: [
"Required before `deploy create` can target a checkpoint. The platform",
"may auto-export the best checkpoint when a job reaches SUCCEEDED — explicit",
"export is the canonical path for non-best checkpoints.",
"Required before `deploy <modality> create` can target a checkpoint. The",
"platform may auto-export the best checkpoint when a job reaches SUCCEEDED —",
"explicit export is the canonical path for non-best checkpoints.",
],
async run(ctx) {
const { identity, settings, flags } = ctx;
@@ -66,7 +66,9 @@ export default defineCommand({
emitBare(exported);
} else if (format === "text") {
emitBare(`Exported ${jobId} / ${checkpoint} → model_name=${exported}`);
emitBare(`Next: ${identity.binName} deploy create --model ${exported} --name <display-name>`);
emitBare(
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
);
} else {
emitResult(response, format);
}
@@ -71,7 +71,7 @@ export default defineCommand({
if (item.hyper_params) emitBare(`hyper_params: ${item.hyper_params}`);
if (item.output_model)
emitBare(
`output_model: ${item.output_model} (→ ${identity.binName} deploy create --model)`,
`output_model: ${item.output_model} (→ ${identity.binName} deploy text create --model)`,
);
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
@@ -75,6 +75,8 @@ export default defineCommand({
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitBare(`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy create --model\``);
emitBare(
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
);
},
});
@@ -1,15 +1,16 @@
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
import {
defineCommand,
detectOutputFormat,
getFineTune,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DEFAULT_INTERVAL_SEC = 10;
const MIN_INTERVAL_SEC = 1;
const TERMINAL_STATUSES = new Set(["SUCCEEDED", "FAILED", "CANCELED"]);
/** SIGINT exit code (128 + signal 2). */
const EXIT_INTERRUPTED = 130;
const EXIT_FAILED = 1;
const EXIT_TIMEOUT = 2;
/** Non-terminal status: the job is still running. Distinct from failure. */
const EXIT_RUNNING = 3;
function nowStamp(): string {
const date = new Date();
@@ -25,18 +26,6 @@ function formatElapsed(milliseconds: number): string {
return `${minutes}m ${seconds}s`;
}
/**
* Exit code for a status value:
* SUCCEEDED -> 0
* FAILED / CANCELED -> 1
* anything else -> 3 (still running)
*/
function exitCodeForStatus(status: string): number {
if (status === "SUCCEEDED") return 0;
if (TERMINAL_STATUSES.has(status)) return EXIT_FAILED;
return EXIT_RUNNING;
}
/**
* Resolve after `milliseconds`, rejecting early if `signal` aborts (Ctrl-C).
* Cleans up its timer + listener so nothing leaks between polls.
@@ -101,9 +90,9 @@ export default defineCommand({
"Default (no --follow) is a NON-BLOCKING single status probe: one fetch, then",
"return immediately. This is the mode meant for agents / scripts — the caller",
"owns the polling cadence, so the CLI never holds the terminal.",
"Exit codes (both modes): 0 SUCCEEDED | 1 FAILED/CANCELED | 2 --poll-timeout",
"exceeded (--follow) | 3 still running (non-terminal, default mode) | 130",
"interrupted (Ctrl-C).",
"A terminal FAILED/CANCELED status raises a normal CLI error (non-zero exit);",
"a SUCCEEDED or still-running status returns 0. With --follow, exceeding",
"--poll-timeout raises a timeout error.",
"Use --follow for the blocking, human-terminal-follow experience; use the",
"default mode when driving the loop yourself (e.g. from an agent).",
"For per-step training output (not status), use `finetune logs`.",
@@ -130,32 +119,34 @@ export default defineCommand({
return;
}
// Exit codes here are a public probe contract (0 succeeded / 1 failed / 2
// timeout / 3 still running / 130 interrupted) — deliberately routed via
// process.exit instead of the central error handler.
// ---- Default: non-blocking single status probe -------------------------
// A terminal FAILED/CANCELED status is surfaced as a BailianError (the
// central handler prints it and exits non-zero); SUCCEEDED and still-running
// both return normally. No process.exit / custom exit-code contract.
if (!follow) {
const response = await getFineTune(ctx.client, jobId);
const job = response.output ?? response.data;
const status = String(job?.status ?? "").toUpperCase();
const terminal = TERMINAL_STATUSES.has(status);
const code = exitCodeForStatus(status);
if (settings.quiet) {
// Just the status word — ideal for `status=$(... finetune watch ... --quiet)`.
emitBare(status || "UNKNOWN");
} else if (format === "text") {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
if (terminal) {
const mark = status === "SUCCEEDED" ? "✓" : "✗";
emitBare(`${mark} ${jobId} ${status}`);
}
if (status === "SUCCEEDED") emitBare(`✓ ${jobId} ${status}`);
} else {
// json: a compact, purpose-built status probe.
emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format);
}
process.exit(code);
if (terminal && status !== "SUCCEEDED") {
throw new BailianError(
`Fine-tune job ${jobId} ended in status ${status}.`,
ExitCode.GENERAL,
);
}
return;
}
// ---- --follow: blocking poll loop (legacy behavior) -------------------
@@ -182,28 +173,35 @@ export default defineCommand({
const elapsed = Date.now() - startedAt;
if (format !== "text" || settings.quiet) {
emitResult(response, format);
} else {
const mark = status === "SUCCEEDED" ? "✓" : "✗";
emitBare(`\n${mark} ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
} else if (status === "SUCCEEDED") {
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
}
process.exit(exitCodeForStatus(status));
if (status !== "SUCCEEDED") {
throw new BailianError(
`Fine-tune job ${jobId} ended in status ${status} (elapsed ${formatElapsed(elapsed)}).`,
ExitCode.GENERAL,
);
}
return;
}
if (pollTimeoutSec !== undefined && (Date.now() - startedAt) / 1000 >= pollTimeoutSec) {
if (format === "text" && !settings.quiet) {
emitBare(
`\n⏼ ${jobId} timed out after ${formatElapsed(Date.now() - startedAt)} (last status: ${status || "UNKNOWN"})`,
);
}
process.exit(EXIT_TIMEOUT);
throw new BailianError(
`Watching fine-tune job ${jobId} timed out after ` +
`${formatElapsed(Date.now() - startedAt)} (last status: ${status || "UNKNOWN"}).`,
ExitCode.TIMEOUT,
);
}
await sleep(intervalSec * 1000, controller.signal);
}
} catch (error) {
// Ctrl-C aborts the poll loop: report and return normally (no custom code).
// Any other error (including the BailianError thrown above) propagates to
// the central handler.
if (controller.signal.aborted) {
emitBare("\nInterrupted.");
process.exit(EXIT_INTERRUPTED);
return;
}
throw error;
} finally {
+10 -2
View File
@@ -56,7 +56,11 @@ export { default as datasetList } from "./commands/dataset/list.ts";
export { default as datasetGet } from "./commands/dataset/get.ts";
export { default as datasetDelete } from "./commands/dataset/delete.ts";
export { default as datasetValidate } from "./commands/dataset/validate.ts";
export { default as finetuneCreate } from "./commands/finetune/create.ts";
export {
finetuneTextCreate,
finetuneAudioCreate,
finetuneImageCreate,
} from "./commands/finetune/create.ts";
export { default as finetuneList } from "./commands/finetune/list.ts";
export { default as finetuneGet } from "./commands/finetune/get.ts";
export { default as finetuneCancel } from "./commands/finetune/cancel.ts";
@@ -66,7 +70,11 @@ export { default as finetuneCheckpoints } from "./commands/finetune/checkpoints.
export { default as finetuneExport } from "./commands/finetune/export.ts";
export { default as finetuneWatch } from "./commands/finetune/watch.ts";
export { default as finetuneCapability } from "./commands/finetune/capability.ts";
export { default as deployCreate } from "./commands/deploy/create.ts";
export {
deployTextCreate,
deployAudioCreate,
deployImageCreate,
} from "./commands/deploy/create.ts";
export { default as deployList } from "./commands/deploy/list.ts";
export { default as deployGet } from "./commands/deploy/get.ts";
export { default as deployModels } from "./commands/deploy/models.ts";
@@ -1,15 +1,14 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { ADVISOR_ROUTES } from "./topic-routes.ts";
describe("e2e: advisor recommend", () => {
test("advisor shows subcommand groups and exits successfully", async () => {
const { stdout, stderr, exitCode } = await runCli(["advisor"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/advisor|recommend/i);
});
test("advisor recommend --help exits successfully", async () => {
const { stderr, exitCode } = await runCli(["advisor", "recommend", "--help"]);
const { stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/recommend|--message|dry-run/i);
});
@@ -17,13 +16,17 @@ describe("e2e: advisor recommend", () => {
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () => {
test("advisor recommend without --message errors as usage error (2)", async () => {
const { stdout, stderr, exitCode } = await runCli(["advisor", "recommend", "--quiet"]);
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/--message|Usage:/i);
});
test("advisor recommend --dry-run outputs intent analysis and candidates", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
@@ -63,7 +66,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
}, 60_000);
test("advisor recommend full flow returns results", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--message",
@@ -93,10 +96,10 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
expect(data.result?.recommendations?.[0]?.highlights?.length).toBeGreaterThan(0);
}, 120_000);
// ---- Model preference: positive cases ----
// ---- Mode coverage: all 4 modes ----
test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => {
const { stdout, stderr, exitCode } = await runCli([
test("mode: scoped — family-scoped query sets mode=scoped with targets", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
@@ -109,19 +112,20 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
const data = parseStdoutJson<{
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
}>(stdout);
// Model preference detection depends on LLM interpretation
// Accept either "scoped" or "unconstrained" as valid
const mode = data.intent?.modelPreference?.mode;
expect(mode === "scoped" || mode === "unconstrained" || mode === undefined).toBe(true);
const pref = data.intent?.modelPreference;
expect(pref?.mode).toBe("scoped");
expect(pref?.targets?.length).toBeGreaterThan(0);
// Should contain "deepseek" (case-insensitive substring)
expect(pref?.targets?.some((t) => t.toLowerCase().includes("deepseek"))).toBe(true);
}, 60_000);
test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => {
const { stdout, stderr, exitCode } = await runCli([
test("mode: comparison — comparing two models sets mode=comparison with both targets", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
"--message",
"Which is better for code generation, qwen-max or deepseek-v3?",
"Compare qwen-max and deepseek-v3 for legal contract review, high precision required",
"--output",
"json",
]);
@@ -129,19 +133,41 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
const data = parseStdoutJson<{
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
}>(stdout);
// Model preference detection depends on LLM interpretation
// Accept either "comparison" or "unconstrained" as valid
const mode = data.intent?.modelPreference?.mode;
expect(mode === "comparison" || mode === "unconstrained" || mode === undefined).toBe(true);
const pref = data.intent?.modelPreference;
expect(pref?.mode).toBe("comparison");
expect(pref?.targets?.length).toBeGreaterThanOrEqual(2);
const targetsLower = pref?.targets?.map((t) => t.toLowerCase()) ?? [];
expect(targetsLower.some((t) => t.includes("qwen"))).toBe(true);
expect(targetsLower.some((t) => t.includes("deepseek"))).toBe(true);
}, 60_000);
test("excludes preference — intent detects modelPreference when excluding models", async () => {
const { stdout, stderr, exitCode } = await runCli([
test("mode: alternative — reference model query sets mode=alternative with target", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
"--message",
"Not qwen, recommend a model suitable for text generation",
"Something like qwen-max but cheaper, for text summarization",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
}>(stdout);
const pref = data.intent?.modelPreference;
expect(pref?.mode).toBe("alternative");
expect(pref?.targets?.length).toBeGreaterThan(0);
expect(pref?.targets?.some((t) => t.toLowerCase().includes("qwen"))).toBe(true);
}, 60_000);
test("mode: excludes — excluding a family populates excludes array", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
"--message",
"Recommend a model for text generation, but not qwen",
"--output",
"json",
]);
@@ -154,19 +180,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
};
};
}>(stdout);
// Model preference detection depends on LLM interpretation
// If excludes is detected, verify it contains qwen; otherwise accept as valid
const pref = data.intent?.modelPreference;
if (pref?.excludes && pref.excludes.length > 0) {
expect(pref.excludes.some((e) => e.toLowerCase().includes("qwen"))).toBe(true);
}
// Test passes if exit code is 0, regardless of whether excludes was detected
expect(pref?.excludes?.length).toBeGreaterThan(0);
expect(pref?.excludes?.some((e) => e.toLowerCase().includes("qwen"))).toBe(true);
}, 60_000);
// ---- Model preference: negative cases ----
test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => {
const { stdout, stderr, exitCode } = await runCli([
test("mode: unconstrained — generic query has no modelPreference", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
@@ -1,48 +1,29 @@
import { readFileSync } from "fs";
import { join } from "path";
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, runCli } from "./helpers.ts";
import {
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { AUTH_ROUTES } from "./topic-routes.ts";
/**
* Auth 相关 E2E:只验证 CLI 进程能正常解析参数并退出。
*/
describe("e2e: auth", () => {
test("auth 分组展示子命令帮助且退出码为 0", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/auth|Authentication|login|logout|status/i);
});
test("auth login --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "login", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/login|api-key/i);
expect(stderr).toMatch(/--console-site/);
expect(stderr).toMatch(/--open-api/);
});
test("auth logout --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["auth", "logout", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/logout|dry-run|yes/i);
});
test("auth status --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["auth", "status", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/status|output/i);
});
test("auth login 缺少 --api-key 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/Choose exactly one login mode/);
});
test("auth login 一次只能选择一种登录模式", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--console",
@@ -54,11 +35,16 @@ describe("e2e: auth", () => {
});
test("auth login 模式专属参数不能脱离对应模式", async () => {
const openApiFlagOnly = await runCli(["auth", "login", "--access-key-id", "LTAI-e2e"]);
const openApiFlagOnly = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--access-key-id",
"LTAI-e2e",
]);
expect(openApiFlagOnly.exitCode).toBe(2);
expect(openApiFlagOnly.stderr).toMatch(/Use --open-api with --access-key-id/);
const baseUrlWithoutApiKey = await runCli([
const baseUrlWithoutApiKey = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--console",
@@ -68,7 +54,7 @@ describe("e2e: auth", () => {
expect(baseUrlWithoutApiKey.exitCode).toBe(2);
expect(baseUrlWithoutApiKey.stderr).toMatch(/Use --base-url only with --api-key/);
const consoleSiteWithoutConsole = await runCli([
const consoleSiteWithoutConsole = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--api-key",
@@ -81,7 +67,7 @@ describe("e2e: auth", () => {
});
test("auth login --open-api 要求 AK/SK 成对输入", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--open-api",
@@ -92,8 +78,26 @@ describe("e2e: auth", () => {
expect(stderr).toMatch(/Provide --access-key-id and --access-key-secret with --open-api/);
});
test("auth logout --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/logout|dry-run|yes/i);
});
test("auth status --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/status|output/i);
});
test("auth login 缺少 --api-key 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "login", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/Choose exactly one login mode/);
});
test("auth login --dry-run --api-key 不发起校验与落盘", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--dry-run",
@@ -105,7 +109,7 @@ describe("e2e: auth", () => {
});
test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--dry-run",
@@ -121,7 +125,12 @@ describe("e2e: auth", () => {
});
test("auth login 缺少密钥且 --output json 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--output", "json"]);
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--output",
"json",
]);
expect(exitCode).toBe(2);
const err = JSON.parse(stderr.trim()) as { error?: { code?: number; message?: string } };
expect(err.error?.code).toBe(2);
@@ -129,20 +138,29 @@ describe("e2e: auth", () => {
});
test("auth logout --dry-run 不写入配置", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run"]);
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"logout",
"--dry-run",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("No changes made.");
expect(stderr).not.toContain("Cleared api_key");
});
test("auth logout --dry-run --quiet", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run", "--quiet"]);
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"logout",
"--dry-run",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("No changes made.");
});
test("auth logout --dry-run --output json(不清除密钥)", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"logout",
"--dry-run",
@@ -155,7 +173,12 @@ describe("e2e: auth", () => {
});
test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "text"]);
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"status",
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(
/Authentication Status|API key:|Console token:|DashScope API:|Console gateway:/,
@@ -163,7 +186,12 @@ describe("e2e: auth", () => {
});
test.skipIf(!isDashScopeE2EReady())("auth status --output json", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "json"]);
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"status",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
@@ -176,7 +204,8 @@ describe("e2e: auth", () => {
test.skipIf(!isDashScopeE2EReady())(
"auth status --output json --quiet(base_url 经 env 指定;凭证域 flag 对 status 不可见)",
async () => {
const { stdout, stderr, exitCode } = await runCli(
const { stdout, stderr, exitCode } = await runCommandE2e(
AUTH_ROUTES,
["auth", "status", "--output", "json", "--quiet"],
{ DASHSCOPE_BASE_URL: "https://dashscope.aliyuncs.com" },
);
@@ -188,16 +217,25 @@ describe("e2e: auth", () => {
);
test("auth status 不接受凭证域覆盖 flag(--base-url 报 Unknown flag)", async () => {
const { stderr, exitCode } = await runCli(["auth", "status", "--base-url", "https://x.test"]);
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"status",
"--base-url",
"https://x.test",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--base-url/);
});
test("auth status 展示 env OpenAPI AK/SK 且不接受 OpenAPI flag 覆盖", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "json"], {
ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-e2e-placeholder",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-e2e-placeholder",
});
const { stdout, stderr, exitCode } = await runCommandE2e(
AUTH_ROUTES,
["auth", "status", "--output", "json"],
{
ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-e2e-placeholder",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-e2e-placeholder",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
@@ -208,7 +246,7 @@ describe("e2e: auth", () => {
expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-placeholder");
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-placeholder");
const denied = await runCli(["auth", "status", "--access-key-id", "ak"]);
const denied = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--access-key-id", "ak"]);
expect(denied.exitCode).not.toBe(0);
expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/);
});
@@ -221,7 +259,8 @@ describe("e2e: auth", () => {
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
};
const login = await runCli(
const login = await runCommandE2e(
AUTH_ROUTES,
[
"auth",
"login",
@@ -245,7 +284,7 @@ describe("e2e: auth", () => {
expect(config.openapi_access_key_id).toBeUndefined();
expect(config.openapi_access_key_secret).toBeUndefined();
const status = await runCli(["auth", "status", "--output", "json"], env);
const status = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
expect(status.exitCode, status.stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
@@ -256,11 +295,11 @@ describe("e2e: auth", () => {
expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-login-placeholder");
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-login-placeholder");
const logout = await runCli(["auth", "logout", "--open-api"], env);
const logout = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--open-api"], env);
expect(logout.exitCode, logout.stderr).toBe(0);
expect(logout.stderr).toMatch(/Cleared access_key_id/);
const after = await runCli(["auth", "status", "--output", "json"], env);
const after = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
expect(after.exitCode, after.stderr).toBe(0);
const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(after.stdout);
expect(afterData.openapi).toBeUndefined();
@@ -1,32 +1,31 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { CONFIG_ROUTES } from "./topic-routes.ts";
/**
* Config 相关 E2E
*/
describe("e2e: config", () => {
test("config 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["config"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/config|show|set/i);
});
test("config show --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["config", "show", "--help"]);
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "show", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/show|config/i);
});
test("config set --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["config", "set", "--help"]);
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/set|--key|--value/i);
});
test("config show --output json", async () => {
const { stdout, stderr, exitCode } = await runCli(["config", "show", "--output", "json"]);
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"show",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
config_file?: string;
@@ -39,19 +38,24 @@ describe("e2e: config", () => {
});
test("config show --output text", async () => {
const { stdout, stderr, exitCode } = await runCli(["config", "show", "--output", "text"]);
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"show",
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/config_file|timeout|base_url/i);
});
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["config", "set", "--quiet"]);
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/--key|--value|Usage:/i);
});
test("config set 非法 key 时退出为用法错误", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--key",
@@ -64,7 +68,7 @@ describe("e2e: config", () => {
});
test("config set 非法 output", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--key",
@@ -77,7 +81,7 @@ describe("e2e: config", () => {
});
test("config set 非法 timeout", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--key",
@@ -90,7 +94,7 @@ describe("e2e: config", () => {
});
test("config set --dry-run 不落盘(仅输出 would_set)", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
@@ -107,7 +111,7 @@ describe("e2e: config", () => {
});
test("config set --dry-run 支持连字符别名 key", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
@@ -124,7 +128,7 @@ describe("e2e: config", () => {
});
test("config set --dry-run 支持 AccessKey 短字段别名", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
@@ -141,7 +145,7 @@ describe("e2e: config", () => {
});
test("config set 不接受旧 OpenAPI AccessKey 字段名", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--key",
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { CONSOLE_FLAGS_DRY_RUN_ROUTES } from "./topic-routes.ts";
type ConsoleDryRunMeta = {
consoleRegion?: string;
@@ -7,55 +8,13 @@ type ConsoleDryRunMeta = {
consoleSwitchAgent?: number;
};
/**
* E2E for global console flags (`--console-region`, `--console-site`,
* `--console-switch-agent`) and DashScope `--base-url`.
*/
describe("e2e: console global flags", () => {
test("根帮助展示 --base-url 与 console 全局标志", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--base-url/);
expect(stderr).toMatch(/--console-region/);
expect(stderr).toMatch(/--console-site/);
expect(stderr).toMatch(/--console-switch-agent/);
expect(stderr).not.toMatch(/^\s*--region\s/m);
});
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Global Flags:/);
expect(stderr).toMatch(/--console-region <region>/);
expect(stderr).toMatch(/--model <model>/);
expect(stderr).toMatch(/--period <minutes>/);
expect(stderr).toMatch(/--output <format>/);
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
});
test("跨域 flag 拒绝:model 命令传 --console-region 报 Unknown flag", async () => {
const { stderr, exitCode } = await runCli([
"text",
"chat",
"--message",
"hi",
"--console-region",
"cn-hangzhou",
"--dry-run",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--console-region/);
});
test("跨域 flag 拒绝:console 命令传 --api-key 报 Unknown flag", async () => {
const { stderr, exitCode } = await runCli(["mcp", "list", "--api-key", "sk-test", "--dry-run"]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--api-key/);
});
describe("e2e: console global flags (dry-run)", () => {
test("console call --help 不暴露命令级 region/site,示例使用 --console-region", async () => {
const { stderr, exitCode } = await runCli(["console", "call", "--help"]);
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"console",
"call",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--api <api>/);
expect(stderr).toMatch(/--data <json>/);
@@ -65,7 +24,11 @@ describe("e2e: console global flags", () => {
});
test("auth login --help:自有 flag 含 --console-site,不含其余 console 域 flag", async () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"auth",
"login",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--api-key <key>/);
expect(stderr).toMatch(/--base-url <url>/);
@@ -75,7 +38,7 @@ describe("e2e: console global flags", () => {
});
test("console call --dry-run 默认 consoleRegion 为 cn-beijing", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"console",
"call",
"--api",
@@ -93,7 +56,7 @@ describe("e2e: console global flags", () => {
});
test("console call --dry-run --console-region / --console-site / --console-switch-agent 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"console",
"call",
"--api",
@@ -118,7 +81,7 @@ describe("e2e: console global flags", () => {
});
test("console call 拒绝未知全局 flag --region", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"console",
"call",
"--api",
@@ -134,7 +97,7 @@ describe("e2e: console global flags", () => {
});
test("mcp list --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"mcp",
"list",
"--dry-run",
@@ -149,7 +112,7 @@ describe("e2e: console global flags", () => {
});
test("quota check --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"quota",
"check",
"--dry-run",
@@ -1,9 +1,7 @@
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));
import { join } from "path";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e, e2eFixturesDir } from "./helpers.ts";
import { DATASET_ROUTES } from "./topic-routes.ts";
/**
* Dataset (fine-tune file) E2E.
@@ -19,22 +17,19 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
*/
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"]);
const { stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"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([
const file = join(e2eFixturesDir, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"validate",
"--file",
@@ -49,8 +44,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
});
test("dataset validate 拒绝 pretty-printed JSON 并以非零码退出", async () => {
const file = join(__dirname, ".dataset-invalid.jsonl");
const { stdout, exitCode } = await runCli([
const file = join(e2eFixturesDir, ".dataset-invalid.jsonl");
const { stdout, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"validate",
"--file",
@@ -68,8 +63,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
});
test("dataset upload --no-validate --dry-run 跳过本地校验", async () => {
const file = join(__dirname, ".dataset-invalid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
const file = join(e2eFixturesDir, ".dataset-invalid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"upload",
"--file",
@@ -88,8 +83,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
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([
const file = join(e2eFixturesDir, ".dataset-dpo-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"validate",
"--file",
@@ -106,8 +101,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
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([
const file = join(e2eFixturesDir, ".dataset-cpt-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"validate",
"--file",
@@ -122,8 +117,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
});
test("dataset validate --schema cpt 拒绝缺失 text 的记录", async () => {
const file = join(__dirname, ".dataset-valid.jsonl"); // SFT {messages}, no text
const { stdout, exitCode } = await runCli([
const file = join(e2eFixturesDir, ".dataset-valid.jsonl"); // SFT {messages}, no text
const { stdout, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"validate",
"--file",
@@ -142,8 +137,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
});
test("dataset validate --schema dpo 拒绝缺失 rejected 的记录", async () => {
const file = join(__dirname, ".dataset-dpo-invalid.jsonl");
const { stdout, exitCode } = await runCli([
const file = join(e2eFixturesDir, ".dataset-dpo-invalid.jsonl");
const { stdout, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"validate",
"--file",
@@ -163,8 +158,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
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([
const file = join(e2eFixturesDir, ".dataset-dpo-invalid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"validate",
"--file",
@@ -181,8 +176,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
});
test("dataset validate --schema <bad> 以非零码退出", async () => {
const file = join(__dirname, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
const file = join(e2eFixturesDir, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"validate",
"--file",
@@ -197,8 +192,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
});
test("dataset upload --dry-run 转发 --schema", async () => {
const file = join(__dirname, ".dataset-dpo-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
const file = join(e2eFixturesDir, ".dataset-dpo-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"upload",
"--file",
@@ -214,11 +209,88 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
expect(data.action).toBe("dataset.upload");
expect(data.schema).toBe("dpo");
});
test("dataset upload --schema image --no-validate --dry-run 采用 1GB 媒体上限", async () => {
// image schema raises the upload cap to 1 GiB (vs 300 MB for text).
// --no-validate keeps this offline (the jsonl fixture is not a real zip).
const file = join(e2eFixturesDir, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"upload",
"--file",
file,
"--schema",
"image",
"--no-validate",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string; schema: string; max_bytes: number }>(stdout);
expect(data.action).toBe("dataset.upload");
expect(data.schema).toBe("image");
expect(data.max_bytes).toBe(1024 * 1024 * 1024);
});
test.each(["tts", "image"])("dataset upload --dry-run 接受媒体 schema %s", async (schema) => {
const file = join(e2eFixturesDir, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"upload",
"--file",
file,
"--schema",
schema,
"--no-validate",
"--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(schema);
});
test("dataset validate --schema video 拒绝(视频生成入口已隐藏)", async () => {
const file = join(e2eFixturesDir, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"validate",
"--file",
file,
"--schema",
"video",
"--output",
"json",
]);
expect(exitCode, stdout + stderr).not.toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/--schema video is not supported/);
});
test("dataset upload --schema video 拒绝(视频生成入口已隐藏)", async () => {
const file = join(e2eFixturesDir, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"upload",
"--file",
file,
"--schema",
"video",
"--no-validate",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stdout + stderr).not.toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/--schema video is not supported/);
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (DashScope)", () => {
test("dataset list --output json 返回结构化结果", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [
"dataset",
"list",
"--page-size",
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { DEPLOY_ROUTES } from "./topic-routes.ts";
/**
* Deploy E2E.
@@ -15,21 +16,27 @@ import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
test("deploy 列出子命令", async () => {
const { stdout, stderr, exitCode } = await runCli(["deploy"]);
const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, ["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"]);
const { stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [
"deploy",
"text",
"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([
const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [
"deploy",
"text",
"create",
"--model",
"qwen-plus-2025-12-01",
@@ -56,8 +63,67 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
expect(data.body.capacity).toBe(1);
});
test("deploy create --plan mu --deploy-spec --dry-run 透传 deploy_spec", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [
"deploy",
"text",
"create",
"--model",
"qwen3-8b",
"--name",
"my-qwen3-mu",
"--plan",
"mu",
"--deploy-spec",
"MU1",
"--capacity",
"2",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: { plan: string; deploy_spec?: string; capacity?: number };
}>(stdout);
expect(data.action).toBe("deploy.create");
expect(data.body.plan).toBe("mu");
expect(data.body.deploy_spec).toBe("MU1");
expect(data.body.capacity).toBe(2);
});
test("deploy audio create --dry-run 默认 plan=mu(CosyVoice 部署契约)", async () => {
// Audio (CosyVoice TTS) outputs deploy model-unit-billed: the modality fixes
// the default plan to `mu` (text/image stay `lora`). In dry-run the mu
// strategy skips the catalog lookup, so deploy_spec is omitted and capacity
// falls back to 1 with billing_method POST_PAY.
const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [
"deploy",
"audio",
"create",
"--model",
"my-cosyvoice-ft",
"--name",
"my-tts",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: { plan: string; name: string; billing_method?: string; capacity?: number };
}>(stdout);
expect(data.action).toBe("deploy.create");
expect(data.body.plan).toBe("mu");
expect(data.body.name).toBe("my-tts");
expect(data.body.billing_method).toBe("POST_PAY");
expect(data.body.capacity).toBe(1);
});
test("deploy scale --dry-run 转发 capacity", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [
"deploy",
"scale",
"--deployed-model",
@@ -80,7 +146,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
});
test("deploy update --dry-run 转发 rate limits", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [
"deploy",
"update",
"--deployed-model",
@@ -104,7 +170,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
});
test("deploy scale --dry-run 缺少 capacity/input-tpm/output-tpm 时报错", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [
"deploy",
"scale",
"--deployed-model",
@@ -124,7 +190,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
["models", ["--source", "custom"]],
["delete", ["--deployed-model", "dep-xxx"]],
])("deploy %s --dry-run 发出结构化动作", async (sub, extra) => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [
"deploy",
sub,
...extra,
@@ -147,7 +213,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (DashScope)", () => {
* 而非进程崩溃),即视为通过。
*/
test("deploy list --output json 优雅返回(空账号或鉴权失败均通过)", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [
"deploy",
"list",
"--page-size",
@@ -1,24 +1,19 @@
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));
import { join } from "path";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e, e2eFixturesDir } from "./helpers.ts";
import { FILE_UPLOAD_ROUTES } from "./topic-routes.ts";
/**
* File upload E2E
*/
describe("e2e: file upload", () => {
test("file 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["file"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/file|upload/i);
});
test("file upload --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["file", "upload", "--help"]);
const { stderr, exitCode } = await runCommandE2e(FILE_UPLOAD_ROUTES, [
"file",
"upload",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/upload|--file|--model/i);
});
@@ -26,21 +21,31 @@ describe("e2e: file upload", () => {
describe.skipIf(!isDashScopeE2EReady())("e2e: file upload(DashScope)", () => {
test("file upload 缺少 --file 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["file", "upload", "--model", "qwen3-vl-plus"]);
const { stderr, exitCode } = await runCommandE2e(FILE_UPLOAD_ROUTES, [
"file",
"upload",
"--model",
"qwen3-vl-plus",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--file|Usage:/i);
});
test("file upload 缺少 --model 时报用法错误并退出 (2)", async () => {
const testFile = join(__dirname, ".smoke-32.png");
const { stderr, exitCode } = await runCli(["file", "upload", "--file", testFile]);
const testFile = join(e2eFixturesDir, ".smoke-32.png");
const { stderr, exitCode } = await runCommandE2e(FILE_UPLOAD_ROUTES, [
"file",
"upload",
"--file",
testFile,
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--model|Usage:/i);
});
test("上传文件成功返回oss临时 URL", async () => {
const testFile = join(__dirname, ".smoke-32.png");
const { stdout, stderr, exitCode } = await runCli([
const testFile = join(e2eFixturesDir, ".smoke-32.png");
const { stdout, stderr, exitCode } = await runCommandE2e(FILE_UPLOAD_ROUTES, [
"file",
"upload",
"--file",
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vite-plus/test";
import { join } from "path";
import { isDashScopeE2EReady, parseStdoutJson, runCli, cliPackageRoot } from "./helpers.ts";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e, e2eFixturesDir } from "./helpers.ts";
import { FINETUNE_ROUTES } from "./topic-routes.ts";
/**
* Fine-tune E2E.
@@ -16,21 +17,27 @@ import { isDashScopeE2EReady, parseStdoutJson, runCli, cliPackageRoot } from "./
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
test("finetune 列出子命令", async () => {
const { stdout, stderr, exitCode } = await runCli(["finetune"]);
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, ["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"]);
const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"text",
"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([
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"text",
"create",
"--model",
"qwen3-8b",
@@ -62,8 +69,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
});
test("finetune create --dry-run 转发训练类型与超参", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"text",
"create",
"--model",
"qwen3-8b",
@@ -114,9 +122,40 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
});
});
test.each([
["sft", "sft"],
["sft-lora", "efficient_sft"],
["dpo", "dpo_full"],
["dpo-lora", "dpo_lora"],
["cpt", "cpt"],
])(
"finetune create --training-type %s 经 profile 映射为 server 类型 %s",
async (cliType, serverType) => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"text",
"create",
"--model",
"qwen3-8b",
"--datasets",
"file-aaa",
"--training-type",
cliType,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string; body: { training_type: string } }>(stdout);
expect(data.action).toBe("finetune.create");
expect(data.body.training_type).toBe(serverType);
},
);
test("finetune create --training-type 拒绝不支持的训练类型值", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"text",
"create",
"--model",
"qwen3-8b",
@@ -132,9 +171,10 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
});
test("finetune create --dry-run 把本地路径标记为 pending 上传且不发起网络请求", async () => {
const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
const localPath = join(e2eFixturesDir, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"text",
"create",
"--model",
"qwen3-8b",
@@ -163,8 +203,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
});
test("finetune create --datasets 为空时拒绝", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"text",
"create",
"--model",
"qwen3-8b",
@@ -182,9 +223,10 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
// 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([
const localPath = join(e2eFixturesDir, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"text",
"create",
"--model",
"qwen3-8b",
@@ -203,9 +245,10 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
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([
const localPath = join(e2eFixturesDir, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"text",
"create",
"--model",
"qwen3-8b",
@@ -231,7 +274,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
["watch", ["--job-id", "ft-xxx"]],
["capability", ["--model", "qwen3-8b"]],
])("finetune %s --dry-run 发出结构化动作", async (sub, extra) => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
sub,
...extra,
@@ -245,8 +288,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
});
test("finetune create --dry-run 解析多 datasets 中的空白", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"text",
"create",
"--model",
"qwen3-8b",
@@ -262,6 +306,65 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
}>(stdout);
expect(data.body.training_file_ids).toEqual(["file-a", "file-b"]);
});
test("finetune audio create --dry-run 用 sft-lora 默认 + audio 超参", async () => {
// Audio has no --training-type flag; the command fixes modality=audio and
// defaults to sft-lora (efficient_sft) with the fixed CosyVoice hyper-params.
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"audio",
"create",
"--model",
"cosyvoice-v3-flash",
"--datasets",
"file-audio",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: { training_type: string; hyper_parameters: Record<string, unknown> };
}>(stdout);
expect(data.action).toBe("finetune.create");
expect(data.body.training_type).toBe("efficient_sft");
// Audio TTS defaults are fixed (not the text n_epochs/batch_size surface).
expect(data.body.hyper_parameters.lm_max_epoch).toBeDefined();
});
test("finetune audio create --help 不暴露文本超参 flag", async () => {
// Audio models don't consume --training-type / --n-epochs / --batch-size /
// --learning-rate / --max-length, so those flags are not offered.
const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"audio",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--datasets/i);
expect(stderr).not.toMatch(/--training-type|--n-epochs|--batch-size|--max-length/);
});
test("finetune image create --dry-run 用 sft-lora 默认", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"image",
"create",
"--model",
"wan2.7-image-pro",
"--datasets",
"file-image",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string; body: { training_type: string } }>(stdout);
expect(data.action).toBe("finetune.create");
expect(data.body.training_type).toBe("efficient_sft");
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => {
@@ -273,7 +376,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => {
* 而非进程崩溃),即视为通过。
*/
test("finetune list --output json 优雅返回(空账号或鉴权失败均通过)", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"list",
"--page-size",

Before

Width:  |  Height:  |  Size: 202 B

After

Width:  |  Height:  |  Size: 202 B

@@ -0,0 +1,13 @@
import pkg from "../../../package.json" with { type: "json" };
/**
* commands E2E harness 产品身份。
* version 取自 commands 包(与 monorepo 同步);npmPackage 使用非发布名以阻断
* versionCheckStage 的 registry 查询与 CI 中的 auto-update。
*/
export const E2E_HARNESS_IDENTITY = {
binName: "bl",
version: pkg.version,
clientName: "commands-e2e",
npmPackage: "bailian-cli-commands-e2e-harness",
} as const;
@@ -0,0 +1,29 @@
import * as cmd from "bailian-cli-commands";
import type { AnyCommand } from "bailian-cli-core";
import { createCli } from "bailian-cli-runtime";
import { E2E_HARNESS_IDENTITY } from "./identity.ts";
interface RouteSpec {
path: string;
export: string;
}
function buildRoutesFromEnv(): Record<string, AnyCommand> {
const raw = process.env.BAILIAN_E2E_ROUTES;
if (!raw?.trim()) {
throw new Error("BAILIAN_E2E_ROUTES is required for commands E2E harness");
}
const spec = JSON.parse(raw) as RouteSpec[];
const routes: Record<string, AnyCommand> = {};
const lib = cmd as Record<string, unknown>;
for (const { path, export: exportName } of spec) {
const command = lib[exportName];
if (typeof command !== "object" || command === null || !("run" in command)) {
throw new Error(`Unknown bailian-cli-commands export: ${exportName}`);
}
routes[path] = command as AnyCommand;
}
return routes;
}
void createCli(buildRoutesFromEnv(), E2E_HARNESS_IDENTITY).run();
+65
View File
@@ -0,0 +1,65 @@
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import {
cliTimeoutPrefix,
cliTimeoutSeconds,
e2eLabelFromMetaUrl,
isConsoleAuthFailure,
makeE2eOutputDir,
parseStdoutJson,
} from "e2e/output";
import { runNodeMain, type RunCliResult } from "e2e/runner";
import type { E2eRouteExports } from "./topic-routes.ts";
export {
cliTimeoutPrefix,
cliTimeoutSeconds,
e2eLabelFromMetaUrl,
isConsoleAuthFailure,
makeE2eOutputDir,
parseStdoutJson,
};
export type { RunCliResult };
export {
isBailianE2EEnabled,
isBailianE2EMediaEnabled,
isBailianE2EVideoEnabled,
isChatE2EReady,
isConsoleE2EReady,
isDashScopeE2EReady,
isSearchE2EReady,
} from "e2e/gating";
const e2eDir = dirname(fileURLToPath(import.meta.url));
const harnessMainTs = join(e2eDir, "harness", "main.ts");
/** `packages/commands` 根目录 */
export const commandsPackageRoot = join(e2eDir, "..", "..");
/** E2E fixtures 目录 */
export const e2eFixturesDir = join(e2eDir, "fixtures");
function serializeRoutes(routes: E2eRouteExports): string {
return JSON.stringify(
Object.entries(routes).map(([path, exportName]) => ({ path, export: exportName })),
);
}
/**
* 通过 harness 子进程执行命令 E2E。
* `routes` 为本用例所需的最小 path → export 映射,不维护全量产品 map。
*/
export async function runCommandE2e(
routes: E2eRouteExports,
args: string[],
envOverrides: NodeJS.ProcessEnv = {},
): Promise<RunCliResult> {
return runNodeMain(harnessMainTs, args, {
cwd: commandsPackageRoot,
env: {
BAILIAN_E2E_ROUTES: serializeRoutes(routes),
...envOverrides,
},
});
}
@@ -1,37 +1,29 @@
import { describe, expect, test } from "vite-plus/test";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { join } from "path";
import {
e2eFixturesDir,
e2eLabelFromMetaUrl,
isBailianE2EMediaEnabled,
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
runCommandE2e,
} from "./helpers.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
import { IMAGE_ROUTES } from "./topic-routes.ts";
/**
* Image edit E2E
*/
describe("e2e: image edit", () => {
test("image 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["image"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/image|generate|edit/i);
});
test("image edit --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["image", "edit", "--help"]);
const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, ["image", "edit", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/edit|--image|--prompt|--async|--concurrent/i);
});
test("image edit --dry-run 接受 async 模型的 --async 与 --concurrent", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
"image",
"edit",
"--dry-run",
@@ -58,21 +50,31 @@ describe("e2e: image edit", () => {
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: image edit", () => {
test("image edit 缺少 --image 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["image", "edit", "--prompt", "仅提示词"]);
const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
"image",
"edit",
"--prompt",
"仅提示词",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--image|Usage:/i);
});
test("image edit 缺少 --prompt 时报用法错误并退出 (2)", async () => {
const testPng = join(__dirname, ".smoke-32.png");
const { stderr, exitCode } = await runCli(["image", "edit", "--image", testPng]);
const testPng = join(e2eFixturesDir, ".smoke-32.png");
const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
"image",
"edit",
"--image",
testPng,
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prompt|Usage:/i);
});
test("【qwen-image-2.0】图片编辑", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const gen = await runCli([
const gen = await runCommandE2e(IMAGE_ROUTES, [
"image",
"generate",
"--model",
@@ -92,7 +94,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: ima
expect(imagePath).toBeTruthy();
const ed = await runCli([
const ed = await runCommandE2e(IMAGE_ROUTES, [
"image",
"edit",
"--model",
@@ -5,8 +5,9 @@ import {
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
runCommandE2e,
} from "./helpers.ts";
import { IMAGE_ROUTES } from "./topic-routes.ts";
/**
* Image generate:先做 help / 分组等常规检测(不依赖密钥、不调生成接口)。
@@ -15,15 +16,8 @@ import {
*/
describe("e2e: image generate", () => {
test("image 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["image"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/image|generate|edit/i);
});
test("image generate --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["image", "generate", "--help"]);
const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, ["image", "generate", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/generate|--prompt|--model/i);
});
@@ -33,14 +27,19 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"e2e: image generate",
() => {
test("image generate 缺少 --prompt 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["image", "generate", "--model", "qwen-image-2.0"]);
const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
"image",
"generate",
"--model",
"qwen-image-2.0",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prompt|Usage:/i);
});
test("【qwen-image-2.0】图片生成", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
"image",
"generate",
"--model",
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
import { isChatE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { KNOWLEDGE_CHAT_ROUTES } from "./topic-routes.ts";
interface ContentPart {
type: string;
@@ -24,7 +25,11 @@ interface DryRunBody {
describe("e2e: knowledge chat", () => {
test("knowledge chat --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "chat", "--help"]);
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--message/i);
expect(stderr).toMatch(/--agent-id/i);
@@ -32,19 +37,30 @@ describe("e2e: knowledge chat", () => {
});
test("缺少 --message 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "chat", "--agent-id", "aid_test"]);
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--agent-id",
"aid_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--message|Usage:/i);
});
test("缺少 --agent-id 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "chat", "--message", "Hello"]);
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--message",
"Hello",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--agent-id|Usage:/i);
});
test("缺少 --workspace-id 时非零退出并提示", async () => {
const { stderr, exitCode } = await runCli(
const { stderr, exitCode } = await runCommandE2e(
KNOWLEDGE_CHAT_ROUTES,
// 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入
[
"knowledge",
@@ -65,7 +81,7 @@ describe("e2e: knowledge chat", () => {
});
test("--dry-run 输出 endpoint 和 request body", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--dry-run",
@@ -88,7 +104,7 @@ describe("e2e: knowledge chat", () => {
});
test("--dry-run 多轮消息解析 role:content 前缀", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--dry-run",
@@ -118,7 +134,7 @@ describe("e2e: knowledge chat", () => {
});
test("--dry-run + --image 输出多模态 content 数组", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--dry-run",
@@ -147,7 +163,7 @@ describe("e2e: knowledge chat", () => {
});
test("--dry-run + --image 无 --message 自动创建空 user message", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--dry-run",
@@ -178,3 +194,133 @@ describe("e2e: knowledge chat", () => {
});
});
});
interface ChatJsonResult {
answer: string;
request_id: string;
}
describe.skipIf(!isChatE2EReady())("e2e: knowledge chat (live)", () => {
const agentId = process.env.BAILIAN_E2E_CHAT_AGENT_ID!;
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("chat (JSON mode) returns answer", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--message",
"什么是大模型?",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ChatJsonResult>(stdout);
expect(data.answer).toBeTruthy();
expect(data.answer.length).toBeGreaterThan(0);
expect(data.request_id).toBeTruthy();
});
test("chat (text mode) returns plain text", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--message",
"什么是RAG?",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout.trim().length).toBeGreaterThan(0);
});
test("chat (stream, JSON mode) collects and returns answer", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--message",
"什么是检索增强生成?",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ChatJsonResult>(stdout);
expect(data.answer).toBeTruthy();
expect(data.answer.length).toBeGreaterThan(0);
expect(data.request_id).toBeTruthy();
});
test("chat (stream, text mode) outputs streaming text", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--message",
"什么是向量检索?",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout.trim().length).toBeGreaterThan(0);
});
test("chat with multi-turn messages returns context-aware answer", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--message",
"user:什么是大模型",
"--message",
"assistant:大模型是大规模语言模型,具有强大的理解和生成能力",
"--message",
"它有哪些应用场景?",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ChatJsonResult>(stdout);
expect(data.answer).toBeTruthy();
expect(data.answer.length).toBeGreaterThan(0);
});
test("chat with invalid agent_id fails gracefully", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--message",
"test",
"--agent-id",
"aid-invalid-not-exist",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toBeTruthy();
});
});
@@ -0,0 +1,271 @@
import { describe, expect, test } from "vite-plus/test";
import { isSearchE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { KNOWLEDGE_SEARCH_ROUTES } from "./topic-routes.ts";
interface DryRunBody {
endpoint?: string;
request?: {
query?: string;
agent_id?: string;
images?: string[];
query_history?: Array<{ role: string; content: string }>;
};
}
describe("e2e: knowledge search", () => {
test("knowledge search --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--query/i);
expect(stderr).toMatch(/--agent-id/i);
expect(stderr).toMatch(/--workspace-id/i);
expect(stderr).toMatch(/--image/i);
expect(stderr).toMatch(/--query-history/i);
});
test("缺少 --query 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--agent-id",
"aid_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--query|Usage:/i);
});
test("缺少 --agent-id 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--query",
"test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--agent-id|Usage:/i);
});
test("缺少 --workspace-id 时非零退出并提示", async () => {
const { stderr, exitCode } = await runCommandE2e(
KNOWLEDGE_SEARCH_ROUTES,
// 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入
[
"knowledge",
"search",
"--query",
"test",
"--agent-id",
"aid_test",
"--api-key",
"sk-fake",
"--output",
"json",
],
{ BAILIAN_WORKSPACE_ID: "", BAILIAN_CONFIG_DIR: "/tmp" },
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/workspace.*required/i);
});
test("--dry-run 输出 endpoint 和 request body", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--dry-run",
"--query",
"什么是RAG",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/ws_test\.cn-beijing\.maas\.aliyuncs\.com/);
expect(data.endpoint).toMatch(/api\/v1\/indices\/knowledge\/search/);
expect(data.request?.query).toBe("什么是RAG");
expect(data.request?.agent_id).toBe("aid_test");
});
test("--dry-run + --image 输出 images", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--dry-run",
"--query",
"test",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--image",
"https://example.com/a.jpg",
"--image",
"https://example.com/b.jpg",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.images).toEqual([
"https://example.com/a.jpg",
"https://example.com/b.jpg",
]);
});
test("--dry-run + --query-history 输出用户对话历史", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--dry-run",
"--query",
"它怎么工作",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--query-history",
'[{"role":"user","content":"什么是RAG"},{"role":"assistant","content":"RAG是检索增强生成"}]',
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.query_history).toEqual([
{ role: "user", content: "什么是RAG" },
{ role: "assistant", content: "RAG是检索增强生成" },
]);
});
test("--dry-run + --query-history 无效 JSON 非零退出", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--dry-run",
"--query",
"test",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--query-history",
"not-valid-json",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/query-history.*valid JSON/i);
});
});
interface SearchResponse {
code: string;
status_code: number;
request_id: string;
data: {
total: number;
cost_time: number;
nodes: Array<{
score: number;
text: string;
metadata: Record<string, unknown>;
}>;
};
}
describe.skipIf(!isSearchE2EReady())("e2e: knowledge search (live)", () => {
const agentId = process.env.BAILIAN_E2E_SEARCH_AGENT_ID!;
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("search returns results in JSON mode", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--query",
"什么是大模型",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<SearchResponse>(stdout);
expect(data.code).toBe("Success");
expect(data.request_id).toBeTruthy();
expect(data.data.total).toBeGreaterThan(0);
expect(data.data.nodes.length).toBeGreaterThan(0);
const firstNode = data.data.nodes[0]!;
expect(typeof firstNode.score).toBe("number");
expect(firstNode.score).toBeGreaterThanOrEqual(0);
expect(typeof firstNode.text).toBe("string");
expect(firstNode.text.length).toBeGreaterThan(0);
});
test("search returns results in text mode", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--query",
"RAG",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/\[1\].*score/);
});
test("search with --query-history returns results", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--query",
"它怎么工作",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--query-history",
'[{"role":"user","content":"什么是大模型"},{"role":"assistant","content":"大模型是大规模语言模型"}]',
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<SearchResponse>(stdout);
expect(data.code).toBe("Success");
expect(data.data.nodes.length).toBeGreaterThan(0);
});
test("search with invalid agent_id fails gracefully", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--query",
"test",
"--agent-id",
"aid-invalid-not-exist",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toBeTruthy();
});
});
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { KNOWLEDGE_ROUTES } from "./topic-routes.ts";
// ---- Types ----
@@ -20,15 +21,12 @@ interface DryRunBody {
// ---- Help & missing args (no credentials needed) ----
describe("e2e: knowledge retrieve", () => {
test("knowledge 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["knowledge"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/knowledge|retrieve/i);
});
test("knowledge retrieve --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "retrieve", "--help"]);
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_ROUTES, [
"knowledge",
"retrieve",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--index-id/i);
expect(stderr).toMatch(/--query/i);
@@ -37,13 +35,23 @@ describe("e2e: knowledge retrieve", () => {
});
test("缺少 --index-id 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "retrieve", "--query", "test"]);
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_ROUTES, [
"knowledge",
"retrieve",
"--query",
"test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--index-id|Usage:/i);
});
test("缺少 --query 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "retrieve", "--index-id", "idx_test"]);
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_ROUTES, [
"knowledge",
"retrieve",
"--index-id",
"idx_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--query|Usage:/i);
});
@@ -53,7 +61,8 @@ describe("e2e: knowledge retrieve", () => {
describe.skipIf(!isDashScopeE2EReady())("e2e: knowledge retrieve errors", () => {
test("无任何凭证时提示缺少密钥并非零退出", async () => {
const { stderr, exitCode } = await runCli(
const { stderr, exitCode } = await runCommandE2e(
KNOWLEDGE_ROUTES,
["knowledge", "retrieve", "--index-id", "idx_test", "--query", "test", "--output", "json"],
{
DASHSCOPE_API_KEY: "",
@@ -70,7 +79,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: knowledge retrieve errors", () =>
describe("e2e: knowledge retrieve dry-run", () => {
test("--dry-run 输出 endpoint 和 snake_case body", async () => {
const { stdout, stderr, exitCode } = await runCli(
const { stdout, stderr, exitCode } = await runCommandE2e(
KNOWLEDGE_ROUTES,
[
"knowledge",
"retrieve",
@@ -92,7 +102,8 @@ describe("e2e: knowledge retrieve dry-run", () => {
});
test("--dry-run + --top-k 转发到 rerank_top_n 并输出废弃警告", async () => {
const { stdout, stderr, exitCode } = await runCli(
const { stdout, stderr, exitCode } = await runCommandE2e(
KNOWLEDGE_ROUTES,
[
"knowledge",
"retrieve",
@@ -115,7 +126,8 @@ describe("e2e: knowledge retrieve dry-run", () => {
});
test("--dry-run + --rerank-top-n 优先于 --top-k", async () => {
const { stdout, stderr, exitCode } = await runCli(
const { stdout, stderr, exitCode } = await runCommandE2e(
KNOWLEDGE_ROUTES,
[
"knowledge",
"retrieve",
@@ -139,7 +151,8 @@ describe("e2e: knowledge retrieve dry-run", () => {
});
test("--dry-run + rerank 参数完整输出", async () => {
const { stdout, stderr, exitCode } = await runCli(
const { stdout, stderr, exitCode } = await runCommandE2e(
KNOWLEDGE_ROUTES,
[
"knowledge",
"retrieve",
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { MCP_ROUTES } from "./topic-routes.ts";
/**
* `bl mcp` E2E.
@@ -16,40 +17,32 @@ import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
*/
describe("e2e: mcp", () => {
test("mcp 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["mcp"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/mcp/i);
expect(out).toMatch(/list|tools|call/i);
});
test("mcp list --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["mcp", "list", "--help"]);
const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/list|--name|--type|--page/i);
});
test("mcp tools --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["mcp", "tools", "--help"]);
const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "tools", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/tools|--server|--url/i);
});
test("mcp call --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["mcp", "call", "--help"]);
const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "call", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/call|--target|--arg|--json/i);
});
test("mcp list --help 不暴露 --all 入口(市场全量已下线)", async () => {
const { stderr, exitCode } = await runCli(["mcp", "list", "--help"]);
const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).not.toMatch(/--all/);
});
test("mcp list --dry-run 仅打印计划且固定 activated=1", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
"mcp",
"list",
"--dry-run",
@@ -87,7 +80,7 @@ describe("e2e: mcp", () => {
});
test("mcp list --dry-run 自定义 --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
"mcp",
"list",
"--dry-run",
@@ -102,7 +95,7 @@ describe("e2e: mcp", () => {
});
test("mcp tools --server <code> --dry-run 输出 /api/v1/mcps/<code>/mcp 形态 URL", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
"mcp",
"tools",
"--server",
@@ -121,7 +114,7 @@ describe("e2e: mcp", () => {
});
test("mcp tools --url 覆盖 baseUrl 约定", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
"mcp",
"tools",
"--server",
@@ -139,13 +132,13 @@ describe("e2e: mcp", () => {
});
test("mcp tools 缺少 --server 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["mcp", "tools", "--quiet"]);
const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "tools", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/--server|Usage:/i);
});
test("mcp call --target <server.tool> --dry-run 输出工具调用计划", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
"mcp",
"call",
"--target",
@@ -171,7 +164,7 @@ describe("e2e: mcp", () => {
});
test("mcp call --json 与 --arg 合并(arg 覆盖 json),--query 等价 arg.query", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
"mcp",
"call",
"--target",
@@ -207,7 +200,7 @@ describe("e2e: mcp", () => {
});
test("mcp call --target 缺少 . 时报错且非零退出", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
"mcp",
"call",
"--target",
@@ -220,7 +213,7 @@ describe("e2e: mcp", () => {
});
test("mcp call --arg 非 K=V 形式时报错且非零退出", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
"mcp",
"call",
"--target",
@@ -235,7 +228,7 @@ describe("e2e: mcp", () => {
});
test("mcp call --json 无效 JSON 报错且非零退出", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
"mcp",
"call",
"--target",
@@ -250,7 +243,7 @@ describe("e2e: mcp", () => {
});
test("mcp call 缺少 --target 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["mcp", "call", "--quiet"]);
const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "call", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/--target|Usage:/i);
});
@@ -261,7 +254,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: mcp (live)", () => {
// Regression: bailianMcpUrl previously added an `AliyunBailianMCP_` prefix,
// which made every real call 500. This test asserts the convention-built URL
// (no --url override) actually reaches a live MCP server end-to-end.
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
"mcp",
"tools",
"--server",
@@ -1,5 +1,11 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
import {
isBailianE2EEnabled,
isDashScopeE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { MEMORY_ROUTES } from "./topic-routes.ts";
interface MemoryAddBody {
memory_ids?: string[];
@@ -31,33 +37,31 @@ function memoryLibraryCliArgs(): string[] {
*/
describe("e2e: memory", () => {
test("memory 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["memory"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/memory|add|list|search|update|delete|profile/i);
});
test("memory add --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["memory", "add", "--help"]);
const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, ["memory", "add", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/add|--user-id|--content|messages/i);
});
test("memory list --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["memory", "list", "--help"]);
const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, ["memory", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/list|--user-id|memory-library/i);
});
test("memory search --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["memory", "search", "--help"]);
const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, ["memory", "search", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/search|--query|user-id/i);
});
test("memory profile create --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["memory", "profile", "create", "--help"]);
const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"profile",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/profile|create|user-id/i);
});
@@ -70,7 +74,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())(
"e2e: memory CRUD + search",
() => {
test("memory add 缺少 --user-id 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"add",
...memoryLibraryCliArgs(),
@@ -83,7 +87,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())(
test("memory add 缺少 --messages 与 --content 时报错正常退出", async () => {
const userId = process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID;
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"add",
...memoryLibraryCliArgs(),
@@ -96,7 +100,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())(
test("memory add --dry-run 仅输出计划且不入网", async () => {
const userId = process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID;
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"add",
"--dry-run",
@@ -121,7 +125,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())(
const contentA = "CLI vp test:记忆写入(可删)";
const contentB = "CLI vp test:记忆已更新";
const addRes = await runCli([
const addRes = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"add",
...memoryLibraryCliArgs(),
@@ -136,7 +140,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())(
const added = parseStdoutJson<MemoryAddBody & { request_id?: string }>(addRes.stdout);
expect(added.request_id?.length ?? 0, addRes.stdout + addRes.stderr).toBeGreaterThan(0);
const listRes = await runCli([
const listRes = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"list",
...memoryLibraryCliArgs(),
@@ -157,7 +161,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())(
const nodeId = listed.memory_nodes![0]!.memory_node_id.trim();
expect(nodeId.length).toBeGreaterThan(0);
const searchRes = await runCli([
const searchRes = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"search",
...memoryLibraryCliArgs(),
@@ -174,7 +178,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())(
const searched = parseStdoutJson<MemorySearchBody>(searchRes.stdout);
expect(searched.memory_nodes?.length ?? 0).toBeGreaterThan(0);
const updRes = await runCli([
const updRes = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"update",
...memoryLibraryCliArgs(),
@@ -189,7 +193,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())(
]);
expect(updRes.exitCode, updRes.stderr).toBe(0);
const delRes = await runCli([
const delRes = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"delete",
...memoryLibraryCliArgs(),
@@ -6,12 +6,13 @@ import {
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
runCommandE2e,
} from "./helpers.ts";
import { OMNI_ROUTES } from "./topic-routes.ts";
describe("e2e: omni", () => {
test("omni --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["omni", "--help"]);
const { stderr, exitCode } = await runCommandE2e(OMNI_ROUTES, ["omni", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/omni|--message|--audio|text-only/i);
});
@@ -21,7 +22,10 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"e2e: omni(DashScope 媒体)",
() => {
test("omni --list-voices 输出音色列表并退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["omni", "--list-voices"]);
const { stdout, stderr, exitCode } = await runCommandE2e(OMNI_ROUTES, [
"omni",
"--list-voices",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/Omni output voices:/);
expect(stdout).toMatch(/Tina/);
@@ -30,13 +34,17 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
});
test("omni 缺少 --message 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["omni", "--model", "qwen3.5-omni-flash"]);
const { stderr, exitCode } = await runCommandE2e(OMNI_ROUTES, [
"omni",
"--model",
"qwen3.5-omni-flash",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--message|Usage:/i);
});
test("omni --audio 无法识别扩展名时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(OMNI_ROUTES, [
"omni",
"--model",
"qwen3.5-omni-flash",
@@ -51,7 +59,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
});
test("omni --dry-run --audio 构造 input_audio 而非 audio_url", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(OMNI_ROUTES, [
"omni",
"--dry-run",
"--model",
@@ -91,7 +99,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
const clipText = "端到端Omni音频测试";
const clipWav = join(outDir, "e2e-omni-input.wav");
const syn = await runCli([
const syn = await runCommandE2e(OMNI_ROUTES, [
"speech",
"synthesize",
"--model",
@@ -109,7 +117,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
]);
expect(syn.exitCode, syn.stderr).toBe(0);
const omni = await runCli([
const omni = await runCommandE2e(OMNI_ROUTES, [
"omni",
"--model",
"qwen3.5-omni-flash",
@@ -2,7 +2,8 @@ import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseStdoutJson, runCli } from "./helpers.ts";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { PIPELINE_ROUTES } from "./topic-routes.ts";
describe("e2e: pipeline", () => {
let tempDir: string;
@@ -63,30 +64,29 @@ describe("e2e: pipeline", () => {
await rm(tempDir, { recursive: true, force: true });
});
test("pipeline 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["pipeline"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/pipeline|run|validate/i);
expect(out).toMatch(/Minimal workflow\.yaml|text\/chat|bl pipeline run workflow\.yaml/i);
expect(out).toMatch(/Say hello in one short sentence|--dry-run --output json/i);
});
test("pipeline run --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["pipeline", "run", "--help"]);
const { stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [
"pipeline",
"run",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/pipeline run|--input|--input-file|--events|--concurrency/i);
expect(stderr).not.toMatch(/--session-(?:dir|id)/i);
});
test("pipeline validate --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["pipeline", "validate", "--help"]);
const { stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [
"pipeline",
"validate",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/pipeline validate|workflow\.json|output json/i);
});
test("pipeline validate --output json 校验合法 workflow", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [
"pipeline",
"validate",
"--file",
@@ -101,7 +101,8 @@ describe("e2e: pipeline", () => {
});
test("pipeline validate 使用 config 输出格式", async () => {
const { stdout, stderr, exitCode } = await runCli(
const { stdout, stderr, exitCode } = await runCommandE2e(
PIPELINE_ROUTES,
["pipeline", "validate", "--file", chatBasicPath],
{
DASHSCOPE_OUTPUT: "text",
@@ -112,7 +113,7 @@ describe("e2e: pipeline", () => {
});
test("pipeline validate 拒绝非法依赖 workflow", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [
"pipeline",
"validate",
"--file",
@@ -128,13 +129,17 @@ describe("e2e: pipeline", () => {
});
test("pipeline run 缺少 --file 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["pipeline", "run", "--quiet"]);
const { stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [
"pipeline",
"run",
"--quiet",
]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/Usage: bl pipeline run --file <path>|--file/i);
});
test("pipeline run --dry-run --output json 仅输出计划", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [
"pipeline",
"run",
"--file",
@@ -167,7 +172,8 @@ describe("e2e: pipeline", () => {
});
test("pipeline run 使用 config 输出格式", async () => {
const { stdout, stderr, exitCode } = await runCli(
const { stdout, stderr, exitCode } = await runCommandE2e(
PIPELINE_ROUTES,
["pipeline", "run", "--file", chatBasicPath, "--input", '{"message":"hello"}', "--dry-run"],
{ DASHSCOPE_OUTPUT: "text" },
);
@@ -177,7 +183,7 @@ describe("e2e: pipeline", () => {
});
test("pipeline run --verbose 打印总步数和当前步骤序号", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [
"pipeline",
"run",
"--file",
@@ -193,7 +199,7 @@ describe("e2e: pipeline", () => {
});
test("pipeline run --events jsonl 在 dry-run 下输出生命周期事件", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [
"pipeline",
"run",
"--file",
@@ -221,7 +227,7 @@ describe("e2e: pipeline", () => {
});
test("pipeline run 拒绝未知 events format", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [
"pipeline",
"run",
"--file",
@@ -1,16 +1,22 @@
import { describe, expect, test } from "vite-plus/test";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
import {
isConsoleE2EReady,
isConsoleAuthFailure,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { QUOTA_ROUTES } from "./topic-routes.ts";
describe("e2e: quota", () => {
test("quota list --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "list", "--help"]);
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--all");
});
test("quota list --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCli(["quota", "list", "--help"]);
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl quota list");
expect(stderr).toContain("bl quota list --model qwen3.6-plus");
@@ -18,21 +24,21 @@ describe("e2e: quota", () => {
});
test("quota request --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "request", "--help"]);
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "request", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--tpm");
});
test("quota history --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "history", "--help"]);
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "history", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--page");
expect(stderr).toContain("--model");
});
test("quota check --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--period");
@@ -40,7 +46,12 @@ describe("e2e: quota", () => {
});
test("quota check --period 0 报错最小值", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--period", "0.5"]);
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"check",
"--period",
"0.5",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("at least 1 minute");
});
@@ -48,7 +59,7 @@ describe("e2e: quota", () => {
describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
test("quota list --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--dry-run",
@@ -68,7 +79,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota list --dry-run --all 不传 supports 过滤", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--all",
@@ -84,19 +95,26 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota list 文本输出包含英文表头", async () => {
const result = await runCli(["quota", "list", "--output", "text"]);
const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--output", "text"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 指定模型返回结果", async () => {
const result = await runCli(["quota", "list", "--model", "qwen3.6-plus", "--output", "text"]);
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
"qwen3.6-plus",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 不存在的模型报错", async () => {
const result = await runCli([
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
@@ -110,13 +128,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota list JSON 输出包含 model/rpm/tpm/maxTPM", async () => {
const result = await runCli(["quota", "list", "--output", "json"]);
const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota request --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"request",
"--model",
@@ -138,7 +156,14 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota request TPM 超范围报错", async () => {
const result = await runCli(["quota", "request", "--model", "qwen3.6-plus", "--tpm", "999"]);
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"request",
"--model",
"qwen3.6-plus",
"--tpm",
"999",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain("out of range");
@@ -147,7 +172,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota request 不支持提额的模型报错", async () => {
const result = await runCli([
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"request",
"--model",
@@ -161,7 +186,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota history --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"history",
"--dry-run",
@@ -179,7 +204,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota check --dry-run 输出 API 信息", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"check",
"--dry-run",
@@ -196,7 +221,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota check --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"check",
"--dry-run",
@@ -211,19 +236,26 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota check 文本输出包含英文表头", async () => {
const result = await runCli(["quota", "check", "--output", "text"]);
const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "check", "--output", "text"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota check --model 指定单模型", async () => {
const result = await runCli(["quota", "check", "--model", "qwen3.6-plus", "--output", "text"]);
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"check",
"--model",
"qwen3.6-plus",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota check --model 逗号分隔多模型", async () => {
const result = await runCli([
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"check",
"--model",
@@ -236,13 +268,20 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota check JSON 输出包含用量和限额字段", async () => {
const result = await runCli(["quota", "check", "--model", "qwen3.6-plus", "--output", "json"]);
const result = await runCommandE2e(QUOTA_ROUTES, [
"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 () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"history",
"--page",
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { SEARCH_WEB_ROUTES } from "./topic-routes.ts";
function pagesFromSearchWebStdout(stdout: string): Array<{ title?: string; url?: string }> {
const envelope = parseStdoutJson<{ content?: Array<{ type?: string; text?: string }> }>(stdout);
@@ -15,21 +16,19 @@ function pagesFromSearchWebStdout(stdout: string): Array<{ title?: string; url?:
*/
describe("e2e: search web", () => {
test("search 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["search"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/search|web/i);
});
test("search web --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["search", "web", "--help"]);
const { stderr, exitCode } = await runCommandE2e(SEARCH_WEB_ROUTES, [
"search",
"web",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/web|--query|list-tools|count/i);
});
test("search web --dry-run --list-tools 无需 --query 也无需凭证即可干跑", async () => {
const { stdout, stderr, exitCode } = await runCli(
const { stdout, stderr, exitCode } = await runCommandE2e(
SEARCH_WEB_ROUTES,
["search", "web", "--dry-run", "--list-tools", "--output", "json"],
{
DASHSCOPE_API_KEY: undefined,
@@ -44,13 +43,17 @@ describe("e2e: search web", () => {
describe.skipIf(!isDashScopeE2EReady())("e2e: search web", () => {
test("search web 缺少 --query 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["search", "web", "--quiet"]);
const { stderr, exitCode } = await runCommandE2e(SEARCH_WEB_ROUTES, [
"search",
"web",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--query|Usage:/i);
});
test("search web --dry-run 仅输出计划且不调 MCP", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(SEARCH_WEB_ROUTES, [
"search",
"web",
"--dry-run",
@@ -74,7 +77,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: search web", () => {
});
test("联网搜索返回 JSON 且含搜索结果", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(SEARCH_WEB_ROUTES, [
"search",
"web",
"--query",
@@ -1,26 +1,28 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, runCli } from "./helpers.ts";
import { isDashScopeE2EReady, runCommandE2e } from "./helpers.ts";
import { SPEECH_ROUTES } from "./topic-routes.ts";
/**
* Speech list-voices E2E
*/
describe("e2e: speech list-voices", () => {
test("speech 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["speech"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/speech|synthesize|recognize/i);
});
test("speech synthesize --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["speech", "synthesize", "--help"]);
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/synthesize|--text|--voice|list-voices|model/i);
});
test("speech recognize --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["speech", "recognize", "--help"]);
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/recognize|--url|audio|model/i);
});
@@ -28,13 +30,17 @@ describe("e2e: speech list-voices", () => {
describe.skipIf(!isDashScopeE2EReady())("e2e: speech list-voices", () => {
test("speech synthesize 缺少 --text 且非 --list-voices 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["speech", "synthesize", "--quiet"]);
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--text|Usage:/i);
});
test("【cosyvoice-v3-flash】获取音色列表", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--list-voices",
@@ -7,22 +7,21 @@ import {
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
runCommandE2e,
} from "./helpers.ts";
import { SPEECH_ROUTES } from "./topic-routes.ts";
/**
* Speech recognize:help / 分组不依赖密钥;识别流程需媒体 E2E + DashScope。
*/
describe("e2e: speech recognize", () => {
test("speech 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["speech"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/speech|synthesize|recognize/i);
});
test("speech recognize --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["speech", "recognize", "--help"]);
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/recognize|--url|model|audio/i);
});
@@ -32,7 +31,11 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"e2e: speech recognize(DashScope 媒体)",
() => {
test("speech recognize 缺少 --url 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["speech", "recognize", "--quiet"]);
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--url|Usage:/i);
});
@@ -40,7 +43,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
test("【fun-asr】语音识别", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const outMp3 = join(outDir, "e2e-tts.mp3");
const syn = await runCli([
const syn = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--model",
@@ -60,7 +63,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
expect(audioUrl?.startsWith("http")).toBe(true);
const asrJson = join(outDir, "e2e-asr.json");
const rec = await runCli([
const rec = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
@@ -6,22 +6,21 @@ import {
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
runCommandE2e,
} from "./helpers.ts";
import { SPEECH_ROUTES } from "./topic-routes.ts";
/**
* Speech synthesize:help / 分组不依赖密钥;合成本地需媒体 E2E + DashScope。
*/
describe("e2e: speech synthesize", () => {
test("speech 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["speech"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/speech|synthesize|recognize/i);
});
test("speech synthesize --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["speech", "synthesize", "--help"]);
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/synthesize|--text|--voice|model/i);
});
@@ -31,7 +30,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"e2e: speech synthesize(DashScope 媒体)",
() => {
test("speech synthesize 缺少 --text 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--model",
@@ -44,7 +43,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
});
test("speech synthesize --dry-run 仅输出 request 且不调 TTS", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--dry-run",
@@ -68,7 +67,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
test("【cosyvoice-v3-flash】语音合成", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const outMp3 = join(outDir, "e2e-tts.mp3");
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--model",
@@ -1,19 +1,14 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { TEXT_CHAT_ROUTES } from "./topic-routes.ts";
/**
* Text chat:help / 分组不依赖密钥;对话需 DashScope。
*/
describe("e2e: text chat", () => {
test("text 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["text"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/text|chat/i);
});
test("text chat --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["text", "chat", "--help"]);
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, ["text", "chat", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/chat|--message|model|stream/i);
});
@@ -21,13 +16,18 @@ describe("e2e: text chat", () => {
describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
test("text chat 缺少 --message 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["text", "chat", "--model", "qwen3.7-max"]);
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
"text",
"chat",
"--model",
"qwen3.7-max",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--message|Usage:/i);
});
test("text chat --dry-run 仅输出 request 且不调对话接口", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
"text",
"chat",
"--dry-run",
@@ -49,7 +49,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
});
test("【qwen3.7-max】文本对话", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
"text",
"chat",
"--model",
@@ -1,16 +1,22 @@
import { describe, expect, test } from "vite-plus/test";
import { makeE2eOutputDir, parseStdoutJson, runCli } from "./helpers.ts";
import { makeE2eOutputDir, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { TOKEN_PLAN_ROUTES } from "./topic-routes.ts";
describe("e2e: token-plan", () => {
test("token-plan help shows centralized OpenAPI auth flags", async () => {
const { stderr, exitCode } = await runCli(["token-plan", "list-seats", "--help"]);
const { stderr, exitCode } = await runCommandE2e(TOKEN_PLAN_ROUTES, [
"token-plan",
"list-seats",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--access-key-id/);
expect(stderr).toMatch(/--access-key-secret/);
});
test("token-plan dry-run does not require OpenAPI AK/SK", async () => {
const { stdout, stderr, exitCode } = await runCli(
const { stdout, stderr, exitCode } = await runCommandE2e(
TOKEN_PLAN_ROUTES,
["token-plan", "list-seats", "--dry-run", "--output", "json"],
{
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
@@ -25,22 +31,30 @@ describe("e2e: token-plan", () => {
test("token-plan non-dry-run requires OpenAPI AK/SK", async () => {
const configDir = makeE2eOutputDir("token-plan-missing-openapi");
const { stderr, exitCode } = await runCli(["token-plan", "list-seats"], {
BAILIAN_CONFIG_DIR: configDir,
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
});
const { stderr, exitCode } = await runCommandE2e(
TOKEN_PLAN_ROUTES,
["token-plan", "list-seats"],
{
BAILIAN_CONFIG_DIR: configDir,
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
},
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/OpenAPI AK\/SK|access-key-id|ALIBABA_CLOUD_ACCESS_KEY_ID/);
});
test("token-plan partial OpenAPI env reports AK/SK hint without API key onboarding", async () => {
const configDir = makeE2eOutputDir("token-plan-partial-openapi-env");
const { stderr, exitCode } = await runCli(["token-plan", "list-seats"], {
BAILIAN_CONFIG_DIR: configDir,
ALIBABA_CLOUD_ACCESS_KEY_ID: "ak-e2e-placeholder",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
});
const { stderr, exitCode } = await runCommandE2e(
TOKEN_PLAN_ROUTES,
["token-plan", "list-seats"],
{
BAILIAN_CONFIG_DIR: configDir,
ALIBABA_CLOUD_ACCESS_KEY_ID: "ak-e2e-placeholder",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
},
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Incomplete OpenAPI AK\/SK/);
expect(stderr).toMatch(/ALIBABA_CLOUD_ACCESS_KEY_ID/);
+145
View File
@@ -0,0 +1,145 @@
/**
* 各 topic E2E 的最小路由(path → bailian-cli-commands export 名)。
* 仅包含该 topic 测试会调用的 path,不维护全量产品 map。
*/
export type E2eRouteExports = Record<string, string>;
export const AUTH_ROUTES: E2eRouteExports = {
"auth login": "authLogin",
"auth status": "authStatus",
"auth logout": "authLogout",
};
export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" };
export const CONFIG_ROUTES: E2eRouteExports = {
"config show": "configShow",
"config set": "configSet",
};
export const MEMORY_ROUTES: E2eRouteExports = {
"memory add": "memoryAdd",
"memory search": "memorySearch",
"memory list": "memoryList",
"memory update": "memoryUpdate",
"memory delete": "memoryDelete",
"memory profile create": "memoryProfileCreate",
"memory profile get": "memoryProfileGet",
};
export const KNOWLEDGE_ROUTES: E2eRouteExports = {
"knowledge retrieve": "knowledgeRetrieve",
"knowledge search": "knowledgeSearch",
"knowledge chat": "knowledgeChat",
};
export const KNOWLEDGE_SEARCH_ROUTES: E2eRouteExports = {
"knowledge search": "knowledgeSearch",
};
export const KNOWLEDGE_CHAT_ROUTES: E2eRouteExports = {
"knowledge chat": "knowledgeChat",
};
export const IMAGE_ROUTES: E2eRouteExports = {
"image generate": "imageGenerate",
"image edit": "imageEdit",
};
export const VIDEO_ROUTES: E2eRouteExports = {
"image generate": "imageGenerate",
"video generate": "videoGenerate",
"video edit": "videoEdit",
"video ref": "videoRef",
"video task get": "videoTaskGet",
"video download": "videoDownload",
};
export const SPEECH_ROUTES: E2eRouteExports = {
"speech synthesize": "speechSynthesize",
"speech recognize": "speechRecognize",
};
export const MCP_ROUTES: E2eRouteExports = {
"mcp call": "mcpCall",
"mcp list": "mcpList",
"mcp tools": "mcpTools",
};
export const SEARCH_WEB_ROUTES: E2eRouteExports = { "search web": "searchWeb" };
export const PIPELINE_ROUTES: E2eRouteExports = {
"pipeline run": "pipelineRun",
"pipeline validate": "pipelineValidate",
};
export const OMNI_ROUTES: E2eRouteExports = {
omni: "textOmni",
"speech synthesize": "speechSynthesize",
};
export const FILE_UPLOAD_ROUTES: E2eRouteExports = { "file upload": "fileUpload" };
export const ADVISOR_ROUTES: E2eRouteExports = { "advisor recommend": "advisorRecommend" };
export const QUOTA_ROUTES: E2eRouteExports = {
"quota list": "quotaList",
"quota request": "quotaRequest",
"quota history": "quotaHistory",
"quota check": "quotaCheck",
};
export const USAGE_ROUTES: E2eRouteExports = {
"usage free": "usageFree",
"usage freetier": "usageFreetier",
"usage stats": "usageStats",
};
export const DEPLOY_ROUTES: E2eRouteExports = {
"deploy text create": "deployTextCreate",
"deploy audio create": "deployAudioCreate",
"deploy image create": "deployImageCreate",
"deploy list": "deployList",
"deploy get": "deployGet",
"deploy models": "deployModels",
"deploy scale": "deployScale",
"deploy update": "deployUpdate",
"deploy delete": "deployDelete",
};
export const DATASET_ROUTES: E2eRouteExports = {
"dataset upload": "datasetUpload",
"dataset list": "datasetList",
"dataset get": "datasetGet",
"dataset delete": "datasetDelete",
"dataset validate": "datasetValidate",
};
export const FINETUNE_ROUTES: E2eRouteExports = {
"finetune text create": "finetuneTextCreate",
"finetune audio create": "finetuneAudioCreate",
"finetune image create": "finetuneImageCreate",
"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",
};
export const CONSOLE_FLAGS_DRY_RUN_ROUTES: E2eRouteExports = {
"auth login": "authLogin",
"console call": "consoleCall",
"mcp list": "mcpList",
"quota check": "quotaCheck",
};
export const TOKEN_PLAN_ROUTES: E2eRouteExports = {
"token-plan list-seats": "tokenPlanListSeats",
"token-plan create-key": "tokenPlanCreateKey",
"token-plan assign-seats": "tokenPlanAssignSeats",
"token-plan add-member": "tokenPlanAddMember",
};
@@ -1,22 +1,21 @@
import { describe, expect, test } from "vite-plus/test";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
import {
isConsoleE2EReady,
isConsoleAuthFailure,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { USAGE_ROUTES } from "./topic-routes.ts";
describe("e2e: usage free", () => {
test("usage 分组展示子命令帮助且退出码为 0", async () => {
const { stdout, stderr, exitCode } = await runCli(["usage"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/usage|free|freetier/i);
});
test("usage free --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["usage", "free", "--help"]);
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "free", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|quota|free-tier/i);
});
test("usage free --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCli(["usage", "free", "--help"]);
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "free", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage free");
expect(stderr).toContain("bl usage free --model qwen3-max");
@@ -26,7 +25,7 @@ describe("e2e: usage free", () => {
describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
test("usage free --dry-run --model 输出请求参数不发起调用", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--dry-run",
@@ -45,7 +44,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
});
test("usage free --dry-run --model 逗号分隔多个模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--dry-run",
@@ -62,7 +61,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
});
test("usage free --dry-run --model 重复模型名自动去重", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--dry-run",
@@ -79,7 +78,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
});
test("usage free --dry-run --model 逗号间有空格也能正确解析", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--dry-run",
@@ -96,30 +95,57 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
});
test("usage free --dry-run 不指定 --model 传全量模型列表", async () => {
const { stderr, exitCode } = await runCli(["usage", "free", "--dry-run", "--output", "json"]);
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
});
test("usage free --model 单模型查询返回 JSON 结果", async () => {
const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "json"]);
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"json",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 单模型文本输出包含表头", async () => {
const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "text"]);
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 文本输出包含模型名", async () => {
const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "text"]);
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 逗号分隔多模型文本输出包含所有模型", async () => {
const result = await runCli([
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--model",
@@ -132,25 +158,46 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
});
test("usage free --model 文本输出包含正确的 Type 列", async () => {
const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "text"]);
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model quotaStatus 为 UNKNOWN 时 Auto-Stop 显示 Unsupported", async () => {
const result = await runCli(["usage", "free", "--model", "wan2.7-image", "--output", "text"]);
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--model",
"wan2.7-image",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model quotaStatus 为 UNKNOWN 时额度显示为 -", async () => {
const result = await runCli(["usage", "free", "--model", "wan2.7-image", "--output", "text"]);
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--model",
"wan2.7-image",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 不存在的模型仍返回表格行", async () => {
const result = await runCli([
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--model",
@@ -163,13 +210,20 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
});
test("usage free --model Auto-Stop 显示 ON、OFF 或 Unsupported", async () => {
const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "text"]);
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model --console-region cn-beijing 指定区域查询", async () => {
const result = await runCli([
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"free",
"--model",
@@ -1,5 +1,11 @@
import { describe, expect, test } from "vite-plus/test";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
import {
isConsoleE2EReady,
isConsoleAuthFailure,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { USAGE_ROUTES } from "./topic-routes.ts";
import { readConfigFile } from "bailian-cli-core";
function getStaticWorkspaceId(): string | undefined {
@@ -20,7 +26,7 @@ async function fetchDefaultWorkspaceId(): Promise<string> {
const staticId = getStaticWorkspaceId();
if (staticId) return staticId;
const result = await runCli(["workspace", "list", "--output", "json"]);
const result = await runCommandE2e(USAGE_ROUTES, ["workspace", "list", "--output", "json"]);
if (isConsoleAuthFailure(result) || result.exitCode !== 0) return FALLBACK_WORKSPACE_ID;
try {
const parsed = JSON.parse(result.stdout);
@@ -36,13 +42,13 @@ async function fetchDefaultWorkspaceId(): Promise<string> {
describe("e2e: usage stats", () => {
test("usage stats --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]);
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--days|stats/i);
});
test("usage stats --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]);
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage stats");
expect(stderr).toContain("bl usage stats --model qwen-turbo");
@@ -50,7 +56,7 @@ describe("e2e: usage stats", () => {
});
test("usage stats --help 包含 --workspace-id 选项", async () => {
const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]);
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--workspace-id");
});
@@ -66,7 +72,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats --dry-run 概览模式输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
@@ -95,7 +101,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats --dry-run --days 30 时间跨度约 30 天", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
@@ -117,7 +123,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats --dry-run --model 指定模型使用 list API", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
@@ -139,7 +145,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats --dry-run --type Text 传递 obsModelType", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
@@ -158,25 +164,46 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats 概览模式返回 JSON 结果", async () => {
const result = await runCli(["usage", "stats", "--workspace-id", wsId, "--output", "json"]);
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
wsId,
"--output",
"json",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats 概览文本输出包含英文标签", async () => {
const result = await runCli(["usage", "stats", "--workspace-id", wsId, "--output", "text"]);
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
wsId,
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats 概览文本输出包含 Token 用量", async () => {
const result = await runCli(["usage", "stats", "--workspace-id", wsId, "--output", "text"]);
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
wsId,
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --model 单模型文本输出包含英文表头", async () => {
const result = await runCli([
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
@@ -191,7 +218,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats --model 逗号分隔多模型返回多行", async () => {
const result = await runCli([
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
@@ -206,7 +233,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats --model 不存在的模型返回空表格", async () => {
const result = await runCli([
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
@@ -221,7 +248,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats --days 1 短时间范围正常返回", async () => {
const result = await runCli([
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
@@ -236,7 +263,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats --type Vision 按类型过滤", async () => {
const result = await runCli([
const result = await runCommandE2e(USAGE_ROUTES, [
"usage",
"stats",
"--workspace-id",
@@ -8,8 +8,9 @@ import {
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
runCommandE2e,
} from "./helpers.ts";
import { VIDEO_ROUTES } from "./topic-routes.ts";
/** dry-run 占位 UUID */
const PLACEHOLDER_TASK_ID = "00000000-0000-4000-8000-000000000001";
@@ -20,14 +21,8 @@ const PLACEHOLDER_TASK_ID = "00000000-0000-4000-8000-000000000001";
*/
describe("e2e: video download", () => {
test("video 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["video"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i);
});
test("video download --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["video", "download", "--help"]);
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, ["video", "download", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/download|--task-id|--out/i);
});
@@ -37,7 +32,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"e2e: video download(DashScope 视频)",
() => {
test("video download 缺少 --task-id 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"download",
"--out",
@@ -48,7 +43,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
});
test("video download 缺少 --out 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"download",
"--task-id",
@@ -61,7 +56,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
test("video download --dry-run 仅输出计划且不下载", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const fakeOut = join(outDir, "e2e-dry-not-written.mp4");
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"download",
"--dry-run",
@@ -83,7 +78,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const genMp4 = join(outDir, "e2e-gen-for-download.mp4");
const gen = await runCli([
const gen = await runCommandE2e(VIDEO_ROUTES, [
"video",
"generate",
...cliTimeoutPrefix(),
@@ -111,7 +106,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
expect(existsSync(genMp4)).toBe(true);
const downloadMp4 = join(outDir, "e2e-download.mp4");
const dl = await runCli([
const dl = await runCommandE2e(VIDEO_ROUTES, [
"video",
"download",
...cliTimeoutPrefix(),
@@ -7,28 +7,23 @@ import {
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
runCommandE2e,
} from "./helpers.ts";
import { VIDEO_ROUTES } from "./topic-routes.ts";
/**
* Video edit E2E
*/
describe("e2e: video edit", () => {
test("video 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["video"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i);
});
test("video edit --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["video", "edit", "--help"]);
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, ["video", "edit", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/edit|--video|--prompt|model|--async|--concurrent/i);
});
test("video edit --dry-run 接受 --async 与 --concurrent", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"edit",
"--dry-run",
@@ -54,7 +49,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"e2e: video edit(DashScope 视频)",
() => {
test("video edit 缺少 --video 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"edit",
...cliTimeoutPrefix(),
@@ -71,7 +66,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const t2vPath = join(outDir, "e2e-video-t2v.mp4");
const t2v = await runCli([
const t2v = await runCommandE2e(VIDEO_ROUTES, [
"video",
"generate",
...cliTimeoutPrefix(),
@@ -88,7 +83,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
const t2vData = parseStdoutJson<{ status?: string; video_url?: string }>(t2v.stdout);
expect(t2vData.status).toBe("SUCCEEDED");
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"edit",
...cliTimeoutPrefix(),
@@ -7,22 +7,17 @@ import {
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
runCommandE2e,
} from "./helpers.ts";
import { VIDEO_ROUTES } from "./topic-routes.ts";
/**
* Video generate (i2v):help / 分组不依赖密钥;长任务需视频 E2E + DashScope。
*/
describe("e2e: video generate (i2v)", () => {
test("video 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["video"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i);
});
test("video generate --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["video", "generate", "--help"]);
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, ["video", "generate", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/generate|--prompt|--image|model/i);
});
@@ -32,7 +27,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"e2e: video generate (i2v)(DashScope 视频)",
() => {
test("video generate 缺少 --prompt 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"generate",
...cliTimeoutPrefix(),
@@ -46,7 +41,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
});
test("video generate --dry-run(无 --image)仅输出 request(t2v 路径不调上传)", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"generate",
...cliTimeoutPrefix(),
@@ -69,7 +64,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
test("【happyhorse-1.1-i2v】图片生成视频", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const png = join(outDir, "e2e-gen.png");
const gen = await runCli([
const gen = await runCommandE2e(VIDEO_ROUTES, [
"image",
"generate",
"--model",
@@ -87,7 +82,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
const genData = parseStdoutJson<{ saved?: string[] }>(gen.stdout);
const imagePath = genData.saved?.[0] ?? png;
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"generate",
...cliTimeoutPrefix(),
@@ -7,22 +7,17 @@ import {
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
runCommandE2e,
} from "./helpers.ts";
import { VIDEO_ROUTES } from "./topic-routes.ts";
/**
* Video generate (t2v):help / 分组不依赖密钥;长任务需视频 E2E + DashScope。
*/
describe("e2e: video generate (t2v)", () => {
test("video 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["video"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i);
});
test("video generate --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["video", "generate", "--help"]);
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, ["video", "generate", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/generate|--prompt|--model|download|image/i);
});
@@ -32,7 +27,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"e2e: video generate (t2v)(DashScope 视频)",
() => {
test("video generate 缺少 --prompt 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"generate",
...cliTimeoutPrefix(),
@@ -44,7 +39,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
});
test("video generate --dry-run(无 --image)仅输出 request 且不调生成接口", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"generate",
"--dry-run",
@@ -66,7 +61,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
test("【happyhorse-1.1-t2v】文本生成视频", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"generate",
...cliTimeoutPrefix(),
@@ -7,28 +7,23 @@ import {
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
runCommandE2e,
} from "./helpers.ts";
import { VIDEO_ROUTES } from "./topic-routes.ts";
/**
* Video ref (r2v):help / 分组不依赖密钥;参考生成需视频 E2E + DashScope。
*/
describe("e2e: video ref (r2v)", () => {
test("video 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["video"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i);
});
test("video ref --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["video", "ref", "--help"]);
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, ["video", "ref", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/ref|--prompt|--image|model|--async|--concurrent/i);
});
test("video ref --dry-run 接受 --async 与 --concurrent", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"ref",
"--dry-run",
@@ -54,7 +49,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"e2e: video ref (r2v)(DashScope 视频)",
() => {
test("video ref 缺少 --prompt 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"ref",
...cliTimeoutPrefix(),
@@ -68,7 +63,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
});
test("video ref 缺少 --image 与 --ref-video 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"ref",
...cliTimeoutPrefix(),
@@ -83,7 +78,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
test("【happyhorse-1.1-r2v】视频参考生成", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const gen = await runCli([
const gen = await runCommandE2e(VIDEO_ROUTES, [
"image",
"generate",
"--model",
@@ -102,7 +97,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
const imagePath = genData.saved?.[0];
expect(imagePath).toBeTruthy();
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"ref",
...cliTimeoutPrefix(),
@@ -1,5 +1,11 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
import {
isBailianE2EEnabled,
isDashScopeE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { VIDEO_ROUTES } from "./topic-routes.ts";
const taskId = process.env.BAILIAN_E2E_VIDEO_TASK_ID?.trim();
@@ -8,14 +14,13 @@ const taskId = process.env.BAILIAN_E2E_VIDEO_TASK_ID?.trim();
*/
describe("e2e: video task get", () => {
test("video 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["video"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i);
});
test("video task get --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["video", "task", "get", "--help"]);
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"task",
"get",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/task|get|--task-id/i);
});
@@ -25,7 +30,13 @@ describe.skipIf(!isBailianE2EEnabled() || !taskId || !isDashScopeE2EReady())(
"e2e: video task get(DashScope)",
() => {
test("video task get 缺少 --task-id 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["video", "task", "get", "--output", "json"]);
const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"task",
"get",
"--output",
"json",
]);
expect(exitCode).toBe(2);
const err = JSON.parse(stderr.trim()) as { error?: { code?: number; message?: string } };
expect(err.error?.code).toBe(2);
@@ -33,7 +44,7 @@ describe.skipIf(!isBailianE2EEnabled() || !taskId || !isDashScopeE2EReady())(
});
test("video task get --dry-run 仅回显 task_id 且不调任务接口", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"task",
"get",
@@ -49,7 +60,7 @@ describe.skipIf(!isBailianE2EEnabled() || !taskId || !isDashScopeE2EReady())(
});
test("根据 task_id 查询任务状态", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [
"video",
"task",
"get",
+2 -1
View File
@@ -16,5 +16,6 @@
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
}
},
"include": ["src/**/*"]
}
+5
View File
@@ -1,6 +1,11 @@
import { defineConfig } from "vite-plus";
export default defineConfig({
test: {
globalSetup: "../e2e/src/global-setup.ts",
testTimeout: 60_000,
hookTimeout: 60_000,
},
pack: {
minify: true,
dts: {
+3 -1
View File
@@ -40,10 +40,12 @@
"check": "vp check"
},
"dependencies": {
"yaml": "^2.8.3"
"yaml": "^2.8.3",
"yauzl": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/yauzl": "catalog:",
"@typescript/native-preview": "7.0.0-dev.20260328.1",
"typescript": "^6.0.2",
"vite-plus": "catalog:"
@@ -1,8 +1,5 @@
export { DEFAULT_INTENT } from "./defaults.ts";
export {
INTENT_DETECT_MODEL,
INTENT_DETECT_TOOL,
buildIntentDetectSystemPrompt,
INTENT_EXTRACTION_MODEL,
INTENT_SYSTEM_PROMPT,
JSON_RETRY_HINT,
+3 -79
View File
@@ -1,14 +1,9 @@
export const RANKING_MODEL = "qwen-flash";
/**
* Dedicated intent-detection model. Sub-100ms latency, designed for fast
* classification + tool routing. Provides mode/targets/excludes/complexity.
*/
export const INTENT_DETECT_MODEL = "tongyi-intent-detect-v3";
/**
* Rich field extraction model. Runs in parallel with detect-v3 to extract
* taskSummary, modalities, budget, qualityPreference, etc.
* Intent extraction model. Runs a single call to extract taskSummary,
* modalities, budget, qualityPreference, modelPreference, and all other
* structured fields from the user's input.
*/
export const INTENT_EXTRACTION_MODEL = "qwen3.6-flash";
@@ -193,74 +188,3 @@ The intent's modelPreference.targets is the reference model.
## Output Format
{"type":"single","recommendations":[{"model":"model ID","reason":"alternative analysis","highlights":["differentiators"]}]}`;
/**
* Tool definition for `tongyi-intent-detect-v3`. Serialized to a JSON string
* and embedded in the system prompt (NOT passed via the request body `tools`
* field — this model doesn't use OpenAI function-calling; it has its own
* `<tags>` / `
</think>
` output format driven by the system prompt).
*/
export const INTENT_DETECT_TOOL = {
name: "classify_intent",
description:
"Classify the user's model recommendation intent. Extract the mode and any model/family names mentioned.",
parameters: {
type: "object",
properties: {
mode: {
type: "string",
enum: ["unconstrained", "scoped", "comparison", "alternative"],
description: "The detected intent mode.",
},
targets: {
type: "array",
items: { type: "string" },
description:
"Model or family names the user mentioned or wants to evaluate. Empty for unconstrained.",
},
excludes: {
type: "array",
items: { type: "string" },
description:
"Model or family names the user explicitly wants to exclude. Empty if none mentioned.",
},
complexity: {
type: "string",
enum: ["single", "pipeline"],
description:
"Whether the task needs a single model or a multi-step pipeline. Default to single unless the user clearly describes chained steps.",
},
},
required: ["mode"],
},
} as const;
/**
* Build the system prompt for `tongyi-intent-detect-v3` following the official
* template from the model's documentation. The tools JSON is embedded in the
* prompt text — the model reads it from the system message, not from a separate
* `tools` request field.
*
* Official template:
* "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.
* You may call one or more tools to assist with the user query.
* The tools you can use are as follows:
* {tools_string}
* Response in INTENT_MODE."
*
* `INTENT_MODE` tells the model to emit `<tags>label</tags>` + `
</think>
`
* output. The tag carries the mode classification; the tool_call carries
* structured targets/excludes/complexity extracted from the user's prompt.
*/
export function buildIntentDetectSystemPrompt(): string {
const toolsString = JSON.stringify([INTENT_DETECT_TOOL], null, 2);
return `You are Qwen, created by Alibaba Cloud. You are a helpful assistant. You may call one or more tools to assist with the user query. The tools you can use are as follows:
${toolsString}
Response in INTENT_MODE.`;
}
+42 -219
View File
@@ -1,18 +1,11 @@
import { chatPath, intentDetectEndpoint } from "../client/endpoints.ts";
import { chatPath } from "../client/endpoints.ts";
import type { Client } from "../client/client.ts";
import type { ChatResponse, DashScopeIntentDetectResponse } from "../types/api.ts";
import type { ChatResponse } from "../types/api.ts";
import { Complexities } from "./types.ts";
import type { IntentProfile, ModelPreference, PreferenceMode } from "./types.ts";
import {
INTENT_DETECT_MODEL,
buildIntentDetectSystemPrompt,
INTENT_EXTRACTION_MODEL,
INTENT_SYSTEM_PROMPT,
} from "./constants/prompts.ts";
import { INTENT_EXTRACTION_MODEL, INTENT_SYSTEM_PROMPT } from "./constants/prompts.ts";
import { DEFAULT_INTENT } from "./constants/defaults.ts";
// ---- tongyi-intent-detect-v3: fast mode classification via DashScope native API
const VALID_MODES: readonly PreferenceMode[] = [
"unconstrained",
"scoped",
@@ -20,174 +13,44 @@ const VALID_MODES: readonly PreferenceMode[] = [
"alternative",
];
/** Seconds per attempt; http.ts multiplies by 1000 -> ms. */
const INTENT_DETECT_TIMEOUT = 10;
/**
* Result of the fast intent-detect pass. Only the fields that
* `tongyi-intent-detect-v3` reliably extracts -- the remaining IntentProfile
* fields are still filled by the qwen3.6-flash extraction path.
* Build a ModelPreference from the extraction model's raw output.
* Returns undefined when no valid preference data is present.
*/
interface IntentDetectResult {
mode: PreferenceMode;
targets: string[];
excludes: string[];
complexity: "single" | "pipeline";
}
function extractModelPreference(
raw: Record<string, unknown> | undefined,
): ModelPreference | undefined {
if (!raw || typeof raw !== "object") return undefined;
/**
* Parse the tags block from the detect model's response.
* Returns the trimmed tag content, or "" when no tag is found.
*/
function parseTags(content: string): string {
const re = /<tags>\s*([\s\S]*?)\s*<\/tags>/i;
const match = content.match(re);
return match ? match[1].trim() : "";
}
/**
* Parse the tool_call block from the detect model's response.
* Returns the first tool call's arguments, or null when not found.
*/
function parseToolCall(content: string): Record<string, unknown> | null {
const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/i;
const match = content.match(re);
if (!match) return null;
try {
const parsed = JSON.parse(match[1]);
if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].arguments) {
return parsed[0].arguments as Record<string, unknown>;
}
if (
parsed &&
typeof parsed === "object" &&
"arguments" in (parsed as Record<string, unknown>)
) {
return (parsed as Record<string, unknown>).arguments as Record<string, unknown>;
}
return null;
} catch {
return null;
}
}
/**
* Extract string[] helper for safe array extraction from unknown values.
*/
function safeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((v): v is string => typeof v === "string");
}
/**
* Call `tongyi-intent-detect-v3` via DashScope native API for fast classification.
*
* Uses INTENT_MODE: the model emits `<tags>mode</tags>` for classification
* plus a `classify_intent` tool_call carrying targets/excludes/complexity.
* Returns null on any failure; caller falls back to extraction model fields.
*/
async function detectIntentMode(
client: Client,
input: string,
intentDetectBaseUrl?: string,
): Promise<IntentDetectResult | null> {
// 意图识别模型可指向独立 region/workspace;未配置时落回模型域 baseUrl。
const url = intentDetectEndpoint(intentDetectBaseUrl ?? client.baseUrl);
// Build system prompt following the official template:
// tools JSON is embedded in the prompt text, NOT passed via request body.
const systemPrompt = buildIntentDetectSystemPrompt();
// DashScope-native request shape: { model, input, parameters }
const body = {
model: INTENT_DETECT_MODEL,
input: {
messages: [
{ role: "system" as const, content: systemPrompt },
{ role: "user" as const, content: input },
],
},
parameters: {
result_format: "message" as const,
max_tokens: 512,
temperature: 0,
},
};
try {
const response = await client.requestJson<DashScopeIntentDetectResponse>({
path: url,
method: "POST",
body,
timeout: INTENT_DETECT_TIMEOUT,
});
const text = response.output?.choices?.[0]?.message?.content ?? "";
// 1. Extract mode from <tags>
const tag = parseTags(text);
const mode: PreferenceMode = VALID_MODES.includes(tag as PreferenceMode)
? (tag as PreferenceMode)
const mode: PreferenceMode =
typeof raw.mode === "string" && VALID_MODES.includes(raw.mode as PreferenceMode)
? (raw.mode as PreferenceMode)
: "unconstrained";
// 2. Extract structured fields from <tool_call>
const args = parseToolCall(text);
const targets = args ? safeStringArray(args.targets) : [];
const excludes = args ? safeStringArray(args.excludes) : [];
const rawComplexity = args?.complexity;
const complexity = rawComplexity === "pipeline" ? ("pipeline" as const) : ("single" as const);
const targets = Array.isArray(raw.targets)
? (raw.targets as unknown[]).filter((v): v is string => typeof v === "string")
: [];
const excludes = Array.isArray(raw.excludes)
? (raw.excludes as unknown[]).filter((v): v is string => typeof v === "string")
: [];
return { mode, targets, excludes, complexity };
} catch {
// detect-v3 failure is non-fatal: caller falls back to extraction model fields
return null;
}
return {
mode,
targets: targets.length > 0 ? targets : undefined,
excludes: excludes.length > 0 ? excludes : undefined,
};
}
/**
* Merge detect-v3 and extraction model results into a single ModelPreference.
* detect-v3 wins on mode/targets/excludes; extraction model is the fallback.
*/
function buildModelPreference(
detect: IntentDetectResult | null,
extractionFallback?: { mode: PreferenceMode; targets: string[]; excludes: string[] },
): ModelPreference | undefined {
if (detect) {
return {
mode: detect.mode,
targets: detect.targets.length > 0 ? detect.targets : undefined,
excludes: detect.excludes.length > 0 ? detect.excludes : undefined,
};
}
if (extractionFallback) {
return {
mode: extractionFallback.mode,
targets: extractionFallback.targets.length > 0 ? extractionFallback.targets : undefined,
excludes: extractionFallback.excludes.length > 0 ? extractionFallback.excludes : undefined,
};
}
return undefined;
}
// ---- Main entry: parallel detect-v3 + qwen3.6-flash ------------------------
/**
* Analyze the user's input to produce an IntentProfile.
*
* Two LLM calls run in parallel:
* 1. `tongyi-intent-detect-v3` (DashScope native) -- fast mode/targets/excludes/complexity
* 2. `qwen3.6-flash` -- rich field extraction (taskSummary, modalities, budget, etc.)
* Calls `qwen3.6-flash` via the OpenAI-compatible chat endpoint to extract
* structured intent fields: taskSummary, modalities, capabilities, budget,
* qualityPreference, modelPreference (mode/targets/excludes), and more.
*
* detect-v3 takes priority for mode/targets/excludes/complexity;
* the extraction model fills everything else.
* On failure, degrades gracefully to DEFAULT_INTENT with confidence 0.
*/
export async function analyzeIntent(
client: Client,
input: string,
opts?: { intentDetectBaseUrl?: string },
): Promise<IntentProfile> {
const detectPromise = detectIntentMode(client, input, opts?.intentDetectBaseUrl);
export async function analyzeIntent(client: Client, input: string): Promise<IntentProfile> {
const url = chatPath();
const body = {
model: INTENT_EXTRACTION_MODEL,
@@ -199,71 +62,31 @@ export async function analyzeIntent(
temperature: 0,
};
const extractionPromise = client.requestJson<ChatResponse>({
path: url,
method: "POST",
body,
timeout: 30,
});
const [detectResult, extractionResponse] = await Promise.all([
detectPromise,
extractionPromise.catch(() => null),
]);
// If extraction model failed, use detect-v3 result + defaults
if (!extractionResponse) {
return {
...DEFAULT_INTENT,
modelPreference: buildModelPreference(detectResult),
complexity:
detectResult?.complexity === "pipeline" ? Complexities.Pipeline : Complexities.Single,
};
let response: ChatResponse;
try {
response = await client.requestJson<ChatResponse>({
path: url,
method: "POST",
body,
timeout: 30,
});
} catch {
return { ...DEFAULT_INTENT };
}
const text = extractionResponse.choices?.[0]?.message?.content ?? "";
const text = response.choices?.[0]?.message?.content ?? "";
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
return {
...DEFAULT_INTENT,
confidence: detectResult ? 1 : 0,
modelPreference: buildModelPreference(detectResult),
complexity:
detectResult?.complexity === "pipeline" ? Complexities.Pipeline : Complexities.Single,
};
return { ...DEFAULT_INTENT };
}
const parsed = JSON.parse(jsonMatch[0]);
// Extraction model's mode/targets/excludes (fallback when detect-v3 is null)
const rawPref = parsed.modelPreference as Record<string, unknown> | undefined;
const extractionMode: PreferenceMode =
rawPref && typeof rawPref === "object" && typeof rawPref.mode === "string"
? VALID_MODES.includes(rawPref.mode as PreferenceMode)
? (rawPref.mode as PreferenceMode)
: "unconstrained"
: "unconstrained";
const extractionTargets: string[] =
rawPref && typeof rawPref === "object" && Array.isArray(rawPref.targets)
? (rawPref.targets as string[])
: [];
const extractionExcludes: string[] =
rawPref && typeof rawPref === "object" && Array.isArray(rawPref.excludes)
? (rawPref.excludes as string[])
: [];
const modelPreference = extractModelPreference(rawPref);
// Merge: detect-v3 wins, extraction model fills gaps
const modelPreference = buildModelPreference(detectResult, {
mode: extractionMode,
targets: extractionTargets,
excludes: extractionExcludes,
});
// Extraction model complexity, but detect-v3 pipeline tag overrides
const extractionComplexity =
parsed.complexity === Complexities.Pipeline ? Complexities.Pipeline : Complexities.Single;
const complexity =
detectResult?.complexity === "pipeline" ? Complexities.Pipeline : extractionComplexity;
parsed.complexity === Complexities.Pipeline ? Complexities.Pipeline : Complexities.Single;
return {
complexity,
+30 -24
View File
@@ -61,12 +61,28 @@ function normalizeStr(value: string): string {
function matchesTarget(model: ModelProfile, target: string): boolean {
const needle = normalizeStr(target);
if (!needle) return false;
// exact normalized match on id/name wins (resolves "qwen max" → "qwen-max")
// Tier 1: exact normalized match on model id or display name
if (normalizeStr(model.model) === needle || normalizeStr(model.name) === needle) return true;
// otherwise normalized substring across identifier-ish fields
return [model.model, model.name, model.family, model.familyName, model.provider].some((field) =>
field ? normalizeStr(field).includes(needle) : false,
);
// Tier 2: suffix match for provider-prefix model ids
// e.g. "siliconflow/deepseek-v3" → suffix "deepseek-v3" → normalize → "deepseekv3"
const modelId = model.model;
const slashIdx = modelId.lastIndexOf("/");
if (slashIdx >= 0) {
const suffix = normalizeStr(modelId.slice(slashIdx + 1));
if (suffix === needle) return true;
}
// Tier 3: substring match only on family / familyName
// e.g. target "deepseek" matches family "DeepSeek" → normalize → "deepseek"
// but target "deepseek-v3" does NOT match family "DeepSeek" because
// "deepseekv3".includes("deepseek") is the wrong direction (needle ⊃ field).
return [model.family, model.familyName].some((field) => {
if (!field) return false;
const normalized = normalizeStr(field);
return normalized.length > 0 && needle.includes(normalized);
});
}
function matchesAnyTarget(model: ModelProfile, targets: string[]): boolean {
@@ -287,16 +303,18 @@ function recallScoped(
function recallComparison(
models: ModelProfile[],
embeddings: ModelEmbedding[],
queryVector: number[],
_embeddings: ModelEmbedding[],
_queryVector: number[],
preference: ModelPreference,
topK: number,
modelMap: Map<string, ModelProfile>,
intent?: IntentProfile,
_topK: number,
_modelMap: Map<string, ModelProfile>,
_intent?: IntentProfile,
): ScoredCandidate[] {
const targets = preference.targets ?? [];
// user-named models are forced in (bypass hard gate), priority 1.0
// Comparison mode: only return the user-specified models (bypass hard gate).
// No fusion-ranked fillers — the user explicitly asked to compare these models,
// so extra candidates would only give the LLM ranker room to substitute them.
const forced: ScoredCandidate[] = [];
const forcedIds = new Set<string>();
for (const profile of models) {
@@ -306,19 +324,7 @@ function recallComparison(
}
}
const remaining = Math.max(0, topK - forced.length);
if (remaining > 0) {
const candidatePool = models.filter((profile) => !forcedIds.has(profile.model));
const poolIds = filterWithFallback(candidatePool, intent);
const extra = rankByFusion(embeddings, queryVector, poolIds, remaining, modelMap, intent);
for (const cand of extra) {
forced.push(cand);
}
}
// clamp in case many targets matched beyond topK (forced are first, so they
// are preserved up to topK and extras drop first)
return forced.slice(0, Math.max(0, topK));
return forced;
}
function recallAlternative(
-13
View File
@@ -6,19 +6,6 @@ export function chatPath(): string {
return "/compatible-mode/v1/chat/completions";
}
// ---- Intent Detect (DashScope Native) ----
/**
* DashScope-native text-generation endpoint for `tongyi-intent-detect-v3`.
* This model does not use the OpenAI-compatible chat endpoint — it requires
* the native `{ model, input, parameters }` request shape with
* `result_format: "message"` and returns a `{ output, usage, request_id }`
* envelope.
*/
export function intentDetectEndpoint(baseUrl: string): string {
return `${baseUrl}/api/v1/services/aigc/text-generation/generation`;
}
// ---- Image Generation (DashScope) ----
export function imagePath(): string {
return "/api/v1/services/aigc/image-generation/generation";
-2
View File
@@ -125,8 +125,6 @@ export function buildSettings(s: ResolutionSources): Settings {
return {
configPath: s.configPath ?? getConfigPath(),
configName: s.configName,
intentDetectBaseUrl:
file.intent_detect_base_url || env.DASHSCOPE_INTENT_DETECT_BASE_URL || undefined,
output: detectOutputFormat(flags.output || env.DASHSCOPE_OUTPUT || file.output),
outputExplicit: Boolean(flags.output || env.DASHSCOPE_OUTPUT || file.output),
outputDir: file.output_dir || undefined,
-11
View File
@@ -25,12 +25,6 @@ export interface ConfigFile {
/** Alibaba Cloud STS Security Token (optional, for temporary credentials). */
security_token?: string;
base_url?: string;
/**
* Dedicated base URL for the intent-detect model (tongyi-intent-detect-v3).
* Allows pointing the intent API at a different region/workspace than the
* main chat endpoint. Falls back to `base_url` when not set.
*/
intent_detect_base_url?: string;
output?: "text" | "json";
output_dir?: string;
timeout?: number;
@@ -53,7 +47,6 @@ export const CONFIG_FILE_KEYS = [
"access_key_secret",
"security_token",
"base_url",
"intent_detect_base_url",
"output",
"output_dir",
"timeout",
@@ -111,8 +104,6 @@ export function parseConfigFile(raw: unknown): ConfigFile {
if (typeof obj.security_token === "string" && obj.security_token.length > 0)
out.security_token = obj.security_token;
if (typeof obj.base_url === "string" && isHttpUrl(obj.base_url)) out.base_url = obj.base_url;
if (typeof obj.intent_detect_base_url === "string" && isHttpUrl(obj.intent_detect_base_url))
out.intent_detect_base_url = obj.intent_detect_base_url;
if (typeof obj.output === "string" && VALID_OUTPUTS.has(obj.output))
out.output = obj.output as ConfigFile["output"];
if (typeof obj.output_dir === "string" && obj.output_dir.length > 0)
@@ -160,8 +151,6 @@ export interface Identity {
export interface Settings {
configPath?: string;
configName?: string;
/** Dedicated base URL for intent-detect model; falls back to the model baseUrl at call site. */
intentDetectBaseUrl?: string;
output: "text" | "json";
/**
* Whether `output` came from an explicit source (flag/env/file) rather than
+2
View File
@@ -1,11 +1,13 @@
export * from "./types.ts";
export * from "./api.ts";
export { detectModality } from "./inspect.ts";
export {
validateDataset,
pickValidator,
registerValidator,
listSupportedFormats,
MAX_DATASET_BYTES,
MAX_MEDIA_ZIP_BYTES,
parseDatasetSchemaFlag,
formatIssue,
} from "./validate/index.ts";
+209
View File
@@ -0,0 +1,209 @@
/**
* Data inspector — lightweight content parser that determines the modality
* of a training data file.
*
* The inspector peeks at the first non-blank line of a JSONL file (or the
* `data.jsonl` manifest inside a ZIP) and inspects the record's fields to
* decide whether the data carries text, audio, image, or video samples.
*
* This is intentionally shallow — it reads at most one record — so it stays
* fast even on very large files. The full structural validation is the job
* of the format-specific validator (`jsonl.ts`, `zip.ts`), not this module.
*
* Routing in `create.ts`:
* 1. `--training-type` → Profile (via `getProfile`)
* 2. Profile.acceptedExtensions → match file extension
* 3. `detectModality(filePath)` → "text" | "audio" | "image" | "video"
* 4. Profile validates / resolves hyper-params using detected modality
*/
import { createReadStream } from "fs";
import { createInterface } from "readline";
import { extname } from "path";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import { openZipAndFindEntry } from "./validate/zip.ts";
import type { DataModality } from "../finetune/profiles/types.ts";
/**
* Inspect a file and return its data modality.
*
* `.jsonl` → read the first non-blank line, parse JSON, check fields.
* `.zip` → locate `data.jsonl` inside the archive, read its first line.
*
* Image data returns `"image"` (T2I) or `"image-i2i"` (I2I, first record
* has `input_img`). Callers that don't distinguish can normalise to `"image"`.
*
* Throws USAGE if the file extension is not `.jsonl` or `.zip`, or if the
* content cannot be parsed.
*/
export async function detectModality(filePath: string): Promise<DataModality> {
const ext = extname(filePath).toLowerCase();
if (ext === ".jsonl") return detectFromJsonl(filePath);
if (ext === ".zip") return detectFromZip(filePath);
throw new BailianError(
`Cannot inspect file with extension "${ext}". Expected .jsonl or .zip.`,
ExitCode.USAGE,
);
}
/**
* Read the first non-blank line of a JSONL file and determine the modality.
*/
async function detectFromJsonl(filePath: string): Promise<DataModality> {
const firstLine = await readFirstNonBlankLine(filePath);
if (!firstLine) {
throw new BailianError(
`JSONL file is empty or contains only blank lines: ${filePath}`,
ExitCode.USAGE,
);
}
const modality = classifyRecord(firstLine);
// JSONL files are always text data (chatml / dpo / cpt).
return modality === "unknown" ? "text" : modality;
}
/**
* Locate `data.jsonl` inside a ZIP archive, extract its first non-blank line,
* and determine the modality.
*
* Uses `yauzl` for streaming access — only the target entry is read, the rest
* of the archive is skipped.
*/
async function detectFromZip(filePath: string): Promise<DataModality> {
const firstLine = await readFirstLineFromZipEntry(filePath, "data.jsonl");
if (!firstLine) {
throw new BailianError(
`ZIP archive does not contain "data.jsonl" or it is empty: ${filePath}`,
ExitCode.USAGE,
`Audio training data must be a ZIP with data.jsonl at the root and a train/ subfolder.`,
);
}
const modality = classifyRecord(firstLine);
if (modality === "unknown") {
throw new BailianError(
`ZIP data.jsonl does not match any supported media format ` +
`(expected wav_fn / img_path / first_frame_path / video_path): ${filePath}`,
ExitCode.USAGE,
`ZIP archives are for audio/image/video training data. ` +
`For text data, use a .jsonl file instead.`,
);
}
return modality;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Classify a JSON record into a data modality based on its field names. */
function classifyRecord(line: string): DataModality | "unknown" {
let record: Record<string, unknown>;
try {
record = JSON.parse(line);
} catch {
throw new BailianError(
`Failed to parse first JSON record for modality detection: ${line.slice(0, 120)}`,
ExitCode.USAGE,
);
}
if (typeof record !== "object" || record === null || Array.isArray(record)) {
throw new BailianError(
`Expected a JSON object as the first record, got ${Array.isArray(record) ? "array" : typeof record}.`,
ExitCode.USAGE,
);
}
if ("wav_fn" in record) return "audio";
if ("img_path" in record) {
// Image generation: distinguish T2I (no input_img) from I2I (has input_img).
// The subtype drives hyper-parameter defaults (max_pixels 2k vs 1k).
return "input_img" in record ? "image-i2i" : "image";
}
if ("first_frame_path" in record || "video_path" in record) {
// Video generation (Wan i2v/kf2v): distinguish first-frame-only (i2v) from
// first+last-frame (kf2v, has last_frame_path). The subtype lets the
// profile cross-check the chosen --model against the data shape.
return "last_frame_path" in record ? "video-kf2v" : "video";
}
// No known media field found — caller decides how to handle.
return "unknown";
}
/** Read the first non-blank line from a file using a readline stream. */
function readFirstNonBlankLine(filePath: string): Promise<string | null> {
return new Promise((resolve, reject) => {
const stream = createReadStream(filePath, { encoding: "utf8" });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
let found = false;
rl.on("line", (line) => {
if (found) return;
const trimmed = line.trim();
if (trimmed.length === 0) return;
found = true;
rl.close();
stream.destroy();
resolve(trimmed);
});
rl.on("close", () => {
if (!found) resolve(null);
});
rl.on("error", reject);
stream.on("error", reject);
});
}
/**
* Open a ZIP archive, locate the entry with the given name, and return the
* first non-blank line from its content. Returns `null` if the entry is not
* found or is empty.
*
* Delegates ZIP open/locate to the shared `openZipAndFindEntry` helper in
* `validate/zip.ts` — avoids duplicating yauzl boilerplate.
*/
function readFirstLineFromZipEntry(zipPath: string, entryName: string): Promise<string | null> {
return openZipAndFindEntry(zipPath, entryName)
.then(({ entry, zipfile }) => {
return new Promise<string | null>((resolve, reject) => {
zipfile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) {
zipfile.close();
reject(
new BailianError(
`Failed to read "${entryName}" from ZIP: ${streamErr?.message}`,
ExitCode.USAGE,
),
);
return;
}
const rl = createInterface({ input: readStream, crlfDelay: Infinity });
let found = false;
rl.on("line", (line) => {
if (found) return;
const trimmed = line.trim();
if (trimmed.length === 0) return;
found = true;
rl.close();
readStream.destroy();
zipfile.close();
resolve(trimmed);
});
rl.on("close", () => {
if (!found) {
zipfile.close();
resolve(null);
}
});
rl.on("error", (readError) => {
zipfile.close();
reject(readError);
});
});
});
})
.catch((error) => {
// openZipAndFindEntry rejects when the entry is not found — treat as null.
if (error instanceof Error && error.message.includes("not found in ZIP")) {
return null;
}
throw error;
});
}
+11 -3
View File
@@ -18,6 +18,13 @@ import type { DatasetSchema, ValidationIssue, ValidationStats } from "./types.ts
*/
export const MAX_DATASET_BYTES = 300 * 1024 * 1024;
/**
* Image / video ZIP size cap — 1 GB per the platform docs (vs 300 MB for
* text / audio). Used by `bl dataset upload` for media schemas and by the
* `sft-lora` training profile for image / video validation.
*/
export const MAX_MEDIA_ZIP_BYTES = 1024 * 1024 * 1024;
export interface PreflightResult {
bytes: number;
ext: string;
@@ -75,11 +82,12 @@ export function emptyStats(): ValidationStats {
export function parseDatasetSchemaFlag(value: string | undefined): DatasetSchema | undefined {
if (value === undefined || value.trim() === "") return undefined;
const v = value.trim();
if (v === "chatml" || v === "dpo" || v === "cpt") return v;
if (v === "chatml" || v === "dpo" || v === "cpt" || v === "tts" || v === "image" || v === "video")
return v;
throw new BailianError(
`Unsupported --schema "${value}". Supported: chatml, dpo, cpt.`,
`Unsupported --schema "${value}". Supported: chatml, dpo, cpt, tts, image.`,
ExitCode.USAGE,
`Omit --schema to auto-detect per record (chosen/rejected → DPO, text → CPT, else ChatML).`,
`Omit --schema to auto-detect per record (chosen/rejected → DPO, text → CPT, wav_fn → TTS, img_path → image, else ChatML).`,
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ export {
registerValidator,
listSupportedFormats,
} from "./registry.ts";
export { MAX_DATASET_BYTES, parseDatasetSchemaFlag } from "./common.ts";
export { MAX_DATASET_BYTES, MAX_MEDIA_ZIP_BYTES, parseDatasetSchemaFlag } from "./common.ts";
export { formatIssue } from "./format.ts";
export type {
ValidatorSpec,
@@ -16,10 +16,11 @@ import { extname } from "path";
import { BailianError } from "../../errors/base.ts";
import { ExitCode } from "../../errors/codes.ts";
import { jsonlValidator } from "./jsonl.ts";
import { zipValidator } from "./zip.ts";
import { preflight, MAX_DATASET_BYTES } from "./common.ts";
import type { ValidatorSpec, ValidateOpts, ValidationResult } from "./types.ts";
const REGISTRY: ValidatorSpec[] = [jsonlValidator];
const REGISTRY: ValidatorSpec[] = [jsonlValidator, zipValidator];
/** Lookup the validator that handles a given file extension. */
export function pickValidator(filePath: string): ValidatorSpec {
@@ -0,0 +1,177 @@
/**
* Image generation record schema — Wan2.x fine-tuning.
*
* Two record flavours share the same schema:
* - **Text-to-image (T2I):** `{"prompt": "...", "img_path": "./x.png"}`
* - **Image-to-image (I2I):** `{"prompt": "...", "input_img": "./in.jpg", "img_path": "./out.jpg"}`
*
* The presence of `img_path` is the distinguishing field — auto-detect picks
* this schema before the ChatML fallback. `input_img` is optional (I2I only).
*
* Image data lives in a ZIP with a flat layout (no `train/` subdirectory).
* File names must be ASCII-only per platform requirements.
*/
import { makeIssue } from "../common.ts";
import type { ValidationIssue } from "../types.ts";
import type { RecordSchemaSpec } from "./types.ts";
/** Accepted image file extensions (lower-case, with dot). */
export const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tiff"]);
/**
* Check that a path string ends with an accepted image extension.
* Returns the extension (lower-case) or an empty string.
*/
function imageExt(path: string): string {
const dot = path.lastIndexOf(".");
return dot >= 0 ? path.slice(dot).toLowerCase() : "";
}
/**
* Warn (non-error) when a filename contains non-ASCII characters.
* The platform requires English-only filenames.
*/
function asciiOnly(value: string): boolean {
return /^[\x20-\x7E]+$/.test(value);
}
function inspectImageRecord(record: Record<string, unknown>, lineNo: number): ValidationIssue[] {
const out: ValidationIssue[] = [];
// --- prompt (required) ---
if (!("prompt" in record)) {
out.push(
makeIssue("error", "MISSING_PROMPT", `Required field "prompt" is missing.`, {
line: lineNo,
path: "prompt",
}),
);
} else {
const prompt = record.prompt;
if (typeof prompt !== "string") {
out.push(
makeIssue("error", "INVALID_PROMPT", `"prompt" must be a string (got ${typeof prompt}).`, {
line: lineNo,
path: "prompt",
}),
);
} else if (prompt.trim().length === 0) {
out.push(
makeIssue("error", "EMPTY_PROMPT", `"prompt" must not be empty / whitespace-only.`, {
line: lineNo,
path: "prompt",
}),
);
}
}
// --- img_path (required) ---
if (!("img_path" in record)) {
out.push(
makeIssue("error", "MISSING_IMG_PATH", `Required field "img_path" is missing.`, {
line: lineNo,
path: "img_path",
}),
);
} else {
const imgPath = record.img_path;
if (typeof imgPath !== "string") {
out.push(
makeIssue(
"error",
"INVALID_IMG_PATH",
`"img_path" must be a string (got ${typeof imgPath}).`,
{ line: lineNo, path: "img_path" },
),
);
} else if (imgPath.trim().length === 0) {
out.push(
makeIssue("error", "EMPTY_IMG_PATH", `"img_path" must not be empty.`, {
line: lineNo,
path: "img_path",
}),
);
} else {
const ext = imageExt(imgPath);
if (!IMAGE_EXTENSIONS.has(ext)) {
out.push(
makeIssue(
"warning",
"UNUSUAL_IMAGE_EXT",
`"img_path" points to a non-standard image extension "${ext || "(none)"}". ` +
`Expected one of: ${[...IMAGE_EXTENSIONS].join(", ")}.`,
{ line: lineNo, path: "img_path" },
),
);
}
if (!asciiOnly(imgPath)) {
out.push(
makeIssue(
"error",
"NON_ASCII_IMG_PATH",
`"img_path" must contain only ASCII characters (English filenames required). Got: "${imgPath}".`,
{ line: lineNo, path: "img_path" },
),
);
}
}
}
// --- input_img (optional — present only for I2I records) ---
if ("input_img" in record) {
const inputImg = record.input_img;
if (typeof inputImg !== "string") {
out.push(
makeIssue(
"error",
"INVALID_INPUT_IMG",
`"input_img" must be a string (got ${typeof inputImg}).`,
{ line: lineNo, path: "input_img" },
),
);
} else if (inputImg.trim().length === 0) {
out.push(
makeIssue("error", "EMPTY_INPUT_IMG", `"input_img" must not be empty.`, {
line: lineNo,
path: "input_img",
}),
);
} else {
const ext = imageExt(inputImg);
if (!IMAGE_EXTENSIONS.has(ext)) {
out.push(
makeIssue(
"warning",
"UNUSUAL_INPUT_IMG_EXT",
`"input_img" points to a non-standard image extension "${ext || "(none)"}". ` +
`Expected one of: ${[...IMAGE_EXTENSIONS].join(", ")}.`,
{ line: lineNo, path: "input_img" },
),
);
}
if (!asciiOnly(inputImg)) {
out.push(
makeIssue(
"error",
"NON_ASCII_INPUT_IMG",
`"input_img" must contain only ASCII characters (English filenames required). Got: "${inputImg}".`,
{ line: lineNo, path: "input_img" },
),
);
}
}
}
return out;
}
/**
* Image generation schema. Auto-detect: a record matches when it carries
* `img_path`. Placed before ChatML in the registry so image data is never
* misclassified.
*/
export const imageSchema: RecordSchemaSpec = {
name: "image",
detect: (record) => "img_path" in record,
inspect: inspectImageRecord,
};
@@ -16,11 +16,21 @@ import type { RecordSchemaSpec } from "./types.ts";
import { chatmlSchema } from "./chatml.ts";
import { cptSchema } from "./cpt.ts";
import { dpoSchema } from "./dpo.ts";
import { ttsSchema } from "./tts.ts";
import { imageSchema } from "./image.ts";
import { videoSchema } from "./video.ts";
// Order matters: DPO (chosen/rejected) and CPT (text) before ChatML (the
// catch-all fallback). Each keys off a distinguishing field so the three
// partition cleanly — DPO never looks like CPT, etc.
export const RECORD_SCHEMAS: RecordSchemaSpec[] = [dpoSchema, cptSchema, chatmlSchema];
// Order matters: TTS (wav_fn), image (img_path), video (first_frame_path/
// video_path), DPO (chosen/rejected) and CPT (text) before ChatML (the catch-
// all fallback). Each keys off a distinguishing field so they partition cleanly.
export const RECORD_SCHEMAS: RecordSchemaSpec[] = [
ttsSchema,
imageSchema,
videoSchema,
dpoSchema,
cptSchema,
chatmlSchema,
];
/**
* Pick the right schema for a single parsed record.
@@ -0,0 +1,117 @@
/**
* TTS record schema — `{"wav_fn": "train/xxx.wav", "text": "..."}`.
*
* Used for audio fine-tuning (e.g. CosyVoice v3 Flash). Each JSONL record
* inside the training data ZIP's `data.jsonl` maps a `.wav` file path to its
* transcript. The ZIP validator (`../zip.ts`) calls into this schema via the
* standard `jsonlValidator` pipeline — the schema only owns per-record checks,
* the ZIP-level structural validation is separate.
*
* Auto-detect: a record matches when it carries `wav_fn` — this is unique to
* audio training data and will never collide with ChatML/DPO/CPT.
*/
import { makeIssue } from "../common.ts";
import type { ValidationIssue } from "../types.ts";
import type { RecordSchemaSpec } from "./types.ts";
function inspectTTSRecord(record: Record<string, unknown>, lineNo: number): ValidationIssue[] {
const out: ValidationIssue[] = [];
// --- wav_fn ---
if (!("wav_fn" in record)) {
out.push(
makeIssue("error", "MISSING_WAV_FN", `Required field "wav_fn" is missing.`, {
line: lineNo,
path: "wav_fn",
}),
);
} else {
const wavFn = record.wav_fn;
if (typeof wavFn !== "string") {
out.push(
makeIssue("error", "INVALID_WAV_FN", `"wav_fn" must be a string (got ${typeof wavFn}).`, {
line: lineNo,
path: "wav_fn",
}),
);
} else if (wavFn.trim().length === 0) {
out.push(
makeIssue("error", "EMPTY_WAV_FN", `"wav_fn" must not be empty.`, {
line: lineNo,
path: "wav_fn",
}),
);
} else {
// CosyVoice requires each wav_fn to reference a `.wav` file placed under
// the `train/` directory (matching the expected ZIP layout). Both are
// hard server-side requirements, so they are surfaced as errors — a
// dataset that violates them passes no useful preflight and would be
// rejected on submit.
if (!wavFn.startsWith("train/")) {
out.push(
makeIssue(
"error",
"WAV_FN_PREFIX",
`"wav_fn" must start with "train/" (got "${wavFn}").`,
{
line: lineNo,
path: "wav_fn",
},
),
);
}
const dotIndex = wavFn.lastIndexOf(".");
const ext = dotIndex >= 0 ? wavFn.slice(dotIndex).toLowerCase() : "";
if (ext !== ".wav") {
out.push(
makeIssue(
"error",
"INVALID_AUDIO_EXT",
`"wav_fn" must reference a .wav file (got "${ext || "(none)"}"). ` +
`CosyVoice training audio must be WAV.`,
{ line: lineNo, path: "wav_fn" },
),
);
}
}
}
// --- text ---
if (!("text" in record)) {
out.push(
makeIssue("error", "MISSING_TEXT", `Required field "text" is missing.`, {
line: lineNo,
path: "text",
}),
);
} else {
const text = record.text;
if (typeof text !== "string") {
out.push(
makeIssue("error", "INVALID_TEXT", `"text" must be a string (got ${typeof text}).`, {
line: lineNo,
path: "text",
}),
);
} else if (text.trim().length === 0) {
out.push(
makeIssue("error", "EMPTY_TEXT", `"text" must not be empty / whitespace-only.`, {
line: lineNo,
path: "text",
}),
);
}
}
return out;
}
/**
* TTS schema. Auto-detect: a record is treated as TTS when it carries `wav_fn`.
* Placed first in the registry so audio data is never misclassified as ChatML.
*/
export const ttsSchema: RecordSchemaSpec = {
name: "tts",
detect: (record) => "wav_fn" in record,
inspect: inspectTTSRecord,
};
@@ -0,0 +1,158 @@
/**
* Video generation record schema — Wan i2v / kf2v fine-tuning.
*
* Two record flavours share the same schema:
* - **Image-to-video, first frame (i2v):**
* `{"prompt": "...", "first_frame_path": "image_1.jpg", "video_path": "video_1.mp4"}`
* - **Image-to-video, first+last frame (kf2v):**
* `{"prompt": "...", "first_frame_path": "image/x_first.jpg",
* "last_frame_path": "image/x_last.jpg", "video_path": "video/x.mp4"}`
*
* The presence of `first_frame_path` / `video_path` is the distinguishing
* signal — auto-detect picks this schema before the ChatML fallback.
*
* `video_path` is OPTIONAL: validation-set records omit the target video (the
* platform generates preview videos from the first frame + prompt at each eval
* checkpoint), so the same schema validates both training and validation zips.
* `last_frame_path` is optional (kf2v only).
*
* Video data lives in a ZIP: i2v is flat, kf2v uses `image/` + `video/`
* subfolders. File names should be ASCII-only per platform requirements.
*/
import { makeIssue } from "../common.ts";
import type { ValidationIssue } from "../types.ts";
import type { RecordSchemaSpec } from "./types.ts";
/** Accepted image (frame) file extensions (lower-case, with dot). */
export const VIDEO_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".bmp", ".webp"]);
/** Accepted video file extensions (lower-case, with dot). */
export const VIDEO_EXTENSIONS = new Set([".mp4", ".mov"]);
/** Return the lower-case extension of a path (with dot), or "". */
function pathExt(path: string): string {
const dot = path.lastIndexOf(".");
return dot >= 0 ? path.slice(dot).toLowerCase() : "";
}
/** The platform requires English-only (ASCII) file names. */
function asciiOnly(value: string): boolean {
return /^[\x20-\x7E]+$/.test(value);
}
/**
* Validate a required string path field, checking its extension against the
* accepted set and warning on non-ASCII names. Pushes issues into `out`.
*/
function checkPathField(
out: ValidationIssue[],
record: Record<string, unknown>,
field: string,
required: boolean,
accepted: Set<string>,
lineNo: number,
): void {
if (!(field in record)) {
if (required) {
out.push(
makeIssue("error", "MISSING_FIELD", `Required field "${field}" is missing.`, {
line: lineNo,
path: field,
}),
);
}
return;
}
const value = record[field];
if (typeof value !== "string") {
out.push(
makeIssue("error", "INVALID_FIELD", `"${field}" must be a string (got ${typeof value}).`, {
line: lineNo,
path: field,
}),
);
return;
}
if (value.trim().length === 0) {
out.push(
makeIssue("error", "EMPTY_FIELD", `"${field}" must not be empty.`, {
line: lineNo,
path: field,
}),
);
return;
}
const ext = pathExt(value);
if (!accepted.has(ext)) {
out.push(
makeIssue(
"warning",
"UNUSUAL_MEDIA_EXT",
`"${field}" points to a non-standard extension "${ext || "(none)"}". ` +
`Expected one of: ${[...accepted].join(", ")}.`,
{ line: lineNo, path: field },
),
);
}
if (!asciiOnly(value)) {
out.push(
makeIssue(
"error",
"NON_ASCII_PATH",
`"${field}" must contain only ASCII characters (English filenames required). Got: "${value}".`,
{ line: lineNo, path: field },
),
);
}
}
function inspectVideoRecord(record: Record<string, unknown>, lineNo: number): ValidationIssue[] {
const out: ValidationIssue[] = [];
// --- prompt (required) ---
if (!("prompt" in record)) {
out.push(
makeIssue("error", "MISSING_PROMPT", `Required field "prompt" is missing.`, {
line: lineNo,
path: "prompt",
}),
);
} else {
const prompt = record.prompt;
if (typeof prompt !== "string") {
out.push(
makeIssue("error", "INVALID_PROMPT", `"prompt" must be a string (got ${typeof prompt}).`, {
line: lineNo,
path: "prompt",
}),
);
} else if (prompt.trim().length === 0) {
out.push(
makeIssue("error", "EMPTY_PROMPT", `"prompt" must not be empty / whitespace-only.`, {
line: lineNo,
path: "prompt",
}),
);
}
}
// --- first_frame_path (required) ---
checkPathField(out, record, "first_frame_path", true, VIDEO_IMAGE_EXTENSIONS, lineNo);
// --- last_frame_path (optional — kf2v only) ---
checkPathField(out, record, "last_frame_path", false, VIDEO_IMAGE_EXTENSIONS, lineNo);
// --- video_path (optional — training only; validation sets omit it) ---
checkPathField(out, record, "video_path", false, VIDEO_EXTENSIONS, lineNo);
return out;
}
/**
* Video generation schema. Auto-detect: a record matches when it carries
* `first_frame_path` or `video_path`. Placed before ChatML in the registry so
* video data is never misclassified. Distinct from image (`img_path`).
*/
export const videoSchema: RecordSchemaSpec = {
name: "video",
detect: (record) => "first_frame_path" in record || "video_path" in record,
inspect: inspectVideoRecord,
};
+8 -1
View File
@@ -32,10 +32,17 @@ export interface ValidateOpts {
* platform ten minutes in.
*/
schema?: DatasetSchema;
/**
* Model identifier (`--model`) forwarded for schema-agnostic cross-checks —
* e.g. the video validator uses it to verify a `kf2v` model is paired with
* first+last-frame data. Optional: absent for bare file-id flows.
*/
model?: string;
}
/** The schemas a `.jsonl` record can be validated against. */
export type DatasetSchema = "chatml" | "dpo" | "cpt";
export type DatasetSchema = "chatml" | "dpo" | "cpt" | "tts" | "image" | "video";
export type ValidationSeverity = "error" | "warning";
+363
View File
@@ -0,0 +1,363 @@
/**
* ZIP validator — audio / image / video training data archives.
*
* A training data ZIP must have:
* - `data.jsonl` at the root — the manifest mapping media files to labels.
* - A `train/` subfolder (or media files at the root) referenced by the
* manifest entries.
*
* This validator owns the **ZIP-level structural checks** (entries present,
* references resolve). The **per-record JSONL content validation** is delegated
* to the existing `jsonlValidator` — we extract `data.jsonl` to a temp file,
* run the full pipeline (quickScan + deepCheck + schema dispatch), and stitch
* the results together.
*
* The schema for `data.jsonl` records is passed via `opts.schema` (typically
* `"tts"` for audio). The profile layer decides which schema to use based on
* the detected modality — this validator is schema-agnostic.
*/
import { createReadStream, createWriteStream, mkdirSync, rmSync } from "fs";
import { createInterface } from "readline";
import { tmpdir } from "os";
import { join } from "path";
import { pipeline } from "stream/promises";
import { randomBytes } from "crypto";
import * as yauzl from "yauzl";
import type { ValidatorSpec, ValidateOpts, ValidationResult, ValidationIssue } from "./types.ts";
import { makeIssue } from "./common.ts";
import { jsonlValidator } from "./jsonl.ts";
import { IMAGE_EXTENSIONS } from "./schemas/image.ts";
/**
* Open a ZIP archive and locate a specific entry by name.
* Returns the entry and zipfile handle — **caller must close the zipfile**.
* Normalises entry names (backslash → forward-slash) and supports entries
* at the root or inside subdirectories (matches `name === targetName` or
* `name.endsWith("/${targetName}")`).
*
* Shared by `extractZipEntry` (this file) and `readFirstLineFromZipEntry`
* (inspect.ts) to avoid duplicating yauzl open/iterate/locate boilerplate.
*/
export function openZipAndFindEntry(
zipPath: string,
targetName: string,
): Promise<{ entry: yauzl.Entry; zipfile: yauzl.ZipFile }> {
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
if (err || !zipfile) {
reject(new Error(`Failed to open ZIP: ${err?.message ?? "unknown error"}`));
return;
}
zipfile.readEntry();
zipfile.on("entry", (entry) => {
const name = entry.fileName.replace(/\\/g, "/");
if (name === targetName || name.endsWith(`/${targetName}`)) {
resolve({ entry, zipfile });
} else {
zipfile.readEntry();
}
});
zipfile.on("end", () => {
zipfile.close();
reject(new Error(`Entry "${targetName}" not found in ZIP`));
});
zipfile.on("error", reject);
});
});
}
/**
* Collect all entry paths from a ZIP archive using yauzl.
* Returns normalised forward-slash paths.
*/
function collectZipEntries(zipPath: string): Promise<string[]> {
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
if (err || !zipfile) {
reject(new Error(`Failed to open ZIP: ${err?.message ?? "unknown error"}`));
return;
}
const entries: string[] = [];
zipfile.readEntry();
zipfile.on("entry", (entry) => {
entries.push(entry.fileName.replace(/\\/g, "/"));
zipfile.readEntry();
});
zipfile.on("end", () => {
zipfile.close();
resolve(entries);
});
zipfile.on("error", reject);
});
});
}
/**
* Extract a single entry from a ZIP archive to a destination path.
*/
async function extractZipEntry(
zipPath: string,
entryName: string,
destPath: string,
): Promise<void> {
const { entry, zipfile } = await openZipAndFindEntry(zipPath, entryName);
return new Promise((resolve, reject) => {
zipfile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) {
zipfile.close();
reject(streamErr ?? new Error("Failed to open entry stream"));
return;
}
const writeStream = createWriteStream(destPath);
pipeline(readStream, writeStream)
.then(() => {
zipfile.close();
resolve();
})
.catch((pipelineError) => {
zipfile.close();
reject(pipelineError);
});
});
});
}
/**
* Read media file references from the first N records of a JSONL file to verify
* that referenced files exist inside the ZIP.
*
* Collects from all known schema fields: `wav_fn` (audio), `img_path` +
* `input_img` (image generation), `first_frame_path` / `last_frame_path` /
* `video_path` (video generation), `image_fn` / `video_fn` (legacy).
*/
async function collectMediaRefs(
jsonlPath: string,
maxLines = 100,
): Promise<{ refs: string[]; totalLines: number }> {
const stream = createReadStream(jsonlPath, { encoding: "utf8" });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
const refs: string[] = [];
let totalLines = 0;
for await (const raw of rl) {
totalLines++;
if (refs.length >= maxLines) continue;
const line = raw.trim();
if (line.length === 0) continue;
try {
const obj = JSON.parse(line) as Record<string, unknown>;
if (typeof obj.wav_fn === "string") refs.push(obj.wav_fn);
if (typeof obj.img_path === "string") refs.push(obj.img_path);
if (typeof obj.input_img === "string") refs.push(obj.input_img);
if (typeof obj.first_frame_path === "string") refs.push(obj.first_frame_path);
if (typeof obj.last_frame_path === "string") refs.push(obj.last_frame_path);
if (typeof obj.video_path === "string") refs.push(obj.video_path);
if (typeof obj.image_fn === "string") refs.push(obj.image_fn);
if (typeof obj.video_fn === "string") refs.push(obj.video_fn);
} catch {
// Parse errors are reported by the JSONL validator, not here.
}
}
return { refs, totalLines };
}
export const zipValidator: ValidatorSpec = {
format: "zip",
extensions: [".zip"],
async validate(filePath: string, opts: ValidateOpts): Promise<ValidationResult> {
const start = Date.now();
const errors: ValidationIssue[] = [];
const warnings: ValidationIssue[] = [];
// --- 1. Collect ZIP entries ---
let entries: string[];
try {
entries = await collectZipEntries(filePath);
} catch (openError) {
return {
valid: false,
format: "zip",
filePath,
errors: [
makeIssue(
"error",
"ZIP_OPEN_FAILED",
`Could not open ZIP archive: ${(openError as Error).message}`,
),
],
warnings: [],
stats: { durationMs: Date.now() - start },
};
}
if (entries.length === 0) {
return {
valid: false,
format: "zip",
filePath,
errors: [makeIssue("error", "ZIP_EMPTY", `ZIP archive contains no entries.`)],
warnings: [],
stats: { durationMs: Date.now() - start },
};
}
// --- 2. Check for data.jsonl ---
const hasDataJsonl = entries.some(
(entry) => entry === "data.jsonl" || entry.endsWith("/data.jsonl"),
);
if (!hasDataJsonl) {
errors.push(
makeIssue(
"error",
"MISSING_DATA_JSONL",
`ZIP archive must contain "data.jsonl" at the root. ` +
`This file maps media files (e.g. .wav) to their labels.`,
),
);
}
// --- 3. Check for train/ directory (modality-aware) ---
// Audio TTS data uses a train/ subdirectory; image and video generation
// data do not (image is flat; video i2v is flat, kf2v uses image//video/).
// Only warn about missing train/ for audio (schema === "tts" or auto-detect
// when we don't know the schema yet).
const isImageSchema = opts.schema === "image";
const isVideoSchema = opts.schema === "video";
const hasTrainDir = entries.some((entry) => entry === "train/" || entry.startsWith("train/"));
// The train/ layout is an audio-TTS convention (media referenced as
// "train/xxx.wav"). It is only meaningful when the archive actually contains
// .wav files — image/video ZIPs (flat, or kf2v's image//video/ layout)
// legitimately have no train/ dir. Gating on .wav presence also fixes the
// auto-detect path (opts.schema undefined), where we would otherwise
// false-warn on every image/video archive.
const hasWavFiles = entries.some((entry) => entry.toLowerCase().endsWith(".wav"));
if (!hasTrainDir && !isImageSchema && !isVideoSchema && hasWavFiles) {
warnings.push(
makeIssue(
"warning",
"NO_TRAIN_DIR",
`No "train/" directory found in the ZIP. Media files are typically ` +
`placed under "train/" and referenced as "train/xxx.wav" in data.jsonl.`,
),
);
}
// --- 3b. Minimum image count check (image generation only) ---
// The platform requires at least 25 training images (50+ recommended).
if (isImageSchema) {
const MIN_IMAGES = 25;
const imageFiles = entries.filter((entry) => {
if (entry === "data.jsonl" || entry.endsWith("/data.jsonl")) return false;
if (entry.endsWith("/")) return false; // directory entries
const dot = entry.lastIndexOf(".");
const ext = dot >= 0 ? entry.slice(dot).toLowerCase() : "";
return IMAGE_EXTENSIONS.has(ext);
});
if (imageFiles.length < MIN_IMAGES) {
errors.push(
makeIssue(
"error",
"INSUFFICIENT_IMAGES",
`Found ${imageFiles.length} image(s) in ZIP, but image generation fine-tuning ` +
`requires at least ${MIN_IMAGES} images (50+ recommended).`,
),
);
}
}
// If data.jsonl is missing, we can't do JSONL content validation.
if (!hasDataJsonl) {
return {
valid: false,
format: "zip",
filePath,
errors,
warnings,
stats: { totalRecords: entries.length, durationMs: Date.now() - start },
};
}
// --- 4. Extract data.jsonl to a temp file and run jsonlValidator ---
const dataJsonlEntry = entries.find(
(entry) => entry === "data.jsonl" || entry.endsWith("/data.jsonl"),
)!;
const tmpDir = join(tmpdir(), `bl-zip-${randomBytes(6).toString("hex")}`);
mkdirSync(tmpDir, { recursive: true });
const tmpJsonl = join(tmpDir, "data.jsonl");
try {
await extractZipEntry(filePath, dataJsonlEntry, tmpJsonl);
} catch (extractError) {
errors.push(
makeIssue(
"error",
"EXTRACT_FAILED",
`Failed to extract "data.jsonl" from ZIP: ${(extractError as Error).message}`,
),
);
rmSync(tmpDir, { recursive: true, force: true });
return {
valid: false,
format: "zip",
filePath,
errors,
warnings,
stats: { durationMs: Date.now() - start },
};
}
// Delegate JSONL content validation. The profile layer passes opts.schema
// (e.g. "tts") so the right record-schema spec is used.
const jsonlResult = await jsonlValidator.validate(tmpJsonl, opts);
errors.push(...jsonlResult.errors);
warnings.push(...jsonlResult.warnings);
// --- 5. Verify media file references (sample first 100 records) ---
if (jsonlResult.valid) {
const { refs } = await collectMediaRefs(tmpJsonl);
const entrySet = new Set(entries);
// Media paths in data.jsonl are relative to the manifest's location. Many
// official sample archives wrap everything in a single top-level folder
// (e.g. "wan-i2v-valid-dataset/data.jsonl" alongside
// "wan-i2v-valid-dataset/image_1.jpg"), so a bare "image_1.jpg" ref
// resolves against that folder, not the ZIP root. Derive the manifest's
// directory prefix and accept either the wrapped or root-relative form.
const slash = dataJsonlEntry.lastIndexOf("/");
const baseDir = slash >= 0 ? dataJsonlEntry.slice(0, slash + 1) : "";
const danglingRefs: string[] = [];
for (const ref of refs) {
// Normalise: some archives use "train/foo.wav", some use "./train/foo.wav".
const normalised = ref.replace(/^\.\//, "");
if (entrySet.has(normalised) || entrySet.has(baseDir + normalised)) continue;
danglingRefs.push(ref);
}
if (danglingRefs.length > 0) {
const shown = danglingRefs.slice(0, 5).join(", ");
const suffix = danglingRefs.length > 5 ? ` (and ${danglingRefs.length - 5} more)` : "";
errors.push(
makeIssue(
"error",
"DANGLING_MEDIA_REFS",
`${danglingRefs.length} media file(s) referenced in data.jsonl not found in ZIP: ${shown}${suffix}`,
),
);
}
}
// --- 6. Clean up ---
rmSync(tmpDir, { recursive: true, force: true });
return {
valid: errors.length === 0,
format: "zip",
filePath,
errors,
warnings,
stats: {
totalRecords: jsonlResult.stats.totalRecords ?? entries.length,
sampledRecords: jsonlResult.stats.sampledRecords,
durationMs: Date.now() - start,
},
};
},
};
+58
View File
@@ -0,0 +1,58 @@
/**
* Deploy-domain constants — billing plans, billing methods and template
* charge types. Centralised here so no `deploy` command carries a magic
* string for these server-contract values.
*/
/** Billing plan (`--plan` value, matches the server's deployment plan). */
export const DEPLOY_PLAN = {
/** Token-billed; the CLI default. */
LORA: "lora",
/** Token-billed, provisioned throughput. */
PTU: "ptu",
/** Model-unit-billed. */
MU: "mu",
} as const;
export type DeployPlan = (typeof DEPLOY_PLAN)[keyof typeof DEPLOY_PLAN];
/** CLI default plan when `--plan` is omitted. */
export const DEFAULT_DEPLOY_PLAN: DeployPlan = DEPLOY_PLAN.LORA;
/** Deployment target modality — fixes the default plan when `--plan` is omitted. */
export type DeployModality = "text" | "audio" | "image";
/**
* Default plan per modality when `--plan` is omitted.
*
* The contract differs by modality (verified against the DashScope docs):
* - text / image LoRA outputs deploy Token-billed (`lora`) — the image
* fine-tune guide's deploy example uses `plan: "lora"`.
* - CosyVoice (audio TTS) outputs deploy model-unit-billed (`mu`) — the
* speech-synthesis guide fixes `plan: "mu"` and requires deploy_spec /
* capacity / billing_method (all auto-picked by the mu strategy).
*/
export function defaultDeployPlan(modality: DeployModality): DeployPlan {
return modality === "audio" ? DEPLOY_PLAN.MU : DEFAULT_DEPLOY_PLAN;
}
/** Billing method (`billing_method`, plan=mu only). */
export const BILLING_METHOD = {
/** Post-paid (currently the only server-supported value). */
POST_PAY: "POST_PAY",
/** Pre-paid. */
PRE_PAY: "PRE_PAY",
} as const;
export type BillingMethod = (typeof BILLING_METHOD)[keyof typeof BILLING_METHOD];
/** Default billing method for plan=mu when `--billing-method` is omitted. */
export const DEFAULT_BILLING_METHOD: BillingMethod = BILLING_METHOD.POST_PAY;
/** Template charge type (`charge_type`) returned by the deployable-models catalog. */
export const CHARGE_TYPE = {
POST_PAID: "post_paid",
PRE_PAID: "pre_paid",
} as const;
export type ChargeType = (typeof CHARGE_TYPE)[keyof typeof CHARGE_TYPE];
+2
View File
@@ -1,2 +1,4 @@
export * from "./api.ts";
export * from "./types.ts";
export * from "./constants.ts";
export * from "./plans.ts";
@@ -1,5 +1,5 @@
/**
* Per-plan strategy table for `bl deploy create`.
* Per-plan strategy table for `deploy <modality> create`.
*
* Each PlanStrategy owns one slice of plan-specific behaviour:
* - required-flag checks (returned as validate-style error strings)
@@ -7,18 +7,23 @@
* catalog; lora/ptu are pure)
* - the plan-specific body fragment for POST /api/v1/deployments
*
* 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 places where lora / ptu /
* mu used to be hard-coded (default value list / required-flag checks /
* auto-pick / body assembly) into one strategy entry per plan.
* The dispatcher in the `deploy <modality> create` command only knows about
* `STRATEGIES[plan]`. Adding a new plan = one new strategy object + one line in
* `STRATEGIES`. Nothing in the command needs to change. This collapses the
* places where lora / ptu / mu used to be hard-coded (default value list /
* required-flag checks / auto-pick / body assembly) into one strategy entry per
* plan.
*/
import { listDeployableModels, BailianError, ExitCode, type Client } from "bailian-cli-core";
import { listDeployableModels } from "./api.ts";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type { Client } from "../client/client.ts";
import { DEPLOY_PLAN, BILLING_METHOD, CHARGE_TYPE, DEFAULT_BILLING_METHOD } from "./constants.ts";
/** Plan-relevant subset of `deploy create` flags (parsed flags satisfy this shape). */
/** Plan-relevant subset of `deploy <modality> create` flags (parsed flags satisfy this shape). */
export interface CreatePlanFlags {
plan?: string;
templateId?: string;
deploySpec?: string;
capacity?: number;
billingMethod?: string;
inputTpm?: number;
@@ -65,7 +70,7 @@ export interface PlanStrategy {
* the CLI injects `1` as a placeholder.
*/
const loraStrategy: PlanStrategy = {
name: "lora",
name: DEPLOY_PLAN.LORA,
validateFlags() {
return undefined; /* no required flags */
},
@@ -81,7 +86,7 @@ const loraStrategy: PlanStrategy = {
* required.
*/
const ptuStrategy: PlanStrategy = {
name: "ptu",
name: DEPLOY_PLAN.PTU,
validateFlags(flags) {
if (flags.inputTpm === undefined || flags.outputTpm === undefined) {
return "--input-tpm and --output-tpm are required for plan=ptu.";
@@ -101,35 +106,35 @@ const ptuStrategy: PlanStrategy = {
};
/**
* `mu` (model-unit-billed). `capacity`, `billing_method` and `template_id` are
* `mu` (model-unit-billed). `capacity`, `billing_method` and `deploy_spec` 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
* - deploy_spec 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:
* The catalog lookup is skipped when `--deploy-spec` 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",
name: DEPLOY_PLAN.MU,
validateFlags() {
return undefined; /* every required field has a default — nothing to assert up-front */
},
async resolve(ctx: PlanContext): Promise<PlanResolved> {
const billingMethod = ctx.flags.billingMethod || "POST_PAY";
let templateId = ctx.flags.templateId;
const billingMethod = ctx.flags.billingMethod || DEFAULT_BILLING_METHOD;
let deploySpec = ctx.flags.deploySpec;
let capacity = ctx.flags.capacity;
if (!ctx.dryRun && !templateId) {
if (!ctx.dryRun && !deploySpec) {
const noTemplateError = () =>
new BailianError(
`No mu-plan template found for model "${ctx.model}". ` +
`Run \`${ctx.binName} deploy models --source base\` to inspect available models, ` +
`or pass --template-id explicitly.`,
`or pass --deploy-spec explicitly.`,
ExitCode.USAGE,
);
try {
@@ -139,23 +144,25 @@ const muStrategy: PlanStrategy = {
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 target = (payload?.models ?? []).find((model) => model.model_name === ctx.model);
const muPlan = target?.plans?.find(({ plan }) => plan === DEPLOY_PLAN.MU);
const templates = muPlan?.templates ?? [];
if (templates.length === 0) throw noTemplateError();
// 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 noTemplateError();
templateId = picked.template_id;
const wantChargeType =
billingMethod === BILLING_METHOD.POST_PAY ? CHARGE_TYPE.POST_PAID : CHARGE_TYPE.PRE_PAID;
const picked =
templates.find((template) => template.charge_type === wantChargeType) ?? templates[0];
if (!picked?.deploy_spec && !picked?.template_id) throw noTemplateError();
deploySpec = picked.deploy_spec ?? picked.template_id;
if (capacity === undefined) {
capacity = picked.roles?.unified?.capacity_unit_per_instance ?? 1;
}
} catch (e) {
if (e instanceof BailianError) throw e;
} catch (error) {
if (error instanceof BailianError) throw error;
throw new BailianError(
`Failed to auto-pick template for plan=mu: ${(e as Error).message}. ` +
`Pass --template-id explicitly.`,
`Failed to auto-pick template for plan=mu: ${(error as Error).message}. ` +
`Pass --deploy-spec explicitly.`,
ExitCode.USAGE,
);
}
@@ -165,7 +172,7 @@ const muStrategy: PlanStrategy = {
capacity: capacity ?? 1,
billing_method: billingMethod,
};
if (templateId) body.template_id = templateId;
if (deploySpec) body.deploy_spec = deploySpec;
return { body };
},
};
@@ -173,23 +180,23 @@ const muStrategy: PlanStrategy = {
/**
* 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
* accepted by the create endpoint, so the dispatcher in the command will
* reject anything outside this table with a clear USAGE error.
*/
export const STRATEGIES: Record<string, PlanStrategy> = {
lora: loraStrategy,
ptu: ptuStrategy,
mu: muStrategy,
[DEPLOY_PLAN.LORA]: loraStrategy,
[DEPLOY_PLAN.PTU]: ptuStrategy,
[DEPLOY_PLAN.MU]: muStrategy,
};
/** Throws USAGE if `plan` is not in the strategy table. */
export function pickPlanStrategy(plan: string): PlanStrategy {
const s = STRATEGIES[plan];
if (!s) {
const strategy = STRATEGIES[plan];
if (!strategy) {
throw new BailianError(
`Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`,
ExitCode.USAGE,
);
}
return s;
return strategy;
}
+21 -2
View File
@@ -119,8 +119,8 @@ export interface CreateDeploymentRequest {
plan: string;
/** Required by API even for token-billed (lora) plans where it is ignored — CLI injects 1. */
capacity?: number;
/** Optional template id for advanced configurations. */
template_id?: string;
/** Deploy spec id (e.g. "MU1", "dps-..."), sent as `deploy_spec` in POST body. */
deploy_spec?: string;
/**
* PTU capacity (provisioned throughput limits). Only effective when
* `plan === "ptu"`. The doc says this defaults to 10000/1000 when omitted,
@@ -128,10 +128,29 @@ export interface CreateDeploymentRequest {
* info"), so the CLI treats it as required for ptu.
*/
ptu_capacity?: PtuCapacity;
/**
* AIGC generation config for fine-tuned Wan video (i2v/kf2v) LoRA deployments.
* Ignored by non-video plans. See `AigcConfig`.
*/
aigc_config?: AigcConfig;
/** Future-compat: arbitrary additional fields are forwarded as-is. */
[k: string]: unknown;
}
/**
* AIGC generation config — used when deploying fine-tuned Wan video (i2v/kf2v)
* LoRA models. Controls how prompts are applied at inference time:
* - use_input_prompt=false: ignore the caller's prompt, use the preset
* `prompt` template instead (the common LoRA case).
* - use_input_prompt=true: honor the caller's prompt.
* `lora_prompt_default` is the default trigger-word phrase appended for the LoRA.
*/
export interface AigcConfig {
use_input_prompt?: boolean;
prompt?: string;
lora_prompt_default?: string;
}
/** PTU throughput limits — only used when `plan === "ptu"`. */
export interface PtuCapacity {
/** Max input tokens per minute (all models). */
+1 -1
View File
@@ -144,7 +144,7 @@ export async function listCheckpoints(
* GET /api/v1/fine-tunes/{job_id}/export/{checkpoint}?model_name={name}
*
* Publishes a training checkpoint as a deployable model — required before
* `bl deploy create` can target it. The platform may auto-export the best
* `deploy <modality> create` can target it. The platform may auto-export the best
* checkpoint on SUCCEEDED, but explicit export is the canonical path.
*/
export async function exportCheckpoint(
-5
View File
@@ -57,11 +57,6 @@ export function isTrainingTypeCli(value: string): value is TrainingTypeCli {
return value in TRAINING_TYPE_MAP;
}
/** Map a CLI training type to the server `training_type` for the request body. */
export function toServerTrainingType(value: TrainingTypeCli): string {
return TRAINING_TYPE_MAP[value].server;
}
/** The (method, variant) pair a CLI training type resolves to. */
export function trainingTypeMethodVariant(value: TrainingTypeCli): {
method: string;
+1
View File
@@ -2,3 +2,4 @@ export * from "./types.ts";
export * from "./api.ts";
export * from "./capability.ts";
export * from "./preflight.ts";
export * from "./profiles/index.ts";
@@ -0,0 +1,79 @@
/**
* Shared helpers for training profiles.
*
* Most text-based training types (sft, dpo, cpt, and their -lora variants)
* share the same hyper-parameter resolution logic: n_epochs defaults to 3,
* learning_rate and max_length are set only when explicitly provided, and
* batch_size is clamped to the server's [8, 1024] range. Extracting this
* into a single helper prevents drift when defaults or bounds change.
*/
import type { TrainingProfile, DataModality } from "./types.ts";
import type { ValidateOpts, ValidationResult } from "../../dataset/validate/types.ts";
import type { DatasetSchema } from "../../dataset/validate/types.ts";
import { validateDataset } from "../../dataset/validate/registry.ts";
/**
* Resolve text-mode hyper-parameters from CLI flags.
*
* Shared by every profile's text branch (and by profiles that only support
* text). The audio branch in `sft-lora` uses its own `AUDIO_HYPER_PARAMS`.
*/
export function resolveTextHyperParameters(
flags: Record<string, unknown>,
): Record<string, unknown> {
const hp: Record<string, unknown> = {};
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;
if (flags.batchSize !== undefined) {
const requested = flags.batchSize as number;
let batchSize = requested;
if (batchSize < 8) batchSize = 8;
if (batchSize > 1024) batchSize = 1024;
hp.batch_size = batchSize;
}
return hp;
}
/**
* Factory for text-only training profiles (sft, dpo, dpo-lora, cpt).
*
* These profiles are structurally identical — they differ only in three
* string constants (CLI name, server name, record schema). Using a factory
* eliminates four near-identical files and prevents drift when the
* TrainingProfile interface changes.
*/
export function textProfile(
clientTrainingType: string,
serverTrainingType: string,
schema: DatasetSchema,
): TrainingProfile {
return {
clientTrainingType,
serverTrainingType,
acceptedExtensions: [".jsonl"],
async validate(
filePath: string,
_modality: DataModality,
opts: ValidateOpts,
): Promise<ValidationResult> {
return validateDataset(filePath, { ...opts, schema });
},
resolveHyperParameters(
_modality: DataModality,
flags: Record<string, unknown>,
): Record<string, unknown> {
return resolveTextHyperParameters(flags);
},
shouldSkipGate(_gate: string, _modality: DataModality): boolean {
return false;
},
shouldSkipCapabilityCheck(_modality: DataModality): boolean {
return false;
},
};
}
@@ -0,0 +1,7 @@
/**
* `cpt` profile — Continual Pre-Training (full-parameter).
* Maps to the server's `cpt` training type. CPT record schema.
*/
import { textProfile } from "./common.ts";
export const cptProfile = textProfile("cpt", "cpt", "cpt");

Some files were not shown because too many files have changed in this diff Show More