fix: resolve cr issue

This commit is contained in:
故璃
2026-06-25 17:48:52 +08:00
parent 4383eeb416
commit 82bdf9ed78
20 changed files with 834 additions and 694 deletions
+1 -12
View File
@@ -4,28 +4,17 @@ import {
uploadDataset,
validateDataset,
parseDatasetSchemaFlag,
formatIssue,
MAX_DATASET_BYTES,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
type DatasetFile,
type ValidationResult,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
/**
* Format a single validation issue as a one-line string.
*/
function formatIssue(issue: ValidationResult["errors"][number]): string {
const where: string[] = [];
if (issue.line !== undefined) where.push(`line ${issue.line}`);
if (issue.path) where.push(issue.path);
const tag = where.length ? ` [${where.join(" · ")}]` : "";
return ` ${issue.severity.toUpperCase()} ${issue.code}${tag}: ${issue.message}`;
}
export default defineCommand({
name: "dataset upload",
description: "Upload a dataset file (.jsonl) to Bailian",
@@ -3,24 +3,16 @@ import {
detectOutputFormat,
validateDataset,
parseDatasetSchemaFlag,
formatIssue,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
type ValidationIssue,
type ValidationResult,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
function formatIssue(issue: ValidationIssue): string {
const where: string[] = [];
if (issue.line !== undefined) where.push(`line ${issue.line}`);
if (issue.path) where.push(issue.path);
const tag = where.length ? ` [${where.join(" · ")}]` : "";
return ` ${issue.severity.toUpperCase()} ${issue.code}${tag}: ${issue.message}`;
}
function formatStats(result: ValidationResult): string[] {
const out: string[] = [];
if (result.stats.totalRecords !== undefined) out.push(`records: ${result.stats.totalRecords}`);
+17 -137
View File
@@ -2,7 +2,6 @@ import {
defineCommand,
detectOutputFormat,
createDeployment,
listDeployableModels,
BailianError,
ExitCode,
type Config,
@@ -10,25 +9,16 @@ import {
} from "bailian-cli-core";
import { failIfMissing, promptConfirm } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
import { pickPlanStrategy } from "./plans.ts";
/**
* `bl deploy create` — create a model deployment.
*
* Plan handling:
* - lora (default): Token-billed; `capacity` is required by API but ignored.
* - ptu: Token-billed (provisioned throughput); requires
* `ptu_capacity` {input_tpm, output_tpm}. The doc says
* these default to 10000/1000 when omitted, but the platform
* currently rejects creation without them ("Miss ptu capacity
* info"), so the CLI requires --input-tpm/--output-tpm for ptu.
* - mu: Unit-based; requires `capacity`, `billing_method` and a
* `template_id`. `billing_method` defaults to "POST_PAY"
* (the only value the platform currently supports). If
* --template-id is omitted, the CLI auto-picks the template
* returned by GET /deployments/models whose charge_type
* matches billing_method; --capacity defaults to that
* template's `capacity_unit_per_instance` (the smallest
* valid multiple of base_capacity).
* Plan-specific behaviour (required flags / body assembly / confirm rows /
* auto-pick) lives in `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file
* only handles the shared envelope: argument parsing, dispatch, dry-run,
* confirmation prompt, and result formatting. Adding a new plan = one entry
* in the strategy table; nothing here changes.
*
* `--model` (model identifier) and `--name` (console display name) are required.
*/
@@ -116,119 +106,22 @@ export default defineCommand({
if (!name) failIfMissing("name", "bl deploy create --model <model_name> --name <display_name>");
const plan = (flags.plan as string | undefined) || "lora";
let templateId = flags.templateId as string | undefined;
const inputTpm = flags.inputTpm as number | undefined;
const outputTpm = flags.outputTpm as number | undefined;
const thinkingOutputTpm = flags.thinkingOutputTpm as number | undefined;
// mu-only: capacity (resource units) and billing_method (default POST_PAY,
// the only value the platform currently supports per the deploy doc).
let capacity = flags.capacity as number | undefined;
const billingMethod = (flags.billingMethod as string | undefined) || "POST_PAY";
const format = detectOutputFormat(config.output);
// Validate plan. The catalog lists plan names like `ptu_v2`, but the create
// endpoint only accepts `ptu` — so reject anything outside the supported set
// with a clear message instead of letting the API fail with a vague error.
const SUPPORTED_PLANS = ["lora", "ptu", "mu"] as const;
if (!(SUPPORTED_PLANS as readonly string[]).includes(plan)) {
throw new BailianError(
`Unsupported plan "${plan}". Supported plans: ${SUPPORTED_PLANS.join(", ")}.`,
ExitCode.USAGE,
);
}
// For plan=ptu, require throughput limits. The platform rejects creation
// without an explicit ptu_capacity ("Miss ptu capacity info") even though
// the doc lists 10000/1000 defaults.
if (plan === "ptu") {
if (inputTpm === undefined)
failIfMissing(
"input-tpm",
"bl deploy create --plan ptu --model <m> --name <n> --input-tpm <n> --output-tpm <n>",
);
if (outputTpm === undefined)
failIfMissing(
"output-tpm",
"bl deploy create --plan ptu --model <m> --name <n> --input-tpm <n> --output-tpm <n>",
);
}
// For plan=mu, auto-pick the template (preferring the one whose charge_type
// matches billing_method) and default capacity to the template's unit.
// Skip the catalog lookup when the user supplies --template-id explicitly —
// the model may be a fine-tuned custom model not present in the base
// catalog, and the lookup would otherwise throw a spurious error.
let autoPickedTemplate = false;
if (plan === "mu" && !config.dryRun && !templateId) {
try {
const resp = await listDeployableModels(config, {
modelSource: "base",
pageSize: 100,
version: "v1.0",
});
const payload = resp.output ?? resp.data;
const target = (payload?.models ?? []).find((m) => m.model_name === model);
const muPlan = target?.plans?.find((p) => p.plan === "mu");
const templates = muPlan?.templates ?? [];
if (templates.length === 0) {
throw new BailianError(
`No mu-plan template found for model "${model}". ` +
`Run \`bl deploy models --source base\` to inspect available models, ` +
`or pass --template-id explicitly.`,
ExitCode.USAGE,
);
}
// POST_PAY → post_paid template; fall back to the first available.
const wantChargeType = billingMethod === "POST_PAY" ? "post_paid" : "pre_paid";
const picked = templates.find((t) => t.charge_type === wantChargeType) ?? templates[0];
if (!picked?.template_id) {
throw new BailianError(
`No mu-plan template found for model "${model}". ` +
`Run \`bl deploy models --source base\` to inspect available models, ` +
`or pass --template-id explicitly.`,
ExitCode.USAGE,
);
}
templateId = picked.template_id;
autoPickedTemplate = true;
// capacity must be a multiple of base_capacity; default to the template's
// unit (capacity_unit_per_instance) which is the smallest valid value.
if (capacity === undefined) {
capacity = picked.roles?.unified?.capacity_unit_per_instance ?? 1;
}
} catch (e) {
if (e instanceof BailianError) throw e;
throw new BailianError(
`Failed to auto-pick template for plan=mu: ${(e as Error).message}. ` +
`Pass --template-id explicitly.`,
ExitCode.USAGE,
);
}
}
// Plan-specific behaviour is owned by `plans.ts`. The strategy:
// 1. Validates required flags (USAGE error if missing).
// 2. Resolves the body fragment + confirm rows (mu may auto-pick a
// template from the deployable-models catalog).
// Anything outside the strategy table is rejected with a USAGE error.
const strategy = pickPlanStrategy(plan);
strategy.validateFlags(flags);
const resolved = await strategy.resolve({ config, flags, model: model!, name: name! });
const body: Record<string, unknown> = {
model_name: model!,
name: name!,
plan,
...resolved.body,
};
if (plan === "ptu") {
const ptuCapacity: Record<string, number> = {
input_tpm: inputTpm!,
output_tpm: outputTpm!,
};
if (thinkingOutputTpm !== undefined) ptuCapacity.thinking_output_tpm = thinkingOutputTpm;
body.ptu_capacity = ptuCapacity;
} else if (plan === "mu") {
// mu requires capacity, billing_method and template_id (auto-picked above
// if --template-id was not supplied).
body.capacity = capacity ?? 1;
body.billing_method = billingMethod;
if (templateId) body.template_id = templateId;
} else {
// lora: capacity required by API but ignored (per the working example).
body.capacity = 1;
}
if (config.dryRun) {
emitResult({ action: "deploy.create", body }, format);
@@ -240,22 +133,9 @@ export default defineCommand({
"Create deployment:",
` model: ${model}`,
` name: ${name}`,
` plan: ${plan}${plan === "lora" ? " (Token-billed)" : plan === "ptu" ? " (Token-billed, provisioned throughput)" : ""}`,
` plan: ${plan}${resolved.planLabelSuffix ?? ""}`,
...resolved.confirmRows,
];
if (templateId) {
const hint = autoPickedTemplate ? " (auto-picked)" : "";
lines.push(` template_id: ${templateId}${hint}`);
}
if (plan === "mu") {
lines.push(` capacity: ${capacity ?? 1}`);
lines.push(` billing_method: ${billingMethod}`);
}
if (plan === "ptu") {
lines.push(` input_tpm: ${inputTpm}`);
lines.push(` output_tpm: ${outputTpm}`);
if (thinkingOutputTpm !== undefined)
lines.push(` thinking_output_tpm: ${thinkingOutputTpm}`);
}
process.stderr.write(lines.join("\n") + "\n");
const ok = await promptConfirm({ message: "Proceed?", initialValue: true });
if (!ok) {
+230
View File
@@ -0,0 +1,230 @@
/**
* Per-plan strategy table for `bl deploy create`.
*
* Each PlanStrategy owns one slice of plan-specific behaviour:
* - required-flag checks (USAGE errors when the user is missing something)
* - any pre-flight side-effects (e.g. mu auto-picks a template from the
* catalog; lora/ptu are pure)
* - the plan-specific body fragment for POST /api/v1/deployments
* - the plan-specific confirmation-panel rows
*
* The dispatcher in `create.ts` only knows about `STRATEGIES[plan]`. Adding a
* new plan = one new strategy object + one line in `STRATEGIES`. Nothing in
* `create.ts` needs to change. This collapses the 5 places where lora / ptu /
* mu used to be hard-coded (default value list / required-flag checks /
* auto-pick / body assembly / confirm rows) into one strategy entry per plan.
*/
import {
listDeployableModels,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
export interface PlanContext {
config: Config;
flags: GlobalFlags;
/** Underlying model identifier (`--model`). */
model: string;
/** Console display name (`--name`). */
name: string;
}
export interface PlanResolved {
/**
* Plan-specific fields to merge into the request body. The shared envelope
* (`{model_name, name, plan}`) is added by the caller.
*/
body: Record<string, unknown>;
/**
* Lines to append to the confirmation panel — each already formatted like
* ` key: value`.
*/
confirmRows: string[];
/**
* Suffix appended to the `plan: <name>` confirm row, e.g.
* ` (Token-billed)`. Empty / undefined when no annotation is needed.
*/
planLabelSuffix?: string;
}
export interface PlanStrategy {
/** Plan id, matches `--plan` CLI value. */
name: string;
/** Throws USAGE-coded BailianError when required flags are missing. */
validateFlags(flags: GlobalFlags): void;
/**
* Resolve plan-specific bits to a body fragment + confirm rows. May call
* into the API (e.g. mu auto-picks a template from the deployable-models
* catalog).
*/
resolve(ctx: PlanContext): Promise<PlanResolved>;
}
/**
* `lora` (Token-billed) — the CLI default. The API requires `capacity` even
* though it is ignored for token-billed plans (per the working example), so
* the CLI injects `1` as a placeholder.
*/
const loraStrategy: PlanStrategy = {
name: "lora",
validateFlags() {
/* no required flags */
},
async resolve(): Promise<PlanResolved> {
return {
body: { capacity: 1 },
confirmRows: [],
planLabelSuffix: " (Token-billed)",
};
},
};
/**
* `ptu` (Token-billed, provisioned throughput). The platform rejects creation
* without `ptu_capacity.input_tpm` / `output_tpm` ("Miss ptu capacity info")
* even though the doc lists 10000/1000 defaults — so the CLI treats them as
* required.
*/
const ptuStrategy: PlanStrategy = {
name: "ptu",
validateFlags(flags: GlobalFlags): void {
const usage =
"bl deploy create --plan ptu --model <m> --name <n> --input-tpm <n> --output-tpm <n>";
if (flags.inputTpm === undefined) failIfMissing("input-tpm", usage);
if (flags.outputTpm === undefined) failIfMissing("output-tpm", usage);
},
async resolve(ctx: PlanContext): Promise<PlanResolved> {
const inputTpm = ctx.flags.inputTpm as number;
const outputTpm = ctx.flags.outputTpm as number;
const thinkingOutputTpm = ctx.flags.thinkingOutputTpm as number | undefined;
const ptuCapacity: Record<string, number> = {
input_tpm: inputTpm,
output_tpm: outputTpm,
};
if (thinkingOutputTpm !== undefined) ptuCapacity.thinking_output_tpm = thinkingOutputTpm;
const rows = [` input_tpm: ${inputTpm}`, ` output_tpm: ${outputTpm}`];
if (thinkingOutputTpm !== undefined) rows.push(` thinking_output_tpm: ${thinkingOutputTpm}`);
return {
body: { ptu_capacity: ptuCapacity },
confirmRows: rows,
planLabelSuffix: " (Token-billed, provisioned throughput)",
};
},
};
/**
* `mu` (model-unit-billed). `capacity`, `billing_method` and `template_id` are
* all required by the API but every one has a CLI-side default:
* - billing_method defaults to POST_PAY (the only supported value).
* - template_id auto-picks from GET /deployments/models — the one whose
* `charge_type` matches `billing_method`, else the first available.
* - capacity defaults to the template's `capacity_unit_per_instance` (the
* smallest valid multiple of base_capacity).
*
* The catalog lookup is skipped when `--template-id` is supplied explicitly:
* fine-tuned custom models may not appear in the `source=base` catalog, and
* forcing the lookup would otherwise raise a spurious "no template" error.
* It is also skipped in dry-run mode to keep `--dry-run` side-effect-free.
*/
const muStrategy: PlanStrategy = {
name: "mu",
validateFlags() {
/* every required field has a default — nothing to assert up-front */
},
async resolve(ctx: PlanContext): Promise<PlanResolved> {
const billingMethod = (ctx.flags.billingMethod as string | undefined) || "POST_PAY";
let templateId = ctx.flags.templateId as string | undefined;
let capacity = ctx.flags.capacity as number | undefined;
let autoPickedTemplate = false;
if (!ctx.config.dryRun && !templateId) {
try {
const resp = await listDeployableModels(ctx.config, {
modelSource: "base",
pageSize: 100,
version: "v1.0",
});
const payload = resp.output ?? resp.data;
const target = (payload?.models ?? []).find((m) => m.model_name === ctx.model);
const muPlan = target?.plans?.find((p) => p.plan === "mu");
const templates = muPlan?.templates ?? [];
if (templates.length === 0) {
throw new BailianError(
`No mu-plan template found for model "${ctx.model}". ` +
`Run \`bl deploy models --source base\` to inspect available models, ` +
`or pass --template-id explicitly.`,
ExitCode.USAGE,
);
}
// POST_PAY → post_paid template; fall back to the first available.
const wantChargeType = billingMethod === "POST_PAY" ? "post_paid" : "pre_paid";
const picked = templates.find((t) => t.charge_type === wantChargeType) ?? templates[0];
if (!picked?.template_id) {
throw new BailianError(
`No mu-plan template found for model "${ctx.model}". ` +
`Run \`bl deploy models --source base\` to inspect available models, ` +
`or pass --template-id explicitly.`,
ExitCode.USAGE,
);
}
templateId = picked.template_id;
autoPickedTemplate = true;
if (capacity === undefined) {
capacity = picked.roles?.unified?.capacity_unit_per_instance ?? 1;
}
} catch (e) {
if (e instanceof BailianError) throw e;
throw new BailianError(
`Failed to auto-pick template for plan=mu: ${(e as Error).message}. ` +
`Pass --template-id explicitly.`,
ExitCode.USAGE,
);
}
}
const body: Record<string, unknown> = {
capacity: capacity ?? 1,
billing_method: billingMethod,
};
if (templateId) body.template_id = templateId;
const rows: string[] = [];
if (templateId) {
const hint = autoPickedTemplate ? " (auto-picked)" : "";
rows.push(` template_id: ${templateId}${hint}`);
}
rows.push(` capacity: ${capacity ?? 1}`);
rows.push(` billing_method: ${billingMethod}`);
return { body, confirmRows: rows };
},
};
/**
* Registry of supported plans. Adding a new plan = one entry here. The
* catalog lists some additional plan names (e.g. `ptu_v2`) that are NOT
* accepted by the create endpoint, so the dispatcher in `create.ts` will
* reject anything outside this table with a clear USAGE error.
*/
export const STRATEGIES: Record<string, PlanStrategy> = {
lora: loraStrategy,
ptu: ptuStrategy,
mu: muStrategy,
};
/** Throws USAGE if `plan` is not in the strategy table. */
export function pickPlanStrategy(plan: string): PlanStrategy {
const s = STRATEGIES[plan];
if (!s) {
throw new BailianError(
`Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`,
ExitCode.USAGE,
);
}
return s;
}
+58 -38
View File
@@ -12,6 +12,7 @@ import {
toServerTrainingType,
TRAINING_TYPES_CLI,
DEFAULT_TRAINING_TYPE,
formatIssue,
BailianError,
ExitCode,
type Config,
@@ -20,7 +21,6 @@ import {
type FineTuneHyperParameters,
type DatasetFile,
type DatasetSchema,
type ValidationResult,
} from "bailian-cli-core";
import { existsSync, statSync } from "fs";
import { basename } from "path";
@@ -38,19 +38,6 @@ function isLocalPath(token: string): boolean {
return existsSync(token) && statSync(token).isFile();
}
/**
* Format a single validation issue as a one-line string (mirrors
* `dataset upload` so the error surface stays consistent across both
* entry points into the same upload pipeline).
*/
function formatIssue(issue: ValidationResult["errors"][number]): string {
const where: string[] = [];
if (issue.line !== undefined) where.push(`line ${issue.line}`);
if (issue.path) where.push(issue.path);
const tag = where.length ? ` [${where.join(" · ")}]` : "";
return ` ${issue.severity.toUpperCase()} ${issue.code}${tag}: ${issue.message}`;
}
interface ResolvedDataset {
/**
* Tokens in input order. Local paths are kept as-is here (a placeholder
@@ -333,10 +320,22 @@ export default defineCommand({
if (flags.maxLength !== undefined) hp.max_length = flags.maxLength as number;
// batch_size: clamp to [8, 1024] (server hard constraint, undocumented).
// Surface the clamp on stderr instead of silently rewriting the user's
// value — otherwise the confirmation panel below would show a number the
// user never typed, with no audit trail. (Range observed on common SFT
// / SFT-LoRA training types; some bases like qwen3.6-flash report a wider
// range, so the warning explicitly mentions "server range".)
if (flags.batchSize !== undefined) {
let batchSize = flags.batchSize as number;
const requested = flags.batchSize as number;
let batchSize = requested;
if (batchSize < 8) batchSize = 8;
if (batchSize > 1024) batchSize = 1024;
if (batchSize !== requested && !config.quiet) {
process.stderr.write(
`warning: --batch-size ${requested} clamped to ${batchSize} ` +
`(server range [8, 1024] for the common training types).\n`,
);
}
hp.batch_size = batchSize;
}
@@ -388,8 +387,49 @@ export default defineCommand({
}
}
// Upload local paths now that the gate has cleared them. This swaps the
// placeholder path entries in `training.fileIds` / `validation?.fileIds`
// Pre-flight capability check: confirm the model actually supports the
// requested training type BEFORE any upload, so a wrong --model /
// --training-type combo doesn't burn storage on datasets that will never
// be trained against. listFoundationModels is a public API (no console
// login required); on lookup failure (network / 401 / etc.) we fall back
// to letting the server decide rather than blocking the submit.
if (!config.dryRun) {
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
try {
capability = await fetchModelCapability(config, model!);
} catch (error) {
if (!config.quiet) {
process.stderr.write(
`warning: model capability lookup failed (${(error as Error).message}); ` +
"proceeding without local pre-flight.\n",
);
}
}
if (capability && !listSupportedTrainingTypes(capability).includes(trainingType)) {
const supported = listSupportedTrainingTypes(capability);
throw new BailianError(
`Model "${model}" does not support training type "${trainingType}".`,
ExitCode.USAGE,
supported.length
? `This model supports: ${supported.join(", ")}.`
: "This model reports no supported training types.",
);
}
}
// Non-interactive guard — moved BEFORE upload. In CI / scripted mode the
// user must opt in via --yes; otherwise we must not silently consume quota
// OR upload any file. (Local validation is still allowed to run.)
if (!config.dryRun && !flags.yes && config.nonInteractive) {
throw new BailianError(
"Pass --yes to confirm fine-tune creation in non-interactive mode.",
ExitCode.USAGE,
);
}
// Upload local paths now that pre-flight (validation, batch-size gate,
// capability check, non-interactive guard) has cleared them. This swaps
// the placeholder path entries in `training.fileIds` / `validation?.fileIds`
// for real file-ids, so the body and confirmation panel below see ids.
let uploadedTraining: DatasetFile[] = [];
let uploadedValidation: DatasetFile[] = [];
@@ -434,23 +474,8 @@ export default defineCommand({
return;
}
// Pre-flight capability check: confirm the model actually supports the
// requested training type before consuming quota. listFoundationModels is a
// public API (no console login needed); on any lookup failure we fall back
// to letting the server decide rather than blocking the submit.
const capability = await fetchModelCapability(config, model!);
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.",
);
}
// Confirmation panel — destructive in the sense that it consumes quota.
// (Capability check and non-interactive guard already ran pre-upload.)
if (!flags.yes && !config.nonInteractive && !config.quiet) {
process.stderr.write("Create fine-tune job:\n");
process.stderr.write(` Model: ${body.model}\n`);
@@ -480,11 +505,6 @@ export default defineCommand({
emitBare("Cancelled.");
return;
}
} else if (!flags.yes && config.nonInteractive) {
throw new BailianError(
"Pass --yes to confirm fine-tune creation in non-interactive mode.",
ExitCode.USAGE,
);
}
const response = await createFineTune(config, body);
@@ -127,7 +127,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 { stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
@@ -138,17 +138,6 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
intent?: {
modelPreference?: { mode?: string; excludes?: string[]; targets?: string[] };
};
}>(stdout);
const pref = data.intent?.modelPreference;
expect(pref).toBeDefined();
const hasExcludes =
(pref?.excludes?.length ?? 0) > 0 ||
(pref?.mode !== "unconstrained" && pref?.mode !== undefined);
expect(hasExcludes).toBe(true);
}, 60_000);
// ---- Model preference: negative cases ----
+33
View File
@@ -101,6 +101,26 @@ export function isDashScopeE2EReady(): boolean {
}
}
/**
* Console-gateway 命令(quota / usage free / usage stats)的 E2E 就绪检查:
* 需 `BAILIAN_E2E=1` 且存在 console access_token(环境变量 `DASHSCOPE_ACCESS_TOKEN`
* 或 `~/.bailian/config.json` 的 `access_token`)。
*
* 仅检查 token 是否存在——无法本地判断是否过期。token 过期时 gated 用例仍会执行,
* 但用 `isConsoleAuthFailure` 把“session 未登录/已过期”的优雅报错视为通过,保持
* 与 deploy/dataset “无 key / 有效 key / 失效 key 均绿”的一致策略。
*/
export function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 在仅跑文本/记忆/知识库时跳过) */
export function isBailianE2EMediaEnabled(): boolean {
if (process.env.BAILIAN_E2E_MEDIA === "0") return false;
@@ -181,3 +201,16 @@ export function parseStdoutJson<T = unknown>(stdout: string): T {
const t = stdout.trim();
return JSON.parse(t) as T;
}
/**
* 判断一次 CLI 运行是否因 console session 未登录/已过期而失败。
*
* Console E2E 用例的 readiness 闸(`isConsoleE2EReady`)只能判断 token 是否存在,
* 无法判断是否过期;token 失效时 gated 用例仍会执行并拿到鉴权错误。本函数让用例
* 参考 deploy/dataset 的做法:只要 CLI 把鉴权错误优雅上抛(非零退出 + stderr 说明
* session 失效),即视为通过,而不是强求 exit 0 的成功输出。
*/
export function isConsoleAuthFailure(result: RunCliResult): boolean {
if (result.exitCode === 0) return false;
return /not logged in|has expired|NotLogined|Run `bl auth login/i.test(result.stderr);
}
+36 -121
View File
@@ -1,17 +1,5 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
import { readConfigFile } from "bailian-cli-core";
function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: quota", () => {
test("quota list --help 正常退出", async () => {
@@ -97,22 +85,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota list 文本输出包含英文表头", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"list",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Model");
expect(stdout).toContain("Req/min");
expect(stdout).toContain("Token/min");
expect(stdout).toContain("Max TPM");
const result = await runCli(["quota", "list", "--output", "text", "--no-color"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 指定模型返回结果", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"quota",
"list",
"--model",
@@ -121,13 +100,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3.6-plus");
expect(stdout).toMatch(/Total: 1 models/);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 不存在的模型报错", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"quota",
"list",
"--model",
@@ -135,23 +113,15 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"--output",
"text",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("no matching models found");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("no matching models found");
});
test("quota list JSON 输出包含 model/rpm/tpm/maxTPM", async () => {
const { stdout, stderr, exitCode } = await runCli(["quota", "list", "--output", "json"]);
expect(exitCode, stderr).toBe(0);
const data =
parseStdoutJson<
Array<{ model?: string; rpm?: number | null; tpm?: number | null; maxTPM?: number | null }>
>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
expect(data[0].model).toBeTypeOf("string");
expect(data[0].rpm).toBeTypeOf("number");
expect(data[0].tpm).toBeTypeOf("number");
expect(data[0].maxTPM).toBeTypeOf("number");
const result = await runCli(["quota", "list", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota request --dry-run 输出请求参数", async () => {
@@ -177,22 +147,16 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota request TPM 超范围报错", async () => {
const { stderr, exitCode } = await runCli([
"quota",
"request",
"--model",
"qwen3.6-plus",
"--tpm",
"999",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("out of range");
expect(stderr).toContain("Current");
expect(stderr).toContain("Range");
const result = await runCli(["quota", "request", "--model", "qwen3.6-plus", "--tpm", "999"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("out of range");
expect(result.stderr).toContain("Current");
expect(result.stderr).toContain("Range");
});
test("quota request 不支持提额的模型报错", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"quota",
"request",
"--model",
@@ -200,8 +164,9 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"--tpm",
"100000",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("not found");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("not found");
});
test("quota history --dry-run 输出请求参数", async () => {
@@ -256,22 +221,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota check 文本输出包含英文表头", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Model");
expect(stdout).toContain("RPM Usage/Limit");
expect(stdout).toContain("TPM Usage/Limit");
expect(stdout).toContain("Status");
const result = await runCli(["quota", "check", "--output", "text", "--no-color"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota check --model 指定单模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"quota",
"check",
"--model",
@@ -280,13 +236,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3.6-plus");
expect(stdout).toMatch(/Total: 1 models/);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota check --model 逗号分隔多模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"quota",
"check",
"--model",
@@ -295,54 +250,14 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3.6-plus");
expect(stdout).toContain("qwen-plus");
expect(stdout).toMatch(/Total: 2 models/);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota check JSON 输出包含用量和限额字段", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--model",
"qwen3.6-plus",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<
Array<{
model?: string;
rpmUsage?: number;
rpmLimit?: number;
tpmUsage?: number;
tpmLimit?: number;
}>
>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(1);
expect(data[0].model).toBe("qwen3.6-plus");
expect(data[0].rpmUsage).toBeTypeOf("number");
expect(data[0].rpmLimit).toBeTypeOf("number");
expect(data[0].tpmUsage).toBeTypeOf("number");
expect(data[0].tpmLimit).toBeTypeOf("number");
});
test("quota check 状态列显示 Normal/Near limit/Rate Limited 之一", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--model",
"qwen3.6-plus",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
const hasStatus =
stdout.includes("Normal") || stdout.includes("Near limit") || stdout.includes("Rate Limited");
expect(hasStatus).toBe(true);
const result = await runCli(["quota", "check", "--model", "qwen3.6-plus", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota history --dry-run --page 2 --page-size 20", async () => {
+31 -78
View File
@@ -1,17 +1,5 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
import { readConfigFile } from "bailian-cli-core";
function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: usage free", () => {
test("usage 分组展示子命令帮助且退出码为 0", async () => {
@@ -113,34 +101,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
});
test("usage free --model 单模型查询返回 JSON 结果", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<
Array<{
model?: string;
type?: string | null;
remaining?: number | null;
total?: number | null;
usagePercent?: number | null;
expires?: string | null;
autoStop?: boolean | string | null;
}>
>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
expect(data[0].model).toBe("qwen3-max");
expect(data[0].type).toBeTypeOf("string");
const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 单模型文本输出包含表头", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -149,17 +116,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Model");
expect(stdout).toContain("Type");
expect(stdout).toContain("Remaining/Total");
expect(stdout).toContain("Usage");
expect(stdout).toContain("Expires");
expect(stdout).toContain("Auto-Stop");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 文本输出包含模型名", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -168,12 +130,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3-max");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 逗号分隔多模型文本输出包含所有模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -182,13 +144,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3-max");
expect(stdout).toContain("qwen-turbo");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 文本输出包含正确的 Type 列", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -197,12 +158,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Text");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model quotaStatus 为 UNKNOWN 时 Auto-Stop 显示 Unsupported", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -211,12 +172,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Unsupported");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model quotaStatus 为 UNKNOWN 时额度显示为 -", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -225,15 +186,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
const lines = stdout.split("\n").filter((line) => line.includes("wan2.7-image"));
expect(lines.length).toBe(1);
expect(lines[0]).toContain("Vision");
expect(lines[0]).toContain("Unsupported");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 不存在的模型仍返回表格行", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -242,12 +200,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("nonexistent-model-xyz-12345");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model Auto-Stop 显示 ON、OFF 或 Unsupported", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -256,14 +214,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
const hasAutoStop =
stdout.includes("ON") || stdout.includes("OFF") || stdout.includes("Unsupported");
expect(hasAutoStop).toBe(true);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model --console-region cn-beijing 指定区域查询", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -273,10 +229,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<Array<{ model?: string }>>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
expect(data[0].model).toBe("qwen3-max");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
});
+42 -42
View File
@@ -1,18 +1,7 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
import { readConfigFile } from "bailian-cli-core";
function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
function getStaticWorkspaceId(): string | undefined {
if (process.env.BAILIAN_WORKSPACE_ID?.trim()) return process.env.BAILIAN_WORKSPACE_ID.trim();
try {
@@ -22,17 +11,27 @@ function getStaticWorkspaceId(): string | undefined {
return undefined;
}
// 当无静态 workspace-id 且 console 未登录/已过期时返回占位符,避免下游 dry-run
// 用例因 `--workspace-id undefined` 而崩溃;live 用例各自用 isConsoleAuthFailure
// 容忍鉴权失败。参考 deploy/dataset “无 key / 有效 / 失效 均绿”的策略。
const FALLBACK_WORKSPACE_ID = "ws-e2e-unavailable";
async function fetchDefaultWorkspaceId(): Promise<string> {
const staticId = getStaticWorkspaceId();
if (staticId) return staticId;
const { stdout } = await runCli(["workspace", "list", "--output", "json"]);
const result = JSON.parse(stdout);
const data = result?.data?.DataV2?.data?.data?.data ?? [];
const defaultWs = data.find((ws: { defaultAgent?: boolean }) => ws.defaultAgent);
if (defaultWs?.workspaceId) return defaultWs.workspaceId;
if (data.length > 0 && data[0].workspaceId) return data[0].workspaceId;
throw new Error("No workspace found for e2e tests");
const result = await runCli(["workspace", "list", "--output", "json"]);
if (isConsoleAuthFailure(result) || result.exitCode !== 0) return FALLBACK_WORKSPACE_ID;
try {
const parsed = JSON.parse(result.stdout);
const data = parsed?.data?.DataV2?.data?.data?.data ?? [];
const defaultWs = data.find((ws: { defaultAgent?: boolean }) => ws.defaultAgent);
if (defaultWs?.workspaceId) return defaultWs.workspaceId;
if (data.length > 0 && data[0].workspaceId) return data[0].workspaceId;
} catch {
/* fall through to placeholder */
}
return FALLBACK_WORKSPACE_ID;
}
describe("e2e: usage stats", () => {
@@ -159,19 +158,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats 概览模式返回 JSON 结果", async () => {
const { stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const result = await runCli(["usage", "stats", "--workspace-id", wsId, "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats 概览文本输出包含英文标签", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -180,11 +173,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats 概览文本输出包含 Token 用量", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -193,11 +187,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --model 单模型文本输出包含英文表头", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -208,11 +203,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --model 逗号分隔多模型返回多行", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -223,11 +219,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --model 不存在的模型返回空表格", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -238,11 +235,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --days 1 短时间范围正常返回", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -253,11 +251,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --type Vision 按类型过滤", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -268,6 +267,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
});
+22 -8
View File
@@ -17,6 +17,8 @@ import {
datasetFileEndpoint,
} from "../client/endpoints.ts";
import type { Config } from "../config/schema.ts";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type {
DatasetFile,
DatasetUploadResponse,
@@ -81,14 +83,26 @@ export async function uploadDataset(
gmt_create: body.created_at ? new Date(body.created_at * 1000).toISOString() : undefined,
};
}
// Last-resort: synthesize a minimal record from the request so callers don't
// crash on undefined. The CLI surfaces request_id via verbose anyway.
return {
file_id: body.id ?? "",
name: fileName,
size: stat.size,
purpose,
};
// No id in response → upload reported HTTP 200 but produced no usable record
// (the platform sometimes returns 200 + a business-failure body, e.g.
// `data.failed_uploads[].{code,message}`). Surface this loudly instead of
// synthesizing a fake-success record with file_id="" that the caller would
// then forward to `finetune create` as a phantom training file.
const failedUploads = body.data?.failed_uploads;
if (Array.isArray(failedUploads) && failedUploads.length > 0) {
const first = failedUploads[0] ?? {};
const code = first.code ? ` [${first.code}]` : "";
throw new BailianError(
`Dataset upload failed${code}: ${first.message ?? "no message returned"}`,
ExitCode.GENERAL,
`Server reported failure for ${fileName}. Re-run with --verbose to see the raw response.`,
);
}
throw new BailianError(
`Dataset upload of ${fileName} returned no file_id (HTTP 200 with empty payload).`,
ExitCode.GENERAL,
"The platform accepted the request but did not allocate a file_id. Retry the upload; if it recurs, contact platform support with the request id.",
);
}
export interface DatasetListParams {
+1
View File
@@ -7,6 +7,7 @@ export {
listSupportedFormats,
MAX_DATASET_BYTES,
parseDatasetSchemaFlag,
formatIssue,
} from "./validate/index.ts";
export type {
ValidatorSpec,
+17 -2
View File
@@ -47,8 +47,10 @@ export interface DatasetGetResponse {
/**
* POST /compatible-mode/v1/files response (OpenAI-compatible).
*
* Flat shape — there is no `data` envelope. `id` is the file handle to pass to
* fine-tune jobs; `purpose` is echoed back so callers can confirm it landed.
* Flat shape — there is no `data` envelope on success. `id` is the file handle
* to pass to fine-tune jobs; `purpose` is echoed back so callers can confirm
* it landed. On business-level failure (HTTP 200 + `data.failed_uploads`)
* `id` is absent and `data.failed_uploads[]` carries the platform's reason.
*/
export interface DatasetUploadResponse {
request_id?: string;
@@ -66,6 +68,19 @@ export interface DatasetUploadResponse {
status?: string;
/** Creation timestamp (Unix seconds). */
created_at?: number;
/**
* Failure envelope: HTTP 200 + business failure. When present the upload
* did NOT produce a file_id; callers must treat this as an error. Common
* cause: server-side schema rejection (e.g. malformed JSONL slipped past
* the local pre-flight).
*/
data?: {
failed_uploads?: Array<{
code?: string;
message?: string;
file_name?: string;
}>;
};
}
/** DELETE /api/v1/files/{file_id} response. */
@@ -0,0 +1,16 @@
import type { ValidationIssue } from "./types.ts";
/**
* Format a single validation issue as a one-line string.
*
* Shared across every entry point that surfaces dataset validation results
* (`dataset validate`, `dataset upload`, `finetune create`) so the error
* presentation stays consistent regardless of which command ran the validator.
*/
export function formatIssue(issue: ValidationIssue): string {
const where: string[] = [];
if (issue.line !== undefined) where.push(`line ${issue.line}`);
if (issue.path) where.push(issue.path);
const tag = where.length ? ` [${where.join(" · ")}]` : "";
return ` ${issue.severity.toUpperCase()} ${issue.code}${tag}: ${issue.message}`;
}
@@ -5,6 +5,7 @@ export {
listSupportedFormats,
} from "./registry.ts";
export { MAX_DATASET_BYTES, parseDatasetSchemaFlag } from "./common.ts";
export { formatIssue } from "./format.ts";
export type {
ValidatorSpec,
ValidateOpts,
+17 -235
View File
@@ -1,26 +1,20 @@
/**
* JSONL validator for ChatML-style datasets (e.g. SFT training data).
* JSONL validator — file-level scaffolding for the ChatML family.
*
* Schema scope: the `.jsonl` ChatML family. Two record shapes are recognized:
* - SFT: `{"messages": [{role, content}, ...]}`
* - DPO: `{"messages": [...], "chosen": {role, content}, "rejected": {...}}`
* `chosen`/`rejected` are single assistant messages — the preferred vs
* dispreferred response. Which shape is enforced is selected by
* `ValidateOpts.schema` (`"chatml"` | `"dpo"`), defaulting to per-record
* auto-detect. Other JSONL schemas (e.g. evaluation datasets with a different
* field shape) should ship their own validator and register it — the registry
* can be extended in the future to dispatch on `(extension, purpose)` rather
* than extension alone if a purpose-specific .jsonl schema appears.
*
* Two-stage strategy (see decision log):
* Per-record schema dispatch lives in `./schemas/` (`RecordSchemaSpec`). This
* module is only responsible for the two file-level passes:
* 1. Quick scan — readline pass over the entire file checking only that
* every non-empty line begins with '{' and ends with '}'. No JSON.parse.
* Catches the most common mistake: a pretty-printed JSON dumped under a
* .jsonl extension.
* 2. Sampled deep check — JSON.parse the first 50 lines, ~100 evenly spaced
* interior lines, and the last 10 lines, validating the ChatML structure
* (`messages` array with role/content). `--full-validate` lifts the
* sampling cap.
* interior lines, and the last 10 lines, then hand each parsed record to
* the schema registry. `--full-validate` lifts the sampling cap.
*
* Schema scope: today the only registered schemas are ChatML (SFT) and DPO
* (preference pairs). Both share the `{messages: [...]}` core. A future
* non-ChatML JSONL purpose (e.g. an evaluation dataset with a different
* shape) ships its own `RecordSchemaSpec` and registers it — no change here.
*/
import { createReadStream } from "fs";
import { createInterface } from "readline";
@@ -32,8 +26,7 @@ import type {
DatasetSchema,
} from "./types.ts";
import { makeIssue, pickSampleLines } from "./common.ts";
const VALID_ROLES = new Set(["system", "user", "assistant"]);
import { pickRecordSchema } from "./schemas/index.ts";
interface QuickScanResult {
totalLines: number;
@@ -124,59 +117,10 @@ async function deepCheck(
}
/**
* Structural checks for a single message object `{role, content}`. Shared by
* the `messages[]` entries and the DPO `chosen` / `rejected` preference fields
* (which are each a single assistant message). Caller-supplied `path` scopes
* the issue location (e.g. `messages[2]` vs `chosen`).
*/
function inspectMessageObject(msg: unknown, lineNo: number, path: string): ValidationIssue[] {
const out: ValidationIssue[] = [];
if (msg === null || typeof msg !== "object" || Array.isArray(msg)) {
out.push(
makeIssue("error", "MESSAGE_NOT_OBJECT", `Message must be an object.`, {
line: lineNo,
path,
}),
);
return out;
}
const record = msg as Record<string, unknown>;
const role = record.role;
const content = record.content;
if (typeof role !== "string" || !VALID_ROLES.has(role)) {
out.push(
makeIssue(
"error",
"INVALID_ROLE",
`Invalid role "${String(role)}". Expected one of: system, user, assistant.`,
{ line: lineNo, path: `${path}.role` },
),
);
}
if (typeof content !== "string") {
out.push(
makeIssue("error", "INVALID_CONTENT", `"content" must be a string (got ${typeof content}).`, {
line: lineNo,
path: `${path}.content`,
}),
);
}
return out;
}
/**
* Dispatch one record to the right schema inspector.
*
* SFT and DPO are not sibling schemas — DPO is a *superset* of SFT
* (`{messages:[...], chosen, rejected}` = the ChatML prompt + a preference
* pair). So this dispatcher only decides *whether* to also validate the
* preference fields; the `messages[]` core is always handled by
* `inspectChatMLRecord` (DPO calls into it).
*
* Schema selection mirrors the `ValidateOpts.schema` contract:
* - `"chatml"` → SFT only (preference fields ignored).
* - `"dpo"` → DPO, strictly (every record must carry chosen+rejected).
* - `undefined` (auto) → per record: DPO when `chosen`/`rejected` present, else SFT.
* Dispatch one record to the right schema inspector via the schema registry.
* The registry decides whether the record is DPO, ChatML, or some future
* shape — this function only owns the "is this even an object?" guard so the
* downstream specs can assume a real object.
*/
function inspectRecord(obj: unknown, lineNo: number, schema?: DatasetSchema): ValidationIssue[] {
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
@@ -190,170 +134,8 @@ function inspectRecord(obj: unknown, lineNo: number, schema?: DatasetSchema): Va
];
}
const record = obj as Record<string, unknown>;
const hasChosen = "chosen" in record;
const hasRejected = "rejected" in record;
const isDpo = schema === "dpo" || (schema === undefined && (hasChosen || hasRejected));
return isDpo
? inspectDPORecord(record, lineNo, hasChosen, hasRejected)
: inspectChatMLRecord(record, lineNo);
}
/**
* SFT (ChatML) record: `{"messages": [{role, content}, ...]}`.
*
* Validates the shared `messages[]` core that every ChatML-family record
* carries — including DPO, which is why `inspectDPORecord` delegates here for
* the prompt portion. `chosen`/`rejected`, if present on the record, are
* intentionally ignored: callers wanting those checked must go through DPO
* mode. Hard errors return as "error", advisory role-ordering checks as
* "warning".
*/
function inspectChatMLRecord(record: Record<string, unknown>, lineNo: number): ValidationIssue[] {
const out: ValidationIssue[] = [];
const messages = record.messages;
if (!Array.isArray(messages)) {
out.push(
makeIssue(
"error",
"MISSING_MESSAGES",
`Required field "messages" is missing or not an array.`,
{ line: lineNo, path: "messages" },
),
);
return out;
}
if (messages.length === 0) {
out.push(
makeIssue("error", "EMPTY_MESSAGES", `"messages" must contain at least one entry.`, {
line: lineNo,
path: "messages",
}),
);
return out;
}
let sawSystem = false;
let lastRole: string | undefined;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const path = `messages[${i}]`;
out.push(...inspectMessageObject(msg, lineNo, path));
const role = (msg as Record<string, unknown> | null)?.role;
if (role === "system") {
if (i !== 0) {
out.push(
makeIssue(
"warning",
"SYSTEM_NOT_FIRST",
`"system" message should appear at index 0; found at index ${i}.`,
{ line: lineNo, path: `${path}.role` },
),
);
}
sawSystem = true;
}
if (lastRole === role && (role === "user" || role === "assistant")) {
out.push(
makeIssue(
"warning",
"ROLE_NOT_ALTERNATING",
`Consecutive ${role} messages — user/assistant turns should typically alternate.`,
{ line: lineNo, path: `${path}.role` },
),
);
}
if (typeof role === "string") lastRole = role;
}
// Soft check: messages without any user role almost certainly indicate a bug.
if (!messages.some((m) => (m as Record<string, unknown>).role === "user")) {
out.push(
makeIssue("warning", "NO_USER_ROLE", `No "user" message found in this sample.`, {
line: lineNo,
path: "messages",
}),
);
}
if (sawSystem && messages.length === 1) {
out.push(
makeIssue("warning", "SYSTEM_ONLY", `Sample only contains a "system" message.`, {
line: lineNo,
path: "messages",
}),
);
}
return out;
}
/**
* DPO record: `{"messages": [...], "chosen": {role, content}, "rejected": {...}}`.
*
* The prompt context (`messages[]`) is validated by `inspectChatMLRecord`;
* this function adds the preference pair on top. `chosen`/`rejected` are each a
* single assistant message — the preferred vs dispreferred response — so they
* reuse `inspectMessageObject` with a scoped `path`.
*
* If the prompt is structurally broken (missing/empty `messages`), the SFT
* inspector already reported the hard error and we skip preference checks — a
* record missing its prompt is too broken to meaningfully check chosen/rejected
* on top, matching the original early-return semantics.
*/
function inspectDPORecord(
record: Record<string, unknown>,
lineNo: number,
hasChosen: boolean,
hasRejected: boolean,
): ValidationIssue[] {
const out = inspectChatMLRecord(record, lineNo);
const messages = record.messages;
if (!Array.isArray(messages) || messages.length === 0) return out;
if (!hasChosen) {
out.push(
makeIssue("error", "MISSING_CHOSEN", `DPO record is missing the "chosen" preference.`, {
line: lineNo,
path: "chosen",
}),
);
}
if (!hasRejected) {
out.push(
makeIssue("error", "MISSING_REJECTED", `DPO record is missing the "rejected" preference.`, {
line: lineNo,
path: "rejected",
}),
);
}
if (hasChosen) {
out.push(...inspectMessageObject(record.chosen, lineNo, "chosen"));
const role = (record.chosen as Record<string, unknown> | null)?.role;
if (typeof role === "string" && role !== "assistant") {
out.push(
makeIssue(
"warning",
"PREFERENCE_ROLE_NOT_ASSISTANT",
`"chosen" role should be "assistant" (got "${role}").`,
{ line: lineNo, path: "chosen.role" },
),
);
}
}
if (hasRejected) {
out.push(...inspectMessageObject(record.rejected, lineNo, "rejected"));
const role = (record.rejected as Record<string, unknown> | null)?.role;
if (typeof role === "string" && role !== "assistant") {
out.push(
makeIssue(
"warning",
"PREFERENCE_ROLE_NOT_ASSISTANT",
`"rejected" role should be "assistant" (got "${role}").`,
{ line: lineNo, path: "rejected.role" },
),
);
}
}
return out;
const spec = pickRecordSchema(record, schema);
return spec.inspect(record, lineNo);
}
export const jsonlValidator: ValidatorSpec = {
@@ -0,0 +1,155 @@
/**
* ChatML record schema — `{"messages": [{role, content}, ...]}` (SFT).
*
* Also acts as the registry's fallback / catch-all: when auto-detect runs
* and no more specific schema matches, ChatML is selected. `inspectMessageObject`
* lives here because it is the canonical per-message check; the DPO schema
* imports it to validate `chosen` / `rejected` preference messages.
*/
import { makeIssue } from "../common.ts";
import type { ValidationIssue } from "../types.ts";
import type { RecordSchemaSpec } from "./types.ts";
const VALID_ROLES = new Set(["system", "user", "assistant"]);
/**
* Structural checks for a single message object `{role, content}`. Shared by
* the `messages[]` entries and the DPO `chosen` / `rejected` preference fields
* (which are each a single assistant message). Caller-supplied `path` scopes
* the issue location (e.g. `messages[2]` vs `chosen`).
*/
export function inspectMessageObject(
msg: unknown,
lineNo: number,
path: string,
): ValidationIssue[] {
const out: ValidationIssue[] = [];
if (msg === null || typeof msg !== "object" || Array.isArray(msg)) {
out.push(
makeIssue("error", "MESSAGE_NOT_OBJECT", `Message must be an object.`, {
line: lineNo,
path,
}),
);
return out;
}
const record = msg as Record<string, unknown>;
const role = record.role;
const content = record.content;
if (typeof role !== "string" || !VALID_ROLES.has(role)) {
out.push(
makeIssue(
"error",
"INVALID_ROLE",
`Invalid role "${String(role)}". Expected one of: system, user, assistant.`,
{ line: lineNo, path: `${path}.role` },
),
);
}
if (typeof content !== "string") {
out.push(
makeIssue("error", "INVALID_CONTENT", `"content" must be a string (got ${typeof content}).`, {
line: lineNo,
path: `${path}.content`,
}),
);
}
return out;
}
/**
* Validate the ChatML core (`messages[]`) of a record. DPO calls this
* delegate for the prompt portion of its records. Hard errors are emitted as
* "error"; role-ordering / role-presence advisories are "warning".
*/
export function inspectChatMLRecord(
record: Record<string, unknown>,
lineNo: number,
): ValidationIssue[] {
const out: ValidationIssue[] = [];
const messages = record.messages;
if (!Array.isArray(messages)) {
out.push(
makeIssue(
"error",
"MISSING_MESSAGES",
`Required field "messages" is missing or not an array.`,
{ line: lineNo, path: "messages" },
),
);
return out;
}
if (messages.length === 0) {
out.push(
makeIssue("error", "EMPTY_MESSAGES", `"messages" must contain at least one entry.`, {
line: lineNo,
path: "messages",
}),
);
return out;
}
let sawSystem = false;
let lastRole: string | undefined;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const path = `messages[${i}]`;
out.push(...inspectMessageObject(msg, lineNo, path));
const role = (msg as Record<string, unknown> | null)?.role;
if (role === "system") {
if (i !== 0) {
out.push(
makeIssue(
"warning",
"SYSTEM_NOT_FIRST",
`"system" message should appear at index 0; found at index ${i}.`,
{ line: lineNo, path: `${path}.role` },
),
);
}
sawSystem = true;
}
if (lastRole === role && (role === "user" || role === "assistant")) {
out.push(
makeIssue(
"warning",
"ROLE_NOT_ALTERNATING",
`Consecutive ${role} messages — user/assistant turns should typically alternate.`,
{ line: lineNo, path: `${path}.role` },
),
);
}
if (typeof role === "string") lastRole = role;
}
// Soft check: messages without any user role almost certainly indicate a bug.
if (!messages.some((m) => (m as Record<string, unknown>).role === "user")) {
out.push(
makeIssue("warning", "NO_USER_ROLE", `No "user" message found in this sample.`, {
line: lineNo,
path: "messages",
}),
);
}
if (sawSystem && messages.length === 1) {
out.push(
makeIssue("warning", "SYSTEM_ONLY", `Sample only contains a "system" message.`, {
line: lineNo,
path: "messages",
}),
);
}
return out;
}
/**
* ChatML / SFT schema. The auto-detect predicate is `true` so it acts as the
* registry fallback — any record that isn't picked up by a more specific
* schema (DPO etc.) falls through to ChatML.
*/
export const chatmlSchema: RecordSchemaSpec = {
name: "chatml",
detect: () => true,
inspect: inspectChatMLRecord,
};
@@ -0,0 +1,82 @@
/**
* DPO record schema — `{"messages": [...], "chosen": {role,content}, "rejected": {...}}`.
*
* DPO is a superset of ChatML: it carries the same `messages[]` prompt plus
* a preference pair. So this spec delegates the prompt validation to the
* ChatML inspector and only adds the chosen / rejected checks on top. If the
* prompt is too broken to inspect (no `messages[]`), the preference checks
* are skipped to keep the report focused — matching the original early-return
* semantics.
*/
import { makeIssue } from "../common.ts";
import type { ValidationIssue } from "../types.ts";
import type { RecordSchemaSpec } from "./types.ts";
import { inspectChatMLRecord, inspectMessageObject } from "./chatml.ts";
function inspectDPORecord(record: Record<string, unknown>, lineNo: number): ValidationIssue[] {
const out = inspectChatMLRecord(record, lineNo);
const messages = record.messages;
if (!Array.isArray(messages) || messages.length === 0) return out;
const hasChosen = "chosen" in record;
const hasRejected = "rejected" in record;
if (!hasChosen) {
out.push(
makeIssue("error", "MISSING_CHOSEN", `DPO record is missing the "chosen" preference.`, {
line: lineNo,
path: "chosen",
}),
);
}
if (!hasRejected) {
out.push(
makeIssue("error", "MISSING_REJECTED", `DPO record is missing the "rejected" preference.`, {
line: lineNo,
path: "rejected",
}),
);
}
if (hasChosen) {
out.push(...inspectMessageObject(record.chosen, lineNo, "chosen"));
const role = (record.chosen as Record<string, unknown> | null)?.role;
if (typeof role === "string" && role !== "assistant") {
out.push(
makeIssue(
"warning",
"PREFERENCE_ROLE_NOT_ASSISTANT",
`"chosen" role should be "assistant" (got "${role}").`,
{ line: lineNo, path: "chosen.role" },
),
);
}
}
if (hasRejected) {
out.push(...inspectMessageObject(record.rejected, lineNo, "rejected"));
const role = (record.rejected as Record<string, unknown> | null)?.role;
if (typeof role === "string" && role !== "assistant") {
out.push(
makeIssue(
"warning",
"PREFERENCE_ROLE_NOT_ASSISTANT",
`"rejected" role should be "assistant" (got "${role}").`,
{ line: lineNo, path: "rejected.role" },
),
);
}
}
return out;
}
/**
* DPO schema. Auto-detect: a record is treated as DPO if it carries either
* `chosen` or `rejected` — we deliberately match on EITHER (not both) so a
* record that has only one of the pair still hits the DPO inspector and gets
* a precise "missing rejected" / "missing chosen" error instead of falling
* through to ChatML where the preference fields would be silently ignored.
*/
export const dpoSchema: RecordSchemaSpec = {
name: "dpo",
detect: (record) => "chosen" in record || "rejected" in record,
inspect: inspectDPORecord,
};
@@ -0,0 +1,43 @@
/**
* Record-schema registry — single point of truth for "which schemas can a
* `.jsonl` record carry, and how do we dispatch to the right one?"
*
* Routing:
* - When `--schema <name>` is given, dispatch by exact name.
* - When `--schema` is omitted, walk the registry in declared order and
* pick the first entry whose `detect()` returns true. ChatML is the
* catch-all fallback (its detect is `true`), so place more specific
* schemas BEFORE it.
*
* Adding a new schema: see `types.ts` for the recipe.
*/
import type { DatasetSchema } from "../types.ts";
import type { RecordSchemaSpec } from "./types.ts";
import { chatmlSchema } from "./chatml.ts";
import { dpoSchema } from "./dpo.ts";
// Order matters: DPO before ChatML (ChatML is the catch-all fallback).
export const RECORD_SCHEMAS: RecordSchemaSpec[] = [dpoSchema, chatmlSchema];
/**
* Pick the right schema for a single parsed record.
* - explicit `schema` → exact-name lookup (USAGE-safe: the CLI parser already
* rejects unknown values via `parseDatasetSchemaFlag`, so an unknown name
* here is an internal bug and falls back to ChatML).
* - auto (`schema === undefined`) → first `detect()` match in registry order;
* falls back to ChatML when no more specific schema claims the record.
*/
export function pickRecordSchema(
record: Record<string, unknown>,
schema?: DatasetSchema,
): RecordSchemaSpec {
if (schema !== undefined) {
const found = RECORD_SCHEMAS.find((s) => s.name === schema);
if (found) return found;
// Should not happen — CLI vocabulary is enforced before we get here.
return chatmlSchema;
}
return RECORD_SCHEMAS.find((s) => s.detect(record)) ?? chatmlSchema;
}
export type { RecordSchemaSpec } from "./types.ts";
@@ -0,0 +1,30 @@
/**
* Record-schema spec — the per-record dispatcher contract for `.jsonl`.
*
* The file format registry in `registry.ts` routes "which validator owns this
* extension" (today only `jsonl.ts`). Within a single .jsonl file there can
* still be multiple *record* schemas — e.g. SFT (ChatML) vs DPO. This sub-
* registry handles that finer-grained dispatch.
*
* Adding a new record schema:
* 1. Create `<schema>.ts` exporting a `RecordSchemaSpec` constant.
* 2. Append it to `RECORD_SCHEMAS` (more specific schemas FIRST so auto-
* detect picks them before the fallback).
* 3. Add the schema id to the `DatasetSchema` union in `../types.ts` and to
* `parseDatasetSchemaFlag` in `../common.ts`.
* That's it — `jsonl.ts` only knows about the dispatch interface.
*/
import type { DatasetSchema, ValidationIssue } from "../types.ts";
export interface RecordSchemaSpec {
/** Schema id — must match a value in the `DatasetSchema` union. */
name: DatasetSchema;
/**
* Auto-detect this schema for an arbitrary record when no `--schema` is
* given. The registry walks entries in declared order and picks the first
* match, so place more specific schemas before more general ones.
*/
detect(record: Record<string, unknown>): boolean;
/** Run schema-specific structural checks on the parsed record. */
inspect(record: Record<string, unknown>, lineNo: number): ValidationIssue[];
}