Compare commits

...

11 Commits

Author SHA1 Message Date
故璃 5d1b7aac3a Merge branch 'main' into feat/cli-skill-sync 2026-08-11 10:47:45 +08:00
故璃 5007b9b574 feat: change output to json 2026-08-07 16:35:47 +08:00
故璃 8286a74fb6 test: remove sync pipeline verification marker 2026-08-07 14:40:33 +08:00
故璃 9eb2acbb65 test: trigger skills sync pipeline 2026-08-07 14:38:40 +08:00
故璃 1d35326c86 ci: read FC trigger url from variables 2026-08-07 14:36:42 +08:00
故璃 eb6c2b8e2a Merge branch 'feat/model-finetune-opt' into feat/cli-skill-sync 2026-08-07 14:26:38 +08:00
故璃 ebd6226a9f ci: rename trigger secret to FC_TRIGGER_URL 2026-08-07 14:25:49 +08:00
故璃 e25d3b0b8e ci: add workflow to publish skills to OSS 2026-08-07 14:13:47 +08:00
故璃 3c64461cca feat: video finetune/deploy/invoke full pipeline + training cost calculation
- Add finetune video create subcommand (wan2.7/2.5/2.2 i2v + kf2v)
- Align video hyperparams with official docs (n_epochs=50, per-model batch_size/max_pixels)
- Add --last-frame flag to video generate for kf2v (image2video endpoint)
- Fix wan2.1-2.6 i2v input format (img_url instead of media[])
- Add training_cost field to finetune get/watch (catalog ft price, API-key domain only)
- Add --aigc-* flags to deploy create (optional, for video LoRA prompt config)
2026-08-07 07:03:01 +08:00
故璃 f30fff9065 feat(finetune): clarify model flags; add price estimate & actual cost
- Rename for clarity: finetune --model → --base-model (create/price/
  capability/list); deploy create --model → --model-name, --name →
  --display-name
- Add `finetune price` (console domain) for pre-training cost estimate
  (sft/dpo/cpt)
- Add actual training cost (fee.ts) enriched into finetune get/watch
  from catalog price × reported usage
2026-08-06 15:08:02 +08:00
故璃 a7245c0f62 feat(deploy): add pause/resume commands; JSON-only output for dataset/finetune/deploy
- Add `bl deploy pause` and `bl deploy resume` (console domain, first
  console-auth commands in deploy group) via modelInstance start/stop APIs
- Add core deploy/lifecycle.ts with input-wrapped console gateway calls
- Switch all dataset/finetune/deploy commands to JSON-only output, removing
  text formatting logic
- Expose usage/charge_type in finetune get, model_name/expire_time in
  finetune checkpoints with near-expiry warning
