From 8fd072bcd1e29363f5fdfa68260aaabbef7aa256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Thu, 9 Jul 2026 10:02:56 +0800 Subject: [PATCH 01/13] feat: merge self-built-framework --- AGENTS.md | 11 + packages/cli/tests/e2e/dataset.e2e.test.ts | 46 +++ packages/cli/tests/e2e/deploy.e2e.test.ts | 92 +++++ packages/cli/tests/e2e/finetune.e2e.test.ts | 29 ++ .../commands/src/commands/dataset/upload.ts | 41 +- .../commands/src/commands/dataset/validate.ts | 25 +- .../commands/src/commands/deploy/create.ts | 62 ++- .../commands/src/commands/deploy/delete.ts | 4 +- packages/commands/src/commands/deploy/list.ts | 14 +- .../commands/src/commands/deploy/models.ts | 77 ++-- .../commands/src/commands/deploy/plans.ts | 41 +- .../commands/src/commands/finetune/create.ts | 140 ++++--- packages/core/package.json | 4 +- packages/core/src/dataset/index.ts | 2 + packages/core/src/dataset/inspect.ts | 209 ++++++++++ packages/core/src/dataset/validate/common.ts | 14 +- packages/core/src/dataset/validate/index.ts | 2 +- .../core/src/dataset/validate/registry.ts | 3 +- .../src/dataset/validate/schemas/image.ts | 177 +++++++++ .../src/dataset/validate/schemas/index.ts | 18 +- .../core/src/dataset/validate/schemas/tts.ts | 103 +++++ .../src/dataset/validate/schemas/video.ts | 158 ++++++++ packages/core/src/dataset/validate/types.ts | 9 +- packages/core/src/dataset/validate/zip.ts | 371 ++++++++++++++++++ packages/core/src/deploy/types.ts | 23 +- packages/core/src/finetune/capability.ts | 5 - packages/core/src/finetune/index.ts | 1 + packages/core/src/finetune/profiles/common.ts | 79 ++++ packages/core/src/finetune/profiles/cpt.ts | 7 + .../core/src/finetune/profiles/dpo-lora.ts | 7 + packages/core/src/finetune/profiles/dpo.ts | 7 + packages/core/src/finetune/profiles/index.ts | 2 + .../core/src/finetune/profiles/registry.ts | 50 +++ .../core/src/finetune/profiles/sft-lora.ts | 231 +++++++++++ packages/core/src/finetune/profiles/sft.ts | 7 + packages/core/src/finetune/profiles/types.ts | 84 ++++ packages/core/tests/dataset-validate.test.ts | 5 +- pnpm-lock.yaml | 36 +- pnpm-workspace.yaml | 2 + skills/bailian-cli/reference/dataset.md | 106 ++--- skills/bailian-cli/reference/deploy.md | 44 ++- skills/bailian-cli/reference/index.md | 4 +- 42 files changed, 2120 insertions(+), 232 deletions(-) create mode 100644 packages/core/src/dataset/inspect.ts create mode 100644 packages/core/src/dataset/validate/schemas/image.ts create mode 100644 packages/core/src/dataset/validate/schemas/tts.ts create mode 100644 packages/core/src/dataset/validate/schemas/video.ts create mode 100644 packages/core/src/dataset/validate/zip.ts create mode 100644 packages/core/src/finetune/profiles/common.ts create mode 100644 packages/core/src/finetune/profiles/cpt.ts create mode 100644 packages/core/src/finetune/profiles/dpo-lora.ts create mode 100644 packages/core/src/finetune/profiles/dpo.ts create mode 100644 packages/core/src/finetune/profiles/index.ts create mode 100644 packages/core/src/finetune/profiles/registry.ts create mode 100644 packages/core/src/finetune/profiles/sft-lora.ts create mode 100644 packages/core/src/finetune/profiles/sft.ts create mode 100644 packages/core/src/finetune/profiles/types.ts diff --git a/AGENTS.md b/AGENTS.md index ec3633f..d2be627 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/cli/tests/e2e/dataset.e2e.test.ts b/packages/cli/tests/e2e/dataset.e2e.test.ts index ef7f08e..50acb54 100644 --- a/packages/cli/tests/e2e/dataset.e2e.test.ts +++ b/packages/cli/tests/e2e/dataset.e2e.test.ts @@ -214,6 +214,52 @@ 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/video schemas raise 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(__dirname, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCli([ + "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", "video"])( + "dataset upload --dry-run 接受媒体 schema %s", + async (schema) => { + const file = join(__dirname, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCli([ + "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); + }, + ); }); describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (DashScope)", () => { diff --git a/packages/cli/tests/e2e/deploy.e2e.test.ts b/packages/cli/tests/e2e/deploy.e2e.test.ts index 5fdcd63..7c3a47f 100644 --- a/packages/cli/tests/e2e/deploy.e2e.test.ts +++ b/packages/cli/tests/e2e/deploy.e2e.test.ts @@ -56,6 +56,98 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { expect(data.body.capacity).toBe(1); }); + test("deploy create --dry-run 组装视频 LoRA 的 aigc_config", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "deploy", + "create", + "--model", + "wan2.5-i2v-preview-ft-xxx", + "--name", + "my-video-lora", + "--aigc-prompt", + "a cat surfing", + "--aigc-lora-prompt-default", + "trigger-word", + "--aigc-use-input-prompt", + "true", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + action: string; + body: { + plan: string; + aigc_config?: { + use_input_prompt?: boolean; + prompt?: string; + lora_prompt_default?: string; + }; + }; + }>(stdout); + expect(data.action).toBe("deploy.create"); + expect(data.body.plan).toBe("lora"); + expect(data.body.aigc_config).toEqual({ + use_input_prompt: true, + prompt: "a cat surfing", + lora_prompt_default: "trigger-word", + }); + }); + + test("deploy create --aigc-* 仅对 plan=lora 有效(plan=ptu 时报错)", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "deploy", + "create", + "--model", + "wan2.5-i2v-preview-ft-xxx", + "--name", + "my-video-lora", + "--plan", + "ptu", + "--input-tpm", + "10000", + "--output-tpm", + "1000", + "--aigc-prompt", + "a cat surfing", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stdout + stderr).not.toBe(0); + expect(`${stdout}\n${stderr}`).toMatch(/--aigc-\* flags are only valid for plan=lora/); + }); + + test("deploy create --plan mu --deploy-spec --dry-run 透传 deploy_spec", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "deploy", + "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 scale --dry-run 转发 capacity", async () => { const { stdout, stderr, exitCode } = await runCli([ "deploy", diff --git a/packages/cli/tests/e2e/finetune.e2e.test.ts b/packages/cli/tests/e2e/finetune.e2e.test.ts index 067cf13..faed0a8 100644 --- a/packages/cli/tests/e2e/finetune.e2e.test.ts +++ b/packages/cli/tests/e2e/finetune.e2e.test.ts @@ -114,6 +114,35 @@ 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 runCli([ + "finetune", + "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([ "finetune", diff --git a/packages/commands/src/commands/dataset/upload.ts b/packages/commands/src/commands/dataset/upload.ts index 40e06c8..ed0a7e1 100644 --- a/packages/commands/src/commands/dataset/upload.ts +++ b/packages/commands/src/commands/dataset/upload.ts @@ -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: "", - description: "Local .jsonl dataset file (≤300MB)", + description: "Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image/video)", required: true, }, purpose: { @@ -29,7 +30,7 @@ const UPLOAD_FLAGS = { type: "string", valueHint: "", 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), "image" (image generation), or "video" (video generation). Default auto-detects per record.', }, noValidate: { type: "switch", @@ -42,30 +43,31 @@ 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 [--purpose ] [--schema ] [--no-validate] [--full-validate]", + "--file [--purpose ] [--schema ] [--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.", + "Supports .jsonl (text) and .zip (audio/image/video archives with a", + "data.jsonl manifest). Six 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); video = {first_frame_path:"...",', + 'video_path:"..."} (video generation). With no --schema, a record', + "carrying wav_fn is validated as TTS, img_path as image, video_path /", + "first_frame_path as video, chosen/rejected as DPO, text (no messages)", + "as CPT, otherwise ChatML. Upload cap: 300MB text, 1GB image/video.", "Upload uses the OpenAI-compatible /compatible-mode/v1/files endpoint so", "the purpose tag is persisted (the DashScope-native /api/v1/files drops it).", ], @@ -75,9 +77,16 @@ export default defineCommand({ const purpose = flags.purpose || "fine-tune"; const schema = parseDatasetSchemaFlag(flags.schema); const format = detectOutputFormat(settings.output); + // Image / video schemas allow larger ZIPs (1 GB vs 300 MB for text). + const isMediaSchema = schema === "image" || schema === "video"; 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 +121,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", }, diff --git a/packages/commands/src/commands/dataset/validate.ts b/packages/commands/src/commands/dataset/validate.ts index f8b7f51..f9c8cb0 100644 --- a/packages/commands/src/commands/dataset/validate.ts +++ b/packages/commands/src/commands/dataset/validate.ts @@ -25,7 +25,7 @@ const VALIDATE_FLAGS = { file: { type: "string", valueHint: "", - 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: "", 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), "image" (image generation), or "video" (video 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 [--full-validate] [--schema ]", + usageArgs: "--file [--full-validate] [--schema ]", 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,12 +58,16 @@ 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); video =', + '{first_frame_path:"...", video_path:"..."} (video generation). With no', + "--schema, a record carrying wav_fn is validated as TTS, img_path as", + "image, video_path / first_frame_path as video, 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; diff --git a/packages/commands/src/commands/deploy/create.ts b/packages/commands/src/commands/deploy/create.ts index 376a91c..d9a63e8 100644 --- a/packages/commands/src/commands/deploy/create.ts +++ b/packages/commands/src/commands/deploy/create.ts @@ -2,6 +2,8 @@ import { defineCommand, detectOutputFormat, createDeployment, + BailianError, + ExitCode, type CreateDeploymentRequest, type FlagsDef, } from "bailian-cli-core"; @@ -26,10 +28,10 @@ const CREATE_FLAGS = { valueHint: "", description: "Billing plan: lora (default, Token-billed) | ptu (Token-billed) | mu", }, - templateId: { + deploySpec: { type: "string", valueHint: "", - 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", @@ -56,6 +58,23 @@ const CREATE_FLAGS = { valueHint: "", description: "PTU max thinking-output tokens/min (optional, some models)", }, + aigcUseInputPrompt: { + type: "boolean", + valueHint: "", + description: + "Video LoRA (aigc_config): honor the caller's prompt at inference (default false = use preset template)", + }, + aigcPrompt: { + type: "string", + valueHint: "", + description: + "Video LoRA (aigc_config): preset prompt template used when use-input-prompt is false", + }, + aigcLoraPromptDefault: { + type: "string", + valueHint: "", + description: "Video LoRA (aigc_config): default trigger-word phrase for the LoRA", + }, } satisfies FlagsDef; /** @@ -73,25 +92,29 @@ export default defineCommand({ description: "Create a model deployment", auth: "apiKey", usageArgs: - "--model --name [--plan ] [--template-id ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]", + "--model --name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]", 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", + '--model wan2.5-i2v-preview-ft-xxx --name my-video-lora --plan lora --aigc-prompt "..." --aigc-lora-prompt-default "..."', ], 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", + "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 ", + "For fine-tuned Wan video (i2v/kf2v) LoRA models, use --plan lora and pass", + "--aigc-prompt / --aigc-lora-prompt-default (and optionally", + "--aigc-use-input-prompt) to set the deployment's aigc_config.", "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`", @@ -136,6 +159,33 @@ export default defineCommand({ ...resolved.body, }; + // AIGC config (fine-tuned Wan video LoRA deployments). Only valid for + // plan=lora — reject early for ptu/mu so the user gets a clear CLI error + // instead of an opaque server-side rejection. + const aigcUseInputPrompt = flags.aigcUseInputPrompt; + const aigcPrompt = flags.aigcPrompt; + const aigcLoraPromptDefault = flags.aigcLoraPromptDefault; + const hasAigcFlags = + aigcUseInputPrompt !== undefined || + aigcPrompt !== undefined || + aigcLoraPromptDefault !== undefined; + if (hasAigcFlags && plan !== "lora") { + throw new BailianError( + `--aigc-* flags are only valid for plan=lora (video LoRA deployments). Got plan=${plan}.`, + ExitCode.USAGE, + ); + } + if (hasAigcFlags) { + const aigcConfig: Record = { + use_input_prompt: aigcUseInputPrompt ?? false, + }; + if (aigcPrompt !== undefined) aigcConfig.prompt = aigcPrompt; + if (aigcLoraPromptDefault !== undefined) { + aigcConfig.lora_prompt_default = aigcLoraPromptDefault; + } + body.aigc_config = aigcConfig; + } + if (settings.dryRun) { emitResult({ action: "deploy.create", body }, format); return; diff --git a/packages/commands/src/commands/deploy/delete.ts b/packages/commands/src/commands/deploy/delete.ts index 3e8560f..8340d09 100644 --- a/packages/commands/src/commands/deploy/delete.ts +++ b/packages/commands/src/commands/deploy/delete.ts @@ -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. } } diff --git a/packages/commands/src/commands/deploy/list.ts b/packages/commands/src/commands/deploy/list.ts index a2e9610..d26b4e3 100644 --- a/packages/commands/src/commands/deploy/list.ts +++ b/packages/commands/src/commands/deploy/list.ts @@ -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}`); diff --git a/packages/commands/src/commands/deploy/models.ts b/packages/commands/src/commands/deploy/models.ts index 1b742b5..f6ad233 100644 --- a/packages/commands/src/commands/deploy/models.ts +++ b/packages/commands/src/commands/deploy/models.ts @@ -76,44 +76,44 @@ export default defineCommand({ // downstream tooling can drive `bl deploy create --template-id <…>` without // a second round-trip. For text: keep the compact one-line summary. if (format === "json") { - const items = models.map((m) => { + const items = models.map((model) => { const out: Record = { - 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 = { 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 = { 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) { + if (plan.templates && plan.templates.length > 0) { // Pull the top 6 fields most useful for `bl deploy create`. // Drop noisy/redundant: template_source, template_type, // template_version, deploy_spec (typically == template_id). - planEntry.templates = p.templates.map((t) => { + planEntry.templates = plan.templates.map((template) => { const tpl: Record = {}; - 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}`); }, diff --git a/packages/commands/src/commands/deploy/plans.ts b/packages/commands/src/commands/deploy/plans.ts index 02d886b..93f8c9d 100644 --- a/packages/commands/src/commands/deploy/plans.ts +++ b/packages/commands/src/commands/deploy/plans.ts @@ -18,7 +18,7 @@ import { listDeployableModels, BailianError, ExitCode, type Client } from "baili /** Plan-relevant subset of `deploy create` flags (parsed flags satisfy this shape). */ export interface CreatePlanFlags { plan?: string; - templateId?: string; + deploySpec?: string; capacity?: number; billingMethod?: string; inputTpm?: number; @@ -101,15 +101,15 @@ 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. @@ -121,15 +121,15 @@ const muStrategy: PlanStrategy = { }, async resolve(ctx: PlanContext): Promise { const billingMethod = ctx.flags.billingMethod || "POST_PAY"; - let templateId = ctx.flags.templateId; + 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 +139,24 @@ 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.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 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 +166,7 @@ const muStrategy: PlanStrategy = { capacity: capacity ?? 1, billing_method: billingMethod, }; - if (templateId) body.template_id = templateId; + if (deploySpec) body.deploy_spec = deploySpec; return { body }; }, }; @@ -184,12 +185,12 @@ export const STRATEGIES: Record = { /** 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; } diff --git a/packages/commands/src/commands/finetune/create.ts b/packages/commands/src/commands/finetune/create.ts index a843c14..81d5ad1 100644 --- a/packages/commands/src/commands/finetune/create.ts +++ b/packages/commands/src/commands/finetune/create.ts @@ -4,12 +4,12 @@ import { createFineTune, getDataset, uploadDataset, - validateDataset, + detectModality, + getProfile, fetchModelCapability, listSupportedTrainingTypes, preflightBatchSizeGate, isTrainingTypeCli, - toServerTrainingType, TRAINING_TYPES_CLI, DEFAULT_TRAINING_TYPE, formatIssue, @@ -20,7 +20,8 @@ import { 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 +78,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 { const tokens = raw .split(",") @@ -109,11 +114,22 @@ 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 }); + // Detect modality per-file so each dataset is validated under the correct + // schema (e.g. a text JSONL and an audio ZIP in the same --datasets list + // are each validated with their own record 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 + : await detectModality(token); + + // Local path → validate through the profile. The profile internally routes + // to the correct validator based on modality (detected from file content). + // Upload is deferred to `uploadResolvedLocal` so the gate can run first. + // `model` is forwarded for schema-agnostic cross-checks (e.g. the video + // validator verifies a kf2v model is paired with kf2v data). + const result = await profile.validate(token, tokenModality, { model }); if (!result.valid) { const lines = [ `Dataset validation failed for ${token}`, @@ -306,20 +322,31 @@ export default defineCommand({ `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"; + + // Profile: single source of truth for how this training type behaves + // (validation rules, hyper-parameters, gates, capability check). + const profile = getProfile(trainingType); + + // Detect data modality from the first local file path in --datasets. This + // drives the profile's internal branching (text vs audio vs image/video + // hyper-parameters, gate skips, capability check bypass). Falls back to + // "text" when no local file is available (file-id only). Image generation + // has a subtype "image-i2i" (first record has input_img) picked here. + const firstLocalPath = datasetsRaw + .split(",") + .map((token) => token.trim()) + .find((token) => isLocalPath(token)); + const modality: DataModality = firstLocalPath ? await detectModality(firstLocalPath) : "text"; const training = await analyzeDatasetTokens( settings, identity.binName, datasetsRaw, "datasets", - datasetSchema, + profile, + modality, + model, + firstLocalPath ? { path: firstLocalPath, modality } : undefined, ); const trainingFileIds = training.fileIds; @@ -329,7 +356,9 @@ export default defineCommand({ identity.binName, flags.validations, "validations", - datasetSchema, + profile, + modality, + model, ) : undefined; const validationFileIds = validation?.fileIds; @@ -337,39 +366,52 @@ export default defineCommand({ 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; + // 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, + ) as FineTuneHyperParameters; - // 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) { + // 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/video): 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 (video uses 2/4) and misleading. + if ( + flags.batchSize !== undefined && + hp.batch_size !== undefined && + !settings.quiet && + !profile.shouldSkipGate("batch_size", modality) + ) { const requested = flags.batchSize; - let batchSize = requested; - if (batchSize < 8) batchSize = 8; - if (batchSize > 1024) batchSize = 1024; - if (batchSize !== requested && !settings.quiet) { + if (hp.batch_size !== requested) { process.stderr.write( - `warning: --batch-size ${requested} clamped to ${batchSize} ` + + `warning: --batch-size ${requested} clamped to ${hp.batch_size} ` + `(server range [8, 1024] for the common training types).\n`, ); } - hp.batch_size = batchSize; + } + // For modalities that skip the batch_size gate (video: fixed 2/4 by model + // family), warn the user that their explicit --batch-size was discarded. + if ( + flags.batchSize !== undefined && + !settings.quiet && + profile.shouldSkipGate("batch_size", modality) + ) { + const requested = flags.batchSize; + 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: 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) { + // Auto batch_size for small datasets — only for text data. Audio/image/video + // 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 { @@ -396,7 +438,11 @@ export default defineCommand({ // 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) { + 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; @@ -415,7 +461,7 @@ export default defineCommand({ // 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) { + if (!settings.dryRun && !profile.shouldSkipCapabilityCheck(modality)) { let capability: Awaited> | undefined; try { capability = await fetchModelCapability(settings, model); @@ -453,8 +499,8 @@ export default defineCommand({ 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), + // 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) { diff --git a/packages/core/package.json b/packages/core/package.json index abd3311..6257b2a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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:" diff --git a/packages/core/src/dataset/index.ts b/packages/core/src/dataset/index.ts index d1e73a9..cc605c4 100644 --- a/packages/core/src/dataset/index.ts +++ b/packages/core/src/dataset/index.ts @@ -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"; diff --git a/packages/core/src/dataset/inspect.ts b/packages/core/src/dataset/inspect.ts new file mode 100644 index 0000000..65c43da --- /dev/null +++ b/packages/core/src/dataset/inspect.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 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 { + 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 { + 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 { + 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; + 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 { + 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 { + return import("./validate/zip.ts") + .then(({ openZipAndFindEntry }) => openZipAndFindEntry(zipPath, entryName)) + .then(({ entry, zipfile }) => { + return new Promise((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; + }); +} diff --git a/packages/core/src/dataset/validate/common.ts b/packages/core/src/dataset/validate/common.ts index 26cc964..36b6d30 100644 --- a/packages/core/src/dataset/validate/common.ts +++ b/packages/core/src/dataset/validate/common.ts @@ -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, video.`, 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, first_frame_path/video_path → video, else ChatML).`, ); } diff --git a/packages/core/src/dataset/validate/index.ts b/packages/core/src/dataset/validate/index.ts index ce686ee..42df442 100644 --- a/packages/core/src/dataset/validate/index.ts +++ b/packages/core/src/dataset/validate/index.ts @@ -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, diff --git a/packages/core/src/dataset/validate/registry.ts b/packages/core/src/dataset/validate/registry.ts index f59d634..1be491a 100644 --- a/packages/core/src/dataset/validate/registry.ts +++ b/packages/core/src/dataset/validate/registry.ts @@ -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 { diff --git a/packages/core/src/dataset/validate/schemas/image.ts b/packages/core/src/dataset/validate/schemas/image.ts new file mode 100644 index 0000000..daef380 --- /dev/null +++ b/packages/core/src/dataset/validate/schemas/image.ts @@ -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, 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, +}; diff --git a/packages/core/src/dataset/validate/schemas/index.ts b/packages/core/src/dataset/validate/schemas/index.ts index 742815e..19cdeb8 100644 --- a/packages/core/src/dataset/validate/schemas/index.ts +++ b/packages/core/src/dataset/validate/schemas/index.ts @@ -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. diff --git a/packages/core/src/dataset/validate/schemas/tts.ts b/packages/core/src/dataset/validate/schemas/tts.ts new file mode 100644 index 0000000..8e7948a --- /dev/null +++ b/packages/core/src/dataset/validate/schemas/tts.ts @@ -0,0 +1,103 @@ +/** + * 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"; + +/** Expected audio file extensions (lower-case, with dot). */ +const AUDIO_EXTENSIONS = new Set([".wav", ".mp3", ".flac", ".ogg", ".m4a"]); + +function inspectTTSRecord(record: Record, 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 { + // Check that the path looks like it references an audio file. + const dotIndex = wavFn.lastIndexOf("."); + const ext = dotIndex >= 0 ? wavFn.slice(dotIndex).toLowerCase() : ""; + if (!AUDIO_EXTENSIONS.has(ext)) { + out.push( + makeIssue( + "warning", + "UNUSUAL_AUDIO_EXT", + `"wav_fn" points to a non-standard audio extension "${ext || "(none)"}". ` + + `Expected one of: ${[...AUDIO_EXTENSIONS].join(", ")}.`, + { 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, +}; diff --git a/packages/core/src/dataset/validate/schemas/video.ts b/packages/core/src/dataset/validate/schemas/video.ts new file mode 100644 index 0000000..9495548 --- /dev/null +++ b/packages/core/src/dataset/validate/schemas/video.ts @@ -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, + field: string, + required: boolean, + accepted: Set, + 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, 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, +}; diff --git a/packages/core/src/dataset/validate/types.ts b/packages/core/src/dataset/validate/types.ts index 9f71615..ea8cfce 100644 --- a/packages/core/src/dataset/validate/types.ts +++ b/packages/core/src/dataset/validate/types.ts @@ -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"; diff --git a/packages/core/src/dataset/validate/zip.ts b/packages/core/src/dataset/validate/zip.ts new file mode 100644 index 0000000..5dbd655 --- /dev/null +++ b/packages/core/src/dataset/validate/zip.ts @@ -0,0 +1,371 @@ +/** + * 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 { createWriteStream, mkdirSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { pipeline } from "stream/promises"; +import { randomBytes } from "crypto"; +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: import("yauzl").Entry; zipfile: import("yauzl").ZipFile }> { + return new Promise((resolve, reject) => { + import("yauzl") + .then((yauzl) => { + 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); + }); + }) + .catch(reject); + }); +} + +/** + * Collect all entry paths from a ZIP archive using yauzl. + * Returns normalised forward-slash paths. + */ +function collectZipEntries(zipPath: string): Promise { + return new Promise((resolve, reject) => { + import("yauzl") + .then((yauzl) => { + 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); + }); + }) + .catch(reject); + }); +} + +/** + * Extract a single entry from a ZIP archive to a destination path. + */ +async function extractZipEntry( + zipPath: string, + entryName: string, + destPath: string, +): Promise { + 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 { createReadStream } = await import("fs"); + const { createInterface } = await import("readline"); + 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; + 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 { + 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, + }, + }; + }, +}; diff --git a/packages/core/src/deploy/types.ts b/packages/core/src/deploy/types.ts index f1a2b05..e7927b8 100644 --- a/packages/core/src/deploy/types.ts +++ b/packages/core/src/deploy/types.ts @@ -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). */ diff --git a/packages/core/src/finetune/capability.ts b/packages/core/src/finetune/capability.ts index f3c0ecc..314bcd1 100644 --- a/packages/core/src/finetune/capability.ts +++ b/packages/core/src/finetune/capability.ts @@ -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; diff --git a/packages/core/src/finetune/index.ts b/packages/core/src/finetune/index.ts index b162966..e0b064b 100644 --- a/packages/core/src/finetune/index.ts +++ b/packages/core/src/finetune/index.ts @@ -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"; diff --git a/packages/core/src/finetune/profiles/common.ts b/packages/core/src/finetune/profiles/common.ts new file mode 100644 index 0000000..142e0bb --- /dev/null +++ b/packages/core/src/finetune/profiles/common.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, +): Record { + const hp: Record = {}; + 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 { + return validateDataset(filePath, { ...opts, schema }); + }, + + resolveHyperParameters( + _modality: DataModality, + flags: Record, + ): Record { + return resolveTextHyperParameters(flags); + }, + + shouldSkipGate(_gate: string, _modality: DataModality): boolean { + return false; + }, + + shouldSkipCapabilityCheck(_modality: DataModality): boolean { + return false; + }, + }; +} diff --git a/packages/core/src/finetune/profiles/cpt.ts b/packages/core/src/finetune/profiles/cpt.ts new file mode 100644 index 0000000..ab5aba7 --- /dev/null +++ b/packages/core/src/finetune/profiles/cpt.ts @@ -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"); diff --git a/packages/core/src/finetune/profiles/dpo-lora.ts b/packages/core/src/finetune/profiles/dpo-lora.ts new file mode 100644 index 0000000..820fbd0 --- /dev/null +++ b/packages/core/src/finetune/profiles/dpo-lora.ts @@ -0,0 +1,7 @@ +/** + * `dpo-lora` profile — LoRA variant of Direct Preference Optimization. + * Maps to the server's `dpo_lora` training type. DPO record schema. + */ +import { textProfile } from "./common.ts"; + +export const dpoLoraProfile = textProfile("dpo-lora", "dpo_lora", "dpo"); diff --git a/packages/core/src/finetune/profiles/dpo.ts b/packages/core/src/finetune/profiles/dpo.ts new file mode 100644 index 0000000..d76e040 --- /dev/null +++ b/packages/core/src/finetune/profiles/dpo.ts @@ -0,0 +1,7 @@ +/** + * `dpo` profile — Direct Preference Optimization (full-parameter). + * Maps to the server's `dpo_full` training type. DPO record schema. + */ +import { textProfile } from "./common.ts"; + +export const dpoProfile = textProfile("dpo", "dpo_full", "dpo"); diff --git a/packages/core/src/finetune/profiles/index.ts b/packages/core/src/finetune/profiles/index.ts new file mode 100644 index 0000000..5f1caf7 --- /dev/null +++ b/packages/core/src/finetune/profiles/index.ts @@ -0,0 +1,2 @@ +export type { TrainingProfile, DataModality } from "./types.ts"; +export { getProfile, listTrainingTypes } from "./registry.ts"; diff --git a/packages/core/src/finetune/profiles/registry.ts b/packages/core/src/finetune/profiles/registry.ts new file mode 100644 index 0000000..8d4531c --- /dev/null +++ b/packages/core/src/finetune/profiles/registry.ts @@ -0,0 +1,50 @@ +/** + * Training profile registry — single point of truth for which training types + * the CLI supports. + * + * Routing: `--training-type ` → exact match on `clientTrainingType`. + * Unknown values are rejected with a USAGE error listing all registered types. + * + * Adding a new training type: + * 1. Create `.ts` exporting a `TrainingProfile` constant. + * 2. Import and append to `PROFILES`. + * That's it. `create.ts` never needs to change. + */ +import { BailianError } from "../../errors/base.ts"; +import { ExitCode } from "../../errors/codes.ts"; +import type { TrainingProfile } from "./types.ts"; +import { sftProfile } from "./sft.ts"; +import { sftLoraProfile } from "./sft-lora.ts"; +import { dpoProfile } from "./dpo.ts"; +import { dpoLoraProfile } from "./dpo-lora.ts"; +import { cptProfile } from "./cpt.ts"; + +const PROFILES: TrainingProfile[] = [ + sftProfile, + sftLoraProfile, + dpoProfile, + dpoLoraProfile, + cptProfile, +]; + +/** Look up a profile by its CLI training-type name. Throws USAGE if unknown. */ +export function getProfile(clientTrainingType: string): TrainingProfile { + const p = PROFILES.find((p) => p.clientTrainingType === clientTrainingType); + if (!p) { + const known = PROFILES.map((p) => p.clientTrainingType).join(", "); + throw new BailianError( + `Unknown training type "${clientTrainingType}".`, + ExitCode.USAGE, + `Supported training types: ${known}.`, + ); + } + return p; +} + +/** All registered CLI training-type names (for help text / whitelisting). */ +export function listTrainingTypes(): string[] { + return PROFILES.map((p) => p.clientTrainingType); +} + +export type { TrainingProfile } from "./types.ts"; +export type { DataModality } from "./types.ts"; diff --git a/packages/core/src/finetune/profiles/sft-lora.ts b/packages/core/src/finetune/profiles/sft-lora.ts new file mode 100644 index 0000000..2319d9a --- /dev/null +++ b/packages/core/src/finetune/profiles/sft-lora.ts @@ -0,0 +1,231 @@ +/** + * `sft-lora` profile — LoRA fine-tuning via the server's `efficient_sft`. + * + * Accepts `.jsonl` (text data with ChatML `{messages}` schema) and `.zip` + * (audio / image data with a `data.jsonl` manifest inside). The data + * inspector detects the modality from file content; this profile routes to the + * correct validator and assembles modality-specific hyper-parameters. + * + * Text, audio, and image all share the same server training type + * (`efficient_sft`) but differ in every other dimension: validation rules, + * hyper-parameters, and pre-flight gates. All branching is internal — + * `create.ts` calls the uniform profile interface without knowing the modality. + */ +import type { TrainingProfile, DataModality } from "./types.ts"; +import type { ValidateOpts, ValidationResult } from "../../dataset/validate/types.ts"; +import { validateDataset } from "../../dataset/validate/registry.ts"; +import { makeIssue, MAX_MEDIA_ZIP_BYTES } from "../../dataset/validate/common.ts"; +import { resolveTextHyperParameters } from "./common.ts"; + +/** Audio TTS hyper-parameter defaults (CosyVoice v3 Flash). */ +const AUDIO_HYPER_PARAMS: Record = { + lm_max_epoch: 60, + lm_step: 5, + lm_num: 3, + lm_batch_size: 1000, + fm_max_epoch: 100, + fm_step: 10, + fm_num: 3, + fm_batch_size: 2000, +}; + +/** + * Image generation hyper-parameter defaults (Wan2.x). + * + * The platform accepts these via `hyper_parameters` in the POST /fine-tunes + * body. `generation_type` controls `max_pixels` and `val_img_size` defaults: + * - t2i: "2k" (2048×2048) + * - i2i: "1k" (1024×1024) + * + * The default is "t2i". The generation type is auto-detected from data + * content: if the first JSONL record has `input_img`, it's I2I; otherwise T2I. + * The inspector returns `"image-i2i"` for I2I data, which this profile uses + * directly to pick the right hyper-parameter set. + * `split` (0.9) auto-splits training data into train/validation when no + * explicit validation_file_ids are provided. + */ +const IMAGE_HYPER_PARAMS_T2I: Record = { + learning_rate: "3e-5", + max_steps: 800, + eval_steps: 200, + max_token_length: "1k", + gradient_clip: 0.5, + weight_decay: 0.02, + max_pixels: "2k", + val_img_size: "2k", + generation_type: "t2i", + lora_rank: 32, + save_total_limit: 10, + split: 0.9, +}; + +const IMAGE_HYPER_PARAMS_I2I: Record = { + ...IMAGE_HYPER_PARAMS_T2I, + max_pixels: "1k", + val_img_size: "1k", + generation_type: "i2i", +}; + +/** + * Image / video ZIP size cap — shared constant from dataset/validate/common.ts. + */ + +/** + * Video generation hyper-parameter defaults (Wan i2v / kf2v). + * + * Shared across all video models; `batch_size` and `max_pixels` differ by model + * family (resolved per model in `resolveHyperParameters`): + * - wan2.5 (e.g. wan2.5-i2v-preview): batch_size 2, max_pixels 36864 + * - wan2.2 (i2v-flash / kf2v-flash): batch_size 4, max_pixels 262144 + * + * `learning_rate` is a string to avoid JSON-number precision loss (consistent + * with the image defaults). `split` (0.9) + `max_split_val_dataset_sample` + * (5) drive the automatic train/validation split when no explicit + * validation_file_ids are provided. + */ +const VIDEO_HYPER_PARAMS_BASE: Record = { + n_epochs: 400, + learning_rate: "2e-5", + split: 0.9, + max_split_val_dataset_sample: 5, + eval_epochs: 50, + save_total_limit: 10, + lora_rank: 32, + lora_alpha: 32, +}; + +/** True for any image modality (base or subtype). */ +function isImage(modality: DataModality): boolean { + return modality === "image" || modality === "image-i2i"; +} + +/** True for any video modality (i2v base or kf2v subtype). */ +function isVideo(modality: DataModality): boolean { + return modality === "video" || modality === "video-kf2v"; +} + +/** wan2.5 family uses smaller batch_size / max_pixels than wan2.2. */ +function isWan25(model: string | undefined): boolean { + return typeof model === "string" && /wan2\.5/i.test(model); +} + +export const sftLoraProfile: TrainingProfile = { + clientTrainingType: "sft-lora", + serverTrainingType: "efficient_sft", + acceptedExtensions: [".jsonl", ".zip"], + + async validate( + filePath: string, + modality: DataModality, + opts: ValidateOpts, + ): Promise { + if (modality === "audio") { + // ZIP validation: structure check + JSONL content via tts schema. + // The zip validator internally calls jsonlValidator for data.jsonl. + return validateDataset(filePath, { ...opts, schema: "tts" }); + } + if (isImage(modality)) { + // Image generation: flat ZIP with data.jsonl + image files. + // The zip validator handles ≥25 image count + flat structure when + // schema is "image". Size cap is 1 GB (vs 300 MB for text/audio). + return validateDataset(filePath, { + ...opts, + schema: "image", + maxBytes: MAX_MEDIA_ZIP_BYTES, + }); + } + if (isVideo(modality)) { + // Video generation (Wan i2v/kf2v): ZIP with data.jsonl + frame images and + // (for training) target videos. i2v is flat; kf2v uses image//video/ + // subfolders. 1 GB cap like image. + const result = await validateDataset(filePath, { + ...opts, + schema: "video", + maxBytes: MAX_MEDIA_ZIP_BYTES, + }); + // Model <-> data cross-check: a kf2v model needs first+last-frame data, + // an i2v model ignores last_frame_path. Only runs when we know the model + // (bare file-id flows pass no model). Detected subtype comes from the + // data inspector (video = i2v, video-kf2v = has last_frame_path). + if (typeof opts.model === "string" && opts.model.length > 0) { + const modelIsKf2v = /kf2v/i.test(opts.model); + const dataIsKf2v = modality === "video-kf2v"; + if (modelIsKf2v && !dataIsKf2v) { + result.errors.push( + makeIssue( + "error", + "KF2V_DATA_MISMATCH", + `Model "${opts.model}" is a first+last-frame (kf2v) model but the data has ` + + `no "last_frame_path". kf2v training data must include a last frame per record.`, + ), + ); + } else if (!modelIsKf2v && dataIsKf2v) { + result.warnings.push( + makeIssue( + "warning", + "I2V_LAST_FRAME_IGNORED", + `Model "${opts.model}" is a first-frame (i2v) model but the data includes ` + + `"last_frame_path"; the last frame will be ignored during training.`, + ), + ); + } + } + result.valid = result.errors.length === 0; + return result; + } + // Text: standard JSONL validation with chatml schema. + return validateDataset(filePath, { ...opts, schema: "chatml" }); + }, + + resolveHyperParameters( + modality: DataModality, + flags: Record, + ): Record { + if (modality === "audio") { + // Audio: use TTS defaults, user flags override (MVP: all hardcoded). + return { ...AUDIO_HYPER_PARAMS }; + } + if (isImage(modality)) { + // Image: modality "image-i2i" (auto-detected from data having input_img) + // uses I2I defaults; plain "image" uses T2I defaults. + const base = modality === "image-i2i" ? IMAGE_HYPER_PARAMS_I2I : IMAGE_HYPER_PARAMS_T2I; + const hp = { ...base }; + if (flags.learningRate !== undefined) hp.learning_rate = flags.learningRate as string; + return hp; + } + if (isVideo(modality)) { + // Video: shared defaults + model-family-specific batch_size / max_pixels. + // wan2.5 uses batch_size 2 / max_pixels 36864; wan2.2 uses 4 / 262144. + const wan25 = isWan25(flags.model as string | undefined); + const hp: Record = { + ...VIDEO_HYPER_PARAMS_BASE, + batch_size: wan25 ? 2 : 4, + max_pixels: wan25 ? 36864 : 262144, + }; + // Optional overrides (no clamping — video batch_size is intentionally small). + if (flags.nEpochs !== undefined) hp.n_epochs = flags.nEpochs as number; + if (flags.learningRate !== undefined) hp.learning_rate = flags.learningRate as string; + return hp; + } + // Text: existing hyper-parameter logic (n_epochs, batch_size, etc.). + return resolveTextHyperParameters(flags); + }, + + shouldSkipGate(gate: string, modality: DataModality): boolean { + if (modality === "audio" || isImage(modality) || isVideo(modality)) { + // Audio has no batch_size hyper-parameter; image uses max_steps; video + // datasets are intentionally small (batch_size 2/4). All skip the + // batch-size pre-flight gate to avoid false rejections. + if (gate === "batch_size") return true; + } + return false; + }, + + shouldSkipCapabilityCheck(modality: DataModality): boolean { + // Audio models (e.g. cosyvoice-v3-flash), image models + // (e.g. wan2.7-image-pro) and video models (e.g. wan2.5-i2v-preview) report + // supports.sft=false in listFoundationModels but the API accepts + // efficient_sft — skip to avoid false blocking. + return modality === "audio" || isImage(modality) || isVideo(modality); + }, +}; diff --git a/packages/core/src/finetune/profiles/sft.ts b/packages/core/src/finetune/profiles/sft.ts new file mode 100644 index 0000000..8dba888 --- /dev/null +++ b/packages/core/src/finetune/profiles/sft.ts @@ -0,0 +1,7 @@ +/** + * `sft` profile — full-parameter Supervised Fine-Tuning. + * Maps to the server's `sft` training type. ChatML record schema. + */ +import { textProfile } from "./common.ts"; + +export const sftProfile = textProfile("sft", "sft", "chatml"); diff --git a/packages/core/src/finetune/profiles/types.ts b/packages/core/src/finetune/profiles/types.ts new file mode 100644 index 0000000..a93ef62 --- /dev/null +++ b/packages/core/src/finetune/profiles/types.ts @@ -0,0 +1,84 @@ +/** + * Training profile — single source of truth for "how a training type behaves". + * + * Each profile encapsulates everything the CLI needs to know about a specific + * `--training-type` value: what file formats it accepts, how to validate data, + * which hyper-parameters to use, which pre-flight gates to run, and whether + * the model-capability check should be skipped. + * + * Profiles are **decoupled from validators**: the profile declares which file + * extensions it accepts, and the data inspector (`dataset/inspect.ts`) detects + * the data modality by parsing the file content. The profile then routes to + * the appropriate validator internally — `create.ts` never sees an if-else + * about modalities or file formats. + * + * Adding a new training type = one new profile file + one line in the registry. + * Nothing in `create.ts` needs to change. + */ +import type { ValidateOpts, ValidationResult } from "../../dataset/validate/types.ts"; + +/** + * Data modality detected by the inspector from file content. + * + * The inspector peeks at the first record of a JSONL or the `data.jsonl` inside + * a ZIP to determine what kind of data the file carries. This is orthogonal to + * the training type — `sft-lora` accepts both `.jsonl` (text) and `.zip` + * (audio / image / video). + * + * `"image-i2i"` is a subtype of `"image"` — the first record contains an + * `input_img` field (image-to-image). `"video-kf2v"` is a subtype of `"video"` + * — the first record contains a `last_frame_path` field (first+last-frame video, + * Wan kf2v). Profiles can branch on subtypes to adjust hyper-parameter defaults + * or cross-check the chosen `--model`. Callers that don't care about a subtype + * can normalise `"image-i2i"` → `"image"` and `"video-kf2v"` → `"video"`. + */ +export type DataModality = "text" | "audio" | "image" | "image-i2i" | "video" | "video-kf2v"; + +/** + * A training profile. One per `--training-type` CLI value. + * + * The profile owns all modality-specific branching internally — callers + * (`create.ts`) interact with it through a uniform interface and never need + * to know whether the data is text, audio, or something else. + */ +export interface TrainingProfile { + /** CLI vocabulary: the value users pass to `--training-type`. */ + clientTrainingType: string; + + /** Server `training_type` for the POST /fine-tunes request body. */ + serverTrainingType: string; + + /** File extensions this profile accepts (lower-case, with dot). */ + acceptedExtensions: string[]; + + /** + * Validate the training data file. The profile internally routes to the + * correct validator based on `modality` (detected by the data inspector). + */ + validate(filePath: string, modality: DataModality, opts: ValidateOpts): Promise; + + /** + * Build the `hyper_parameters` object for the API request. Merges user-supplied + * flags with modality-specific defaults (e.g. audio TTS uses `lm_max_epoch` / + * `fm_max_epoch`, text SFT uses `n_epochs` / `batch_size`). + */ + resolveHyperParameters( + modality: DataModality, + flags: Record, + ): Record; + + /** + * Whether a specific pre-flight gate should be skipped for the given modality. + * Common gates: `"batch_size"` (samples must exceed batch_size), etc. + * Audio TTS skips the batch-size gate (no batch_size hyper-parameter). + */ + shouldSkipGate(gate: string, modality: DataModality): boolean; + + /** + * Whether the model-capability pre-flight check should be skipped. + * Audio models (e.g. cosyvoice-v3-flash) report `supports.sft = false` in + * `listFoundationModels` but the API accepts `efficient_sft` — the check + * would incorrectly block the submission. + */ + shouldSkipCapabilityCheck(modality: DataModality): boolean; +} diff --git a/packages/core/tests/dataset-validate.test.ts b/packages/core/tests/dataset-validate.test.ts index 632cade..b4b2b52 100644 --- a/packages/core/tests/dataset-validate.test.ts +++ b/packages/core/tests/dataset-validate.test.ts @@ -169,10 +169,13 @@ describe("parseDatasetSchemaFlag", () => { expect(parseDatasetSchemaFlag(" ")).toBeUndefined(); }); - test("chatml / dpo / cpt pass through", () => { + test("chatml / dpo / cpt / tts / image / video pass through", () => { expect(parseDatasetSchemaFlag("chatml")).toBe("chatml"); expect(parseDatasetSchemaFlag("dpo")).toBe("dpo"); expect(parseDatasetSchemaFlag("cpt")).toBe("cpt"); + expect(parseDatasetSchemaFlag("tts")).toBe("tts"); + expect(parseDatasetSchemaFlag("image")).toBe("image"); + expect(parseDatasetSchemaFlag("video")).toBe("video"); expect(parseDatasetSchemaFlag(" dpo ")).toBe("dpo"); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a316881..67246c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@types/node': specifier: ^24 version: 24.12.2 + '@types/yauzl': + specifier: ^3.4.0 + version: 3.4.0 ajv: specifier: ^8.20.0 version: 8.20.0 @@ -27,6 +30,9 @@ catalogs: yaml: specifier: ^2.8.3 version: 2.8.3 + yauzl: + specifier: ^3.4.0 + version: 3.4.0 overrides: vite: npm:@voidzero-dev/vite-plus-core@latest @@ -119,10 +125,16 @@ importers: yaml: specifier: ^2.8.3 version: 2.8.3 + yauzl: + specifier: 'catalog:' + version: 3.4.0 devDependencies: '@types/node': specifier: 'catalog:' version: 24.12.2 + '@types/yauzl': + specifier: 'catalog:' + version: 3.4.0 '@typescript/native-preview': specifier: 7.0.0-dev.20260328.1 version: 7.0.0-dev.20260328.1 @@ -645,6 +657,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/yauzl@3.4.0': + resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260328.1': resolution: {integrity: sha512-BmJGDWC0bSQ2w5O/E+Mw9eTv9RklJ3vjshu7UdD92bUMxc4V4dkBhYj5r0qxbl4f+VFNX7fXvcDDI+9o+Kb6yw==} cpu: [arm64] @@ -1021,6 +1036,9 @@ packages: oxlint-tsgolint: optional: true + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1193,6 +1211,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + snapshots: '@clack/core@0.3.5': @@ -1443,7 +1465,10 @@ snapshots: '@types/node@25.6.0': dependencies: undici-types: 7.19.2 - optional: true + + '@types/yauzl@3.4.0': + dependencies: + '@types/node': 25.6.0 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260328.1': optional: true @@ -1781,6 +1806,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc': 1.63.0 oxlint-tsgolint: 0.22.1 + pend@1.2.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -1874,8 +1901,7 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.19.2: - optional: true + undici-types@7.19.2: {} undici@8.4.1: {} @@ -2016,3 +2042,7 @@ snapshots: ws@8.20.0: {} yaml@2.8.3: {} + + yauzl@3.4.0: + dependencies: + pend: 1.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 031814b..cb9f7f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: catalog: "@types/node": ^24 + "@types/yauzl": ^3.4.0 ajv: ^8.20.0 boxen: ^8.0.1 chalk: ^5.6.2 @@ -13,6 +14,7 @@ catalog: vite-plus: latest vitest: npm:@voidzero-dev/vite-plus-test@latest yaml: ^2.8.3 + yauzl: ^3.4.0 catalogMode: prefer overrides: diff --git a/skills/bailian-cli/reference/dataset.md b/skills/bailian-cli/reference/dataset.md index 65650e9..365d84d 100644 --- a/skills/bailian-cli/reference/dataset.md +++ b/skills/bailian-cli/reference/dataset.md @@ -7,13 +7,13 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| --------------------- | ---------------------------------------------------------- | -| `bl dataset delete` | Delete a dataset file by ID | -| `bl dataset get` | Get details of a single dataset file | -| `bl dataset list` | List uploaded dataset files | -| `bl dataset upload` | Upload a dataset file (.jsonl) to Bailian | -| `bl dataset validate` | Locally validate a dataset file (.jsonl) without uploading | +| Command | Description | +| --------------------- | ------------------------------------------------------------------ | +| `bl dataset delete` | Delete a dataset file by ID | +| `bl dataset get` | Get details of a single dataset file | +| `bl dataset list` | List uploaded dataset files | +| `bl dataset upload` | Upload a dataset file (.jsonl or .zip) to Bailian | +| `bl dataset validate` | Locally validate a dataset file (.jsonl or .zip) without uploading | ## Command details @@ -107,36 +107,36 @@ bl dataset list --output json ### `bl dataset upload` -| Field | Value | -| --------------- | -------------------------------------------------------------------------------------------------------------------- | -| **Name** | `dataset upload` | -| **Description** | Upload a dataset file (.jsonl) to Bailian | -| **Usage** | `bl dataset upload --file [--purpose ] [--schema ] [--no-validate] [--full-validate]` | +| Field | Value | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `dataset upload` | +| **Description** | Upload a dataset file (.jsonl or .zip) to Bailian | +| **Usage** | `bl dataset upload --file [--purpose ] [--schema ] [--no-validate] [--full-validate]` | #### Flags -| Flag | Type | Required | Description | -| ------------------ | ------ | -------- | ------------------------------------------------------------------------------------------------------------- | -| `--file ` | string | yes | Local .jsonl dataset file (≤300MB) | -| `--purpose ` | string | no | Dataset purpose tag (default: "fine-tune"; e.g. "evaluation") | -| `--schema ` | string | no | Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record. | -| `--no-validate` | switch | no | Skip the local JSONL pre-flight check (not recommended) | -| `--full-validate` | switch | no | JSON.parse every line instead of sampling (slower) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--file ` | string | yes | Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image/video) | +| `--purpose ` | string | no | Dataset purpose tag (default: "fine-tune"; e.g. "evaluation") | +| `--schema ` | string | no | Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). Default auto-detects per record. | +| `--no-validate` | switch | no | Skip the local JSONL pre-flight check (not recommended) | +| `--full-validate` | switch | no | JSON.parse every line instead of sampling (slower) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### 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. +- Supports .jsonl (text) and .zip (audio/image/video archives with a +- data.jsonl manifest). Six 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); video = {first_frame_path:"...", +- video_path:"..."} (video generation). With no --schema, a record +- carrying wav_fn is validated as TTS, img_path as image, video_path / +- first_frame_path as video, chosen/rejected as DPO, text (no messages) +- as CPT, otherwise ChatML. Upload cap: 300MB text, 1GB image/video. - Upload uses the OpenAI-compatible /compatible-mode/v1/files endpoint so - the purpose tag is persisted (the DashScope-native /api/v1/files drops it). @@ -154,6 +154,10 @@ bl dataset upload --file dpo.jsonl --schema dpo bl dataset upload --file cpt.jsonl --schema cpt ``` +```bash +bl dataset upload --file audio.zip --schema tts +``` + ```bash bl dataset upload --file eval.jsonl --purpose evaluation ``` @@ -168,31 +172,35 @@ bl dataset upload --file train.jsonl --no-validate ### `bl dataset validate` -| Field | Value | -| --------------- | ----------------------------------------------------------------------------------- | -| **Name** | `dataset validate` | -| **Description** | Locally validate a dataset file (.jsonl) without uploading | -| **Usage** | `bl dataset validate --file [--full-validate] [--schema ]` | +| Field | Value | +| --------------- | ------------------------------------------------------------------------------------------------------ | +| **Name** | `dataset validate` | +| **Description** | Locally validate a dataset file (.jsonl or .zip) without uploading | +| **Usage** | `bl dataset validate --file [--full-validate] [--schema ]` | #### Flags -| Flag | Type | Required | Description | -| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------- | -| `--file ` | string | yes | Local .jsonl dataset file | -| `--full-validate` | switch | no | JSON.parse every line instead of sampling (slower) | -| `--schema ` | string | no | Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record. | +| Flag | Type | Required | Description | +| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--file ` | string | yes | Local dataset file (.jsonl or .zip) | +| `--full-validate` | switch | no | JSON.parse every line instead of sampling (slower) | +| `--schema ` | string | no | Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). Default auto-detects per record. | #### Notes - Default scan: every line gets a structural check, then ~160 lines (front 50, - evenly spaced 100, last 10) are JSON.parsed against the active schema. - Schemas: chatml = {messages:[...]} (SFT); dpo = {messages:[...], chosen, -- rejected} where chosen/rejected are single assistant messages; cpt = -- {text:"..."} (continual pre-training, raw text). With no --schema, a -- record carrying chosen/rejected is validated as DPO, one with text (and no -- messages) as CPT, otherwise as ChatML. Pass --schema dpo / cpt to require -- that shape on every record (strict), or --schema chatml to ignore the -- preference / text fields. Use --full-validate to JSON.parse every line. +- rejected}; cpt = {text:"..."} (continual pre-training, raw text); +- tts = {wav_fn:"train/xxx.wav", text:"..."} (audio fine-tuning); +- image = {img_path:"..."} (image generation); video = +- {first_frame_path:"...", video_path:"..."} (video generation). With no +- --schema, a record carrying wav_fn is validated as TTS, img_path as +- image, video_path / first_frame_path as video, 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. #### Examples @@ -208,6 +216,10 @@ bl dataset validate --file dpo.jsonl --schema dpo bl dataset validate --file cpt.jsonl --schema cpt ``` +```bash +bl dataset validate --file audio.zip --schema tts +``` + ```bash bl dataset validate --file eval.jsonl --full-validate ``` diff --git a/skills/bailian-cli/reference/deploy.md b/skills/bailian-cli/reference/deploy.md index 99e9a9c..57f4190 100644 --- a/skills/bailian-cli/reference/deploy.md +++ b/skills/bailian-cli/reference/deploy.md @@ -25,23 +25,26 @@ Index: [index.md](index.md) | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Name** | `deploy create` | | **Description** | Create a model deployment | -| **Usage** | `bl deploy create --model --name [--plan ] [--template-id ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]` | +| **Usage** | `bl deploy create --model --name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]` | #### Flags -| Flag | Type | Required | Description | -| --------------------------- | ------ | -------- | ------------------------------------------------------------------------------- | -| `--model ` | string | yes | Model name (catalog model or fine-tuned output) (required) | -| `--name ` | string | yes | Console display name for the deployment (required) | -| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu | -| `--template-id ` | string | no | Template id (only used by plan=mu; auto-picked if omitted) | -| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | -| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | -| `--input-tpm ` | number | no | PTU max input tokens/min (required for plan=ptu) | -| `--output-tpm ` | number | no | PTU max output tokens/min (required for plan=ptu) | -| `--thinking-output-tpm ` | number | no | PTU max thinking-output tokens/min (optional, some models) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ----------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------ | +| `--model ` | string | yes | Model name (catalog model or fine-tuned output) (required) | +| `--name ` | string | yes | Console display name for the deployment (required) | +| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu | +| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | +| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | +| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | +| `--input-tpm ` | number | no | PTU max input tokens/min (required for plan=ptu) | +| `--output-tpm ` | number | no | PTU max output tokens/min (required for plan=ptu) | +| `--thinking-output-tpm ` | number | no | PTU max thinking-output tokens/min (optional, some models) | +| `--aigc-use-input-prompt ` | boolean | no | Video LoRA (aigc_config): honor the caller's prompt at inference (default false = use preset template) | +| `--aigc-prompt ` | string | no | Video LoRA (aigc_config): preset prompt template used when use-input-prompt is false | +| `--aigc-lora-prompt-default ` | string | no | Video LoRA (aigc_config): default trigger-word phrase for the LoRA | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Notes @@ -49,12 +52,15 @@ Index: [index.md](index.md) - 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 +- 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 +- For fine-tuned Wan video (i2v/kf2v) LoRA models, use --plan lora and pass +- --aigc-prompt / --aigc-lora-prompt-default (and optionally +- --aigc-use-input-prompt) to set the deployment's aigc_config. - 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` @@ -78,7 +84,11 @@ bl deploy create --model qwen3-8b --name my-qwen3-mu --plan mu ``` ```bash -bl deploy create --model qwen3-8b --name my-qwen3 --plan mu --template-id MU1 --capacity 2 +bl deploy create --model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2 +``` + +```bash +bl deploy create --model wan2.5-i2v-preview-ft-xxx --name my-video-lora --plan lora --aigc-prompt "..." --aigc-lora-prompt-default "..." ``` ### `bl deploy delete` diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index df02287..07aff6b 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -22,8 +22,8 @@ Use this index for the full quick index and global flags. | `bl dataset delete` | Delete a dataset file by ID | [dataset.md](dataset.md) | | `bl dataset get` | Get details of a single dataset file | [dataset.md](dataset.md) | | `bl dataset list` | List uploaded dataset files | [dataset.md](dataset.md) | -| `bl dataset upload` | Upload a dataset file (.jsonl) to Bailian | [dataset.md](dataset.md) | -| `bl dataset validate` | Locally validate a dataset file (.jsonl) without uploading | [dataset.md](dataset.md) | +| `bl dataset upload` | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) | +| `bl dataset validate` | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) | | `bl deploy create` | Create a model deployment | [deploy.md](deploy.md) | | `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) | [deploy.md](deploy.md) | | `bl deploy get` | Get details of a single model deployment | [deploy.md](deploy.md) | From d8aa89dc6cbbcde82998c9feafd2874d449e950b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Thu, 9 Jul 2026 15:36:32 +0800 Subject: [PATCH 02/13] feat: add image/audio finetune --- packages/cli/tests/e2e/dataset.e2e.test.ts | 77 +++++++++++----- packages/cli/tests/e2e/deploy.e2e.test.ts | 63 ------------- .../commands/src/commands/dataset/upload.ts | 39 ++++---- .../commands/src/commands/dataset/validate.ts | 26 +++--- .../commands/src/commands/deploy/constants.ts | 41 +++++++++ .../commands/src/commands/deploy/create.ts | 55 +----------- .../commands/src/commands/deploy/plans.ts | 20 +++-- .../commands/src/commands/finetune/create.ts | 10 +++ packages/core/src/dataset/inspect.ts | 4 +- packages/core/src/dataset/validate/common.ts | 4 +- packages/core/src/dataset/validate/zip.ts | 88 +++++++++---------- .../core/src/finetune/profiles/sft-lora.ts | 2 +- skills/bailian-cli/reference/dataset.md | 85 +++++++++--------- skills/bailian-cli/reference/deploy.md | 36 +++----- 14 files changed, 256 insertions(+), 294 deletions(-) create mode 100644 packages/commands/src/commands/deploy/constants.ts diff --git a/packages/cli/tests/e2e/dataset.e2e.test.ts b/packages/cli/tests/e2e/dataset.e2e.test.ts index 50acb54..2118ff8 100644 --- a/packages/cli/tests/e2e/dataset.e2e.test.ts +++ b/packages/cli/tests/e2e/dataset.e2e.test.ts @@ -216,7 +216,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test("dataset upload --schema image --no-validate --dry-run 采用 1GB 媒体上限", async () => { - // image/video schemas raise the upload cap to 1 GiB (vs 300 MB for text). + // 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(__dirname, ".dataset-valid.jsonl"); const { stdout, stderr, exitCode } = await runCli([ @@ -238,28 +238,59 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { expect(data.max_bytes).toBe(1024 * 1024 * 1024); }); - test.each(["tts", "image", "video"])( - "dataset upload --dry-run 接受媒体 schema %s", - async (schema) => { - const file = join(__dirname, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ - "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.each(["tts", "image"])("dataset upload --dry-run 接受媒体 schema %s", async (schema) => { + const file = join(__dirname, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCli([ + "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(__dirname, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCli([ + "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(__dirname, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCli([ + "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)", () => { diff --git a/packages/cli/tests/e2e/deploy.e2e.test.ts b/packages/cli/tests/e2e/deploy.e2e.test.ts index 7c3a47f..d40ac2c 100644 --- a/packages/cli/tests/e2e/deploy.e2e.test.ts +++ b/packages/cli/tests/e2e/deploy.e2e.test.ts @@ -56,69 +56,6 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { expect(data.body.capacity).toBe(1); }); - test("deploy create --dry-run 组装视频 LoRA 的 aigc_config", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "deploy", - "create", - "--model", - "wan2.5-i2v-preview-ft-xxx", - "--name", - "my-video-lora", - "--aigc-prompt", - "a cat surfing", - "--aigc-lora-prompt-default", - "trigger-word", - "--aigc-use-input-prompt", - "true", - "--dry-run", - "--output", - "json", - ]); - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ - action: string; - body: { - plan: string; - aigc_config?: { - use_input_prompt?: boolean; - prompt?: string; - lora_prompt_default?: string; - }; - }; - }>(stdout); - expect(data.action).toBe("deploy.create"); - expect(data.body.plan).toBe("lora"); - expect(data.body.aigc_config).toEqual({ - use_input_prompt: true, - prompt: "a cat surfing", - lora_prompt_default: "trigger-word", - }); - }); - - test("deploy create --aigc-* 仅对 plan=lora 有效(plan=ptu 时报错)", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "deploy", - "create", - "--model", - "wan2.5-i2v-preview-ft-xxx", - "--name", - "my-video-lora", - "--plan", - "ptu", - "--input-tpm", - "10000", - "--output-tpm", - "1000", - "--aigc-prompt", - "a cat surfing", - "--dry-run", - "--output", - "json", - ]); - expect(exitCode, stdout + stderr).not.toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/--aigc-\* flags are only valid for plan=lora/); - }); - test("deploy create --plan mu --deploy-spec --dry-run 透传 deploy_spec", async () => { const { stdout, stderr, exitCode } = await runCli([ "deploy", diff --git a/packages/commands/src/commands/dataset/upload.ts b/packages/commands/src/commands/dataset/upload.ts index ed0a7e1..f7d805b 100644 --- a/packages/commands/src/commands/dataset/upload.ts +++ b/packages/commands/src/commands/dataset/upload.ts @@ -18,7 +18,7 @@ const UPLOAD_FLAGS = { file: { type: "string", valueHint: "", - description: "Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image/video)", + description: "Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image)", required: true, }, purpose: { @@ -30,7 +30,7 @@ const UPLOAD_FLAGS = { type: "string", valueHint: "", description: - 'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). 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", @@ -46,7 +46,7 @@ export default defineCommand({ description: "Upload a dataset file (.jsonl or .zip) to Bailian", auth: "apiKey", usageArgs: - "--file [--purpose ] [--schema ] [--no-validate] [--full-validate]", + "--file [--purpose ] [--schema ] [--no-validate] [--full-validate]", flags: UPLOAD_FLAGS, exampleArgs: [ "--file train.jsonl", @@ -58,27 +58,32 @@ export default defineCommand({ "--file train.jsonl --no-validate", ], notes: [ - "Supports .jsonl (text) and .zip (audio/image/video archives with a", - "data.jsonl manifest). Six 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); video = {first_frame_path:"...",', - 'video_path:"..."} (video generation). With no --schema, a record', - "carrying wav_fn is validated as TTS, img_path as image, video_path /", - "first_frame_path as video, chosen/rejected as DPO, text (no messages)", - "as CPT, otherwise ChatML. Upload cap: 300MB text, 1GB image/video.", - "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 / video schemas allow larger ZIPs (1 GB vs 300 MB for text). - const isMediaSchema = schema === "image" || schema === "video"; + // Image schema allows larger ZIPs (1 GB vs 300 MB for text). + const isMediaSchema = schema === "image"; if (!flags.noValidate) { const maxBytes = isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES; diff --git a/packages/commands/src/commands/dataset/validate.ts b/packages/commands/src/commands/dataset/validate.ts index f9c8cb0..8bf0d29 100644 --- a/packages/commands/src/commands/dataset/validate.ts +++ b/packages/commands/src/commands/dataset/validate.ts @@ -36,7 +36,7 @@ const VALIDATE_FLAGS = { type: "string", valueHint: "", description: - 'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). 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; @@ -44,7 +44,7 @@ export default defineCommand({ description: "Locally validate a dataset file (.jsonl or .zip) without uploading", // 纯本地校验,不触网、不需 API key(与 `pipeline validate` 一致)。 auth: "none", - usageArgs: "--file [--full-validate] [--schema ]", + usageArgs: "--file [--full-validate] [--schema ]", flags: VALIDATE_FLAGS, exampleArgs: [ "--file train.jsonl", @@ -60,19 +60,25 @@ export default defineCommand({ "Schemas: 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); video =', - '{first_frame_path:"...", video_path:"..."} (video generation). With no', - "--schema, a record carrying wav_fn is validated as TTS, img_path as", - "image, video_path / first_frame_path as video, 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.", + '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) { diff --git a/packages/commands/src/commands/deploy/constants.ts b/packages/commands/src/commands/deploy/constants.ts new file mode 100644 index 0000000..748645f --- /dev/null +++ b/packages/commands/src/commands/deploy/constants.ts @@ -0,0 +1,41 @@ +/** + * 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; + +/** 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]; diff --git a/packages/commands/src/commands/deploy/create.ts b/packages/commands/src/commands/deploy/create.ts index d9a63e8..6f4a2dd 100644 --- a/packages/commands/src/commands/deploy/create.ts +++ b/packages/commands/src/commands/deploy/create.ts @@ -2,13 +2,12 @@ import { defineCommand, detectOutputFormat, createDeployment, - BailianError, - ExitCode, type CreateDeploymentRequest, type FlagsDef, } from "bailian-cli-core"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { pickPlanStrategy, STRATEGIES } from "./plans.ts"; +import { DEFAULT_DEPLOY_PLAN } from "./constants.ts"; const CREATE_FLAGS = { model: { @@ -58,23 +57,6 @@ const CREATE_FLAGS = { valueHint: "", description: "PTU max thinking-output tokens/min (optional, some models)", }, - aigcUseInputPrompt: { - type: "boolean", - valueHint: "", - description: - "Video LoRA (aigc_config): honor the caller's prompt at inference (default false = use preset template)", - }, - aigcPrompt: { - type: "string", - valueHint: "", - description: - "Video LoRA (aigc_config): preset prompt template used when use-input-prompt is false", - }, - aigcLoraPromptDefault: { - type: "string", - valueHint: "", - description: "Video LoRA (aigc_config): default trigger-word phrase for the LoRA", - }, } satisfies FlagsDef; /** @@ -99,7 +81,6 @@ export default defineCommand({ "--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 --deploy-spec MU1 --capacity 2", - '--model wan2.5-i2v-preview-ft-xxx --name my-video-lora --plan lora --aigc-prompt "..." --aigc-lora-prompt-default "..."', ], notes: [ "Plan defaults to `lora` (Token-billed). Pass --plan to override.", @@ -112,9 +93,6 @@ export default defineCommand({ "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 ", - "For fine-tuned Wan video (i2v/kf2v) LoRA models, use --plan lora and pass", - "--aigc-prompt / --aigc-lora-prompt-default (and optionally", - "--aigc-use-input-prompt) to set the deployment's aigc_config.", "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`", @@ -124,7 +102,7 @@ export default defineCommand({ "Do not reuse the value across the two commands.", ], validate: (flags) => { - const plan = flags.plan || "lora"; + const plan = flags.plan || DEFAULT_DEPLOY_PLAN; const strategy = STRATEGIES[plan]; if (!strategy) { return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`; @@ -135,7 +113,7 @@ export default defineCommand({ const { identity, settings, flags } = ctx; const model = flags.model; const name = flags.name; - const plan = flags.plan || "lora"; + const plan = flags.plan || DEFAULT_DEPLOY_PLAN; const format = detectOutputFormat(settings.output); // Plan-specific behaviour is owned by `plans.ts`. The strategy resolves @@ -159,33 +137,6 @@ export default defineCommand({ ...resolved.body, }; - // AIGC config (fine-tuned Wan video LoRA deployments). Only valid for - // plan=lora — reject early for ptu/mu so the user gets a clear CLI error - // instead of an opaque server-side rejection. - const aigcUseInputPrompt = flags.aigcUseInputPrompt; - const aigcPrompt = flags.aigcPrompt; - const aigcLoraPromptDefault = flags.aigcLoraPromptDefault; - const hasAigcFlags = - aigcUseInputPrompt !== undefined || - aigcPrompt !== undefined || - aigcLoraPromptDefault !== undefined; - if (hasAigcFlags && plan !== "lora") { - throw new BailianError( - `--aigc-* flags are only valid for plan=lora (video LoRA deployments). Got plan=${plan}.`, - ExitCode.USAGE, - ); - } - if (hasAigcFlags) { - const aigcConfig: Record = { - use_input_prompt: aigcUseInputPrompt ?? false, - }; - if (aigcPrompt !== undefined) aigcConfig.prompt = aigcPrompt; - if (aigcLoraPromptDefault !== undefined) { - aigcConfig.lora_prompt_default = aigcLoraPromptDefault; - } - body.aigc_config = aigcConfig; - } - if (settings.dryRun) { emitResult({ action: "deploy.create", body }, format); return; diff --git a/packages/commands/src/commands/deploy/plans.ts b/packages/commands/src/commands/deploy/plans.ts index 93f8c9d..bde40a4 100644 --- a/packages/commands/src/commands/deploy/plans.ts +++ b/packages/commands/src/commands/deploy/plans.ts @@ -14,6 +14,7 @@ * auto-pick / body assembly) into one strategy entry per plan. */ import { listDeployableModels, BailianError, ExitCode, type Client } from "bailian-cli-core"; +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). */ export interface CreatePlanFlags { @@ -65,7 +66,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 +82,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."; @@ -115,12 +116,12 @@ const ptuStrategy: PlanStrategy = { * 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 { - const billingMethod = ctx.flags.billingMethod || "POST_PAY"; + const billingMethod = ctx.flags.billingMethod || DEFAULT_BILLING_METHOD; let deploySpec = ctx.flags.deploySpec; let capacity = ctx.flags.capacity; @@ -140,11 +141,12 @@ const muStrategy: PlanStrategy = { }); const payload = resp.output ?? resp.data; const target = (payload?.models ?? []).find((model) => model.model_name === ctx.model); - const muPlan = target?.plans?.find((plan) => plan.plan === "mu"); + 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 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(); @@ -178,9 +180,9 @@ const muStrategy: PlanStrategy = { * reject anything outside this table with a clear USAGE error. */ export const STRATEGIES: Record = { - 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. */ diff --git a/packages/commands/src/commands/finetune/create.ts b/packages/commands/src/commands/finetune/create.ts index 81d5ad1..58907eb 100644 --- a/packages/commands/src/commands/finetune/create.ts +++ b/packages/commands/src/commands/finetune/create.ts @@ -337,6 +337,16 @@ export default defineCommand({ .map((token) => token.trim()) .find((token) => isLocalPath(token)); const modality: DataModality = firstLocalPath ? await detectModality(firstLocalPath) : "text"; + // Video generation model fine-tuning is currently not exposed via the CLI. + // The core profile/schema implementation is retained, but the entry point + // is disabled here so video datasets are not accepted. + if (modality === "video" || modality === "video-kf2v") { + throw new BailianError( + `Video generation model fine-tuning is not supported.`, + ExitCode.USAGE, + `Detected video data in "${firstLocalPath}". Supported data: text, audio, image.`, + ); + } const training = await analyzeDatasetTokens( settings, diff --git a/packages/core/src/dataset/inspect.ts b/packages/core/src/dataset/inspect.ts index 65c43da..9a556ae 100644 --- a/packages/core/src/dataset/inspect.ts +++ b/packages/core/src/dataset/inspect.ts @@ -21,6 +21,7 @@ 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"; /** @@ -159,8 +160,7 @@ function readFirstNonBlankLine(filePath: string): Promise { * `validate/zip.ts` — avoids duplicating yauzl boilerplate. */ function readFirstLineFromZipEntry(zipPath: string, entryName: string): Promise { - return import("./validate/zip.ts") - .then(({ openZipAndFindEntry }) => openZipAndFindEntry(zipPath, entryName)) + return openZipAndFindEntry(zipPath, entryName) .then(({ entry, zipfile }) => { return new Promise((resolve, reject) => { zipfile.openReadStream(entry, (streamErr, readStream) => { diff --git a/packages/core/src/dataset/validate/common.ts b/packages/core/src/dataset/validate/common.ts index 36b6d30..17bdbad 100644 --- a/packages/core/src/dataset/validate/common.ts +++ b/packages/core/src/dataset/validate/common.ts @@ -85,9 +85,9 @@ export function parseDatasetSchemaFlag(value: string | undefined): DatasetSchema 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, tts, image, video.`, + `Unsupported --schema "${value}". Supported: chatml, dpo, cpt, tts, image.`, ExitCode.USAGE, - `Omit --schema to auto-detect per record (chosen/rejected → DPO, text → CPT, wav_fn → TTS, img_path → image, first_frame_path/video_path → video, else ChatML).`, + `Omit --schema to auto-detect per record (chosen/rejected → DPO, text → CPT, wav_fn → TTS, img_path → image, else ChatML).`, ); } diff --git a/packages/core/src/dataset/validate/zip.ts b/packages/core/src/dataset/validate/zip.ts index 5dbd655..4309133 100644 --- a/packages/core/src/dataset/validate/zip.ts +++ b/packages/core/src/dataset/validate/zip.ts @@ -16,11 +16,13 @@ * `"tts"` for audio). The profile layer decides which schema to use based on * the detected modality — this validator is schema-agnostic. */ -import { createWriteStream, mkdirSync, rmSync } from "fs"; +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"; @@ -39,32 +41,28 @@ import { IMAGE_EXTENSIONS } from "./schemas/image.ts"; export function openZipAndFindEntry( zipPath: string, targetName: string, -): Promise<{ entry: import("yauzl").Entry; zipfile: import("yauzl").ZipFile }> { +): Promise<{ entry: yauzl.Entry; zipfile: yauzl.ZipFile }> { return new Promise((resolve, reject) => { - import("yauzl") - .then((yauzl) => { - yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => { - if (err || !zipfile) { - reject(new Error(`Failed to open ZIP: ${err?.message ?? "unknown error"}`)); - return; - } + 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("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); - }); - }) - .catch(reject); + } + }); + zipfile.on("end", () => { + zipfile.close(); + reject(new Error(`Entry "${targetName}" not found in ZIP`)); + }); + zipfile.on("error", reject); + }); }); } @@ -74,27 +72,23 @@ export function openZipAndFindEntry( */ function collectZipEntries(zipPath: string): Promise { return new Promise((resolve, reject) => { - import("yauzl") - .then((yauzl) => { - 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); - }); - }) - .catch(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); + }); }); } @@ -140,8 +134,6 @@ async function collectMediaRefs( jsonlPath: string, maxLines = 100, ): Promise<{ refs: string[]; totalLines: number }> { - const { createReadStream } = await import("fs"); - const { createInterface } = await import("readline"); const stream = createReadStream(jsonlPath, { encoding: "utf8" }); const rl = createInterface({ input: stream, crlfDelay: Infinity }); const refs: string[] = []; diff --git a/packages/core/src/finetune/profiles/sft-lora.ts b/packages/core/src/finetune/profiles/sft-lora.ts index 2319d9a..d442dc5 100644 --- a/packages/core/src/finetune/profiles/sft-lora.ts +++ b/packages/core/src/finetune/profiles/sft-lora.ts @@ -199,7 +199,7 @@ export const sftLoraProfile: TrainingProfile = { const wan25 = isWan25(flags.model as string | undefined); const hp: Record = { ...VIDEO_HYPER_PARAMS_BASE, - batch_size: wan25 ? 2 : 4, + batch_size: 4, max_pixels: wan25 ? 36864 : 262144, }; // Optional overrides (no clamping — video batch_size is intentionally small). diff --git a/skills/bailian-cli/reference/dataset.md b/skills/bailian-cli/reference/dataset.md index 365d84d..523a421 100644 --- a/skills/bailian-cli/reference/dataset.md +++ b/skills/bailian-cli/reference/dataset.md @@ -107,38 +107,36 @@ bl dataset list --output json ### `bl dataset upload` -| Field | Value | -| --------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| **Name** | `dataset upload` | -| **Description** | Upload a dataset file (.jsonl or .zip) to Bailian | -| **Usage** | `bl dataset upload --file [--purpose ] [--schema ] [--no-validate] [--full-validate]` | +| Field | Value | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `dataset upload` | +| **Description** | Upload a dataset file (.jsonl or .zip) to Bailian | +| **Usage** | `bl dataset upload --file [--purpose ] [--schema ] [--no-validate] [--full-validate]` | #### Flags -| Flag | Type | Required | Description | -| ------------------ | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--file ` | string | yes | Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image/video) | -| `--purpose ` | string | no | Dataset purpose tag (default: "fine-tune"; e.g. "evaluation") | -| `--schema ` | string | no | Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). Default auto-detects per record. | -| `--no-validate` | switch | no | Skip the local JSONL pre-flight check (not recommended) | -| `--full-validate` | switch | no | JSON.parse every line instead of sampling (slower) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--file ` | string | yes | Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image) | +| `--purpose ` | string | no | Dataset purpose tag (default: "fine-tune"; e.g. "evaluation") | +| `--schema ` | string | no | Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record. | +| `--no-validate` | switch | no | Skip the local JSONL pre-flight check (not recommended) | +| `--full-validate` | switch | no | JSON.parse every line instead of sampling (slower) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Notes -- Supports .jsonl (text) and .zip (audio/image/video archives with a -- data.jsonl manifest). Six 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); video = {first_frame_path:"...", -- video_path:"..."} (video generation). With no --schema, a record -- carrying wav_fn is validated as TTS, img_path as image, video_path / -- first_frame_path as video, chosen/rejected as DPO, text (no messages) -- as CPT, otherwise ChatML. Upload cap: 300MB text, 1GB image/video. -- 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). #### Examples @@ -172,19 +170,19 @@ bl dataset upload --file train.jsonl --no-validate ### `bl dataset validate` -| Field | Value | -| --------------- | ------------------------------------------------------------------------------------------------------ | -| **Name** | `dataset validate` | -| **Description** | Locally validate a dataset file (.jsonl or .zip) without uploading | -| **Usage** | `bl dataset validate --file [--full-validate] [--schema ]` | +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------------------- | +| **Name** | `dataset validate` | +| **Description** | Locally validate a dataset file (.jsonl or .zip) without uploading | +| **Usage** | `bl dataset validate --file [--full-validate] [--schema ]` | #### Flags -| Flag | Type | Required | Description | -| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--file ` | string | yes | Local dataset file (.jsonl or .zip) | -| `--full-validate` | switch | no | JSON.parse every line instead of sampling (slower) | -| `--schema ` | string | no | Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). Default auto-detects per record. | +| Flag | Type | Required | Description | +| ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--file ` | string | yes | Local dataset file (.jsonl or .zip) | +| `--full-validate` | switch | no | JSON.parse every line instead of sampling (slower) | +| `--schema ` | string | no | Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record. | #### Notes @@ -193,14 +191,13 @@ bl dataset upload --file train.jsonl --no-validate - Schemas: 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); video = -- {first_frame_path:"...", video_path:"..."} (video generation). With no -- --schema, a record carrying wav_fn is validated as TTS, img_path as -- image, video_path / first_frame_path as video, 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. +- 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. #### Examples diff --git a/skills/bailian-cli/reference/deploy.md b/skills/bailian-cli/reference/deploy.md index 57f4190..184e31f 100644 --- a/skills/bailian-cli/reference/deploy.md +++ b/skills/bailian-cli/reference/deploy.md @@ -29,22 +29,19 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| ----------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------ | -| `--model ` | string | yes | Model name (catalog model or fine-tuned output) (required) | -| `--name ` | string | yes | Console display name for the deployment (required) | -| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu | -| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | -| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | -| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | -| `--input-tpm ` | number | no | PTU max input tokens/min (required for plan=ptu) | -| `--output-tpm ` | number | no | PTU max output tokens/min (required for plan=ptu) | -| `--thinking-output-tpm ` | number | no | PTU max thinking-output tokens/min (optional, some models) | -| `--aigc-use-input-prompt ` | boolean | no | Video LoRA (aigc_config): honor the caller's prompt at inference (default false = use preset template) | -| `--aigc-prompt ` | string | no | Video LoRA (aigc_config): preset prompt template used when use-input-prompt is false | -| `--aigc-lora-prompt-default ` | string | no | Video LoRA (aigc_config): default trigger-word phrase for the LoRA | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------------------- | ------ | -------- | ------------------------------------------------------------------------------- | +| `--model ` | string | yes | Model name (catalog model or fine-tuned output) (required) | +| `--name ` | string | yes | Console display name for the deployment (required) | +| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu | +| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | +| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | +| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | +| `--input-tpm ` | number | no | PTU max input tokens/min (required for plan=ptu) | +| `--output-tpm ` | number | no | PTU max output tokens/min (required for plan=ptu) | +| `--thinking-output-tpm ` | number | no | PTU max thinking-output tokens/min (optional, some models) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Notes @@ -58,9 +55,6 @@ Index: [index.md](index.md) - 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 -- For fine-tuned Wan video (i2v/kf2v) LoRA models, use --plan lora and pass -- --aigc-prompt / --aigc-lora-prompt-default (and optionally -- --aigc-use-input-prompt) to set the deployment's aigc_config. - 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` @@ -87,10 +81,6 @@ bl deploy create --model qwen3-8b --name my-qwen3-mu --plan mu bl deploy create --model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2 ``` -```bash -bl deploy create --model wan2.5-i2v-preview-ft-xxx --name my-video-lora --plan lora --aigc-prompt "..." --aigc-lora-prompt-default "..." -``` - ### `bl deploy delete` | Field | Value | From f66889c9393d003696106a4ea9b152e00f518b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Thu, 9 Jul 2026 20:19:27 +0800 Subject: [PATCH 03/13] feat: update model deploy --- packages/cli/src/commands.ts | 16 +- packages/cli/tests/e2e/deploy.e2e.test.ts | 26 +- packages/cli/tests/e2e/finetune.e2e.test.ts | 65 +- .../commands/src/commands/deploy/create.ts | 234 +++--- .../commands/src/commands/finetune/create.ts | 680 ++++++++++-------- .../commands/src/commands/finetune/watch.ts | 84 ++- packages/commands/src/index.ts | 12 +- .../commands => core/src}/deploy/constants.ts | 0 packages/core/src/deploy/index.ts | 2 + .../src/commands => core/src}/deploy/plans.ts | 20 +- packages/core/src/types/index.ts | 1 + skills/bailian-cli/reference/deploy.md | 162 ++++- skills/bailian-cli/reference/finetune.md | 299 +++++--- skills/bailian-cli/reference/index.md | 62 +- 14 files changed, 1076 insertions(+), 587 deletions(-) rename packages/{commands/src/commands => core/src}/deploy/constants.ts (100%) rename packages/{commands/src/commands => core/src}/deploy/plans.ts (90%) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 2d3ca54..6957d52 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -52,7 +52,9 @@ import { datasetGet, datasetDelete, datasetValidate, - finetuneCreate, + finetuneTextCreate, + finetuneAudioCreate, + finetuneImageCreate, finetuneList, finetuneGet, finetuneCancel, @@ -62,7 +64,9 @@ import { finetuneExport, finetuneWatch, finetuneCapability, - deployCreate, + deployTextCreate, + deployAudioCreate, + deployImageCreate, deployList, deployGet, deployModels, @@ -133,7 +137,9 @@ export const commands: Record = { "dataset get": datasetGet, "dataset delete": datasetDelete, "dataset validate": datasetValidate, - "finetune create": finetuneCreate, + "finetune text create": finetuneTextCreate, + "finetune audio create": finetuneAudioCreate, + "finetune image create": finetuneImageCreate, "finetune list": finetuneList, "finetune get": finetuneGet, "finetune cancel": finetuneCancel, @@ -143,7 +149,9 @@ export const commands: Record = { "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, diff --git a/packages/cli/tests/e2e/deploy.e2e.test.ts b/packages/cli/tests/e2e/deploy.e2e.test.ts index d40ac2c..0253eab 100644 --- a/packages/cli/tests/e2e/deploy.e2e.test.ts +++ b/packages/cli/tests/e2e/deploy.e2e.test.ts @@ -22,7 +22,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { }); test("deploy create --help 正常退出并展示必填项", async () => { - const { stderr, exitCode } = await runCli(["deploy", "create", "--help"]); + const { stderr, exitCode } = await runCli(["deploy", "text", "create", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--model|--name/i); }); @@ -30,6 +30,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { test("deploy create --dry-run 构造 lora 部署请求体", async () => { const { stdout, stderr, exitCode } = await runCli([ "deploy", + "text", "create", "--model", "qwen-plus-2025-12-01", @@ -59,6 +60,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { test("deploy create --plan mu --deploy-spec --dry-run 透传 deploy_spec", async () => { const { stdout, stderr, exitCode } = await runCli([ "deploy", + "text", "create", "--model", "qwen3-8b", @@ -85,6 +87,28 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { expect(data.body.capacity).toBe(2); }); + test("deploy audio create --dry-run 构造 lora 部署请求体", async () => { + // deploy create does not inspect modality; the split only changes the path. + // The audio subcommand shares the full flag surface and run logic. + const { stdout, stderr, exitCode } = await runCli([ + "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 } }>(stdout); + expect(data.action).toBe("deploy.create"); + expect(data.body.plan).toBe("lora"); + expect(data.body.name).toBe("my-tts"); + }); + test("deploy scale --dry-run 转发 capacity", async () => { const { stdout, stderr, exitCode } = await runCli([ "deploy", diff --git a/packages/cli/tests/e2e/finetune.e2e.test.ts b/packages/cli/tests/e2e/finetune.e2e.test.ts index faed0a8..8af40f5 100644 --- a/packages/cli/tests/e2e/finetune.e2e.test.ts +++ b/packages/cli/tests/e2e/finetune.e2e.test.ts @@ -23,7 +23,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --help 正常退出并展示必填项", async () => { - const { stderr, exitCode } = await runCli(["finetune", "create", "--help"]); + const { stderr, exitCode } = await runCli(["finetune", "text", "create", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--model|--datasets/i); }); @@ -31,6 +31,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { test("finetune create --dry-run 构造 SFT 默认请求体", async () => { const { stdout, stderr, exitCode } = await runCli([ "finetune", + "text", "create", "--model", "qwen3-8b", @@ -64,6 +65,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { test("finetune create --dry-run 转发训练类型与超参", async () => { const { stdout, stderr, exitCode } = await runCli([ "finetune", + "text", "create", "--model", "qwen3-8b", @@ -125,6 +127,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { async (cliType, serverType) => { const { stdout, stderr, exitCode } = await runCli([ "finetune", + "text", "create", "--model", "qwen3-8b", @@ -146,6 +149,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { test("finetune create --training-type 拒绝不支持的训练类型值", async () => { const { stdout, stderr, exitCode } = await runCli([ "finetune", + "text", "create", "--model", "qwen3-8b", @@ -164,6 +168,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl"); const { stdout, stderr, exitCode } = await runCli([ "finetune", + "text", "create", "--model", "qwen3-8b", @@ -194,6 +199,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { test("finetune create --datasets 为空时拒绝", async () => { const { stdout, stderr, exitCode } = await runCli([ "finetune", + "text", "create", "--model", "qwen3-8b", @@ -214,6 +220,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl"); const { stdout, stderr, exitCode } = await runCli([ "finetune", + "text", "create", "--model", "qwen3-8b", @@ -235,6 +242,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl"); const { stdout, stderr, exitCode } = await runCli([ "finetune", + "text", "create", "--model", "qwen3-8b", @@ -276,6 +284,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { test("finetune create --dry-run 解析多 datasets 中的空白", async () => { const { stdout, stderr, exitCode } = await runCli([ "finetune", + "text", "create", "--model", "qwen3-8b", @@ -291,6 +300,60 @@ 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 runCli([ + "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 }; + }>(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 runCli(["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 runCli([ + "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)", () => { diff --git a/packages/commands/src/commands/deploy/create.ts b/packages/commands/src/commands/deploy/create.ts index 6f4a2dd..b595573 100644 --- a/packages/commands/src/commands/deploy/create.ts +++ b/packages/commands/src/commands/deploy/create.ts @@ -2,12 +2,15 @@ import { defineCommand, detectOutputFormat, createDeployment, + pickPlanStrategy, + STRATEGIES, + DEFAULT_DEPLOY_PLAN, 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"; -import { DEFAULT_DEPLOY_PLAN } from "./constants.ts"; const CREATE_FLAGS = { model: { @@ -59,22 +62,111 @@ const CREATE_FLAGS = { }, } satisfies FlagsDef; +const CREATE_USAGE = + "--model --name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]"; + +const CREATE_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 `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 ", + "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.", +]; + /** - * `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 create` flag validation. Plan support is + * server-catalog-driven, not modality-scoped, so validation is identical for + * every modality: reject an unknown --plan, then defer to the plan strategy's + * required-flag check. */ -export default defineCommand({ - description: "Create a model deployment", +function validateCreate(flags: CreatePlanFlags): string | undefined { + const plan = flags.plan || DEFAULT_DEPLOY_PLAN; + const strategy = STRATEGIES[plan]; + if (!strategy) { + return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`; + } + return strategy.validateFlags(flags); +} + +/** + * Shared `deploy create` implementation. deploy create takes a model + * by name and a billing plan — it does NOT inspect data modality, so the run + * logic is identical across text / audio / image. The modality split only + * changes the command path, description and examples; the parameter surface and + * run logic are unchanged. + * + * 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(ctx: CommandContext): Promise { + const { identity, settings, flags } = ctx; + const model = flags.model as string; + const name = flags.name as string; + const plan = (flags.plan as string | undefined) || DEFAULT_DEPLOY_PLAN; + 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 = { + 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 ?? ""}`, + ); + } 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 --name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]", + usageArgs: CREATE_USAGE, flags: CREATE_FLAGS, exampleArgs: [ "--model my-qwen-sft --name my-sft-test", @@ -82,81 +174,39 @@ export default defineCommand({ "--model qwen3-8b --name my-qwen3-mu --plan mu", "--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 `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 ", - "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 || DEFAULT_DEPLOY_PLAN; - 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 || DEFAULT_DEPLOY_PLAN; - 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 = { - 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 ?? ""}`, - ); - } else { - emitResult(response, format); - } - }, + notes: CREATE_NOTES, + validate: (flags) => validateCreate(flags), + run: (ctx) => runCreate(ctx), +}); + +/** `bl deploy audio create` — deploy an audio (TTS) model. */ +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-mu --plan mu", + "--model my-cosyvoice-ft --name my-tts --dry-run", + ], + notes: CREATE_NOTES, + validate: (flags) => validateCreate(flags), + run: (ctx) => runCreate(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(flags), + run: (ctx) => runCreate(ctx), }); diff --git a/packages/commands/src/commands/finetune/create.ts b/packages/commands/src/commands/finetune/create.ts index 58907eb..c9fcf6a 100644 --- a/packages/commands/src/commands/finetune/create.ts +++ b/packages/commands/src/commands/finetune/create.ts @@ -17,6 +17,7 @@ import { ExitCode, type Client, type Settings, + type CommandContext, type CreateFineTuneRequest, type FineTuneHyperParameters, type DatasetFile, @@ -79,7 +80,7 @@ async function analyzeDatasetTokens( raw: string, label: string, profile: TrainingProfile, - _modality: DataModality, + modality: DataModality, model: string, /** Pre-detected modality for a known path (avoids re-opening the file). */ knownModality?: { path: string; modality: DataModality }, @@ -114,21 +115,17 @@ async function analyzeDatasetTokens( if (settings.dryRun) continue; - // Detect modality per-file so each dataset is validated under the correct - // schema (e.g. a text JSONL and an audio ZIP in the same --datasets list - // are each validated with their own record schema). Reuse the caller's - // pre-detected modality when available to avoid opening the same file - // twice (matters for large ZIPs). + // 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 - : await detectModality(token); + knownModality && knownModality.path === token ? knownModality.modality : modality; // Local path → validate through the profile. The profile internally routes - // to the correct validator based on modality (detected from file content). - // Upload is deferred to `uploadResolvedLocal` so the gate can run first. - // `model` is forwarded for schema-agnostic cross-checks (e.g. the video - // validator verifies a kf2v model is paired with kf2v data). + // 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 = [ @@ -210,25 +207,33 @@ async function uploadResolvedLocal( return uploaded; } -const CREATE_FLAGS = { +/** The modality a `finetune create` subcommand is bound to. */ +type CommandModality = "text" | "audio" | "image"; + +/** + * Flags shared by every `finetune 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: "", - description: "Base model to fine-tune (e.g. qwen3-8b, qwen3-14b)", + description: "Base model to fine-tune", required: true, }, datasets: { type: "string", valueHint: "", 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: "", 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", @@ -240,6 +245,16 @@ const CREATE_FLAGS = { valueHint: "", 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: "", @@ -268,12 +283,330 @@ 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 knob exposed. + */ +const IMAGE_FLAGS = { + ...COMMON_FLAGS, + learningRate: { + type: "string", + valueHint: "", + description: 'Learning rate as a string to preserve precision (e.g. "3e-5")', + }, +} satisfies FlagsDef; + +const TEXT_USAGE = + "--model --datasets [--validations ] [--model-name ] [--suffix ] [--n-epochs ] [--batch-size ] [--learning-rate ] [--max-length ] [--training-type ]"; + +const AUDIO_USAGE = + "--model --datasets [--validations ] [--model-name ] [--suffix ]"; + +const IMAGE_USAGE = + "--model --datasets [--validations ] [--model-name ] [--suffix ] [--learning-rate ]"; + +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 `` / `-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 auto-detected from the", + "data (records with input_img train I2I), which sets max_pixels accordingly.", +]; + +/** + * Shared `finetune 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( + commandModality: CommandModality, + ctx: CommandContext, +): Promise { + const { identity, settings } = ctx; + const flags = ctx.flags as Record; + const model = flags.model as string; + const datasetsRaw = flags.datasets as string; + + // 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 probes a + // local file to upgrade T2I → I2I; a bare file-id stays "image". + const firstLocalPath = datasetsRaw + .split(",") + .map((token) => token.trim()) + .find((token) => isLocalPath(token)); + let modality: DataModality = commandModality; + if (commandModality === "image" && 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, + ) 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> | 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 --datasets [--validations ] [--model-name ] [--suffix ] [--n-epochs ] [--batch-size ] [--learning-rate ] [--max-length ] [--training-type ]", - flags: CREATE_FLAGS, + usageArgs: TEXT_USAGE, + flags: TEXT_FLAGS, exampleArgs: [ "--model qwen3-8b --datasets file-xxx", "--model qwen3-8b --datasets ./train.jsonl", @@ -284,271 +617,40 @@ 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 `` / `-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}).`, - ); - } - - // Profile: single source of truth for how this training type behaves - // (validation rules, hyper-parameters, gates, capability check). - const profile = getProfile(trainingType); - - // Detect data modality from the first local file path in --datasets. This - // drives the profile's internal branching (text vs audio vs image/video - // hyper-parameters, gate skips, capability check bypass). Falls back to - // "text" when no local file is available (file-id only). Image generation - // has a subtype "image-i2i" (first record has input_img) picked here. - const firstLocalPath = datasetsRaw - .split(",") - .map((token) => token.trim()) - .find((token) => isLocalPath(token)); - const modality: DataModality = firstLocalPath ? await detectModality(firstLocalPath) : "text"; - // Video generation model fine-tuning is currently not exposed via the CLI. - // The core profile/schema implementation is retained, but the entry point - // is disabled here so video datasets are not accepted. - if (modality === "video" || modality === "video-kf2v") { - throw new BailianError( - `Video generation model fine-tuning is not supported.`, - ExitCode.USAGE, - `Detected video data in "${firstLocalPath}". Supported data: text, audio, image.`, - ); - } - - 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, - "validations", - profile, - modality, - model, - ) - : undefined; - const validationFileIds = validation?.fileIds; - - const modelName = flags.modelName; - const suffix = flags.suffix; - - // 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, - ) 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/video): 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 (video uses 2/4) and misleading. - if ( - flags.batchSize !== undefined && - hp.batch_size !== undefined && - !settings.quiet && - !profile.shouldSkipGate("batch_size", modality) - ) { - const requested = flags.batchSize; - 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 (video: fixed 2/4 by model - // family), warn the user that their explicit --batch-size was discarded. - if ( - flags.batchSize !== undefined && - !settings.quiet && - profile.shouldSkipGate("batch_size", modality) - ) { - const requested = flags.batchSize; - 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/video - // 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. - // - // 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 && - !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> | 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, - // 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); - } - }, + 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 ./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), }); diff --git a/packages/commands/src/commands/finetune/watch.ts b/packages/commands/src/commands/finetune/watch.ts index 9a496f3..1eb589a 100644 --- a/packages/commands/src/commands/finetune/watch.ts +++ b/packages/commands/src/commands/finetune/watch.ts @@ -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 { diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index d8467a7..c3d857a 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -55,7 +55,11 @@ export { default as datasetList } from "./commands/dataset/list.ts"; export { default as datasetGet } from "./commands/dataset/get.ts"; export { default as datasetDelete } from "./commands/dataset/delete.ts"; export { default as datasetValidate } from "./commands/dataset/validate.ts"; -export { default as finetuneCreate } from "./commands/finetune/create.ts"; +export { + finetuneTextCreate, + finetuneAudioCreate, + finetuneImageCreate, +} from "./commands/finetune/create.ts"; export { default as finetuneList } from "./commands/finetune/list.ts"; export { default as finetuneGet } from "./commands/finetune/get.ts"; export { default as finetuneCancel } from "./commands/finetune/cancel.ts"; @@ -65,7 +69,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"; diff --git a/packages/commands/src/commands/deploy/constants.ts b/packages/core/src/deploy/constants.ts similarity index 100% rename from packages/commands/src/commands/deploy/constants.ts rename to packages/core/src/deploy/constants.ts diff --git a/packages/core/src/deploy/index.ts b/packages/core/src/deploy/index.ts index 9811231..e0515c1 100644 --- a/packages/core/src/deploy/index.ts +++ b/packages/core/src/deploy/index.ts @@ -1,2 +1,4 @@ export * from "./api.ts"; export * from "./types.ts"; +export * from "./constants.ts"; +export * from "./plans.ts"; diff --git a/packages/commands/src/commands/deploy/plans.ts b/packages/core/src/deploy/plans.ts similarity index 90% rename from packages/commands/src/commands/deploy/plans.ts rename to packages/core/src/deploy/plans.ts index bde40a4..8961308 100644 --- a/packages/commands/src/commands/deploy/plans.ts +++ b/packages/core/src/deploy/plans.ts @@ -1,5 +1,5 @@ /** - * Per-plan strategy table for `bl deploy create`. + * Per-plan strategy table for `deploy create`. * * Each PlanStrategy owns one slice of plan-specific behaviour: * - required-flag checks (returned as validate-style error strings) @@ -7,13 +7,17 @@ * 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 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). */ @@ -176,7 +180,7 @@ 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 = { diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index f580c76..11202f2 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,6 +1,7 @@ export type { Command, AnyCommand, + CommandContext, FlagDef, FlagsDef, ParsedFlags, diff --git a/skills/bailian-cli/reference/deploy.md b/skills/bailian-cli/reference/deploy.md index 184e31f..1ad7bcd 100644 --- a/skills/bailian-cli/reference/deploy.md +++ b/skills/bailian-cli/reference/deploy.md @@ -7,25 +7,27 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| ------------------ | --------------------------------------------------------- | -| `bl deploy create` | Create a model deployment | -| `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) | -| `bl deploy get` | Get details of a single model deployment | -| `bl deploy list` | List model deployments | -| `bl deploy models` | List models available for deployment | -| `bl deploy scale` | Scale a deployment's capacity | -| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) | +| Command | Description | +| ------------------------ | --------------------------------------------------------- | +| `bl deploy audio create` | Create an audio (TTS) model deployment | +| `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) | +| `bl deploy get` | Get details of a single model deployment | +| `bl deploy image create` | Create an image generation model deployment | +| `bl deploy list` | List model deployments | +| `bl deploy models` | List models available for deployment | +| `bl deploy scale` | Scale a deployment's capacity | +| `bl deploy text create` | Create a text model deployment | +| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) | ## Command details -### `bl deploy create` +### `bl deploy audio create` -| Field | Value | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Name** | `deploy create` | -| **Description** | Create a model deployment | -| **Usage** | `bl deploy create --model --name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]` | +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy audio create` | +| **Description** | Create an audio (TTS) model deployment | +| **Usage** | `bl deploy audio create --model --name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]` | #### Flags @@ -66,19 +68,15 @@ Index: [index.md](index.md) #### Examples ```bash -bl deploy create --model my-qwen-sft --name my-sft-test +bl deploy audio create --model my-cosyvoice-ft --name my-tts ``` ```bash -bl deploy create --model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000 +bl deploy audio create --model my-cosyvoice-ft --name my-tts-mu --plan mu ``` ```bash -bl deploy create --model qwen3-8b --name my-qwen3-mu --plan mu -``` - -```bash -bl deploy create --model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2 +bl deploy audio create --model my-cosyvoice-ft --name my-tts --dry-run ``` ### `bl deploy delete` @@ -134,6 +132,64 @@ bl deploy get --deployed-model qwen-plus-2025-12-01-b6d61c71 bl deploy get --deployed-model qwen-plus-2025-12-01-b6d61c71 --output json ``` +### `bl deploy image create` + +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy image create` | +| **Description** | Create an image generation model deployment | +| **Usage** | `bl deploy image create --model --name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------------- | ------ | -------- | ------------------------------------------------------------------------------- | +| `--model ` | string | yes | Model name (catalog model or fine-tuned output) (required) | +| `--name ` | string | yes | Console display name for the deployment (required) | +| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu | +| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | +| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | +| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | +| `--input-tpm ` | number | no | PTU max input tokens/min (required for plan=ptu) | +| `--output-tpm ` | number | no | PTU max output tokens/min (required for plan=ptu) | +| `--thinking-output-tpm ` | number | no | PTU max thinking-output tokens/min (optional, some models) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### 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 `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 +- 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. + +#### Examples + +```bash +bl deploy image create --model my-wan-ft --name my-wan +``` + +```bash +bl deploy image create --model my-wan-ft --name my-wan-mu --plan mu +``` + +```bash +bl deploy image create --model my-wan-ft --name my-wan --dry-run +``` + ### `bl deploy list` | Field | Value | @@ -232,6 +288,68 @@ bl deploy scale --deployed-model qwen-plus-...-b6d61c71 --capacity 8 bl deploy scale --deployed-model dep-... --capacity 2 ``` +### `bl deploy text create` + +| Field | Value | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy text create` | +| **Description** | Create a text model deployment | +| **Usage** | `bl deploy text create --model --name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------------- | ------ | -------- | ------------------------------------------------------------------------------- | +| `--model ` | string | yes | Model name (catalog model or fine-tuned output) (required) | +| `--name ` | string | yes | Console display name for the deployment (required) | +| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu | +| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | +| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | +| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | +| `--input-tpm ` | number | no | PTU max input tokens/min (required for plan=ptu) | +| `--output-tpm ` | number | no | PTU max output tokens/min (required for plan=ptu) | +| `--thinking-output-tpm ` | number | no | PTU max thinking-output tokens/min (optional, some models) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### 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 `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 +- 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. + +#### Examples + +```bash +bl deploy text create --model my-qwen-sft --name my-sft-test +``` + +```bash +bl deploy text create --model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000 +``` + +```bash +bl deploy text create --model qwen3-8b --name my-qwen3-mu --plan mu +``` + +```bash +bl deploy text create --model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2 +``` + ### `bl deploy update` | Field | Value | diff --git a/skills/bailian-cli/reference/finetune.md b/skills/bailian-cli/reference/finetune.md index 21511fc..60e3c2d 100644 --- a/skills/bailian-cli/reference/finetune.md +++ b/skills/bailian-cli/reference/finetune.md @@ -7,21 +7,76 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `bl finetune cancel` | Cancel a running fine-tune job | -| `bl finetune capability` | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | -| `bl finetune checkpoints` | List checkpoints produced by a fine-tune job | -| `bl finetune create` | Create a fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | -| `bl finetune delete` | Delete a fine-tune job record | -| `bl finetune export` | Publish a checkpoint as a deployable model | -| `bl finetune get` | Get details of a single fine-tune job | -| `bl finetune list` | List fine-tune jobs | -| `bl finetune logs` | Fetch training logs for a fine-tune job | -| `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | +| Command | Description | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `bl finetune audio create` | Create an audio TTS model fine-tune job (sft-lora) | +| `bl finetune cancel` | Cancel a running fine-tune job | +| `bl finetune capability` | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | +| `bl finetune checkpoints` | List checkpoints produced by a fine-tune job | +| `bl finetune delete` | Delete a fine-tune job record | +| `bl finetune export` | Publish a checkpoint as a deployable model | +| `bl finetune get` | Get details of a single fine-tune job | +| `bl finetune image create` | Create an image generation model fine-tune job (sft-lora) | +| `bl finetune list` | List fine-tune jobs | +| `bl finetune logs` | Fetch training logs for a fine-tune job | +| `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | +| `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | ## Command details +### `bl finetune audio create` + +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `finetune audio create` | +| **Description** | Create an audio TTS model fine-tune job (sft-lora) | +| **Usage** | `bl finetune audio create --model --datasets [--validations ] [--model-name ] [--suffix ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ---------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--model ` | string | yes | Base model to fine-tune | +| `--datasets ` | string | yes | 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. | +| `--validations ` | string | no | Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets). | +| `--model-name ` | string | no | Output model name (after training) | +| `--suffix ` | string | no | Output suffix appended by the platform (finetuned_output_suffix) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### 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. +- 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. + +#### Examples + +```bash +bl finetune audio create --model cosyvoice-v3-flash --datasets ./audio.zip +``` + +```bash +bl finetune audio create --model cosyvoice-v3-flash --datasets file-xxx +``` + +```bash +bl finetune audio create --model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts +``` + +```bash +bl finetune audio create --model cosyvoice-v3-flash --datasets file-xxx --output json +``` + +```bash +bl finetune audio create --model cosyvoice-v3-flash --datasets ./audio.zip --dry-run +``` + ### `bl finetune cancel` | Field | Value | @@ -124,87 +179,6 @@ bl finetune checkpoints --job-id ft-xxx bl finetune checkpoints --job-id ft-xxx --output json ``` -### `bl finetune create` - -| Field | Value | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Name** | `finetune create` | -| **Description** | Create a fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | -| **Usage** | `bl finetune create --model --datasets [--validations ] [--model-name ] [--suffix ] [--n-epochs ] [--batch-size ] [--learning-rate ] [--max-length ] [--training-type ]` | - -#### Flags - -| Flag | Type | Required | Description | -| ---------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--model ` | string | yes | Base model to fine-tune (e.g. qwen3-8b, qwen3-14b) | -| `--datasets ` | string | yes | Comma-separated dataset file IDs or local .jsonl paths. Local paths are uploaded (validated) first, then their file-ids are used. | -| `--validations ` | string | no | Comma-separated validation dataset file IDs or local .jsonl paths (auto-uploaded like --datasets). | -| `--model-name ` | string | no | Output model name (after training) | -| `--suffix ` | string | no | Output suffix appended by the platform (finetuned_output_suffix) | -| `--training-type ` | string | no | Training type: sft \| sft-lora \| dpo \| dpo-lora \| cpt (default: sft-lora). Mapping to the server happens at the interface boundary (e.g. sft-lora -> efficient_sft, dpo -> dpo_full). | -| `--n-epochs ` | number | no | Number of epochs (default: 3) | -| `--batch-size ` | number | no | Per-device batch size (clamped to [8, 1024]). Auto-set to 8 for small datasets (<100KB) | -| `--learning-rate ` | string | no | Learning rate as a string to preserve precision (e.g. "1.6e-5") | -| `--max-length ` | number | no | Max sequence length | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | - -#### 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 `` / `-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). - -#### Examples - -```bash -bl finetune create --model qwen3-8b --datasets file-xxx -``` - -```bash -bl finetune create --model qwen3-8b --datasets ./train.jsonl -``` - -```bash -bl finetune create --model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl -``` - -```bash -bl finetune create --model qwen3-8b --datasets file-aaa,./extra.jsonl -``` - -```bash -bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft -``` - -```bash -bl finetune create --model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4 -``` - -```bash -bl finetune create --model qwen3-8b --datasets file-xxx --output json -``` - -```bash -bl finetune create --model qwen3-8b --datasets file-xxx --dry-run -``` - ### `bl finetune delete` | Field | Value | @@ -292,6 +266,60 @@ bl finetune get --job-id ft-xxx bl finetune get --job-id ft-xxx --output json ``` +### `bl finetune image create` + +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `finetune image create` | +| **Description** | Create an image generation model fine-tune job (sft-lora) | +| **Usage** | `bl finetune image create --model --datasets [--validations ] [--model-name ] [--suffix ] [--learning-rate ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ---------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--model ` | string | yes | Base model to fine-tune | +| `--datasets ` | string | yes | 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. | +| `--validations ` | string | no | Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets). | +| `--model-name ` | string | no | Output model name (after training) | +| `--suffix ` | string | no | Output suffix appended by the platform (finetuned_output_suffix) | +| `--learning-rate ` | string | no | Learning rate as a string to preserve precision (e.g. "3e-5") | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### 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. +- Image generation training runs sft-lora (efficient_sft) with fixed defaults; +- only --learning-rate is overridable. T2I vs I2I is auto-detected from the +- data (records with input_img train I2I), which sets max_pixels accordingly. + +#### Examples + +```bash +bl finetune image create --model wan2.7-image-pro --datasets ./images.zip +``` + +```bash +bl finetune image create --model wan2.7-image-pro --datasets file-xxx +``` + +```bash +bl finetune image create --model wan2.7-image-pro --datasets ./images.zip --model-name my-wan +``` + +```bash +bl finetune image create --model wan2.7-image-pro --datasets file-xxx --output json +``` + +```bash +bl finetune image create --model wan2.7-image-pro --datasets ./images.zip --dry-run +``` + ### `bl finetune list` | Field | Value | @@ -370,6 +398,85 @@ bl finetune logs --job-id ft-xxx --tail 20 bl finetune logs --job-id ft-xxx --search checkpoint --tail 5 ``` +### `bl finetune text create` + +| Field | Value | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `finetune text create` | +| **Description** | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | +| **Usage** | `bl finetune text create --model --datasets [--validations ] [--model-name ] [--suffix ] [--n-epochs ] [--batch-size ] [--learning-rate ] [--max-length ] [--training-type ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ---------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--model ` | string | yes | Base model to fine-tune | +| `--datasets ` | string | yes | 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. | +| `--validations ` | string | no | Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets). | +| `--model-name ` | string | no | Output model name (after training) | +| `--suffix ` | string | no | Output suffix appended by the platform (finetuned_output_suffix) | +| `--training-type ` | string | no | Training type: sft \| sft-lora \| dpo \| dpo-lora \| cpt (default: sft-lora). Mapping to the server happens at the interface boundary (e.g. sft-lora -> efficient_sft, dpo -> dpo_full). | +| `--n-epochs ` | number | no | Number of epochs (default: 3) | +| `--batch-size ` | number | no | Per-device batch size (clamped to [8, 1024]). Auto-set to 8 for small datasets (<100KB) | +| `--learning-rate ` | string | no | Learning rate as a string to preserve precision (e.g. "1.6e-5") | +| `--max-length ` | number | no | Max sequence length | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### 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. +- Training-type values use the `` / `-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). + +#### Examples + +```bash +bl finetune text create --model qwen3-8b --datasets file-xxx +``` + +```bash +bl finetune text create --model qwen3-8b --datasets ./train.jsonl +``` + +```bash +bl finetune text create --model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl +``` + +```bash +bl finetune text create --model qwen3-8b --datasets file-aaa,./extra.jsonl +``` + +```bash +bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft +``` + +```bash +bl finetune text create --model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4 +``` + +```bash +bl finetune text create --model qwen3-8b --datasets file-xxx --output json +``` + +```bash +bl finetune text create --model qwen3-8b --datasets file-xxx --dry-run +``` + ### `bl finetune watch` | Field | Value | @@ -394,9 +501,9 @@ bl finetune logs --job-id ft-xxx --search checkpoint --tail 5 - 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`. diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 088b583..003a9f8 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -24,23 +24,27 @@ Use this index for the full quick index and global flags. | `bl dataset list` | List uploaded dataset files | [dataset.md](dataset.md) | | `bl dataset upload` | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) | | `bl dataset validate` | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) | -| `bl deploy create` | Create a model deployment | [deploy.md](deploy.md) | +| `bl deploy audio create` | Create an audio (TTS) model deployment | [deploy.md](deploy.md) | | `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) | [deploy.md](deploy.md) | | `bl deploy get` | Get details of a single model deployment | [deploy.md](deploy.md) | +| `bl deploy image create` | Create an image generation model deployment | [deploy.md](deploy.md) | | `bl deploy list` | List model deployments | [deploy.md](deploy.md) | | `bl deploy models` | List models available for deployment | [deploy.md](deploy.md) | | `bl deploy scale` | Scale a deployment's capacity | [deploy.md](deploy.md) | +| `bl deploy text create` | Create a text model deployment | [deploy.md](deploy.md) | | `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) | [deploy.md](deploy.md) | | `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) | +| `bl finetune audio create` | Create an audio TTS model fine-tune job (sft-lora) | [finetune.md](finetune.md) | | `bl finetune cancel` | Cancel a running fine-tune job | [finetune.md](finetune.md) | | `bl finetune capability` | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | [finetune.md](finetune.md) | | `bl finetune checkpoints` | List checkpoints produced by a fine-tune job | [finetune.md](finetune.md) | -| `bl finetune create` | Create a fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) | | `bl finetune delete` | Delete a fine-tune job record | [finetune.md](finetune.md) | | `bl finetune export` | Publish a checkpoint as a deployable model | [finetune.md](finetune.md) | | `bl finetune get` | Get details of a single fine-tune job | [finetune.md](finetune.md) | +| `bl finetune image create` | Create an image generation model fine-tune job (sft-lora) | [finetune.md](finetune.md) | | `bl finetune list` | List fine-tune jobs | [finetune.md](finetune.md) | | `bl finetune logs` | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) | +| `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) | | `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [finetune.md](finetune.md) | | `bl image edit` | Edit an existing image with text instructions (Qwen-Image) | [image.md](image.md) | | `bl image generate` | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | @@ -86,33 +90,33 @@ Use this index for the full quick index and global flags. ## By group -| Group | Commands | Reference | -| ------------ | --------------------------------------------------------------------------------------------------- | ------------------------------ | -| `advisor` | `recommend` | [advisor.md](advisor.md) | -| `app` | `call`, `list` | [app.md](app.md) | -| `auth` | `login`, `logout`, `status` | [auth.md](auth.md) | -| `config` | `set`, `show` | [config.md](config.md) | -| `console` | `call` | [console.md](console.md) | -| `dataset` | `delete`, `get`, `list`, `upload`, `validate` | [dataset.md](dataset.md) | -| `deploy` | `create`, `delete`, `get`, `list`, `models`, `scale`, `update` | [deploy.md](deploy.md) | -| `file` | `upload` | [file.md](file.md) | -| `finetune` | `cancel`, `capability`, `checkpoints`, `create`, `delete`, `export`, `get`, `list`, `logs`, `watch` | [finetune.md](finetune.md) | -| `image` | `edit`, `generate` | [image.md](image.md) | -| `knowledge` | `chat`, `retrieve`, `search` | [knowledge.md](knowledge.md) | -| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) | -| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) | -| `omni` | `(root)` | [omni.md](omni.md) | -| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) | -| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) | -| `search` | `web` | [search.md](search.md) | -| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) | -| `text` | `chat` | [text.md](text.md) | -| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) | -| `update` | `(root)` | [update.md](update.md) | -| `usage` | `free`, `freetier`, `stats` | [usage.md](usage.md) | -| `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) | -| `vision` | `describe` | [vision.md](vision.md) | -| `workspace` | `list` | [workspace.md](workspace.md) | +| Group | Commands | Reference | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| `advisor` | `recommend` | [advisor.md](advisor.md) | +| `app` | `call`, `list` | [app.md](app.md) | +| `auth` | `login`, `logout`, `status` | [auth.md](auth.md) | +| `config` | `set`, `show` | [config.md](config.md) | +| `console` | `call` | [console.md](console.md) | +| `dataset` | `delete`, `get`, `list`, `upload`, `validate` | [dataset.md](dataset.md) | +| `deploy` | `audio create`, `delete`, `get`, `image create`, `list`, `models`, `scale`, `text create`, `update` | [deploy.md](deploy.md) | +| `file` | `upload` | [file.md](file.md) | +| `finetune` | `audio create`, `cancel`, `capability`, `checkpoints`, `delete`, `export`, `get`, `image create`, `list`, `logs`, `text create`, `watch` | [finetune.md](finetune.md) | +| `image` | `edit`, `generate` | [image.md](image.md) | +| `knowledge` | `chat`, `retrieve`, `search` | [knowledge.md](knowledge.md) | +| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) | +| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) | +| `omni` | `(root)` | [omni.md](omni.md) | +| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) | +| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) | +| `search` | `web` | [search.md](search.md) | +| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) | +| `text` | `chat` | [text.md](text.md) | +| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) | +| `update` | `(root)` | [update.md](update.md) | +| `usage` | `free`, `freetier`, `stats` | [usage.md](usage.md) | +| `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) | +| `vision` | `describe` | [vision.md](vision.md) | +| `workspace` | `list` | [workspace.md](workspace.md) | ## Global flags From 9602209113127a1a86469e08b017fdbe3dcb2e54 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 10 Jul 2026 11:46:05 +0800 Subject: [PATCH 04/13] feat(e2e): integrate e2e package and update test configurations - Added e2e package as a workspace dependency in pnpm-lock.yaml. - Updated globalSetup path in vite.config.ts to point to the new e2e package location. - Enhanced documentation for CLI E2E testing, including architecture layers and triggering conditions. - Refactored test paths and commands in the documentation to align with the new structure. - Removed outdated dataset and auth E2E tests to streamline the test suite. - Updated command references and validation checks in the documentation to reflect recent changes. --- docs/agents/cli-e2e-tests.md | 84 +++++-- docs/agents/command-add-remove.md | 12 +- packages/cli/package.json | 1 + packages/cli/tests/e2e/helpers.ts | 234 +++--------------- .../cli/tests/e2e/registry.smoke.e2e.test.ts | 56 +++++ packages/cli/vite.config.ts | 2 +- packages/commands/package.json | 1 + .../tests/e2e/advisor-recommend.e2e.test.ts | 20 +- .../tests/e2e/auth.e2e.test.ts | 52 ++-- .../tests/e2e/config.e2e.test.ts | 34 ++- .../e2e/console-flags-dry-run.e2e.test.ts} | 64 +---- .../tests/e2e/dataset.e2e.test.ts | 53 ++-- .../tests/e2e/deploy.e2e.test.ts | 18 +- .../tests/e2e/file-upload.e2e.test.ts | 26 +- .../tests/e2e/finetune.e2e.test.ts | 32 +-- .../e2e/fixtures}/.dataset-cpt-valid.jsonl | 0 .../e2e/fixtures}/.dataset-dpo-invalid.jsonl | 0 .../e2e/fixtures}/.dataset-dpo-valid.jsonl | 0 .../e2e/fixtures}/.dataset-invalid.jsonl | 0 .../tests/e2e/fixtures}/.dataset-valid.jsonl | 0 .../tests/e2e/fixtures}/.smoke-32.png | Bin .../harness/e2e-command-map.contract.test.ts | 71 ++++++ .../tests/e2e/harness/e2e-command-map.ts | 233 +++++++++++++++++ packages/commands/tests/e2e/harness/main.ts | 9 + packages/commands/tests/e2e/helpers.ts | 51 ++++ .../tests/e2e/image-edit.e2e.test.ts | 24 +- .../tests/e2e/image-generate.e2e.test.ts | 15 +- .../tests/e2e/knowledge-chat.e2e.test.ts | 153 +++++++++++- .../tests/e2e/knowledge-search.e2e.test.ts | 129 +++++++++- .../tests/e2e/knowledge.e2e.test.ts | 25 +- .../tests/e2e/mcp.e2e.test.ts | 36 +-- .../tests/e2e/memory.e2e.test.ts | 33 +-- .../tests/e2e/omni.e2e.test.ts | 16 +- .../tests/e2e/pipeline.e2e.test.ts | 26 +- .../tests/e2e/quota.e2e.test.ts | 81 ++++-- .../tests/e2e/search-web.e2e.test.ts | 14 +- .../tests/e2e/speech-list-voices.e2e.test.ts | 12 +- .../tests/e2e/speech-recognize.e2e.test.ts | 12 +- .../tests/e2e/speech-synthesize.e2e.test.ts | 12 +- .../tests/e2e/text-chat.e2e.test.ts | 12 +- .../tests/e2e/usage-free.e2e.test.ts | 98 ++++++-- .../tests/e2e/usage-stats.e2e.test.ts | 60 +++-- .../tests/e2e/video-download.e2e.test.ts | 16 +- .../tests/e2e/video-edit.e2e.test.ts | 14 +- .../tests/e2e/video-generate-i2v.e2e.test.ts | 14 +- .../tests/e2e/video-generate-t2v.e2e.test.ts | 12 +- .../tests/e2e/video-ref-r2v.e2e.test.ts | 16 +- .../tests/e2e/video-task-get.e2e.test.ts | 23 +- packages/commands/vite.config.ts | 5 + packages/e2e/package.json | 25 ++ packages/e2e/src/gating.ts | 56 +++++ .../tests/e2e => e2e/src}/global-setup.ts | 33 +-- packages/e2e/src/monorepo-root.ts | 15 ++ packages/e2e/src/output.ts | 77 ++++++ packages/e2e/src/run-subprocess.ts | 43 ++++ packages/e2e/vite.config.ts | 11 + packages/kscli/package.json | 1 + packages/kscli/tests/e2e/chat.e2e.test.ts | 131 ---------- packages/kscli/tests/e2e/global-setup.ts | 9 - packages/kscli/tests/e2e/helpers.ts | 143 +++-------- .../tests/e2e/registry.smoke.e2e.test.ts | 45 ++++ packages/kscli/tests/e2e/search.e2e.test.ts | 122 --------- packages/kscli/vite.config.ts | 2 +- packages/runtime/package.json | 2 + .../runtime/tests/console-flags.e2e.test.ts | 34 +++ .../e2e => runtime/tests}/proxy.e2e.test.ts | 28 +-- packages/runtime/vite.config.ts | 5 + pnpm-lock.yaml | 31 +++ tools/compare-e2e-command-map.ts | 35 +++ vite.config.ts | 2 +- 70 files changed, 1739 insertions(+), 1022 deletions(-) create mode 100644 packages/cli/tests/e2e/registry.smoke.e2e.test.ts rename packages/{cli => commands}/tests/e2e/advisor-recommend.e2e.test.ts (89%) rename packages/{cli => commands}/tests/e2e/auth.e2e.test.ts (73%) rename packages/{cli => commands}/tests/e2e/config.e2e.test.ts (75%) rename packages/{cli/tests/e2e/console-flags.e2e.test.ts => commands/tests/e2e/console-flags-dry-run.e2e.test.ts} (58%) rename packages/{cli => commands}/tests/e2e/dataset.e2e.test.ts (79%) rename packages/{cli => commands}/tests/e2e/deploy.e2e.test.ts (88%) rename packages/{cli => commands}/tests/e2e/file-upload.e2e.test.ts (66%) rename packages/{cli => commands}/tests/e2e/finetune.e2e.test.ts (89%) rename packages/{cli/tests/e2e => commands/tests/e2e/fixtures}/.dataset-cpt-valid.jsonl (100%) rename packages/{cli/tests/e2e => commands/tests/e2e/fixtures}/.dataset-dpo-invalid.jsonl (100%) rename packages/{cli/tests/e2e => commands/tests/e2e/fixtures}/.dataset-dpo-valid.jsonl (100%) rename packages/{cli/tests/e2e => commands/tests/e2e/fixtures}/.dataset-invalid.jsonl (100%) rename packages/{cli/tests/e2e => commands/tests/e2e/fixtures}/.dataset-valid.jsonl (100%) rename packages/{cli/tests/e2e => commands/tests/e2e/fixtures}/.smoke-32.png (100%) create mode 100644 packages/commands/tests/e2e/harness/e2e-command-map.contract.test.ts create mode 100644 packages/commands/tests/e2e/harness/e2e-command-map.ts create mode 100644 packages/commands/tests/e2e/harness/main.ts create mode 100644 packages/commands/tests/e2e/helpers.ts rename packages/{cli => commands}/tests/e2e/image-edit.e2e.test.ts (81%) rename packages/{cli => commands}/tests/e2e/image-generate.e2e.test.ts (82%) rename packages/{cli => commands}/tests/e2e/knowledge-chat.e2e.test.ts (54%) rename packages/{cli => commands}/tests/e2e/knowledge-search.e2e.test.ts (52%) rename packages/{cli => commands}/tests/e2e/knowledge.e2e.test.ts (85%) rename packages/{cli => commands}/tests/e2e/mcp.e2e.test.ts (86%) rename packages/{cli => commands}/tests/e2e/memory.e2e.test.ts (86%) rename packages/{cli => commands}/tests/e2e/omni.e2e.test.ts (88%) rename packages/{cli => commands}/tests/e2e/pipeline.e2e.test.ts (87%) rename packages/{cli => commands}/tests/e2e/quota.e2e.test.ts (77%) rename packages/{cli => commands}/tests/e2e/search-web.e2e.test.ts (83%) rename packages/{cli => commands}/tests/e2e/speech-list-voices.e2e.test.ts (72%) rename packages/{cli => commands}/tests/e2e/speech-recognize.e2e.test.ts (86%) rename packages/{cli => commands}/tests/e2e/speech-synthesize.e2e.test.ts (87%) rename packages/{cli => commands}/tests/e2e/text-chat.e2e.test.ts (80%) rename packages/{cli => commands}/tests/e2e/usage-free.e2e.test.ts (73%) rename packages/{cli => commands}/tests/e2e/usage-stats.e2e.test.ts (83%) rename packages/{cli => commands}/tests/e2e/video-download.e2e.test.ts (89%) rename packages/{cli => commands}/tests/e2e/video-edit.e2e.test.ts (88%) rename packages/{cli => commands}/tests/e2e/video-generate-i2v.e2e.test.ts (88%) rename packages/{cli => commands}/tests/e2e/video-generate-t2v.e2e.test.ts (87%) rename packages/{cli => commands}/tests/e2e/video-ref-r2v.e2e.test.ts (88%) rename packages/{cli => commands}/tests/e2e/video-task-get.e2e.test.ts (77%) create mode 100644 packages/e2e/package.json create mode 100644 packages/e2e/src/gating.ts rename packages/{cli/tests/e2e => e2e/src}/global-setup.ts (65%) create mode 100644 packages/e2e/src/monorepo-root.ts create mode 100644 packages/e2e/src/output.ts create mode 100644 packages/e2e/src/run-subprocess.ts create mode 100644 packages/e2e/vite.config.ts delete mode 100644 packages/kscli/tests/e2e/chat.e2e.test.ts delete mode 100644 packages/kscli/tests/e2e/global-setup.ts create mode 100644 packages/kscli/tests/e2e/registry.smoke.e2e.test.ts delete mode 100644 packages/kscli/tests/e2e/search.e2e.test.ts create mode 100644 packages/runtime/tests/console-flags.e2e.test.ts rename packages/{cli/tests/e2e => runtime/tests}/proxy.e2e.test.ts (70%) create mode 100644 tools/compare-e2e-command-map.ts diff --git a/docs/agents/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md index 055dd69..3a07f20 100644 --- a/docs/agents/cli-e2e-tests.md +++ b/docs/agents/cli-e2e-tests.md @@ -1,18 +1,44 @@ # CLI E2E 测试规范 +## 架构分层 + +| 层级 | 路径 | 测什么 | +| --------------- | ----------------------------------------------------- | --------------------------------------------------------------- | +| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup(`private`,不发布) | +| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、live(gated);harness `binName: "bl"` | +| **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` | 6 条 flat path `--help`;`search --help` 与 harness 一致 | +| **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/.e2e.test.ts` +- 新增 bl 产品 path → 同步 `registry.smoke`(自动覆盖 leaf path)与 `harness/e2e-command-map.ts` -以上情况必须同步维护 `packages/cli/tests/e2e/.e2e.test.ts`。跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。 +跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。 ## 文件与工具 -- 路径:`packages/cli/tests/e2e/.e2e.test.ts` -- 框架:`vite-plus/test`;子进程跑 CLI:`runCli` from `./helpers.ts` +### commands E2E + +- 路径:`packages/commands/tests/e2e/.e2e.test.ts` +- 子进程:`runCommandE2e` from `./helpers.ts`(spawn `harness/main.ts`) +- fixtures:`packages/commands/tests/e2e/fixtures/` +- map:`harness/e2e-command-map.ts`(自维护,初始从 `cli/commands.ts` 复制) + +### 产品 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)` 等显式超时 @@ -33,24 +59,31 @@ describe.skipIf()("e2e: (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 +1. **分组 help**:`runCommandE2e(["image"])` → `exitCode === 0`,stdout+stderr 含子命令名 +2. **--help**:`runCommandE2e([..., "--help"])` → stderr 含主要 flags 3. **缺参**:带一个无害全局 flag(如 `--quiet`)且不传 required flag → `exitCode === 2`,stderr 匹配 `--flag|Missing required argument` 4. **--dry-run**:仅当实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本,不入网 5. **真实集成**:保留既有用例名称与断言;放在 skip 块**末尾** +## 契约与防漂移 + +- `harness/e2e-command-map.contract.test.ts`:path 唯一、合法 export、白名单、测试 argv 前缀在 map 中 +- Advisory(不阻塞 CI):`node tools/compare-e2e-command-map.ts` 对比 `cli/commands.ts` 与 harness map + +增删命令须同步三处:**实现 export** + **`cli/commands.ts`** + **`e2e-command-map.ts`**(见 [command-add-remove.md](command-add-remove.md))。 + ## 安全与例外 - **禁止真实破坏性操作**:`auth logout` 只用 `--dry-run`;`config set` 只用 `--dry-run` @@ -60,24 +93,35 @@ describe.skipIf()("e2e: (DashScope …)", () => { ## 新增 command 检查清单 -- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `tests/e2e/.e2e.test.ts`(新建或扩展) +- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `harness/e2e-command-map.ts` 登记 +- [ ] `packages/commands/tests/e2e/.e2e.test.ts`(新建或扩展) - [ ] 若改了 `usageArgs` / `flags` / `exampleArgs`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/` 并提交 - [ ] 顶层:分组 help + 子命令 `--help`(多子命令则各一条 help) - [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条 - [ ] 至少一条真实集成(或说明为何仅 smoke);不破坏已有集成用例顺序 -- [ ] `pnpm test packages/cli/tests/e2e/` 通过 +- [ ] `vp test packages/commands/tests/e2e/` 通过 + +## 调试命令 + +```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 +node tools/compare-e2e-command-map.ts +``` ## 示例片段 ```ts test("foo bar 缺少 --prompt 时退出为用法错误 (2)", async () => { - const { stderr, exitCode } = await runCli(["foo", "bar", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(["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", "bar", "--dry-run", @@ -97,4 +141,4 @@ test("foo bar --dry-run 仅输出计划", async () => { - **E2E**:单条/少量调用、断言固定、可进 `vp test`(见上文 skip 条件) - **批量压测**:`packages/cli/tests/stress/run.mjs` + `targets/*.mjs`,并发 + 报告,**仅手动** `pnpm run test:stress -- ` -勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md)。 +勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md). diff --git a/docs/agents/command-add-remove.md b/docs/agents/command-add-remove.md index 2ea3cce..145ccfb 100644 --- a/docs/agents/command-add-remove.md +++ b/docs/agents/command-add-remove.md @@ -92,9 +92,12 @@ packages/commands/src/index.ts ### D. 测试层 -- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/cli/tests/e2e/.e2e.test.ts` -- [ ] 删除命令时一并删对应 e2e / README 示例 / reference 生成结果 -- [ ] 如果 shared command 在不同入口路径下复用,至少确保 `bl` 入口 e2e 覆盖;`rag` 入口改动需补对应入口测试或手工 smoke +- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/commands/tests/e2e/.e2e.test.ts` +- [ ] 同步 `packages/commands/tests/e2e/harness/e2e-command-map.ts` 与 `E2E_COMMAND_EXPORTS` +- [ ] `packages/cli/src/commands.ts` 增删 path 后跑 `node tools/compare-e2e-command-map.ts`(advisory)确认与 harness 一致 +- [ ] bl 产品 path 变更由 `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` 覆盖;kscli 变更同步 `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` +- [ ] 删除命令时一并删对应 commands e2e / README 示例 / reference 生成结果 / harness map 条目 +- [ ] 如果 shared command 在不同入口路径下复用,至少确保 commands e2e 覆盖 `bl` path;`rag` / `kscli` 入口改动需补对应 smoke 或说明不测 flat path live ### E. 重命名特殊处理 @@ -111,7 +114,8 @@ packages/commands/src/index.ts pnpm run sync:skill-assets node packages/cli/src/main.ts --help node packages/cli/src/main.ts -vp test packages/cli/tests/e2e/.e2e.test.ts +vp test packages/commands/tests/e2e/.e2e.test.ts +node tools/compare-e2e-command-map.ts ``` 如改了 `rag` 入口: diff --git a/packages/cli/package.json b/packages/cli/package.json index 3f19aec..29f53e4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,6 +59,7 @@ "ajv": "catalog:", "boxen": "catalog:", "chalk": "catalog:", + "e2e": "workspace:*", "typescript": "^6.0.2", "undici": "catalog:", "vite-plus": "0.1.22", diff --git a/packages/cli/tests/e2e/helpers.ts b/packages/cli/tests/e2e/helpers.ts index f695ed5..8d42da7 100644 --- a/packages/cli/tests/e2e/helpers.ts +++ b/packages/cli/tests/e2e/helpers.ts @@ -1,206 +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, "..", ".."); -} - -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` 下 `node src/main.ts ...`)。 - * request_id 等诊断信息在 stderr;`--output json` 时 JSON 在 stdout。 - */ +/** 子进程执行 bl CLI */ export async function runCli( args: string[], envOverrides: NodeJS.ProcessEnv = {}, ): Promise { - try { - const { stdout, stderr } = await execFileAsync("node", [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(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 }); } diff --git a/packages/cli/tests/e2e/registry.smoke.e2e.test.ts b/packages/cli/tests/e2e/registry.smoke.e2e.test.ts new file mode 100644 index 0000000..8d48c26 --- /dev/null +++ b/packages/cli/tests/e2e/registry.smoke.e2e.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vite-plus/test"; +import { commands } from "../../src/commands.ts"; +import { runCli } from "./helpers.ts"; + +/** 从命令 map 推导有子命令的分组路径 */ +function deriveGroupPaths(commandPaths: string[]): string[] { + const groups = new Set(); + for (const path of commandPaths) { + const parts = path.split(" "); + for (let i = 1; i < parts.length; i++) { + const prefix = parts.slice(0, i).join(" "); + const hasChildren = commandPaths.some( + (candidate) => candidate.startsWith(`${prefix} `) && candidate !== prefix, + ); + if (hasChildren) groups.add(prefix); + } + } + return [...groups].sort(); +} + +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 /); + expect(stderr).toMatch(/--model /); + expect(stderr).toMatch(/--period /); + expect(stderr).toMatch(/--output /); + 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); + }); +}); diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 769e51e..dc315e3 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -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, }, diff --git a/packages/commands/package.json b/packages/commands/package.json index 6caffa2..a7d2761 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -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" }, diff --git a/packages/cli/tests/e2e/advisor-recommend.e2e.test.ts b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts similarity index 89% rename from packages/cli/tests/e2e/advisor-recommend.e2e.test.ts rename to packages/commands/tests/e2e/advisor-recommend.e2e.test.ts index 201e036..5f01005 100644 --- a/packages/cli/tests/e2e/advisor-recommend.e2e.test.ts +++ b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts @@ -1,15 +1,15 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; describe("e2e: advisor recommend", () => { test("advisor shows subcommand groups and exits successfully", async () => { - const { stdout, stderr, exitCode } = await runCli(["advisor"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "recommend", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/recommend|--message|dry-run/i); }); @@ -17,13 +17,13 @@ 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", "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", "recommend", "--dry-run", @@ -63,7 +63,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", "recommend", "--message", @@ -96,7 +96,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () // ---- Model preference: positive cases ---- test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "advisor", "recommend", "--dry-run", @@ -116,7 +116,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () }, 60_000); test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "advisor", "recommend", "--dry-run", @@ -136,7 +136,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () }, 60_000); test("excludes preference — intent detects modelPreference when excluding models", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "advisor", "recommend", "--dry-run", @@ -166,7 +166,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () // ---- Model preference: negative cases ---- test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "advisor", "recommend", "--dry-run", diff --git a/packages/cli/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts similarity index 73% rename from packages/cli/tests/e2e/auth.e2e.test.ts rename to packages/commands/tests/e2e/auth.e2e.test.ts index 89905cc..8cc340b 100644 --- a/packages/cli/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; /** * Auth 相关 E2E:只验证 CLI 进程能正常解析参数并退出。 @@ -7,39 +7,39 @@ import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; describe("e2e: auth", () => { test("auth 分组展示子命令帮助且退出码为 0", async () => { - const { stdout, stderr, exitCode } = await runCli(["auth"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "login", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/login|api-key/i); expect(stderr).toMatch(/--console-site/); }); test("auth logout --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["auth", "logout", "--help"]); + const { stderr, exitCode } = await runCommandE2e(["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"]); + const { stderr, exitCode } = await runCommandE2e(["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"]); + const { stderr, exitCode } = await runCommandE2e(["auth", "login", "--quiet"]); expect(exitCode, stderr).toBe(2); expect(stderr).toMatch(/--api-key|Usage:/i); }); test("auth login --dry-run --api-key 不发起校验与落盘", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "auth", "login", "--dry-run", @@ -51,7 +51,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", "login", "--dry-run", @@ -67,7 +67,7 @@ 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", "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); @@ -75,20 +75,25 @@ 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", "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", + "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", "logout", "--dry-run", @@ -101,7 +106,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", + "status", + "--output", + "text", + ]); expect(exitCode, stderr).toBe(0); expect(stdout).toMatch( /Authentication Status|API key:|Console token:|DashScope API:|Console gateway:/, @@ -109,7 +119,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", + "status", + "--output", + "json", + ]); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson<{ authenticated?: boolean; @@ -122,7 +137,7 @@ 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", "status", "--output", "json", "--quiet"], { DASHSCOPE_BASE_URL: "https://dashscope.aliyuncs.com" }, ); @@ -134,7 +149,12 @@ 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", + "status", + "--base-url", + "https://x.test", + ]); expect(exitCode).not.toBe(0); expect(stderr).toMatch(/Unknown flag.*--base-url/); }); diff --git a/packages/cli/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts similarity index 75% rename from packages/cli/tests/e2e/config.e2e.test.ts rename to packages/commands/tests/e2e/config.e2e.test.ts index 07700db..47f3cb6 100644 --- a/packages/cli/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCli } from "./helpers.ts"; +import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; /** * Config 相关 E2E @@ -7,26 +7,31 @@ import { parseStdoutJson, runCli } from "./helpers.ts"; describe("e2e: config", () => { test("config 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["config"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "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", "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", + "show", + "--output", + "json", + ]); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson<{ config_file?: string; @@ -39,19 +44,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", + "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", "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", "set", "--key", @@ -64,7 +74,7 @@ describe("e2e: config", () => { }); test("config set 非法 output", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e([ "config", "set", "--key", @@ -77,7 +87,7 @@ describe("e2e: config", () => { }); test("config set 非法 timeout", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e([ "config", "set", "--key", @@ -90,7 +100,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", "set", "--dry-run", @@ -107,7 +117,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 支持连字符别名 key", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "config", "set", "--dry-run", diff --git a/packages/cli/tests/e2e/console-flags.e2e.test.ts b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts similarity index 58% rename from packages/cli/tests/e2e/console-flags.e2e.test.ts rename to packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts index f5b8a6e..f0d1c8f 100644 --- a/packages/cli/tests/e2e/console-flags.e2e.test.ts +++ b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCli } from "./helpers.ts"; +import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; type ConsoleDryRunMeta = { consoleRegion?: string; @@ -7,55 +7,9 @@ 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 /); - expect(stderr).toMatch(/--model /); - expect(stderr).toMatch(/--period /); - expect(stderr).toMatch(/--output /); - 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", "call", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--api /); expect(stderr).toMatch(/--data /); @@ -65,7 +19,7 @@ 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(["auth", "login", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--api-key /); expect(stderr).toMatch(/--base-url /); @@ -75,7 +29,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", "call", "--api", @@ -93,7 +47,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", "call", "--api", @@ -118,7 +72,7 @@ describe("e2e: console global flags", () => { }); test("console call 拒绝未知全局 flag --region", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e([ "console", "call", "--api", @@ -134,7 +88,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([ "mcp", "list", "--dry-run", @@ -149,7 +103,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([ "quota", "check", "--dry-run", diff --git a/packages/cli/tests/e2e/dataset.e2e.test.ts b/packages/commands/tests/e2e/dataset.e2e.test.ts similarity index 79% rename from packages/cli/tests/e2e/dataset.e2e.test.ts rename to packages/commands/tests/e2e/dataset.e2e.test.ts index ef7f08e..6dd19e2 100644 --- a/packages/cli/tests/e2e/dataset.e2e.test.ts +++ b/packages/commands/tests/e2e/dataset.e2e.test.ts @@ -1,9 +1,6 @@ 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"; /** * Dataset (fine-tune file) E2E. @@ -20,21 +17,21 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset --help 列出子命令", async () => { - const { stdout, stderr, exitCode } = await runCli(["dataset"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "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", "validate", "--file", @@ -49,8 +46,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", "validate", "--file", @@ -68,8 +65,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", "upload", "--file", @@ -88,8 +85,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", "validate", "--file", @@ -106,8 +103,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", "validate", "--file", @@ -122,8 +119,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", "validate", "--file", @@ -142,8 +139,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", "validate", "--file", @@ -163,8 +160,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", "validate", "--file", @@ -181,8 +178,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test("dataset validate --schema 以非零码退出", 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", "validate", "--file", @@ -197,8 +194,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", "upload", "--file", @@ -218,7 +215,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { 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", "list", "--page-size", diff --git a/packages/cli/tests/e2e/deploy.e2e.test.ts b/packages/commands/tests/e2e/deploy.e2e.test.ts similarity index 88% rename from packages/cli/tests/e2e/deploy.e2e.test.ts rename to packages/commands/tests/e2e/deploy.e2e.test.ts index 5fdcd63..b2b4891 100644 --- a/packages/cli/tests/e2e/deploy.e2e.test.ts +++ b/packages/commands/tests/e2e/deploy.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; /** * Deploy E2E. @@ -15,20 +15,20 @@ 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"]); 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", "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", "create", "--model", @@ -57,7 +57,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { }); test("deploy scale --dry-run 转发 capacity", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "deploy", "scale", "--deployed-model", @@ -80,7 +80,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", "update", "--deployed-model", @@ -104,7 +104,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", "scale", "--deployed-model", @@ -124,7 +124,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", sub, ...extra, @@ -147,7 +147,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", "list", "--page-size", diff --git a/packages/cli/tests/e2e/file-upload.e2e.test.ts b/packages/commands/tests/e2e/file-upload.e2e.test.ts similarity index 66% rename from packages/cli/tests/e2e/file-upload.e2e.test.ts rename to packages/commands/tests/e2e/file-upload.e2e.test.ts index 241910f..f49c6d2 100644 --- a/packages/cli/tests/e2e/file-upload.e2e.test.ts +++ b/packages/commands/tests/e2e/file-upload.e2e.test.ts @@ -1,9 +1,6 @@ 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"; /** * File upload E2E @@ -11,14 +8,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); describe("e2e: file upload", () => { test("file 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["file"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/upload|--file|--model/i); }); @@ -26,21 +23,26 @@ 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", + "--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", "--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", "--file", diff --git a/packages/cli/tests/e2e/finetune.e2e.test.ts b/packages/commands/tests/e2e/finetune.e2e.test.ts similarity index 89% rename from packages/cli/tests/e2e/finetune.e2e.test.ts rename to packages/commands/tests/e2e/finetune.e2e.test.ts index 067cf13..c5b32c1 100644 --- a/packages/cli/tests/e2e/finetune.e2e.test.ts +++ b/packages/commands/tests/e2e/finetune.e2e.test.ts @@ -1,6 +1,6 @@ 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"; /** * Fine-tune E2E. @@ -16,20 +16,20 @@ 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"]); 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", "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", "create", "--model", @@ -62,7 +62,7 @@ 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", "create", "--model", @@ -115,7 +115,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --training-type 拒绝不支持的训练类型值", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "finetune", "create", "--model", @@ -132,8 +132,8 @@ 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", "create", "--model", @@ -163,7 +163,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --datasets 为空时拒绝", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "finetune", "create", "--model", @@ -182,8 +182,8 @@ 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", "create", "--model", @@ -203,8 +203,8 @@ 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", "create", "--model", @@ -231,7 +231,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", sub, ...extra, @@ -245,7 +245,7 @@ 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", "create", "--model", @@ -273,7 +273,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", "list", "--page-size", diff --git a/packages/cli/tests/e2e/.dataset-cpt-valid.jsonl b/packages/commands/tests/e2e/fixtures/.dataset-cpt-valid.jsonl similarity index 100% rename from packages/cli/tests/e2e/.dataset-cpt-valid.jsonl rename to packages/commands/tests/e2e/fixtures/.dataset-cpt-valid.jsonl diff --git a/packages/cli/tests/e2e/.dataset-dpo-invalid.jsonl b/packages/commands/tests/e2e/fixtures/.dataset-dpo-invalid.jsonl similarity index 100% rename from packages/cli/tests/e2e/.dataset-dpo-invalid.jsonl rename to packages/commands/tests/e2e/fixtures/.dataset-dpo-invalid.jsonl diff --git a/packages/cli/tests/e2e/.dataset-dpo-valid.jsonl b/packages/commands/tests/e2e/fixtures/.dataset-dpo-valid.jsonl similarity index 100% rename from packages/cli/tests/e2e/.dataset-dpo-valid.jsonl rename to packages/commands/tests/e2e/fixtures/.dataset-dpo-valid.jsonl diff --git a/packages/cli/tests/e2e/.dataset-invalid.jsonl b/packages/commands/tests/e2e/fixtures/.dataset-invalid.jsonl similarity index 100% rename from packages/cli/tests/e2e/.dataset-invalid.jsonl rename to packages/commands/tests/e2e/fixtures/.dataset-invalid.jsonl diff --git a/packages/cli/tests/e2e/.dataset-valid.jsonl b/packages/commands/tests/e2e/fixtures/.dataset-valid.jsonl similarity index 100% rename from packages/cli/tests/e2e/.dataset-valid.jsonl rename to packages/commands/tests/e2e/fixtures/.dataset-valid.jsonl diff --git a/packages/cli/tests/e2e/.smoke-32.png b/packages/commands/tests/e2e/fixtures/.smoke-32.png similarity index 100% rename from packages/cli/tests/e2e/.smoke-32.png rename to packages/commands/tests/e2e/fixtures/.smoke-32.png diff --git a/packages/commands/tests/e2e/harness/e2e-command-map.contract.test.ts b/packages/commands/tests/e2e/harness/e2e-command-map.contract.test.ts new file mode 100644 index 0000000..e384570 --- /dev/null +++ b/packages/commands/tests/e2e/harness/e2e-command-map.contract.test.ts @@ -0,0 +1,71 @@ +import { readdirSync, readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, test } from "vite-plus/test"; +import * as commandExports from "bailian-cli-commands"; +import { E2E_COMMAND_EXPORTS, e2eCommandMap } from "./e2e-command-map.ts"; + +const e2eDir = dirname(fileURLToPath(import.meta.url)); + +const exportedCommands = new Set( + Object.values(commandExports).filter( + (value) => typeof value === "object" && value !== null && "run" in value, + ), +); + +/** 从 runCommandE2e([...]) 调用中提取命令 path 前缀 */ +function extractCommandPathsFromSource(source: string): string[] { + const paths: string[] = []; + const callPattern = /runCommandE2e\(\[([\s\S]*?)\]/g; + + for (const match of source.matchAll(callPattern)) { + const arrayBody = match[1] ?? ""; + const tokens: string[] = []; + const stringPattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/g; + for (const tokenMatch of arrayBody.matchAll(stringPattern)) { + const token = tokenMatch[1] ?? tokenMatch[2] ?? ""; + if (token.startsWith("--")) break; + tokens.push(token); + } + if (tokens.length > 0) paths.push(tokens.join(" ")); + } + + return paths; +} + +describe("e2e-command-map contract", () => { + test("path 唯一", () => { + const paths = Object.keys(e2eCommandMap); + expect(new Set(paths).size).toBe(paths.length); + }); + + test("value 均为 bailian-cli-commands 合法 export", () => { + for (const [path, command] of Object.entries(e2eCommandMap)) { + expect(exportedCommands.has(command), `invalid export for path "${path}"`).toBe(true); + } + }); + + test("E2E_COMMAND_EXPORTS 白名单 ⊆ map values", () => { + const mapValues = new Set(Object.values(e2eCommandMap)); + for (const command of E2E_COMMAND_EXPORTS) { + expect(mapValues.has(command)).toBe(true); + } + }); + + test("各 e2e 测试 argv 前缀在 map 中有对应 path", () => { + const mapPaths = new Set(Object.keys(e2eCommandMap)); + const testFiles = readdirSync(e2eDir).filter( + (name) => name.endsWith(".e2e.test.ts") && name !== "proxy.e2e.test.ts", + ); + + const missing: string[] = []; + for (const file of testFiles) { + const source = readFileSync(join(e2eDir, file), "utf8"); + for (const path of extractCommandPathsFromSource(source)) { + if (!mapPaths.has(path)) missing.push(`${file}: ${path}`); + } + } + + expect(missing).toEqual([]); + }); +}); diff --git a/packages/commands/tests/e2e/harness/e2e-command-map.ts b/packages/commands/tests/e2e/harness/e2e-command-map.ts new file mode 100644 index 0000000..bc976b5 --- /dev/null +++ b/packages/commands/tests/e2e/harness/e2e-command-map.ts @@ -0,0 +1,233 @@ +import type { AnyCommand } from "bailian-cli-core"; +import { + authLogin, + authStatus, + authLogout, + textChat, + textOmni, + imageGenerate, + imageEdit, + videoGenerate, + videoEdit, + videoRef, + videoTaskGet, + videoDownload, + visionDescribe, + configShow, + configSet, + update, + appCall, + appList, + memoryAdd, + memorySearch, + memoryList, + memoryUpdate, + memoryDelete, + memoryProfileCreate, + memoryProfileGet, + knowledgeRetrieve, + knowledgeSearch, + knowledgeChat, + mcpCall, + mcpList, + mcpTools, + searchWeb, + speechSynthesize, + speechRecognize, + fileUpload, + consoleCall, + usageFree, + usageFreetier, + usageStats, + pipelineRun, + pipelineValidate, + advisorRecommend, + workspaceList, + quotaList, + quotaRequest, + quotaHistory, + quotaCheck, + datasetUpload, + datasetList, + datasetGet, + datasetDelete, + datasetValidate, + finetuneCreate, + finetuneList, + finetuneGet, + finetuneCancel, + finetuneDelete, + finetuneLogs, + finetuneCheckpoints, + finetuneExport, + finetuneWatch, + finetuneCapability, + deployCreate, + deployList, + deployGet, + deployModels, + deployScale, + deployUpdate, + deployDelete, + tokenPlanListSeats, + tokenPlanCreateKey, + tokenPlanAssignSeats, + tokenPlanAddMember, +} from "bailian-cli-commands"; + +/** + * E2E 专用全量路由表(与产品 map 独立维护)。 + * 新增/删改命令时须同步:commands 实现、产品 map、本文件。 + */ +export const e2eCommandMap: Record = { + "auth login": authLogin, + "auth status": authStatus, + "auth logout": authLogout, + "text chat": textChat, + omni: textOmni, + "image generate": imageGenerate, + "image edit": imageEdit, + "video generate": videoGenerate, + "video edit": videoEdit, + "video ref": videoRef, + "video task get": videoTaskGet, + "video download": videoDownload, + "vision describe": visionDescribe, + "config show": configShow, + "config set": configSet, + update, + "app call": appCall, + "app list": appList, + "memory add": memoryAdd, + "memory search": memorySearch, + "memory list": memoryList, + "memory update": memoryUpdate, + "memory delete": memoryDelete, + "memory profile create": memoryProfileCreate, + "memory profile get": memoryProfileGet, + "knowledge retrieve": knowledgeRetrieve, + "knowledge search": knowledgeSearch, + "knowledge chat": knowledgeChat, + "mcp call": mcpCall, + "mcp list": mcpList, + "mcp tools": mcpTools, + "search web": searchWeb, + "speech synthesize": speechSynthesize, + "speech recognize": speechRecognize, + "file upload": fileUpload, + "console call": consoleCall, + "usage free": usageFree, + "usage freetier": usageFreetier, + "usage stats": usageStats, + "pipeline run": pipelineRun, + "pipeline validate": pipelineValidate, + "advisor recommend": advisorRecommend, + "workspace list": workspaceList, + "quota list": quotaList, + "quota request": quotaRequest, + "quota history": quotaHistory, + "quota check": quotaCheck, + "dataset upload": datasetUpload, + "dataset list": datasetList, + "dataset get": datasetGet, + "dataset delete": datasetDelete, + "dataset validate": datasetValidate, + "finetune create": finetuneCreate, + "finetune list": finetuneList, + "finetune get": finetuneGet, + "finetune cancel": finetuneCancel, + "finetune delete": finetuneDelete, + "finetune logs": finetuneLogs, + "finetune checkpoints": finetuneCheckpoints, + "finetune export": finetuneExport, + "finetune watch": finetuneWatch, + "finetune capability": finetuneCapability, + "deploy create": deployCreate, + "deploy list": deployList, + "deploy get": deployGet, + "deploy models": deployModels, + "deploy scale": deployScale, + "deploy update": deployUpdate, + "deploy delete": deployDelete, + "token-plan list-seats": tokenPlanListSeats, + "token-plan create-key": tokenPlanCreateKey, + "token-plan assign-seats": tokenPlanAssignSeats, + "token-plan add-member": tokenPlanAddMember, +}; + +/** 须在 e2eCommandMap 中出现至少一次的 commands export(契约测试用) */ +export const E2E_COMMAND_EXPORTS = [ + authLogin, + authStatus, + authLogout, + textChat, + textOmni, + imageGenerate, + imageEdit, + videoGenerate, + videoEdit, + videoRef, + videoTaskGet, + videoDownload, + visionDescribe, + configShow, + configSet, + update, + appCall, + appList, + memoryAdd, + memorySearch, + memoryList, + memoryUpdate, + memoryDelete, + memoryProfileCreate, + memoryProfileGet, + knowledgeRetrieve, + knowledgeSearch, + knowledgeChat, + mcpCall, + mcpList, + mcpTools, + searchWeb, + speechSynthesize, + speechRecognize, + fileUpload, + consoleCall, + usageFree, + usageFreetier, + usageStats, + pipelineRun, + pipelineValidate, + advisorRecommend, + workspaceList, + quotaList, + quotaRequest, + quotaHistory, + quotaCheck, + datasetUpload, + datasetList, + datasetGet, + datasetDelete, + datasetValidate, + finetuneCreate, + finetuneList, + finetuneGet, + finetuneCancel, + finetuneDelete, + finetuneLogs, + finetuneCheckpoints, + finetuneExport, + finetuneWatch, + finetuneCapability, + deployCreate, + deployList, + deployGet, + deployModels, + deployScale, + deployUpdate, + deployDelete, + tokenPlanListSeats, + tokenPlanCreateKey, + tokenPlanAssignSeats, + tokenPlanAddMember, +] as const; diff --git a/packages/commands/tests/e2e/harness/main.ts b/packages/commands/tests/e2e/harness/main.ts new file mode 100644 index 0000000..01def24 --- /dev/null +++ b/packages/commands/tests/e2e/harness/main.ts @@ -0,0 +1,9 @@ +import { createCli } from "bailian-cli-runtime"; +import { e2eCommandMap } from "./e2e-command-map.ts"; + +void createCli(e2eCommandMap, { + binName: "bl", + version: "0.0.0-e2e", + clientName: "commands-e2e", + npmPackage: "bailian-cli-commands", +}).run(); diff --git a/packages/commands/tests/e2e/helpers.ts b/packages/commands/tests/e2e/helpers.ts new file mode 100644 index 0000000..4082a70 --- /dev/null +++ b/packages/commands/tests/e2e/helpers.ts @@ -0,0 +1,51 @@ +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"; + +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"); + +/** 通过 harness 子进程执行 E2E 命令 */ +export async function runCommandE2e( + args: string[], + envOverrides: NodeJS.ProcessEnv = {}, +): Promise { + return runNodeMain(harnessMainTs, args, { + cwd: commandsPackageRoot, + env: envOverrides, + }); +} diff --git a/packages/cli/tests/e2e/image-edit.e2e.test.ts b/packages/commands/tests/e2e/image-edit.e2e.test.ts similarity index 81% rename from packages/cli/tests/e2e/image-edit.e2e.test.ts rename to packages/commands/tests/e2e/image-edit.e2e.test.ts index 6a11685..3fd3272 100644 --- a/packages/cli/tests/e2e/image-edit.e2e.test.ts +++ b/packages/commands/tests/e2e/image-edit.e2e.test.ts @@ -1,37 +1,35 @@ 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)); - /** * Image edit E2E */ describe("e2e: image edit", () => { test("image 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["image"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "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", "edit", "--dry-run", @@ -58,21 +56,21 @@ 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", "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", "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", "generate", "--model", @@ -92,7 +90,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: ima expect(imagePath).toBeTruthy(); - const ed = await runCli([ + const ed = await runCommandE2e([ "image", "edit", "--model", diff --git a/packages/cli/tests/e2e/image-generate.e2e.test.ts b/packages/commands/tests/e2e/image-generate.e2e.test.ts similarity index 82% rename from packages/cli/tests/e2e/image-generate.e2e.test.ts rename to packages/commands/tests/e2e/image-generate.e2e.test.ts index 445fb5a..7bee37e 100644 --- a/packages/cli/tests/e2e/image-generate.e2e.test.ts +++ b/packages/commands/tests/e2e/image-generate.e2e.test.ts @@ -5,7 +5,7 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; /** @@ -16,14 +16,14 @@ import { describe("e2e: image generate", () => { test("image 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["image"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "generate", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/generate|--prompt|--model/i); }); @@ -33,14 +33,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", + "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", "generate", "--model", diff --git a/packages/cli/tests/e2e/knowledge-chat.e2e.test.ts b/packages/commands/tests/e2e/knowledge-chat.e2e.test.ts similarity index 54% rename from packages/cli/tests/e2e/knowledge-chat.e2e.test.ts rename to packages/commands/tests/e2e/knowledge-chat.e2e.test.ts index 0640857..93796f3 100644 --- a/packages/cli/tests/e2e/knowledge-chat.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge-chat.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCli } from "./helpers.ts"; +import { isChatE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; interface ContentPart { type: string; @@ -24,7 +24,7 @@ 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", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--message/i); expect(stderr).toMatch(/--agent-id/i); @@ -32,19 +32,24 @@ 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", + "--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", "--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( // 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入 [ "knowledge", @@ -65,7 +70,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", "--dry-run", @@ -88,7 +93,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", "--dry-run", @@ -118,7 +123,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", "--dry-run", @@ -147,7 +152,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", "--dry-run", @@ -178,3 +183,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", + "--message", + "什么是大模型?", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(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", + "--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", + "--message", + "什么是检索增强生成?", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(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", + "--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", + "--message", + "user:什么是大模型", + "--message", + "assistant:大模型是大规模语言模型,具有强大的理解和生成能力", + "--message", + "它有哪些应用场景?", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(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", + "--message", + "test", + "--agent-id", + "aid-invalid-not-exist", + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode).not.toBe(0); + expect(stderr).toBeTruthy(); + }); +}); diff --git a/packages/cli/tests/e2e/knowledge-search.e2e.test.ts b/packages/commands/tests/e2e/knowledge-search.e2e.test.ts similarity index 52% rename from packages/cli/tests/e2e/knowledge-search.e2e.test.ts rename to packages/commands/tests/e2e/knowledge-search.e2e.test.ts index fee03f6..9ff1963 100644 --- a/packages/cli/tests/e2e/knowledge-search.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge-search.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCli } from "./helpers.ts"; +import { isSearchE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; interface DryRunBody { endpoint?: string; @@ -13,7 +13,7 @@ interface DryRunBody { describe("e2e: knowledge search", () => { test("knowledge search --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "search", "--help"]); + const { stderr, exitCode } = await runCommandE2e(["knowledge", "search", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--query/i); expect(stderr).toMatch(/--agent-id/i); @@ -23,19 +23,24 @@ describe("e2e: knowledge search", () => { }); test("缺少 --query 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "search", "--agent-id", "aid_test"]); + const { stderr, exitCode } = await runCommandE2e([ + "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"]); + const { stderr, exitCode } = await runCommandE2e(["knowledge", "search", "--query", "test"]); 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( // 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入 [ "knowledge", @@ -56,7 +61,7 @@ describe("e2e: knowledge search", () => { }); test("--dry-run 输出 endpoint 和 request body", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "knowledge", "search", "--dry-run", @@ -78,7 +83,7 @@ describe("e2e: knowledge search", () => { }); test("--dry-run + --image 输出 images", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "knowledge", "search", "--dry-run", @@ -104,7 +109,7 @@ describe("e2e: knowledge search", () => { }); test("--dry-run + --query-history 输出用户对话历史", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "knowledge", "search", "--dry-run", @@ -128,7 +133,7 @@ describe("e2e: knowledge search", () => { }); test("--dry-run + --query-history 无效 JSON 非零退出", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e([ "knowledge", "search", "--dry-run", @@ -147,3 +152,109 @@ describe("e2e: knowledge search", () => { 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; + }>; + }; +} + +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", + "--query", + "什么是大模型", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(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", + "--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", + "--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(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", + "--query", + "test", + "--agent-id", + "aid-invalid-not-exist", + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode).not.toBe(0); + expect(stderr).toBeTruthy(); + }); +}); diff --git a/packages/cli/tests/e2e/knowledge.e2e.test.ts b/packages/commands/tests/e2e/knowledge.e2e.test.ts similarity index 85% rename from packages/cli/tests/e2e/knowledge.e2e.test.ts rename to packages/commands/tests/e2e/knowledge.e2e.test.ts index f4c960c..88be979 100644 --- a/packages/cli/tests/e2e/knowledge.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; // ---- Types ---- @@ -21,14 +21,14 @@ interface DryRunBody { describe("e2e: knowledge retrieve", () => { test("knowledge 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["knowledge"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "retrieve", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--index-id/i); expect(stderr).toMatch(/--query/i); @@ -37,13 +37,18 @@ describe("e2e: knowledge retrieve", () => { }); test("缺少 --index-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "retrieve", "--query", "test"]); + const { stderr, exitCode } = await runCommandE2e(["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", + "retrieve", + "--index-id", + "idx_test", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--query|Usage:/i); }); @@ -53,7 +58,7 @@ describe("e2e: knowledge retrieve", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: knowledge retrieve errors", () => { test("无任何凭证时提示缺少密钥并非零退出", async () => { - const { stderr, exitCode } = await runCli( + const { stderr, exitCode } = await runCommandE2e( ["knowledge", "retrieve", "--index-id", "idx_test", "--query", "test", "--output", "json"], { DASHSCOPE_API_KEY: "", @@ -70,7 +75,7 @@ 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", "retrieve", @@ -92,7 +97,7 @@ 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", "retrieve", @@ -115,7 +120,7 @@ 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", "retrieve", @@ -139,7 +144,7 @@ describe("e2e: knowledge retrieve dry-run", () => { }); test("--dry-run + rerank 参数完整输出", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( [ "knowledge", "retrieve", diff --git a/packages/cli/tests/e2e/mcp.e2e.test.ts b/packages/commands/tests/e2e/mcp.e2e.test.ts similarity index 86% rename from packages/cli/tests/e2e/mcp.e2e.test.ts rename to packages/commands/tests/e2e/mcp.e2e.test.ts index fec9919..71a9fef 100644 --- a/packages/cli/tests/e2e/mcp.e2e.test.ts +++ b/packages/commands/tests/e2e/mcp.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; /** * `bl mcp` E2E. @@ -17,7 +17,7 @@ import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; describe("e2e: mcp", () => { test("mcp 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["mcp"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["mcp"]); expect(exitCode, stderr).toBe(0); const out = `${stdout}\n${stderr}`; expect(out).toMatch(/mcp/i); @@ -25,31 +25,31 @@ describe("e2e: mcp", () => { }); test("mcp list --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["mcp", "list", "--help"]); + const { stderr, exitCode } = await runCommandE2e(["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", "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", "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", "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", "list", "--dry-run", @@ -87,7 +87,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", "list", "--dry-run", @@ -102,7 +102,7 @@ describe("e2e: mcp", () => { }); test("mcp tools --server --dry-run 输出 /api/v1/mcps//mcp 形态 URL", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "mcp", "tools", "--server", @@ -121,7 +121,7 @@ describe("e2e: mcp", () => { }); test("mcp tools --url 覆盖 baseUrl 约定", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "mcp", "tools", "--server", @@ -139,13 +139,13 @@ describe("e2e: mcp", () => { }); test("mcp tools 缺少 --server 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["mcp", "tools", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(["mcp", "tools", "--quiet"]); expect(exitCode, stderr).toBe(2); expect(stderr).toMatch(/--server|Usage:/i); }); test("mcp call --target --dry-run 输出工具调用计划", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "mcp", "call", "--target", @@ -171,7 +171,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", "call", "--target", @@ -207,7 +207,7 @@ describe("e2e: mcp", () => { }); test("mcp call --target 缺少 . 时报错且非零退出", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e([ "mcp", "call", "--target", @@ -220,7 +220,7 @@ describe("e2e: mcp", () => { }); test("mcp call --arg 非 K=V 形式时报错且非零退出", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e([ "mcp", "call", "--target", @@ -235,7 +235,7 @@ describe("e2e: mcp", () => { }); test("mcp call --json 无效 JSON 报错且非零退出", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e([ "mcp", "call", "--target", @@ -250,7 +250,7 @@ describe("e2e: mcp", () => { }); test("mcp call 缺少 --target 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["mcp", "call", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(["mcp", "call", "--quiet"]); expect(exitCode, stderr).toBe(2); expect(stderr).toMatch(/--target|Usage:/i); }); @@ -261,7 +261,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", "tools", "--server", diff --git a/packages/cli/tests/e2e/memory.e2e.test.ts b/packages/commands/tests/e2e/memory.e2e.test.ts similarity index 86% rename from packages/cli/tests/e2e/memory.e2e.test.ts rename to packages/commands/tests/e2e/memory.e2e.test.ts index 66bcf9d..e94bfe9 100644 --- a/packages/cli/tests/e2e/memory.e2e.test.ts +++ b/packages/commands/tests/e2e/memory.e2e.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "vite-plus/test"; -import { isBailianE2EEnabled, isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { + isBailianE2EEnabled, + isDashScopeE2EReady, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; interface MemoryAddBody { memory_ids?: string[]; @@ -32,32 +37,32 @@ function memoryLibraryCliArgs(): string[] { describe("e2e: memory", () => { test("memory 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["memory"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "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", "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", "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", "profile", "create", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/profile|create|user-id/i); }); @@ -70,7 +75,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", "add", ...memoryLibraryCliArgs(), @@ -83,7 +88,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", "add", ...memoryLibraryCliArgs(), @@ -96,7 +101,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", "add", "--dry-run", @@ -121,7 +126,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( const contentA = "CLI vp test:记忆写入(可删)"; const contentB = "CLI vp test:记忆已更新"; - const addRes = await runCli([ + const addRes = await runCommandE2e([ "memory", "add", ...memoryLibraryCliArgs(), @@ -136,7 +141,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( const added = parseStdoutJson(addRes.stdout); expect(added.request_id?.length ?? 0, addRes.stdout + addRes.stderr).toBeGreaterThan(0); - const listRes = await runCli([ + const listRes = await runCommandE2e([ "memory", "list", ...memoryLibraryCliArgs(), @@ -157,7 +162,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", "search", ...memoryLibraryCliArgs(), @@ -174,7 +179,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( const searched = parseStdoutJson(searchRes.stdout); expect(searched.memory_nodes?.length ?? 0).toBeGreaterThan(0); - const updRes = await runCli([ + const updRes = await runCommandE2e([ "memory", "update", ...memoryLibraryCliArgs(), @@ -189,7 +194,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( ]); expect(updRes.exitCode, updRes.stderr).toBe(0); - const delRes = await runCli([ + const delRes = await runCommandE2e([ "memory", "delete", ...memoryLibraryCliArgs(), diff --git a/packages/cli/tests/e2e/omni.e2e.test.ts b/packages/commands/tests/e2e/omni.e2e.test.ts similarity index 88% rename from packages/cli/tests/e2e/omni.e2e.test.ts rename to packages/commands/tests/e2e/omni.e2e.test.ts index b6f6853..cdf83b6 100644 --- a/packages/cli/tests/e2e/omni.e2e.test.ts +++ b/packages/commands/tests/e2e/omni.e2e.test.ts @@ -6,12 +6,12 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; describe("e2e: omni", () => { test("omni --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["omni", "--help"]); + const { stderr, exitCode } = await runCommandE2e(["omni", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/omni|--message|--audio|text-only/i); }); @@ -21,7 +21,7 @@ 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", "--list-voices"]); expect(exitCode, stderr).toBe(0); expect(stdout).toMatch(/Omni output voices:/); expect(stdout).toMatch(/Tina/); @@ -30,13 +30,13 @@ 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", "--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", "--model", "qwen3.5-omni-flash", @@ -51,7 +51,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", "--dry-run", "--model", @@ -91,7 +91,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( const clipText = "端到端Omni音频测试"; const clipWav = join(outDir, "e2e-omni-input.wav"); - const syn = await runCli([ + const syn = await runCommandE2e([ "speech", "synthesize", "--model", @@ -109,7 +109,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( ]); expect(syn.exitCode, syn.stderr).toBe(0); - const omni = await runCli([ + const omni = await runCommandE2e([ "omni", "--model", "qwen3.5-omni-flash", diff --git a/packages/cli/tests/e2e/pipeline.e2e.test.ts b/packages/commands/tests/e2e/pipeline.e2e.test.ts similarity index 87% rename from packages/cli/tests/e2e/pipeline.e2e.test.ts rename to packages/commands/tests/e2e/pipeline.e2e.test.ts index 5fa8b85..8589a77 100644 --- a/packages/cli/tests/e2e/pipeline.e2e.test.ts +++ b/packages/commands/tests/e2e/pipeline.e2e.test.ts @@ -2,7 +2,7 @@ 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"; describe("e2e: pipeline", () => { let tempDir: string; @@ -64,7 +64,7 @@ describe("e2e: pipeline", () => { }); test("pipeline 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["pipeline"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["pipeline"]); expect(exitCode, stderr).toBe(0); const out = `${stdout}\n${stderr}`; expect(out).toMatch(/pipeline|run|validate/i); @@ -73,20 +73,20 @@ describe("e2e: pipeline", () => { }); test("pipeline run --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["pipeline", "run", "--help"]); + const { stderr, exitCode } = await runCommandE2e(["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", "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", "validate", "--file", @@ -101,7 +101,7 @@ describe("e2e: pipeline", () => { }); test("pipeline validate 使用 config 输出格式", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( ["pipeline", "validate", "--file", chatBasicPath], { DASHSCOPE_OUTPUT: "text", @@ -112,7 +112,7 @@ describe("e2e: pipeline", () => { }); test("pipeline validate 拒绝非法依赖 workflow", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "pipeline", "validate", "--file", @@ -128,13 +128,13 @@ describe("e2e: pipeline", () => { }); test("pipeline run 缺少 --file 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["pipeline", "run", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(["pipeline", "run", "--quiet"]); expect(exitCode, stderr).toBe(2); expect(stderr).toMatch(/Usage: bl pipeline run --file |--file/i); }); test("pipeline run --dry-run --output json 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "pipeline", "run", "--file", @@ -167,7 +167,7 @@ describe("e2e: pipeline", () => { }); test("pipeline run 使用 config 输出格式", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( ["pipeline", "run", "--file", chatBasicPath, "--input", '{"message":"hello"}', "--dry-run"], { DASHSCOPE_OUTPUT: "text" }, ); @@ -177,7 +177,7 @@ describe("e2e: pipeline", () => { }); test("pipeline run --verbose 打印总步数和当前步骤序号", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e([ "pipeline", "run", "--file", @@ -193,7 +193,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", "run", "--file", @@ -221,7 +221,7 @@ describe("e2e: pipeline", () => { }); test("pipeline run 拒绝未知 events format", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "pipeline", "run", "--file", diff --git a/packages/cli/tests/e2e/quota.e2e.test.ts b/packages/commands/tests/e2e/quota.e2e.test.ts similarity index 77% rename from packages/cli/tests/e2e/quota.e2e.test.ts rename to packages/commands/tests/e2e/quota.e2e.test.ts index 857d5d8..d332b76 100644 --- a/packages/cli/tests/e2e/quota.e2e.test.ts +++ b/packages/commands/tests/e2e/quota.e2e.test.ts @@ -1,16 +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"; describe("e2e: quota", () => { test("quota list --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["quota", "list", "--help"]); + const { stderr, exitCode } = await runCommandE2e(["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", "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 +23,21 @@ describe("e2e: quota", () => { }); test("quota request --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["quota", "request", "--help"]); + const { stderr, exitCode } = await runCommandE2e(["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", "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", "check", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("--model"); expect(stderr).toContain("--period"); @@ -40,7 +45,7 @@ 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", "check", "--period", "0.5"]); expect(exitCode).toBe(2); expect(stderr).toContain("at least 1 minute"); }); @@ -48,7 +53,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", "list", "--dry-run", @@ -68,7 +73,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", "list", "--all", @@ -84,19 +89,26 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota list 文本输出包含英文表头", async () => { - const result = await runCli(["quota", "list", "--output", "text"]); + const result = await runCommandE2e(["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", + "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", "list", "--model", @@ -110,13 +122,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", "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", "request", "--model", @@ -138,7 +150,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", + "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 +166,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota request 不支持提额的模型报错", async () => { - const result = await runCli([ + const result = await runCommandE2e([ "quota", "request", "--model", @@ -161,7 +180,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", "history", "--dry-run", @@ -179,7 +198,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", "check", "--dry-run", @@ -196,7 +215,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", "check", "--dry-run", @@ -211,19 +230,26 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check 文本输出包含英文表头", async () => { - const result = await runCli(["quota", "check", "--output", "text"]); + const result = await runCommandE2e(["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", + "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", "check", "--model", @@ -236,13 +262,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", + "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", "history", "--page", diff --git a/packages/cli/tests/e2e/search-web.e2e.test.ts b/packages/commands/tests/e2e/search-web.e2e.test.ts similarity index 83% rename from packages/cli/tests/e2e/search-web.e2e.test.ts rename to packages/commands/tests/e2e/search-web.e2e.test.ts index 817f7f7..9ae5c64 100644 --- a/packages/cli/tests/e2e/search-web.e2e.test.ts +++ b/packages/commands/tests/e2e/search-web.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; function pagesFromSearchWebStdout(stdout: string): Array<{ title?: string; url?: string }> { const envelope = parseStdoutJson<{ content?: Array<{ type?: string; text?: string }> }>(stdout); @@ -16,20 +16,20 @@ function pagesFromSearchWebStdout(stdout: string): Array<{ title?: string; url?: describe("e2e: search web", () => { test("search 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["search"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "--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", "--dry-run", "--list-tools", "--output", "json"], { DASHSCOPE_API_KEY: undefined, @@ -44,13 +44,13 @@ 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", "--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", "--dry-run", @@ -74,7 +74,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: search web", () => { }); test("联网搜索返回 JSON 且含搜索结果", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e([ "search", "web", "--query", diff --git a/packages/cli/tests/e2e/speech-list-voices.e2e.test.ts b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts similarity index 72% rename from packages/cli/tests/e2e/speech-list-voices.e2e.test.ts rename to packages/commands/tests/e2e/speech-list-voices.e2e.test.ts index 65a7120..057c020 100644 --- a/packages/cli/tests/e2e/speech-list-voices.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, runCommandE2e } from "./helpers.ts"; /** * Speech list-voices E2E @@ -7,20 +7,20 @@ import { isDashScopeE2EReady, runCli } from "./helpers.ts"; describe("e2e: speech list-voices", () => { test("speech 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["speech"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "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", "recognize", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/recognize|--url|audio|model/i); }); @@ -28,13 +28,13 @@ 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", "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", "synthesize", "--list-voices", diff --git a/packages/cli/tests/e2e/speech-recognize.e2e.test.ts b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts similarity index 86% rename from packages/cli/tests/e2e/speech-recognize.e2e.test.ts rename to packages/commands/tests/e2e/speech-recognize.e2e.test.ts index cc4a106..a2209ed 100644 --- a/packages/cli/tests/e2e/speech-recognize.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts @@ -7,7 +7,7 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; /** @@ -16,13 +16,13 @@ import { describe("e2e: speech recognize", () => { test("speech 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["speech"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "recognize", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/recognize|--url|model|audio/i); }); @@ -32,7 +32,7 @@ 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", "recognize", "--quiet"]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--url|Usage:/i); }); @@ -40,7 +40,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", "synthesize", "--model", @@ -60,7 +60,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", "recognize", "--model", diff --git a/packages/cli/tests/e2e/speech-synthesize.e2e.test.ts b/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts similarity index 87% rename from packages/cli/tests/e2e/speech-synthesize.e2e.test.ts rename to packages/commands/tests/e2e/speech-synthesize.e2e.test.ts index 6dfe1a0..3f73472 100644 --- a/packages/cli/tests/e2e/speech-synthesize.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts @@ -6,7 +6,7 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; /** @@ -15,13 +15,13 @@ import { describe("e2e: speech synthesize", () => { test("speech 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["speech"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "synthesize", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/synthesize|--text|--voice|model/i); }); @@ -31,7 +31,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", "synthesize", "--model", @@ -44,7 +44,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", "synthesize", "--dry-run", @@ -68,7 +68,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", "synthesize", "--model", diff --git a/packages/cli/tests/e2e/text-chat.e2e.test.ts b/packages/commands/tests/e2e/text-chat.e2e.test.ts similarity index 80% rename from packages/cli/tests/e2e/text-chat.e2e.test.ts rename to packages/commands/tests/e2e/text-chat.e2e.test.ts index c54b6fb..c8e10b0 100644 --- a/packages/cli/tests/e2e/text-chat.e2e.test.ts +++ b/packages/commands/tests/e2e/text-chat.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; /** * Text chat:help / 分组不依赖密钥;对话需 DashScope。 @@ -7,13 +7,13 @@ import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; describe("e2e: text chat", () => { test("text 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["text"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/chat|--message|model|stream/i); }); @@ -21,13 +21,13 @@ 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", "--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", "--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", "--model", diff --git a/packages/cli/tests/e2e/usage-free.e2e.test.ts b/packages/commands/tests/e2e/usage-free.e2e.test.ts similarity index 73% rename from packages/cli/tests/e2e/usage-free.e2e.test.ts rename to packages/commands/tests/e2e/usage-free.e2e.test.ts index 40521d0..7967b65 100644 --- a/packages/cli/tests/e2e/usage-free.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-free.e2e.test.ts @@ -1,22 +1,27 @@ import { describe, expect, test } from "vite-plus/test"; -import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts"; +import { + isConsoleE2EReady, + isConsoleAuthFailure, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; describe("e2e: usage free", () => { test("usage 分组展示子命令帮助且退出码为 0", async () => { - const { stdout, stderr, exitCode } = await runCli(["usage"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "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", "free", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("bl usage free"); expect(stderr).toContain("bl usage free --model qwen3-max"); @@ -26,7 +31,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", "free", "--dry-run", @@ -45,7 +50,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", "free", "--dry-run", @@ -62,7 +67,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", "free", "--dry-run", @@ -79,7 +84,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", "free", "--dry-run", @@ -96,30 +101,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", + "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", + "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", + "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", + "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", "free", "--model", @@ -132,25 +164,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", + "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", + "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", + "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", "free", "--model", @@ -163,13 +216,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", + "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", "free", "--model", diff --git a/packages/cli/tests/e2e/usage-stats.e2e.test.ts b/packages/commands/tests/e2e/usage-stats.e2e.test.ts similarity index 83% rename from packages/cli/tests/e2e/usage-stats.e2e.test.ts rename to packages/commands/tests/e2e/usage-stats.e2e.test.ts index cdda8bf..b6e2320 100644 --- a/packages/cli/tests/e2e/usage-stats.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-stats.e2e.test.ts @@ -1,5 +1,10 @@ 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 { readConfigFile } from "bailian-cli-core"; function getStaticWorkspaceId(): string | undefined { @@ -20,7 +25,7 @@ async function fetchDefaultWorkspaceId(): Promise { const staticId = getStaticWorkspaceId(); if (staticId) return staticId; - const result = await runCli(["workspace", "list", "--output", "json"]); + const result = await runCommandE2e(["workspace", "list", "--output", "json"]); if (isConsoleAuthFailure(result) || result.exitCode !== 0) return FALLBACK_WORKSPACE_ID; try { const parsed = JSON.parse(result.stdout); @@ -36,13 +41,13 @@ async function fetchDefaultWorkspaceId(): Promise { describe("e2e: usage stats", () => { test("usage stats --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]); + const { stderr, exitCode } = await runCommandE2e(["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", "stats", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("bl usage stats"); expect(stderr).toContain("bl usage stats --model qwen-turbo"); @@ -50,7 +55,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", "stats", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("--workspace-id"); }); @@ -66,7 +71,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", "stats", "--workspace-id", @@ -95,7 +100,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", "stats", "--workspace-id", @@ -117,7 +122,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", "stats", "--workspace-id", @@ -139,7 +144,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", "stats", "--workspace-id", @@ -158,25 +163,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", + "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", + "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", + "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", "stats", "--workspace-id", @@ -191,7 +217,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --model 逗号分隔多模型返回多行", async () => { - const result = await runCli([ + const result = await runCommandE2e([ "usage", "stats", "--workspace-id", @@ -206,7 +232,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --model 不存在的模型返回空表格", async () => { - const result = await runCli([ + const result = await runCommandE2e([ "usage", "stats", "--workspace-id", @@ -221,7 +247,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --days 1 短时间范围正常返回", async () => { - const result = await runCli([ + const result = await runCommandE2e([ "usage", "stats", "--workspace-id", @@ -236,7 +262,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --type Vision 按类型过滤", async () => { - const result = await runCli([ + const result = await runCommandE2e([ "usage", "stats", "--workspace-id", diff --git a/packages/cli/tests/e2e/video-download.e2e.test.ts b/packages/commands/tests/e2e/video-download.e2e.test.ts similarity index 89% rename from packages/cli/tests/e2e/video-download.e2e.test.ts rename to packages/commands/tests/e2e/video-download.e2e.test.ts index 70e79c7..46a6bd9 100644 --- a/packages/cli/tests/e2e/video-download.e2e.test.ts +++ b/packages/commands/tests/e2e/video-download.e2e.test.ts @@ -8,7 +8,7 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; /** dry-run 占位 UUID */ @@ -21,13 +21,13 @@ const PLACEHOLDER_TASK_ID = "00000000-0000-4000-8000-000000000001"; describe("e2e: video download", () => { test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "download", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/download|--task-id|--out/i); }); @@ -37,7 +37,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", "download", "--out", @@ -48,7 +48,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( }); test("video download 缺少 --out 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e([ "video", "download", "--task-id", @@ -61,7 +61,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", "download", "--dry-run", @@ -83,7 +83,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", "generate", ...cliTimeoutPrefix(), @@ -111,7 +111,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", "download", ...cliTimeoutPrefix(), diff --git a/packages/cli/tests/e2e/video-edit.e2e.test.ts b/packages/commands/tests/e2e/video-edit.e2e.test.ts similarity index 88% rename from packages/cli/tests/e2e/video-edit.e2e.test.ts rename to packages/commands/tests/e2e/video-edit.e2e.test.ts index fa6e9a2..fd692eb 100644 --- a/packages/cli/tests/e2e/video-edit.e2e.test.ts +++ b/packages/commands/tests/e2e/video-edit.e2e.test.ts @@ -7,7 +7,7 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; /** @@ -16,19 +16,19 @@ import { describe("e2e: video edit", () => { test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "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", "edit", "--dry-run", @@ -54,7 +54,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", "edit", ...cliTimeoutPrefix(), @@ -71,7 +71,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", "generate", ...cliTimeoutPrefix(), @@ -88,7 +88,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", "edit", ...cliTimeoutPrefix(), diff --git a/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts similarity index 88% rename from packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts rename to packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts index 1c54b9d..e0f3ecd 100644 --- a/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts @@ -7,7 +7,7 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; /** @@ -16,13 +16,13 @@ import { describe("e2e: video generate (i2v)", () => { test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "generate", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/generate|--prompt|--image|model/i); }); @@ -32,7 +32,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", "generate", ...cliTimeoutPrefix(), @@ -46,7 +46,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", "generate", ...cliTimeoutPrefix(), @@ -69,7 +69,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([ "image", "generate", "--model", @@ -87,7 +87,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", "generate", ...cliTimeoutPrefix(), diff --git a/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts b/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts similarity index 87% rename from packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts rename to packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts index 41d8685..86507dc 100644 --- a/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts @@ -7,7 +7,7 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; /** @@ -16,13 +16,13 @@ import { describe("e2e: video generate (t2v)", () => { test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "generate", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/generate|--prompt|--model|download|image/i); }); @@ -32,7 +32,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", "generate", ...cliTimeoutPrefix(), @@ -44,7 +44,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", "generate", "--dry-run", @@ -66,7 +66,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", "generate", ...cliTimeoutPrefix(), diff --git a/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts similarity index 88% rename from packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts rename to packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts index 55b80a0..b3839f6 100644 --- a/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts @@ -7,7 +7,7 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; /** @@ -16,19 +16,19 @@ import { describe("e2e: video ref (r2v)", () => { test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "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", "ref", "--dry-run", @@ -54,7 +54,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", "ref", ...cliTimeoutPrefix(), @@ -68,7 +68,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( }); test("video ref 缺少 --image 与 --ref-video 时退出为用法错误 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e([ "video", "ref", ...cliTimeoutPrefix(), @@ -83,7 +83,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([ "image", "generate", "--model", @@ -102,7 +102,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", "ref", ...cliTimeoutPrefix(), diff --git a/packages/cli/tests/e2e/video-task-get.e2e.test.ts b/packages/commands/tests/e2e/video-task-get.e2e.test.ts similarity index 77% rename from packages/cli/tests/e2e/video-task-get.e2e.test.ts rename to packages/commands/tests/e2e/video-task-get.e2e.test.ts index 0ea0fc7..ddd4162 100644 --- a/packages/cli/tests/e2e/video-task-get.e2e.test.ts +++ b/packages/commands/tests/e2e/video-task-get.e2e.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "vite-plus/test"; -import { isBailianE2EEnabled, isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { + isBailianE2EEnabled, + isDashScopeE2EReady, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; const taskId = process.env.BAILIAN_E2E_VIDEO_TASK_ID?.trim(); @@ -9,13 +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"]); + const { stdout, stderr, exitCode } = await runCommandE2e(["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", "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", + "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", "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", "task", "get", diff --git a/packages/commands/vite.config.ts b/packages/commands/vite.config.ts index 1c26ed4..7325db8 100644 --- a/packages/commands/vite.config.ts +++ b/packages/commands/vite.config.ts @@ -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: { diff --git a/packages/e2e/package.json b/packages/e2e/package.json new file mode 100644 index 0000000..9abcd17 --- /dev/null +++ b/packages/e2e/package.json @@ -0,0 +1,25 @@ +{ + "name": "e2e", + "version": "0.0.0", + "private": true, + "description": "Shared E2E harness utilities for bailian-cli monorepo (not published).", + "type": "module", + "exports": { + "./gating": "./src/gating.ts", + "./runner": "./src/run-subprocess.ts", + "./output": "./src/output.ts", + "./global-setup": "./src/global-setup.ts", + "./monorepo-root": "./src/monorepo-root.ts" + }, + "dependencies": { + "bailian-cli-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "^6.0.2", + "vite-plus": "0.1.22" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/packages/e2e/src/gating.ts b/packages/e2e/src/gating.ts new file mode 100644 index 0000000..36ade27 --- /dev/null +++ b/packages/e2e/src/gating.ts @@ -0,0 +1,56 @@ +import { readConfigFile } from "bailian-cli-core"; + +/** 显式开启后才跑真实网络 E2E */ +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 命令 E2E 就绪检查 */ +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"; +} + +/** 知识检索 E2E 就绪 */ +export function isSearchE2EReady(): boolean { + if (!isDashScopeE2EReady()) return false; + return ( + !!process.env.BAILIAN_E2E_SEARCH_AGENT_ID?.trim() && !!process.env.BAILIAN_WORKSPACE_ID?.trim() + ); +} + +/** 知识问答 E2E 就绪 */ +export function isChatE2EReady(): boolean { + if (!isDashScopeE2EReady()) return false; + return ( + !!process.env.BAILIAN_E2E_CHAT_AGENT_ID?.trim() && !!process.env.BAILIAN_WORKSPACE_ID?.trim() + ); +} diff --git a/packages/cli/tests/e2e/global-setup.ts b/packages/e2e/src/global-setup.ts similarity index 65% rename from packages/cli/tests/e2e/global-setup.ts rename to packages/e2e/src/global-setup.ts index de36bd8..f12704c 100644 --- a/packages/cli/tests/e2e/global-setup.ts +++ b/packages/e2e/src/global-setup.ts @@ -1,16 +1,17 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"; import { join } from "path"; import { parseEnv } from "util"; -import { E2E_RUN_SESSION_FILENAME, monorepoRoot } from "./helpers.ts"; +import { monorepoRoot } from "./monorepo-root.ts"; + +/** Vitest globalSetup 写入的会话标记文件名 */ +export const E2E_RUN_SESSION_FILENAME = ".e2e-run-session"; /** - * Vitest 在所有 worker 启动前执行一次:写入共享会话 id,使多进程并行时仍共用一个 `test/output/<会话>/`。 - * 结束后删除标记文件,避免非 Vitest 流程误用上一次会话 id。 + * Vitest 在所有 worker 启动前执行一次:加载 `.env` 并写入共享 E2E 输出会话 id。 */ - export default function vitestGlobalSetup(): () => void { - // 加载根目录 `.env` 合并变量(包含shell 注入) - const rootEnv = join(monorepoRoot(), ".env"); + const root = monorepoRoot(); + const rootEnv = join(root, ".env"); if (existsSync(rootEnv)) { const parsed = parseEnv(readFileSync(rootEnv, "utf8")); Object.assign(process.env, parsed); @@ -18,34 +19,36 @@ export default function vitestGlobalSetup(): () => void { process.env.BAILIAN_E2E = "1"; process.env.BAILIAN_E2E_MEDIA = "1"; process.env.BAILIAN_E2E_VIDEO = "1"; - // 如根目录不存在 .env,则生成一个 .env const envContent = `# 是否开启 E2E 测试 BAILIAN_E2E=1 # 是否开启图片/语音 E2E 测试 BAILIAN_E2E_MEDIA=1 # 是否开启视频 E2E 测试 BAILIAN_E2E_VIDEO=1 +# DashScope Base URL +DASHSCOPE_BASE_URL= # DashScope API Key DASHSCOPE_API_KEY= # ------------------------------- BAILIAN_E2E_VIDEO_TASK_ID=b499a8cb-1fc4-4d43-9495-e23c7f78ae0d # ------------------------------- -# 知识库 ID +# Workspace ID BAILIAN_WORKSPACE_ID= -# 索引 ID -BAILIAN_E2E_INDEX_ID= # ------------------------------- - `; +# 知识库检索Agent ID +BAILIAN_E2E_SEARCH_AGENT_ID= +# 知识库问答Agent ID +BAILIAN_E2E_CHAT_AGENT_ID= +`; writeFileSync(rootEnv, envContent, "utf8"); } - // 创建生成内容目录 const now = new Date(); const pad = (n: number) => n.toString().padStart(2, "0"); const dateStr = [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join("-"); const timeStr = [pad(now.getHours()), pad(now.getMinutes()), pad(now.getSeconds())].join(":"); const runId = `e2e-run-${dateStr} ${timeStr}`; - const outDir = join(monorepoRoot(), "test", "output"); + const outDir = join(root, "test", "output"); mkdirSync(outDir, { recursive: true }); const marker = join(outDir, E2E_RUN_SESSION_FILENAME); writeFileSync(marker, `${runId}\n`, "utf8"); @@ -53,7 +56,7 @@ BAILIAN_E2E_INDEX_ID= try { unlinkSync(marker); } catch { - /* 忽略:已删或权限等 */ + /* 忽略 */ } }; } diff --git a/packages/e2e/src/monorepo-root.ts b/packages/e2e/src/monorepo-root.ts new file mode 100644 index 0000000..5676bd2 --- /dev/null +++ b/packages/e2e/src/monorepo-root.ts @@ -0,0 +1,15 @@ +import { existsSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +/** Monorepo 根目录(含根 `package.json`) */ +export function monorepoRoot(fromModuleUrl: string = import.meta.url): string { + let dir = dirname(fileURLToPath(fromModuleUrl)); + for (let i = 0; i < 8; i++) { + if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error("Could not locate monorepo root (pnpm-workspace.yaml)"); +} diff --git a/packages/e2e/src/output.ts b/packages/e2e/src/output.ts new file mode 100644 index 0000000..385299d --- /dev/null +++ b/packages/e2e/src/output.ts @@ -0,0 +1,77 @@ +import { mkdirSync, readFileSync } from "fs"; +import { basename, join } from "path"; +import { fileURLToPath } from "url"; +import { E2E_RUN_SESSION_FILENAME } from "./global-setup.ts"; +import { monorepoRoot } from "./monorepo-root.ts"; +import type { RunCliResult } from "./run-subprocess.ts"; + +let e2eOutputSessionId: string | undefined; + +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/<会话>/` 下创建用例子目录 */ +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()]; +} + +/** 从 `import.meta.url` 生成 OUT 子目录标签 */ +export function e2eLabelFromMetaUrl(metaUrl: string): string { + return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, ""); +} + +export function parseStdoutJson(stdout: string): T { + const t = stdout.trim(); + 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; +} + +/** Console session 未登录/已过期时的优雅失败判定 */ +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); +} diff --git a/packages/e2e/src/run-subprocess.ts b/packages/e2e/src/run-subprocess.ts new file mode 100644 index 0000000..91b6626 --- /dev/null +++ b/packages/e2e/src/run-subprocess.ts @@ -0,0 +1,43 @@ +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +export interface RunCliResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** 子进程执行指定 main.ts 入口 */ +export async function runNodeMain( + mainTs: string, + args: string[], + options: { cwd: string; env?: NodeJS.ProcessEnv } = { cwd: process.cwd() }, +): Promise { + try { + const { stdout, stderr } = await execFileAsync("node", [mainTs, ...args], { + cwd: options.cwd, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + env: { + ...process.env, + NODE_NO_WARNINGS: "1", + DO_NOT_TRACK: "1", + ...options.env, + }, + }); + 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, + }; + } +} diff --git a/packages/e2e/vite.config.ts b/packages/e2e/vite.config.ts new file mode 100644 index 0000000..168a023 --- /dev/null +++ b/packages/e2e/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + lint: { + options: { + typeAware: true, + typeCheck: true, + }, + }, + fmt: {}, +}); diff --git a/packages/kscli/package.json b/packages/kscli/package.json index 6c266d3..a2eaf61 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -59,6 +59,7 @@ "ajv": "catalog:", "boxen": "catalog:", "chalk": "catalog:", + "e2e": "workspace:*", "typescript": "^6.0.2", "undici": "catalog:", "vite-plus": "0.1.22", diff --git a/packages/kscli/tests/e2e/chat.e2e.test.ts b/packages/kscli/tests/e2e/chat.e2e.test.ts deleted file mode 100644 index ed4535f..0000000 --- a/packages/kscli/tests/e2e/chat.e2e.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, test } from "vite-plus/test"; -import { isChatE2EReady, parseStdoutJson, runKscli } from "./helpers.ts"; - -// ---- Types ---- - -interface ChatJsonResult { - answer: string; - request_id: string; -} - -// ---- Real API call tests (gated by BAILIAN_E2E + credentials) ---- - -describe.skipIf(!isChatE2EReady())("e2e: kscli 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 runKscli([ - "chat", - "--message", - "什么是大模型?", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(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 runKscli([ - "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 runKscli([ - "chat", - "--message", - "什么是检索增强生成?", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(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 runKscli([ - "chat", - "--message", - "什么是向量检索?", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "text", - ]); - - expect(exitCode, stderr).toBe(0); - // Streaming text mode: output should contain some text content - expect(stdout.trim().length).toBeGreaterThan(0); - }); - - test("chat with multi-turn messages returns context-aware answer", async () => { - const { stdout, stderr, exitCode } = await runKscli([ - "chat", - "--message", - "user:什么是大模型", - "--message", - "assistant:大模型是大规模语言模型,具有强大的理解和生成能力", - "--message", - "它有哪些应用场景?", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(stdout); - expect(data.answer).toBeTruthy(); - expect(data.answer.length).toBeGreaterThan(0); - }); - - test("chat with invalid agent_id fails gracefully", async () => { - const { stderr, exitCode } = await runKscli([ - "chat", - "--message", - "test", - "--agent-id", - "aid-invalid-not-exist", - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode).not.toBe(0); - expect(stderr).toBeTruthy(); - }); -}); diff --git a/packages/kscli/tests/e2e/global-setup.ts b/packages/kscli/tests/e2e/global-setup.ts deleted file mode 100644 index fc93de0..0000000 --- a/packages/kscli/tests/e2e/global-setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { loadRootEnv } from "./helpers.ts"; - -/** - * Vitest globalSetup: load monorepo root `.env` into `process.env` before tests run. - */ -export default function vitestGlobalSetup(): () => void { - loadRootEnv(); - return () => {}; -} diff --git a/packages/kscli/tests/e2e/helpers.ts b/packages/kscli/tests/e2e/helpers.ts index c575061..65e56b3 100644 --- a/packages/kscli/tests/e2e/helpers.ts +++ b/packages/kscli/tests/e2e/helpers.ts @@ -1,129 +1,42 @@ -import { execFile } from "child_process"; -import { existsSync, mkdtempSync, readFileSync } from "fs"; +import { mkdtempSync } from "fs"; import { tmpdir } from "os"; -import { promisify } from "util"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { parseEnv } from "util"; +import { parseStdoutJson } from "e2e/output"; +import { runNodeMain, type RunCliResult } from "e2e/runner"; +import { + isBailianE2EEnabled, + isChatE2EReady, + isDashScopeE2EReady, + isSearchE2EReady, +} from "e2e/gating"; +import { monorepoRoot } from "e2e/monorepo-root"; -const execFileAsync = promisify(execFile); +export { + isBailianE2EEnabled, + isChatE2EReady, + isDashScopeE2EReady, + isSearchE2EReady, + monorepoRoot, + parseStdoutJson, +}; +export type { RunCliResult }; -/** `packages/kscli` 根目录(含 `src/main.ts`) */ +/** `packages/kscli` 根目录 */ export const kscliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const mainTs = join(kscliPackageRoot, "src", "main.ts"); -/** Monorepo 根(含根 `package.json` 和 `.env`) */ -export function monorepoRoot(): string { - return join(kscliPackageRoot, "..", ".."); -} - -// ---- E2E gating helpers ---- - -// ---- .env loader (cached) ---- - -let _rootEnvCache: Record | null = null; - -/** 读取 monorepo 根目录 `.env` 并缓存(.env 值优先于 shell 环境变量) */ -function getRootEnv(): Record { - if (_rootEnvCache !== null) return _rootEnvCache; - const rootEnvPath = join(monorepoRoot(), ".env"); - _rootEnvCache = existsSync(rootEnvPath) ? parseEnv(readFileSync(rootEnvPath, "utf8")) : {}; - return _rootEnvCache; -} - -/** 从 .env 或 process.env 获取值(.env 优先) */ -function envVar(key: string): string | undefined { - return getRootEnv()[key] ?? process.env[key]; -} - -// ---- E2E gating helpers ---- - -/** 显式开启后才跑真实网络 E2E */ -export function isBailianE2EEnabled(): boolean { - return envVar("BAILIAN_E2E") === "1"; -} - -/** 是否有 DashScope API Key 可用 */ -export function isDashScopeE2EReady(): boolean { - if (!isBailianE2EEnabled()) return false; - return !!envVar("DASHSCOPE_API_KEY")?.trim(); -} - -/** 知识检索 E2E 就绪:E2E 开启 + API Key + search agent ID + workspace ID */ -export function isSearchE2EReady(): boolean { - if (!isDashScopeE2EReady()) return false; - return ( - !!envVar("BAILIAN_E2E_SEARCH_AGENT_ID")?.trim() && !!envVar("BAILIAN_WORKSPACE_ID")?.trim() - ); -} - -/** 知识问答 E2E 就绪:E2E 开启 + API Key + chat agent ID + workspace ID */ -export function isChatE2EReady(): boolean { - if (!isDashScopeE2EReady()) return false; - return !!envVar("BAILIAN_E2E_CHAT_AGENT_ID")?.trim() && !!envVar("BAILIAN_WORKSPACE_ID")?.trim(); -} - -// ---- CLI runner ---- - -export interface RunCliResult { - stdout: string; - stderr: string; - exitCode: number; -} - -/** - * 子进程执行 kscli(等价于 `node packages/kscli/src/main.ts ...`)。 - */ +/** 子进程执行 kscli */ export async function runKscli( args: string[], envOverrides: NodeJS.ProcessEnv = {}, ): Promise { - try { - const { stdout, stderr } = await execFileAsync("node", [mainTs, ...args], { - cwd: kscliPackageRoot, - encoding: "utf8", - maxBuffer: 32 * 1024 * 1024, - env: { - ...process.env, - // .env values override shell env vars (ensures correct API key is used) - ...getRootEnv(), - // Unique clean config dir per run — prevents stale config.json from previous tests - BAILIAN_CONFIG_DIR: mkdtempSync(join(tmpdir(), "kscli-test-")), - 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(stdout: string): T { - const t = stdout.trim(); - return JSON.parse(t) as T; -} - -// ---- Global setup: load root .env ---- - -/** - * Vitest globalSetup:加载 monorepo 根目录 `.env` 合并到 `process.env`。 - */ -export function loadRootEnv(): void { - const rootEnv = join(monorepoRoot(), ".env"); - if (existsSync(rootEnv)) { - const parsed = parseEnv(readFileSync(rootEnv, "utf8")); - Object.assign(process.env, parsed); - } + return runNodeMain(mainTs, args, { + cwd: kscliPackageRoot, + env: { + BAILIAN_CONFIG_DIR: mkdtempSync(join(tmpdir(), "kscli-test-")), + ...envOverrides, + }, + }); } diff --git a/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts b/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts new file mode 100644 index 0000000..6cbe5be --- /dev/null +++ b/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts @@ -0,0 +1,45 @@ +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, test } from "vite-plus/test"; +import { runNodeMain } from "e2e/runner"; +import { runKscli } from "./helpers.ts"; + +const kscliPaths = ["config show", "config set", "update", "retrieve", "search", "chat"] as const; + +const harnessMainTs = join( + dirname(fileURLToPath(import.meta.url)), + "../../../commands/tests/e2e/harness/main.ts", +); +const commandsPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../commands"); + +/** 提取 Flags + Global Flags 段,忽略 Notes/Examples 中的产品 bin 差异 */ +function extractFlagSections(text: string): string { + const start = text.indexOf("Flags:"); + if (start < 0) return text.trim(); + const notesIdx = text.indexOf("\nNotes:", start); + const examplesIdx = text.indexOf("\nExamples:", start); + const endCandidates = [notesIdx, examplesIdx].filter((idx) => idx >= 0); + const end = endCandidates.length > 0 ? Math.min(...endCandidates) : text.length; + return text.slice(start, end).trim(); +} + +describe("e2e: kscli registry smoke", () => { + test.each(kscliPaths)("已注册命令 %s --help 成功", async (path) => { + const { stderr, exitCode } = await runKscli([...path.split(" "), "--help"]); + expect(exitCode, stderr).toBe(0); + }); + + test("search --help flag 与 harness knowledge search --help 一致", async () => { + const [kscliHelp, harnessHelp] = await Promise.all([ + runKscli(["search", "--help"]), + runNodeMain(harnessMainTs, ["knowledge", "search", "--help"], { + cwd: commandsPackageRoot, + }), + ]); + + expect(kscliHelp.exitCode, kscliHelp.stderr).toBe(0); + expect(harnessHelp.exitCode, harnessHelp.stderr).toBe(0); + + expect(extractFlagSections(kscliHelp.stderr)).toBe(extractFlagSections(harnessHelp.stderr)); + }); +}); diff --git a/packages/kscli/tests/e2e/search.e2e.test.ts b/packages/kscli/tests/e2e/search.e2e.test.ts deleted file mode 100644 index 93f5b6f..0000000 --- a/packages/kscli/tests/e2e/search.e2e.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, test } from "vite-plus/test"; -import { isSearchE2EReady, parseStdoutJson, runKscli } from "./helpers.ts"; - -// ---- Types ---- - -interface SearchResponse { - code: string; - status_code: number; - request_id: string; - data: { - total: number; - cost_time: number; - nodes: Array<{ - score: number; - text: string; - metadata: { - content?: string; - title?: string; - doc_id?: string; - doc_name?: string; - doc_url?: string; - pipeline_id?: string; - workspace_id?: string; - page_number?: number; - image_url?: string; - _knowledge_type?: string; - _citation_index?: number; - _score?: number; - }; - }>; - }; -} - -// ---- Real API call tests (gated by BAILIAN_E2E + credentials) ---- - -describe.skipIf(!isSearchE2EReady())("e2e: kscli 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 runKscli([ - "search", - "--query", - "什么是大模型", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(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 runKscli([ - "search", - "--query", - "RAG", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "text", - ]); - - expect(exitCode, stderr).toBe(0); - // Text mode: [1] (score: 0.xxxx) followed by text content - expect(stdout).toMatch(/\[1\].*score/); - }); - - test("search with --query-history returns results", async () => { - const { stdout, stderr, exitCode } = await runKscli([ - "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(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 runKscli([ - "search", - "--query", - "test", - "--agent-id", - "aid-invalid-not-exist", - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode).not.toBe(0); - expect(stderr).toBeTruthy(); - }); -}); diff --git a/packages/kscli/vite.config.ts b/packages/kscli/vite.config.ts index 3bfbf90..f8bef24 100644 --- a/packages/kscli/vite.config.ts +++ b/packages/kscli/vite.config.ts @@ -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, }, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 50f989f..393827b 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -50,6 +50,8 @@ "@types/node": "catalog:", "@typescript/native-preview": "7.0.0-dev.20260328.1", "ajv": "catalog:", + "bailian-cli-commands": "workspace:*", + "e2e": "workspace:*", "typescript": "^6.0.2", "vite-plus": "0.1.22", "yaml": "catalog:" diff --git a/packages/runtime/tests/console-flags.e2e.test.ts b/packages/runtime/tests/console-flags.e2e.test.ts new file mode 100644 index 0000000..a03a3c3 --- /dev/null +++ b/packages/runtime/tests/console-flags.e2e.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vite-plus/test"; +import { runCommandE2e } from "../../commands/tests/e2e/helpers.ts"; + +/** + * 跨域 flag 拒绝:runtime 凭证域与命令 flag 解析边界。 + */ + +describe("e2e: console global flags (cross-domain rejection)", () => { + test("跨域 flag 拒绝:model 命令传 --console-region 报 Unknown flag", async () => { + const { stderr, exitCode } = await runCommandE2e([ + "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 runCommandE2e([ + "mcp", + "list", + "--api-key", + "sk-test", + "--dry-run", + ]); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/Unknown flag.*--api-key/); + }); +}); diff --git a/packages/cli/tests/e2e/proxy.e2e.test.ts b/packages/runtime/tests/proxy.e2e.test.ts similarity index 70% rename from packages/cli/tests/e2e/proxy.e2e.test.ts rename to packages/runtime/tests/proxy.e2e.test.ts index 80b7f0b..ce902dc 100644 --- a/packages/cli/tests/e2e/proxy.e2e.test.ts +++ b/packages/runtime/tests/proxy.e2e.test.ts @@ -3,37 +3,30 @@ import { createServer, type Server } from "http"; import { mkdtempSync, rmSync, writeFileSync } from "fs"; import type { AddressInfo } from "net"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; import { promisify } from "util"; import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; -import { cliPackageRoot } from "./helpers.ts"; const execFileAsync = promisify(execFile); +const runtimePackageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + /** - * 代理支持 E2E(issue #35):只验证 `setupProxyFromEnv()` 是否把代理 dispatcher - * 正确装到全局 fetch 上——设了 HTTPS_PROXY 后裸 `fetch()` 走代理,未设置时直连, - * NO_PROXY 命中时跳过,非法代理值给出明确报错。 - * - * 不经过任何 CLI 命令(不解析凭证、不打 gateway),因此 CI 上无需 api key / - * access token,与既有 e2e 设计一致。全程离线:目标域名用 `.invalid`(保留顶级域, - * 必然无法解析),代理收到 CONNECT 后规范返回 502,不产生真实外网请求。 + * 代理支持 E2E:验证 `setupProxyFromEnv()` 是否把代理 dispatcher 正确装到全局 fetch 上。 + * 不经过 CLI 命令,CI 无需 api key。 */ const FAKE_HOST = "bl-proxy-e2e.invalid"; const FAKE_URL = `https://${FAKE_HOST}/probe`; -/** - * 最小探针脚本:调用真实的 `setupProxyFromEnv()`,再对目标发一个普通 fetch。 - * 代理行为由进程环境变量决定,正是被测对象;fetch 成败不重要,我们只看代理是否收到 CONNECT。 - */ const PROBE_SCRIPT = ` -import { setupProxyFromEnv } from ${JSON.stringify(join(cliPackageRoot, "..", "runtime", "src", "proxy.ts"))}; +import { setupProxyFromEnv } from ${JSON.stringify(join(runtimePackageRoot, "src", "proxy.ts"))}; setupProxyFromEnv(); try { await fetch(${JSON.stringify(FAKE_URL)}, { signal: AbortSignal.timeout(5000) }); } catch { - // 目标不可达/隧道被拒都正常——本测试只关心代理是否收到 CONNECT + // 目标不可达/隧道被拒都正常 } `; @@ -45,7 +38,6 @@ const connectTargets: string[] = []; beforeAll(async () => { proxy = createServer(); - // 记录收到的 CONNECT 目标(host:port),并以 502 拒绝隧道 proxy.on("connect", (req, clientSocket) => { connectTargets.push(req.url ?? ""); clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); @@ -63,7 +55,6 @@ afterAll(async () => { rmSync(scriptDir, { recursive: true, force: true }); }); -/** 清空所有代理相关环境变量,确保每个用例只受自身设置影响 */ const PROXY_ENV_CLEARED = { HTTPS_PROXY: "", https_proxy: "", @@ -73,13 +64,12 @@ const PROXY_ENV_CLEARED = { no_proxy: "", }; -/** 以给定代理环境变量运行探针脚本,返回 { exitCode, stderr } */ async function runProbe( envOverrides: NodeJS.ProcessEnv, ): Promise<{ exitCode: number; stderr: string }> { try { await execFileAsync("node", [scriptPath], { - cwd: cliPackageRoot, + cwd: runtimePackageRoot, encoding: "utf8", env: { ...process.env, NODE_NO_WARNINGS: "1", ...PROXY_ENV_CLEARED, ...envOverrides }, }); diff --git a/packages/runtime/vite.config.ts b/packages/runtime/vite.config.ts index 1c26ed4..7325db8 100644 --- a/packages/runtime/vite.config.ts +++ b/packages/runtime/vite.config.ts @@ -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: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a316881..012b867 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: chalk: specifier: 'catalog:' version: 5.6.2 + e2e: + specifier: workspace:* + version: link:../e2e typescript: specifier: ^6.0.2 version: 6.0.3 @@ -107,6 +110,9 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20260328.1 version: 7.0.0-dev.20260328.1 + e2e: + specifier: workspace:* + version: link:../e2e typescript: specifier: ^6.0.2 version: 6.0.3 @@ -133,6 +139,22 @@ importers: specifier: 'catalog:' version: 0.1.22(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3) + packages/e2e: + dependencies: + bailian-cli-core: + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.12.2 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vite-plus: + specifier: 0.1.22 + version: 0.1.22(@types/node@24.12.2)(jiti@2.6.1)(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3))(yaml@2.8.3) + packages/kscli: dependencies: bailian-cli-commands: @@ -163,6 +185,9 @@ importers: chalk: specifier: 'catalog:' version: 5.6.2 + e2e: + specifier: workspace:* + version: link:../e2e typescript: specifier: ^6.0.2 version: 6.0.3 @@ -203,6 +228,12 @@ importers: ajv: specifier: 'catalog:' version: 8.20.0 + bailian-cli-commands: + specifier: workspace:* + version: link:../commands + e2e: + specifier: workspace:* + version: link:../e2e typescript: specifier: ^6.0.2 version: 6.0.3 diff --git a/tools/compare-e2e-command-map.ts b/tools/compare-e2e-command-map.ts new file mode 100644 index 0000000..0a501ae --- /dev/null +++ b/tools/compare-e2e-command-map.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * Advisory:对比 bl 产品 map 与 E2E harness map 是否指向同一 command 实例。 + * 不一致时打印 warning,不阻塞测试。 + */ +import { commands as cliCommands } from "../packages/cli/src/commands.ts"; +import { e2eCommandMap } from "../packages/commands/tests/e2e/harness/e2e-command-map.ts"; + +const cliPaths = new Set(Object.keys(cliCommands)); +const e2ePaths = new Set(Object.keys(e2eCommandMap)); + +let hasWarning = false; + +for (const path of cliPaths) { + if (!e2ePaths.has(path)) { + console.warn(`[advisory] cli map path missing in e2e map: ${path}`); + hasWarning = true; + } else if (cliCommands[path] !== e2eCommandMap[path]) { + console.warn(`[advisory] command instance mismatch for path: ${path}`); + hasWarning = true; + } +} + +for (const path of e2ePaths) { + if (!cliPaths.has(path)) { + console.warn(`[advisory] e2e map path missing in cli map: ${path}`); + hasWarning = true; + } +} + +if (!hasWarning) { + console.log("[advisory] cli commands map and e2e harness map are aligned."); +} + +process.exit(0); diff --git a/vite.config.ts b/vite.config.ts index e3173cd..36c4ad5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { - globalSetup: "./packages/cli/tests/e2e/global-setup.ts", + globalSetup: "./packages/e2e/src/global-setup.ts", testTimeout: 60_000, hookTimeout: 60_000, }, From 301986b669d110ac6efff070c8aefb737f49be95 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 10 Jul 2026 12:26:41 +0800 Subject: [PATCH 05/13] fix(ci): stop emitting and checking generated tests helpers.d.ts Runtime pack build was pulling in e2e test imports and tsgo emitted helpers.d.ts beside commands/tests; restrict runtime tsconfig to src and ignore tests/**/*.d.ts in fmt/gitignore as a safety net. --- .gitignore | 1 + packages/runtime/tsconfig.json | 3 ++- vite.config.ts | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f1c88cb..46ba8f8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ tools/generated # Test & scene outputs /test/ packages/cli/scene/**/outputs/ +**/tests/**/*.d.ts # Environment variables (sensitive data) .env diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json index ff4adab..e2bb481 100644 --- a/packages/runtime/tsconfig.json +++ b/packages/runtime/tsconfig.json @@ -16,5 +16,6 @@ "isolatedModules": true, "verbatimModuleSyntax": true, "skipLibCheck": true - } + }, + "include": ["src/**/*"] } diff --git a/vite.config.ts b/vite.config.ts index 36c4ad5..413af82 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,4 +30,7 @@ export default defineConfig({ run: { cache: true, }, + fmt: { + ignorePatterns: ["**/tests/**/*.d.ts"], + }, }); From 6e5ecf8923e64ee3a847d7f75670db3c188e8aed Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 10 Jul 2026 12:32:30 +0800 Subject: [PATCH 06/13] chore: drop redundant helpers.d.ts fmt/gitignore guards Runtime tsconfig already limits pack to src, so tests/**/*.d.ts is never emitted during build; the fmt ignore and gitignore entries are unnecessary. --- .gitignore | 1 - vite.config.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index 46ba8f8..f1c88cb 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,6 @@ tools/generated # Test & scene outputs /test/ packages/cli/scene/**/outputs/ -**/tests/**/*.d.ts # Environment variables (sensitive data) .env diff --git a/vite.config.ts b/vite.config.ts index 413af82..36c4ad5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,7 +30,4 @@ export default defineConfig({ run: { cache: true, }, - fmt: { - ignorePatterns: ["**/tests/**/*.d.ts"], - }, }); From 7b77bf6a6dde76ac4307b3e017d4ae27e3ae2f5c Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 10 Jul 2026 13:01:10 +0800 Subject: [PATCH 07/13] fix(e2e): run subprocess harness and proxy probe via tsx Node's strip-only TypeScript mode cannot execute workspace source that uses parameter properties. Use the monorepo tsx binary for runNodeMain and the proxy e2e probe (.mts for top-level await). --- packages/e2e/src/run-subprocess.ts | 13 +++++++++++-- packages/runtime/tests/proxy.e2e.test.ts | 5 +++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/e2e/src/run-subprocess.ts b/packages/e2e/src/run-subprocess.ts index 91b6626..6d41cd2 100644 --- a/packages/e2e/src/run-subprocess.ts +++ b/packages/e2e/src/run-subprocess.ts @@ -1,5 +1,7 @@ import { execFile } from "child_process"; +import { join } from "path"; import { promisify } from "util"; +import { monorepoRoot } from "./monorepo-root.ts"; const execFileAsync = promisify(execFile); @@ -9,14 +11,21 @@ export interface RunCliResult { exitCode: number; } -/** 子进程执行指定 main.ts 入口 */ +/** monorepo 根目录下的 tsx 可执行文件 */ +export function resolveTsxBin(): string { + const root = monorepoRoot(); + const name = process.platform === "win32" ? "tsx.cmd" : "tsx"; + return join(root, "node_modules", ".bin", name); +} + +/** 子进程通过 tsx 执行指定 main.ts 入口 */ export async function runNodeMain( mainTs: string, args: string[], options: { cwd: string; env?: NodeJS.ProcessEnv } = { cwd: process.cwd() }, ): Promise { try { - const { stdout, stderr } = await execFileAsync("node", [mainTs, ...args], { + const { stdout, stderr } = await execFileAsync(resolveTsxBin(), [mainTs, ...args], { cwd: options.cwd, encoding: "utf8", maxBuffer: 32 * 1024 * 1024, diff --git a/packages/runtime/tests/proxy.e2e.test.ts b/packages/runtime/tests/proxy.e2e.test.ts index ce902dc..dbd2ae3 100644 --- a/packages/runtime/tests/proxy.e2e.test.ts +++ b/packages/runtime/tests/proxy.e2e.test.ts @@ -7,6 +7,7 @@ import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { promisify } from "util"; import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; +import { resolveTsxBin } from "e2e/runner"; const execFileAsync = promisify(execFile); @@ -46,7 +47,7 @@ beforeAll(async () => { proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`; scriptDir = mkdtempSync(join(tmpdir(), "bl-proxy-e2e-")); - scriptPath = join(scriptDir, "probe.ts"); + scriptPath = join(scriptDir, "probe.mts"); writeFileSync(scriptPath, PROBE_SCRIPT); }); @@ -68,7 +69,7 @@ async function runProbe( envOverrides: NodeJS.ProcessEnv, ): Promise<{ exitCode: number; stderr: string }> { try { - await execFileAsync("node", [scriptPath], { + await execFileAsync(resolveTsxBin(), [scriptPath], { cwd: runtimePackageRoot, encoding: "utf8", env: { ...process.env, NODE_NO_WARNINGS: "1", ...PROXY_ENV_CLEARED, ...envOverrides }, From 1870500f97f375b45405adda774c66a42367b66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Fri, 10 Jul 2026 13:50:11 +0800 Subject: [PATCH 08/13] feat: code review fixture --- README.md | 6 +- README.zh.md | 6 +- packages/cli/README.md | 6 +- packages/cli/README.zh.md | 6 +- packages/cli/tests/e2e/deploy.e2e.test.ts | 17 ++++-- .../commands/src/commands/deploy/create.ts | 60 ++++++++++--------- .../commands/src/commands/deploy/models.ts | 6 +- .../commands/src/commands/finetune/create.ts | 57 +++++++++++++++--- .../commands/src/commands/finetune/export.ts | 10 ++-- .../commands/src/commands/finetune/get.ts | 2 +- .../commands/src/commands/finetune/list.ts | 4 +- .../core/src/dataset/validate/schemas/tts.ts | 36 +++++++---- packages/core/src/deploy/constants.ts | 17 ++++++ packages/core/src/deploy/plans.ts | 6 +- packages/core/src/finetune/api.ts | 2 +- skills/bailian-cli/reference/deploy.md | 50 +++++++++------- skills/bailian-cli/reference/finetune.md | 47 ++++++++------- 17 files changed, 219 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 020ae34..68ff687 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh.md b/README.zh.md index 90fa079..eaa27e4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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 diff --git a/packages/cli/README.md b/packages/cli/README.md index 020ae34..68ff687 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index 90fa079..eaa27e4 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -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 diff --git a/packages/cli/tests/e2e/deploy.e2e.test.ts b/packages/cli/tests/e2e/deploy.e2e.test.ts index 0253eab..3dc5c55 100644 --- a/packages/cli/tests/e2e/deploy.e2e.test.ts +++ b/packages/cli/tests/e2e/deploy.e2e.test.ts @@ -87,9 +87,11 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { expect(data.body.capacity).toBe(2); }); - test("deploy audio create --dry-run 构造 lora 部署请求体", async () => { - // deploy create does not inspect modality; the split only changes the path. - // The audio subcommand shares the full flag surface and run logic. + 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 runCli([ "deploy", "audio", @@ -103,10 +105,15 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { "json", ]); expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ action: string; body: { plan: string; name: string } }>(stdout); + 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("lora"); + 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 () => { diff --git a/packages/commands/src/commands/deploy/create.ts b/packages/commands/src/commands/deploy/create.ts index b595573..60e1857 100644 --- a/packages/commands/src/commands/deploy/create.ts +++ b/packages/commands/src/commands/deploy/create.ts @@ -4,7 +4,8 @@ import { createDeployment, pickPlanStrategy, STRATEGIES, - DEFAULT_DEPLOY_PLAN, + defaultDeployPlan, + type DeployModality, type CreateDeploymentRequest, type CreatePlanFlags, type CommandContext, @@ -66,7 +67,8 @@ const CREATE_USAGE = "--model --name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]"; const CREATE_NOTES = [ - "Plan defaults to `lora` (Token-billed). Pass --plan to override.", + "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).", @@ -77,22 +79,23 @@ const CREATE_NOTES = [ "After creation, status starts at PENDING and transitions to RUNNING.", "Invoke the deployed model with: bl text chat --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.", + "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.", ]; /** * Shared `deploy create` flag validation. Plan support is - * server-catalog-driven, not modality-scoped, so validation is identical for - * every modality: reject an unknown --plan, then defer to the plan strategy's - * required-flag check. + * 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. */ -function validateCreate(flags: CreatePlanFlags): string | undefined { - const plan = flags.plan || DEFAULT_DEPLOY_PLAN; +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(", ")}.`; @@ -102,20 +105,23 @@ function validateCreate(flags: CreatePlanFlags): string | undefined { /** * Shared `deploy create` implementation. deploy create takes a model - * by name and a billing plan — it does NOT inspect data modality, so the run - * logic is identical across text / audio / image. The modality split only - * changes the command path, description and examples; the parameter surface and - * run logic are unchanged. + * 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(ctx: CommandContext): Promise { +async function runCreate( + modality: DeployModality, + ctx: CommandContext, +): Promise { const { identity, settings, flags } = ctx; const model = flags.model as string; const name = flags.name as string; - const plan = (flags.plan as string | undefined) || DEFAULT_DEPLOY_PLAN; + 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 @@ -175,11 +181,11 @@ export const deployTextCreate = defineCommand({ "--model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2", ], notes: CREATE_NOTES, - validate: (flags) => validateCreate(flags), - run: (ctx) => runCreate(ctx), + validate: (flags) => validateCreate("text", flags), + run: (ctx) => runCreate("text", ctx), }); -/** `bl deploy audio create` — deploy an audio (TTS) model. */ +/** `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", @@ -187,12 +193,12 @@ export const deployAudioCreate = defineCommand({ flags: CREATE_FLAGS, exampleArgs: [ "--model my-cosyvoice-ft --name my-tts", - "--model my-cosyvoice-ft --name my-tts-mu --plan mu", + "--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(flags), - run: (ctx) => runCreate(ctx), + validate: (flags) => validateCreate("audio", flags), + run: (ctx) => runCreate("audio", ctx), }); /** `bl deploy image create` — deploy an image generation model. */ @@ -207,6 +213,6 @@ export const deployImageCreate = defineCommand({ "--model my-wan-ft --name my-wan --dry-run", ], notes: CREATE_NOTES, - validate: (flags) => validateCreate(flags), - run: (ctx) => runCreate(ctx), + validate: (flags) => validateCreate("image", flags), + run: (ctx) => runCreate("image", ctx), }); diff --git a/packages/commands/src/commands/deploy/models.ts b/packages/commands/src/commands/deploy/models.ts index f6ad233..6bc8daa 100644 --- a/packages/commands/src/commands/deploy/models.ts +++ b/packages/commands/src/commands/deploy/models.ts @@ -73,8 +73,8 @@ 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 create --deploy-spec <…>` + // without a second round-trip. For text: keep the compact one-line summary. if (format === "json") { const items = models.map((model) => { const out: Record = { @@ -92,7 +92,7 @@ export default defineCommand({ planEntry.cu_specs = plan.cu_specs; } if (plan.templates && plan.templates.length > 0) { - // Pull the top 6 fields most useful for `bl deploy create`. + // Pull the top 6 fields most useful for `bl deploy create`. // Drop noisy/redundant: template_source, template_type, // template_version, deploy_spec (typically == template_id). planEntry.templates = plan.templates.map((template) => { diff --git a/packages/commands/src/commands/finetune/create.ts b/packages/commands/src/commands/finetune/create.ts index c9fcf6a..8e72375 100644 --- a/packages/commands/src/commands/finetune/create.ts +++ b/packages/commands/src/commands/finetune/create.ts @@ -295,10 +295,20 @@ const AUDIO_FLAGS = { /** * 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 knob exposed. + * 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: "", + 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: "", @@ -313,7 +323,7 @@ const AUDIO_USAGE = "--model --datasets [--validations ] [--model-name ] [--suffix ]"; const IMAGE_USAGE = - "--model --datasets [--validations ] [--model-name ] [--suffix ] [--learning-rate ]"; + "--model --datasets [--validations ] [--model-name ] [--suffix ] [--generation-type ] [--learning-rate ]"; const COMMON_NOTES = [ "Creating a job uploads any local datasets and consumes training quota.", @@ -349,8 +359,10 @@ const AUDIO_NOTES = [ 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 auto-detected from the", - "data (records with input_img train I2I), which sets max_pixels accordingly.", + "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.", ]; /** @@ -374,6 +386,24 @@ async function runCreate( 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 @@ -392,16 +422,24 @@ async function runCreate( 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 probes a - // local file to upgrade T2I → I2I; a bare file-id stays "image". + // 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" && firstLocalPath && !settings.dryRun) { - const detected = await detectModality(firstLocalPath); - if (detected === "image-i2i") modality = "image-i2i"; + 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( @@ -647,6 +685,7 @@ export const finetuneImageCreate = defineCommand({ 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", diff --git a/packages/commands/src/commands/finetune/export.ts b/packages/commands/src/commands/finetune/export.ts index 4131fe6..7f3857e 100644 --- a/packages/commands/src/commands/finetune/export.ts +++ b/packages/commands/src/commands/finetune/export.ts @@ -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 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 `); + emitBare( + `Next: ${identity.binName} deploy text create --model ${exported} --name `, + ); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/get.ts b/packages/commands/src/commands/finetune/get.ts index 3bb2d6c..d661c79 100644 --- a/packages/commands/src/commands/finetune/get.ts +++ b/packages/commands/src/commands/finetune/get.ts @@ -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}`); diff --git a/packages/commands/src/commands/finetune/list.ts b/packages/commands/src/commands/finetune/list.ts index b42cf4f..d8d822d 100644 --- a/packages/commands/src/commands/finetune/list.ts +++ b/packages/commands/src/commands/finetune/list.ts @@ -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\``, + ); }, }); diff --git a/packages/core/src/dataset/validate/schemas/tts.ts b/packages/core/src/dataset/validate/schemas/tts.ts index 8e7948a..5e6ab6f 100644 --- a/packages/core/src/dataset/validate/schemas/tts.ts +++ b/packages/core/src/dataset/validate/schemas/tts.ts @@ -14,9 +14,6 @@ import { makeIssue } from "../common.ts"; import type { ValidationIssue } from "../types.ts"; import type { RecordSchemaSpec } from "./types.ts"; -/** Expected audio file extensions (lower-case, with dot). */ -const AUDIO_EXTENSIONS = new Set([".wav", ".mp3", ".flac", ".ogg", ".m4a"]); - function inspectTTSRecord(record: Record, lineNo: number): ValidationIssue[] { const out: ValidationIssue[] = []; @@ -45,16 +42,33 @@ function inspectTTSRecord(record: Record, lineNo: number): Vali }), ); } else { - // Check that the path looks like it references an audio file. - const dotIndex = wavFn.lastIndexOf("."); - const ext = dotIndex >= 0 ? wavFn.slice(dotIndex).toLowerCase() : ""; - if (!AUDIO_EXTENSIONS.has(ext)) { + // 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( - "warning", - "UNUSUAL_AUDIO_EXT", - `"wav_fn" points to a non-standard audio extension "${ext || "(none)"}". ` + - `Expected one of: ${[...AUDIO_EXTENSIONS].join(", ")}.`, + "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" }, ), ); diff --git a/packages/core/src/deploy/constants.ts b/packages/core/src/deploy/constants.ts index 748645f..e918145 100644 --- a/packages/core/src/deploy/constants.ts +++ b/packages/core/src/deploy/constants.ts @@ -19,6 +19,23 @@ 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). */ diff --git a/packages/core/src/deploy/plans.ts b/packages/core/src/deploy/plans.ts index 8961308..ab9835c 100644 --- a/packages/core/src/deploy/plans.ts +++ b/packages/core/src/deploy/plans.ts @@ -1,5 +1,5 @@ /** - * Per-plan strategy table for `deploy create`. + * Per-plan strategy table for `deploy create`. * * Each PlanStrategy owns one slice of plan-specific behaviour: * - required-flag checks (returned as validate-style error strings) @@ -7,7 +7,7 @@ * catalog; lora/ptu are pure) * - the plan-specific body fragment for POST /api/v1/deployments * - * The dispatcher in the `deploy create` command only knows about + * The dispatcher in the `deploy 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 / @@ -20,7 +20,7 @@ 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 create` flags (parsed flags satisfy this shape). */ export interface CreatePlanFlags { plan?: string; deploySpec?: string; diff --git a/packages/core/src/finetune/api.ts b/packages/core/src/finetune/api.ts index bba820a..1126c9d 100644 --- a/packages/core/src/finetune/api.ts +++ b/packages/core/src/finetune/api.ts @@ -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 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( diff --git a/skills/bailian-cli/reference/deploy.md b/skills/bailian-cli/reference/deploy.md index 1ad7bcd..a33d2c5 100644 --- a/skills/bailian-cli/reference/deploy.md +++ b/skills/bailian-cli/reference/deploy.md @@ -47,7 +47,8 @@ Index: [index.md](index.md) #### Notes -- Plan defaults to `lora` (Token-billed). Pass --plan to override. +- 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). @@ -58,12 +59,13 @@ Index: [index.md](index.md) - After creation, status starts at PENDING and transitions to RUNNING. - Invoke the deployed model with: bl text chat --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. +- 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. #### Examples @@ -72,7 +74,7 @@ bl deploy audio create --model my-cosyvoice-ft --name my-tts ``` ```bash -bl deploy audio create --model my-cosyvoice-ft --name my-tts-mu --plan mu +bl deploy audio create --model my-cosyvoice-ft --name my-tts --deploy-spec dps-xxxx --capacity 1 ``` ```bash @@ -158,7 +160,8 @@ bl deploy get --deployed-model qwen-plus-2025-12-01-b6d61c71 --output json #### Notes -- Plan defaults to `lora` (Token-billed). Pass --plan to override. +- 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). @@ -169,12 +172,13 @@ bl deploy get --deployed-model qwen-plus-2025-12-01-b6d61c71 --output json - After creation, status starts at PENDING and transitions to RUNNING. - Invoke the deployed model with: bl text chat --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. +- 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. #### Examples @@ -314,7 +318,8 @@ bl deploy scale --deployed-model dep-... --capacity 2 #### Notes -- Plan defaults to `lora` (Token-billed). Pass --plan to override. +- 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). @@ -325,12 +330,13 @@ bl deploy scale --deployed-model dep-... --capacity 2 - After creation, status starts at PENDING and transitions to RUNNING. - Invoke the deployed model with: bl text chat --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. +- 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. #### Examples diff --git a/skills/bailian-cli/reference/finetune.md b/skills/bailian-cli/reference/finetune.md index 60e3c2d..d269dfc 100644 --- a/skills/bailian-cli/reference/finetune.md +++ b/skills/bailian-cli/reference/finetune.md @@ -230,9 +230,9 @@ bl finetune delete --job-id ft-xxx --dry-run #### 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 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. #### Examples @@ -268,24 +268,25 @@ bl finetune get --job-id ft-xxx --output json ### `bl finetune image create` -| Field | Value | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Name** | `finetune image create` | -| **Description** | Create an image generation model fine-tune job (sft-lora) | -| **Usage** | `bl finetune image create --model --datasets [--validations ] [--model-name ] [--suffix ] [--learning-rate ]` | +| Field | Value | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Name** | `finetune image create` | +| **Description** | Create an image generation model fine-tune job (sft-lora) | +| **Usage** | `bl finetune image create --model --datasets [--validations ] [--model-name ] [--suffix ] [--generation-type ] [--learning-rate ]` | #### Flags -| Flag | Type | Required | Description | -| ---------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--model ` | string | yes | Base model to fine-tune | -| `--datasets ` | string | yes | 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. | -| `--validations ` | string | no | Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets). | -| `--model-name ` | string | no | Output model name (after training) | -| `--suffix ` | string | no | Output suffix appended by the platform (finetuned_output_suffix) | -| `--learning-rate ` | string | no | Learning rate as a string to preserve precision (e.g. "3e-5") | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--model ` | string | yes | Base model to fine-tune | +| `--datasets ` | string | yes | 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. | +| `--validations ` | string | no | Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets). | +| `--model-name ` | string | no | Output model name (after training) | +| `--suffix ` | string | no | Output suffix appended by the platform (finetuned_output_suffix) | +| `--generation-type ` | string | no | 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). | +| `--learning-rate ` | string | no | Learning rate as a string to preserve precision (e.g. "3e-5") | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Notes @@ -295,8 +296,10 @@ bl finetune get --job-id ft-xxx --output json - or local paths. Local paths are validated and uploaded first, then their - file-ids are submitted — a one-step upload-and-train. - Image generation training runs sft-lora (efficient_sft) with fixed defaults; -- only --learning-rate is overridable. T2I vs I2I is auto-detected from the -- data (records with input_img train I2I), which sets max_pixels accordingly. +- 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. #### Examples @@ -308,6 +311,10 @@ bl finetune image create --model wan2.7-image-pro --datasets ./images.zip bl finetune image create --model wan2.7-image-pro --datasets file-xxx ``` +```bash +bl finetune image create --model wan2.7-image-pro --datasets file-xxx --generation-type i2i +``` + ```bash bl finetune image create --model wan2.7-image-pro --datasets ./images.zip --model-name my-wan ``` From 91a7106bf42cd38dbb5590aa1250150f842c1c12 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 10 Jul 2026 14:04:21 +0800 Subject: [PATCH 09/13] fix(e2e): update CLI version to match package.json --- packages/commands/tests/e2e/harness/main.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/commands/tests/e2e/harness/main.ts b/packages/commands/tests/e2e/harness/main.ts index 01def24..0b8941e 100644 --- a/packages/commands/tests/e2e/harness/main.ts +++ b/packages/commands/tests/e2e/harness/main.ts @@ -1,9 +1,10 @@ import { createCli } from "bailian-cli-runtime"; +import pkg from "../../../../cli/package.json" with { type: "json" }; import { e2eCommandMap } from "./e2e-command-map.ts"; void createCli(e2eCommandMap, { binName: "bl", - version: "0.0.0-e2e", + version: pkg.version, clientName: "commands-e2e", npmPackage: "bailian-cli-commands", }).run(); From d646a4e7805bd0f7f351ad7cca7c4088437f91f2 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 10 Jul 2026 15:55:53 +0800 Subject: [PATCH 10/13] refactor(e2e): update test routes and streamline command handling - Replaced hardcoded command paths with dynamic route mappings in E2E tests. - Enhanced documentation for command testing, specifying minimum route requirements. - Consolidated command path management into `topic-routes.ts` for better maintainability. - Updated various E2E test cases to utilize the new routing structure, ensuring consistency across tests. - Removed redundant helper functions and improved test clarity by directly referencing route exports. --- docs/agents/cli-e2e-tests.md | 55 ++--- docs/agents/command-add-remove.md | 12 +- .../cli/tests/e2e/registry.smoke.e2e.test.ts | 17 +- .../tests/e2e/advisor-recommend.e2e.test.ts | 31 +-- packages/commands/tests/e2e/auth.e2e.test.ts | 44 ++-- .../commands/tests/e2e/config.e2e.test.ts | 32 +-- .../e2e/console-flags-dry-run.e2e.test.ts | 23 +- .../commands/tests/e2e/dataset.e2e.test.ts | 36 ++- .../commands/tests/e2e/deploy.e2e.test.ts | 22 +- .../tests/e2e/file-upload.e2e.test.ts | 25 +- .../commands/tests/e2e/finetune.e2e.test.ts | 34 ++- .../harness/e2e-command-map.contract.test.ts | 71 ------ .../tests/e2e/harness/e2e-command-map.ts | 233 ------------------ .../commands/tests/e2e/harness/identity.ts | 13 + packages/commands/tests/e2e/harness/main.ts | 35 ++- packages/commands/tests/e2e/helpers.ts | 18 +- .../commands/tests/e2e/image-edit.e2e.test.ts | 30 ++- .../tests/e2e/image-generate.e2e.test.ts | 14 +- .../tests/e2e/knowledge-chat.e2e.test.ts | 37 ++- .../tests/e2e/knowledge-search.e2e.test.ts | 33 ++- .../commands/tests/e2e/knowledge.e2e.test.ts | 28 ++- packages/commands/tests/e2e/mcp.e2e.test.ts | 41 ++- .../commands/tests/e2e/memory.e2e.test.ts | 37 ++- packages/commands/tests/e2e/omni.e2e.test.ts | 22 +- .../commands/tests/e2e/pipeline.e2e.test.ts | 42 ++-- packages/commands/tests/e2e/quota.e2e.test.ts | 52 ++-- .../commands/tests/e2e/search-web.e2e.test.ts | 25 +- .../tests/e2e/speech-list-voices.e2e.test.ts | 28 ++- .../tests/e2e/speech-recognize.e2e.test.ts | 23 +- .../tests/e2e/speech-synthesize.e2e.test.ts | 19 +- .../commands/tests/e2e/text-chat.e2e.test.ts | 20 +- packages/commands/tests/e2e/topic-routes.ts | 129 ++++++++++ .../commands/tests/e2e/usage-free.e2e.test.ts | 42 ++-- .../tests/e2e/usage-stats.e2e.test.ts | 33 +-- .../tests/e2e/video-download.e2e.test.ts | 19 +- .../commands/tests/e2e/video-edit.e2e.test.ts | 17 +- .../tests/e2e/video-generate-i2v.e2e.test.ts | 17 +- .../tests/e2e/video-generate-t2v.e2e.test.ts | 15 +- .../tests/e2e/video-ref-r2v.e2e.test.ts | 19 +- .../tests/e2e/video-task-get.e2e.test.ts | 20 +- packages/e2e/package.json | 3 +- packages/e2e/src/registry-smoke.ts | 18 ++ packages/kscli/src/commands.ts | 23 ++ packages/kscli/src/main.ts | 24 +- .../tests/e2e/registry.smoke.e2e.test.ts | 73 +++--- .../runtime/tests/console-flags.e2e.test.ts | 18 +- .../tests/harness/cross-domain-main.ts | 33 +++ tools/compare-e2e-command-map.ts | 35 --- 48 files changed, 799 insertions(+), 891 deletions(-) delete mode 100644 packages/commands/tests/e2e/harness/e2e-command-map.contract.test.ts delete mode 100644 packages/commands/tests/e2e/harness/e2e-command-map.ts create mode 100644 packages/commands/tests/e2e/harness/identity.ts create mode 100644 packages/commands/tests/e2e/topic-routes.ts create mode 100644 packages/e2e/src/registry-smoke.ts create mode 100644 packages/kscli/src/commands.ts create mode 100644 packages/runtime/tests/harness/cross-domain-main.ts delete mode 100644 tools/compare-e2e-command-map.ts diff --git a/docs/agents/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md index 3a07f20..749329c 100644 --- a/docs/agents/cli-e2e-tests.md +++ b/docs/agents/cli-e2e-tests.md @@ -2,13 +2,13 @@ ## 架构分层 -| 层级 | 路径 | 测什么 | -| --------------- | ----------------------------------------------------- | --------------------------------------------------------------- | -| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup(`private`,不发布) | -| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、live(gated);harness `binName: "bl"` | -| **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` | 6 条 flat path `--help`;`search --help` 与 harness 一致 | -| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 | +| 层级 | 路径 | 测什么 | +| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| **共享基建** | `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 路径除外)。 @@ -17,18 +17,20 @@ - 新增/修改 `packages/commands/src/commands` 下的 command 实现 - 新增/修改 `packages/cli/src/commands.ts` 的 `bl` 命令路径 map - 新建或扩展 `packages/commands/tests/e2e/.e2e.test.ts` -- 新增 bl 产品 path → 同步 `registry.smoke`(自动覆盖 leaf path)与 `harness/e2e-command-map.ts` +- 新增 bl 产品 path → `registry.smoke` 自动覆盖 leaf path;commands topic 测试在 `topic-routes.ts` 补最小路由 跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。 +> **规则**:共享 command 行为在 `commands/tests/e2e`;产品 map、identity、CLI-only 命令留在对应产品 `tests/e2e`。 + ## 文件与工具 ### commands E2E - 路径:`packages/commands/tests/e2e/.e2e.test.ts` -- 子进程:`runCommandE2e` from `./helpers.ts`(spawn `harness/main.ts`) +- 子进程:`runCommandE2e(routes, args)` from `./helpers.ts`(spawn `harness/main.ts`,`routes` 为本 topic 最小 path → export 映射) - fixtures:`packages/commands/tests/e2e/fixtures/` -- map:`harness/e2e-command-map.ts`(自维护,初始从 `cli/commands.ts` 复制) +- 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map) ### 产品 smoke @@ -45,13 +47,12 @@ ## 双层 describe(固定结构) ```ts -// 1) 不 skip:分组 + --help,无密钥、无真实 API +// 1) 不 skip:--help,无密钥、无真实 API(分组 help 由 bl registry.smoke 覆盖) describe("e2e: ", () => { - test(" 分组展示子命令帮助且成功退出", ...); test(" --help 正常退出", ...); }); -// 2) skipIf:缺参 / dry-run / 真实集成;原有集成用例放最后、勿改逻辑 +// 2) skipIf:缺参 / dry-run / 真实集成 describe.skipIf()("e2e: (DashScope …)", () => { test("缺少 -- 时退出为用法错误 (2)", ...); test(" --dry-run ...", ...); // 若适用 @@ -71,18 +72,15 @@ describe.skipIf()("e2e: (DashScope …)", () => { ## 用例类型 -1. **分组 help**:`runCommandE2e(["image"])` → `exitCode === 0`,stdout+stderr 含子命令名 -2. **--help**:`runCommandE2e([..., "--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 块**末尾** -## 契约与防漂移 +## 增删命令同步 -- `harness/e2e-command-map.contract.test.ts`:path 唯一、合法 export、白名单、测试 argv 前缀在 map 中 -- Advisory(不阻塞 CI):`node tools/compare-e2e-command-map.ts` 对比 `cli/commands.ts` 与 harness map - -增删命令须同步三处:**实现 export** + **`cli/commands.ts`** + **`e2e-command-map.ts`**(见 [command-add-remove.md](command-add-remove.md))。 +- **commands export** + **topic 路由**(`topic-routes.ts` 或测试文件内 `ROUTES`)+ **产品 map**(`cli/commands.ts` / `kscli/commands.ts`) +- 分组 help 由产品 `registry.smoke` 负责,无需在 commands 重复 ## 安全与例外 @@ -93,10 +91,10 @@ describe.skipIf()("e2e: (DashScope …)", () => { ## 新增 command 检查清单 -- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `harness/e2e-command-map.ts` 登记 +- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `topic-routes.ts` 补最小路由 - [ ] `packages/commands/tests/e2e/.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);不破坏已有集成用例顺序 - [ ] `vp test packages/commands/tests/e2e/` 通过 @@ -108,20 +106,21 @@ pnpm --filter bailian-cli-commands exec vp test packages/commands/tests/e2e/text 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 -node tools/compare-e2e-command-map.ts ``` ## 示例片段 ```ts +import { FOO_ROUTES } from "./topic-routes.ts"; + test("foo bar 缺少 --prompt 时退出为用法错误 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FOO_ROUTES, [ "foo", "bar", "--dry-run", diff --git a/docs/agents/command-add-remove.md b/docs/agents/command-add-remove.md index 4bd189a..fb333c1 100644 --- a/docs/agents/command-add-remove.md +++ b/docs/agents/command-add-remove.md @@ -93,17 +93,16 @@ packages/commands/src/index.ts ### D. 测试层 - [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/commands/tests/e2e/.e2e.test.ts` -- [ ] 同步 `packages/commands/tests/e2e/harness/e2e-command-map.ts` 与 `E2E_COMMAND_EXPORTS` -- [ ] `packages/cli/src/commands.ts` 增删 path 后跑 `node tools/compare-e2e-command-map.ts`(advisory)确认与 harness 一致 -- [ ] bl 产品 path 变更由 `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` 覆盖;kscli 变更同步 `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` -- [ ] 删除命令时一并删对应 commands e2e / README 示例 / reference 生成结果 / harness map 条目 +- [ ] 同步 `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 ` 前缀 @@ -114,8 +113,7 @@ packages/commands/src/index.ts pnpm run sync:skill-assets pnpm -F bailian-cli exec tsx src/main.ts --help pnpm -F bailian-cli exec tsx src/main.ts -vp test packages/cli/tests/e2e/.e2e.test.ts -node tools/compare-e2e-command-map.ts +vp test packages/commands/tests/e2e/.e2e.test.ts ``` 如改了 `kscli` 入口: diff --git a/packages/cli/tests/e2e/registry.smoke.e2e.test.ts b/packages/cli/tests/e2e/registry.smoke.e2e.test.ts index 8d48c26..abcb2b0 100644 --- a/packages/cli/tests/e2e/registry.smoke.e2e.test.ts +++ b/packages/cli/tests/e2e/registry.smoke.e2e.test.ts @@ -1,23 +1,8 @@ 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"; -/** 从命令 map 推导有子命令的分组路径 */ -function deriveGroupPaths(commandPaths: string[]): string[] { - const groups = new Set(); - for (const path of commandPaths) { - const parts = path.split(" "); - for (let i = 1; i < parts.length; i++) { - const prefix = parts.slice(0, i).join(" "); - const hasChildren = commandPaths.some( - (candidate) => candidate.startsWith(`${prefix} `) && candidate !== prefix, - ); - if (hasChildren) groups.add(prefix); - } - } - return [...groups].sort(); -} - const commandPaths = Object.keys(commands).sort(); const groupPaths = deriveGroupPaths(commandPaths); diff --git a/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts index 5f01005..ec2fb2b 100644 --- a/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts +++ b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts @@ -1,15 +1,14 @@ import { describe, expect, test } from "vite-plus/test"; 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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--message", @@ -96,7 +99,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () // ---- Model preference: positive cases ---- test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--dry-run", @@ -116,7 +119,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () }, 60_000); test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--dry-run", @@ -136,7 +139,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () }, 60_000); test("excludes preference — intent detects modelPreference when excluding models", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--dry-run", @@ -166,7 +169,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () // ---- Model preference: negative cases ---- test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--dry-run", diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 8cc340b..a4f5425 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -1,45 +1,39 @@ import { describe, expect, test } from "vite-plus/test"; import { isDashScopeE2EReady, 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 runCommandE2e(["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 runCommandE2e(["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/); }); test("auth logout --help 正常退出", async () => { - const { stderr, exitCode } = await runCommandE2e(["auth", "logout", "--help"]); + 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", "status", "--help"]); + 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", "login", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "login", "--quiet"]); expect(exitCode, stderr).toBe(2); expect(stderr).toMatch(/--api-key|Usage:/i); }); test("auth login --dry-run --api-key 不发起校验与落盘", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "login", "--dry-run", @@ -51,7 +45,7 @@ describe("e2e: auth", () => { }); test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "login", "--dry-run", @@ -67,7 +61,12 @@ describe("e2e: auth", () => { }); test("auth login 缺少密钥且 --output json 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e(["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); @@ -75,14 +74,18 @@ describe("e2e: auth", () => { }); test("auth logout --dry-run 不写入配置", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(["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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "logout", "--dry-run", @@ -93,7 +96,7 @@ describe("e2e: auth", () => { }); test("auth logout --dry-run --output json(不清除密钥)", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "logout", "--dry-run", @@ -106,7 +109,7 @@ describe("e2e: auth", () => { }); test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "status", "--output", @@ -119,7 +122,7 @@ describe("e2e: auth", () => { }); test.skipIf(!isDashScopeE2EReady())("auth status --output json", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "status", "--output", @@ -138,6 +141,7 @@ describe("e2e: auth", () => { "auth status --output json --quiet(base_url 经 env 指定;凭证域 flag 对 status 不可见)", async () => { const { stdout, stderr, exitCode } = await runCommandE2e( + AUTH_ROUTES, ["auth", "status", "--output", "json", "--quiet"], { DASHSCOPE_BASE_URL: "https://dashscope.aliyuncs.com" }, ); @@ -149,7 +153,7 @@ describe("e2e: auth", () => { ); test("auth status 不接受凭证域覆盖 flag(--base-url 报 Unknown flag)", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "status", "--base-url", diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index 1b6b1af..66c7122 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -1,32 +1,26 @@ import { describe, expect, test } from "vite-plus/test"; import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { CONFIG_ROUTES } from "./topic-routes.ts"; /** * Config 相关 E2E */ describe("e2e: config", () => { - test("config 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "show", "--output", @@ -44,7 +38,7 @@ describe("e2e: config", () => { }); test("config show --output text", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "show", "--output", @@ -55,13 +49,13 @@ describe("e2e: config", () => { }); test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--key", @@ -74,7 +68,7 @@ describe("e2e: config", () => { }); test("config set 非法 output", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--key", @@ -87,7 +81,7 @@ describe("e2e: config", () => { }); test("config set 非法 timeout", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--key", @@ -100,7 +94,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 不落盘(仅输出 would_set)", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -117,7 +111,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 支持连字符别名 key", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -134,7 +128,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 支持 AccessKey 短字段别名", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -151,7 +145,7 @@ describe("e2e: config", () => { }); test("config set 不接受旧 OpenAPI AccessKey 字段名", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--key", diff --git a/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts index f0d1c8f..4fafea5 100644 --- a/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts +++ b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { CONSOLE_FLAGS_DRY_RUN_ROUTES } from "./topic-routes.ts"; type ConsoleDryRunMeta = { consoleRegion?: string; @@ -9,7 +10,11 @@ type ConsoleDryRunMeta = { describe("e2e: console global flags (dry-run)", () => { test("console call --help 不暴露命令级 region/site,示例使用 --console-region", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 /); expect(stderr).toMatch(/--data /); @@ -19,7 +24,11 @@ describe("e2e: console global flags (dry-run)", () => { }); test("auth login --help:自有 flag 含 --console-site,不含其余 console 域 flag", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 /); expect(stderr).toMatch(/--base-url /); @@ -29,7 +38,7 @@ describe("e2e: console global flags (dry-run)", () => { }); test("console call --dry-run 默认 consoleRegion 为 cn-beijing", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "console", "call", "--api", @@ -47,7 +56,7 @@ describe("e2e: console global flags (dry-run)", () => { }); test("console call --dry-run --console-region / --console-site / --console-switch-agent 透传", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "console", "call", "--api", @@ -72,7 +81,7 @@ describe("e2e: console global flags (dry-run)", () => { }); test("console call 拒绝未知全局 flag --region", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "console", "call", "--api", @@ -88,7 +97,7 @@ describe("e2e: console global flags (dry-run)", () => { }); test("mcp list --dry-run --console-region 透传", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "mcp", "list", "--dry-run", @@ -103,7 +112,7 @@ describe("e2e: console global flags (dry-run)", () => { }); test("quota check --dry-run --console-region 透传", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "quota", "check", "--dry-run", diff --git a/packages/commands/tests/e2e/dataset.e2e.test.ts b/packages/commands/tests/e2e/dataset.e2e.test.ts index 6dd19e2..7b68822 100644 --- a/packages/commands/tests/e2e/dataset.e2e.test.ts +++ b/packages/commands/tests/e2e/dataset.e2e.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vite-plus/test"; import { join } from "path"; import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e, e2eFixturesDir } from "./helpers.ts"; +import { DATASET_ROUTES } from "./topic-routes.ts"; /** * Dataset (fine-tune file) E2E. @@ -16,22 +17,19 @@ import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e, e2eFixturesDir } f */ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { - test("dataset --help 列出子命令", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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(e2eFixturesDir, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -47,7 +45,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset validate 拒绝 pretty-printed JSON 并以非零码退出", async () => { const file = join(e2eFixturesDir, ".dataset-invalid.jsonl"); - const { stdout, exitCode } = await runCommandE2e([ + const { stdout, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -66,7 +64,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset upload --no-validate --dry-run 跳过本地校验", async () => { const file = join(e2eFixturesDir, ".dataset-invalid.jsonl"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "upload", "--file", @@ -86,7 +84,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { // No --schema: a record carrying chosen/rejected is auto-detected as DPO // and the valid fixture passes. const file = join(e2eFixturesDir, ".dataset-dpo-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -104,7 +102,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { // No --schema: a record carrying `text` (and no `messages`) is auto-detected // as CPT and the valid fixture passes. const file = join(e2eFixturesDir, ".dataset-cpt-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -120,7 +118,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset validate --schema cpt 拒绝缺失 text 的记录", async () => { const file = join(e2eFixturesDir, ".dataset-valid.jsonl"); // SFT {messages}, no text - const { stdout, exitCode } = await runCommandE2e([ + const { stdout, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -140,7 +138,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset validate --schema dpo 拒绝缺失 rejected 的记录", async () => { const file = join(e2eFixturesDir, ".dataset-dpo-invalid.jsonl"); - const { stdout, exitCode } = await runCommandE2e([ + const { stdout, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -161,7 +159,7 @@ 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(e2eFixturesDir, ".dataset-dpo-invalid.jsonl"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -179,7 +177,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset validate --schema 以非零码退出", async () => { const file = join(e2eFixturesDir, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -195,7 +193,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset upload --dry-run 转发 --schema", async () => { const file = join(e2eFixturesDir, ".dataset-dpo-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "upload", "--file", @@ -215,7 +213,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (DashScope)", () => { test("dataset list --output json 返回结构化结果", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "list", "--page-size", diff --git a/packages/commands/tests/e2e/deploy.e2e.test.ts b/packages/commands/tests/e2e/deploy.e2e.test.ts index b2b4891..6e64344 100644 --- a/packages/commands/tests/e2e/deploy.e2e.test.ts +++ b/packages/commands/tests/e2e/deploy.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { DEPLOY_ROUTES } from "./topic-routes.ts"; /** * Deploy E2E. @@ -14,21 +15,14 @@ import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.t */ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { - test("deploy 列出子命令", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["deploy", "create", "--help"]); + const { stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, ["deploy", "create", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--model|--name/i); }); test("deploy create --dry-run 构造 lora 部署请求体", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "create", "--model", @@ -57,7 +51,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { }); test("deploy scale --dry-run 转发 capacity", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "scale", "--deployed-model", @@ -80,7 +74,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { }); test("deploy update --dry-run 转发 rate limits", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "update", "--deployed-model", @@ -104,7 +98,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { }); test("deploy scale --dry-run 缺少 capacity/input-tpm/output-tpm 时报错", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "scale", "--deployed-model", @@ -124,7 +118,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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", sub, ...extra, @@ -147,7 +141,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (DashScope)", () => { * 而非进程崩溃),即视为通过。 */ test("deploy list --output json 优雅返回(空账号或鉴权失败均通过)", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "list", "--page-size", diff --git a/packages/commands/tests/e2e/file-upload.e2e.test.ts b/packages/commands/tests/e2e/file-upload.e2e.test.ts index f49c6d2..5b7912c 100644 --- a/packages/commands/tests/e2e/file-upload.e2e.test.ts +++ b/packages/commands/tests/e2e/file-upload.e2e.test.ts @@ -1,21 +1,19 @@ import { describe, expect, test } from "vite-plus/test"; 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 runCommandE2e(["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 runCommandE2e(["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); }); @@ -23,7 +21,7 @@ describe("e2e: file upload", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: file upload(DashScope)", () => { test("file upload 缺少 --file 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(FILE_UPLOAD_ROUTES, [ "file", "upload", "--model", @@ -35,14 +33,19 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: file upload(DashScope)", () => test("file upload 缺少 --model 时报用法错误并退出 (2)", async () => { const testFile = join(e2eFixturesDir, ".smoke-32.png"); - const { stderr, exitCode } = await runCommandE2e(["file", "upload", "--file", testFile]); + 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(e2eFixturesDir, ".smoke-32.png"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FILE_UPLOAD_ROUTES, [ "file", "upload", "--file", diff --git a/packages/commands/tests/e2e/finetune.e2e.test.ts b/packages/commands/tests/e2e/finetune.e2e.test.ts index c5b32c1..53869f1 100644 --- a/packages/commands/tests/e2e/finetune.e2e.test.ts +++ b/packages/commands/tests/e2e/finetune.e2e.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vite-plus/test"; import { join } from "path"; import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e, e2eFixturesDir } from "./helpers.ts"; +import { FINETUNE_ROUTES } from "./topic-routes.ts"; /** * Fine-tune E2E. @@ -15,21 +16,18 @@ import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e, e2eFixturesDir } f */ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { - test("finetune 列出子命令", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["finetune", "create", "--help"]); + const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ + "finetune", + "create", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--model|--datasets/i); }); test("finetune create --dry-run 构造 SFT 默认请求体", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "create", "--model", @@ -62,7 +60,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --dry-run 转发训练类型与超参", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "create", "--model", @@ -115,7 +113,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --training-type 拒绝不支持的训练类型值", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "create", "--model", @@ -133,7 +131,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { test("finetune create --dry-run 把本地路径标记为 pending 上传且不发起网络请求", async () => { const localPath = join(e2eFixturesDir, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "create", "--model", @@ -163,7 +161,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --datasets 为空时拒绝", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "create", "--model", @@ -183,7 +181,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { // 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(e2eFixturesDir, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "create", "--model", @@ -204,7 +202,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { // 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(e2eFixturesDir, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "create", "--model", @@ -231,7 +229,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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", sub, ...extra, @@ -245,7 +243,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --dry-run 解析多 datasets 中的空白", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "create", "--model", @@ -273,7 +271,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => { * 而非进程崩溃),即视为通过。 */ test("finetune list --output json 优雅返回(空账号或鉴权失败均通过)", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "list", "--page-size", diff --git a/packages/commands/tests/e2e/harness/e2e-command-map.contract.test.ts b/packages/commands/tests/e2e/harness/e2e-command-map.contract.test.ts deleted file mode 100644 index e384570..0000000 --- a/packages/commands/tests/e2e/harness/e2e-command-map.contract.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { readdirSync, readFileSync } from "fs"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; -import { describe, expect, test } from "vite-plus/test"; -import * as commandExports from "bailian-cli-commands"; -import { E2E_COMMAND_EXPORTS, e2eCommandMap } from "./e2e-command-map.ts"; - -const e2eDir = dirname(fileURLToPath(import.meta.url)); - -const exportedCommands = new Set( - Object.values(commandExports).filter( - (value) => typeof value === "object" && value !== null && "run" in value, - ), -); - -/** 从 runCommandE2e([...]) 调用中提取命令 path 前缀 */ -function extractCommandPathsFromSource(source: string): string[] { - const paths: string[] = []; - const callPattern = /runCommandE2e\(\[([\s\S]*?)\]/g; - - for (const match of source.matchAll(callPattern)) { - const arrayBody = match[1] ?? ""; - const tokens: string[] = []; - const stringPattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/g; - for (const tokenMatch of arrayBody.matchAll(stringPattern)) { - const token = tokenMatch[1] ?? tokenMatch[2] ?? ""; - if (token.startsWith("--")) break; - tokens.push(token); - } - if (tokens.length > 0) paths.push(tokens.join(" ")); - } - - return paths; -} - -describe("e2e-command-map contract", () => { - test("path 唯一", () => { - const paths = Object.keys(e2eCommandMap); - expect(new Set(paths).size).toBe(paths.length); - }); - - test("value 均为 bailian-cli-commands 合法 export", () => { - for (const [path, command] of Object.entries(e2eCommandMap)) { - expect(exportedCommands.has(command), `invalid export for path "${path}"`).toBe(true); - } - }); - - test("E2E_COMMAND_EXPORTS 白名单 ⊆ map values", () => { - const mapValues = new Set(Object.values(e2eCommandMap)); - for (const command of E2E_COMMAND_EXPORTS) { - expect(mapValues.has(command)).toBe(true); - } - }); - - test("各 e2e 测试 argv 前缀在 map 中有对应 path", () => { - const mapPaths = new Set(Object.keys(e2eCommandMap)); - const testFiles = readdirSync(e2eDir).filter( - (name) => name.endsWith(".e2e.test.ts") && name !== "proxy.e2e.test.ts", - ); - - const missing: string[] = []; - for (const file of testFiles) { - const source = readFileSync(join(e2eDir, file), "utf8"); - for (const path of extractCommandPathsFromSource(source)) { - if (!mapPaths.has(path)) missing.push(`${file}: ${path}`); - } - } - - expect(missing).toEqual([]); - }); -}); diff --git a/packages/commands/tests/e2e/harness/e2e-command-map.ts b/packages/commands/tests/e2e/harness/e2e-command-map.ts deleted file mode 100644 index bc976b5..0000000 --- a/packages/commands/tests/e2e/harness/e2e-command-map.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { AnyCommand } from "bailian-cli-core"; -import { - authLogin, - authStatus, - authLogout, - textChat, - textOmni, - imageGenerate, - imageEdit, - videoGenerate, - videoEdit, - videoRef, - videoTaskGet, - videoDownload, - visionDescribe, - configShow, - configSet, - update, - appCall, - appList, - memoryAdd, - memorySearch, - memoryList, - memoryUpdate, - memoryDelete, - memoryProfileCreate, - memoryProfileGet, - knowledgeRetrieve, - knowledgeSearch, - knowledgeChat, - mcpCall, - mcpList, - mcpTools, - searchWeb, - speechSynthesize, - speechRecognize, - fileUpload, - consoleCall, - usageFree, - usageFreetier, - usageStats, - pipelineRun, - pipelineValidate, - advisorRecommend, - workspaceList, - quotaList, - quotaRequest, - quotaHistory, - quotaCheck, - datasetUpload, - datasetList, - datasetGet, - datasetDelete, - datasetValidate, - finetuneCreate, - finetuneList, - finetuneGet, - finetuneCancel, - finetuneDelete, - finetuneLogs, - finetuneCheckpoints, - finetuneExport, - finetuneWatch, - finetuneCapability, - deployCreate, - deployList, - deployGet, - deployModels, - deployScale, - deployUpdate, - deployDelete, - tokenPlanListSeats, - tokenPlanCreateKey, - tokenPlanAssignSeats, - tokenPlanAddMember, -} from "bailian-cli-commands"; - -/** - * E2E 专用全量路由表(与产品 map 独立维护)。 - * 新增/删改命令时须同步:commands 实现、产品 map、本文件。 - */ -export const e2eCommandMap: Record = { - "auth login": authLogin, - "auth status": authStatus, - "auth logout": authLogout, - "text chat": textChat, - omni: textOmni, - "image generate": imageGenerate, - "image edit": imageEdit, - "video generate": videoGenerate, - "video edit": videoEdit, - "video ref": videoRef, - "video task get": videoTaskGet, - "video download": videoDownload, - "vision describe": visionDescribe, - "config show": configShow, - "config set": configSet, - update, - "app call": appCall, - "app list": appList, - "memory add": memoryAdd, - "memory search": memorySearch, - "memory list": memoryList, - "memory update": memoryUpdate, - "memory delete": memoryDelete, - "memory profile create": memoryProfileCreate, - "memory profile get": memoryProfileGet, - "knowledge retrieve": knowledgeRetrieve, - "knowledge search": knowledgeSearch, - "knowledge chat": knowledgeChat, - "mcp call": mcpCall, - "mcp list": mcpList, - "mcp tools": mcpTools, - "search web": searchWeb, - "speech synthesize": speechSynthesize, - "speech recognize": speechRecognize, - "file upload": fileUpload, - "console call": consoleCall, - "usage free": usageFree, - "usage freetier": usageFreetier, - "usage stats": usageStats, - "pipeline run": pipelineRun, - "pipeline validate": pipelineValidate, - "advisor recommend": advisorRecommend, - "workspace list": workspaceList, - "quota list": quotaList, - "quota request": quotaRequest, - "quota history": quotaHistory, - "quota check": quotaCheck, - "dataset upload": datasetUpload, - "dataset list": datasetList, - "dataset get": datasetGet, - "dataset delete": datasetDelete, - "dataset validate": datasetValidate, - "finetune create": finetuneCreate, - "finetune list": finetuneList, - "finetune get": finetuneGet, - "finetune cancel": finetuneCancel, - "finetune delete": finetuneDelete, - "finetune logs": finetuneLogs, - "finetune checkpoints": finetuneCheckpoints, - "finetune export": finetuneExport, - "finetune watch": finetuneWatch, - "finetune capability": finetuneCapability, - "deploy create": deployCreate, - "deploy list": deployList, - "deploy get": deployGet, - "deploy models": deployModels, - "deploy scale": deployScale, - "deploy update": deployUpdate, - "deploy delete": deployDelete, - "token-plan list-seats": tokenPlanListSeats, - "token-plan create-key": tokenPlanCreateKey, - "token-plan assign-seats": tokenPlanAssignSeats, - "token-plan add-member": tokenPlanAddMember, -}; - -/** 须在 e2eCommandMap 中出现至少一次的 commands export(契约测试用) */ -export const E2E_COMMAND_EXPORTS = [ - authLogin, - authStatus, - authLogout, - textChat, - textOmni, - imageGenerate, - imageEdit, - videoGenerate, - videoEdit, - videoRef, - videoTaskGet, - videoDownload, - visionDescribe, - configShow, - configSet, - update, - appCall, - appList, - memoryAdd, - memorySearch, - memoryList, - memoryUpdate, - memoryDelete, - memoryProfileCreate, - memoryProfileGet, - knowledgeRetrieve, - knowledgeSearch, - knowledgeChat, - mcpCall, - mcpList, - mcpTools, - searchWeb, - speechSynthesize, - speechRecognize, - fileUpload, - consoleCall, - usageFree, - usageFreetier, - usageStats, - pipelineRun, - pipelineValidate, - advisorRecommend, - workspaceList, - quotaList, - quotaRequest, - quotaHistory, - quotaCheck, - datasetUpload, - datasetList, - datasetGet, - datasetDelete, - datasetValidate, - finetuneCreate, - finetuneList, - finetuneGet, - finetuneCancel, - finetuneDelete, - finetuneLogs, - finetuneCheckpoints, - finetuneExport, - finetuneWatch, - finetuneCapability, - deployCreate, - deployList, - deployGet, - deployModels, - deployScale, - deployUpdate, - deployDelete, - tokenPlanListSeats, - tokenPlanCreateKey, - tokenPlanAssignSeats, - tokenPlanAddMember, -] as const; diff --git a/packages/commands/tests/e2e/harness/identity.ts b/packages/commands/tests/e2e/harness/identity.ts new file mode 100644 index 0000000..ff6d5f1 --- /dev/null +++ b/packages/commands/tests/e2e/harness/identity.ts @@ -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; diff --git a/packages/commands/tests/e2e/harness/main.ts b/packages/commands/tests/e2e/harness/main.ts index 0b8941e..a6c65d6 100644 --- a/packages/commands/tests/e2e/harness/main.ts +++ b/packages/commands/tests/e2e/harness/main.ts @@ -1,10 +1,29 @@ +import * as cmd from "bailian-cli-commands"; +import type { AnyCommand } from "bailian-cli-core"; import { createCli } from "bailian-cli-runtime"; -import pkg from "../../../../cli/package.json" with { type: "json" }; -import { e2eCommandMap } from "./e2e-command-map.ts"; +import { E2E_HARNESS_IDENTITY } from "./identity.ts"; -void createCli(e2eCommandMap, { - binName: "bl", - version: pkg.version, - clientName: "commands-e2e", - npmPackage: "bailian-cli-commands", -}).run(); +interface RouteSpec { + path: string; + export: string; +} + +function buildRoutesFromEnv(): Record { + 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 = {}; + const lib = cmd as Record; + 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(); diff --git a/packages/commands/tests/e2e/helpers.ts b/packages/commands/tests/e2e/helpers.ts index 4082a70..0ee7375 100644 --- a/packages/commands/tests/e2e/helpers.ts +++ b/packages/commands/tests/e2e/helpers.ts @@ -9,6 +9,7 @@ import { parseStdoutJson, } from "e2e/output"; import { runNodeMain, type RunCliResult } from "e2e/runner"; +import type { E2eRouteExports } from "./topic-routes.ts"; export { cliTimeoutPrefix, @@ -39,13 +40,26 @@ export const commandsPackageRoot = join(e2eDir, "..", ".."); /** E2E fixtures 目录 */ export const e2eFixturesDir = join(e2eDir, "fixtures"); -/** 通过 harness 子进程执行 E2E 命令 */ +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 { return runNodeMain(harnessMainTs, args, { cwd: commandsPackageRoot, - env: envOverrides, + env: { + BAILIAN_E2E_ROUTES: serializeRoutes(routes), + ...envOverrides, + }, }); } diff --git a/packages/commands/tests/e2e/image-edit.e2e.test.ts b/packages/commands/tests/e2e/image-edit.e2e.test.ts index 3fd3272..486bbca 100644 --- a/packages/commands/tests/e2e/image-edit.e2e.test.ts +++ b/packages/commands/tests/e2e/image-edit.e2e.test.ts @@ -9,27 +9,21 @@ import { parseStdoutJson, runCommandE2e, } from "./helpers.ts"; +import { IMAGE_ROUTES } from "./topic-routes.ts"; /** * Image edit E2E */ describe("e2e: image edit", () => { - test("image 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ "image", "edit", "--dry-run", @@ -56,21 +50,31 @@ describe("e2e: image edit", () => { describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: image edit", () => { test("image edit 缺少 --image 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e(["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(e2eFixturesDir, ".smoke-32.png"); - const { stderr, exitCode } = await runCommandE2e(["image", "edit", "--image", testPng]); + 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 runCommandE2e([ + const gen = await runCommandE2e(IMAGE_ROUTES, [ "image", "generate", "--model", @@ -90,7 +94,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: ima expect(imagePath).toBeTruthy(); - const ed = await runCommandE2e([ + const ed = await runCommandE2e(IMAGE_ROUTES, [ "image", "edit", "--model", diff --git a/packages/commands/tests/e2e/image-generate.e2e.test.ts b/packages/commands/tests/e2e/image-generate.e2e.test.ts index 7bee37e..b987536 100644 --- a/packages/commands/tests/e2e/image-generate.e2e.test.ts +++ b/packages/commands/tests/e2e/image-generate.e2e.test.ts @@ -7,6 +7,7 @@ import { parseStdoutJson, 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 runCommandE2e(["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 runCommandE2e(["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,7 +27,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( "e2e: image generate", () => { test("image generate 缺少 --prompt 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ "image", "generate", "--model", @@ -45,7 +39,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( test("【qwen-image-2.0】图片生成", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ "image", "generate", "--model", diff --git a/packages/commands/tests/e2e/knowledge-chat.e2e.test.ts b/packages/commands/tests/e2e/knowledge-chat.e2e.test.ts index 93796f3..5ee66f5 100644 --- a/packages/commands/tests/e2e/knowledge-chat.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge-chat.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; 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 runCommandE2e(["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,7 +37,7 @@ describe("e2e: knowledge chat", () => { }); test("缺少 --message 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--agent-id", @@ -43,13 +48,19 @@ describe("e2e: knowledge chat", () => { }); test("缺少 --agent-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e( + KNOWLEDGE_CHAT_ROUTES, // 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入 [ "knowledge", @@ -70,7 +81,7 @@ describe("e2e: knowledge chat", () => { }); test("--dry-run 输出 endpoint 和 request body", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--dry-run", @@ -93,7 +104,7 @@ describe("e2e: knowledge chat", () => { }); test("--dry-run 多轮消息解析 role:content 前缀", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--dry-run", @@ -123,7 +134,7 @@ describe("e2e: knowledge chat", () => { }); test("--dry-run + --image 输出多模态 content 数组", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--dry-run", @@ -152,7 +163,7 @@ describe("e2e: knowledge chat", () => { }); test("--dry-run + --image 无 --message 自动创建空 user message", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--dry-run", @@ -194,7 +205,7 @@ describe.skipIf(!isChatE2EReady())("e2e: knowledge chat (live)", () => { const workspaceId = process.env.BAILIAN_WORKSPACE_ID!; test("chat (JSON mode) returns answer", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--message", @@ -215,7 +226,7 @@ describe.skipIf(!isChatE2EReady())("e2e: knowledge chat (live)", () => { }); test("chat (text mode) returns plain text", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--message", @@ -233,7 +244,7 @@ describe.skipIf(!isChatE2EReady())("e2e: knowledge chat (live)", () => { }); test("chat (stream, JSON mode) collects and returns answer", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--message", @@ -254,7 +265,7 @@ describe.skipIf(!isChatE2EReady())("e2e: knowledge chat (live)", () => { }); test("chat (stream, text mode) outputs streaming text", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--message", @@ -272,7 +283,7 @@ describe.skipIf(!isChatE2EReady())("e2e: knowledge chat (live)", () => { }); test("chat with multi-turn messages returns context-aware answer", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--message", @@ -296,7 +307,7 @@ describe.skipIf(!isChatE2EReady())("e2e: knowledge chat (live)", () => { }); test("chat with invalid agent_id fails gracefully", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--message", diff --git a/packages/commands/tests/e2e/knowledge-search.e2e.test.ts b/packages/commands/tests/e2e/knowledge-search.e2e.test.ts index 9ff1963..fdbdf47 100644 --- a/packages/commands/tests/e2e/knowledge-search.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge-search.e2e.test.ts @@ -1,5 +1,6 @@ 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; @@ -13,7 +14,11 @@ interface DryRunBody { describe("e2e: knowledge search", () => { test("knowledge search --help 正常退出", async () => { - const { stderr, exitCode } = await runCommandE2e(["knowledge", "search", "--help"]); + 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); @@ -23,7 +28,7 @@ describe("e2e: knowledge search", () => { }); test("缺少 --query 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ "knowledge", "search", "--agent-id", @@ -34,13 +39,19 @@ describe("e2e: knowledge search", () => { }); test("缺少 --agent-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e(["knowledge", "search", "--query", "test"]); + 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", @@ -61,7 +72,7 @@ describe("e2e: knowledge search", () => { }); test("--dry-run 输出 endpoint 和 request body", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ "knowledge", "search", "--dry-run", @@ -83,7 +94,7 @@ describe("e2e: knowledge search", () => { }); test("--dry-run + --image 输出 images", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ "knowledge", "search", "--dry-run", @@ -109,7 +120,7 @@ describe("e2e: knowledge search", () => { }); test("--dry-run + --query-history 输出用户对话历史", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ "knowledge", "search", "--dry-run", @@ -133,7 +144,7 @@ describe("e2e: knowledge search", () => { }); test("--dry-run + --query-history 无效 JSON 非零退出", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ "knowledge", "search", "--dry-run", @@ -173,7 +184,7 @@ describe.skipIf(!isSearchE2EReady())("e2e: knowledge search (live)", () => { const workspaceId = process.env.BAILIAN_WORKSPACE_ID!; test("search returns results in JSON mode", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ "knowledge", "search", "--query", @@ -201,7 +212,7 @@ describe.skipIf(!isSearchE2EReady())("e2e: knowledge search (live)", () => { }); test("search returns results in text mode", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ "knowledge", "search", "--query", @@ -219,7 +230,7 @@ describe.skipIf(!isSearchE2EReady())("e2e: knowledge search (live)", () => { }); test("search with --query-history returns results", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ "knowledge", "search", "--query", @@ -241,7 +252,7 @@ describe.skipIf(!isSearchE2EReady())("e2e: knowledge search (live)", () => { }); test("search with invalid agent_id fails gracefully", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ "knowledge", "search", "--query", diff --git a/packages/commands/tests/e2e/knowledge.e2e.test.ts b/packages/commands/tests/e2e/knowledge.e2e.test.ts index 88be979..cfb58d4 100644 --- a/packages/commands/tests/e2e/knowledge.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; 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 runCommandE2e(["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 runCommandE2e(["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,18 @@ describe("e2e: knowledge retrieve", () => { }); test("缺少 --index-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_ROUTES, [ "knowledge", "retrieve", "--index-id", @@ -59,6 +62,7 @@ describe("e2e: knowledge retrieve", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: knowledge retrieve errors", () => { test("无任何凭证时提示缺少密钥并非零退出", async () => { const { stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_ROUTES, ["knowledge", "retrieve", "--index-id", "idx_test", "--query", "test", "--output", "json"], { DASHSCOPE_API_KEY: "", @@ -76,6 +80,7 @@ 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 runCommandE2e( + KNOWLEDGE_ROUTES, [ "knowledge", "retrieve", @@ -98,6 +103,7 @@ describe("e2e: knowledge retrieve dry-run", () => { test("--dry-run + --top-k 转发到 rerank_top_n 并输出废弃警告", async () => { const { stdout, stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_ROUTES, [ "knowledge", "retrieve", @@ -121,6 +127,7 @@ describe("e2e: knowledge retrieve dry-run", () => { test("--dry-run + --rerank-top-n 优先于 --top-k", async () => { const { stdout, stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_ROUTES, [ "knowledge", "retrieve", @@ -145,6 +152,7 @@ describe("e2e: knowledge retrieve dry-run", () => { test("--dry-run + rerank 参数完整输出", async () => { const { stdout, stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_ROUTES, [ "knowledge", "retrieve", diff --git a/packages/commands/tests/e2e/mcp.e2e.test.ts b/packages/commands/tests/e2e/mcp.e2e.test.ts index 71a9fef..ad5d06e 100644 --- a/packages/commands/tests/e2e/mcp.e2e.test.ts +++ b/packages/commands/tests/e2e/mcp.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { MCP_ROUTES } from "./topic-routes.ts"; /** * `bl mcp` E2E. @@ -16,40 +17,32 @@ import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.t */ describe("e2e: mcp", () => { - test("mcp 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "list", "--dry-run", @@ -102,7 +95,7 @@ describe("e2e: mcp", () => { }); test("mcp tools --server --dry-run 输出 /api/v1/mcps//mcp 形态 URL", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e(["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 --dry-run 输出工具调用计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e(["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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "tools", "--server", diff --git a/packages/commands/tests/e2e/memory.e2e.test.ts b/packages/commands/tests/e2e/memory.e2e.test.ts index e94bfe9..f81c6d7 100644 --- a/packages/commands/tests/e2e/memory.e2e.test.ts +++ b/packages/commands/tests/e2e/memory.e2e.test.ts @@ -5,6 +5,7 @@ import { parseStdoutJson, runCommandE2e, } from "./helpers.ts"; +import { MEMORY_ROUTES } from "./topic-routes.ts"; interface MemoryAddBody { memory_ids?: string[]; @@ -36,33 +37,31 @@ function memoryLibraryCliArgs(): string[] { */ describe("e2e: memory", () => { - test("memory 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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); }); @@ -75,7 +74,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( "e2e: memory CRUD + search", () => { test("memory add 缺少 --user-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [ "memory", "add", ...memoryLibraryCliArgs(), @@ -88,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 runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [ "memory", "add", ...memoryLibraryCliArgs(), @@ -101,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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [ "memory", "add", "--dry-run", @@ -126,7 +125,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( const contentA = "CLI vp test:记忆写入(可删)"; const contentB = "CLI vp test:记忆已更新"; - const addRes = await runCommandE2e([ + const addRes = await runCommandE2e(MEMORY_ROUTES, [ "memory", "add", ...memoryLibraryCliArgs(), @@ -141,7 +140,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( const added = parseStdoutJson(addRes.stdout); expect(added.request_id?.length ?? 0, addRes.stdout + addRes.stderr).toBeGreaterThan(0); - const listRes = await runCommandE2e([ + const listRes = await runCommandE2e(MEMORY_ROUTES, [ "memory", "list", ...memoryLibraryCliArgs(), @@ -162,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 runCommandE2e([ + const searchRes = await runCommandE2e(MEMORY_ROUTES, [ "memory", "search", ...memoryLibraryCliArgs(), @@ -179,7 +178,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( const searched = parseStdoutJson(searchRes.stdout); expect(searched.memory_nodes?.length ?? 0).toBeGreaterThan(0); - const updRes = await runCommandE2e([ + const updRes = await runCommandE2e(MEMORY_ROUTES, [ "memory", "update", ...memoryLibraryCliArgs(), @@ -194,7 +193,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( ]); expect(updRes.exitCode, updRes.stderr).toBe(0); - const delRes = await runCommandE2e([ + const delRes = await runCommandE2e(MEMORY_ROUTES, [ "memory", "delete", ...memoryLibraryCliArgs(), diff --git a/packages/commands/tests/e2e/omni.e2e.test.ts b/packages/commands/tests/e2e/omni.e2e.test.ts index cdf83b6..93804d0 100644 --- a/packages/commands/tests/e2e/omni.e2e.test.ts +++ b/packages/commands/tests/e2e/omni.e2e.test.ts @@ -8,10 +8,11 @@ import { parseStdoutJson, runCommandE2e, } from "./helpers.ts"; +import { OMNI_ROUTES } from "./topic-routes.ts"; describe("e2e: omni", () => { test("omni --help 正常退出", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + const omni = await runCommandE2e(OMNI_ROUTES, [ "omni", "--model", "qwen3.5-omni-flash", diff --git a/packages/commands/tests/e2e/pipeline.e2e.test.ts b/packages/commands/tests/e2e/pipeline.e2e.test.ts index 8589a77..f6f01ca 100644 --- a/packages/commands/tests/e2e/pipeline.e2e.test.ts +++ b/packages/commands/tests/e2e/pipeline.e2e.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; 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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ "pipeline", "validate", "--file", @@ -102,6 +102,7 @@ describe("e2e: pipeline", () => { test("pipeline validate 使用 config 输出格式", async () => { 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 runCommandE2e([ + 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 runCommandE2e(["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 |--file/i); }); test("pipeline run --dry-run --output json 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ "pipeline", "run", "--file", @@ -168,6 +173,7 @@ describe("e2e: pipeline", () => { test("pipeline run 使用 config 输出格式", async () => { 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ "pipeline", "run", "--file", diff --git a/packages/commands/tests/e2e/quota.e2e.test.ts b/packages/commands/tests/e2e/quota.e2e.test.ts index d332b76..b0223d3 100644 --- a/packages/commands/tests/e2e/quota.e2e.test.ts +++ b/packages/commands/tests/e2e/quota.e2e.test.ts @@ -5,17 +5,18 @@ import { parseStdoutJson, runCommandE2e, } from "./helpers.ts"; +import { QUOTA_ROUTES } from "./topic-routes.ts"; describe("e2e: quota", () => { test("quota list --help 正常退出", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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"); @@ -23,21 +24,21 @@ describe("e2e: quota", () => { }); test("quota request --help 正常退出", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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"); @@ -45,7 +46,12 @@ describe("e2e: quota", () => { }); test("quota check --period 0 报错最小值", async () => { - const { stderr, exitCode } = await runCommandE2e(["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"); }); @@ -53,7 +59,7 @@ describe("e2e: quota", () => { describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { test("quota list --dry-run 输出请求参数", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "list", "--dry-run", @@ -73,7 +79,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota list --dry-run --all 不传 supports 过滤", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "list", "--all", @@ -89,13 +95,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota list 文本输出包含英文表头", async () => { - const result = await runCommandE2e(["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 runCommandE2e([ + const result = await runCommandE2e(QUOTA_ROUTES, [ "quota", "list", "--model", @@ -108,7 +114,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota list --model 不存在的模型报错", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(QUOTA_ROUTES, [ "quota", "list", "--model", @@ -122,13 +128,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota list JSON 输出包含 model/rpm/tpm/maxTPM", async () => { - const result = await runCommandE2e(["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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "request", "--model", @@ -150,7 +156,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota request TPM 超范围报错", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(QUOTA_ROUTES, [ "quota", "request", "--model", @@ -166,7 +172,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota request 不支持提额的模型报错", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(QUOTA_ROUTES, [ "quota", "request", "--model", @@ -180,7 +186,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota history --dry-run 输出请求参数", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "history", "--dry-run", @@ -198,7 +204,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check --dry-run 输出 API 信息", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--dry-run", @@ -215,7 +221,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check --dry-run --console-region 透传", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--dry-run", @@ -230,13 +236,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check 文本输出包含英文表头", async () => { - const result = await runCommandE2e(["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 runCommandE2e([ + const result = await runCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--model", @@ -249,7 +255,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check --model 逗号分隔多模型", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--model", @@ -262,7 +268,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check JSON 输出包含用量和限额字段", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--model", @@ -275,7 +281,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota history --dry-run --page 2 --page-size 20", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "history", "--page", diff --git a/packages/commands/tests/e2e/search-web.e2e.test.ts b/packages/commands/tests/e2e/search-web.e2e.test.ts index 9ae5c64..89e1b30 100644 --- a/packages/commands/tests/e2e/search-web.e2e.test.ts +++ b/packages/commands/tests/e2e/search-web.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; 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 runCommandE2e(["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 runCommandE2e(["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 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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(SEARCH_WEB_ROUTES, [ "search", "web", "--query", diff --git a/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts index 057c020..df9dc55 100644 --- a/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts @@ -1,26 +1,28 @@ import { describe, expect, test } from "vite-plus/test"; 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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ "speech", "synthesize", "--list-voices", diff --git a/packages/commands/tests/e2e/speech-recognize.e2e.test.ts b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts index a2209ed..926af0e 100644 --- a/packages/commands/tests/e2e/speech-recognize.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts @@ -9,20 +9,19 @@ import { parseStdoutJson, 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 runCommandE2e(["speech"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/speech|synthesize|recognize/i); - }); - test("speech recognize --help 正常退出", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + const rec = await runCommandE2e(SPEECH_ROUTES, [ "speech", "recognize", "--model", diff --git a/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts b/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts index 3f73472..4881bca 100644 --- a/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts @@ -8,20 +8,19 @@ import { parseStdoutJson, 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 runCommandE2e(["speech"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/speech|synthesize|recognize/i); - }); - test("speech synthesize --help 正常退出", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ "speech", "synthesize", "--model", diff --git a/packages/commands/tests/e2e/text-chat.e2e.test.ts b/packages/commands/tests/e2e/text-chat.e2e.test.ts index c8e10b0..a5f4d5e 100644 --- a/packages/commands/tests/e2e/text-chat.e2e.test.ts +++ b/packages/commands/tests/e2e/text-chat.e2e.test.ts @@ -1,19 +1,14 @@ import { describe, expect, test } from "vite-plus/test"; 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 runCommandE2e(["text"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/text|chat/i); - }); - test("text chat --help 正常退出", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [ "text", "chat", "--model", diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts new file mode 100644 index 0000000..8c5382e --- /dev/null +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -0,0 +1,129 @@ +/** + * 各 topic E2E 的最小路由(path → bailian-cli-commands export 名)。 + * 仅包含该 topic 测试会调用的 path,不维护全量产品 map。 + */ +export type E2eRouteExports = Record; + +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 = { + "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" }; + +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 create": "deployCreate", + "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 create": "finetuneCreate", + "finetune list": "finetuneList", + "finetune get": "finetuneGet", + "finetune cancel": "finetuneCancel", + "finetune delete": "finetuneDelete", + "finetune logs": "finetuneLogs", + "finetune checkpoints": "finetuneCheckpoints", + "finetune export": "finetuneExport", + "finetune watch": "finetuneWatch", + "finetune capability": "finetuneCapability", +}; + +export const CONSOLE_FLAGS_DRY_RUN_ROUTES: E2eRouteExports = { + "console call": "consoleCall", + "mcp list": "mcpList", + "quota check": "quotaCheck", +}; diff --git a/packages/commands/tests/e2e/usage-free.e2e.test.ts b/packages/commands/tests/e2e/usage-free.e2e.test.ts index 7967b65..14eea08 100644 --- a/packages/commands/tests/e2e/usage-free.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-free.e2e.test.ts @@ -5,23 +5,17 @@ import { 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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e(["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"); @@ -31,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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -50,7 +44,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run --model 逗号分隔多个模型", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -67,7 +61,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run --model 重复模型名自动去重", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -84,7 +78,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run --model 逗号间有空格也能正确解析", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -101,7 +95,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run 不指定 --model 传全量模型列表", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -112,7 +106,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 单模型查询返回 JSON 结果", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -125,7 +119,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 单模型文本输出包含表头", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -138,7 +132,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 文本输出包含模型名", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -151,7 +145,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 逗号分隔多模型文本输出包含所有模型", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -164,7 +158,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 文本输出包含正确的 Type 列", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -177,7 +171,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model quotaStatus 为 UNKNOWN 时 Auto-Stop 显示 Unsupported", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -190,7 +184,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model quotaStatus 为 UNKNOWN 时额度显示为 -", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -203,7 +197,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 不存在的模型仍返回表格行", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -216,7 +210,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model Auto-Stop 显示 ON、OFF 或 Unsupported", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -229,7 +223,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model --console-region cn-beijing 指定区域查询", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", diff --git a/packages/commands/tests/e2e/usage-stats.e2e.test.ts b/packages/commands/tests/e2e/usage-stats.e2e.test.ts index b6e2320..150a6c8 100644 --- a/packages/commands/tests/e2e/usage-stats.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-stats.e2e.test.ts @@ -5,6 +5,7 @@ import { parseStdoutJson, runCommandE2e, } from "./helpers.ts"; +import { USAGE_ROUTES } from "./topic-routes.ts"; import { readConfigFile } from "bailian-cli-core"; function getStaticWorkspaceId(): string | undefined { @@ -25,7 +26,7 @@ async function fetchDefaultWorkspaceId(): Promise { const staticId = getStaticWorkspaceId(); if (staticId) return staticId; - const result = await runCommandE2e(["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); @@ -41,13 +42,13 @@ async function fetchDefaultWorkspaceId(): Promise { describe("e2e: usage stats", () => { test("usage stats --help 正常退出", async () => { - const { stderr, exitCode } = await runCommandE2e(["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 runCommandE2e(["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"); @@ -55,7 +56,7 @@ describe("e2e: usage stats", () => { }); test("usage stats --help 包含 --workspace-id 选项", async () => { - const { stderr, exitCode } = await runCommandE2e(["usage", "stats", "--help"]); + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "stats", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("--workspace-id"); }); @@ -71,7 +72,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run 概览模式输出请求参数", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -100,7 +101,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run --days 30 时间跨度约 30 天", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -122,7 +123,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run --model 指定模型使用 list API", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -144,7 +145,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run --type Text 传递 obsModelType", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -163,7 +164,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats 概览模式返回 JSON 结果", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -176,7 +177,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats 概览文本输出包含英文标签", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -189,7 +190,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats 概览文本输出包含 Token 用量", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -202,7 +203,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --model 单模型文本输出包含英文表头", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -217,7 +218,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --model 逗号分隔多模型返回多行", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -232,7 +233,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --model 不存在的模型返回空表格", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -247,7 +248,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --days 1 短时间范围正常返回", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -262,7 +263,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --type Vision 按类型过滤", async () => { - const result = await runCommandE2e([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", diff --git a/packages/commands/tests/e2e/video-download.e2e.test.ts b/packages/commands/tests/e2e/video-download.e2e.test.ts index 46a6bd9..ece742d 100644 --- a/packages/commands/tests/e2e/video-download.e2e.test.ts +++ b/packages/commands/tests/e2e/video-download.e2e.test.ts @@ -10,6 +10,7 @@ import { parseStdoutJson, 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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + const dl = await runCommandE2e(VIDEO_ROUTES, [ "video", "download", ...cliTimeoutPrefix(), diff --git a/packages/commands/tests/e2e/video-edit.e2e.test.ts b/packages/commands/tests/e2e/video-edit.e2e.test.ts index fd692eb..95e526a 100644 --- a/packages/commands/tests/e2e/video-edit.e2e.test.ts +++ b/packages/commands/tests/e2e/video-edit.e2e.test.ts @@ -9,26 +9,21 @@ import { parseStdoutJson, 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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "edit", ...cliTimeoutPrefix(), diff --git a/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts index e0f3ecd..1363ed3 100644 --- a/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts @@ -9,20 +9,15 @@ import { parseStdoutJson, 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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", ...cliTimeoutPrefix(), diff --git a/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts b/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts index 86507dc..e1efdfb 100644 --- a/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts @@ -9,20 +9,15 @@ import { parseStdoutJson, 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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", ...cliTimeoutPrefix(), diff --git a/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts index b3839f6..9a3af7f 100644 --- a/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts @@ -9,26 +9,21 @@ import { parseStdoutJson, 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 runCommandE2e(["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 runCommandE2e(["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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + 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 runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "ref", ...cliTimeoutPrefix(), diff --git a/packages/commands/tests/e2e/video-task-get.e2e.test.ts b/packages/commands/tests/e2e/video-task-get.e2e.test.ts index ddd4162..eeeb0e4 100644 --- a/packages/commands/tests/e2e/video-task-get.e2e.test.ts +++ b/packages/commands/tests/e2e/video-task-get.e2e.test.ts @@ -5,6 +5,7 @@ import { parseStdoutJson, runCommandE2e, } from "./helpers.ts"; +import { VIDEO_ROUTES } from "./topic-routes.ts"; const taskId = process.env.BAILIAN_E2E_VIDEO_TASK_ID?.trim(); @@ -13,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 runCommandE2e(["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 runCommandE2e(["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); }); @@ -30,7 +30,7 @@ describe.skipIf(!isBailianE2EEnabled() || !taskId || !isDashScopeE2EReady())( "e2e: video task get(DashScope)", () => { test("video task get 缺少 --task-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "task", "get", @@ -44,7 +44,7 @@ describe.skipIf(!isBailianE2EEnabled() || !taskId || !isDashScopeE2EReady())( }); test("video task get --dry-run 仅回显 task_id 且不调任务接口", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "task", "get", @@ -60,7 +60,7 @@ describe.skipIf(!isBailianE2EEnabled() || !taskId || !isDashScopeE2EReady())( }); test("根据 task_id 查询任务状态", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "task", "get", diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 9abcd17..f1d5540 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -9,7 +9,8 @@ "./runner": "./src/run-subprocess.ts", "./output": "./src/output.ts", "./global-setup": "./src/global-setup.ts", - "./monorepo-root": "./src/monorepo-root.ts" + "./monorepo-root": "./src/monorepo-root.ts", + "./registry-smoke": "./src/registry-smoke.ts" }, "dependencies": { "bailian-cli-core": "workspace:*" diff --git a/packages/e2e/src/registry-smoke.ts b/packages/e2e/src/registry-smoke.ts new file mode 100644 index 0000000..bed14cc --- /dev/null +++ b/packages/e2e/src/registry-smoke.ts @@ -0,0 +1,18 @@ +/** + * 从产品 commands map 的 path key 推导有子命令的分组前缀。 + * 供 cli / kscli registry smoke 共用。 + */ +export function deriveGroupPaths(commandPaths: string[]): string[] { + const groups = new Set(); + for (const path of commandPaths) { + const parts = path.split(" "); + for (let i = 1; i < parts.length; i++) { + const prefix = parts.slice(0, i).join(" "); + const hasChildren = commandPaths.some( + (candidate) => candidate.startsWith(`${prefix} `) && candidate !== prefix, + ); + if (hasChildren) groups.add(prefix); + } + } + return [...groups].sort(); +} diff --git a/packages/kscli/src/commands.ts b/packages/kscli/src/commands.ts new file mode 100644 index 0000000..ad49dff --- /dev/null +++ b/packages/kscli/src/commands.ts @@ -0,0 +1,23 @@ +import type { AnyCommand } from "bailian-cli-core"; +import { + configShow, + configSet, + update, + knowledgeRetrieve, + knowledgeSearch, + knowledgeChat, +} from "bailian-cli-commands"; + +// kscli (Knowledge Studio CLI): lightweight RAG product. Ships config/update +// plus the knowledge commands, remapped to flat paths. Routing is driven +// entirely by these keys, and usage/examples/errors render the path from the +// key — so the same shared command shows `kscli search` here and +// `bl knowledge search` in bl. +export const commands: Record = { + "config show": configShow, + "config set": configSet, + update, + retrieve: knowledgeRetrieve, + search: knowledgeSearch, + chat: knowledgeChat, +}; diff --git a/packages/kscli/src/main.ts b/packages/kscli/src/main.ts index f0bd202..fbd0f04 100644 --- a/packages/kscli/src/main.ts +++ b/packages/kscli/src/main.ts @@ -1,29 +1,7 @@ import { createCli } from "bailian-cli-runtime"; -import type { AnyCommand } from "bailian-cli-core"; -import { - configShow, - configSet, - update, - knowledgeRetrieve, - knowledgeSearch, - knowledgeChat, -} from "bailian-cli-commands"; +import { commands } from "./commands.ts"; import pkg from "../package.json" with { type: "json" }; -// kscli (Knowledge Studio CLI): lightweight RAG product. Ships config/update -// plus the knowledge commands, remapped to flat paths. Routing is driven -// entirely by these keys, and usage/examples/errors render the path from the -// key — so the same shared command shows `kscli search` here and -// `bl knowledge search` in bl. -const commands: Record = { - "config show": configShow, - "config set": configSet, - update, - retrieve: knowledgeRetrieve, - search: knowledgeSearch, - chat: knowledgeChat, -}; - void createCli(commands, { binName: "kscli", version: pkg.version, diff --git a/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts b/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts index 6cbe5be..892aa1f 100644 --- a/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts +++ b/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts @@ -1,45 +1,50 @@ -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; import { describe, expect, test } from "vite-plus/test"; -import { runNodeMain } from "e2e/runner"; +import { deriveGroupPaths } from "e2e/registry-smoke"; +import pkg from "../../package.json" with { type: "json" }; +import { commands } from "../../src/commands.ts"; import { runKscli } from "./helpers.ts"; -const kscliPaths = ["config show", "config set", "update", "retrieve", "search", "chat"] as const; - -const harnessMainTs = join( - dirname(fileURLToPath(import.meta.url)), - "../../../commands/tests/e2e/harness/main.ts", -); -const commandsPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../commands"); - -/** 提取 Flags + Global Flags 段,忽略 Notes/Examples 中的产品 bin 差异 */ -function extractFlagSections(text: string): string { - const start = text.indexOf("Flags:"); - if (start < 0) return text.trim(); - const notesIdx = text.indexOf("\nNotes:", start); - const examplesIdx = text.indexOf("\nExamples:", start); - const endCandidates = [notesIdx, examplesIdx].filter((idx) => idx >= 0); - const end = endCandidates.length > 0 ? Math.min(...endCandidates) : text.length; - return text.slice(start, end).trim(); -} +const commandPaths = Object.keys(commands).sort(); +const groupPaths = deriveGroupPaths(commandPaths); describe("e2e: kscli registry smoke", () => { - test.each(kscliPaths)("已注册命令 %s --help 成功", async (path) => { + test("根帮助展示 kscli 与全局 flag", async () => { + const { stderr, exitCode } = await runKscli(["--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/\bkscli\b/i); + expect(stderr).toMatch(/--base-url/); + expect(stderr).not.toMatch(/^\s*--region\s/m); + }); + + test("--version 输出产品名与版本", async () => { + const { stdout, exitCode } = await runKscli(["--version"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(`kscli ${pkg.version}`); + }); + + test("search --help 展示 kscli 路径与 knowledge 必填 flag", async () => { + const { stderr, exitCode } = await runKscli(["search", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/kscli search/i); + expect(stderr).toMatch(/--query/i); + expect(stderr).toMatch(/--agent-id/i); + expect(stderr).toMatch(/--workspace-id/i); + expect(stderr).not.toMatch(/bl knowledge search/i); + }); + + test("search 缺少 --query 时报用法错误 (2)", async () => { + const { stderr, exitCode } = await runKscli(["search", "--agent-id", "aid_test"]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--query|Missing required/i); + }); + + test.each(commandPaths)("已注册命令 %s --help 成功", async (path) => { const { stderr, exitCode } = await runKscli([...path.split(" "), "--help"]); expect(exitCode, stderr).toBe(0); }); - test("search --help flag 与 harness knowledge search --help 一致", async () => { - const [kscliHelp, harnessHelp] = await Promise.all([ - runKscli(["search", "--help"]), - runNodeMain(harnessMainTs, ["knowledge", "search", "--help"], { - cwd: commandsPackageRoot, - }), - ]); - - expect(kscliHelp.exitCode, kscliHelp.stderr).toBe(0); - expect(harnessHelp.exitCode, harnessHelp.stderr).toBe(0); - - expect(extractFlagSections(kscliHelp.stderr)).toBe(extractFlagSections(harnessHelp.stderr)); + test.each(groupPaths)("命令分组 %s --help 成功", async (path) => { + const { stderr, exitCode } = await runKscli([...path.split(" "), "--help"]); + expect(exitCode, stderr).toBe(0); }); }); diff --git a/packages/runtime/tests/console-flags.e2e.test.ts b/packages/runtime/tests/console-flags.e2e.test.ts index a03a3c3..66ee1e2 100644 --- a/packages/runtime/tests/console-flags.e2e.test.ts +++ b/packages/runtime/tests/console-flags.e2e.test.ts @@ -1,13 +1,21 @@ +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; import { describe, expect, test } from "vite-plus/test"; -import { runCommandE2e } from "../../commands/tests/e2e/helpers.ts"; +import { runNodeMain } from "e2e/runner"; + +const runtimeRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const harnessMainTs = join(runtimeRoot, "tests/harness/cross-domain-main.ts"); + +async function runRuntimeHarness(args: string[]) { + return runNodeMain(harnessMainTs, args, { cwd: runtimeRoot }); +} /** - * 跨域 flag 拒绝:runtime 凭证域与命令 flag 解析边界。 + * 跨域 flag 拒绝:runtime 凭证域与命令 flag 解析边界(synthetic command,不依赖 commands)。 */ - describe("e2e: console global flags (cross-domain rejection)", () => { test("跨域 flag 拒绝:model 命令传 --console-region 报 Unknown flag", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runRuntimeHarness([ "text", "chat", "--message", @@ -21,7 +29,7 @@ describe("e2e: console global flags (cross-domain rejection)", () => { }); test("跨域 flag 拒绝:console 命令传 --api-key 报 Unknown flag", async () => { - const { stderr, exitCode } = await runCommandE2e([ + const { stderr, exitCode } = await runRuntimeHarness([ "mcp", "list", "--api-key", diff --git a/packages/runtime/tests/harness/cross-domain-main.ts b/packages/runtime/tests/harness/cross-domain-main.ts new file mode 100644 index 0000000..c585486 --- /dev/null +++ b/packages/runtime/tests/harness/cross-domain-main.ts @@ -0,0 +1,33 @@ +import { defineCommand } from "bailian-cli-core"; +import { createCli } from "bailian-cli-runtime"; +import pkg from "../../package.json" with { type: "json" }; + +const noop = async () => {}; + +/** apiKey 域 stub:用于跨域 flag 拒绝测试 */ +const textChat = defineCommand({ + description: "runtime e2e stub", + auth: "apiKey", + flags: { + message: { type: "string", valueHint: "", description: "message" }, + }, + run: noop, +}); + +/** console 域 stub:用于跨域 flag 拒绝测试 */ +const mcpList = defineCommand({ + description: "runtime e2e stub", + auth: "console", + flags: {}, + run: noop, +}); + +void createCli( + { "text chat": textChat, "mcp list": mcpList }, + { + binName: "bl", + version: pkg.version, + clientName: "runtime-e2e", + npmPackage: "bailian-cli-runtime-e2e-harness", + }, +).run(); diff --git a/tools/compare-e2e-command-map.ts b/tools/compare-e2e-command-map.ts deleted file mode 100644 index 0a501ae..0000000 --- a/tools/compare-e2e-command-map.ts +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node -/** - * Advisory:对比 bl 产品 map 与 E2E harness map 是否指向同一 command 实例。 - * 不一致时打印 warning,不阻塞测试。 - */ -import { commands as cliCommands } from "../packages/cli/src/commands.ts"; -import { e2eCommandMap } from "../packages/commands/tests/e2e/harness/e2e-command-map.ts"; - -const cliPaths = new Set(Object.keys(cliCommands)); -const e2ePaths = new Set(Object.keys(e2eCommandMap)); - -let hasWarning = false; - -for (const path of cliPaths) { - if (!e2ePaths.has(path)) { - console.warn(`[advisory] cli map path missing in e2e map: ${path}`); - hasWarning = true; - } else if (cliCommands[path] !== e2eCommandMap[path]) { - console.warn(`[advisory] command instance mismatch for path: ${path}`); - hasWarning = true; - } -} - -for (const path of e2ePaths) { - if (!cliPaths.has(path)) { - console.warn(`[advisory] e2e map path missing in cli map: ${path}`); - hasWarning = true; - } -} - -if (!hasWarning) { - console.log("[advisory] cli commands map and e2e harness map are aligned."); -} - -process.exit(0); From a0b466694030881f40d657cdb77202de6444956a Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 10 Jul 2026 17:11:20 +0800 Subject: [PATCH 11/13] refactor(e2e): remove outdated token-plan tests and add new route exports - Deleted the `token-plan.e2e.test.ts` file as it contained outdated test cases. - Introduced `TOKEN_PLAN_ROUTES` in `topic-routes.ts` to define new command routes for token-plan operations. - This update streamlines the E2E testing structure and prepares for future enhancements. --- .../tests/e2e/token-plan.e2e.test.ts | 40 +++++++++++++------ packages/commands/tests/e2e/topic-routes.ts | 7 ++++ 2 files changed, 34 insertions(+), 13 deletions(-) rename packages/{cli => commands}/tests/e2e/token-plan.e2e.test.ts (62%) diff --git a/packages/cli/tests/e2e/token-plan.e2e.test.ts b/packages/commands/tests/e2e/token-plan.e2e.test.ts similarity index 62% rename from packages/cli/tests/e2e/token-plan.e2e.test.ts rename to packages/commands/tests/e2e/token-plan.e2e.test.ts index b37fb14..7bc6cda 100644 --- a/packages/cli/tests/e2e/token-plan.e2e.test.ts +++ b/packages/commands/tests/e2e/token-plan.e2e.test.ts @@ -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/); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 596012e..faf1850 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -136,3 +136,10 @@ export const CONSOLE_FLAGS_DRY_RUN_ROUTES: E2eRouteExports = { "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", +}; From 94adb919b183bd019c58497a4a16e06e08952a3e Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 10 Jul 2026 17:46:17 +0800 Subject: [PATCH 12/13] refactor(e2e): enhance auth login tests and remove unused dependencies - Removed the `bailian-cli-commands` dependency from `pnpm-lock.yaml` and `package.json` as it was no longer needed. - Expanded the E2E tests in `auth.e2e.test.ts` to cover additional scenarios for the `auth login` command, ensuring proper error handling for conflicting flags and missing parameters. - Improved test assertions for better clarity and coverage of authentication flows. --- packages/commands/tests/e2e/auth.e2e.test.ts | 148 ++++++++++++++++++- packages/runtime/package.json | 1 - pnpm-lock.yaml | 3 - 3 files changed, 145 insertions(+), 7 deletions(-) diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index a4f5425..6e8a2a4 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -1,5 +1,12 @@ +import { readFileSync } from "fs"; +import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; import { AUTH_ROUTES } from "./topic-routes.ts"; /** @@ -12,6 +19,63 @@ describe("e2e: auth", () => { expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/login|api-key/i); expect(stderr).toMatch(/--console-site/); + expect(stderr).toMatch(/--open-api/); + }); + + test("auth login 一次只能选择一种登录模式", async () => { + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "login", + "--console", + "--api-key", + "sk-e2e-placeholder", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/Choose exactly one login mode/); + }); + + test("auth login 模式专属参数不能脱离对应模式", async () => { + const openApiFlagOnly = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "login", + "--access-key-id", + "LTAI-e2e", + ]); + expect(openApiFlagOnly.exitCode).toBe(2); + expect(openApiFlagOnly.stderr).toMatch(/Use --open-api with --access-key-id/); + + const baseUrlWithoutApiKey = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "login", + "--console", + "--base-url", + "https://dashscope.aliyuncs.com", + ]); + expect(baseUrlWithoutApiKey.exitCode).toBe(2); + expect(baseUrlWithoutApiKey.stderr).toMatch(/Use --base-url only with --api-key/); + + const consoleSiteWithoutConsole = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "login", + "--api-key", + "sk-e2e-placeholder", + "--console-site", + "international", + ]); + expect(consoleSiteWithoutConsole.exitCode).toBe(2); + expect(consoleSiteWithoutConsole.stderr).toMatch(/Use --console-site only with --console/); + }); + + test("auth login --open-api 要求 AK/SK 成对输入", async () => { + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "login", + "--open-api", + "--access-key-id", + "LTAI-e2e", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/Provide --access-key-id and --access-key-secret with --open-api/); }); test("auth logout --help 正常退出", async () => { @@ -29,7 +93,7 @@ describe("e2e: auth", () => { 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(/--api-key|Usage:/i); + expect(stderr).toMatch(/Choose exactly one login mode/); }); test("auth login --dry-run --api-key 不发起校验与落盘", async () => { @@ -70,7 +134,7 @@ describe("e2e: auth", () => { expect(exitCode).toBe(2); const err = JSON.parse(stderr.trim()) as { error?: { code?: number; message?: string } }; expect(err.error?.code).toBe(2); - expect(err.error?.message).toMatch(/--api-key|console/i); + expect(err.error?.message).toMatch(/Choose exactly one login mode/); }); test("auth logout --dry-run 不写入配置", async () => { @@ -162,4 +226,82 @@ describe("e2e: auth", () => { expect(exitCode).not.toBe(0); expect(stderr).toMatch(/Unknown flag.*--base-url/); }); + + test("auth status 展示 env OpenAPI AK/SK 且不接受 OpenAPI flag 覆盖", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e( + AUTH_ROUTES, + ["auth", "status", "--output", "json"], + { + ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-e2e-placeholder", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-e2e-placeholder", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + authenticated?: boolean; + openapi?: { source?: string; access_key_id?: string; access_key_secret?: string }; + }>(stdout); + expect(data.authenticated).toBe(true); + expect(data.openapi?.source).toBe("env"); + expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-placeholder"); + expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-placeholder"); + + const denied = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--access-key-id", "ak"]); + expect(denied.exitCode).not.toBe(0); + expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/); + }); + + test("auth login --open-api 持久化 OpenAPI AK/SK 并支持单独 logout", async () => { + const configDir = makeE2eOutputDir("auth-openapi-login"); + const env = { + BAILIAN_CONFIG_DIR: configDir, + ALIBABA_CLOUD_ACCESS_KEY_ID: "", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", + }; + + const login = await runCommandE2e( + AUTH_ROUTES, + [ + "auth", + "login", + "--open-api", + "--access-key-id", + "LTAI-e2e-login-placeholder", + "--access-key-secret", + "secret-e2e-login-placeholder", + ], + env, + ); + expect(login.exitCode, login.stderr).toBe(0); + expect(login.stderr).toMatch(/OpenAPI credentials saved/); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.access_key_id).toBe("LTAI-e2e-login-placeholder"); + expect(config.access_key_secret).toBe("secret-e2e-login-placeholder"); + expect(config.openapi_access_key_id).toBeUndefined(); + expect(config.openapi_access_key_secret).toBeUndefined(); + + const status = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env); + expect(status.exitCode, status.stderr).toBe(0); + const data = parseStdoutJson<{ + authenticated?: boolean; + openapi?: { source?: string; access_key_id?: string; access_key_secret?: string }; + }>(status.stdout); + expect(data.authenticated).toBe(true); + expect(data.openapi?.source).toBe("config"); + expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-login-placeholder"); + expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-login-placeholder"); + + const logout = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--open-api"], env); + expect(logout.exitCode, logout.stderr).toBe(0); + expect(logout.stderr).toMatch(/Cleared access_key_id/); + + const after = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env); + expect(after.exitCode, after.stderr).toBe(0); + const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(after.stdout); + expect(afterData.openapi).toBeUndefined(); + }); }); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c16dec9..20a5401 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -50,7 +50,6 @@ "@types/node": "catalog:", "@typescript/native-preview": "7.0.0-dev.20260328.1", "ajv": "catalog:", - "bailian-cli-commands": "workspace:*", "e2e": "workspace:*", "typescript": "^6.0.2", "vite-plus": "0.1.22", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e512f48..a434066 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,9 +246,6 @@ importers: ajv: specifier: 'catalog:' version: 8.20.0 - bailian-cli-commands: - specifier: workspace:* - version: link:../commands e2e: specifier: workspace:* version: link:../e2e From 58ab622e11db404878386809d9f032c868443dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A1=BE=E6=8D=B7=E6=99=94?= Date: Sun, 12 Jul 2026 13:44:00 +0800 Subject: [PATCH 13/13] feat: remove intent-detect-v3 model --- .../src/commands/advisor/recommend.ts | 4 +- .../tests/e2e/advisor-recommend.e2e.test.ts | 63 +++-- packages/core/src/advisor/constants/index.ts | 3 - .../core/src/advisor/constants/prompts.ts | 82 +----- packages/core/src/advisor/intent.ts | 261 +++--------------- packages/core/src/advisor/recall-semantic.ts | 54 ++-- packages/core/src/client/endpoints.ts | 13 - packages/core/src/config/loader.ts | 2 - packages/core/src/config/schema.ts | 10 - packages/core/src/types/api.ts | 45 --- 10 files changed, 116 insertions(+), 421 deletions(-) diff --git a/packages/commands/src/commands/advisor/recommend.ts b/packages/commands/src/commands/advisor/recommend.ts index 865919d..c848a51 100644 --- a/packages/commands/src/commands/advisor/recommend.ts +++ b/packages/commands/src/commands/advisor/recommend.ts @@ -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..."); diff --git a/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts index ec2fb2b..7f22870 100644 --- a/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts +++ b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts @@ -96,9 +96,9 @@ 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 () => { + test("mode: scoped — family-scoped query sets mode=scoped with targets", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", @@ -112,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 () => { + 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", ]); @@ -132,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 () => { + 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", ]); @@ -157,18 +180,12 @@ 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 () => { + test("mode: unconstrained — generic query has no modelPreference", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", diff --git a/packages/core/src/advisor/constants/index.ts b/packages/core/src/advisor/constants/index.ts index 8e00124..8d8001d 100644 --- a/packages/core/src/advisor/constants/index.ts +++ b/packages/core/src/advisor/constants/index.ts @@ -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, diff --git a/packages/core/src/advisor/constants/prompts.ts b/packages/core/src/advisor/constants/prompts.ts index 7618e62..731fff5 100644 --- a/packages/core/src/advisor/constants/prompts.ts +++ b/packages/core/src/advisor/constants/prompts.ts @@ -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 - * `` / ` - - -` 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 `label` + ` - - -` - * 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.`; -} diff --git a/packages/core/src/advisor/intent.ts b/packages/core/src/advisor/intent.ts index 85529f9..a014156 100644 --- a/packages/core/src/advisor/intent.ts +++ b/packages/core/src/advisor/intent.ts @@ -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 | 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 = /\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 | null { - const re = /\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; - } - if ( - parsed && - typeof parsed === "object" && - "arguments" in (parsed as Record) - ) { - return (parsed as Record).arguments as Record; - } - 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 `mode` 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 { - // 意图识别模型可指向独立 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({ - path: url, - method: "POST", - body, - timeout: INTENT_DETECT_TIMEOUT, - }); - - const text = response.output?.choices?.[0]?.message?.content ?? ""; - - // 1. Extract mode from - 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 - 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 { - const detectPromise = detectIntentMode(client, input, opts?.intentDetectBaseUrl); - +export async function analyzeIntent(client: Client, input: string): Promise { const url = chatPath(); const body = { model: INTENT_EXTRACTION_MODEL, @@ -199,71 +62,31 @@ export async function analyzeIntent( temperature: 0, }; - const extractionPromise = client.requestJson({ - 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({ + 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 | 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, diff --git a/packages/core/src/advisor/recall-semantic.ts b/packages/core/src/advisor/recall-semantic.ts index 16ddcab..fb931ed 100644 --- a/packages/core/src/advisor/recall-semantic.ts +++ b/packages/core/src/advisor/recall-semantic.ts @@ -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, - intent?: IntentProfile, + _topK: number, + _modelMap: Map, + _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(); 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( diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 4495178..55369ae 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -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"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 9674e2e..e59dc79 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -61,8 +61,6 @@ export function buildSettings(s: ResolutionSources): Settings { return { configPath: getConfigPath(), - 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, diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index b4bf0a4..b76b931 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -23,12 +23,6 @@ export interface ConfigFile { /** Alibaba Cloud OpenAPI AccessKey secret from `bl auth login --open-api`. */ access_key_secret?: 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; @@ -84,8 +78,6 @@ export function parseConfigFile(raw: unknown): ConfigFile { ) out.access_key_secret = obj.openapi_access_key_secret; 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) @@ -132,8 +124,6 @@ export interface Identity { */ export interface Settings { configPath?: 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 diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index 698a603..3fa0092 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -108,51 +108,6 @@ export interface StreamChunk { }; } -// ---- Intent Detect (DashScope Native) ---- - -/** - * Request body for `tongyi-intent-detect-v3` via the DashScope-native - * text-generation endpoint. Uses `{ model, input, parameters }` shape — - * NOT the OpenAI `{ model, messages }` shape. - */ -export interface DashScopeIntentDetectRequest { - model: string; - input: { - messages: Array<{ - role: "system" | "user" | "assistant"; - content: string; - }>; - }; - parameters?: { - result_format?: "message"; - max_tokens?: number; - temperature?: number; - }; -} - -/** - * Response envelope from the DashScope-native text-generation endpoint with - * `result_format: "message"`. The model's output lives under `output.choices`, - * mirroring the OpenAI shape but nested one level deeper. - */ -export interface DashScopeIntentDetectResponse { - output: { - choices?: Array<{ - finish_reason: string; - message: { - role: string; - content: string; - }; - }>; - }; - usage?: { - total_tokens?: number; - input_tokens?: number; - output_tokens?: number; - }; - request_id: string; -} - // ---- Image (DashScope) ---- export interface DashScopeImageRequest {