mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
fix: resolve cr issue
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
listSupportedFormats,
|
||||
MAX_DATASET_BYTES,
|
||||
parseDatasetSchemaFlag,
|
||||
formatIssue,
|
||||
} from "./validate/index.ts";
|
||||
export type {
|
||||
ValidatorSpec,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user