- Update deploy delete hint to suggest `bl deploy pause`
2026-08-06 11:34:48 +08:00
48 changed files with 1647 additions and 830 deletions
+27
View File
@@ -0,0 +1,27 @@
# Poke the FC publish-skills flow after skills/ changes land.
# The FC side reconciles this repo's skills/ directory against OSS
# (bailian-wiki/skills/) using the repo HEAD snapshot as the only
# source of truth — the request itself carries no content. Both the
# repo and branch params are validated against FC-side whitelists
# (PUBLISH_REPOS / PUBLISH_BRANCHES).
#
# feat/cli-skill-sync is temporary for end-to-end testing; remove it
# (here and from the FC PUBLISH_BRANCHES whitelist) once the sync
# link is verified on main.
name: Publish skills to OSS
on:
push:
branches:
- main
- feat/cli-skill-sync
paths:
- "skills/**"
jobs:
poke:
runs-on: ubuntu-latest
steps:
- name: Trigger FC publish-skills
run: |
curl -sf -X POST "${{ vars.FC_TRIGGER_URL }}/publish-skills?repo=modelstudioai/cli&branch=${{ github.ref_name }}"
+8
View File
@@ -62,6 +62,7 @@ import {
finetuneTextCreate,
finetuneAudioCreate,
finetuneImageCreate,
finetuneVideoCreate,
finetuneList,
finetuneGet,
finetuneCancel,
@@ -71,6 +72,7 @@ import {
finetuneExport,
finetuneWatch,
finetuneCapability,
finetunePrice,
deployTextCreate,
deployAudioCreate,
deployImageCreate,
@@ -80,6 +82,8 @@ import {
deployScale,
deployUpdate,
deployDelete,
deployPause,
deployResume,
tokenPlanListSeats,
tokenPlanCreateKey,
tokenPlanAssignSeats,
@@ -181,6 +185,7 @@ export const commands: Record<string, AnyCommand> = {
"finetune text create": finetuneTextCreate,
"finetune audio create": finetuneAudioCreate,
"finetune image create": finetuneImageCreate,
"finetune video create": finetuneVideoCreate,
"finetune list": finetuneList,
"finetune get": finetuneGet,
"finetune cancel": finetuneCancel,
@@ -190,6 +195,7 @@ export const commands: Record<string, AnyCommand> = {
"finetune export": finetuneExport,
"finetune watch": finetuneWatch,
"finetune capability": finetuneCapability,
"finetune price": finetunePrice,
"deploy text create": deployTextCreate,
"deploy audio create": deployAudioCreate,
"deploy image create": deployImageCreate,
@@ -199,6 +205,8 @@ export const commands: Record<string, AnyCommand> = {
"deploy scale": deployScale,
"deploy update": deployUpdate,
"deploy delete": deployDelete,
"deploy pause": deployPause,
"deploy resume": deployResume,
"token-plan list-seats": tokenPlanListSeats,
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, deleteDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, deleteDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DELETE_FLAGS = {
fileId: {
@@ -19,20 +19,18 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const fileId = flags.fileId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "dataset.delete", file_id: fileId }, format);
emitResult({ action: "dataset.delete", file_id: fileId }, "json");
return;
}
const response = await deleteDataset(ctx.client, fileId);
if (settings.quiet || format === "text") {
emitBare(`Deleted ${fileId}.`);
emitRequestId(response.request_id, settings.quiet);
if (settings.quiet) {
emitBare(fileId);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
+7 -17
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, getDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, getDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const GET_FLAGS = {
fileId: {
@@ -19,10 +19,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const fileId = flags.fileId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "dataset.get", file_id: fileId }, format);
emitResult({ action: "dataset.get", file_id: fileId }, "json");
return;
}
@@ -45,19 +44,10 @@ export default defineCommand({
description: file.description ?? "",
};
if (format === "json") {
emitResult({ ...item, request_id: response.request_id }, format);
return;
if (settings.quiet) {
emitBare(item.file_id);
} else {
emitResult({ ...item, request_id: response.request_id }, "json");
}
// text / quiet
emitBare(`file_id: ${item.file_id}`);
emitBare(`name: ${item.name}`);
emitBare(`size: ${item.size}`);
if (item.md5) emitBare(`md5: ${item.md5}`);
if (item.purpose) emitBare(`purpose: ${item.purpose}`);
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
if (item.description) emitBare(`description: ${item.description}`);
emitRequestId(response.request_id, settings.quiet);
},
});
+7 -19
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, listDatasets, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listDatasets, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -23,7 +23,6 @@ export default defineCommand({
exampleArgs: ["", "--purpose fine-tune", "--purpose evaluation --page-size 20", "--output json"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -33,7 +32,7 @@ export default defineCommand({
page_size: flags.pageSize,
purpose: flags.purpose,
},
format,
"json",
);
return;
}
@@ -46,7 +45,6 @@ export default defineCommand({
const files = response.data?.files ?? [];
const total = response.data?.total;
// Normalize to consistent structure for both text/json output.
const items = files.map((item) => ({
file_id: item.file_id ?? "",
name: item.name ?? "",
@@ -54,20 +52,10 @@ export default defineCommand({
purpose: item.purpose ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
if (settings.quiet) {
for (const item of items) emitBare(item.file_id);
} else {
emitResult({ items, total, request_id: response.request_id }, "json");
}
// text / quiet
if (items.length === 0) {
emitBare("No dataset files found.");
return;
}
const headers = ["FILE_ID", "NAME", "SIZE", "PURPOSE"];
const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -1,6 +1,5 @@
import {
defineCommand,
detectOutputFormat,
uploadDataset,
validateDataset,
parseDatasetSchemaFlag,
@@ -11,7 +10,7 @@ import {
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const UPLOAD_FLAGS = {
file: {
@@ -80,7 +79,6 @@ export default defineCommand({
`Supported schemas: chatml, dpo, cpt, tts, image.`,
);
}
const format = detectOutputFormat(settings.output);
// Image schema allows larger ZIPs (1 GB vs 300 MB for text).
const isMediaSchema = schema === "image";
@@ -129,7 +127,7 @@ export default defineCommand({
validate: !flags.noValidate,
schema: schema ?? "auto",
},
format,
"json",
);
return;
}
@@ -142,11 +140,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(file.file_id);
} else if (format === "text") {
emitBare(`Uploaded ${file.name} → file_id=${file.file_id}`);
emitRequestId(request_id, settings.quiet);
} else {
emitResult({ ...file, request_id }, format);
emitResult({ ...file, request_id }, "json");
}
},
});
@@ -1,26 +1,13 @@
import {
defineCommand,
detectOutputFormat,
validateDataset,
parseDatasetSchemaFlag,
formatIssue,
BailianError,
ExitCode,
type ValidationResult,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
function formatStats(result: ValidationResult): string[] {
const out: string[] = [];
if (result.stats.totalRecords !== undefined) out.push(`records: ${result.stats.totalRecords}`);
if (result.stats.sampledRecords !== undefined)
out.push(`sampled: ${result.stats.sampledRecords}`);
if (result.stats.bytes !== undefined) out.push(`bytes: ${result.stats.bytes}`);
if (result.stats.durationMs !== undefined) out.push(`took: ${result.stats.durationMs}ms`);
return out;
}
const VALIDATE_FLAGS = {
file: {
type: "string",
@@ -79,8 +66,6 @@ export default defineCommand({
`Supported schemas: chatml, dpo, cpt, tts, image.`,
);
}
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
@@ -89,38 +74,17 @@ export default defineCommand({
full: flags.fullValidate,
schema: schema ?? "auto",
},
format,
"json",
);
return;
}
const result = await validateDataset(filePath, { fullValidate: flags.fullValidate, schema });
if (format === "json") {
// For json output we always emit the structured result, exit code conveys validity.
emitResult(result, format);
} else if (settings.quiet) {
if (settings.quiet) {
emitBare(result.valid ? "ok" : "fail");
} else {
const status = result.valid ? "PASSED" : "FAILED";
emitBare(`Dataset validation ${status} for ${result.filePath}`);
const stats = formatStats(result);
if (stats.length) emitBare(` ${stats.join(" · ")}`);
if (result.errors.length) {
emitBare(`Errors (${result.errors.length}):`);
for (const error of result.errors.slice(0, 20)) emitBare(formatIssue(error));
if (result.errors.length > 20) {
emitBare(` … and ${result.errors.length - 20} more.`);
}
}
if (result.warnings.length) {
emitBare(`Warnings (${result.warnings.length}):`);
for (const warning of result.warnings.slice(0, 10)) emitBare(formatIssue(warning));
if (result.warnings.length > 10) {
emitBare(` … and ${result.warnings.length - 10} more.`);
}
}
emitResult(result, "json");
}
if (!result.valid) {
+25 -39
View File
@@ -1,6 +1,5 @@
import {
defineCommand,
detectOutputFormat,
createDeployment,
pickPlanStrategy,
STRATEGIES,
@@ -11,16 +10,16 @@ import {
type CommandContext,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const CREATE_FLAGS = {
model: {
modelName: {
type: "string",
valueHint: "<name>",
description: "Model name (catalog model or fine-tuned output) (required)",
valueHint: "<model_name>",
description: "Model to deploy — fine-tuned output name or catalog model (required)",
required: true,
},
name: {
displayName: {
type: "string",
valueHint: "<display_name>",
description: "Console display name for the deployment (required)",
@@ -64,7 +63,7 @@ const CREATE_FLAGS = {
} satisfies FlagsDef;
const CREATE_USAGE =
"--model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
"--model-name <model_name> --display-name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
const CREATE_NOTES = [
"Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-",
@@ -78,14 +77,11 @@ const CREATE_NOTES = [
"Use `bl deploy models --source base` to inspect available templates.",
"After creation, status starts at PENDING and transitions to RUNNING.",
"Invoke the deployed model with: bl text chat --model <deployed_model>",
"WARNING: --model is overloaded across commands and refers to DIFFERENT",
"values. `bl deploy <modality> create --model` takes the exported model_name",
"(e.g. `qwen3-8b-ft-...`), but the create response also returns a",
"`deployed_model` field (the deployment instance id, e.g.",
"`qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use",
"the `deployed_model` from the create response — NOT the `model_name` you",
"passed to `deploy <modality> create`. Do not reuse the value across the two",
"commands.",
"NOTE: --model-name is the model being deployed (e.g. `qwen3-8b-ft-...`).",
"The create response also returns a `deployed_model` field — the deployment",
"instance id (e.g. `qwen3-8b-5ecb5f068d79`). Use that id for inference",
"(`bl text chat --model <deployed_model>`) and lifecycle commands",
"(`deploy get/scale/pause/resume/delete --deployed-model <id>`).",
];
/**
@@ -119,10 +115,9 @@ async function runCreate(
ctx: CommandContext<typeof CREATE_FLAGS>,
): Promise<void> {
const { identity, settings, flags } = ctx;
const model = flags.model as string;
const name = flags.name as string;
const model = flags.modelName as string;
const name = flags.displayName as string;
const plan = (flags.plan as string | undefined) || defaultDeployPlan(modality);
const format = detectOutputFormat(settings.output);
// Plan-specific behaviour is owned by core `plans.ts`. The strategy resolves
// the plan-specific body fragment (mu may auto-pick a template from the
@@ -146,7 +141,7 @@ async function runCreate(
};
if (settings.dryRun) {
emitResult({ action: "deploy.create", body }, format);
emitResult({ action: "deploy.create", body }, "json");
return;
}
@@ -155,17 +150,8 @@ async function runCreate(
if (settings.quiet) {
emitBare(deployment?.deployed_model ?? "");
} else if (format === "text") {
emitBare(`Created deployment.`);
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
if (deployment?.status) emitBare(` status: ${deployment.status}`);
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
emitBare(
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
}
@@ -176,10 +162,10 @@ export const deployTextCreate = defineCommand({
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-qwen-sft --name my-sft-test",
"--model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
"--model qwen3-8b --name my-qwen3-mu --plan mu",
"--model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
"--model-name my-qwen-sft --display-name my-sft-test",
"--model-name qwen3.6-flash-2026-04-16 --display-name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
"--model-name qwen3-8b --display-name my-qwen3-mu --plan mu",
"--model-name qwen3-8b --display-name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("text", flags),
@@ -193,9 +179,9 @@ export const deployAudioCreate = defineCommand({
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-cosyvoice-ft --name my-tts",
"--model my-cosyvoice-ft --name my-tts --deploy-spec dps-xxxx --capacity 1",
"--model my-cosyvoice-ft --name my-tts --dry-run",
"--model-name my-cosyvoice-ft --display-name my-tts",
"--model-name my-cosyvoice-ft --display-name my-tts --deploy-spec dps-xxxx --capacity 1",
"--model-name my-cosyvoice-ft --display-name my-tts --dry-run",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("audio", flags),
@@ -209,9 +195,9 @@ export const deployImageCreate = defineCommand({
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-wan-ft --name my-wan",
"--model my-wan-ft --name my-wan-mu --plan mu",
"--model my-wan-ft --name my-wan --dry-run",
"--model-name my-wan-ft --display-name my-wan",
"--model-name my-wan-ft --display-name my-wan-mu --plan mu",
"--model-name my-wan-ft --display-name my-wan --dry-run",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("image", flags),
@@ -1,13 +1,12 @@
import {
defineCommand,
detectOutputFormat,
deleteDeployment,
getDeployment,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DELETE_FLAGS = {
deployedModel: {
@@ -38,10 +37,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "deploy.delete", deployed_model: deployedModel }, format);
emitResult({ action: "deploy.delete", deployed_model: deployedModel }, "json");
return;
}
@@ -55,7 +53,8 @@ export default defineCommand({
if (status && status !== "STOPPED" && status !== "FAILED") {
throw new BailianError(
`Deployment ${deployedModel} is ${status}. Only STOPPED / FAILED deployments can be deleted. ` +
`Stop it first via the platform console, or pass --skip-precheck to attempt deletion anyway.`,
`Run \`bl deploy pause --deployed-model ${deployedModel}\` to pause it first, ` +
`or pass --skip-precheck to attempt deletion anyway.`,
ExitCode.USAGE,
);
}
@@ -69,11 +68,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(deployedModel);
} else if (format === "text") {
emitBare(`Deleted ${deployedModel}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
+5 -18
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, getDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, getDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const GET_FLAGS = {
deployedModel: {
@@ -22,10 +22,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "deploy.get", deployed_model: deployedModel }, format);
emitResult({ action: "deploy.get", deployed_model: deployedModel }, "json");
return;
}
@@ -33,7 +32,7 @@ export default defineCommand({
const deployment = response.output ?? response.data;
if (!deployment) {
emitBare(`No data returned for ${deployedModel}`);
emitResult({ deployed_model: deployedModel, request_id: response.request_id }, "json");
return;
}
@@ -57,18 +56,6 @@ export default defineCommand({
if (deployment.gmt_create) item.created_at = deployment.gmt_create;
if (deployment.gmt_modified) item.updated_at = deployment.gmt_modified;
if (format === "json") {
emitResult({ ...item, request_id: response.request_id }, format);
return;
}
// text / quiet — fixed-width label column for alignment
const label = (key: string) => `${key}:`.padEnd(18);
for (const [key, value] of Object.entries(item)) {
if (value === "" || value === undefined) continue;
const display = typeof value === "string" ? value : JSON.stringify(value);
emitBare(`${label(key)}${display}`);
}
emitRequestId(response.request_id, settings.quiet);
emitResult({ ...item, request_id: response.request_id }, "json");
},
});
+4 -31
View File
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
listDeployments,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listDeployments, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -28,13 +23,12 @@ export default defineCommand({
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const status = flags.status || undefined;
if (settings.dryRun) {
emitResult(
{ action: "deploy.list", page: flags.page, page_size: flags.pageSize, status },
format,
"json",
);
return;
}
@@ -57,27 +51,6 @@ export default defineCommand({
created_at: item.gmt_create ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
// text / quiet
if (items.length === 0) {
emitBare("No deployments found.");
return;
}
const headers = ["DEPLOYED_MODEL", "MODEL_NAME", "STATUS", "PLAN", "CAPACITY", "CREATED_AT"];
const rows = items.map((item) => [
item.deployed_model,
item.model_name,
item.status,
item.plan,
item.capacity,
item.created_at,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
emitResult({ items, total, request_id: response.request_id }, "json");
},
});
+49 -102
View File
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
listDeployableModels,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listDeployableModels, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const MODELS_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -39,7 +34,6 @@ export default defineCommand({
],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
// Default version to v1.0 — without it, the API returns the legacy catalog
// (only old fine-tune outputs). Pass --catalog-version "" to opt out.
const version = flags.catalogVersion === "" ? undefined : (flags.catalogVersion ?? "v1.0");
@@ -54,7 +48,7 @@ export default defineCommand({
version,
model_source: modelSource,
},
format,
"json",
);
return;
}
@@ -72,102 +66,55 @@ export default defineCommand({
// Two response shapes:
// - custom (fine-tuned): top-level supported_plans: string[]
// - base (catalog): plans: [{plan, templates?, cu_specs?}]
// For json: surface the deployment-relevant fields preserved as a tree, so
// Surface the deployment-relevant fields preserved as a tree, so
// downstream tooling can drive `bl deploy <modality> create --deploy-spec <…>`
// without a second round-trip. For text: keep the compact one-line summary.
if (format === "json") {
const items = models.map((model) => {
const out: Record<string, unknown> = {
model_name: model.model_name ?? "",
};
if (model.base_model) out.base_model = model.base_model;
if (model.model_source) out.model_source = model.model_source;
if (model.supported_plans && model.supported_plans.length > 0) {
out.supported_plans = model.supported_plans;
}
if (model.plans && model.plans.length > 0) {
out.plans = model.plans.map((plan) => {
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
if (plan.cu_specs && plan.cu_specs.length > 0) {
planEntry.cu_specs = plan.cu_specs;
}
if (plan.templates && plan.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
// Drop noisy/redundant: template_source, template_type,
// template_version, deploy_spec (typically == template_id).
planEntry.templates = plan.templates.map((template) => {
const tpl: Record<string, unknown> = {};
if (template.template_id) tpl.template_id = template.template_id;
if (template.template_name) tpl.template_name = template.template_name;
if (template.charge_type) tpl.charge_type = template.charge_type;
// Flatten roles.unified for the common COUPLED case.
const unified = template.roles?.unified;
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
if (unified?.capacity_unit_per_instance !== undefined)
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
// Preserve split-role configs (SEPERATED) as-is so callers
// can still drive prefill/decode sizing.
if (template.roles?.prefill || template.roles?.decode) {
tpl.roles = {
prefill: template.roles?.prefill,
decode: template.roles?.decode,
};
}
if (template.template_desc) tpl.template_desc = template.template_desc;
return tpl;
});
}
return planEntry;
});
}
return out;
});
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
// text / quiet — keep the compact single-line summary table.
const textItems = models.map((model) => {
let plansSummary = "";
if (model.supported_plans && model.supported_plans.length > 0) {
plansSummary = model.supported_plans.join(",");
} else if (model.plans && model.plans.length > 0) {
plansSummary = model.plans
.map((plan) => {
const planName = plan.plan ?? "?";
if (plan.templates && plan.templates.length > 0) {
return `${planName}(${plan.templates.length}t)`;
}
if (plan.cu_specs && plan.cu_specs.length > 0) {
return `${planName}(${plan.cu_specs.join("/")})`;
}
return planName;
})
.join(",");
} else {
plansSummary = "-";
}
return {
// without a second round-trip.
const items = models.map((model) => {
const out: Record<string, unknown> = {
model_name: model.model_name ?? "",
base_model: model.base_model ?? "",
source: model.model_source ?? "",
plans: plansSummary,
};
if (model.base_model) out.base_model = model.base_model;
if (model.model_source) out.model_source = model.model_source;
if (model.supported_plans && model.supported_plans.length > 0) {
out.supported_plans = model.supported_plans;
}
if (model.plans && model.plans.length > 0) {
out.plans = model.plans.map((plan) => {
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
if (plan.cu_specs && plan.cu_specs.length > 0) {
planEntry.cu_specs = plan.cu_specs;
}
if (plan.templates && plan.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
// Drop noisy/redundant: template_source, template_type,
// template_version, deploy_spec (typically == template_id).
planEntry.templates = plan.templates.map((template) => {
const tpl: Record<string, unknown> = {};
if (template.template_id) tpl.template_id = template.template_id;
if (template.template_name) tpl.template_name = template.template_name;
if (template.charge_type) tpl.charge_type = template.charge_type;
// Flatten roles.unified for the common COUPLED case.
const unified = template.roles?.unified;
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
if (unified?.capacity_unit_per_instance !== undefined)
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
// Preserve split-role configs (SEPERATED) as-is so callers
// can still drive prefill/decode sizing.
if (template.roles?.prefill || template.roles?.decode) {
tpl.roles = {
prefill: template.roles?.prefill,
decode: template.roles?.decode,
};
}
if (template.template_desc) tpl.template_desc = template.template_desc;
return tpl;
});
}
return planEntry;
});
}
return out;
});
if (textItems.length === 0) {
emitBare("No deployable models found.");
return;
}
const headers = ["MODEL_NAME", "BASE_MODEL", "SOURCE", "PLANS"];
const rows = textItems.map((item) => [
item.model_name,
item.base_model,
item.source,
item.plans,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
emitResult({ items, total, request_id: response.request_id }, "json");
},
});
@@ -0,0 +1,85 @@
import {
defineCommand,
stopModelService,
listIndependentDeployedModels,
findDeploymentEntry,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const PAUSE_FLAGS = {
deployedModel: {
type: "string",
valueHint: "<id>",
description: "Deployed model identifier (required)",
required: true,
},
skipPrecheck: {
type: "switch",
description: "Skip the local RUNNING/PENDING status precheck",
},
} satisfies FlagsDef;
/**
* `bl deploy pause` — pause a running deployment.
*
* Takes the model service offline so it no longer serves inference requests.
* For mu/ptu plans, billing stops while paused.
* Precheck: status must be RUNNING or PENDING.
*/
export default defineCommand({
description: "Pause a running model deployment (stops billing for mu/ptu)",
auth: "console",
usageArgs: "--deployed-model <id> [--skip-precheck]",
flags: PAUSE_FLAGS,
exampleArgs: [
"--deployed-model dep-...",
"--deployed-model dep-... --skip-precheck",
"--deployed-model dep-... --dry-run",
],
notes: [
"While paused, billing ceases for mu/ptu plans. Use `deploy resume` to bring it back online or `deploy delete` to remove.",
"Precheck verifies status is RUNNING/PENDING before issuing the pause; pass --skip-precheck to bypass.",
],
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
if (settings.dryRun) {
emitResult({ action: "deploy.pause", deployed_model: deployedModel }, "json");
return;
}
// Precheck: verify the deployment is in a pausable state.
if (!flags.skipPrecheck) {
try {
const entries = await listIndependentDeployedModels(ctx.client);
const entry = findDeploymentEntry(entries, deployedModel);
if (entry) {
const status = (entry.status ?? "").toUpperCase();
if (status && status !== "RUNNING" && status !== "PENDING") {
throw new BailianError(
`Deployment ${deployedModel} is ${status}. Only RUNNING / PENDING deployments can be paused. ` +
`Pass --skip-precheck to attempt the pause anyway.`,
ExitCode.USAGE,
);
}
}
// If entry not found in list, proceed — the server will surface the real error.
} catch (error) {
if (error instanceof BailianError) throw error;
// If the list call itself failed, proceed and let the API call surface the error.
}
}
const response = await stopModelService(ctx.client, deployedModel);
if (settings.quiet) {
emitBare(deployedModel);
} else {
emitResult({ deployed_model: deployedModel, action: "pause", ...response }, "json");
}
},
});
@@ -0,0 +1,84 @@
import {
defineCommand,
startModelService,
listIndependentDeployedModels,
findDeploymentEntry,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const RESUME_FLAGS = {
deployedModel: {
type: "string",
valueHint: "<id>",
description: "Deployed model identifier (required)",
required: true,
},
skipPrecheck: {
type: "switch",
description: "Skip the local STOPPED status precheck",
},
} satisfies FlagsDef;
/**
* `bl deploy resume` — resume a paused deployment.
*
* Brings the model service back online so it can serve inference requests.
* Precheck: status must be STOPPED.
*/
export default defineCommand({
description: "Resume a paused model deployment (brings service back online)",
auth: "console",
usageArgs: "--deployed-model <id> [--skip-precheck]",
flags: RESUME_FLAGS,
exampleArgs: [
"--deployed-model dep-...",
"--deployed-model dep-... --skip-precheck",
"--deployed-model dep-... --dry-run",
],
notes: [
"Precheck verifies status is STOPPED before issuing the resume; pass --skip-precheck to bypass.",
"For mu/ptu plans, billing resumes once the service is back online.",
],
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
if (settings.dryRun) {
emitResult({ action: "deploy.resume", deployed_model: deployedModel }, "json");
return;
}
// Precheck: verify the deployment is in a resumable state.
if (!flags.skipPrecheck) {
try {
const entries = await listIndependentDeployedModels(ctx.client);
const entry = findDeploymentEntry(entries, deployedModel);
if (entry) {
const status = (entry.status ?? "").toUpperCase();
if (status && status !== "STOPPED") {
throw new BailianError(
`Deployment ${deployedModel} is ${status}. Only STOPPED deployments can be resumed. ` +
`Pass --skip-precheck to attempt the resume anyway.`,
ExitCode.USAGE,
);
}
}
// If entry not found in list, proceed — the server will surface the real error.
} catch (error) {
if (error instanceof BailianError) throw error;
// If the list call itself failed, proceed and let the API call surface the error.
}
}
const response = await startModelService(ctx.client, deployedModel);
if (settings.quiet) {
emitBare(deployedModel);
} else {
emitResult({ deployed_model: deployedModel, action: "resume", ...response }, "json");
}
},
});
+4 -15
View File
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
scaleDeployment,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, scaleDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const SCALE_FLAGS = {
deployedModel: {
@@ -52,7 +47,6 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
const body: Record<string, unknown> = {};
if (flags.capacity !== undefined) body.capacity = flags.capacity;
@@ -60,21 +54,16 @@ export default defineCommand({
if (flags.outputTpm !== undefined) body.output_tpm = flags.outputTpm;
if (settings.dryRun) {
emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, format);
emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, "json");
return;
}
const response = await scaleDeployment(ctx.client, deployedModel, body);
const deployment = response.output ?? response.data;
if (settings.quiet) {
emitBare(deployedModel);
} else if (format === "text") {
const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : "";
emitBare(`Scaled ${deployedModel}${cap}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
updateDeployment,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, updateDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const UPDATE_FLAGS = {
deployedModel: {
@@ -48,31 +43,22 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
const body: Record<string, unknown> = {};
if (flags.rpmLimit !== undefined) body.rpm_limit = flags.rpmLimit;
if (flags.tpmLimit !== undefined) body.tpm_limit = flags.tpmLimit;
if (settings.dryRun) {
emitResult({ action: "deploy.update", deployed_model: deployedModel, body }, format);
emitResult({ action: "deploy.update", deployed_model: deployedModel, body }, "json");
return;
}
const response = await updateDeployment(ctx.client, deployedModel, body);
const deployment = response.output ?? response.data;
if (settings.quiet) {
emitBare(deployedModel);
} else if (format === "text") {
const parts: string[] = [];
if (deployment?.rpm_limit !== undefined) parts.push(`rpm_limit=${deployment.rpm_limit}`);
if (deployment?.tpm_limit !== undefined) parts.push(`tpm_limit=${deployment.tpm_limit}`);
const summary = parts.length ? ` (${parts.join(", ")})` : "";
emitBare(`Updated ${deployedModel}${summary}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, cancelFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, cancelFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const CANCEL_FLAGS = {
jobId: {
@@ -23,24 +23,18 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.cancel", job_id: jobId }, format);
emitResult({ action: "finetune.cancel", job_id: jobId }, "json");
return;
}
const response = await cancelFineTune(ctx.client, jobId);
const job = response.output ?? response.data;
if (settings.quiet) {
emitBare(jobId);
} else if (format === "text") {
const status = job?.status ? ` (status=${job.status})` : "";
emitBare(`Cancelled ${jobId}${status}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,6 +1,5 @@
import {
defineCommand,
detectOutputFormat,
fetchModelList,
fetchModelCapability,
listSupportedTrainingTypes,
@@ -43,19 +42,8 @@ async function fetchAllFoundationModels(settings: Settings): Promise<ModelCapabi
return all as ModelCapability[];
}
const VARIANT_LABEL: Record<string, string> = {
full: "full-parameter",
lora: "LoRA",
};
function describeTrainingType(value: string): string {
if (!isTrainingTypeCli(value)) return value;
const { method, variant } = trainingTypeMethodVariant(value);
return `${VARIANT_LABEL[variant] ?? variant} ${method.toUpperCase()}`;
}
const CAPABILITY_FLAGS = {
model: {
baseModel: {
type: "string",
valueHint: "<m>",
description: "List training types supported by this base model.",
@@ -71,31 +59,31 @@ export default defineCommand({
description:
"Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it)",
auth: "none",
usageArgs: "--model <m> | --training-type <t>",
usageArgs: "--base-model <m> | --training-type <t>",
flags: CAPABILITY_FLAGS,
exampleArgs: [
"--model qwen3-8b",
"--base-model qwen3-8b",
"--training-type sft-lora",
"--training-type cpt --output json",
"--training-type sft --quiet",
],
notes: [
"Exactly one of --model / --training-type is required.",
"Exactly one of --base-model / --training-type is required.",
"Training-type values use the `<method>` / `<method>-lora` convention:",
"sft | sft-lora | dpo | dpo-lora | cpt. (cpt has no -lora variant server-side.)",
"Queries listFoundationModels, a public API — no console login needed.",
],
validate: (f) => {
if (f.model && f.trainingType)
return "--model and --training-type are mutually exclusive; pass one.";
if (!f.model && !f.trainingType) return "one of --model / --training-type is required.";
if (f.baseModel && f.trainingType)
return "--base-model and --training-type are mutually exclusive; pass one.";
if (!f.baseModel && !f.trainingType)
return "one of --base-model / --training-type is required.";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const model = flags.model || undefined;
const model = flags.baseModel || undefined;
const trainingType = flags.trainingType || undefined;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -104,7 +92,7 @@ export default defineCommand({
model,
training_type: trainingType,
},
format,
"json",
);
return;
}
@@ -113,7 +101,7 @@ export default defineCommand({
if (model) {
const capability = await fetchModelCapability(settings, model);
if (!capability) {
emitBare(`No foundation model found matching "${model}".`);
emitResult({ model, error: `No foundation model found matching "${model}".` }, "json");
return;
}
const supported = listSupportedTrainingTypes(capability);
@@ -121,23 +109,15 @@ export default defineCommand({
for (const value of supported) emitBare(value);
return;
}
if (format !== "text") {
emitResult(
{
model: capability.model ?? model,
supported,
supports: capability.supports,
trainingTypes: capability.trainingTypes,
},
format,
);
return;
}
emitBare(`${capability.model ?? model}`);
emitBare(supported.length ? "Supported training types:" : "No supported training types.");
for (const value of supported) {
emitBare(` ${value.padEnd(10)} ${describeTrainingType(value)}`);
}
emitResult(
{
model: capability.model ?? model,
supported,
supports: capability.supports,
trainingTypes: capability.trainingTypes,
},
"json",
);
return;
}
@@ -162,20 +142,15 @@ export default defineCommand({
for (const entry of matched) emitBare(entry.model);
return;
}
if (format !== "text") {
emitResult(
{
training_type: trainingType,
method,
variant,
count: matched.length,
models: matched,
},
format,
);
return;
}
emitBare(`Models supporting ${trainingType} (${method} / ${variant}): ${matched.length}`);
for (const entry of matched) emitBare(` ${entry.model}`);
emitResult(
{
training_type: trainingType,
method,
variant,
count: matched.length,
models: matched,
},
"json",
);
},
});
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
listCheckpoints,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listCheckpoints, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const CHECKPOINTS_FLAGS = {
jobId: {
@@ -15,6 +10,8 @@ const CHECKPOINTS_FLAGS = {
},
} satisfies FlagsDef;
const EXPIRY_WARN_THRESHOLD_MS = 72 * 60 * 60 * 1000; // 72 hours
export default defineCommand({
description: "List checkpoints produced by a fine-tune job",
auth: "apiKey",
@@ -22,16 +19,15 @@ export default defineCommand({
flags: CHECKPOINTS_FLAGS,
exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"],
notes: [
"Use the returned `checkpoint` value with `finetune export` to publish",
"a deployable model.",
"`model_name` (shown for SUCCEEDED checkpoints) is the direct input for `deploy create --model-name`.",
"Checkpoints expire ~15 days after creation; `expire_time` shows the deadline. Export or deploy before expiry.",
],
async run(ctx) {
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.checkpoints", job_id: jobId }, format);
emitResult({ action: "finetune.checkpoints", job_id: jobId }, "json");
return;
}
@@ -44,22 +40,26 @@ export default defineCommand({
checkpoint: item.checkpoint ?? item.checkpoint_id ?? "",
step: item.step !== undefined ? String(item.step) : "",
status: item.status ?? "",
model_name: item.model_name ?? "",
expire_time: item.expire_time ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
emitResult({ items, total, request_id: response.request_id }, "json");
// text / quiet
if (items.length === 0) {
emitBare("No checkpoints found.");
return;
// Near-expiry warning: check if any non-expired checkpoint is within 72h of expiry.
const now = Date.now();
const expiringSoon = items.filter((item) => {
if (!item.expire_time) return false;
const deadline = new Date(item.expire_time).getTime();
if (Number.isNaN(deadline)) return false;
const remaining = deadline - now;
return remaining > 0 && remaining < EXPIRY_WARN_THRESHOLD_MS;
});
if (expiringSoon.length > 0) {
process.stderr.write(
`\n[warning] ${expiringSoon.length} checkpoint(s) will expire within 72 hours. ` +
"Export or deploy before expiry to avoid losing the model artifact.\n",
);
}
const headers = ["CHECKPOINT", "STEP", "STATUS"];
const rows = items.map((i) => [i.checkpoint, i.step, i.status]);
for (const line of formatTable(headers, rows)) emitBare(line);
emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -1,6 +1,5 @@
import {
defineCommand,
detectOutputFormat,
createFineTune,
getDataset,
uploadDataset,
@@ -27,7 +26,7 @@ import {
} from "bailian-cli-core";
import { existsSync, statSync } from "fs";
import { basename } from "path";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
/**
* A `--datasets` / `--validations` token is treated as a local file to upload
@@ -208,7 +207,7 @@ async function uploadResolvedLocal(
}
/** The modality a `finetune <modality> create` subcommand is bound to. */
type CommandModality = "text" | "audio" | "image";
type CommandModality = "text" | "audio" | "image" | "video";
/**
* Flags shared by every `finetune <modality> create` subcommand: what to train
@@ -216,10 +215,10 @@ type CommandModality = "text" | "audio" | "image";
* output. Every modality's model consumes these.
*/
const COMMON_FLAGS = {
model: {
baseModel: {
type: "string",
valueHint: "<model>",
description: "Base model to fine-tune",
description: "Base model to fine-tune (e.g. qwen3-8b; not the output model name)",
required: true,
},
datasets: {
@@ -317,13 +316,41 @@ const IMAGE_FLAGS = {
} satisfies FlagsDef;
const TEXT_USAGE =
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]";
"--base-model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]";
const AUDIO_USAGE =
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
const IMAGE_USAGE =
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
/**
* Video (Wan i2v/kf2v) flags: exposes the three hyper-parameters that the
* video API supports and users may want to override. Defaults are model-specific
* (resolved by the sft-lora profile: wan2.7 batch_size 1 / max_pixels 102400,
* wan2.5 4 / 36864, wan2.2 4 / 262144).
*/
const VIDEO_FLAGS = {
...COMMON_FLAGS,
nEpochs: {
type: "number",
valueHint: "<n>",
description: "Training epochs (default: 50)",
},
batchSize: {
type: "number",
valueHint: "<n>",
description: "Batch size (default: model-specific, 1 for wan2.7, 4 for wan2.5/2.2)",
},
learningRate: {
type: "string",
valueHint: "<str>",
description: 'Learning rate as a string to preserve precision (default: "2e-5")',
},
} satisfies FlagsDef;
const VIDEO_USAGE =
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>]";
const COMMON_NOTES = [
"Creating a job uploads any local datasets and consumes training quota.",
@@ -383,7 +410,7 @@ async function runCreate<F extends FlagsDef>(
): Promise<void> {
const { identity, settings } = ctx;
const flags = ctx.flags as Record<string, unknown>;
const model = flags.model as string;
const model = flags.baseModel as string;
const datasetsRaw = flags.datasets as string;
// CosyVoice audio fine-tuning accepts exactly one training file
@@ -606,8 +633,6 @@ async function runCreate<F extends FlagsDef>(
if (modelName) body.model_name = modelName;
if (suffix) body.finetuned_output_suffix = suffix;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const pending = [
...training.localPaths.map((path) => ({ field: "datasets", path })),
@@ -617,7 +642,7 @@ async function runCreate<F extends FlagsDef>(
pending.length > 0
? { action: "finetune.create", body, pending_uploads: pending }
: { action: "finetune.create", body },
format,
"json",
);
return;
}
@@ -627,16 +652,8 @@ async function runCreate<F extends FlagsDef>(
if (settings.quiet) {
if (job?.job_id) emitBare(job.job_id);
} else if (format === "text") {
if (job?.job_id) {
emitBare(`Created fine-tune job: ${job.job_id}`);
if (job.status) emitBare(`Status: ${job.status}`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
} else {
emitResult(response, format);
emitResult(response, "json");
}
}
@@ -647,14 +664,14 @@ export const finetuneTextCreate = defineCommand({
usageArgs: TEXT_USAGE,
flags: TEXT_FLAGS,
exampleArgs: [
"--model qwen3-8b --datasets file-xxx",
"--model qwen3-8b --datasets ./train.jsonl",
"--model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl",
"--model qwen3-8b --datasets file-aaa,./extra.jsonl",
"--model qwen3-8b --datasets ./train.jsonl --training-type sft",
'--model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4',
"--model qwen3-8b --datasets file-xxx --output json",
"--model qwen3-8b --datasets file-xxx --dry-run",
"--base-model qwen3-8b --datasets file-xxx",
"--base-model qwen3-8b --datasets ./train.jsonl",
"--base-model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl",
"--base-model qwen3-8b --datasets file-aaa,./extra.jsonl",
"--base-model qwen3-8b --datasets ./train.jsonl --training-type sft",
'--base-model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4',
"--base-model qwen3-8b --datasets file-xxx --output json",
"--base-model qwen3-8b --datasets file-xxx --dry-run",
],
notes: TEXT_NOTES,
run: (ctx) => runCreate("text", ctx),
@@ -667,11 +684,11 @@ export const finetuneAudioCreate = defineCommand({
usageArgs: AUDIO_USAGE,
flags: AUDIO_FLAGS,
exampleArgs: [
"--model cosyvoice-v3-flash --datasets ./audio.zip",
"--model cosyvoice-v3-flash --datasets file-xxx",
"--model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
"--model cosyvoice-v3-flash --datasets file-xxx --output json",
"--model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
"--base-model cosyvoice-v3-flash --datasets ./audio.zip",
"--base-model cosyvoice-v3-flash --datasets file-xxx",
"--base-model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
"--base-model cosyvoice-v3-flash --datasets file-xxx --output json",
"--base-model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
],
notes: AUDIO_NOTES,
run: (ctx) => runCreate("audio", ctx),
@@ -684,13 +701,38 @@ export const finetuneImageCreate = defineCommand({
usageArgs: IMAGE_USAGE,
flags: IMAGE_FLAGS,
exampleArgs: [
"--model wan2.7-image-pro --datasets ./images.zip",
"--model wan2.7-image-pro --datasets file-xxx",
"--model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
"--model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
"--model wan2.7-image-pro --datasets file-xxx --output json",
"--model wan2.7-image-pro --datasets ./images.zip --dry-run",
"--base-model wan2.7-image-pro --datasets ./images.zip",
"--base-model wan2.7-image-pro --datasets file-xxx",
"--base-model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
"--base-model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
"--base-model wan2.7-image-pro --datasets file-xxx --output json",
"--base-model wan2.7-image-pro --datasets ./images.zip --dry-run",
],
notes: IMAGE_NOTES,
run: (ctx) => runCreate("image", ctx),
});
const VIDEO_NOTES = [
...COMMON_NOTES,
"Video generation training (Wan i2v/kf2v) runs efficient_sft with model-",
"specific defaults: wan2.7 (batch_size=1, max_pixels=102400), wan2.5/2.2",
"(batch_size=4, max_pixels per model). Override with --batch-size/--n-epochs.",
"Datasets are .zip archives with data.jsonl + frame images + videos.",
"Recommended: ≥10 training samples, 20-100 for stable results.",
];
/** `bl finetune video create` — fine-tune a video generation model. Datasets are `.zip`. */
export const finetuneVideoCreate = defineCommand({
description: "Create a video generation model fine-tune job (Wan i2v/kf2v, efficient_sft)",
auth: "apiKey",
usageArgs: VIDEO_USAGE,
flags: VIDEO_FLAGS,
exampleArgs: [
"--base-model wan2.7-i2v --datasets file-xxx",
"--base-model wan2.7-i2v --datasets ./i2v-data.zip",
"--base-model wan2.2-kf2v-flash --datasets file-xxx --n-epochs 100",
"--base-model wan2.7-i2v --datasets file-xxx --dry-run",
],
notes: VIDEO_NOTES,
run: (ctx) => runCreate("video", ctx),
});
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, deleteFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, deleteFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DELETE_FLAGS = {
jobId: {
@@ -23,10 +23,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.delete", job_id: jobId }, format);
emitResult({ action: "finetune.delete", job_id: jobId }, "json");
return;
}
@@ -34,11 +33,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(jobId);
} else if (format === "text") {
emitBare(`Deleted ${jobId}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
exportCheckpoint,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, exportCheckpoint, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const EXPORT_FLAGS = {
jobId: {
@@ -39,11 +34,10 @@ export default defineCommand({
"explicit export is the canonical path for non-best checkpoints.",
],
async run(ctx) {
const { identity, settings, flags } = ctx;
const { settings, flags } = ctx;
const jobId = flags.jobId;
const checkpoint = flags.checkpoint;
const modelName = flags.modelName;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -53,7 +47,7 @@ export default defineCommand({
checkpoint,
model_name: modelName,
},
format,
"json",
);
return;
}
@@ -64,14 +58,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(exported);
} else if (format === "text") {
emitBare(`Exported ${jobId} / ${checkpoint} → model_name=${exported}`);
emitBare(
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -0,0 +1,105 @@
/**
* Best-effort actual training fee calculation using the model catalog's
* "ft" (fine-tune) price entry. Pure API-key domain no console auth needed.
*
* The model catalog (`listFoundationModels` via public gateway) returns a
* `prices[]` array **only when `queryPrice: true` is passed** (the same flag
* `fetchModelDetail` uses). Combined with the job's `output.usage` (actual
* consumed tokens, present on SUCCEEDED / CANCELED), this gives the exact
* training cost without any console-domain login.
*/
import {
callConsoleGateway,
effectiveConsoleGatewayConfig,
unwrapResponse,
MODEL_LIST_API,
type Settings,
type ModelPriceInfo,
} from "bailian-cli-core";
export interface ActualFee {
cost: number;
unitPrice: number;
priceUnit: string;
}
/**
* Fetch the model's training price from the public catalog gateway.
* Uses the same anonymous gateway path as `fetchModelCapability` (no console
* token required), but adds `queryPrice: true` to include the prices array.
*/
async function fetchTrainingPrice(
settings: Settings,
model: string,
): Promise<ModelPriceInfo | null> {
const eff = effectiveConsoleGatewayConfig(settings);
const result = await callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
settings.timeout,
{
api: MODEL_LIST_API,
data: {
input: {
pageNo: 1,
pageSize: 10,
group: true,
model,
queryPrice: true,
querySampleCode: false,
queryGroupByModel: true,
queryQuota: false,
queryQpmInfo: false,
queryApplyStatus: false,
queryPermissions: false,
queryActivationStatus: false,
},
},
},
);
const responseData = unwrapResponse(result as Record<string, unknown>);
const list = (responseData.list as Record<string, unknown>[]) ?? [];
// The response is grouped; find the exact model in items.
for (const group of list) {
const items = (group.items as Record<string, unknown>[]) ?? [];
for (const item of items) {
if (item.model === model) {
const prices = (item.prices as ModelPriceInfo[]) ?? [];
return prices.find((entry) => entry.type === "ft") ?? null;
}
}
// Flat response fallback (no items nesting).
if (group.model === model) {
const prices = (group.prices as ModelPriceInfo[]) ?? [];
return prices.find((entry) => entry.type === "ft") ?? null;
}
}
return null;
}
/**
* Compute the actual training fee from the model catalog's "ft" price entry.
* Returns null when the price is unavailable (network error, model not in
* catalog, or no "ft" entry). Never throws.
*
* Only uses the public model catalog (model metadata) does NOT call
* console-domain pricing APIs (modelCenter.getModelPrice). Models whose
* catalog entry lacks a "ft" price (e.g. CosyVoice) will simply omit the
* training_cost field until the platform adds it to the catalog.
*/
export async function computeActualFee(
settings: Settings,
model: string,
usageTokens: number,
): Promise<ActualFee | null> {
try {
const ftEntry = await fetchTrainingPrice(settings, model);
const unitPrice = Number(ftEntry?.price);
if (!Number.isFinite(unitPrice) || unitPrice <= 0) return null;
const priceUnit = ftEntry?.priceUnit ?? "每百万tokens";
// Catalog price is yuan per million tokens.
const cost = (usageTokens / 1_000_000) * unitPrice;
return { cost: Number(cost.toFixed(4)), unitPrice, priceUnit };
} catch {
return null;
}
}
+29 -32
View File
@@ -1,5 +1,6 @@
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, getFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { computeActualFee } from "./fee.ts";
const GET_FLAGS = {
jobId: {
@@ -17,12 +18,11 @@ export default defineCommand({
flags: GET_FLAGS,
exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"],
async run(ctx) {
const { identity, settings, flags } = ctx;
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.get", job_id: jobId }, format);
emitResult({ action: "finetune.get", job_id: jobId }, "json");
return;
}
@@ -30,18 +30,24 @@ export default defineCommand({
const job = response.output ?? response.data;
if (!job) {
emitBare(`No data returned for ${jobId}`);
emitResult({ job_id: jobId, error: "No data returned" }, "json");
return;
}
const hp = job.hyper_parameters;
const hyperParameters = job.hyper_parameters;
const hyperParts: string[] = [];
if (hp?.n_epochs !== undefined) hyperParts.push(`n_epochs=${hp.n_epochs}`);
if (hp?.batch_size !== undefined) hyperParts.push(`batch_size=${hp.batch_size}`);
if (hp?.learning_rate !== undefined) hyperParts.push(`learning_rate=${hp.learning_rate}`);
if (hp?.max_length !== undefined) hyperParts.push(`max_length=${hp.max_length}`);
if (hyperParameters?.n_epochs !== undefined)
hyperParts.push(`n_epochs=${hyperParameters.n_epochs}`);
if (hyperParameters?.batch_size !== undefined)
hyperParts.push(`batch_size=${hyperParameters.batch_size}`);
if (hyperParameters?.learning_rate !== undefined)
hyperParts.push(`learning_rate=${hyperParameters.learning_rate}`);
if (hyperParameters?.max_length !== undefined)
hyperParts.push(`max_length=${hyperParameters.max_length}`);
const item = {
const usageTokens = typeof job.usage === "number" ? job.usage : undefined;
const item: Record<string, unknown> = {
job_id: job.job_id ?? jobId,
base_model: job.model ?? "",
status: job.status ?? "",
@@ -53,29 +59,20 @@ export default defineCommand({
model_name: job.model_name ?? "",
created_at: job.create_time ?? job.gmt_create ?? "",
updated_at: job.end_time ?? job.gmt_modified ?? "",
usage_tokens: usageTokens ?? "",
charge_type: typeof job.charge_type === "string" ? job.charge_type : "",
};
if (format === "json") {
emitResult({ ...item, request_id: response.request_id }, format);
return;
// Actual fee: only when the platform reports a concrete token count
// (SUCCEEDED / CANCELED). Best-effort — silently omitted on lookup failure.
if (usageTokens !== undefined && usageTokens > 0 && job.model) {
const fee = await computeActualFee(settings, job.model, usageTokens);
if (fee) {
item.training_cost = fee.cost;
item.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
}
}
// text / quiet
emitBare(`job_id: ${item.job_id}`);
if (item.base_model) emitBare(`base_model: ${item.base_model}`);
if (item.status) emitBare(`status: ${item.status}`);
if (item.training_type) emitBare(`training_type: ${item.training_type}`);
if (item.training_files.length) emitBare(`training_files: ${item.training_files.join(", ")}`);
if (item.validation_files.length)
emitBare(`validation_files: ${item.validation_files.join(", ")}`);
if (item.hyper_params) emitBare(`hyper_params: ${item.hyper_params}`);
if (item.output_model)
emitBare(
`output_model: ${item.output_model} (→ ${identity.binName} deploy text create --model)`,
);
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
if (item.updated_at) emitBare(`updated_at: ${item.updated_at}`);
emitRequestId(response.request_id, settings.quiet);
emitResult({ ...item, request_id: response.request_id }, "json");
},
});
+24 -47
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, listFineTunes, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listFineTunes, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -13,71 +13,48 @@ const LIST_FLAGS = {
valueHint: "<s>",
description: "Filter by status (PENDING / RUNNING / SUCCEEDED / FAILED / CANCELED)",
},
baseModel: {
type: "string",
valueHint: "<model>",
description: "Filter by base model ID (server-side)",
},
} satisfies FlagsDef;
export default defineCommand({
description: "List fine-tune jobs",
auth: "apiKey",
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>]",
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>] [--base-model <model>]",
flags: LIST_FLAGS,
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
exampleArgs: ["", "--status RUNNING", "--base-model qwen3-8b", "--page-size 20"],
async run(ctx) {
const { identity, settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const { settings, flags } = ctx;
const pageNo = flags.page;
const pageSize = flags.pageSize;
const status = flags.status || undefined;
const model = flags.baseModel || undefined;
if (settings.dryRun) {
emitResult({ action: "finetune.list", page: pageNo, page_size: pageSize, status }, format);
emitResult(
{ action: "finetune.list", page: pageNo, page_size: pageSize, status, model },
"json",
);
return;
}
const response = await listFineTunes(ctx.client, { pageNo, pageSize, status });
const response = await listFineTunes(ctx.client, { pageNo, pageSize, status, model });
const payload = response.output ?? response.data;
const jobs = payload?.jobs ?? [];
const total = payload?.total;
const items = jobs.map((item) => ({
job_id: item.job_id ?? "",
base_model: item.model ?? "",
status: item.status ?? "",
training_type: item.training_type ?? "",
output_model: item.finetuned_output ?? "",
created_at: item.create_time ?? item.gmt_create ?? "",
const items = jobs.map((job) => ({
job_id: job.job_id ?? "",
base_model: job.model ?? "",
status: job.status ?? "",
training_type: job.training_type ?? "",
output_model: job.finetuned_output ?? "",
created_at: job.create_time ?? job.gmt_create ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
// text / quiet
if (items.length === 0) {
emitBare("No fine-tune jobs found.");
return;
}
const headers = [
"JOB_ID",
"BASE_MODEL",
"STATUS",
"TRAINING_TYPE",
"OUTPUT_MODEL",
"CREATED_AT",
];
const rows = items.map((i) => [
i.job_id,
i.base_model,
i.status,
i.training_type,
i.output_model,
i.created_at,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitBare(
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
);
emitRequestId(response.request_id, settings.quiet);
emitResult({ items, total, request_id: response.request_id }, "json");
},
});
+15 -44
View File
@@ -1,25 +1,24 @@
import {
defineCommand,
detectOutputFormat,
getFineTuneLogs,
type Client,
type FineTuneLogEntry,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult } from "bailian-cli-runtime";
/**
* Render a single log entry as a single line (mirrors the flatten logic used
* for non-search text output: prefer common fields, fall back to JSON).
* Render a single log entry as a single line (used for search matching:
* prefer common fields, fall back to JSON).
*/
function renderEntry(entry: FineTuneLogEntry | string): string {
if (typeof entry === "string") return entry;
const record = entry as Record<string, unknown>;
const ts = (record.timestamp ?? record.time ?? record.create_time ?? "") as string;
const timestamp = (record.timestamp ?? record.time ?? record.create_time ?? "") as string;
const level = (record.level ?? "") as string;
const msg = (record.message ?? record.msg ?? record.log ?? "") as string;
if (msg || ts || level) {
return [ts, level, msg].filter(Boolean).join("\t");
const message = (record.message ?? record.msg ?? record.log ?? "") as string;
if (message || timestamp || level) {
return [timestamp, level, message].filter(Boolean).join("\t");
}
return JSON.stringify(entry);
}
@@ -48,16 +47,16 @@ async function fetchAllLogs(
let total = 0;
// Hard cap to avoid an unbounded loop if the server misreports `total`.
const maxPages = 200;
for (let i = 0; i < maxPages; i++) {
for (let page = 0; page < maxPages; page++) {
const response = await getFineTuneLogs(client, jobId, { pageNo, pageSize });
const payload = response.output ?? response.data;
const page = payload?.logs ?? [];
const logs = payload?.logs ?? [];
total = payload?.total ?? total;
if (page.length === 0) break;
entries.push(...page);
if (logs.length === 0) break;
entries.push(...logs);
// Stop once we've collected everything the server claims exists.
if (total && entries.length >= total) break;
if (page.length < pageSize) break;
if (logs.length < pageSize) break;
pageNo++;
}
return { entries, total };
@@ -110,7 +109,6 @@ export default defineCommand({
const pageSize = flags.pageSize;
const search = flags.search || undefined;
const tail = flags.tail;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -122,7 +120,7 @@ export default defineCommand({
search,
tail,
},
format,
"json",
);
return;
}
@@ -147,18 +145,6 @@ export default defineCommand({
const result =
tailApplied !== undefined ? scanned.slice(scanned.length - tailApplied) : scanned;
if (settings.quiet || format === "text") {
if (result.length === 0) {
emitBare(search ? `No logs matched "${search}".` : "No logs returned.");
return;
}
for (const entry of result) emitBare(renderEntry(entry));
const parts: string[] = [`${result.length} shown`];
if (matched !== undefined) parts.push(`matched ${matched}`);
parts.push(`of ${entries.length}` + (total ? ` (total ${total})` : ""));
emitBare(`\n${parts.join(", ")}`);
return;
}
emitResult(
{
...(matched !== undefined ? { matched } : {}),
@@ -168,28 +154,13 @@ export default defineCommand({
...(tailApplied !== undefined ? { tail: tailApplied } : {}),
logs: result,
},
format,
"json",
);
return;
}
// Default: single page, verbatim response.
const response = await getFineTuneLogs(ctx.client, jobId, { pageNo, pageSize });
const payload = response.output ?? response.data;
const logs = payload?.logs ?? [];
if (settings.quiet || format === "text") {
if (logs.length === 0) {
emitBare("No logs returned.");
return;
}
for (const entry of logs) {
emitBare(renderEntry(entry));
}
if (payload?.total !== undefined) emitBare(`\nTotal: ${payload.total}`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
emitResult(response, "json");
},
});
@@ -0,0 +1,139 @@
import {
defineCommand,
fetchTrainingModelPrice,
estimateSftDpoTokens,
estimateCptTokens,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const PRICE_FLAGS = {
baseModel: {
type: "string",
valueHint: "<model>",
description: "Base model to fine-tune (e.g. qwen3-8b; not the output model name)",
required: true,
},
datasets: {
type: "string",
valueHint: "<ids>",
description: "Training dataset file IDs, comma-separated (required)",
required: true,
},
trainingType: {
type: "string",
valueHint: "<type>",
description: "Training type: sft | dpo | cpt (default: sft)",
},
nEpochs: {
type: "number",
valueHint: "<n>",
description: "Number of training epochs (default: 3)",
},
} satisfies FlagsDef;
const SUPPORTED_TRAINING_TYPES = ["sft", "dpo", "cpt"];
// Fixed hyper-parameters used for estimation. Only n_epochs materially affects
// the estimate; the rest are held at representative defaults (not exposed as
// flags to keep the command surface minimal).
const ESTIMATE_BATCH_SIZE = 16;
const ESTIMATE_MAX_LENGTH = 8192;
const DEFAULT_N_EPOCHS = 3;
export default defineCommand({
description: "Estimate the training cost for a fine-tune job (token billing)",
auth: "console",
usageArgs: "--base-model <model> --datasets <ids> [--training-type <type>] [--n-epochs <n>]",
flags: PRICE_FLAGS,
exampleArgs: [
"--base-model qwen3-8b --datasets file-ft-xxx",
"--base-model qwen3-8b --datasets file-ft-xxx,file-ft-yyy --n-epochs 2",
"--base-model qwen3-8b --datasets file-ft-xxx --training-type cpt",
],
notes: [
"Estimate only — the server computes token usage from the datasets; final cost is subject to the bill.",
"Covers token billing for sft / dpo / cpt. Training-unit (MTU) billing is not supported by this command.",
"Hyper-parameters other than --n-epochs are fixed at representative defaults for estimation.",
],
async run(ctx) {
const { settings, flags } = ctx;
const model = flags.baseModel;
const datasetIds = flags.datasets
.split(",")
.map((datasetId) => datasetId.trim())
.filter(Boolean);
const trainingType = (flags.trainingType ?? "sft").toLowerCase();
const nEpochs = flags.nEpochs ?? DEFAULT_N_EPOCHS;
if (!SUPPORTED_TRAINING_TYPES.includes(trainingType)) {
throw new BailianError(
`Unsupported training type "${trainingType}". Supported: ${SUPPORTED_TRAINING_TYPES.join(", ")}.`,
ExitCode.USAGE,
);
}
if (datasetIds.length === 0) {
throw new BailianError("--datasets must contain at least one file ID.", ExitCode.USAGE);
}
if (settings.dryRun) {
emitResult(
{ action: "finetune.price", model, datasets: datasetIds, trainingType, nEpochs },
"json",
);
return;
}
// Unit price (yuan per 千Token).
const priceInfo = await fetchTrainingModelPrice(ctx.client, model);
const unitPrice = Number(priceInfo.price);
if (!Number.isFinite(unitPrice)) {
throw new BailianError(
`No training price found for model "${model}".`,
ExitCode.GENERAL,
undefined,
{ rawResponse: JSON.stringify(priceInfo) },
);
}
// Per-epoch token estimate (min/max range).
const estimate =
trainingType === "cpt"
? await estimateCptTokens(ctx.client, model, datasetIds.join(","), nEpochs)
: await estimateSftDpoTokens(ctx.client, datasetIds, {
nEpochs,
batchSize: ESTIMATE_BATCH_SIZE,
maxLength: ESTIMATE_MAX_LENGTH,
});
const minPerEpoch = estimate.estimatedDatasetConsumedTokensMinPerEpoch ?? 0;
const maxPerEpoch = estimate.estimatedDatasetConsumedTokensMaxPerEpoch ?? 0;
const mixedMinPerEpoch = estimate.estimatedMixedConsumedTokensMinPerEpoch ?? 0;
const mixedMaxPerEpoch = estimate.estimatedMixedConsumedTokensMaxPerEpoch ?? 0;
const minTokens = (minPerEpoch + mixedMinPerEpoch) * nEpochs;
const maxTokens = (maxPerEpoch + mixedMaxPerEpoch) * nEpochs;
// price is yuan per 1000 tokens.
const minFee = (minTokens / 1000) * unitPrice;
const maxFee = (maxTokens / 1000) * unitPrice;
emitResult(
{
model,
training_type: trainingType,
n_epochs: nEpochs,
unit_price: unitPrice,
price_unit: priceInfo.priceUnit ?? "千Token",
estimated_tokens: { min: minTokens, max: maxTokens },
estimated_fee_yuan: {
min: Number(minFee.toFixed(4)),
max: Number(maxFee.toFixed(4)),
},
disclaimer: "Server-side estimate; final cost is subject to the bill.",
},
"json",
);
},
});
@@ -1,12 +1,12 @@
import {
defineCommand,
detectOutputFormat,
getFineTune,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { computeActualFee } from "./fee.ts";
const DEFAULT_INTERVAL_SEC = 10;
const MIN_INTERVAL_SEC = 1;
@@ -103,7 +103,6 @@ export default defineCommand({
const follow = flags.follow;
const intervalSec = Math.max(MIN_INTERVAL_SEC, flags.interval ?? DEFAULT_INTERVAL_SEC);
const pollTimeoutSec = flags.pollTimeout;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -114,7 +113,7 @@ export default defineCommand({
interval: intervalSec,
timeout: pollTimeoutSec,
},
format,
"json",
);
return;
}
@@ -132,16 +131,24 @@ export default defineCommand({
if (settings.quiet) {
// Just the status word — ideal for `status=$(... finetune watch ... --quiet)`.
emitBare(status || "UNKNOWN");
} else if (format === "text") {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
if (status === "SUCCEEDED") emitBare(`${jobId} ${status}`);
emitRequestId(response.request_id, settings.quiet);
} else {
// json: a compact, purpose-built status probe.
emitResult(
{ job_id: jobId, status: status || "UNKNOWN", terminal, request_id: response.request_id },
format,
);
const output: Record<string, unknown> = {
job_id: jobId,
status: status || "UNKNOWN",
terminal,
request_id: response.request_id,
};
// Enrich terminal output with actual fee when usage is reported.
const usageTokens = typeof job?.usage === "number" ? job.usage : undefined;
if (terminal && usageTokens && usageTokens > 0 && job?.model) {
output.usage_tokens = usageTokens;
const fee = await computeActualFee(settings, job.model as string, usageTokens);
if (fee) {
output.training_cost = fee.cost;
output.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
}
}
emitResult(output, "json");
}
if (terminal && status !== "SUCCEEDED") {
@@ -168,18 +175,28 @@ export default defineCommand({
const job = response.output ?? response.data;
const status = String(job?.status ?? "").toUpperCase();
if (format === "text" && !settings.quiet && status !== lastStatus) {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
if (!settings.quiet && status !== lastStatus) {
process.stderr.write(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}\n`);
lastStatus = status;
}
if (TERMINAL_STATUSES.has(status)) {
const elapsed = Date.now() - startedAt;
if (format !== "text" || settings.quiet) {
emitResult(response, format);
} else if (status === "SUCCEEDED") {
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
emitRequestId(response.request_id, settings.quiet);
if (settings.quiet) {
emitBare(status || "UNKNOWN");
} else {
// Enrich the raw response with actual fee when usage is available.
const usageTokens = typeof job?.usage === "number" ? job.usage : undefined;
const enriched: Record<string, unknown> = { ...response };
if (usageTokens && usageTokens > 0 && job?.model) {
const fee = await computeActualFee(settings, job.model as string, usageTokens);
if (fee) {
enriched.training_cost = fee.cost;
enriched.usage_tokens = usageTokens;
enriched.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
}
}
emitResult(enriched, "json");
}
if (status !== "SUCCEEDED") {
throw new BailianError(
@@ -205,7 +222,7 @@ export default defineCommand({
// Any other error (including the BailianError thrown above) propagates to
// the central handler.
if (controller.signal.aborted) {
emitBare("\nInterrupted.");
process.stderr.write("\nInterrupted.\n");
return;
}
throw error;
@@ -1,6 +1,7 @@
import {
defineCommand,
videoGeneratePath,
image2videoPath,
taskPath,
detectOutputFormat,
type DashScopeVideoRequest,
@@ -43,6 +44,11 @@ export default defineCommand({
valueHint: "<url>",
description: "Input image URL for image-to-video generation",
},
lastFrame: {
type: "string",
valueHint: "<url>",
description: "Last frame image URL (with --image, enables kf2v first+last frame mode)",
},
negativePrompt: {
type: "string",
valueHint: "<text>",
@@ -110,12 +116,20 @@ export default defineCommand({
const format = detectOutputFormat(settings.output);
const imageUrl = flags.image;
const lastFrameUrl = flags.lastFrame as string | undefined;
// Auto-upload local image file for i2v
let resolvedImageUrl: string | undefined;
if (imageUrl) {
resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model);
}
let resolvedLastFrameUrl: string | undefined;
if (lastFrameUrl) {
resolvedLastFrameUrl = await ctx.client.resolveImageInput(lastFrameUrl, model);
}
// kf2v mode: both --image and --last-frame provided.
const isKf2v = Boolean(resolvedImageUrl && resolvedLastFrameUrl);
const watermark = resolveWatermark(flags.watermark);
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
@@ -125,10 +139,16 @@ export default defineCommand({
input: {
prompt: prompt,
negative_prompt: flags.negativePrompt || undefined,
// i2v models (happyhorse-1.1-i2v) require input.media with type 'first_frame'
...(resolvedImageUrl
? { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
: {}),
// kf2v: first+last frame flat fields via image2video endpoint.
// wan2.1~2.6 i2v: flat img_url via video-generation endpoint.
// wan2.7+ / happyhorse i2v: media[] via video-generation endpoint.
...(isKf2v
? { first_frame_url: resolvedImageUrl, last_frame_url: resolvedLastFrameUrl }
: resolvedImageUrl
? /wan[x]?2\.[1-6]/i.test(model)
? { img_url: resolvedImageUrl }
: { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
: {}),
},
parameters: {
resolution: flags.resolution || undefined,
@@ -141,15 +161,28 @@ export default defineCommand({
};
if (settings.dryRun) {
const previewBody = resolvedImageUrl
? {
...body,
input: {
...body.input,
media: [{ type: "first_frame" as const, url: redactDataUri(resolvedImageUrl) }],
},
}
: body;
let previewBody = body;
if (isKf2v) {
previewBody = {
...body,
input: {
...body.input,
first_frame_url: redactDataUri(resolvedImageUrl ?? ""),
last_frame_url: redactDataUri(resolvedLastFrameUrl ?? ""),
},
};
} else if (resolvedImageUrl) {
const redactedUrl = redactDataUri(resolvedImageUrl);
previewBody = {
...body,
input: {
...body.input,
...(/wan[x]?2\.[1-6]/i.test(model)
? { img_url: redactedUrl }
: { media: [{ type: "first_frame" as const, url: redactedUrl }] }),
},
};
}
emitResult({ request: previewBody }, format);
return;
}
@@ -162,7 +195,7 @@ export default defineCommand({
settings,
() =>
ctx.client.requestJson<DashScopeAsyncResponse>({
path: videoGeneratePath(),
path: isKf2v ? image2videoPath() : videoGeneratePath(),
method: "POST",
body,
async: true,
+4
View File
@@ -66,6 +66,7 @@ export {
finetuneTextCreate,
finetuneAudioCreate,
finetuneImageCreate,
finetuneVideoCreate,
} from "./commands/finetune/create.ts";
export { default as finetuneList } from "./commands/finetune/list.ts";
export { default as finetuneGet } from "./commands/finetune/get.ts";
@@ -76,6 +77,7 @@ export { default as finetuneCheckpoints } from "./commands/finetune/checkpoints.
export { default as finetuneExport } from "./commands/finetune/export.ts";
export { default as finetuneWatch } from "./commands/finetune/watch.ts";
export { default as finetuneCapability } from "./commands/finetune/capability.ts";
export { default as finetunePrice } from "./commands/finetune/price.ts";
export {
deployTextCreate,
deployAudioCreate,
@@ -87,6 +89,8 @@ export { default as deployModels } from "./commands/deploy/models.ts";
export { default as deployScale } from "./commands/deploy/scale.ts";
export { default as deployUpdate } from "./commands/deploy/update.ts";
export { default as deployDelete } from "./commands/deploy/delete.ts";
export { default as deployPause } from "./commands/deploy/pause.ts";
export { default as deployResume } from "./commands/deploy/resume.ts";
export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.ts";
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
@@ -30,7 +30,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--name/i);
expect(stderr).toMatch(/--model-name|--display-name/i);
});
test("deploy create --dry-run 构造 lora 部署请求体", async () => {
@@ -38,9 +38,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"deploy",
"text",
"create",
"--model",
"--model-name",
"qwen-plus-2025-12-01",
"--name",
"--display-name",
"my-qwen-plus",
"--dry-run",
"--output",
@@ -68,9 +68,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"deploy",
"text",
"create",
"--model",
"--model-name",
"qwen3-8b",
"--name",
"--display-name",
"my-qwen3-mu",
"--plan",
"mu",
@@ -102,9 +102,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"deploy",
"audio",
"create",
"--model",
"--model-name",
"my-cosyvoice-ft",
"--name",
"--display-name",
"my-tts",
"--dry-run",
"--output",
+112 -14
View File
@@ -31,7 +31,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--datasets/i);
expect(stderr).toMatch(/--base-model|--datasets/i);
});
test("finetune create --dry-run 构造 SFT 默认请求体", async () => {
@@ -39,7 +39,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa,file-bbb",
@@ -73,7 +73,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa",
@@ -135,7 +135,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa",
@@ -157,7 +157,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa",
@@ -176,7 +176,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
`${localPath},file-bbb`,
@@ -207,7 +207,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
" , ",
@@ -228,7 +228,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
localPath,
@@ -250,7 +250,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
localPath,
@@ -272,7 +272,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
["cancel", ["--job-id", "ft-xxx"]],
["delete", ["--job-id", "ft-xxx"]],
["watch", ["--job-id", "ft-xxx"]],
["capability", ["--model", "qwen3-8b"]],
["capability", ["--base-model", "qwen3-8b"]],
])("finetune %s --dry-run 发出结构化动作", async (sub, extra) => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
@@ -292,7 +292,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
" file-a , ,file-b ",
@@ -314,7 +314,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"audio",
"create",
"--model",
"--base-model",
"cosyvoice-v3-flash",
"--datasets",
"file-audio",
@@ -343,7 +343,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--datasets/i);
expect(stderr).toMatch(/--base-model|--datasets/i);
expect(stderr).not.toMatch(/--training-type|--n-epochs|--batch-size|--max-length/);
});
@@ -352,7 +352,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"image",
"create",
"--model",
"--base-model",
"wan2.7-image-pro",
"--datasets",
"file-image",
@@ -365,6 +365,104 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
expect(data.action).toBe("finetune.create");
expect(data.body.training_type).toBe("efficient_sft");
});
test("finetune video create --help 暴露视频超参且不含文本超参", async () => {
// Video exposes --n-epochs / --batch-size / --learning-rate; the text-only
// --training-type / --max-length surface is not offered.
const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--base-model/);
expect(stderr).toMatch(/--n-epochs/);
expect(stderr).toMatch(/--batch-size/);
expect(stderr).toMatch(/--learning-rate/);
expect(stderr).not.toMatch(/--training-type|--max-length/);
});
test("finetune video create --datasets 缺失时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--base-model",
"wan2.7-i2v",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--datasets|Missing required/i);
});
test.each([
// Model-family-specific defaults resolved by the sft-lora video profile.
["wan2.7-i2v", 1, 102400],
["wan2.5-i2v-preview", 4, 36864],
["wan2.2-kf2v-flash", 4, 262144],
])(
"finetune video create --dry-run %s 解析 batch_size=%i / max_pixels=%i",
async (baseModel, batchSize, maxPixels) => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--base-model",
baseModel,
"--datasets",
"file-video",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: {
model: string;
training_type: string;
hyper_parameters: Record<string, unknown>;
};
}>(stdout);
expect(data.action).toBe("finetune.create");
expect(data.body.model).toBe(baseModel);
expect(data.body.training_type).toBe("efficient_sft");
expect(data.body.hyper_parameters.batch_size).toBe(batchSize);
expect(data.body.hyper_parameters.max_pixels).toBe(maxPixels);
expect(data.body.hyper_parameters.learning_rate).toBe("2e-5");
expect(data.body.hyper_parameters.lora_rank).toBe(32);
},
);
test("finetune video create --dry-run 转发超参覆盖且不做 clamp", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--base-model",
"wan2.7-i2v",
"--datasets",
"file-video",
"--n-epochs",
"100",
"--batch-size",
"2",
"--learning-rate",
"1e-5",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
body: { hyper_parameters: Record<string, unknown> };
}>(stdout);
// Video overrides are forwarded verbatim (no [8, 1024] text clamp).
expect(data.body.hyper_parameters.n_epochs).toBe(100);
expect(data.body.hyper_parameters.batch_size).toBe(2);
expect(data.body.hyper_parameters.learning_rate).toBe("1e-5");
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => {
@@ -135,6 +135,7 @@ export const FINETUNE_ROUTES: E2eRouteExports = {
"finetune text create": "finetuneTextCreate",
"finetune audio create": "finetuneAudioCreate",
"finetune image create": "finetuneImageCreate",
"finetune video create": "finetuneVideoCreate",
"finetune list": "finetuneList",
"finetune get": "finetuneGet",
"finetune cancel": "finetuneCancel",
@@ -112,6 +112,62 @@ describe("e2e: video generate (i2v)", () => {
}>(stdout);
expect(data.request?.input?.media?.[0]?.url).toBe("data:image/png;base64,<omitted>");
});
test.each([
// wan2.1~2.6 (legacy) use flat img_url; wan2.7+ and happyhorse use media[].
["wan2.5-i2v-preview", "img_url"],
["wan2.6-i2v", "img_url"],
["wan2.7-i2v", "media"],
["happyhorse-1.1-i2v", "media"],
])("video generate --dry-run %s 首帧走 %s 字段", async (model, field) => {
const configDir = makeE2eOutputDir(`video-i2v-input-shape-${model}`);
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({
"token-plan": {
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
},
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
VIDEO_ROUTES,
[
"video",
"generate",
"--config",
"token-plan",
"--dry-run",
"--model",
model,
"--image",
"https://example.com/placeholder.png",
"--prompt",
"干跑校验",
"--output",
"json",
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: {
input?: { img_url?: string; media?: Array<{ type?: string; url?: string }> };
};
}>(stdout);
if (field === "img_url") {
expect(data.request?.input?.img_url).toBe("https://example.com/placeholder.png");
expect(data.request?.input?.media).toBeUndefined();
} else {
expect(data.request?.input?.media?.[0]?.type).toBe("first_frame");
expect(data.request?.input?.img_url).toBeUndefined();
}
});
});
describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
+5
View File
@@ -32,6 +32,11 @@ export function videoGeneratePath(): string {
return "/api/v1/services/aigc/video-generation/video-synthesis";
}
/** POST /api/v1/services/aigc/image2video/video-synthesis — kf2v (first+last frame). */
export function image2videoPath(): string {
return "/api/v1/services/aigc/image2video/video-synthesis";
}
// ---- Async Task Query ----
export function taskPath(taskId: string): string {
return `/api/v1/tasks/${encodeURIComponent(taskId)}`;
+1
View File
@@ -19,6 +19,7 @@ export {
taskPath,
userProfilePath,
videoGeneratePath,
image2videoPath,
} from "./endpoints.ts";
export {
isLegacyImage2ImageModel,
+1
View File
@@ -2,3 +2,4 @@ export * from "./api.ts";
export * from "./types.ts";
export * from "./constants.ts";
export * from "./plans.ts";
export * from "./lifecycle.ts";
+99
View File
@@ -0,0 +1,99 @@
/**
* Deployment lifecycle operations via the **console gateway**.
*
* Unlike the DashScope REST endpoints in `api.ts`, start/stop/list-independent
* are console-domain APIs (`zeldaEasy.broadscope-platform.modelInstance.*`).
* Commands using these must declare `auth: "console"`.
*/
import type { Client } from "../client/client.ts";
import { unwrapResponse } from "../console/models.ts";
// ---------------------------------------------------------------------------
// API names
// ---------------------------------------------------------------------------
export const DEPLOY_START_API = "zeldaEasy.broadscope-platform.modelInstance.startModelService";
export const DEPLOY_STOP_API = "zeldaEasy.broadscope-platform.modelInstance.stopModelService";
export const DEPLOY_LIST_INDEPENDENT_API =
"zeldaEasy.broadscope-platform.modelInstance.listIndependentDeployedModel";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ModelServiceEntry {
modelServiceId?: string;
deployedModel?: string;
deployed_model?: string;
status?: string;
modelName?: string;
model_name?: string;
plan?: string;
[key: string]: unknown;
}
// ---------------------------------------------------------------------------
// API wrappers
// ---------------------------------------------------------------------------
/** Start (bring online) a stopped deployment. */
export async function startModelService(
client: Client,
modelServiceId: string,
): Promise<Record<string, unknown>> {
const raw = await client.console<Record<string, unknown>>(DEPLOY_START_API, {
input: { modelServiceId },
});
return unwrapResponse(raw);
}
/** Stop (take offline) a running deployment. Stops billing for mu/ptu plans. */
export async function stopModelService(
client: Client,
modelServiceId: string,
): Promise<Record<string, unknown>> {
const raw = await client.console<Record<string, unknown>>(DEPLOY_STOP_API, {
input: { modelServiceId },
});
return unwrapResponse(raw);
}
/**
* List independently deployed models (console domain).
* Used for precheck status verification and ID mapping.
* Paginates internally to return all entries.
*/
export async function listIndependentDeployedModels(client: Client): Promise<ModelServiceEntry[]> {
const allEntries: ModelServiceEntry[] = [];
let page = 1;
while (true) {
const raw = await client.console<Record<string, unknown>>(DEPLOY_LIST_INDEPENDENT_API, {
input: { pageNo: page, pageSize: 50 },
});
const resp = unwrapResponse(raw);
const records = (resp.records ?? []) as ModelServiceEntry[];
allEntries.push(...records);
const pageCount = (resp.pageCount as number) ?? 1;
if (page >= pageCount || records.length === 0) break;
page++;
}
return allEntries;
}
/**
* Find a deployment entry by its identifier in the console-domain list.
* Matches against `modelServiceId`, `deployedModel`, or `deployed_model`.
*/
export function findDeploymentEntry(
entries: ModelServiceEntry[],
deployedModel: string,
): ModelServiceEntry | undefined {
return entries.find(
(entry) =>
entry.modelServiceId === deployedModel ||
entry.deployedModel === deployedModel ||
entry.deployed_model === deployedModel,
);
}
+3
View File
@@ -43,6 +43,8 @@ export interface ListFineTunesParams {
pageNo?: number;
pageSize?: number;
status?: string;
/** Filter by base model ID (server-side). */
model?: string;
signal?: AbortSignal;
}
@@ -55,6 +57,7 @@ export async function listFineTunes(
if (params.pageNo !== undefined) qs.set("page_no", String(params.pageNo));
if (params.pageSize !== undefined) qs.set("page_size", String(params.pageSize));
if (params.status) qs.set("status", params.status);
if (params.model) qs.set("model", params.model);
const base = finetuneJobsPath();
const path = qs.toString() ? `${base}?${qs.toString()}` : base;
return client.requestJson<ListFineTunesResponse>({
+1
View File
@@ -3,3 +3,4 @@ export * from "./api.ts";
export * from "./capability.ts";
export * from "./preflight.ts";
export * from "./profiles/index.ts";
export * from "./price.ts";
+121
View File
@@ -0,0 +1,121 @@
/**
* Training price estimation via the **console gateway**.
*
* These are console-domain APIs (`zeldaEasy.broadscope-platform.*`); commands
* using them must declare `auth: "console"`. Two different argument wrappers
* exist: `getModelPrice` takes a top-level `query`, while the token-estimation
* APIs take a top-level `input`.
*/
import type { Client } from "../client/client.ts";
import { unwrapResponse } from "../console/models.ts";
// ---------------------------------------------------------------------------
// API names
// ---------------------------------------------------------------------------
export const TRAINING_MODEL_PRICE_API = "zeldaEasy.broadscope-platform.modelCenter.getModelPrice";
export const CALC_DATASETS_TOKENS_API =
"zeldaEasy.broadscope-platform.modelInstance.calculateDatasetsTotalTokens";
export const ESTIMATE_FINETUNE_TOKENS_API =
"zeldaEasy.broadscope-platform.modelInstance.estimateFinetuneTokens";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TrainingModelPrice {
price?: string;
priceUnit?: string;
modelId?: string;
[key: string]: unknown;
}
export interface TokenEstimate {
estimatedDatasetConsumedTokensMinPerEpoch?: number;
estimatedDatasetConsumedTokensMaxPerEpoch?: number;
estimatedMixedConsumedTokensMinPerEpoch?: number;
estimatedMixedConsumedTokensMaxPerEpoch?: number;
[key: string]: unknown;
}
// ---------------------------------------------------------------------------
// API wrappers
// ---------------------------------------------------------------------------
/**
* Training unit price for a model. `price` is denominated in `priceUnit`
* (typically "千Token" yuan per 1000 tokens).
*/
export async function fetchTrainingModelPrice(
client: Client,
modelId: string,
): Promise<TrainingModelPrice> {
const raw = await client.console<Record<string, unknown>>(TRAINING_MODEL_PRICE_API, {
query: { type: 0, modelId },
});
return unwrapResponse(raw) as TrainingModelPrice;
}
/**
* Estimate training tokens for SFT / DPO jobs.
* Returns a per-epoch min/max range; multiply by `n_epochs` for the total.
*/
export async function estimateSftDpoTokens(
client: Client,
datasetIds: string[],
hyperParams: { nEpochs: number; batchSize: number; maxLength: number },
): Promise<TokenEstimate> {
const raw = await client.console<Record<string, unknown>>(CALC_DATASETS_TOKENS_API, {
input: { trainDatasetIds: datasetIds, hyperParams },
});
return unwrapResponse(raw) as TokenEstimate;
}
/**
* Estimate training tokens for CPT jobs.
*
* The console API requires `hyperParams` as a **JSON string** with a full
* `userDefinedObj` payload (captured from the console frontend), plus several
* top-level fields (`algorithmType`, `bizType`, `priority`, ). Only
* `n_epochs` / `max_length` materially affect the estimate; the remaining
* hyper-parameters are fixed defaults.
*/
export async function estimateCptTokens(
client: Client,
model: string,
datasetIdsCsv: string,
nEpochs: number,
): Promise<TokenEstimate> {
const userDefinedObj = {
batch_size: 16,
eval_steps: 50,
learning_rate: "7e-6",
lr_scheduler_type: "linear",
max_length: 8192,
n_epochs: nEpochs,
split: 0.9,
save_total_limit: "3",
resume_from_checkpoint: false,
save_strategy: "epoch",
};
const hyperParams = JSON.stringify({
useDefault: false,
userDefinedObj,
useQwenMixedStrategy: false,
});
const raw = await client.console<Record<string, unknown>>(ESTIMATE_FINETUNE_TOKENS_API, {
input: {
trainingType: "cpt",
instanceName: `${model}_cli_estimate`,
algorithmType: 100,
bizType: 100,
trainDatasetIds: datasetIdsCsv,
hyperParams,
bailianTrainModel: model,
validationDatasetIds: "",
jobName: `${model}_cli_estimate`,
priority: "L0",
},
});
return unwrapResponse(raw) as TokenEstimate;
}
@@ -75,20 +75,24 @@ const IMAGE_HYPER_PARAMS_I2I: Record<string, unknown> = {
*
* Shared across all video models; `batch_size` and `max_pixels` differ by model
* family (resolved per model in `resolveHyperParameters`):
* - wan2.5 (e.g. wan2.5-i2v-preview): batch_size 2, max_pixels 36864
* - wan2.2 (i2v-flash / kf2v-flash): batch_size 4, max_pixels 262144
* - wan2.7 (e.g. wan2.7-i2v): batch_size 1, max_pixels 102400
* - wan2.5 (e.g. wan2.5-i2v-preview): batch_size 4, max_pixels 36864
* - wan2.2 (i2v-flash / kf2v-flash): batch_size 4, max_pixels 262144
*
* `learning_rate` is a string to avoid JSON-number precision loss (consistent
* with the image defaults). `split` (0.9) + `max_split_val_dataset_sample`
* (5) drive the automatic train/validation split when no explicit
* validation_file_ids are provided.
*
* Values aligned with the official user guide (2026-07):
* n_epochs 50, eval_epochs 20 ( n_epochs/10).
*/
const VIDEO_HYPER_PARAMS_BASE: Record<string, unknown> = {
n_epochs: 400,
n_epochs: 50,
learning_rate: "2e-5",
split: 0.9,
split: 0.5,
max_split_val_dataset_sample: 5,
eval_epochs: 50,
eval_epochs: 20,
save_total_limit: 10,
lora_rank: 32,
lora_alpha: 32,
@@ -109,6 +113,11 @@ function isWan25(model: string | undefined): boolean {
return typeof model === "string" && /wan2\.5/i.test(model);
}
/** wan2.7 family uses batch_size 1 and max_pixels 102400. */
function isWan27(model: string | undefined): boolean {
return typeof model === "string" && /wan2\.7/i.test(model);
}
export const sftLoraProfile: TrainingProfile = {
clientTrainingType: "sft-lora",
serverTrainingType: "efficient_sft",
@@ -195,15 +204,18 @@ export const sftLoraProfile: TrainingProfile = {
}
if (isVideo(modality)) {
// Video: shared defaults + model-family-specific batch_size / max_pixels.
// wan2.5 uses batch_size 2 / max_pixels 36864; wan2.2 uses 4 / 262144.
const wan25 = isWan25(flags.model as string | undefined);
// wan2.7: batch_size 1, max_pixels 102400
// wan2.5: batch_size 4, max_pixels 36864
// wan2.2: batch_size 4, max_pixels 262144
const model = (flags.model ?? flags.baseModel) as string | undefined;
const hp: Record<string, unknown> = {
...VIDEO_HYPER_PARAMS_BASE,
batch_size: 4,
max_pixels: wan25 ? 36864 : 262144,
batch_size: isWan27(model) ? 1 : 4,
max_pixels: isWan27(model) ? 102400 : isWan25(model) ? 36864 : 262144,
};
// Optional overrides (no clamping — video batch_size is intentionally small).
if (flags.nEpochs !== undefined) hp.n_epochs = flags.nEpochs as number;
if (flags.batchSize !== undefined) hp.batch_size = flags.batchSize as number;
if (flags.learningRate !== undefined) hp.learning_rate = flags.learningRate as string;
return hp;
}
+2
View File
@@ -168,6 +168,8 @@ export interface DashScopeVideoRequest {
prompt: string;
negative_prompt?: string;
img_url?: string;
first_frame_url?: string;
last_frame_url?: string;
media?: Array<{
type: "image" | "video" | "first_frame" | "last_frame" | "driving_audio" | "first_clip";
url: string;
+5 -5
View File
@@ -23,14 +23,14 @@ description: >-
```
1. Validate data bl dataset validate --file train.jsonl [--schema chatml|dpo|cpt|tts|image]
2. Upload data bl dataset upload --file train.jsonl # returns a file-id
3. Create job bl finetune text|audio|image create --model <base> --datasets <file-id|path>
3. Create job bl finetune text|audio|image create --base-model <base> --datasets <file-id|path>
4. Watch progress bl finetune watch --job-id ft-xxx # or get / logs
5. Pick artifact bl finetune checkpoints --job-id ft-xxx
6. Export model bl finetune export --job-id ft-xxx --checkpoint ckpt-N --model-name my-model
7. Deploy service bl deploy text|audio|image create --model my-model --name my-svc
7. Deploy service bl deploy text|audio|image create --model-name my-model --display-name my-svc
```
- Unsure which training methods a base model supports → `bl finetune capability --model <base>` or `--training-type sft|sft-lora|dpo|cpt`.
- Unsure which training methods a base model supports → `bl finetune capability --base-model <base>` or `--training-type sft|sft-lora|dpo|cpt`.
- Text `--training-type` values: `sft` / `sft-lora` / `dpo` / `dpo-lora` / `cpt`. Audio bases include `cosyvoice-v3-flash`; image bases include `wan2.7-image-pro`.
- Deployment plans: audio defaults to `--plan mu`; text/image default to `lora`.
- Preview write operations (create / delete / cancel / scale) with `--dry-run` first, and confirm with the user before deleting a job or dataset.
@@ -55,10 +55,10 @@ Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl <comma
```bash
bl dataset validate --file train.jsonl
bl dataset upload --file train.jsonl
bl finetune text create --model qwen3-8b --training-type sft-lora --datasets file-xxx
bl finetune text create --base-model qwen3-8b --training-type sft-lora --datasets file-xxx
bl finetune watch --job-id ft-xxx
bl finetune export --job-id ft-xxx --checkpoint ckpt-3 --model-name my-qwen-sft
bl deploy text create --model my-qwen-sft --name my-svc
bl deploy text create --model-name my-qwen-sft --display-name my-svc
```
## Common hand-offs
+168 -99
View File
@@ -7,43 +7,45 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ------------------------ | --------------------------------------------------------- |
| `bl deploy audio create` | Create an audio (TTS) model deployment |
| `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) |
| `bl deploy get` | Get details of a single model deployment |
| `bl deploy image create` | Create an image generation model deployment |
| `bl deploy list` | List model deployments |
| `bl deploy models` | List models available for deployment |
| `bl deploy scale` | Scale a deployment's capacity |
| `bl deploy text create` | Create a text model deployment |
| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) |
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `bl deploy audio create` | Create an audio (TTS) model deployment |
| `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) |
| `bl deploy get` | Get details of a single model deployment |
| `bl deploy image create` | Create an image generation model deployment |
| `bl deploy list` | List model deployments |
| `bl deploy models` | List models available for deployment |
| `bl deploy pause` | Pause a running model deployment (stops billing for mu/ptu) |
| `bl deploy resume` | Resume a paused model deployment (brings service back online) |
| `bl deploy scale` | Scale a deployment's capacity |
| `bl deploy text create` | Create a text model deployment |
| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) |
## Command details
### `bl deploy audio create`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `deploy audio create` |
| **Description** | Create an audio (TTS) model deployment |
| **Usage** | `bl deploy audio create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Name** | `deploy audio create` |
| **Description** | Create an audio (TTS) model deployment |
| **Usage** | `bl deploy audio create --model-name <model_name> --display-name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
#### Flags
| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `--model <name>` | string | yes | Model name (catalog model or fine-tuned output) (required) |
| `--name <display_name>` | string | yes | Console display name for the deployment (required) |
| `--plan <plan>` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu |
| `--deploy-spec <id>` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) |
| `--capacity <n>` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) |
| `--billing-method <m>` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) |
| `--input-tpm <n>` | number | no | PTU max input tokens/min (required for plan=ptu) |
| `--output-tpm <n>` | number | no | PTU max output tokens/min (required for plan=ptu) |
| `--thinking-output-tpm <n>` | number | no | PTU max thinking-output tokens/min (optional, some models) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| ------------------------------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `--model-name <model_name>` | string | yes | Model to deploy — fine-tuned output name or catalog model (required) |
| `--display-name <display_name>` | string | yes | Console display name for the deployment (required) |
| `--plan <plan>` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu |
| `--deploy-spec <id>` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) |
| `--capacity <n>` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) |
| `--billing-method <m>` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) |
| `--input-tpm <n>` | number | no | PTU max input tokens/min (required for plan=ptu) |
| `--output-tpm <n>` | number | no | PTU max output tokens/min (required for plan=ptu) |
| `--thinking-output-tpm <n>` | number | no | PTU max thinking-output tokens/min (optional, some models) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
@@ -58,27 +60,24 @@ Index: [index.md](index.md)
- Use `bl deploy models --source base` to inspect available templates.
- After creation, status starts at PENDING and transitions to RUNNING.
- Invoke the deployed model with: bl text chat --model <deployed_model>
- WARNING: --model is overloaded across commands and refers to DIFFERENT
- values. `bl deploy <modality> create --model` takes the exported model_name
- (e.g. `qwen3-8b-ft-...`), but the create response also returns a
- `deployed_model` field (the deployment instance id, e.g.
- `qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use
- the `deployed_model` from the create response — NOT the `model_name` you
- passed to `deploy <modality> create`. Do not reuse the value across the two
- commands.
- NOTE: --model-name is the model being deployed (e.g. `qwen3-8b-ft-...`).
- The create response also returns a `deployed_model` field — the deployment
- instance id (e.g. `qwen3-8b-5ecb5f068d79`). Use that id for inference
- (`bl text chat --model <deployed_model>`) and lifecycle commands
- (`deploy get/scale/pause/resume/delete --deployed-model <id>`).
#### Examples
```bash
bl deploy audio create --model my-cosyvoice-ft --name my-tts
bl deploy audio create --model-name my-cosyvoice-ft --display-name my-tts
```
```bash
bl deploy audio create --model my-cosyvoice-ft --name my-tts --deploy-spec dps-xxxx --capacity 1
bl deploy audio create --model-name my-cosyvoice-ft --display-name my-tts --deploy-spec dps-xxxx --capacity 1
```
```bash
bl deploy audio create --model my-cosyvoice-ft --name my-tts --dry-run
bl deploy audio create --model-name my-cosyvoice-ft --display-name my-tts --dry-run
```
### `bl deploy delete`
@@ -136,27 +135,27 @@ bl deploy get --deployed-model qwen-plus-2025-12-01-b6d61c71 --output json
### `bl deploy image create`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `deploy image create` |
| **Description** | Create an image generation model deployment |
| **Usage** | `bl deploy image create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Name** | `deploy image create` |
| **Description** | Create an image generation model deployment |
| **Usage** | `bl deploy image create --model-name <model_name> --display-name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
#### Flags
| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `--model <name>` | string | yes | Model name (catalog model or fine-tuned output) (required) |
| `--name <display_name>` | string | yes | Console display name for the deployment (required) |
| `--plan <plan>` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu |
| `--deploy-spec <id>` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) |
| `--capacity <n>` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) |
| `--billing-method <m>` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) |
| `--input-tpm <n>` | number | no | PTU max input tokens/min (required for plan=ptu) |
| `--output-tpm <n>` | number | no | PTU max output tokens/min (required for plan=ptu) |
| `--thinking-output-tpm <n>` | number | no | PTU max thinking-output tokens/min (optional, some models) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| ------------------------------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `--model-name <model_name>` | string | yes | Model to deploy — fine-tuned output name or catalog model (required) |
| `--display-name <display_name>` | string | yes | Console display name for the deployment (required) |
| `--plan <plan>` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu |
| `--deploy-spec <id>` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) |
| `--capacity <n>` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) |
| `--billing-method <m>` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) |
| `--input-tpm <n>` | number | no | PTU max input tokens/min (required for plan=ptu) |
| `--output-tpm <n>` | number | no | PTU max output tokens/min (required for plan=ptu) |
| `--thinking-output-tpm <n>` | number | no | PTU max thinking-output tokens/min (optional, some models) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
@@ -171,27 +170,24 @@ bl deploy get --deployed-model qwen-plus-2025-12-01-b6d61c71 --output json
- Use `bl deploy models --source base` to inspect available templates.
- After creation, status starts at PENDING and transitions to RUNNING.
- Invoke the deployed model with: bl text chat --model <deployed_model>
- WARNING: --model is overloaded across commands and refers to DIFFERENT
- values. `bl deploy <modality> create --model` takes the exported model_name
- (e.g. `qwen3-8b-ft-...`), but the create response also returns a
- `deployed_model` field (the deployment instance id, e.g.
- `qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use
- the `deployed_model` from the create response — NOT the `model_name` you
- passed to `deploy <modality> create`. Do not reuse the value across the two
- commands.
- NOTE: --model-name is the model being deployed (e.g. `qwen3-8b-ft-...`).
- The create response also returns a `deployed_model` field — the deployment
- instance id (e.g. `qwen3-8b-5ecb5f068d79`). Use that id for inference
- (`bl text chat --model <deployed_model>`) and lifecycle commands
- (`deploy get/scale/pause/resume/delete --deployed-model <id>`).
#### Examples
```bash
bl deploy image create --model my-wan-ft --name my-wan
bl deploy image create --model-name my-wan-ft --display-name my-wan
```
```bash
bl deploy image create --model my-wan-ft --name my-wan-mu --plan mu
bl deploy image create --model-name my-wan-ft --display-name my-wan-mu --plan mu
```
```bash
bl deploy image create --model my-wan-ft --name my-wan --dry-run
bl deploy image create --model-name my-wan-ft --display-name my-wan --dry-run
```
### `bl deploy list`
@@ -263,6 +259,82 @@ bl deploy models --source custom --page-size 50
bl deploy models --catalog-version v1.0 --output json
```
### `bl deploy pause`
| Field | Value |
| --------------- | ----------------------------------------------------------- |
| **Name** | `deploy pause` |
| **Description** | Pause a running model deployment (stops billing for mu/ptu) |
| **Usage** | `bl deploy pause --deployed-model <id> [--skip-precheck]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--deployed-model <id>` | string | yes | Deployed model identifier (required) |
| `--skip-precheck` | switch | no | Skip the local RUNNING/PENDING status precheck |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
#### Notes
- While paused, billing ceases for mu/ptu plans. Use `deploy resume` to bring it back online or `deploy delete` to remove.
- Precheck verifies status is RUNNING/PENDING before issuing the pause; pass --skip-precheck to bypass.
#### Examples
```bash
bl deploy pause --deployed-model dep-...
```
```bash
bl deploy pause --deployed-model dep-... --skip-precheck
```
```bash
bl deploy pause --deployed-model dep-... --dry-run
```
### `bl deploy resume`
| Field | Value |
| --------------- | ------------------------------------------------------------- |
| **Name** | `deploy resume` |
| **Description** | Resume a paused model deployment (brings service back online) |
| **Usage** | `bl deploy resume --deployed-model <id> [--skip-precheck]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--deployed-model <id>` | string | yes | Deployed model identifier (required) |
| `--skip-precheck` | switch | no | Skip the local STOPPED status precheck |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
#### Notes
- Precheck verifies status is STOPPED before issuing the resume; pass --skip-precheck to bypass.
- For mu/ptu plans, billing resumes once the service is back online.
#### Examples
```bash
bl deploy resume --deployed-model dep-...
```
```bash
bl deploy resume --deployed-model dep-... --skip-precheck
```
```bash
bl deploy resume --deployed-model dep-... --dry-run
```
### `bl deploy scale`
| Field | Value |
@@ -294,27 +366,27 @@ bl deploy scale --deployed-model dep-... --capacity 2
### `bl deploy text create`
| Field | Value |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `deploy text create` |
| **Description** | Create a text model deployment |
| **Usage** | `bl deploy text create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `deploy text create` |
| **Description** | Create a text model deployment |
| **Usage** | `bl deploy text create --model-name <model_name> --display-name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
#### Flags
| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `--model <name>` | string | yes | Model name (catalog model or fine-tuned output) (required) |
| `--name <display_name>` | string | yes | Console display name for the deployment (required) |
| `--plan <plan>` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu |
| `--deploy-spec <id>` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) |
| `--capacity <n>` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) |
| `--billing-method <m>` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) |
| `--input-tpm <n>` | number | no | PTU max input tokens/min (required for plan=ptu) |
| `--output-tpm <n>` | number | no | PTU max output tokens/min (required for plan=ptu) |
| `--thinking-output-tpm <n>` | number | no | PTU max thinking-output tokens/min (optional, some models) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| ------------------------------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `--model-name <model_name>` | string | yes | Model to deploy — fine-tuned output name or catalog model (required) |
| `--display-name <display_name>` | string | yes | Console display name for the deployment (required) |
| `--plan <plan>` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu |
| `--deploy-spec <id>` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) |
| `--capacity <n>` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) |
| `--billing-method <m>` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) |
| `--input-tpm <n>` | number | no | PTU max input tokens/min (required for plan=ptu) |
| `--output-tpm <n>` | number | no | PTU max output tokens/min (required for plan=ptu) |
| `--thinking-output-tpm <n>` | number | no | PTU max thinking-output tokens/min (optional, some models) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
@@ -329,31 +401,28 @@ bl deploy scale --deployed-model dep-... --capacity 2
- Use `bl deploy models --source base` to inspect available templates.
- After creation, status starts at PENDING and transitions to RUNNING.
- Invoke the deployed model with: bl text chat --model <deployed_model>
- WARNING: --model is overloaded across commands and refers to DIFFERENT
- values. `bl deploy <modality> create --model` takes the exported model_name
- (e.g. `qwen3-8b-ft-...`), but the create response also returns a
- `deployed_model` field (the deployment instance id, e.g.
- `qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use
- the `deployed_model` from the create response — NOT the `model_name` you
- passed to `deploy <modality> create`. Do not reuse the value across the two
- commands.
- NOTE: --model-name is the model being deployed (e.g. `qwen3-8b-ft-...`).
- The create response also returns a `deployed_model` field — the deployment
- instance id (e.g. `qwen3-8b-5ecb5f068d79`). Use that id for inference
- (`bl text chat --model <deployed_model>`) and lifecycle commands
- (`deploy get/scale/pause/resume/delete --deployed-model <id>`).
#### Examples
```bash
bl deploy text create --model my-qwen-sft --name my-sft-test
bl deploy text create --model-name my-qwen-sft --display-name my-sft-test
```
```bash
bl deploy text create --model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000
bl deploy text create --model-name qwen3.6-flash-2026-04-16 --display-name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000
```
```bash
bl deploy text create --model qwen3-8b --name my-qwen3-mu --plan mu
bl deploy text create --model-name qwen3-8b --display-name my-qwen3-mu --plan mu
```
```bash
bl deploy text create --model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2
bl deploy text create --model-name qwen3-8b --display-name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2
```
### `bl deploy update`
+158 -56
View File
@@ -19,24 +19,26 @@ Index: [index.md](index.md)
| `bl finetune image create` | Create an image generation model fine-tune job (sft-lora) |
| `bl finetune list` | List fine-tune jobs |
| `bl finetune logs` | Fetch training logs for a fine-tune job |
| `bl finetune price` | Estimate the training cost for a fine-tune job (token billing) |
| `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
| `bl finetune video create` | Create a video generation model fine-tune job (Wan i2v/kf2v, efficient_sft) |
| `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. |
## Command details
### `bl finetune audio create`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune audio create` |
| **Description** | Create an audio TTS model fine-tune job (sft-lora) |
| **Usage** | `bl finetune audio create --model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>]` |
| Field | Value |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune audio create` |
| **Description** | Create an audio TTS model fine-tune job (sft-lora) |
| **Usage** | `bl finetune audio create --base-model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>]` |
#### Flags
| Flag | Type | Required | Description |
| ---------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--model <model>` | string | yes | Base model to fine-tune |
| `--base-model <model>` | string | yes | Base model to fine-tune (e.g. qwen3-8b; not the output model name) |
| `--datasets <ids\|paths>` | string | yes | Comma-separated dataset file IDs or local paths (.jsonl for text, .zip for audio/image). Local paths are uploaded (validated) first, then their file-ids are used. |
| `--validations <ids\|paths>` | string | no | Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets). |
| `--model-name <name>` | string | no | Output model name (after training) |
@@ -58,23 +60,23 @@ Index: [index.md](index.md)
#### Examples
```bash
bl finetune audio create --model cosyvoice-v3-flash --datasets ./audio.zip
bl finetune audio create --base-model cosyvoice-v3-flash --datasets ./audio.zip
```
```bash
bl finetune audio create --model cosyvoice-v3-flash --datasets file-xxx
bl finetune audio create --base-model cosyvoice-v3-flash --datasets file-xxx
```
```bash
bl finetune audio create --model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts
bl finetune audio create --base-model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts
```
```bash
bl finetune audio create --model cosyvoice-v3-flash --datasets file-xxx --output json
bl finetune audio create --base-model cosyvoice-v3-flash --datasets file-xxx --output json
```
```bash
bl finetune audio create --model cosyvoice-v3-flash --datasets ./audio.zip --dry-run
bl finetune audio create --base-model cosyvoice-v3-flash --datasets ./audio.zip --dry-run
```
### `bl finetune cancel`
@@ -114,18 +116,18 @@ bl finetune cancel --job-id ft-xxx --dry-run
| --------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune capability` |
| **Description** | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) |
| **Usage** | `bl finetune capability --model <m> \| --training-type <t>` |
| **Usage** | `bl finetune capability --base-model <m> \| --training-type <t>` |
#### Flags
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | ------------------------------------------------------------------------------------- |
| `--model <m>` | string | no | List training types supported by this base model. |
| `--base-model <m>` | string | no | List training types supported by this base model. |
| `--training-type <t>` | string | no | List models supporting this training type: sft \| sft-lora \| dpo \| dpo-lora \| cpt. |
#### Notes
- Exactly one of --model / --training-type is required.
- Exactly one of --base-model / --training-type is required.
- Training-type values use the `<method>` / `<method>-lora` convention:
- sft | sft-lora | dpo | dpo-lora | cpt. (cpt has no -lora variant server-side.)
- Queries listFoundationModels, a public API — no console login needed.
@@ -133,7 +135,7 @@ bl finetune cancel --job-id ft-xxx --dry-run
#### Examples
```bash
bl finetune capability --model qwen3-8b
bl finetune capability --base-model qwen3-8b
```
```bash
@@ -166,8 +168,8 @@ bl finetune capability --training-type sft --quiet
#### Notes
- Use the returned `checkpoint` value with `finetune export` to publish
- a deployable model.
- `model_name` (shown for SUCCEEDED checkpoints) is the direct input for `deploy create --model-name`.
- Checkpoints expire ~15 days after creation; `expire_time` shows the deadline. Export or deploy before expiry.
#### Examples
@@ -268,17 +270,17 @@ bl finetune get --job-id ft-xxx --output json
### `bl finetune image create`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Name** | `finetune image create` |
| **Description** | Create an image generation model fine-tune job (sft-lora) |
| **Usage** | `bl finetune image create --model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i\|i2i>] [--learning-rate <str>]` |
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune image create` |
| **Description** | Create an image generation model fine-tune job (sft-lora) |
| **Usage** | `bl finetune image create --base-model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i\|i2i>] [--learning-rate <str>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--model <model>` | string | yes | Base model to fine-tune |
| `--base-model <model>` | string | yes | Base model to fine-tune (e.g. qwen3-8b; not the output model name) |
| `--datasets <ids\|paths>` | string | yes | Comma-separated dataset file IDs or local paths (.jsonl for text, .zip for audio/image). Local paths are uploaded (validated) first, then their file-ids are used. |
| `--validations <ids\|paths>` | string | no | Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets). |
| `--model-name <name>` | string | no | Output model name (after training) |
@@ -304,46 +306,47 @@ bl finetune get --job-id ft-xxx --output json
#### Examples
```bash
bl finetune image create --model wan2.7-image-pro --datasets ./images.zip
bl finetune image create --base-model wan2.7-image-pro --datasets ./images.zip
```
```bash
bl finetune image create --model wan2.7-image-pro --datasets file-xxx
bl finetune image create --base-model wan2.7-image-pro --datasets file-xxx
```
```bash
bl finetune image create --model wan2.7-image-pro --datasets file-xxx --generation-type i2i
bl finetune image create --base-model wan2.7-image-pro --datasets file-xxx --generation-type i2i
```
```bash
bl finetune image create --model wan2.7-image-pro --datasets ./images.zip --model-name my-wan
bl finetune image create --base-model wan2.7-image-pro --datasets ./images.zip --model-name my-wan
```
```bash
bl finetune image create --model wan2.7-image-pro --datasets file-xxx --output json
bl finetune image create --base-model wan2.7-image-pro --datasets file-xxx --output json
```
```bash
bl finetune image create --model wan2.7-image-pro --datasets ./images.zip --dry-run
bl finetune image create --base-model wan2.7-image-pro --datasets ./images.zip --dry-run
```
### `bl finetune list`
| Field | Value |
| --------------- | ---------------------------------------------------------------- |
| **Name** | `finetune list` |
| **Description** | List fine-tune jobs |
| **Usage** | `bl finetune list [--page <n>] [--page-size <n>] [--status <s>]` |
| Field | Value |
| --------------- | --------------------------------------------------------------------------------------- |
| **Name** | `finetune list` |
| **Description** | List fine-tune jobs |
| **Usage** | `bl finetune list [--page <n>] [--page-size <n>] [--status <s>] [--base-model <model>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | -------------------------------------------------------------------- |
| `--page <n>` | number | no | Page number (default: 1) |
| `--page-size <n>` | number | no | Results per page (default: 10, max 100) |
| `--status <s>` | string | no | Filter by status (PENDING / RUNNING / SUCCEEDED / FAILED / CANCELED) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| ---------------------- | ------ | -------- | -------------------------------------------------------------------- |
| `--page <n>` | number | no | Page number (default: 1) |
| `--page-size <n>` | number | no | Results per page (default: 10, max 100) |
| `--status <s>` | string | no | Filter by status (PENDING / RUNNING / SUCCEEDED / FAILED / CANCELED) |
| `--base-model <model>` | string | no | Filter by base model ID (server-side) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Examples
@@ -356,7 +359,11 @@ bl finetune list --status RUNNING
```
```bash
bl finetune list --page-size 20 --output json
bl finetune list --base-model qwen3-8b
```
```bash
bl finetune list --page-size 20
```
### `bl finetune logs`
@@ -405,19 +412,60 @@ bl finetune logs --job-id ft-xxx --tail 20
bl finetune logs --job-id ft-xxx --search checkpoint --tail 5
```
### `bl finetune price`
| Field | Value |
| --------------- | --------------------------------------------------------------------------------------------------- |
| **Name** | `finetune price` |
| **Description** | Estimate the training cost for a fine-tune job (token billing) |
| **Usage** | `bl finetune price --base-model <model> --datasets <ids> [--training-type <type>] [--n-epochs <n>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | ------------------------------------------------------------------ |
| `--base-model <model>` | string | yes | Base model to fine-tune (e.g. qwen3-8b; not the output model name) |
| `--datasets <ids>` | string | yes | Training dataset file IDs, comma-separated (required) |
| `--training-type <type>` | string | no | Training type: sft \| dpo \| cpt (default: sft) |
| `--n-epochs <n>` | number | no | Number of training epochs (default: 3) |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
#### Notes
- Estimate only — the server computes token usage from the datasets; final cost is subject to the bill.
- Covers token billing for sft / dpo / cpt. Training-unit (MTU) billing is not supported by this command.
- Hyper-parameters other than --n-epochs are fixed at representative defaults for estimation.
#### Examples
```bash
bl finetune price --base-model qwen3-8b --datasets file-ft-xxx
```
```bash
bl finetune price --base-model qwen3-8b --datasets file-ft-xxx,file-ft-yyy --n-epochs 2
```
```bash
bl finetune price --base-model qwen3-8b --datasets file-ft-xxx --training-type cpt
```
### `bl finetune text create`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune text create` |
| **Description** | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
| **Usage** | `bl finetune text create --model <model> --datasets <id\|path,...> [--validations <id\|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft\|sft-lora\|dpo\|dpo-lora\|cpt>]` |
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Name** | `finetune text create` |
| **Description** | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
| **Usage** | `bl finetune text create --base-model <model> --datasets <id\|path,...> [--validations <id\|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft\|sft-lora\|dpo\|dpo-lora\|cpt>]` |
#### Flags
| Flag | Type | Required | Description |
| ---------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--model <model>` | string | yes | Base model to fine-tune |
| `--base-model <model>` | string | yes | Base model to fine-tune (e.g. qwen3-8b; not the output model name) |
| `--datasets <ids\|paths>` | string | yes | Comma-separated dataset file IDs or local paths (.jsonl for text, .zip for audio/image). Local paths are uploaded (validated) first, then their file-ids are used. |
| `--validations <ids\|paths>` | string | no | Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets). |
| `--model-name <name>` | string | no | Output model name (after training) |
@@ -453,35 +501,89 @@ bl finetune logs --job-id ft-xxx --search checkpoint --tail 5
#### Examples
```bash
bl finetune text create --model qwen3-8b --datasets file-xxx
bl finetune text create --base-model qwen3-8b --datasets file-xxx
```
```bash
bl finetune text create --model qwen3-8b --datasets ./train.jsonl
bl finetune text create --base-model qwen3-8b --datasets ./train.jsonl
```
```bash
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl
bl finetune text create --base-model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl
```
```bash
bl finetune text create --model qwen3-8b --datasets file-aaa,./extra.jsonl
bl finetune text create --base-model qwen3-8b --datasets file-aaa,./extra.jsonl
```
```bash
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft
bl finetune text create --base-model qwen3-8b --datasets ./train.jsonl --training-type sft
```
```bash
bl finetune text create --model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4
bl finetune text create --base-model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4
```
```bash
bl finetune text create --model qwen3-8b --datasets file-xxx --output json
bl finetune text create --base-model qwen3-8b --datasets file-xxx --output json
```
```bash
bl finetune text create --model qwen3-8b --datasets file-xxx --dry-run
bl finetune text create --base-model qwen3-8b --datasets file-xxx --dry-run
```
### `bl finetune video create`
| Field | Value |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune video create` |
| **Description** | Create a video generation model fine-tune job (Wan i2v/kf2v, efficient_sft) |
| **Usage** | `bl finetune video create --base-model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>]` |
#### Flags
| Flag | Type | Required | Description |
| ---------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--base-model <model>` | string | yes | Base model to fine-tune (e.g. qwen3-8b; not the output model name) |
| `--datasets <ids\|paths>` | string | yes | Comma-separated dataset file IDs or local paths (.jsonl for text, .zip for audio/image). Local paths are uploaded (validated) first, then their file-ids are used. |
| `--validations <ids\|paths>` | string | no | Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets). |
| `--model-name <name>` | string | no | Output model name (after training) |
| `--suffix <text>` | string | no | Output suffix appended by the platform (finetuned_output_suffix) |
| `--n-epochs <n>` | number | no | Training epochs (default: 50) |
| `--batch-size <n>` | number | no | Batch size (default: model-specific, 1 for wan2.7, 4 for wan2.5/2.2) |
| `--learning-rate <str>` | string | no | Learning rate as a string to preserve precision (default: "2e-5") |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- Creating a job uploads any local datasets and consumes training quota.
- Use --dry-run to preview the request body without submitting.
- --datasets / --validations accept either file-ids (from `dataset upload`)
- or local paths. Local paths are validated and uploaded first, then their
- file-ids are submitted — a one-step upload-and-train.
- Video generation training (Wan i2v/kf2v) runs efficient_sft with model-
- specific defaults: wan2.7 (batch_size=1, max_pixels=102400), wan2.5/2.2
- (batch_size=4, max_pixels per model). Override with --batch-size/--n-epochs.
- Datasets are .zip archives with data.jsonl + frame images + videos.
- Recommended: ≥10 training samples, 20-100 for stable results.
#### Examples
```bash
bl finetune video create --base-model wan2.7-i2v --datasets file-xxx
```
```bash
bl finetune video create --base-model wan2.7-i2v --datasets ./i2v-data.zip
```
```bash
bl finetune video create --base-model wan2.2-kf2v-flash --datasets file-xxx --n-epochs 100
```
```bash
bl finetune video create --base-model wan2.7-i2v --datasets file-xxx --dry-run
```
### `bl finetune watch`
+9 -5
View File
@@ -22,6 +22,8 @@ Use this index for the skill-scoped quick index and global flags.
| `bl deploy image create` | Create an image generation model deployment | [deploy.md](deploy.md) |
| `bl deploy list` | List model deployments | [deploy.md](deploy.md) |
| `bl deploy models` | List models available for deployment | [deploy.md](deploy.md) |
| `bl deploy pause` | Pause a running model deployment (stops billing for mu/ptu) | [deploy.md](deploy.md) |
| `bl deploy resume` | Resume a paused model deployment (brings service back online) | [deploy.md](deploy.md) |
| `bl deploy scale` | Scale a deployment's capacity | [deploy.md](deploy.md) |
| `bl deploy text create` | Create a text model deployment | [deploy.md](deploy.md) |
| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) | [deploy.md](deploy.md) |
@@ -35,16 +37,18 @@ Use this index for the skill-scoped quick index and global flags.
| `bl finetune image create` | Create an image generation model fine-tune job (sft-lora) | [finetune.md](finetune.md) |
| `bl finetune list` | List fine-tune jobs | [finetune.md](finetune.md) |
| `bl finetune logs` | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) |
| `bl finetune price` | Estimate the training cost for a fine-tune job (token billing) | [finetune.md](finetune.md) |
| `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) |
| `bl finetune video create` | Create a video generation model fine-tune job (Wan i2v/kf2v, efficient_sft) | [finetune.md](finetune.md) |
| `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [finetune.md](finetune.md) |
## By group
| Group | Commands | Reference |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `dataset` | `delete`, `get`, `list`, `upload`, `validate` | [dataset.md](dataset.md) |
| `deploy` | `audio create`, `delete`, `get`, `image create`, `list`, `models`, `scale`, `text create`, `update` | [deploy.md](deploy.md) |
| `finetune` | `audio create`, `cancel`, `capability`, `checkpoints`, `delete`, `export`, `get`, `image create`, `list`, `logs`, `text create`, `watch` | [finetune.md](finetune.md) |
| Group | Commands | Reference |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `dataset` | `delete`, `get`, `list`, `upload`, `validate` | [dataset.md](dataset.md) |
| `deploy` | `audio create`, `delete`, `get`, `image create`, `list`, `models`, `pause`, `resume`, `scale`, `text create`, `update` | [deploy.md](deploy.md) |
| `finetune` | `audio create`, `cancel`, `capability`, `checkpoints`, `delete`, `export`, `get`, `image create`, `list`, `logs`, `price`, `text create`, `video create`, `watch` | [finetune.md](finetune.md) |
## Global flags
+1
View File
@@ -108,6 +108,7 @@ bl video edit --video https://example.com/input.mp4 --prompt "Put clothes on the
| `--model <model>` | string | no | Model ID (default: happyhorse-1.1-t2v, or happyhorse-1.1-i2v with --image) |
| `--prompt <text>` | string | yes | Video description |
| `--image <url>` | string | no | Input image URL for image-to-video generation |
| `--last-frame <url>` | string | no | Last frame image URL (with --image, enables kf2v first+last frame mode) |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--resolution <res>` | string | no | Resolution: 720P or 1080P (default: 1080P) |
| `--ratio <ratio>` | string | no | Aspect ratio (e.g. 16:9, 9:16, 1:1) |