mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Merge branch 'main' of github.com:modelstudioai/cli into feat/cli-access-token
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
```
|
||||
@@ -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).
|
||||
|
||||
@@ -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` 入口:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"ajv": "catalog:",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"e2e": "workspace:*",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "catalog:",
|
||||
"vite-plus": "0.1.22",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,11 +2,16 @@ import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
createDeployment,
|
||||
pickPlanStrategy,
|
||||
STRATEGIES,
|
||||
defaultDeployPlan,
|
||||
type DeployModality,
|
||||
type CreateDeploymentRequest,
|
||||
type CreatePlanFlags,
|
||||
type CommandContext,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { pickPlanStrategy, STRATEGIES } from "./plans.ts";
|
||||
|
||||
const CREATE_FLAGS = {
|
||||
model: {
|
||||
@@ -26,10 +31,10 @@ const CREATE_FLAGS = {
|
||||
valueHint: "<plan>",
|
||||
description: "Billing plan: lora (default, Token-billed) | ptu (Token-billed) | mu",
|
||||
},
|
||||
templateId: {
|
||||
deploySpec: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Template id (only used by plan=mu; auto-picked if omitted)",
|
||||
description: "Deploy spec (only used by plan=mu; auto-picked if omitted)",
|
||||
},
|
||||
capacity: {
|
||||
type: "number",
|
||||
@@ -58,104 +63,156 @@ const CREATE_FLAGS = {
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const CREATE_USAGE =
|
||||
"--model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
|
||||
|
||||
const CREATE_NOTES = [
|
||||
"Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-",
|
||||
"billed) for audio (CosyVoice TTS). Pass --plan to override.",
|
||||
"For plan=ptu (Token-billed, provisioned throughput), --input-tpm and",
|
||||
"--output-tpm are required (the platform rejects creation without an",
|
||||
"explicit ptu_capacity despite the doc listing defaults).",
|
||||
"For plan=mu, `capacity`, `billing_method` and `deploy_spec` are required.",
|
||||
"billing_method defaults to POST_PAY (only supported value); deploy_spec",
|
||||
"and capacity are auto-picked from GET /deployments/models when omitted.",
|
||||
"Use `bl deploy models --source base` to inspect available templates.",
|
||||
"After creation, status starts at PENDING and transitions to RUNNING.",
|
||||
"Invoke the deployed model with: bl text chat --model <deployed_model>",
|
||||
"WARNING: --model is overloaded across commands and refers to DIFFERENT",
|
||||
"values. `bl deploy <modality> create --model` takes the exported model_name",
|
||||
"(e.g. `qwen3-8b-ft-...`), but the create response also returns a",
|
||||
"`deployed_model` field (the deployment instance id, e.g.",
|
||||
"`qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use",
|
||||
"the `deployed_model` from the create response — NOT the `model_name` you",
|
||||
"passed to `deploy <modality> create`. Do not reuse the value across the two",
|
||||
"commands.",
|
||||
];
|
||||
|
||||
/**
|
||||
* `bl deploy create` — create a model deployment.
|
||||
*
|
||||
* Plan-specific behaviour (required flags / body assembly / auto-pick) lives
|
||||
* in `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file only handles the
|
||||
* shared envelope: flag validation, dispatch, dry-run, and result
|
||||
* formatting. Adding a new plan = one entry in the strategy table;
|
||||
* nothing here changes.
|
||||
*
|
||||
* `--model` (model identifier) and `--name` (console display name) are required.
|
||||
* Shared `deploy <modality> create` flag validation. Plan support is
|
||||
* server-catalog-driven, so validation is identical for every modality: resolve
|
||||
* the effective plan (modality-specific default when --plan is omitted), reject
|
||||
* an unknown --plan, then defer to the plan strategy's required-flag check.
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Create a model deployment",
|
||||
function validateCreate(modality: DeployModality, flags: CreatePlanFlags): string | undefined {
|
||||
const plan = flags.plan || defaultDeployPlan(modality);
|
||||
const strategy = STRATEGIES[plan];
|
||||
if (!strategy) {
|
||||
return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`;
|
||||
}
|
||||
return strategy.validateFlags(flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `deploy <modality> create` implementation. deploy create takes a model
|
||||
* by name and a billing plan — it does NOT inspect data modality for the request
|
||||
* body, so the run logic is identical across text / audio / image. The modality
|
||||
* only fixes the default plan (audio → mu, text/image → lora) and the command
|
||||
* path / description / examples.
|
||||
*
|
||||
* Plan-specific behaviour (required flags / body assembly / auto-pick) lives in
|
||||
* core `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file only handles the
|
||||
* shared envelope: dispatch, dry-run, and result formatting.
|
||||
*/
|
||||
async function runCreate(
|
||||
modality: DeployModality,
|
||||
ctx: CommandContext<typeof CREATE_FLAGS>,
|
||||
): Promise<void> {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const model = flags.model as string;
|
||||
const name = flags.name as string;
|
||||
const plan = (flags.plan as string | undefined) || defaultDeployPlan(modality);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Plan-specific behaviour is owned by core `plans.ts`. The strategy resolves
|
||||
// the plan-specific body fragment (mu may auto-pick a template from the
|
||||
// deployable-models catalog). Anything outside the strategy table was
|
||||
// already rejected by `validate` above.
|
||||
const strategy = pickPlanStrategy(plan);
|
||||
|
||||
const resolved = await strategy.resolve({
|
||||
client: ctx.client,
|
||||
dryRun: settings.dryRun,
|
||||
binName: identity.binName,
|
||||
flags: flags as CreatePlanFlags,
|
||||
model,
|
||||
name,
|
||||
});
|
||||
const body: Record<string, unknown> = {
|
||||
model_name: model,
|
||||
name,
|
||||
plan,
|
||||
...resolved.body,
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.create", body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await createDeployment(ctx.client, body as CreateDeploymentRequest);
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(deployment?.deployed_model ?? "");
|
||||
} else if (format === "text") {
|
||||
emitBare(`Created deployment.`);
|
||||
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
|
||||
if (deployment?.status) emitBare(` status: ${deployment.status}`);
|
||||
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
|
||||
emitBare(
|
||||
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
|
||||
);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
}
|
||||
|
||||
/** `bl deploy text create` — deploy a text model. */
|
||||
export const deployTextCreate = defineCommand({
|
||||
description: "Create a text model deployment",
|
||||
auth: "apiKey",
|
||||
usageArgs:
|
||||
"--model <model_name> --name <display_name> [--plan <plan>] [--template-id <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]",
|
||||
usageArgs: CREATE_USAGE,
|
||||
flags: CREATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model my-qwen-sft --name my-sft-test",
|
||||
"--model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
|
||||
"--model qwen3-8b --name my-qwen3-mu --plan mu",
|
||||
"--model qwen3-8b --name my-qwen3 --plan mu --template-id MU1 --capacity 2",
|
||||
"--model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
|
||||
],
|
||||
notes: [
|
||||
"Plan defaults to `lora` (Token-billed). Pass --plan to override.",
|
||||
"For plan=ptu (Token-billed, provisioned throughput), --input-tpm and",
|
||||
"--output-tpm are required (the platform rejects creation without an",
|
||||
"explicit ptu_capacity despite the doc listing defaults).",
|
||||
"For plan=mu, `capacity`, `billing_method` and `template_id` are required.",
|
||||
"billing_method defaults to POST_PAY (only supported value); template_id",
|
||||
"and capacity are auto-picked from GET /deployments/models when omitted.",
|
||||
"Use `bl deploy models --source base` to inspect available templates.",
|
||||
"After creation, status starts at PENDING and transitions to RUNNING.",
|
||||
"Invoke the deployed model with: bl text chat --model <deployed_model>",
|
||||
"WARNING: --model is overloaded across commands and refers to DIFFERENT",
|
||||
"values. `bl deploy create --model` takes the exported model_name (e.g.",
|
||||
"`qwen3-8b-ft-...`), but the create response also returns a `deployed_model`",
|
||||
"field (the deployment instance id, e.g. `qwen3-8b-5ecb5f068d79`). The",
|
||||
"inference call `bl text chat --model` must use the `deployed_model` from",
|
||||
"the create response — NOT the `model_name` you passed to `deploy create`.",
|
||||
"Do not reuse the value across the two commands.",
|
||||
],
|
||||
validate: (flags) => {
|
||||
const plan = flags.plan || "lora";
|
||||
const strategy = STRATEGIES[plan];
|
||||
if (!strategy) {
|
||||
return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`;
|
||||
}
|
||||
return strategy.validateFlags(flags);
|
||||
},
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const model = flags.model;
|
||||
const name = flags.name;
|
||||
const plan = flags.plan || "lora";
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Plan-specific behaviour is owned by `plans.ts`. The strategy resolves
|
||||
// the plan-specific body fragment (mu may auto-pick a template from the
|
||||
// deployable-models catalog). Anything outside the strategy table was
|
||||
// already rejected by `validate` above.
|
||||
const strategy = pickPlanStrategy(plan);
|
||||
|
||||
const resolved = await strategy.resolve({
|
||||
client: ctx.client,
|
||||
dryRun: settings.dryRun,
|
||||
binName: identity.binName,
|
||||
flags,
|
||||
model,
|
||||
name,
|
||||
});
|
||||
const body: Record<string, unknown> = {
|
||||
model_name: model,
|
||||
name,
|
||||
plan,
|
||||
...resolved.body,
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.create", body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await createDeployment(ctx.client, body as CreateDeploymentRequest);
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(deployment?.deployed_model ?? "");
|
||||
} else if (format === "text") {
|
||||
emitBare(`Created deployment.`);
|
||||
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
|
||||
if (deployment?.status) emitBare(` status: ${deployment.status}`);
|
||||
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
|
||||
emitBare(
|
||||
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
|
||||
);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
notes: CREATE_NOTES,
|
||||
validate: (flags) => validateCreate("text", flags),
|
||||
run: (ctx) => runCreate("text", ctx),
|
||||
});
|
||||
|
||||
/** `bl deploy audio create` — deploy an audio (TTS) model. Defaults to plan=mu. */
|
||||
export const deployAudioCreate = defineCommand({
|
||||
description: "Create an audio (TTS) model deployment",
|
||||
auth: "apiKey",
|
||||
usageArgs: CREATE_USAGE,
|
||||
flags: CREATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model my-cosyvoice-ft --name my-tts",
|
||||
"--model my-cosyvoice-ft --name my-tts --deploy-spec dps-xxxx --capacity 1",
|
||||
"--model my-cosyvoice-ft --name my-tts --dry-run",
|
||||
],
|
||||
notes: CREATE_NOTES,
|
||||
validate: (flags) => validateCreate("audio", flags),
|
||||
run: (ctx) => runCreate("audio", ctx),
|
||||
});
|
||||
|
||||
/** `bl deploy image create` — deploy an image generation model. */
|
||||
export const deployImageCreate = defineCommand({
|
||||
description: "Create an image generation model deployment",
|
||||
auth: "apiKey",
|
||||
usageArgs: CREATE_USAGE,
|
||||
flags: CREATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model my-wan-ft --name my-wan",
|
||||
"--model my-wan-ft --name my-wan-mu --plan mu",
|
||||
"--model my-wan-ft --name my-wan --dry-run",
|
||||
],
|
||||
notes: CREATE_NOTES,
|
||||
validate: (flags) => validateCreate("image", flags),
|
||||
run: (ctx) => runCreate("image", ctx),
|
||||
});
|
||||
|
||||
@@ -59,8 +59,8 @@ export default defineCommand({
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof BailianError) throw e;
|
||||
} catch (error) {
|
||||
if (error instanceof BailianError) throw error;
|
||||
// If the get itself failed (e.g. not found), let the DELETE call surface the real error.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,13 +68,13 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
const headers = ["DEPLOYED_MODEL", "MODEL_NAME", "STATUS", "PLAN", "CAPACITY", "CREATED_AT"];
|
||||
const rows = items.map((i) => [
|
||||
i.deployed_model,
|
||||
i.model_name,
|
||||
i.status,
|
||||
i.plan,
|
||||
i.capacity,
|
||||
i.created_at,
|
||||
const rows = items.map((item) => [
|
||||
item.deployed_model,
|
||||
item.model_name,
|
||||
item.status,
|
||||
item.plan,
|
||||
item.capacity,
|
||||
item.created_at,
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
|
||||
@@ -73,47 +73,47 @@ export default defineCommand({
|
||||
// - custom (fine-tuned): top-level supported_plans: string[]
|
||||
// - base (catalog): plans: [{plan, templates?, cu_specs?}]
|
||||
// For json: surface the deployment-relevant fields preserved as a tree, so
|
||||
// downstream tooling can drive `bl deploy create --template-id <…>` without
|
||||
// a second round-trip. For text: keep the compact one-line summary.
|
||||
// downstream tooling can drive `bl deploy <modality> create --deploy-spec <…>`
|
||||
// without a second round-trip. For text: keep the compact one-line summary.
|
||||
if (format === "json") {
|
||||
const items = models.map((m) => {
|
||||
const items = models.map((model) => {
|
||||
const out: Record<string, unknown> = {
|
||||
model_name: m.model_name ?? "",
|
||||
model_name: model.model_name ?? "",
|
||||
};
|
||||
if (m.base_model) out.base_model = m.base_model;
|
||||
if (m.model_source) out.model_source = m.model_source;
|
||||
if (m.supported_plans && m.supported_plans.length > 0) {
|
||||
out.supported_plans = m.supported_plans;
|
||||
if (model.base_model) out.base_model = model.base_model;
|
||||
if (model.model_source) out.model_source = model.model_source;
|
||||
if (model.supported_plans && model.supported_plans.length > 0) {
|
||||
out.supported_plans = model.supported_plans;
|
||||
}
|
||||
if (m.plans && m.plans.length > 0) {
|
||||
out.plans = m.plans.map((p) => {
|
||||
const planEntry: Record<string, unknown> = { plan: p.plan ?? "" };
|
||||
if (p.cu_specs && p.cu_specs.length > 0) {
|
||||
planEntry.cu_specs = p.cu_specs;
|
||||
if (model.plans && model.plans.length > 0) {
|
||||
out.plans = model.plans.map((plan) => {
|
||||
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
|
||||
if (plan.cu_specs && plan.cu_specs.length > 0) {
|
||||
planEntry.cu_specs = plan.cu_specs;
|
||||
}
|
||||
if (p.templates && p.templates.length > 0) {
|
||||
// Pull the top 6 fields most useful for `bl deploy create`.
|
||||
if (plan.templates && plan.templates.length > 0) {
|
||||
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
|
||||
// Drop noisy/redundant: template_source, template_type,
|
||||
// template_version, deploy_spec (typically == template_id).
|
||||
planEntry.templates = p.templates.map((t) => {
|
||||
planEntry.templates = plan.templates.map((template) => {
|
||||
const tpl: Record<string, unknown> = {};
|
||||
if (t.template_id) tpl.template_id = t.template_id;
|
||||
if (t.template_name) tpl.template_name = t.template_name;
|
||||
if (t.charge_type) tpl.charge_type = t.charge_type;
|
||||
if (template.template_id) tpl.template_id = template.template_id;
|
||||
if (template.template_name) tpl.template_name = template.template_name;
|
||||
if (template.charge_type) tpl.charge_type = template.charge_type;
|
||||
// Flatten roles.unified for the common COUPLED case.
|
||||
const unified = t.roles?.unified;
|
||||
const unified = template.roles?.unified;
|
||||
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
|
||||
if (unified?.capacity_unit_per_instance !== undefined)
|
||||
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
|
||||
// Preserve split-role configs (SEPERATED) as-is so callers
|
||||
// can still drive prefill/decode sizing.
|
||||
if (t.roles?.prefill || t.roles?.decode) {
|
||||
if (template.roles?.prefill || template.roles?.decode) {
|
||||
tpl.roles = {
|
||||
prefill: t.roles?.prefill,
|
||||
decode: t.roles?.decode,
|
||||
prefill: template.roles?.prefill,
|
||||
decode: template.roles?.decode,
|
||||
};
|
||||
}
|
||||
if (t.template_desc) tpl.template_desc = t.template_desc;
|
||||
if (template.template_desc) tpl.template_desc = template.template_desc;
|
||||
return tpl;
|
||||
});
|
||||
}
|
||||
@@ -127,19 +127,19 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
// text / quiet — keep the compact single-line summary table.
|
||||
const textItems = models.map((m) => {
|
||||
const textItems = models.map((model) => {
|
||||
let plansSummary = "";
|
||||
if (m.supported_plans && m.supported_plans.length > 0) {
|
||||
plansSummary = m.supported_plans.join(",");
|
||||
} else if (m.plans && m.plans.length > 0) {
|
||||
plansSummary = m.plans
|
||||
.map((p) => {
|
||||
const planName = p.plan ?? "?";
|
||||
if (p.templates && p.templates.length > 0) {
|
||||
return `${planName}(${p.templates.length}t)`;
|
||||
if (model.supported_plans && model.supported_plans.length > 0) {
|
||||
plansSummary = model.supported_plans.join(",");
|
||||
} else if (model.plans && model.plans.length > 0) {
|
||||
plansSummary = model.plans
|
||||
.map((plan) => {
|
||||
const planName = plan.plan ?? "?";
|
||||
if (plan.templates && plan.templates.length > 0) {
|
||||
return `${planName}(${plan.templates.length}t)`;
|
||||
}
|
||||
if (p.cu_specs && p.cu_specs.length > 0) {
|
||||
return `${planName}(${p.cu_specs.join("/")})`;
|
||||
if (plan.cu_specs && plan.cu_specs.length > 0) {
|
||||
return `${planName}(${plan.cu_specs.join("/")})`;
|
||||
}
|
||||
return planName;
|
||||
})
|
||||
@@ -148,9 +148,9 @@ export default defineCommand({
|
||||
plansSummary = "-";
|
||||
}
|
||||
return {
|
||||
model_name: m.model_name ?? "",
|
||||
base_model: m.base_model ?? "",
|
||||
source: m.model_source ?? "",
|
||||
model_name: model.model_name ?? "",
|
||||
base_model: model.base_model ?? "",
|
||||
source: model.model_source ?? "",
|
||||
plans: plansSummary,
|
||||
};
|
||||
});
|
||||
@@ -160,7 +160,12 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
const headers = ["MODEL_NAME", "BASE_MODEL", "SOURCE", "PLANS"];
|
||||
const rows = textItems.map((i) => [i.model_name, i.base_model, i.source, i.plans]);
|
||||
const rows = textItems.map((item) => [
|
||||
item.model_name,
|
||||
item.base_model,
|
||||
item.source,
|
||||
item.plans,
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
},
|
||||
|
||||
@@ -4,12 +4,12 @@ import {
|
||||
createFineTune,
|
||||
getDataset,
|
||||
uploadDataset,
|
||||
validateDataset,
|
||||
detectModality,
|
||||
getProfile,
|
||||
fetchModelCapability,
|
||||
listSupportedTrainingTypes,
|
||||
preflightBatchSizeGate,
|
||||
isTrainingTypeCli,
|
||||
toServerTrainingType,
|
||||
TRAINING_TYPES_CLI,
|
||||
DEFAULT_TRAINING_TYPE,
|
||||
formatIssue,
|
||||
@@ -17,10 +17,12 @@ import {
|
||||
ExitCode,
|
||||
type Client,
|
||||
type Settings,
|
||||
type CommandContext,
|
||||
type CreateFineTuneRequest,
|
||||
type FineTuneHyperParameters,
|
||||
type DatasetFile,
|
||||
type DatasetSchema,
|
||||
type TrainingProfile,
|
||||
type DataModality,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { existsSync, statSync } from "fs";
|
||||
@@ -77,7 +79,11 @@ async function analyzeDatasetTokens(
|
||||
binName: string,
|
||||
raw: string,
|
||||
label: string,
|
||||
schema?: DatasetSchema,
|
||||
profile: TrainingProfile,
|
||||
modality: DataModality,
|
||||
model: string,
|
||||
/** Pre-detected modality for a known path (avoids re-opening the file). */
|
||||
knownModality?: { path: string; modality: DataModality },
|
||||
): Promise<ResolvedDataset> {
|
||||
const tokens = raw
|
||||
.split(",")
|
||||
@@ -109,11 +115,18 @@ async function analyzeDatasetTokens(
|
||||
|
||||
if (settings.dryRun) continue;
|
||||
|
||||
// Local path → validate (same checks as `dataset upload`). Upload is
|
||||
// deferred to `uploadResolvedLocal` so the gate can run first. The schema
|
||||
// (SFT vs DPO) is derived from --training-type so a DPO job validates the
|
||||
// chosen/rejected preference pairs here, not on the platform.
|
||||
const result = await validateDataset(token, { schema });
|
||||
// The command's modality is authoritative; each local file is validated
|
||||
// under that modality's schema. Reuse the caller's pre-detected modality
|
||||
// when available to avoid opening the same file twice (matters for large
|
||||
// ZIPs).
|
||||
const tokenModality =
|
||||
knownModality && knownModality.path === token ? knownModality.modality : modality;
|
||||
|
||||
// Local path → validate through the profile. The profile internally routes
|
||||
// to the correct validator based on modality. Upload is deferred to
|
||||
// `uploadResolvedLocal` so the gate can run first. `model` is forwarded for
|
||||
// schema-agnostic cross-checks.
|
||||
const result = await profile.validate(token, tokenModality, { model });
|
||||
if (!result.valid) {
|
||||
const lines = [
|
||||
`Dataset validation failed for ${token}`,
|
||||
@@ -194,25 +207,33 @@ async function uploadResolvedLocal(
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
const CREATE_FLAGS = {
|
||||
/** The modality a `finetune <modality> create` subcommand is bound to. */
|
||||
type CommandModality = "text" | "audio" | "image";
|
||||
|
||||
/**
|
||||
* Flags shared by every `finetune <modality> create` subcommand: what to train
|
||||
* (model), what data to train on (datasets / validations), and how to name the
|
||||
* output. Every modality's model consumes these.
|
||||
*/
|
||||
const COMMON_FLAGS = {
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Base model to fine-tune (e.g. qwen3-8b, qwen3-14b)",
|
||||
description: "Base model to fine-tune",
|
||||
required: true,
|
||||
},
|
||||
datasets: {
|
||||
type: "string",
|
||||
valueHint: "<ids|paths>",
|
||||
description:
|
||||
"Comma-separated dataset file IDs or local .jsonl paths. Local paths are uploaded (validated) first, then their file-ids are used.",
|
||||
"Comma-separated dataset file IDs or local paths (.jsonl for text, .zip for audio/image). Local paths are uploaded (validated) first, then their file-ids are used.",
|
||||
required: true,
|
||||
},
|
||||
validations: {
|
||||
type: "string",
|
||||
valueHint: "<ids|paths>",
|
||||
description:
|
||||
"Comma-separated validation dataset file IDs or local .jsonl paths (auto-uploaded like --datasets).",
|
||||
"Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets).",
|
||||
},
|
||||
modelName: {
|
||||
type: "string",
|
||||
@@ -224,6 +245,16 @@ const CREATE_FLAGS = {
|
||||
valueHint: "<text>",
|
||||
description: "Output suffix appended by the platform (finetuned_output_suffix)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/**
|
||||
* Text flags: text models consume the full hyper-parameter surface — training
|
||||
* type selection plus n_epochs / batch_size / learning_rate / max_length (see
|
||||
* resolveTextHyperParameters). Only text exposes --training-type because only
|
||||
* text models support types other than the sft-lora default.
|
||||
*/
|
||||
const TEXT_FLAGS = {
|
||||
...COMMON_FLAGS,
|
||||
trainingType: {
|
||||
type: "string",
|
||||
valueHint: "<t>",
|
||||
@@ -252,12 +283,368 @@ const CREATE_FLAGS = {
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Create a fine-tune job (sft | sft-lora | dpo | dpo-lora | cpt)",
|
||||
/**
|
||||
* Audio (CosyVoice TTS) flags: the audio model runs sft-lora with a fully fixed
|
||||
* hyper-parameter set (AUDIO_HYPER_PARAMS). No --training-type or hyper-parameter
|
||||
* flag is honored by resolveHyperParameters, so none are exposed.
|
||||
*/
|
||||
const AUDIO_FLAGS = {
|
||||
...COMMON_FLAGS,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/**
|
||||
* Image (Wan generation) flags: the image model runs sft-lora with fixed
|
||||
* defaults; resolveHyperParameters only honors learning_rate, so --learning-rate
|
||||
* is the sole extra numeric knob. --generation-type declares T2I vs I2I
|
||||
* explicitly (the platform expects generation_type as a request field); it is
|
||||
* required to reach I2I from a bare file-id or in --dry-run, where the data
|
||||
* cannot be inspected.
|
||||
*/
|
||||
const IMAGE_FLAGS = {
|
||||
...COMMON_FLAGS,
|
||||
generationType: {
|
||||
type: "string",
|
||||
choices: ["t2i", "i2i"] as const,
|
||||
valueHint: "<t2i|i2i>",
|
||||
description:
|
||||
"Generation type: t2i (default) | i2i. Sets generation_type/max_pixels. Required to train I2I from a file-id or with --dry-run (local data auto-detects input_img).",
|
||||
},
|
||||
learningRate: {
|
||||
type: "string",
|
||||
valueHint: "<str>",
|
||||
description: 'Learning rate as a string to preserve precision (e.g. "3e-5")',
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const TEXT_USAGE =
|
||||
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]";
|
||||
|
||||
const AUDIO_USAGE =
|
||||
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
|
||||
|
||||
const IMAGE_USAGE =
|
||||
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
|
||||
|
||||
const COMMON_NOTES = [
|
||||
"Creating a job uploads any local datasets and consumes training quota.",
|
||||
"Use --dry-run to preview the request body without submitting.",
|
||||
"--datasets / --validations accept either file-ids (from `dataset upload`)",
|
||||
"or local paths. Local paths are validated and uploaded first, then their",
|
||||
"file-ids are submitted — a one-step upload-and-train.",
|
||||
];
|
||||
|
||||
const TEXT_NOTES = [
|
||||
...COMMON_NOTES,
|
||||
"Training-type values use the `<method>` / `<method>-lora` convention:",
|
||||
"sft (full) | sft-lora (LoRA) | dpo (full) | dpo-lora (LoRA) | cpt. These map",
|
||||
"to the server's training_type at the interface boundary, so the rest of the",
|
||||
"CLI never sees the raw server strings.",
|
||||
"Before submitting (non dry-run) the job, the model's training capability is",
|
||||
"checked via listFoundationModels (no console login required); an unsupported",
|
||||
"training type fails fast with the list the model actually supports.",
|
||||
"n_epochs defaults to 3. Other hyper-parameters are platform defaults unless set.",
|
||||
"Learning rate is forwarded as a string to avoid JSON-number precision loss.",
|
||||
"Pre-submit gate: if the training dataset's sample count is not greater",
|
||||
"than batch_size, the job is rejected before upload or quota consumption",
|
||||
"(the platform would otherwise fail ~10 min in, after data processing).",
|
||||
];
|
||||
|
||||
const AUDIO_NOTES = [
|
||||
...COMMON_NOTES,
|
||||
"Audio TTS training runs sft-lora (efficient_sft) with fixed CosyVoice",
|
||||
"hyper-parameter defaults; there are no training-type or hyper-parameter",
|
||||
"knobs to set.",
|
||||
];
|
||||
|
||||
const IMAGE_NOTES = [
|
||||
...COMMON_NOTES,
|
||||
"Image generation training runs sft-lora (efficient_sft) with fixed defaults;",
|
||||
"only --learning-rate is overridable. T2I vs I2I is declared with",
|
||||
"--generation-type (default t2i), which sets generation_type/max_pixels. For",
|
||||
"local data the type is auto-detected (records with input_img train I2I);",
|
||||
"pass --generation-type explicitly to train I2I from a file-id or in --dry-run.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared `finetune <modality> create` implementation. The parameter surface and
|
||||
* run logic are identical to the previous single `finetune create`; the ONLY
|
||||
* change is that the data modality is fixed by the subcommand instead of being
|
||||
* detected from data content. This is what lets file-id datasets (which have no
|
||||
* local file to inspect) train the correct model — the old command silently
|
||||
* defaulted a file-id to "text".
|
||||
*
|
||||
* Image is the only modality with a sub-variant (T2I vs I2I). It is upgraded to
|
||||
* `image-i2i` only when a local file's first record carries `input_img`; a bare
|
||||
* file-id defaults to plain "image" (T2I), matching the old detection fallback.
|
||||
*/
|
||||
async function runCreate<F extends FlagsDef>(
|
||||
commandModality: CommandModality,
|
||||
ctx: CommandContext<F>,
|
||||
): Promise<void> {
|
||||
const { identity, settings } = ctx;
|
||||
const flags = ctx.flags as Record<string, unknown>;
|
||||
const model = flags.model as string;
|
||||
const datasetsRaw = flags.datasets as string;
|
||||
|
||||
// CosyVoice audio fine-tuning accepts exactly one training file
|
||||
// (`training_file_ids` supports a single ID per the speech-synthesis
|
||||
// contract). Reject a multi-token --datasets up-front so the job isn't
|
||||
// rejected server-side after an upload.
|
||||
if (commandModality === "audio") {
|
||||
const audioTokens = datasetsRaw
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
if (audioTokens.length > 1) {
|
||||
throw new BailianError(
|
||||
`Audio (TTS) fine-tuning accepts exactly one training file, got ${audioTokens.length}.`,
|
||||
ExitCode.USAGE,
|
||||
"Merge your recordings into a single .zip (or pass one file-id).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the training type before analyzing datasets so the validator can
|
||||
// enforce the right record schema (DPO jobs require chosen/rejected on
|
||||
// every record). Whitelist is the single source of truth in core
|
||||
// (TRAINING_TYPES_CLI); any other value is rejected up-front.
|
||||
const trainingType = (flags.trainingType as string | undefined) || DEFAULT_TRAINING_TYPE;
|
||||
if (!isTrainingTypeCli(trainingType)) {
|
||||
throw new BailianError(
|
||||
`--training-type "${trainingType}" is not supported.`,
|
||||
ExitCode.USAGE,
|
||||
`Supported values: ${TRAINING_TYPES_CLI.join(", ")} (default: ${DEFAULT_TRAINING_TYPE}).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Profile: single source of truth for how this training type behaves
|
||||
// (validation rules, hyper-parameters, gates, capability check).
|
||||
const profile = getProfile(trainingType);
|
||||
|
||||
// Modality is fixed by the subcommand (no content-based detection) — this is
|
||||
// the sole behavioural change of the modality split. Image alone has a T2I/I2I
|
||||
// sub-variant: an explicit --generation-type is authoritative (the only way to
|
||||
// reach I2I from a bare file-id or in --dry-run, where data can't be
|
||||
// inspected); otherwise a local file is probed to upgrade T2I → I2I, and a
|
||||
// bare file-id stays "image" (T2I).
|
||||
const firstLocalPath = datasetsRaw
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.find((token) => isLocalPath(token));
|
||||
let modality: DataModality = commandModality;
|
||||
if (commandModality === "image") {
|
||||
const generationType = flags.generationType as "t2i" | "i2i" | undefined;
|
||||
if (generationType === "i2i") {
|
||||
modality = "image-i2i";
|
||||
} else if (!generationType && firstLocalPath && !settings.dryRun) {
|
||||
const detected = await detectModality(firstLocalPath);
|
||||
if (detected === "image-i2i") modality = "image-i2i";
|
||||
}
|
||||
}
|
||||
|
||||
const training = await analyzeDatasetTokens(
|
||||
settings,
|
||||
identity.binName,
|
||||
datasetsRaw,
|
||||
"datasets",
|
||||
profile,
|
||||
modality,
|
||||
model,
|
||||
firstLocalPath ? { path: firstLocalPath, modality } : undefined,
|
||||
);
|
||||
const trainingFileIds = training.fileIds;
|
||||
|
||||
const validation = flags.validations
|
||||
? await analyzeDatasetTokens(
|
||||
settings,
|
||||
identity.binName,
|
||||
flags.validations as string,
|
||||
"validations",
|
||||
profile,
|
||||
modality,
|
||||
model,
|
||||
)
|
||||
: undefined;
|
||||
const validationFileIds = validation?.fileIds;
|
||||
|
||||
const modelName = flags.modelName as string | undefined;
|
||||
const suffix = flags.suffix as string | undefined;
|
||||
|
||||
// Hyper-parameters: the profile resolves modality-specific defaults
|
||||
// (text: n_epochs/batch_size/learning_rate; audio: lm_max_epoch/fm_max_epoch/...).
|
||||
const hp = profile.resolveHyperParameters(
|
||||
modality,
|
||||
flags as Record<string, unknown>,
|
||||
) as FineTuneHyperParameters;
|
||||
|
||||
// Restore the batch-size clamping warning that was lost when the logic moved
|
||||
// into profiles. The profile silently clamps to [8, 1024]; surface it here
|
||||
// so the user has an audit trail. Skip modalities that bypass the batch_size
|
||||
// gate (image): their batch_size is a fixed model-family default, not a
|
||||
// clamp of the user's value, so the [8, 1024] "clamped" message would be
|
||||
// self-contradictory and misleading.
|
||||
if (
|
||||
flags.batchSize !== undefined &&
|
||||
hp.batch_size !== undefined &&
|
||||
!settings.quiet &&
|
||||
!profile.shouldSkipGate("batch_size", modality)
|
||||
) {
|
||||
const requested = flags.batchSize as number;
|
||||
if (hp.batch_size !== requested) {
|
||||
process.stderr.write(
|
||||
`warning: --batch-size ${requested} clamped to ${hp.batch_size} ` +
|
||||
`(server range [8, 1024] for the common training types).\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// For modalities that skip the batch_size gate, warn the user that their
|
||||
// explicit --batch-size was discarded (model uses a fixed batch_size).
|
||||
if (
|
||||
flags.batchSize !== undefined &&
|
||||
!settings.quiet &&
|
||||
profile.shouldSkipGate("batch_size", modality)
|
||||
) {
|
||||
const requested = flags.batchSize as number;
|
||||
if (hp.batch_size !== undefined && hp.batch_size !== requested) {
|
||||
process.stderr.write(
|
||||
`warning: --batch-size ${requested} ignored for ${modality} training ` +
|
||||
`(model uses a fixed batch_size of ${hp.batch_size}).\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto batch_size for small datasets — only for text data. Audio/image
|
||||
// profiles already set their own batch parameters.
|
||||
if (modality === "text" && hp.batch_size === undefined && !settings.dryRun) {
|
||||
let sizeBytes = training.firstSize ?? 0;
|
||||
if (sizeBytes === 0) {
|
||||
try {
|
||||
const fileInfo = await getDataset(ctx.client, trainingFileIds[0]);
|
||||
sizeBytes = fileInfo.data?.size ?? 0;
|
||||
} catch {
|
||||
// If we can't fetch file info, skip auto-adjustment; platform will use default.
|
||||
}
|
||||
}
|
||||
if (sizeBytes > 0 && sizeBytes < 100 * 1024) {
|
||||
hp.batch_size = 8;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-submit batch-size gate: the platform rejects a job whose number of
|
||||
// training samples is not greater than batch_size, but only surfaces that
|
||||
// ~10 minutes into the run (after data processing). Fail fast here, before
|
||||
// burning quota. `recordCount` is only known when every --datasets token
|
||||
// was a local file we validated; file-id tokens fall through to the
|
||||
// platform rather than risk a false positive from an undercount.
|
||||
if (
|
||||
!settings.dryRun &&
|
||||
training.recordCount !== undefined &&
|
||||
!profile.shouldSkipGate("batch_size", modality)
|
||||
) {
|
||||
// 16 is the platform default when neither the user nor the small-file
|
||||
// auto-adjust set a batch_size (see the auto-adjust comment above).
|
||||
const effectiveBatchSize = hp.batch_size ?? 16;
|
||||
const gate = preflightBatchSizeGate({
|
||||
recordCount: training.recordCount,
|
||||
batchSize: effectiveBatchSize,
|
||||
});
|
||||
if (!gate.ok && gate.issue) {
|
||||
throw new BailianError(gate.issue.message, ExitCode.GENERAL, gate.hint);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight capability check: confirm the model actually supports the
|
||||
// requested training type BEFORE any upload, so a wrong --model /
|
||||
// --training-type combo doesn't burn storage on datasets that will never
|
||||
// be trained against. listFoundationModels is a public API (no console
|
||||
// login required); on lookup failure (network / 401 / etc.) we fall back
|
||||
// to letting the server decide rather than blocking the submit.
|
||||
if (!settings.dryRun && !profile.shouldSkipCapabilityCheck(modality)) {
|
||||
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
|
||||
try {
|
||||
capability = await fetchModelCapability(settings, model);
|
||||
} catch (error) {
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(
|
||||
`warning: model capability lookup failed (${(error as Error).message}); ` +
|
||||
"proceeding without local pre-flight.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (capability && !listSupportedTrainingTypes(capability).includes(trainingType)) {
|
||||
const supported = listSupportedTrainingTypes(capability);
|
||||
throw new BailianError(
|
||||
`Model "${model}" does not support training type "${trainingType}".`,
|
||||
ExitCode.USAGE,
|
||||
supported.length
|
||||
? `This model supports: ${supported.join(", ")}.`
|
||||
: "This model reports no supported training types.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload local paths now that pre-flight (validation, batch-size gate,
|
||||
// capability check) has cleared them. This swaps the placeholder path
|
||||
// entries in `training.fileIds` / `validation?.fileIds` for real file-ids.
|
||||
if (!settings.dryRun) {
|
||||
await uploadResolvedLocal(ctx.client, settings, training, "fine-tune", "datasets");
|
||||
if (validation) {
|
||||
await uploadResolvedLocal(ctx.client, settings, validation, "fine-tune", "validations");
|
||||
}
|
||||
}
|
||||
|
||||
const body: CreateFineTuneRequest = {
|
||||
model,
|
||||
training_file_ids: trainingFileIds,
|
||||
// Profile maps the CLI training type to the server value at the boundary.
|
||||
training_type: profile.serverTrainingType,
|
||||
hyper_parameters: hp,
|
||||
};
|
||||
if (validationFileIds && validationFileIds.length > 0) {
|
||||
body.validation_file_ids = validationFileIds;
|
||||
}
|
||||
if (modelName) body.model_name = modelName;
|
||||
if (suffix) body.finetuned_output_suffix = suffix;
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
const pending = [
|
||||
...training.localPaths.map((path) => ({ field: "datasets", path })),
|
||||
...(validation?.localPaths ?? []).map((path) => ({ field: "validations", path })),
|
||||
];
|
||||
emitResult(
|
||||
pending.length > 0
|
||||
? { action: "finetune.create", body, pending_uploads: pending }
|
||||
: { action: "finetune.create", body },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await createFineTune(ctx.client, body);
|
||||
const job = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
if (job?.job_id) emitBare(job.job_id);
|
||||
} else if (format === "text") {
|
||||
if (job?.job_id) {
|
||||
emitBare(`Created fine-tune job: ${job.job_id}`);
|
||||
if (job.status) emitBare(`Status: ${job.status}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
}
|
||||
|
||||
/** `bl finetune text create` — fine-tune a text model. Datasets are `.jsonl`. */
|
||||
export const finetuneTextCreate = defineCommand({
|
||||
description: "Create a text model fine-tune job (sft | sft-lora | dpo | dpo-lora | cpt)",
|
||||
auth: "apiKey",
|
||||
usageArgs:
|
||||
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]",
|
||||
flags: CREATE_FLAGS,
|
||||
usageArgs: TEXT_USAGE,
|
||||
flags: TEXT_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model qwen3-8b --datasets file-xxx",
|
||||
"--model qwen3-8b --datasets ./train.jsonl",
|
||||
@@ -268,231 +655,41 @@ export default defineCommand({
|
||||
"--model qwen3-8b --datasets file-xxx --output json",
|
||||
"--model qwen3-8b --datasets file-xxx --dry-run",
|
||||
],
|
||||
notes: [
|
||||
"Creating a job uploads any local datasets and consumes training quota.",
|
||||
"Use --dry-run to preview the request body without submitting.",
|
||||
"Training-type values use the `<method>` / `<method>-lora` convention:",
|
||||
"sft (full) | sft-lora (LoRA) | dpo (full) | dpo-lora (LoRA) | cpt. These map",
|
||||
"to the server's training_type at the interface boundary, so the rest of the",
|
||||
"CLI never sees the raw server strings.",
|
||||
"Before submitting (non dry-run) the job, the model's training capability is",
|
||||
"checked via listFoundationModels (no console login required); an unsupported",
|
||||
"training type fails fast with the list the model actually supports.",
|
||||
"n_epochs defaults to 3. Other hyper-parameters are platform defaults unless set.",
|
||||
"Learning rate is forwarded as a string to avoid JSON-number precision loss.",
|
||||
"--datasets / --validations accept either file-ids (from `dataset upload`)",
|
||||
"or local .jsonl paths. Local paths are validated and uploaded first, then",
|
||||
"their file-ids are submitted — a one-step upload-and-train.",
|
||||
"Dataset record schema is chosen from --training-type: dpo* → {messages,",
|
||||
"chosen, rejected}; cpt → {text} (raw pre-training text); else {messages}.",
|
||||
"Pre-submit gate: if the training dataset's sample count is not greater",
|
||||
"than batch_size, the job is rejected before upload or quota consumption",
|
||||
"(the platform would otherwise fail ~10 min in, after data processing).",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const model = flags.model;
|
||||
const datasetsRaw = flags.datasets;
|
||||
|
||||
// Resolve the training type before analyzing datasets so the validator can
|
||||
// enforce the right record schema (DPO jobs require chosen/rejected on
|
||||
// every record). Whitelist is the single source of truth in core
|
||||
// (TRAINING_TYPES_CLI); any other value is rejected up-front.
|
||||
const trainingType = flags.trainingType || DEFAULT_TRAINING_TYPE;
|
||||
if (!isTrainingTypeCli(trainingType)) {
|
||||
throw new BailianError(
|
||||
`--training-type "${trainingType}" is not supported.`,
|
||||
ExitCode.USAGE,
|
||||
`Supported values: ${TRAINING_TYPES_CLI.join(", ")} (default: ${DEFAULT_TRAINING_TYPE}).`,
|
||||
);
|
||||
}
|
||||
// dpo / dpo-lora → "dpo" schema (strict chosen/rejected); cpt → "cpt"
|
||||
// (raw {text} records); else ChatML ({messages}).
|
||||
const datasetSchema: DatasetSchema = trainingType.startsWith("dpo")
|
||||
? "dpo"
|
||||
: trainingType === "cpt"
|
||||
? "cpt"
|
||||
: "chatml";
|
||||
|
||||
const training = await analyzeDatasetTokens(
|
||||
settings,
|
||||
identity.binName,
|
||||
datasetsRaw,
|
||||
"datasets",
|
||||
datasetSchema,
|
||||
);
|
||||
const trainingFileIds = training.fileIds;
|
||||
|
||||
const validation = flags.validations
|
||||
? await analyzeDatasetTokens(
|
||||
settings,
|
||||
identity.binName,
|
||||
flags.validations,
|
||||
"validations",
|
||||
datasetSchema,
|
||||
)
|
||||
: undefined;
|
||||
const validationFileIds = validation?.fileIds;
|
||||
|
||||
const modelName = flags.modelName;
|
||||
const suffix = flags.suffix;
|
||||
|
||||
// Hyper-parameters: inject n_epochs=3 default unless overridden.
|
||||
const hp: FineTuneHyperParameters = {};
|
||||
hp.n_epochs = flags.nEpochs ?? 3;
|
||||
if (flags.learningRate !== undefined) hp.learning_rate = flags.learningRate;
|
||||
if (flags.maxLength !== undefined) hp.max_length = flags.maxLength;
|
||||
|
||||
// batch_size: clamp to [8, 1024] (server hard constraint, undocumented).
|
||||
// Surface the clamp on stderr instead of silently rewriting the user's
|
||||
// value — otherwise the submitted body would carry a number the user never
|
||||
// typed, with no audit trail. (Range observed on common SFT / SFT-LoRA
|
||||
// training types; some bases like qwen3.6-flash report a wider range, so
|
||||
// the warning explicitly mentions "server range".)
|
||||
if (flags.batchSize !== undefined) {
|
||||
const requested = flags.batchSize;
|
||||
let batchSize = requested;
|
||||
if (batchSize < 8) batchSize = 8;
|
||||
if (batchSize > 1024) batchSize = 1024;
|
||||
if (batchSize !== requested && !settings.quiet) {
|
||||
process.stderr.write(
|
||||
`warning: --batch-size ${requested} clamped to ${batchSize} ` +
|
||||
`(server range [8, 1024] for the common training types).\n`,
|
||||
);
|
||||
}
|
||||
hp.batch_size = batchSize;
|
||||
}
|
||||
|
||||
// Auto batch_size for small datasets: fetch first training file size.
|
||||
// With default split=0.9, validation_set = 0.1 * rows.
|
||||
// Platform default batch_size=16 needs rows > 160; batch_size=8 needs rows > 80.
|
||||
// Files < 100KB are conservatively estimated to have < 200 rows.
|
||||
// If the first file was just uploaded we already hold its size; otherwise
|
||||
// fall back to getDataset.
|
||||
if (hp.batch_size === undefined && !settings.dryRun) {
|
||||
let sizeBytes = training.firstSize ?? 0;
|
||||
if (sizeBytes === 0) {
|
||||
try {
|
||||
const fileInfo = await getDataset(ctx.client, trainingFileIds[0]);
|
||||
sizeBytes = fileInfo.data?.size ?? 0;
|
||||
} catch {
|
||||
// If we can't fetch file info, skip auto-adjustment; platform will use default.
|
||||
}
|
||||
}
|
||||
if (sizeBytes > 0 && sizeBytes < 100 * 1024) {
|
||||
hp.batch_size = 8;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-submit batch-size gate: the platform rejects a job whose number of
|
||||
// training samples is not greater than batch_size, but only surfaces that
|
||||
// ~10 minutes into the run (after data processing). Fail fast here, before
|
||||
// burning quota. `recordCount` is only known when every --datasets token
|
||||
// was a local file we validated; file-id tokens fall through to the
|
||||
// platform rather than risk a false positive from an undercount.
|
||||
//
|
||||
// The decision lives in core (`preflightBatchSizeGate`) — a structured,
|
||||
// job-level pre-flight that returns a `ValidationIssue` (same shape / stable
|
||||
// code as `validateDataset`) so the failure surfaces through the same
|
||||
// `BailianError` + issue convention used by `dataset upload`/`validate`.
|
||||
// ExitCode.GENERAL matches the existing validation-failed exit code.
|
||||
if (!settings.dryRun && training.recordCount !== undefined) {
|
||||
// 16 is the platform default when neither the user nor the small-file
|
||||
// auto-adjust set a batch_size (see the auto-adjust comment above).
|
||||
const effectiveBatchSize = hp.batch_size ?? 16;
|
||||
const gate = preflightBatchSizeGate({
|
||||
recordCount: training.recordCount,
|
||||
batchSize: effectiveBatchSize,
|
||||
});
|
||||
if (!gate.ok && gate.issue) {
|
||||
throw new BailianError(gate.issue.message, ExitCode.GENERAL, gate.hint);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight capability check: confirm the model actually supports the
|
||||
// requested training type BEFORE any upload, so a wrong --model /
|
||||
// --training-type combo doesn't burn storage on datasets that will never
|
||||
// be trained against. listFoundationModels is a public API (no console
|
||||
// login required); on lookup failure (network / 401 / etc.) we fall back
|
||||
// to letting the server decide rather than blocking the submit.
|
||||
if (!settings.dryRun) {
|
||||
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
|
||||
try {
|
||||
capability = await fetchModelCapability(settings, model);
|
||||
} catch (error) {
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(
|
||||
`warning: model capability lookup failed (${(error as Error).message}); ` +
|
||||
"proceeding without local pre-flight.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (capability && !listSupportedTrainingTypes(capability).includes(trainingType)) {
|
||||
const supported = listSupportedTrainingTypes(capability);
|
||||
throw new BailianError(
|
||||
`Model "${model}" does not support training type "${trainingType}".`,
|
||||
ExitCode.USAGE,
|
||||
supported.length
|
||||
? `This model supports: ${supported.join(", ")}.`
|
||||
: "This model reports no supported training types.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload local paths now that pre-flight (validation, batch-size gate,
|
||||
// capability check) has cleared them. This swaps the
|
||||
// placeholder path entries in `training.fileIds` / `validation?.fileIds`
|
||||
// for real file-ids, so the body below sees ids.
|
||||
if (!settings.dryRun) {
|
||||
await uploadResolvedLocal(ctx.client, settings, training, "fine-tune", "datasets");
|
||||
if (validation) {
|
||||
await uploadResolvedLocal(ctx.client, settings, validation, "fine-tune", "validations");
|
||||
}
|
||||
}
|
||||
|
||||
const body: CreateFineTuneRequest = {
|
||||
model,
|
||||
training_file_ids: trainingFileIds,
|
||||
// Map the CLI training type to the server value at the interface boundary.
|
||||
training_type: toServerTrainingType(trainingType),
|
||||
hyper_parameters: hp,
|
||||
};
|
||||
if (validationFileIds && validationFileIds.length > 0) {
|
||||
body.validation_file_ids = validationFileIds;
|
||||
}
|
||||
if (modelName) body.model_name = modelName;
|
||||
if (suffix) body.finetuned_output_suffix = suffix;
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
const pending = [
|
||||
...training.localPaths.map((path) => ({ field: "datasets", path })),
|
||||
...(validation?.localPaths ?? []).map((path) => ({ field: "validations", path })),
|
||||
];
|
||||
emitResult(
|
||||
pending.length > 0
|
||||
? { action: "finetune.create", body, pending_uploads: pending }
|
||||
: { action: "finetune.create", body },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await createFineTune(ctx.client, body);
|
||||
const job = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
if (job?.job_id) emitBare(job.job_id);
|
||||
} else if (format === "text") {
|
||||
if (job?.job_id) {
|
||||
emitBare(`Created fine-tune job: ${job.job_id}`);
|
||||
if (job.status) emitBare(`Status: ${job.status}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
notes: TEXT_NOTES,
|
||||
run: (ctx) => runCreate("text", ctx),
|
||||
});
|
||||
|
||||
/** `bl finetune audio create` — fine-tune an audio TTS model. Datasets are `.zip`. */
|
||||
export const finetuneAudioCreate = defineCommand({
|
||||
description: "Create an audio TTS model fine-tune job (sft-lora)",
|
||||
auth: "apiKey",
|
||||
usageArgs: AUDIO_USAGE,
|
||||
flags: AUDIO_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model cosyvoice-v3-flash --datasets ./audio.zip",
|
||||
"--model cosyvoice-v3-flash --datasets file-xxx",
|
||||
"--model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
|
||||
"--model cosyvoice-v3-flash --datasets file-xxx --output json",
|
||||
"--model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
|
||||
],
|
||||
notes: AUDIO_NOTES,
|
||||
run: (ctx) => runCreate("audio", ctx),
|
||||
});
|
||||
|
||||
/** `bl finetune image create` — fine-tune an image generation model. Datasets are `.zip`. */
|
||||
export const finetuneImageCreate = defineCommand({
|
||||
description: "Create an image generation model fine-tune job (sft-lora)",
|
||||
auth: "apiKey",
|
||||
usageArgs: IMAGE_USAGE,
|
||||
flags: IMAGE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model wan2.7-image-pro --datasets ./images.zip",
|
||||
"--model wan2.7-image-pro --datasets file-xxx",
|
||||
"--model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
|
||||
"--model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
|
||||
"--model wan2.7-image-pro --datasets file-xxx --output json",
|
||||
"--model wan2.7-image-pro --datasets ./images.zip --dry-run",
|
||||
],
|
||||
notes: IMAGE_NOTES,
|
||||
run: (ctx) => runCreate("image", ctx),
|
||||
});
|
||||
|
||||
@@ -34,9 +34,9 @@ export default defineCommand({
|
||||
flags: EXPORT_FLAGS,
|
||||
exampleArgs: ["--job-id ft-xxx --checkpoint ckpt-3 --model-name my-qwen-sft"],
|
||||
notes: [
|
||||
"Required before `deploy create` can target a checkpoint. The platform",
|
||||
"may auto-export the best checkpoint when a job reaches SUCCEEDED — explicit",
|
||||
"export is the canonical path for non-best checkpoints.",
|
||||
"Required before `deploy <modality> create` can target a checkpoint. The",
|
||||
"platform may auto-export the best checkpoint when a job reaches SUCCEEDED —",
|
||||
"explicit export is the canonical path for non-best checkpoints.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
@@ -66,7 +66,9 @@ export default defineCommand({
|
||||
emitBare(exported);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Exported ${jobId} / ${checkpoint} → model_name=${exported}`);
|
||||
emitBare(`Next: ${identity.binName} deploy create --model ${exported} --name <display-name>`);
|
||||
emitBare(
|
||||
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
|
||||
);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export default defineCommand({
|
||||
if (item.hyper_params) emitBare(`hyper_params: ${item.hyper_params}`);
|
||||
if (item.output_model)
|
||||
emitBare(
|
||||
`output_model: ${item.output_model} (→ ${identity.binName} deploy create --model)`,
|
||||
`output_model: ${item.output_model} (→ ${identity.binName} deploy text create --model)`,
|
||||
);
|
||||
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
|
||||
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
|
||||
|
||||
@@ -75,6 +75,8 @@ export default defineCommand({
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitBare(`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy create --model\``);
|
||||
emitBare(
|
||||
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getFineTune,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const DEFAULT_INTERVAL_SEC = 10;
|
||||
const MIN_INTERVAL_SEC = 1;
|
||||
const TERMINAL_STATUSES = new Set(["SUCCEEDED", "FAILED", "CANCELED"]);
|
||||
/** SIGINT exit code (128 + signal 2). */
|
||||
const EXIT_INTERRUPTED = 130;
|
||||
const EXIT_FAILED = 1;
|
||||
const EXIT_TIMEOUT = 2;
|
||||
/** Non-terminal status: the job is still running. Distinct from failure. */
|
||||
const EXIT_RUNNING = 3;
|
||||
|
||||
function nowStamp(): string {
|
||||
const date = new Date();
|
||||
@@ -25,18 +26,6 @@ function formatElapsed(milliseconds: number): string {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit code for a status value:
|
||||
* SUCCEEDED -> 0
|
||||
* FAILED / CANCELED -> 1
|
||||
* anything else -> 3 (still running)
|
||||
*/
|
||||
function exitCodeForStatus(status: string): number {
|
||||
if (status === "SUCCEEDED") return 0;
|
||||
if (TERMINAL_STATUSES.has(status)) return EXIT_FAILED;
|
||||
return EXIT_RUNNING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve after `milliseconds`, rejecting early if `signal` aborts (Ctrl-C).
|
||||
* Cleans up its timer + listener so nothing leaks between polls.
|
||||
@@ -101,9 +90,9 @@ export default defineCommand({
|
||||
"Default (no --follow) is a NON-BLOCKING single status probe: one fetch, then",
|
||||
"return immediately. This is the mode meant for agents / scripts — the caller",
|
||||
"owns the polling cadence, so the CLI never holds the terminal.",
|
||||
"Exit codes (both modes): 0 SUCCEEDED | 1 FAILED/CANCELED | 2 --poll-timeout",
|
||||
"exceeded (--follow) | 3 still running (non-terminal, default mode) | 130",
|
||||
"interrupted (Ctrl-C).",
|
||||
"A terminal FAILED/CANCELED status raises a normal CLI error (non-zero exit);",
|
||||
"a SUCCEEDED or still-running status returns 0. With --follow, exceeding",
|
||||
"--poll-timeout raises a timeout error.",
|
||||
"Use --follow for the blocking, human-terminal-follow experience; use the",
|
||||
"default mode when driving the loop yourself (e.g. from an agent).",
|
||||
"For per-step training output (not status), use `finetune logs`.",
|
||||
@@ -130,32 +119,34 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit codes here are a public probe contract (0 succeeded / 1 failed / 2
|
||||
// timeout / 3 still running / 130 interrupted) — deliberately routed via
|
||||
// process.exit instead of the central error handler.
|
||||
|
||||
// ---- Default: non-blocking single status probe -------------------------
|
||||
// A terminal FAILED/CANCELED status is surfaced as a BailianError (the
|
||||
// central handler prints it and exits non-zero); SUCCEEDED and still-running
|
||||
// both return normally. No process.exit / custom exit-code contract.
|
||||
if (!follow) {
|
||||
const response = await getFineTune(ctx.client, jobId);
|
||||
const job = response.output ?? response.data;
|
||||
const status = String(job?.status ?? "").toUpperCase();
|
||||
const terminal = TERMINAL_STATUSES.has(status);
|
||||
const code = exitCodeForStatus(status);
|
||||
|
||||
if (settings.quiet) {
|
||||
// Just the status word — ideal for `status=$(... finetune watch ... --quiet)`.
|
||||
emitBare(status || "UNKNOWN");
|
||||
} else if (format === "text") {
|
||||
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
|
||||
if (terminal) {
|
||||
const mark = status === "SUCCEEDED" ? "✓" : "✗";
|
||||
emitBare(`${mark} ${jobId} ${status}`);
|
||||
}
|
||||
if (status === "SUCCEEDED") emitBare(`✓ ${jobId} ${status}`);
|
||||
} else {
|
||||
// json: a compact, purpose-built status probe.
|
||||
emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format);
|
||||
}
|
||||
process.exit(code);
|
||||
|
||||
if (terminal && status !== "SUCCEEDED") {
|
||||
throw new BailianError(
|
||||
`Fine-tune job ${jobId} ended in status ${status}.`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- --follow: blocking poll loop (legacy behavior) -------------------
|
||||
@@ -182,28 +173,35 @@ export default defineCommand({
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (format !== "text" || settings.quiet) {
|
||||
emitResult(response, format);
|
||||
} else {
|
||||
const mark = status === "SUCCEEDED" ? "✓" : "✗";
|
||||
emitBare(`\n${mark} ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
|
||||
} else if (status === "SUCCEEDED") {
|
||||
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
|
||||
}
|
||||
process.exit(exitCodeForStatus(status));
|
||||
if (status !== "SUCCEEDED") {
|
||||
throw new BailianError(
|
||||
`Fine-tune job ${jobId} ended in status ${status} (elapsed ${formatElapsed(elapsed)}).`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pollTimeoutSec !== undefined && (Date.now() - startedAt) / 1000 >= pollTimeoutSec) {
|
||||
if (format === "text" && !settings.quiet) {
|
||||
emitBare(
|
||||
`\n⏼ ${jobId} timed out after ${formatElapsed(Date.now() - startedAt)} (last status: ${status || "UNKNOWN"})`,
|
||||
);
|
||||
}
|
||||
process.exit(EXIT_TIMEOUT);
|
||||
throw new BailianError(
|
||||
`Watching fine-tune job ${jobId} timed out after ` +
|
||||
`${formatElapsed(Date.now() - startedAt)} (last status: ${status || "UNKNOWN"}).`,
|
||||
ExitCode.TIMEOUT,
|
||||
);
|
||||
}
|
||||
|
||||
await sleep(intervalSec * 1000, controller.signal);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ctrl-C aborts the poll loop: report and return normally (no custom code).
|
||||
// Any other error (including the BailianError thrown above) propagates to
|
||||
// the central handler.
|
||||
if (controller.signal.aborted) {
|
||||
emitBare("\nInterrupted.");
|
||||
process.exit(EXIT_INTERRUPTED);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -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";
|
||||
|
||||
+58
-38
@@ -1,15 +1,14 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { ADVISOR_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
describe("e2e: advisor recommend", () => {
|
||||
test("advisor shows subcommand groups and exits successfully", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["advisor"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/advisor|recommend/i);
|
||||
});
|
||||
|
||||
test("advisor recommend --help exits successfully", async () => {
|
||||
const { stderr, exitCode } = await runCli(["advisor", "recommend", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/recommend|--message|dry-run/i);
|
||||
});
|
||||
@@ -17,13 +16,17 @@ describe("e2e: advisor recommend", () => {
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () => {
|
||||
test("advisor recommend without --message errors as usage error (2)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["advisor", "recommend", "--quiet"]);
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/--message|Usage:/i);
|
||||
});
|
||||
|
||||
test("advisor recommend --dry-run outputs intent analysis and candidates", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
@@ -63,7 +66,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
}, 60_000);
|
||||
|
||||
test("advisor recommend full flow returns results", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--message",
|
||||
@@ -93,10 +96,10 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
expect(data.result?.recommendations?.[0]?.highlights?.length).toBeGreaterThan(0);
|
||||
}, 120_000);
|
||||
|
||||
// ---- Model preference: positive cases ----
|
||||
// ---- Mode coverage: all 4 modes ----
|
||||
|
||||
test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
test("mode: scoped — family-scoped query sets mode=scoped with targets", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
@@ -109,19 +112,20 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
// Model preference detection depends on LLM interpretation
|
||||
// Accept either "scoped" or "unconstrained" as valid
|
||||
const mode = data.intent?.modelPreference?.mode;
|
||||
expect(mode === "scoped" || mode === "unconstrained" || mode === undefined).toBe(true);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref?.mode).toBe("scoped");
|
||||
expect(pref?.targets?.length).toBeGreaterThan(0);
|
||||
// Should contain "deepseek" (case-insensitive substring)
|
||||
expect(pref?.targets?.some((t) => t.toLowerCase().includes("deepseek"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
test("mode: comparison — comparing two models sets mode=comparison with both targets", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"Which is better for code generation, qwen-max or deepseek-v3?",
|
||||
"Compare qwen-max and deepseek-v3 for legal contract review, high precision required",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
@@ -129,19 +133,41 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
// Model preference detection depends on LLM interpretation
|
||||
// Accept either "comparison" or "unconstrained" as valid
|
||||
const mode = data.intent?.modelPreference?.mode;
|
||||
expect(mode === "comparison" || mode === "unconstrained" || mode === undefined).toBe(true);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref?.mode).toBe("comparison");
|
||||
expect(pref?.targets?.length).toBeGreaterThanOrEqual(2);
|
||||
const targetsLower = pref?.targets?.map((t) => t.toLowerCase()) ?? [];
|
||||
expect(targetsLower.some((t) => t.includes("qwen"))).toBe(true);
|
||||
expect(targetsLower.some((t) => t.includes("deepseek"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("excludes preference — intent detects modelPreference when excluding models", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
test("mode: alternative — reference model query sets mode=alternative with target", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"Not qwen, recommend a model suitable for text generation",
|
||||
"Something like qwen-max but cheaper, for text summarization",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref?.mode).toBe("alternative");
|
||||
expect(pref?.targets?.length).toBeGreaterThan(0);
|
||||
expect(pref?.targets?.some((t) => t.toLowerCase().includes("qwen"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("mode: excludes — excluding a family populates excludes array", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"Recommend a model for text generation, but not qwen",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
@@ -154,19 +180,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
};
|
||||
};
|
||||
}>(stdout);
|
||||
// Model preference detection depends on LLM interpretation
|
||||
// If excludes is detected, verify it contains qwen; otherwise accept as valid
|
||||
const pref = data.intent?.modelPreference;
|
||||
if (pref?.excludes && pref.excludes.length > 0) {
|
||||
expect(pref.excludes.some((e) => e.toLowerCase().includes("qwen"))).toBe(true);
|
||||
}
|
||||
// Test passes if exit code is 0, regardless of whether excludes was detected
|
||||
expect(pref?.excludes?.length).toBeGreaterThan(0);
|
||||
expect(pref?.excludes?.some((e) => e.toLowerCase().includes("qwen"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
// ---- Model preference: negative cases ----
|
||||
|
||||
test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
test("mode: unconstrained — generic query has no modelPreference", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
+90
-51
@@ -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();
|
||||
+24
-20
@@ -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",
|
||||
+18
-55
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { CONSOLE_FLAGS_DRY_RUN_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
type ConsoleDryRunMeta = {
|
||||
consoleRegion?: string;
|
||||
@@ -7,55 +8,13 @@ type ConsoleDryRunMeta = {
|
||||
consoleSwitchAgent?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* E2E for global console flags (`--console-region`, `--console-site`,
|
||||
* `--console-switch-agent`) and DashScope `--base-url`.
|
||||
*/
|
||||
|
||||
describe("e2e: console global flags", () => {
|
||||
test("根帮助展示 --base-url 与 console 全局标志", async () => {
|
||||
const { stderr, exitCode } = await runCli(["--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--base-url/);
|
||||
expect(stderr).toMatch(/--console-region/);
|
||||
expect(stderr).toMatch(/--console-site/);
|
||||
expect(stderr).toMatch(/--console-switch-agent/);
|
||||
expect(stderr).not.toMatch(/^\s*--region\s/m);
|
||||
});
|
||||
|
||||
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/Global Flags:/);
|
||||
expect(stderr).toMatch(/--console-region <region>/);
|
||||
expect(stderr).toMatch(/--model <model>/);
|
||||
expect(stderr).toMatch(/--period <minutes>/);
|
||||
expect(stderr).toMatch(/--output <format>/);
|
||||
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
|
||||
});
|
||||
|
||||
test("跨域 flag 拒绝:model 命令传 --console-region 报 Unknown flag", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"text",
|
||||
"chat",
|
||||
"--message",
|
||||
"hi",
|
||||
"--console-region",
|
||||
"cn-hangzhou",
|
||||
"--dry-run",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/Unknown flag.*--console-region/);
|
||||
});
|
||||
|
||||
test("跨域 flag 拒绝:console 命令传 --api-key 报 Unknown flag", async () => {
|
||||
const { stderr, exitCode } = await runCli(["mcp", "list", "--api-key", "sk-test", "--dry-run"]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/Unknown flag.*--api-key/);
|
||||
});
|
||||
|
||||
describe("e2e: console global flags (dry-run)", () => {
|
||||
test("console call --help 不暴露命令级 region/site,示例使用 --console-region", async () => {
|
||||
const { stderr, exitCode } = await runCli(["console", "call", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"console",
|
||||
"call",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--api <api>/);
|
||||
expect(stderr).toMatch(/--data <json>/);
|
||||
@@ -65,7 +24,11 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("auth login --help:自有 flag 含 --console-site,不含其余 console 域 flag", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--api-key <key>/);
|
||||
expect(stderr).toMatch(/--base-url <url>/);
|
||||
@@ -75,7 +38,7 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("console call --dry-run 默认 consoleRegion 为 cn-beijing", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"console",
|
||||
"call",
|
||||
"--api",
|
||||
@@ -93,7 +56,7 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("console call --dry-run --console-region / --console-site / --console-switch-agent 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"console",
|
||||
"call",
|
||||
"--api",
|
||||
@@ -118,7 +81,7 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("console call 拒绝未知全局 flag --region", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"console",
|
||||
"call",
|
||||
"--api",
|
||||
@@ -134,7 +97,7 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("mcp list --dry-run --console-region 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"mcp",
|
||||
"list",
|
||||
"--dry-run",
|
||||
@@ -149,7 +112,7 @@ describe("e2e: console global flags", () => {
|
||||
});
|
||||
|
||||
test("quota check --dry-run --console-region 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
|
||||
"quota",
|
||||
"check",
|
||||
"--dry-run",
|
||||
+106
-34
@@ -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",
|
||||
+75
-9
@@ -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",
|
||||
+23
-18
@@ -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",
|
||||
+119
-16
@@ -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();
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
+21
-19
@@ -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",
|
||||
+10
-11
@@ -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",
|
||||
+155
-9
@@ -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();
|
||||
});
|
||||
});
|
||||
+29
-16
@@ -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",
|
||||
+18
-25
@@ -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",
|
||||
+24
-20
@@ -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(),
|
||||
+16
-8
@@ -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",
|
||||
+27
-21
@@ -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",
|
||||
+63
-24
@@ -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",
|
||||
+16
-13
@@ -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",
|
||||
+18
-12
@@ -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",
|
||||
+14
-11
@@ -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",
|
||||
+10
-11
@@ -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",
|
||||
+11
-11
@@ -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",
|
||||
+27
-13
@@ -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/);
|
||||
@@ -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",
|
||||
};
|
||||
+79
-25
@@ -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",
|
||||
+44
-17
@@ -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
-13
@@ -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
-12
@@ -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
-12
@@ -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(),
|
||||
+6
-11
@@ -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(),
|
||||
+8
-13
@@ -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(),
|
||||
+22
-11
@@ -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",
|
||||
@@ -16,5 +16,6 @@
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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).`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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];
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./api.ts";
|
||||
export * from "./types.ts";
|
||||
export * from "./constants.ts";
|
||||
export * from "./plans.ts";
|
||||
|
||||
+44
-37
@@ -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;
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user