From 47c24de51366878616c4ebf2e484b5b4a9760d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Tue, 22 Sep 2026 14:55:15 +0800 Subject: [PATCH] feat(deploy): add PTU throughput reservation and capacity commands - Add deploy capacity list/get/create/scale/renew/delete/unsubscribe, deploy overflow, and deploy operation get/wait commands - Extend deploy API and types for reservation capacity (kTPM), prepaid info, capacity instances and operations - Refactor plan strategies to support the PTU reservation model with bilingual flag validation; keep MU/LORA legacy behavior - Centralize request timeout and abort handling in the HTTP client - Add refundConsolePage URL helper for prepaid unsubscribe links - Add unit/e2e coverage and update README and finetune skill references --- README.md | 19 +- README.zh.md | 19 +- packages/cli/src/commands.ts | 20 + .../src/commands/deploy/capacity-get.ts | 41 + .../src/commands/deploy/capacity-list.ts | 117 ++ .../commands/deploy/capacity-unsubscribe.ts | 46 + .../src/commands/deploy/capacity-write.ts | 404 +++++++ .../commands/src/commands/deploy/create.ts | 195 ++-- .../commands/src/commands/deploy/delete.ts | 123 +- packages/commands/src/commands/deploy/get.ts | 25 +- packages/commands/src/commands/deploy/list.ts | 92 +- .../src/commands/deploy/mutation-shared.ts | 335 ++++++ .../src/commands/deploy/operation-get.ts | 41 + .../src/commands/deploy/operation-wait.ts | 233 ++++ .../commands/src/commands/deploy/overflow.ts | 81 ++ .../src/commands/deploy/query-shared.ts | 75 ++ .../commands/src/commands/deploy/scale.ts | 210 +++- packages/commands/src/index.ts | 12 + .../tests/deploy-capacity-write.test.ts | 1013 +++++++++++++++++ .../tests/deploy-legacy-write.test.ts | 739 ++++++++++++ packages/commands/tests/deploy-query.test.ts | 950 ++++++++++++++++ .../commands/tests/deploy-wait-http.test.ts | 398 +++++++ .../commands/tests/e2e/deploy.e2e.test.ts | 567 ++++++++- packages/commands/tests/e2e/topic-routes.ts | 10 + packages/core/src/client/endpoints.ts | 32 +- packages/core/src/client/http.ts | 76 +- packages/core/src/deploy/api.ts | 180 ++- packages/core/src/deploy/index.ts | 9 + packages/core/src/deploy/plans.ts | 73 +- packages/core/src/deploy/reservation.ts | 91 ++ packages/core/src/deploy/types.ts | 187 ++- packages/core/tests/deploy-api.test.ts | 358 ++++++ packages/core/tests/deploy-write-api.test.ts | 667 +++++++++++ packages/core/tests/http.test.ts | 324 ++++++ packages/runtime/src/index.ts | 1 + packages/runtime/src/urls.ts | 9 + skills/bailian-finetune/SKILL.md | 30 +- skills/bailian-finetune/reference/deploy.md | 742 +++++++++--- skills/bailian-finetune/reference/index.md | 84 +- 39 files changed, 8195 insertions(+), 433 deletions(-) create mode 100644 packages/commands/src/commands/deploy/capacity-get.ts create mode 100644 packages/commands/src/commands/deploy/capacity-list.ts create mode 100644 packages/commands/src/commands/deploy/capacity-unsubscribe.ts create mode 100644 packages/commands/src/commands/deploy/capacity-write.ts create mode 100644 packages/commands/src/commands/deploy/mutation-shared.ts create mode 100644 packages/commands/src/commands/deploy/operation-get.ts create mode 100644 packages/commands/src/commands/deploy/operation-wait.ts create mode 100644 packages/commands/src/commands/deploy/overflow.ts create mode 100644 packages/commands/src/commands/deploy/query-shared.ts create mode 100644 packages/commands/tests/deploy-capacity-write.test.ts create mode 100644 packages/commands/tests/deploy-legacy-write.test.ts create mode 100644 packages/commands/tests/deploy-query.test.ts create mode 100644 packages/commands/tests/deploy-wait-http.test.ts create mode 100644 packages/core/src/deploy/reservation.ts create mode 100644 packages/core/tests/deploy-api.test.ts create mode 100644 packages/core/tests/deploy-write-api.test.ts create mode 100644 packages/core/tests/http.test.ts diff --git a/README.md b/README.md index fe1550a2..53621ece 100644 --- a/README.md +++ b/README.md @@ -111,15 +111,16 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex Once installed, just describe your task to your AI Agent — no need to assemble commands by hand. -| Scenario | What to say to your Agent | -| ------------------------ | --------------------------------------------------------------------------------- | -| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." | -| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." | -| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." | -| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." | -| Monitoring & alerts | "Show my model call stats, failures and logs, and create an alert rule." | -| Model selection | "Recommend a model for image understanding and customer support." | -| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." | +| Scenario | What to say to your Agent | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." | +| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." | +| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." | +| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." | +| Monitoring & alerts | "Show my model call stats, failures and logs, and create an alert rule." | +| Throughput reservations | "List my throughput reservations and capacity instances, then scale, renew or release capacity and wait for the operation to finish." | +| Model selection | "Recommend a model for image understanding and customer support." | +| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." | > More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) diff --git a/README.zh.md b/README.zh.md index e64a3da4..611a2133 100644 --- a/README.zh.md +++ b/README.zh.md @@ -110,15 +110,16 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex 安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。 -| 场景 | 可以这样对 Agent 说 | -| ---------------- | ----------------------------------------------------------------------- | -| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” | -| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” | -| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” | -| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” | -| 监控与告警 | “查看我的模型调用统计、失败明细和调用日志,并创建一条告警规则。” | -| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” | -| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” | +| 场景 | 可以这样对 Agent 说 | +| ---------------- | ---------------------------------------------------------------------------- | +| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” | +| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” | +| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” | +| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” | +| 监控与告警 | “查看我的模型调用统计、失败明细和调用日志,并创建一条告警规则。” | +| 吞吐预留 | “查看我的吞吐预留及容量实例,并对容量进行扩缩、续订或释放,再等待操作完成。” | +| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” | +| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” | > 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 9a21bf5d..2871cb6c 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -157,6 +157,16 @@ import { deployImageCreate, deployList, deployGet, + deployCapacityList, + deployCapacityGet, + deployCapacityCreate, + deployCapacityScale, + deployCapacityRenew, + deployCapacityDelete, + deployCapacityUnsubscribe, + deployOverflow, + deployOperationGet, + deployOperationWait, deployModels, deployScale, deployUpdate, @@ -431,6 +441,16 @@ export const commands: Record = { "deploy image create": deployImageCreate, "deploy list": deployList, "deploy get": deployGet, + "deploy capacity list": deployCapacityList, + "deploy capacity get": deployCapacityGet, + "deploy capacity create": deployCapacityCreate, + "deploy capacity scale": deployCapacityScale, + "deploy capacity renew": deployCapacityRenew, + "deploy capacity delete": deployCapacityDelete, + "deploy capacity unsubscribe": deployCapacityUnsubscribe, + "deploy overflow": deployOverflow, + "deploy operation get": deployOperationGet, + "deploy operation wait": deployOperationWait, "deploy models": deployModels, "deploy scale": deployScale, "deploy update": deployUpdate, diff --git a/packages/commands/src/commands/deploy/capacity-get.ts b/packages/commands/src/commands/deploy/capacity-get.ts new file mode 100644 index 00000000..74645781 --- /dev/null +++ b/packages/commands/src/commands/deploy/capacity-get.ts @@ -0,0 +1,41 @@ +import { defineCommand, getCapacityInstance } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { CAPACITY_INSTANCE_FLAG, DEPLOYED_MODEL_FLAG, validateQueryIds } from "./query-shared.ts"; + +export default defineCommand({ + description: { + "en-US": "Get a throughput reservation capacity instance", + "zh-CN": "查询吞吐预留容量实例详情", + }, + auth: "apiKey", + flags: { ...DEPLOYED_MODEL_FLAG, ...CAPACITY_INSTANCE_FLAG }, + usageArgs: "--deployed-model --instance-id ", + exampleArgs: ["--deployed-model example-model-code --instance-id example-capacity-instance"], + notes: [ + { + "en-US": + "Read-only; released instances can also be queried. Preserves the API envelope, capacity fields, can_scale/can_renew/can_delete and deleted. Capacities are kTPM; the instance must belong to this ModelCode.", + "zh-CN": + "只读查询,已释放实例也可查询。保留 API 响应结构、容量字段、can_scale/can_renew/can_delete 和 deleted。容量单位为 kTPM,实例必须属于指定 ModelCode。", + }, + ], + validate: validateQueryIds, + async run(ctx) { + const { settings, flags } = ctx; + if (settings.dryRun) { + emitResult( + { + action: "deploy.capacity.get", + deployed_model: flags.deployedModel, + instance_id: flags.instanceId, + }, + "json", + ); + return; + } + emitResult( + await getCapacityInstance(ctx.client, flags.deployedModel, flags.instanceId), + "json", + ); + }, +}); diff --git a/packages/commands/src/commands/deploy/capacity-list.ts b/packages/commands/src/commands/deploy/capacity-list.ts new file mode 100644 index 00000000..48694c62 --- /dev/null +++ b/packages/commands/src/commands/deploy/capacity-list.ts @@ -0,0 +1,117 @@ +import { defineCommand, listCapacityInstances, type FlagsDef } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { + DEPLOYED_MODEL_FLAG, + queryValues, + validatePagination, + validateQueryIds, +} from "./query-shared.ts"; + +const FLAGS = { + ...DEPLOYED_MODEL_FLAG, + page: { + type: "number", + valueHint: "", + description: { "en-US": "Page number (default: 1)", "zh-CN": "页码(默认:1)" }, + }, + pageSize: { + type: "number", + valueHint: "", + description: { + "en-US": "Results per page (1–100, default: 20)", + "zh-CN": "每页条数(1–100,默认:20)", + }, + }, + includeDeleted: { + type: "boolean", + valueHint: "", + description: { + "en-US": "Include released instances (default: true)", + "zh-CN": "包含已释放实例(默认:true)", + }, + }, + statuses: { + type: "string", + valueHint: "", + description: { + "en-US": "Instance statuses, comma-separated (not ModelCode statuses)", + "zh-CN": "实例状态,逗号分隔(非 ModelCode 状态)", + }, + }, + chargeTypes: { + type: "string", + valueHint: "", + description: { + "en-US": "Charge types: pre_paid, post_paid; comma-separated", + "zh-CN": "付费类型:pre_paid、post_paid,逗号分隔", + }, + }, +} satisfies FlagsDef; + +export default defineCommand({ + description: { + "en-US": "List throughput reservation capacity instances", + "zh-CN": "查询吞吐预留容量实例列表", + }, + auth: "apiKey", + flags: FLAGS, + usageArgs: + "--deployed-model [--page ] [--page-size ] [--include-deleted ] [--statuses ] [--charge-types ]", + exampleArgs: [ + "--deployed-model example-model-code", + "--deployed-model example-model-code --include-deleted false --statuses RUNNING,STOPPED", + ], + notes: [ + { + "en-US": + "Preserves the API envelope and records/items/page/itemsPerPage/pageCount pagination. Capacities are kTPM; effective, configured and target capacities are distinct. STOPPED alone does not mean released: inspect deleted.", + "zh-CN": + "保留 API 响应结构及 records/items/page/itemsPerPage/pageCount 分页字段。容量单位为 kTPM,区分生效、配置和目标容量。仅 STOPPED 不代表已释放,需检查 deleted。", + }, + ], + validate(flags) { + const error = validateQueryIds(flags) ?? validatePagination(flags); + if (error) return error; + if ( + [flags.statuses, flags.chargeTypes].some((value) => + queryValues(value)?.some((entry) => !entry), + ) + ) { + return "Filter lists must not contain empty values. / 筛选列表不能包含空值。"; + } + if ( + queryValues(flags.chargeTypes)?.some((value) => !["pre_paid", "post_paid"].includes(value)) + ) { + return "--charge-types accepts pre_paid,post_paid. / --charge-types 仅支持 pre_paid、post_paid。"; + } + return undefined; + }, + async run(ctx) { + const { flags, settings } = ctx; + const params = { + pageNo: flags.page ?? 1, + pageSize: flags.pageSize ?? 20, + includeDeleted: flags.includeDeleted ?? true, + statuses: queryValues(flags.statuses), + chargeTypes: queryValues(flags.chargeTypes), + }; + if (settings.dryRun) { + emitResult( + { + action: "deploy.capacity.list", + deployed_model: flags.deployedModel, + query: { + page_no: params.pageNo, + page_size: params.pageSize, + include_deleted: params.includeDeleted, + statuses: params.statuses?.join(","), + charge_types: params.chargeTypes?.join(","), + }, + }, + "json", + ); + return; + } + emitResult(await listCapacityInstances(ctx.client, flags.deployedModel, params), "json"); + }, +}); diff --git a/packages/commands/src/commands/deploy/capacity-unsubscribe.ts b/packages/commands/src/commands/deploy/capacity-unsubscribe.ts new file mode 100644 index 00000000..9e435083 --- /dev/null +++ b/packages/commands/src/commands/deploy/capacity-unsubscribe.ts @@ -0,0 +1,46 @@ +import { defineCommand } from "bailian-cli-core"; +import { emitResult, emitBare, refundConsolePage } from "bailian-cli-runtime"; +import { CAPACITY_INSTANCE_FLAG, validateQueryIds } from "./query-shared.ts"; + +export default defineCommand({ + description: { + "en-US": "Build the Aliyun billing console unsubscribe link for a prepaid capacity instance", + "zh-CN": "生成预付费容量实例在阿里云费用中心的退订链接", + }, + auth: "none", + flags: CAPACITY_INSTANCE_FLAG, + usageArgs: "--instance-id ", + exampleArgs: ["--instance-id example-instance"], + notes: [ + { + "en-US": + "No API is called and nothing is unsubscribed by this command; it only builds the refund page link. Open the link and finish the unsubscribe flow in the billing console with the account that placed the order.", + "zh-CN": + "本命令不调用任何接口,也不会执行退订,只生成费用中心退订页面链接。请用下单账号打开链接,在费用中心完成退订流程。", + }, + { + "en-US": + "Only prepaid instances are unsubscribed this way; postpaid instances are released with `deploy capacity delete`. The ID is the capacity instance ID (see `deploy capacity list` / `get`), not the ModelCode. Unsubscription may interrupt serving and is irreversible; refund rules are decided by the billing console.", + "zh-CN": + "仅预付费实例通过此方式退订;后付费实例请使用 `deploy capacity delete` 释放。此处为容量实例 ID(见 `deploy capacity list` / `get`),不是 ModelCode。退订可能中断服务且不可撤销,退款规则以费用中心为准。", + }, + ], + validate: validateQueryIds, + async run(ctx) { + const { settings, flags } = ctx; + // Pure link builder: no network call, so dry-run and execution share one path. + const refundUrl = refundConsolePage(flags.instanceId); + if (settings.quiet) { + emitBare(refundUrl); + return; + } + emitResult( + { + action: "deploy.capacity.unsubscribe", + instance_id: flags.instanceId, + refund_url: refundUrl, + }, + "json", + ); + }, +}); diff --git a/packages/commands/src/commands/deploy/capacity-write.ts b/packages/commands/src/commands/deploy/capacity-write.ts new file mode 100644 index 00000000..3655933d --- /dev/null +++ b/packages/commands/src/commands/deploy/capacity-write.ts @@ -0,0 +1,404 @@ +import { randomUUID } from "node:crypto"; +import { + BailianError, + ExitCode, + defineCommand, + createCapacityInstance, + scaleCapacityInstance, + renewCapacityInstance, + deleteCapacityInstance, + buildReservationCapacity, + buildPrepaidInfo, + validateReservationCapacity, + validatePrepaidFlags, + hasPrepaidFlags, + type FlagsDef, +} from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { CAPACITY_INSTANCE_FLAG, DEPLOYED_MODEL_FLAG, validateQueryIds } from "./query-shared.ts"; +import { + CAPACITY_FLAGS, + PREPAID_FLAGS, + WRITE_OPTIONS, + ORDER_TYPE_FLAG, + MUTATION_NOTES, + validateWriteOptions, + requireCapacityInstance, + finishMutation, + submitCapacityWrite, +} from "./mutation-shared.ts"; + +const CREATE_FLAGS = { + ...DEPLOYED_MODEL_FLAG, + ...CAPACITY_FLAGS, + ...PREPAID_FLAGS, + ...WRITE_OPTIONS, + billingMethod: { + type: "string", + valueHint: "", + required: true, + choices: ["PRE_PAY", "POST_PAY"] as const, + description: { + "en-US": "Capacity instance billing method (case-sensitive)", + "zh-CN": "容量实例付费方式(区分大小写)", + }, + }, +} satisfies FlagsDef; + +export const deployCapacityCreate = defineCommand({ + description: { + "en-US": "Purchase an additional capacity instance for a ModelCode", + "zh-CN": "为已有 ModelCode 叠加购买容量实例", + }, + auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": "Purchases additional throughput capacity and may enable recurring charges.", + "zh-CN": "该操作将购买额外吞吐容量,可能开启持续续费。", + }, + }, + flags: CREATE_FLAGS, + usageArgs: + "--deployed-model --billing-method --input-tpm --output-tpm [--duration ] [--auto-renewal ] [--wait]", + exampleArgs: [ + "--deployed-model example-code --billing-method POST_PAY --input-tpm 10000 --output-tpm 1000 --dry-run", + "--deployed-model example-code --billing-method PRE_PAY --input-tpm 10000 --output-tpm 1000 --duration 30 --auto-renewal false --dry-run", + ], + notes: [ + ...MUTATION_NOTES, + { + "en-US": + "Keeps the ModelCode, model and performance tier. Only one unreleased postpaid instance is allowed per ModelCode; the server validates purchase eligibility and slot limits.", + "zh-CN": + "沿用原 ModelCode、模型和性能档位。同一 ModelCode 只允许一个未释放的后付费实例;购买资格和实例数量限制由服务端校验。", + }, + ], + validate(flags) { + const error = + validateQueryIds(flags) ?? + validateWriteOptions(flags) ?? + validateReservationCapacity(flags, true); + if (error) return error; + if (flags.billingMethod === "POST_PAY" && hasPrepaidFlags(flags)) + return "POST_PAY cannot include prepaid settings. / 后付费不能携带预付费配置。"; + return validatePrepaidFlags(flags, flags.billingMethod === "PRE_PAY"); + }, + async run(ctx) { + const { flags, settings } = ctx; + const prepaid = buildPrepaidInfo(flags); + const body = { + billing_method: flags.billingMethod, + ptu_capacity: buildReservationCapacity(flags), + ...(prepaid ? { pre_paid_info: prepaid } : {}), + }; + if (settings.dryRun) { + emitResult( + { + action: "deploy.capacity.create", + deployed_model: flags.deployedModel, + body, + client_request_id: flags.requestId ?? "", + wait: flags.wait, + }, + "json", + ); + return; + } + const requestId = flags.requestId ?? randomUUID(); + const response = await submitCapacityWrite(requestId, () => + createCapacityInstance(ctx.client, flags.deployedModel, body, { + requestId, + }), + ); + await finishMutation(ctx.client, flags.deployedModel, response, flags, requestId, true); + }, +}); + +const SCALE_FLAGS = { + ...DEPLOYED_MODEL_FLAG, + ...CAPACITY_INSTANCE_FLAG, + ...CAPACITY_FLAGS, + ...PREPAID_FLAGS, + ...ORDER_TYPE_FLAG, + ...WRITE_OPTIONS, +} satisfies FlagsDef; + +export const deployCapacityScale = defineCommand({ + description: { + "en-US": "Scale the absolute capacity of one reservation instance", + "zh-CN": "调整单个预留实例的绝对容量", + }, + auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": + "Changes purchased capacity, may incur charges and can reduce serving capacity to zero.", + "zh-CN": "该操作会变更购买容量、可能产生费用,也可能将服务容量降至零。", + }, + }, + flags: SCALE_FLAGS, + usageArgs: + "--deployed-model --instance-id --input-tpm --output-tpm [--order-type ] [--wait]", + exampleArgs: [ + "--deployed-model example-code --instance-id example-instance --input-tpm 20000 --output-tpm 2000 --dry-run", + "--deployed-model example-code --instance-id example-instance --input-tpm 0 --output-tpm 0 --dry-run", + ], + notes: [ + ...MUTATION_NOTES, + { + "en-US": + "Refreshes can_scale before writing. Omitted prepaid settings remain omitted, so the server can reuse saved information. Postpaid instances cannot receive prepaid settings.", + "zh-CN": + "写入前刷新 can_scale。未指定预付费参数时不发送该对象,由服务端复用已保存的信息。后付费实例不能携带预付费配置。", + }, + ], + validate: (flags) => + validateQueryIds(flags) ?? + validateWriteOptions(flags) ?? + validateReservationCapacity(flags, true) ?? + validatePrepaidFlags(flags, false), + async run(ctx) { + const { flags, settings } = ctx; + const prepaid = buildPrepaidInfo(flags); + const body = { + ptu_capacity: buildReservationCapacity(flags), + ...(flags.orderType ? { order_type: flags.orderType } : {}), + ...(prepaid ? { pre_paid_info: prepaid } : {}), + }; + if (settings.dryRun) { + emitResult( + { + action: "deploy.capacity.scale", + deployed_model: flags.deployedModel, + instance_id: flags.instanceId, + body, + client_request_id: flags.requestId ?? "", + precheck: "can_scale", + wait: flags.wait, + }, + "json", + ); + return; + } + const instance = await requireCapacityInstance( + ctx.client, + flags.deployedModel, + flags.instanceId, + "scale", + ); + if (!["pre_paid", "post_paid"].includes(instance.charge_type ?? "")) + throw new BailianError( + "Unknown instance billing type; cannot safely scale. / 实例付费类型不明确,无法安全变配。", + ExitCode.USAGE, + ); + if (prepaid && instance.charge_type !== "pre_paid") + throw new BailianError( + "Prepaid settings require a prepaid instance. / 预付费配置只能用于预付费实例。", + ExitCode.USAGE, + ); + const requestId = flags.requestId ?? randomUUID(); + const response = await submitCapacityWrite(requestId, () => + scaleCapacityInstance(ctx.client, flags.deployedModel, flags.instanceId, body, { requestId }), + ); + await finishMutation(ctx.client, flags.deployedModel, response, flags, requestId, true); + }, +}); + +const RENEW_FLAGS = { + ...DEPLOYED_MODEL_FLAG, + ...CAPACITY_INSTANCE_FLAG, + ...CAPACITY_FLAGS, + ...PREPAID_FLAGS, + ...WRITE_OPTIONS, + isChange: { + type: "boolean", + valueHint: "", + description: { + "en-US": "Change capacity during renewal (default: false)", + "zh-CN": "续订时同时变更容量(默认:false)", + }, + }, +} satisfies FlagsDef; + +export const deployCapacityRenew = defineCommand({ + description: { + "en-US": "Renew a prepaid capacity instance, optionally changing capacity", + "zh-CN": "续订预付费容量实例,可同时变更容量", + }, + auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": "Renews a paid capacity instance and may enable automatic recurring renewal.", + "zh-CN": "该操作将付费续订容量实例,可能开启自动持续续费。", + }, + }, + flags: RENEW_FLAGS, + usageArgs: + "--deployed-model --instance-id --duration --auto-renewal [--is-change ] [--input-tpm ] [--output-tpm ] [--wait]", + exampleArgs: [ + "--deployed-model example-code --instance-id example-instance --duration 30 --auto-renewal false --dry-run", + "--deployed-model example-code --instance-id example-instance --duration 30 --auto-renewal true --auto-renewal-duration 30 --dry-run", + ], + notes: [ + ...MUTATION_NOTES, + { + "en-US": + "Prepaid only; refreshes can_renew. Different capacity requires --is-change true; an unchanged capacity may be sent with false. No order_type is accepted by this endpoint.", + "zh-CN": + "仅支持预付费,写入前刷新 can_renew。变更容量必须指定 --is-change true;相同配置容量可随 false 发送。本接口不接受 order_type。", + }, + ], + validate: (flags) => + validateQueryIds(flags) ?? + validateWriteOptions(flags) ?? + validateReservationCapacity(flags, false) ?? + validatePrepaidFlags(flags, true), + async run(ctx) { + const { flags, settings } = ctx; + const capacity = flags.inputTpm !== undefined ? buildReservationCapacity(flags) : undefined; + const body = { + pre_paid_info: buildPrepaidInfo(flags)!, + is_change: flags.isChange ?? false, + ...(capacity ? { ptu_capacity: capacity } : {}), + }; + if (settings.dryRun) { + emitResult( + { + action: "deploy.capacity.renew", + deployed_model: flags.deployedModel, + instance_id: flags.instanceId, + body, + client_request_id: flags.requestId ?? "", + precheck: "can_renew/pre_paid/configured_capacity", + wait: flags.wait, + }, + "json", + ); + return; + } + const instance = await requireCapacityInstance( + ctx.client, + flags.deployedModel, + flags.instanceId, + "renew", + ); + if (instance.charge_type !== "pre_paid") + throw new BailianError( + "Only prepaid instances can be renewed. / 仅预付费实例可以续订。", + ExitCode.USAGE, + ); + if ( + capacity && + !flags.isChange && + (capacity.input_tpm !== instance.configured_capacity?.input_tpm || + capacity.output_tpm !== instance.configured_capacity?.output_tpm) + ) { + throw new BailianError( + "Different capacity requires --is-change true. / 变更容量必须指定 --is-change true。", + ExitCode.USAGE, + ); + } + const requestId = flags.requestId ?? randomUUID(); + const response = await submitCapacityWrite(requestId, () => + renewCapacityInstance(ctx.client, flags.deployedModel, flags.instanceId, body, { requestId }), + ); + await finishMutation(ctx.client, flags.deployedModel, response, flags, requestId, true); + }, +}); + +const DELETE_FLAGS = { + ...DEPLOYED_MODEL_FLAG, + ...CAPACITY_INSTANCE_FLAG, + ...WRITE_OPTIONS, + reason: { + type: "string", + valueHint: "", + description: { + "en-US": "Optional release reason, sent as a query parameter", + "zh-CN": "可选释放原因,以查询参数发送", + }, + }, +} satisfies FlagsDef; + +export const deployCapacityDelete = defineCommand({ + description: { + "en-US": "Release a capacity instance without deleting its ModelCode", + "zh-CN": "释放容量实例,保留所属 ModelCode", + }, + auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": + "Releases serving capacity and may interrupt requests; this cannot be undone by this command. This is not a prepaid unsubscribe/refund action.", + "zh-CN": + "该操作将释放服务容量,可能中断请求,不能通过本命令撤销。这不是预付费退订或退款操作。", + }, + }, + flags: DELETE_FLAGS, + usageArgs: "--deployed-model --instance-id [--reason ] [--wait]", + exampleArgs: ["--deployed-model example-code --instance-id example-instance --dry-run"], + notes: [ + ...MUTATION_NOTES, + { + "en-US": + "Active prepaid instances must be unsubscribed via `deploy capacity unsubscribe` (billing console link), even when can_delete=true. Failed prepaid instances may have no associated order; only the server can decide whether direct release is supported. Release is asynchronous; verify deleted=true, not merely STOPPED or zero capacity.", + "zh-CN": + "已生效预付费实例须通过 `deploy capacity unsubscribe` 获取费用中心退订链接完成退订,即使 can_delete=true 也不能直接删除。失败的预付费实例可能尚未关联订单,能否直接释放由服务端裁决。释放是异步操作,须确认 deleted=true,不能只看 STOPPED 或零容量。", + }, + ], + validate: (flags) => validateQueryIds(flags) ?? validateWriteOptions(flags), + async run(ctx) { + const { flags, settings } = ctx; + if (settings.dryRun) { + emitResult( + { + action: "deploy.capacity.delete", + deployed_model: flags.deployedModel, + instance_id: flags.instanceId, + query: flags.reason === undefined ? {} : { reason: flags.reason }, + client_request_id: flags.requestId ?? "", + precheck: "can_delete/prepaid_unsubscribe", + wait: flags.wait, + }, + "json", + ); + return; + } + const instance = await requireCapacityInstance( + ctx.client, + flags.deployedModel, + flags.instanceId, + "delete", + ); + if (instance.charge_type === "pre_paid" && instance.status !== "FAILED") + throw new BailianError( + "Prepaid instances must be unsubscribed in the billing console; DELETE is not a refund. Run `deploy capacity unsubscribe --instance-id ` to get the refund page link. / 预付费实例请在费用中心退订,DELETE 不能替代退订退款。运行 `deploy capacity unsubscribe --instance-id ` 获取退订页面链接。", + ExitCode.USAGE, + ); + if (!["pre_paid", "post_paid"].includes(instance.charge_type ?? "")) + throw new BailianError( + "Unknown instance billing type; cannot safely release. / 实例付费类型不明确,无法安全释放。", + ExitCode.USAGE, + ); + const requestId = flags.requestId ?? randomUUID(); + const response = await submitCapacityWrite(requestId, () => + deleteCapacityInstance(ctx.client, flags.deployedModel, flags.instanceId, { + requestId, + reason: flags.reason, + }), + ); + await finishMutation( + ctx.client, + flags.deployedModel, + response, + flags, + requestId, + true, + flags.instanceId, + ); + }, +}); diff --git a/packages/commands/src/commands/deploy/create.ts b/packages/commands/src/commands/deploy/create.ts index afbe6844..8c7dfb83 100644 --- a/packages/commands/src/commands/deploy/create.ts +++ b/packages/commands/src/commands/deploy/create.ts @@ -4,13 +4,22 @@ import { pickPlanStrategy, STRATEGIES, defaultDeployPlan, + hasPrepaidFlags, type DeployModality, type CreateDeploymentRequest, - type CreatePlanFlags, type CommandContext, + type CommandRisk, type FlagsDef, + type ParsedFlags, } from "bailian-cli-core"; import { emitResult, emitBare } from "bailian-cli-runtime"; +import { + CAPACITY_FLAGS, + PREPAID_FLAGS, + WAIT_OPTIONS, + finishMutation, + validateWriteOptions, +} from "./mutation-shared.ts"; const CREATE_FLAGS = { modelName: { @@ -26,17 +35,16 @@ const CREATE_FLAGS = { type: "string", valueHint: "", description: { - "en-US": "Console display name for the deployment (required)", - "zh-CN": "部署在控制台中的显示名称(必填)", + "en-US": "Console display name (optional for PTU; required for other plans)", + "zh-CN": "控制台显示名称(PTU 可选;其他方案必填)", }, - required: true, }, plan: { type: "string", valueHint: "", description: { - "en-US": "Billing plan: lora (default, Token-billed) | ptu (Token-billed) | mu", - "zh-CN": "计费方案:lora(默认,按 Token 计费)| ptu(按 Token 计费)| mu", + "en-US": "Billing plan: lora (default, Token-billed) | ptu (throughput reservation) | mu", + "zh-CN": "计费方案:lora(默认,按 Token 计费)| ptu(吞吐预留)| mu", }, }, deploySpec: { @@ -63,34 +71,55 @@ const CREATE_FLAGS = { "zh-CN": '计费方式(仅 plan=mu;默认且仅支持 "POST_PAY")', }, }, - inputTpm: { + ...CAPACITY_FLAGS, + thinkingOutputTpm: { type: "number", valueHint: "", description: { - "en-US": "PTU max input tokens/min (required for plan=ptu)", - "zh-CN": "PTU 每分钟最大输入 Token 数(plan=ptu 时必填)", + "en-US": "Legacy thinking-output capacity flag; not supported for PTU", + "zh-CN": "旧版思考输出容量参数;PTU 不支持此参数", }, }, - outputTpm: { - type: "number", - valueHint: "", + chargeType: { + type: "string", + valueHint: "", + choices: ["pre_paid", "post_paid"] as const, description: { - "en-US": "PTU max output tokens/min (required for plan=ptu)", - "zh-CN": "PTU 每分钟最大输出 Token 数(plan=ptu 时必填)", + "en-US": "PTU payment type (required for plan=ptu): pre_paid or post_paid", + "zh-CN": "PTU 付费方式(plan=ptu 必填):pre_paid 或 post_paid", }, }, - thinkingOutputTpm: { - type: "number", - valueHint: "", + serviceTier: { + type: "string", + valueHint: "", + choices: ["ptu_fast", "ptu_default"] as const, description: { - "en-US": "PTU max thinking-output tokens/min (optional, some models)", - "zh-CN": "PTU 每分钟最大思考输出 Token 数(部分模型可选)", + "en-US": "PTU service tier: ptu_fast (default) or ptu_default (prepaid only)", + "zh-CN": "PTU 性能档位:ptu_fast(默认高速)或 ptu_default(标速,仅预付费)", }, }, + suffix: { + type: "string", + valueHint: "", + description: { + "en-US": "Optional PTU ModelCode suffix; generated by the service when omitted", + "zh-CN": "可选 PTU ModelCode 后缀;省略时由服务端生成", + }, + }, + ...PREPAID_FLAGS, + ...WAIT_OPTIONS, } satisfies FlagsDef; const CREATE_USAGE = - "--model-name --display-name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]"; + "--model-name [--display-name ] [--plan ] [--charge-type ] [--service-tier ] [--suffix ] [--input-tpm --output-tpm ] [flags]"; + +const CREATE_RISK: CommandRisk = { + level: "high", + message: { + "en-US": "Creating a deployment purchases or provisions resources and may incur charges.", + "zh-CN": "创建部署会购买或开通资源,可能产生费用。", + }, +}; const CREATE_NOTES = [ { @@ -101,9 +130,9 @@ const CREATE_NOTES = [ }, { "en-US": - "For plan=ptu (Token-billed, provisioned throughput), --input-tpm and --output-tpm are required (the platform rejects creation without an explicit ptu_capacity despite the doc listing defaults).", + "For plan=ptu, --charge-type, --input-tpm and --output-tpm are required. Capacity is in kTPM (1 kTPM = 1000 tokens/minute). Separate thinking-output capacity is unsupported. Prepaid requires --duration and explicit --auto-renewal true|false.", "zh-CN": - "plan=ptu(按 Token 计费的预置吞吐)时,--input-tpm 和 --output-tpm 必填;即使文档列出了默认值,未显式传入 ptu_capacity 时平台也会拒绝创建。", + "plan=ptu 时,--charge-type、--input-tpm 和 --output-tpm 必填。容量单位为 kTPM(1 kTPM = 1000 Token/分钟),不支持单独的思考输出容量。预付费须提供 --duration 和显式 --auto-renewal true|false。", }, { "en-US": @@ -112,88 +141,100 @@ const CREATE_NOTES = [ "plan=mu 时,`capacity`、`billing_method` 和 `deploy_spec` 必填。billing_method 默认且仅支持 POST_PAY;省略 deploy_spec 和 capacity 时,会从 GET /deployments/models 自动选择。", }, { - "en-US": "Use `bl deploy models --source base` to inspect available templates.", - "zh-CN": "使用 `bl deploy models --source base` 查看可用模板。", + "en-US": "Use `deploy models --source base` to inspect available templates.", + "zh-CN": "使用 `deploy models --source base` 查看可用模板。", }, { "en-US": - "After creation, status starts at PENDING and transitions to RUNNING. Invoke the deployed model with: bl text chat --model ", + "PTU creation preserves the original JSON and operation_id even with --quiet. --wait only queries the returned operation; pending capacity is not effective capacity. Check the actual status before invoking the model.", "zh-CN": - "创建后状态从 PENDING 开始,随后转为 RUNNING。调用已部署模型:bl text chat --model ", + "PTU 创建即使指定 --quiet 也保留原始 JSON 和 operation_id。--wait 仅查询返回的操作;待生效容量不等于已生效容量。调用模型前请确认实际状态。", }, { "en-US": - "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 `) and lifecycle commands (`deploy get/scale/pause/resume/delete --deployed-model `).", + "--model-name identifies the source model; the returned deployed_model is the invocation identifier (ModelCode). Use it for inference (`text chat --model `) and deployment lifecycle commands.", "zh-CN": - "注意:--model-name 是要部署的模型(例如 `qwen3-8b-ft-...`)。创建响应中的 `deployed_model` 是部署实例 ID(例如 `qwen3-8b-5ecb5f068d79`),用于推理(`bl text chat --model `)及生命周期命令(`deploy get/scale/pause/resume/delete --deployed-model `)。", + "--model-name 标识源模型;返回的 deployed_model 为调用标识(ModelCode),用于推理(`text chat --model `)和部署生命周期命令。", + }, + { + "en-US": + "Initial ModelCode creation does not support --request-id or promise idempotency. A timeout is not proof of failure: check whether the deployment was created before trying again. This command never automatically resubmits creation.", + "zh-CN": + "首次创建 ModelCode 不支持 --request-id,也不承诺幂等。超时不代表失败:再次尝试前先确认部署是否已创建。本命令不会自动重新提交创建请求。", + }, + { + "en-US": + "Use --dry-run to preview without requests. Pass --yes only after confirming the costs.", + "zh-CN": "使用 --dry-run 离线预览;仅在确认费用后传入 --yes 执行。", }, ]; -/** - * Shared `deploy create` flag validation. Plan support is - * server-catalog-driven, so validation is identical for every modality: resolve - * the effective plan (modality-specific default when --plan is omitted), reject - * an unknown --plan, then defer to the plan strategy's required-flag check. - */ -function validateCreate(modality: DeployModality, flags: CreatePlanFlags): string | undefined { +function validateCreate( + modality: DeployModality, + flags: ParsedFlags, +): string | undefined { const plan = flags.plan || defaultDeployPlan(modality); const strategy = STRATEGIES[plan]; if (!strategy) { - return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`; + return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}. / 不支持方案 "${plan}";支持的方案:${Object.keys(STRATEGIES).join(", ")}。`; } - return strategy.validateFlags(flags); + if (plan !== "ptu") { + if ( + flags.chargeType !== undefined || + flags.serviceTier !== undefined || + flags.suffix !== undefined || + hasPrepaidFlags(flags) || + flags.wait || + flags.interval !== undefined || + flags.pollTimeout !== undefined + ) { + return "Payment, service tier, suffix, prepaid and wait options are PTU-only. / 付费方式、性能档位、后缀、预付费和等待参数仅适用于 PTU。"; + } + if (!flags.displayName?.trim()) { + return "--display-name is required for non-PTU plans. / 非 PTU 方案必须提供 --display-name。"; + } + } else if (flags.thinkingOutputTpm !== undefined) { + return "--thinking-output-tpm is not supported for PTU. / PTU 不支持 --thinking-output-tpm。"; + } + return validateWriteOptions(flags) ?? strategy.validateFlags(flags); } -/** - * Shared `deploy create` implementation. deploy create takes a model - * by name and a billing plan — it does NOT inspect data modality for the request - * body, so the run logic is identical across text / audio / image. The modality - * only fixes the default plan (audio → mu, text/image → lora) and the command - * path / description / examples. - * - * Plan-specific behaviour (required flags / body assembly / auto-pick) lives in - * core `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file only handles the - * shared envelope: dispatch, dry-run, and result formatting. - */ +/** Shared create implementation; plan-specific body assembly remains in core. */ async function runCreate( modality: DeployModality, ctx: CommandContext, ): Promise { const { identity, settings, flags } = ctx; - const model = flags.modelName as string; - const name = flags.displayName as string; - const plan = (flags.plan as string | undefined) || defaultDeployPlan(modality); - - // Plan-specific behaviour is owned by core `plans.ts`. The strategy resolves - // the plan-specific body fragment (mu may auto-pick a template from the - // deployable-models catalog). Anything outside the strategy table was - // already rejected by `validate` above. + const plan = flags.plan || defaultDeployPlan(modality); const strategy = pickPlanStrategy(plan); - - const resolved = await strategy.resolve({ - client: ctx.client, - dryRun: settings.dryRun, - binName: identity.binName, - flags: flags as CreatePlanFlags, - model, - name, - }); - const body: Record = { - model_name: model, - name, - plan, - ...resolved.body, + const resolveBody = async (dryRun: boolean): Promise => { + const resolved = await strategy.resolve({ + client: ctx.client, + dryRun, + binName: identity.binName, + flags, + model: flags.modelName, + name: flags.displayName, + }); + return { + model_name: flags.modelName, + ...(flags.displayName !== undefined ? { name: flags.displayName } : {}), + plan, + ...resolved.body, + }; }; + // A dry strategy resolution must precede any catalog lookup or write. if (settings.dryRun) { - emitResult({ action: "deploy.create", body }, "json"); + emitResult({ action: "deploy.create", body: await resolveBody(true) }, "json"); return; } - const response = await createDeployment(ctx.client, body as CreateDeploymentRequest); + const response = await createDeployment(ctx.client, await resolveBody(false)); const deployment = response.output ?? response.data; - - if (settings.quiet) { + if (plan === "ptu") { + await finishMutation(ctx.client, deployment?.deployed_model, response, flags); + } else if (settings.quiet) { emitBare(deployment?.deployed_model ?? ""); } else { emitResult(response, "json"); @@ -204,11 +245,13 @@ async function runCreate( export const deployTextCreate = defineCommand({ description: { "en-US": "Create a text model deployment", "zh-CN": "创建文本模型部署" }, auth: "apiKey", + risk: CREATE_RISK, usageArgs: CREATE_USAGE, flags: CREATE_FLAGS, exampleArgs: [ "--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.6-flash-2026-04-16 --plan ptu --charge-type post_paid --input-tpm 10000 --output-tpm 1000 --dry-run", + "--model-name qwen3.6-flash-2026-04-16 --plan ptu --charge-type pre_paid --service-tier ptu_default --input-tpm 10000 --output-tpm 1000 --duration 30 --auto-renewal false --dry-run", "--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", ], @@ -224,6 +267,7 @@ export const deployAudioCreate = defineCommand({ "zh-CN": "创建音频(TTS)模型部署", }, auth: "apiKey", + risk: CREATE_RISK, usageArgs: CREATE_USAGE, flags: CREATE_FLAGS, exampleArgs: [ @@ -243,6 +287,7 @@ export const deployImageCreate = defineCommand({ "zh-CN": "创建图片生成模型部署", }, auth: "apiKey", + risk: CREATE_RISK, usageArgs: CREATE_USAGE, flags: CREATE_FLAGS, exampleArgs: [ diff --git a/packages/commands/src/commands/deploy/delete.ts b/packages/commands/src/commands/deploy/delete.ts index 33740dbf..489fd908 100644 --- a/packages/commands/src/commands/deploy/delete.ts +++ b/packages/commands/src/commands/deploy/delete.ts @@ -2,6 +2,8 @@ import { defineCommand, deleteDeployment, getDeployment, + listCapacityInstances, + getCapacityOperation, BailianError, ExitCode, type FlagsDef, @@ -21,23 +23,16 @@ const DELETE_FLAGS = { skipPrecheck: { type: "switch", description: { - "en-US": "Skip the local STOPPED/FAILED status precheck", - "zh-CN": "跳过本地 STOPPED/FAILED 状态预检查", + "en-US": "Skip local checks only; the service still validates deletion prerequisites", + "zh-CN": "仅跳过本地检查;服务端仍验证删除前提条件", }, }, } satisfies FlagsDef; -/** - * `bl deploy delete` — destroy a deployment. - * - * Server-side precondition: status must be STOPPED or FAILED. We surface a - * clear local hint for RUNNING / PENDING deployments before issuing the - * DELETE call. - */ export default defineCommand({ description: { - "en-US": "Delete a model deployment (must be STOPPED or FAILED)", - "zh-CN": "删除模型部署(状态必须为 STOPPED 或 FAILED)", + "en-US": "Delete a model deployment (PTU must be STOPPED with all capacity released)", + "zh-CN": "删除模型部署(PTU 须为 STOPPED 且已释放全部容量)", }, auth: "apiKey", risk: { @@ -50,9 +45,25 @@ export default defineCommand({ usageArgs: "--deployed-model [--skip-precheck]", flags: DELETE_FLAGS, exampleArgs: [ - "--deployed-model dep-...", "--deployed-model dep-... --dry-run", - "--deployed-model dep-... --yes", + { + "en-US": "--deployed-model dep-... --yes # Execute only after confirming deletion", + "zh-CN": "--deployed-model dep-... --yes # 仅在确认删除后执行", + }, + ], + notes: [ + { + "en-US": + "PTU deletion requires STOPPED, all capacity instances released, and no processing or queued capacity operations. Pausing or scaling to zero does not release an instance; prepaid capacity may require unsubscription.", + "zh-CN": + "PTU 删除要求状态为 STOPPED、全部容量实例已释放,且不存在执行中或排队的容量操作。暂停或缩容到零不等于释放实例;预付费容量可能需要先退订。", + }, + { + "en-US": + "Local checks inspect the deployment, unreleased instances and any returned operation_id. There is no public queued-operation list API, so these checks cannot confirm all operations are finished; the service makes the final decision. --skip-precheck only omits local checks, never service validation or resource release requirements.", + "zh-CN": + "本地检查部署、未释放实例及返回的 operation_id。没有公开的排队操作列表接口,因此无法在本地确认所有操作均已完成,最终以服务端裁决为准。--skip-precheck 仅省略本地检查,不绕过服务端验证或资源释放要求。", + }, ], async run(ctx) { const { settings, flags } = ctx; @@ -63,29 +74,87 @@ export default defineCommand({ return; } - // Precheck status unless skipped — surface a clear hint instead of letting - // the server return a generic precondition error. if (!flags.skipPrecheck) { - try { - const get = await getDeployment(ctx.client, deployedModel); - const deployment = get.output ?? get.data; - const status = (deployment?.status ?? "").toUpperCase(); - if (status && status !== "STOPPED" && status !== "FAILED") { + // Do not swallow a failed GET and proceed with a destructive request. + const response = await getDeployment(ctx.client, deployedModel); + const deployment = response.output ?? response.data; + const status = (deployment?.status ?? "").toUpperCase(); + const isPtu = + deployment?.plan === "ptu" || + deployment?.plan === "ptu_v2" || + deployment?.ptu_capacity !== undefined || + deployment?.ptu_service_tier !== undefined; + if (isPtu) { + if (status !== "STOPPED") { + throw new BailianError( + `PTU deployment ${deployedModel} must be STOPPED before deletion (current: ${status || "unknown"}). Release all capacity instances first; pausing is not release. / PTU 部署 ${deployedModel} 删除前必须为 STOPPED(当前:${status || "未知"});请先释放全部容量实例,暂停不等于释放。`, + ExitCode.USAGE, + ); + } + const instancesResponse = await listCapacityInstances(ctx.client, deployedModel, { + includeDeleted: false, + pageNo: 1, + pageSize: 1, + }); + const page = instancesResponse.output ?? instancesResponse.data; + if ( + (Array.isArray(page?.records) && page.records.length > 0) || + (typeof page?.items === "number" && page.items > 0) || + (typeof page?.total === "number" && page.total > 0) + ) { throw new BailianError( - `Deployment ${deployedModel} is ${status}. Only STOPPED / FAILED deployments can be deleted. ` + - `Run \`bl deploy pause --deployed-model ${deployedModel}\` to pause it first, ` + - `or pass --skip-precheck to attempt deletion anyway.`, + "Release all capacity instances before deleting the PTU deployment; STOPPED or zero capacity does not mean released. / 删除 PTU 部署前请先释放全部容量实例;STOPPED 或零容量不等于已释放。", ExitCode.USAGE, ); } - } catch (error) { - if (error instanceof BailianError) throw error; - // If the get itself failed (e.g. not found), let the DELETE call surface the real error. + // Require a recognizable empty page and an explicit zero total. + if ( + !Array.isArray(page?.records) || + page.records.length !== 0 || + page.items !== 0 || + (page.total !== undefined && page.total !== 0) || + (page.page !== undefined && page.page !== 1) || + (page.pageCount !== undefined && page.pageCount !== 0 && page.pageCount !== 1) + ) { + throw new BailianError( + "Cannot confirm all capacity instances are released from the list response; deletion was not submitted. / 无法从列表响应确认全部容量实例已释放;未提交删除请求。", + ExitCode.USAGE, + ); + } + if (deployment?.operation_id) { + const operationResponse = await getCapacityOperation( + ctx.client, + deployedModel, + deployment.operation_id, + ); + const operation = operationResponse.output ?? operationResponse.data; + if (operation?.operation_status === "PROCESSING") { + throw new BailianError( + "A capacity operation is processing or queued. Wait for it to finish before deleting the deployment. / 存在执行中或排队的容量操作,请等待其结束后再删除部署。", + ExitCode.USAGE, + ); + } + if ( + operation?.operation_status !== "SUCCEEDED" && + operation?.operation_status !== "FAILED" + ) { + throw new BailianError( + "Cannot confirm the returned capacity operation has finished; deletion was not submitted. / 无法确认返回的容量操作已结束;未提交删除请求。", + ExitCode.USAGE, + ); + } + } + } else if (status !== "STOPPED" && status !== "FAILED") { + throw new BailianError( + `Deployment ${deployedModel} must be STOPPED or FAILED before deletion (current: ${status || "unknown"}). / 部署 ${deployedModel} 删除前必须为 STOPPED 或 FAILED(当前:${status || "未知"})。`, + ExitCode.USAGE, + "Check the deployment status with `deploy get` before deletion. / 删除前请使用 `deploy get` 检查部署状态。", + ); } } + // Prechecks are not atomic and cannot enumerate queued operations; the server decides. const response = await deleteDeployment(ctx.client, deployedModel); - if (settings.quiet) { emitBare(deployedModel); } else { diff --git a/packages/commands/src/commands/deploy/get.ts b/packages/commands/src/commands/deploy/get.ts index d2904d80..d0d3063d 100644 --- a/packages/commands/src/commands/deploy/get.ts +++ b/packages/commands/src/commands/deploy/get.ts @@ -1,17 +1,8 @@ -import { defineCommand, getDeployment, type FlagsDef } from "bailian-cli-core"; +import { defineCommand, getDeployment } from "bailian-cli-core"; import { emitResult } from "bailian-cli-runtime"; +import { DEPLOYED_MODEL_FLAG, validateQueryIds } from "./query-shared.ts"; -const GET_FLAGS = { - deployedModel: { - type: "string", - valueHint: "", - description: { - "en-US": "Deployed model identifier (required)", - "zh-CN": "已部署模型标识(必填)", - }, - required: true, - }, -} satisfies FlagsDef; +const GET_FLAGS = DEPLOYED_MODEL_FLAG; export default defineCommand({ description: { @@ -25,6 +16,15 @@ export default defineCommand({ "--deployed-model qwen-plus-2025-12-01-b6d61c71", "--deployed-model qwen-plus-2025-12-01-b6d61c71 --output json", ], + notes: [ + { + "en-US": + "Preserves deployment fields including ptu_capacity (aggregate effective kTPM), ptu_service_tier, overflow_strategy and pre_paid_info. For mixed billing or multiple instances, query capacity list/get for each instance's status, expiry and capacity; ModelCode status is not instance status.", + "zh-CN": + "保留部署字段,包括 ptu_capacity(汇总生效容量,kTPM)、ptu_service_tier、overflow_strategy 及 pre_paid_info。混合付费或多实例时,通过 capacity list/get 查询各实例状态、到期时间和容量;ModelCode 状态不等于实例状态。", + }, + ], + validate: validateQueryIds, async run(ctx) { const { settings, flags } = ctx; const deployedModel = flags.deployedModel; @@ -43,6 +43,7 @@ export default defineCommand({ } const item: Record = { + ...deployment, deployed_model: deployment.deployed_model ?? deployedModel, deployed_name: deployment.name ?? "", model_name: deployment.model_name ?? "", diff --git a/packages/commands/src/commands/deploy/list.ts b/packages/commands/src/commands/deploy/list.ts index 652683b0..b2ae39b9 100644 --- a/packages/commands/src/commands/deploy/list.ts +++ b/packages/commands/src/commands/deploy/list.ts @@ -1,5 +1,6 @@ import { defineCommand, listDeployments, type FlagsDef } from "bailian-cli-core"; import { emitResult } from "bailian-cli-runtime"; +import { validatePagination } from "./query-shared.ts"; const LIST_FLAGS = { page: { @@ -15,44 +16,82 @@ const LIST_FLAGS = { "zh-CN": "每页结果数(默认:10,最多:100)", }, }, + plan: { + type: "string", + valueHint: "", + description: { + "en-US": "Server-side plan filter; use ptu for throughput reservations", + "zh-CN": "服务端方案筛选;吞吐预留使用 ptu", + }, + }, status: { type: "string", - valueHint: "", + valueHint: "", description: { - "en-US": "Filter by status (PENDING / RUNNING / STOPPED / FAILED)", - "zh-CN": "按状态筛选(PENDING / RUNNING / STOPPED / FAILED)", + "en-US": "Filter only the fetched page locally by ModelCode status; total remains unfiltered", + "zh-CN": "仅在已获取的当前页本地筛选 ModelCode 状态;total 仍为筛选前总数", }, }, } satisfies FlagsDef; export default defineCommand({ - description: { "en-US": "List model deployments", "zh-CN": "列出模型部署" }, + description: { + "en-US": "List model deployments and throughput reservations", + "zh-CN": "列出模型部署及吞吐预留", + }, auth: "apiKey", - usageArgs: "[--page ] [--page-size ] [--status ]", + usageArgs: "[--page ] [--page-size ] [--plan ] [--status ]", flags: LIST_FLAGS, - exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"], + exampleArgs: [ + "", + "--plan ptu", + "--plan ptu --status RUNNING --page-size 100", + "--page-size 20 --output json", + ], + notes: [ + { + "en-US": + "The deployment list API supports page_no/page_size/plan, not status. --status filters only the requested page, not the entire account; an empty filtered page does not mean there are no matches on later pages. total is the server total before local filtering; local_filter reports the current-page match count. PTU capacities are kTPM.", + "zh-CN": + "部署列表 API 支持 page_no/page_size/plan,不支持 status。--status 仅筛选所请求的当前页,不代表全账号筛选;当前页为空不代表后续页没有匹配项。total 是本地筛选前的服务端总数,local_filter 给出当前页匹配数量。PTU 容量单位为 kTPM。", + }, + ], + validate(flags) { + return ( + validatePagination(flags) ?? + ([flags.plan, flags.status].some((value) => value !== undefined && !value.trim()) + ? "Filters must not be empty. / 筛选条件不能为空。" + : undefined) + ); + }, async run(ctx) { const { settings, flags } = ctx; - const status = flags.status || undefined; + const page = flags.page ?? 1; + const pageSize = flags.pageSize ?? 10; + const status = flags.status?.trim(); + const plan = flags.plan?.trim(); if (settings.dryRun) { emitResult( - { action: "deploy.list", page: flags.page, page_size: flags.pageSize, status }, + { + action: "deploy.list", + page, + page_size: pageSize, + plan, + status, + ...(status ? { filter_scope: "page" } : {}), + }, "json", ); return; } - const response = await listDeployments(ctx.client, { - pageNo: flags.page, - pageSize: flags.pageSize, - status, - }); + const response = await listDeployments(ctx.client, { pageNo: page, pageSize, plan }); const payload = response.output ?? response.data; const deployments = payload?.deployments ?? []; - const total = payload?.total; - - const items = deployments.map((item) => ({ + const filtered = status ? deployments.filter((item) => item.status === status) : deployments; + const items = filtered.map((item) => ({ + ...item, deployed_model: item.deployed_model ?? "", model_name: item.model_name ?? "", status: item.status ?? "", @@ -61,6 +100,25 @@ export default defineCommand({ created_at: item.gmt_create ?? "", })); - emitResult({ items, total, request_id: response.request_id }, "json"); + emitResult( + { + items, + total: payload?.total, + page_no: payload?.page_no ?? page, + page_size: payload?.page_size ?? pageSize, + ...(status + ? { + local_filter: { + status, + scope: "page", + matched_count: items.length, + unfiltered_page_count: deployments.length, + }, + } + : {}), + request_id: response.request_id, + }, + "json", + ); }, }); diff --git a/packages/commands/src/commands/deploy/mutation-shared.ts b/packages/commands/src/commands/deploy/mutation-shared.ts new file mode 100644 index 00000000..f5b2df73 --- /dev/null +++ b/packages/commands/src/commands/deploy/mutation-shared.ts @@ -0,0 +1,335 @@ +import { + BailianError, + ExitCode, + getCapacityInstance, + type CapacityInstance, + type CapacityOperation, + type Client, + type Deployment, + type FlagsDef, + type LocalizedText, +} from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { waitForCapacityOperation } from "./operation-wait.ts"; + +export const CAPACITY_FLAGS = { + inputTpm: { + type: "number", + valueHint: "", + description: { + "en-US": "Absolute input capacity in kTPM (1 kTPM = 1000 tokens/minute)", + "zh-CN": "输入绝对容量,单位 kTPM(1 kTPM = 1000 Tokens/分钟)", + }, + }, + outputTpm: { + type: "number", + valueHint: "", + description: { + "en-US": "Absolute output capacity in kTPM", + "zh-CN": "输出绝对容量,单位 kTPM", + }, + }, +} satisfies FlagsDef; + +export const PREPAID_FLAGS = { + duration: { + type: "number", + valueHint: "", + description: { + "en-US": "Prepaid purchase/renewal duration, positive integer days", + "zh-CN": "预付费购买或续订天数,正整数", + }, + }, + autoRenewal: { + type: "boolean", + valueHint: "", + description: { + "en-US": "Explicitly enable or disable automatic renewal for prepaid purchases", + "zh-CN": "显式开启或关闭预付费自动续费", + }, + }, + autoRenewalDuration: { + type: "number", + valueHint: "", + description: { + "en-US": "Automatic renewal duration; required when enabled", + "zh-CN": "自动续费天数,开启自动续费时必填", + }, + }, + autoRenewalCycle: { + type: "string", + valueHint: "", + description: { + "en-US": "Optional supported renewal cycle, e.g. Day", + "zh-CN": "可选续费周期单位,按服务支持的值传入,如 Day", + }, + }, +} satisfies FlagsDef; + +export const WAIT_OPTIONS = { + wait: { + type: "switch", + description: { + "en-US": "Wait for the returned capacity operation and refresh capacity", + "zh-CN": "等待返回的容量操作完成并刷新容量", + }, + }, + interval: { + type: "number", + valueHint: "", + description: { + "en-US": "Initial wait poll interval (1–3600, default: 2 seconds)", + "zh-CN": "等待初始轮询间隔(1–3600,默认:2 秒)", + }, + }, + pollTimeout: { + type: "number", + valueHint: "", + description: { + "en-US": "Wait budget after submission (default: 600 seconds)", + "zh-CN": "提交后的等待总时限(默认:600 秒)", + }, + }, +} satisfies FlagsDef; + +export const WRITE_OPTIONS = { + requestId: { + type: "string", + valueHint: "", + description: { + "en-US": + "Request identifier; generated if omitted. Reuse with identical parameters only for the same capacity operation", + "zh-CN": "请求标识,省略时生成;仅重试同一容量操作时保持标识和参数一致", + }, + }, + ...WAIT_OPTIONS, +} satisfies FlagsDef; + +export const ORDER_TYPE_FLAG = { + orderType: { + type: "string", + valueHint: "", + choices: ["UPGRADE", "DOWNGRADE"] as const, + description: { + "en-US": "Optional order direction; otherwise the server decides", + "zh-CN": "可选变配方向,省略时由服务端判定", + }, + }, +} satisfies FlagsDef; + +export const MUTATION_NOTES: LocalizedText[] = [ + { + "en-US": + "Use --dry-run before confirming with --yes. A write is submitted once, never automatically retried. HTTP 200 is not operation success: inspect operation_status or pass --wait. After interruption or timeout, query the saved ModelCode/operation ID before considering another write.", + "zh-CN": + "先用 --dry-run 预览,确认后再用 --yes。写请求只提交一次,不自动重试。HTTP 200 不等于操作成功:检查 operation_status 或使用 --wait。中断或超时后,先查询已保存的 ModelCode/操作 ID,再考虑是否重试写请求。", + }, + { + "en-US": + "Capacities are absolute kTPM for one instance, not increments or ModelCode totals. Zero capacity is not release. Model-specific steps, limits and purchase periods are validated by the server. Automatic renewal configuration is part of purchase/scale/renew, not a standalone free setting change.", + "zh-CN": + "容量是单个实例的绝对 kTPM,不是增量或 ModelCode 总量;容量归零不等于释放。模型步长、上下限及购买时长限制由服务端校验。自动续费配置属于购买、变配或续订流程,不是独立免费设置操作。", + }, +]; + +export interface MutationOptions { + requestId?: string; + wait?: boolean; + interval?: number; + pollTimeout?: number; +} + +export function validateWriteOptions(flags: MutationOptions): string | undefined { + if ( + flags.requestId !== undefined && + (!flags.requestId.trim() || /[\r\n]/.test(flags.requestId)) + ) { + return "--request-id must be non-empty and contain no line breaks. / --request-id 不能为空或包含换行。"; + } + if (!flags.wait && (flags.interval !== undefined || flags.pollTimeout !== undefined)) { + return "--interval/--poll-timeout require --wait. / --interval/--poll-timeout 需要同时指定 --wait。"; + } + if ( + flags.interval !== undefined && + (!Number.isFinite(flags.interval) || flags.interval < 1 || flags.interval > 3600) + ) { + return "--interval must be 1–3600 seconds. / --interval 必须为 1–3600 秒。"; + } + if ( + flags.pollTimeout !== undefined && + (!Number.isFinite(flags.pollTimeout) || flags.pollTimeout <= 0 || flags.pollTimeout > 2_147_483) + ) { + return "--poll-timeout must be > 0 and <= 2147483 seconds. / --poll-timeout 必须大于 0 且不超过 2147483 秒。"; + } + return undefined; +} + +export async function requireCapacityInstance( + client: Client, + code: string, + id: string, + action: "scale" | "renew" | "delete", +): Promise { + const response = await getCapacityInstance(client, code, id); + const instance = response.output ?? response.data; + if (!instance || instance.deleted === true || instance[`can_${action}`] !== true) { + throw new BailianError( + `Capacity instance does not currently allow ${action}; refresh its details. / 容量实例当前不允许 ${action},请刷新详情。`, + ExitCode.USAGE, + undefined, + { cause: response }, + ); + } + return instance; +} + +type MutationResponse = { + request_id?: string; + output?: Deployment | CapacityOperation; + data?: Deployment | CapacityOperation; +}; + +/** Preserve service errors and enough context to avoid re-submitting an uncertain write. */ +export async function submitCapacityWrite( + requestId: string, + submit: () => Promise, +): Promise { + try { + return await submit(); + } catch (error) { + const context = { + client_request_id: requestId, + error: error instanceof Error ? error.message : error, + }; + const hint = `Request ID: ${requestId}. No automatic retry; first check whether the operation was accepted. / 请求标识:${requestId}。未自动重试,请先核查操作是否已受理。`; + if (error instanceof BailianError) { + throw new BailianError( + error.message, + error.exitCode, + [error.hint, hint].filter(Boolean).join("\n"), + { + api: error.api, + rawResponse: error.rawResponse, + cause: { ...context, original_cause: error.cause }, + }, + ); + } + if (error instanceof Error) { + // Preserve the error identity/name (including AbortError/DOMException) so the + // runtime can still classify local timeouts. DOMException.message is a + // getter-only accessor, so appending would throw and mask the original error; + // guard it and rethrow the untouched error when the message is read-only. + try { + error.message += `\n${hint}`; + } catch { + // Read-only message (e.g. DOMException): keep the original error as-is. + } + } + throw error; + } +} + +export async function finishMutation( + client: Client, + code: string | undefined, + response: MutationResponse, + flags: MutationOptions, + requestId?: string, + requireOperationStatus = false, + releasedInstanceId?: string, +): Promise { + const operation = response.output ?? response.data; + const result = { + ...response, + ...(requestId ? { client_request_id: requestId } : {}), + }; + if (operation?.operation_status === "FAILED") { + const message = + typeof operation.error_message === "string" + ? operation.error_message + : typeof operation.error_code === "string" + ? operation.error_code + : "Capacity operation failed. / 容量操作失败。"; + throw new BailianError(message, ExitCode.GENERAL, undefined, { + api: { + httpStatus: 200, + apiCode: typeof operation.error_code === "string" ? operation.error_code : undefined, + requestId: response.request_id, + }, + cause: result, + rawResponse: JSON.stringify(result), + }); + } + if ( + requireOperationStatus && + !["PROCESSING", "SUCCEEDED"].includes(String(operation?.operation_status)) + ) { + throw new BailianError( + "Missing or unsupported operation_status after submission; do not repeat the write. / 提交后缺少或不支持的 operation_status,请勿重复写入。", + ExitCode.GENERAL, + undefined, + { cause: result }, + ); + } + if (!flags.wait) { + emitResult(result, "json"); + return; + } + const returnedCode = + code ?? (typeof operation?.deployed_model === "string" ? operation.deployed_model : undefined); + const operationId = operation?.operation_id; + if (!returnedCode || typeof operationId !== "string" || !operationId.trim()) { + throw new BailianError( + "Write submitted but no ModelCode/operation ID was returned for waiting; query the deployment before retrying. / 写请求已提交,但未返回等待所需的 ModelCode/操作 ID;重试前请查询部署。", + ExitCode.GENERAL, + undefined, + { cause: result, rawResponse: JSON.stringify(result) }, + ); + } + // stderr survives a later polling failure or Ctrl-C without producing two stdout JSON objects. + process.stderr.write( + `${JSON.stringify({ submitted: true, deployed_model: returnedCode, operation_id: operationId, client_request_id: requestId })}\n`, + ); + const controller = new AbortController(); + const onInterrupt = () => controller.abort(new Error("Interrupted. / 已中断。")); + process.once("SIGINT", onInterrupt); + try { + const completed = await waitForCapacityOperation(client, returnedCode, operationId, { + interval: flags.interval ?? 2, + pollTimeout: flags.pollTimeout ?? 600, + signal: controller.signal, + initialResponse: operation?.operation_status === "SUCCEEDED" ? response : undefined, + fallbackInstanceId: releasedInstanceId, + }); + const confirmed = completed.output ?? completed.data; + const envelope = completed.output ? "output" : "data"; + const finalResult = { + ...completed, + ...(confirmed + ? { + [envelope]: { + ...confirmed, + operation_id: confirmed.operation_id ?? operationId, + }, + } + : {}), + ...(requestId ? { client_request_id: requestId } : {}), + }; + if ( + releasedInstanceId && + (completed.instance?.output ?? completed.instance?.data)?.deleted !== true + ) { + throw new BailianError( + "Release operation completed but deleted=true is not confirmed; refresh the instance before deleting the ModelCode. / 释放操作已结束,但未确认 deleted=true;删除 ModelCode 前请刷新实例。", + ExitCode.GENERAL, + undefined, + { cause: finalResult }, + ); + } + emitResult(finalResult, "json"); + } catch (error) { + if (!controller.signal.aborted) throw error; + } finally { + process.removeListener("SIGINT", onInterrupt); + } +} diff --git a/packages/commands/src/commands/deploy/operation-get.ts b/packages/commands/src/commands/deploy/operation-get.ts new file mode 100644 index 00000000..eeb367d8 --- /dev/null +++ b/packages/commands/src/commands/deploy/operation-get.ts @@ -0,0 +1,41 @@ +import { defineCommand, getCapacityOperation } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { CAPACITY_OPERATION_FLAGS, validateQueryIds } from "./query-shared.ts"; + +export default defineCommand({ + description: { + "en-US": "Query a throughput reservation capacity operation once", + "zh-CN": "单次查询吞吐预留容量操作", + }, + auth: "apiKey", + flags: CAPACITY_OPERATION_FLAGS, + usageArgs: "--deployed-model --operation-id ", + exampleArgs: ["--deployed-model example-model-code --operation-id 100001"], + notes: [ + { + "en-US": + "Preserves the API envelope, including FAILED and its error fields; a successful GET returns exit code 0 regardless of operation status. Use operation wait to wait for success or fail with a non-zero exit. Use the actual returned operation ID; do not construct one.", + "zh-CN": + "保留 API 响应结构,包括 FAILED 状态及错误字段;GET 请求成功即返回退出码 0,不以操作状态判断。需要等待成功或失败时非零退出,请使用 operation wait。操作 ID 必须使用实际返回值,不要自行构造。", + }, + ], + validate: validateQueryIds, + async run(ctx) { + const { settings, flags } = ctx; + if (settings.dryRun) { + emitResult( + { + action: "deploy.operation.get", + deployed_model: flags.deployedModel, + operation_id: flags.operationId, + }, + "json", + ); + return; + } + emitResult( + await getCapacityOperation(ctx.client, flags.deployedModel, flags.operationId), + "json", + ); + }, +}); diff --git a/packages/commands/src/commands/deploy/operation-wait.ts b/packages/commands/src/commands/deploy/operation-wait.ts new file mode 100644 index 00000000..82943026 --- /dev/null +++ b/packages/commands/src/commands/deploy/operation-wait.ts @@ -0,0 +1,233 @@ +import { + BailianError, + defineCommand, + ExitCode, + getCapacityInstance, + getCapacityOperation, + getDeployment, + type Client, + type FlagsDef, + type GetCapacityOperationResponse, +} from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { CAPACITY_OPERATION_FLAGS, validateQueryIds } from "./query-shared.ts"; + +const DEFAULT_INTERVAL = 2; +const DEFAULT_POLL_TIMEOUT = 600; +const MAX_POLL_TIMEOUT = 2_147_483; + +function sleep(milliseconds: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + signal.throwIfAborted(); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** Only GETs: poll the operation, then refresh the confirmed instance and ModelCode. */ +export async function waitForCapacityOperation( + client: Client, + deployedModel: string, + operationId: string, + options: { + interval: number; + pollTimeout: number; + signal?: AbortSignal; + initialResponse?: GetCapacityOperationResponse; + fallbackInstanceId?: string; + }, +) { + const controller = new AbortController(); + let lastResponse: GetCapacityOperationResponse | undefined; + let initialResponse = options.initialResponse; + const timeoutError = () => + new BailianError( + "Capacity operation wait timed out; query the same operation ID before retrying any write. / 等待容量操作超时;重试写操作前请查询同一操作 ID。", + ExitCode.TIMEOUT, + undefined, + { + cause: { + deployed_model: deployedModel, + operation_id: operationId, + last_response: lastResponse, + }, + }, + ); + const onAbort = () => controller.abort(options.signal?.reason); + options.signal?.addEventListener("abort", onAbort, { once: true }); + if (options.signal?.aborted) onAbort(); + const deadline = Date.now() + options.pollTimeout * 1000; + const timer = setTimeout(() => controller.abort(timeoutError()), options.pollTimeout * 1000); + const checkDeadline = () => { + if (Date.now() >= deadline && !controller.signal.aborted) controller.abort(timeoutError()); + controller.signal.throwIfAborted(); + }; + let intervalMs = options.interval * 1000; + const maxIntervalMs = Math.max(intervalMs, 30_000); + try { + while (true) { + checkDeadline(); + const response = + initialResponse ?? + (await getCapacityOperation(client, deployedModel, operationId, controller.signal)); + initialResponse = undefined; + lastResponse = response; + checkDeadline(); + const operation = response.output ?? response.data; + if (operation?.operation_status === "FAILED") { + throw new BailianError( + operation.error_message ?? + operation.error_code ?? + "Capacity operation failed. / 容量操作失败。", + ExitCode.GENERAL, + undefined, + { + api: { + httpStatus: 200, + apiCode: operation.error_code, + requestId: response.request_id, + }, + cause: operation, + rawResponse: JSON.stringify(response), + }, + ); + } + if (operation?.operation_status === "SUCCEEDED") { + const instanceId = operation.instance_id ?? options.fallbackInstanceId; + const instance = instanceId + ? await getCapacityInstance(client, deployedModel, instanceId, controller.signal) + : undefined; + checkDeadline(); + const deployment = await getDeployment(client, deployedModel, controller.signal); + checkDeadline(); + return { ...response, instance, deployment }; + } + if (operation?.operation_status !== "PROCESSING") { + throw new BailianError( + "Missing or unsupported operation_status; cannot determine completion. / 缺少或不支持的 operation_status,无法判断完成状态。", + ExitCode.GENERAL, + undefined, + { cause: response, rawResponse: JSON.stringify(response) }, + ); + } + await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())), controller.signal); + intervalMs = Math.min(intervalMs * 2, maxIntervalMs); + } + } catch (error) { + if (controller.signal.aborted) throw controller.signal.reason; + throw error; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + } +} + +const FLAGS = { + ...CAPACITY_OPERATION_FLAGS, + interval: { + type: "number", + valueHint: "", + description: { + "en-US": "Initial poll interval (1–3600 seconds, default: 2); doubles up to max(initial, 30)", + "zh-CN": "初始轮询间隔(1–3600 秒,默认:2);逐次翻倍至 max(初始值, 30)", + }, + }, + pollTimeout: { + type: "number", + valueHint: "", + description: { + "en-US": "Total wait budget including requests and refresh (default: 600 seconds)", + "zh-CN": "总等待时限,包含请求及刷新(默认:600 秒)", + }, + }, +} satisfies FlagsDef; + +export default defineCommand({ + description: { + "en-US": "Wait for a capacity operation and refresh confirmed capacity", + "zh-CN": "等待容量操作完成并刷新已确认容量", + }, + auth: "apiKey", + flags: FLAGS, + usageArgs: + "--deployed-model --operation-id [--interval ] [--poll-timeout ]", + exampleArgs: [ + "--deployed-model example-model-code --operation-id 100001", + "--deployed-model example-model-code --operation-id 100001 --interval 2 --poll-timeout 600", + ], + notes: [ + { + "en-US": + "Read-only GET polling: PROCESSING continues, FAILED exits 1 with the server error, SUCCEEDED refreshes the instance (when an ID is returned) and deployment. Output preserves the operation envelope and adds instance/deployment response envelopes. Missing or unknown status fails rather than assuming success.", + "zh-CN": + "只读 GET 轮询:PROCESSING 继续等待,FAILED 透传服务端错误并退出 1,SUCCEEDED 刷新实例(返回 ID 时)及部署。输出保留操作响应结构,追加 instance/deployment 响应结构。缺失或未知状态报错,不推定成功。", + }, + { + "en-US": + "--timeout limits each HTTP request; --poll-timeout bounds the entire wait (exit 5 on expiry). Ctrl-C stops local waiting, not the remote operation. No write is retried; use the original operation ID after a timeout or interruption.", + "zh-CN": + "--timeout 限制单次 HTTP 请求,--poll-timeout 限制整个等待过程(超时退出 5)。Ctrl-C 只停止本地等待,不取消远端操作。不重试任何写请求;超时或中断后仍使用原操作 ID 查询。", + }, + ], + validate(flags) { + const error = validateQueryIds(flags); + if (error) return error; + if ( + flags.interval !== undefined && + (!Number.isFinite(flags.interval) || flags.interval < 1 || flags.interval > 3600) + ) { + return "--interval must be 1–3600 seconds. / --interval 必须为 1–3600 秒。"; + } + if ( + flags.pollTimeout !== undefined && + (!Number.isFinite(flags.pollTimeout) || + flags.pollTimeout <= 0 || + flags.pollTimeout > MAX_POLL_TIMEOUT) + ) { + return `--poll-timeout must be > 0 and <= ${MAX_POLL_TIMEOUT} seconds. / --poll-timeout 必须大于 0 且不超过 ${MAX_POLL_TIMEOUT} 秒。`; + } + return undefined; + }, + async run(ctx) { + const { flags, settings } = ctx; + const interval = flags.interval ?? DEFAULT_INTERVAL; + const pollTimeout = flags.pollTimeout ?? DEFAULT_POLL_TIMEOUT; + if (settings.dryRun) { + emitResult( + { + action: "deploy.operation.wait", + deployed_model: flags.deployedModel, + operation_id: flags.operationId, + interval, + poll_timeout: pollTimeout, + }, + "json", + ); + return; + } + const controller = new AbortController(); + const onInterrupt = () => controller.abort(new Error("Interrupted. / 已中断。")); + process.once("SIGINT", onInterrupt); + try { + const result = await waitForCapacityOperation( + ctx.client, + flags.deployedModel, + flags.operationId, + { interval, pollTimeout, signal: controller.signal }, + ); + emitResult(result, "json"); + } catch (error) { + // The CLI runtime owns SIGINT's exit status (130). Do not emit success on interruption. + if (!controller.signal.aborted) throw error; + } finally { + process.removeListener("SIGINT", onInterrupt); + } + }, +}); diff --git a/packages/commands/src/commands/deploy/overflow.ts b/packages/commands/src/commands/deploy/overflow.ts new file mode 100644 index 00000000..69980925 --- /dev/null +++ b/packages/commands/src/commands/deploy/overflow.ts @@ -0,0 +1,81 @@ +import { + BailianError, + defineCommand, + ExitCode, + getDeployment, + updateDeploymentOverflow, +} from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { DEPLOYED_MODEL_FLAG, validateQueryIds } from "./query-shared.ts"; + +export default defineCommand({ + description: { + "en-US": "Set the overflow strategy for a throughput reservation ModelCode", + "zh-CN": "设置吞吐预留 ModelCode 的溢出策略", + }, + auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": + "Enabling overflow incurs pay-as-you-go charges beyond reserved capacity; disabling it rate-limits excess requests.", + "zh-CN": "开启溢出后,预留容量外的流量将产生按量费用;关闭后,超额请求会被限流。", + }, + }, + flags: { + ...DEPLOYED_MODEL_FLAG, + strategy: { + type: "string", + valueHint: "", + required: true, + choices: ["enable", "disable"] as const, + description: { + "en-US": "Allow pay-as-you-go overflow or restrict requests to reserved capacity", + "zh-CN": "允许按量溢出或将请求限制在预留容量内", + }, + }, + }, + usageArgs: "--deployed-model --strategy ", + exampleArgs: [ + "--deployed-model example-code --strategy enable --dry-run", + "--deployed-model example-code --strategy disable --dry-run", + ], + notes: [ + { + "en-US": + "Applies to the whole ModelCode, not one capacity instance. Sends one update then one GET to confirm the strategy. Never retries the update automatically; a read-back failure does not mean the change was not applied.", + "zh-CN": + "配置作用于整个 ModelCode,而非单个容量实例。提交一次更新后 GET 回读确认策略。不自动重试更新;回读失败不代表配置未生效。", + }, + ], + validate: validateQueryIds, + async run(ctx) { + const { settings, flags } = ctx; + if (settings.dryRun) { + emitResult( + { + action: "deploy.overflow", + deployed_model: flags.deployedModel, + body: { overflow_strategy: flags.strategy }, + }, + "json", + ); + return; + } + const response = await updateDeploymentOverflow( + ctx.client, + flags.deployedModel, + flags.strategy, + ); + const deployment = await getDeployment(ctx.client, flags.deployedModel); + if ((deployment.output ?? deployment.data)?.overflow_strategy !== flags.strategy) { + throw new BailianError( + "Overflow update submitted but not confirmed by read-back; query the deployment before retrying. / 溢出配置已提交,但回读未确认,请先查询部署再决定是否重试。", + ExitCode.GENERAL, + undefined, + { cause: { response, deployment } }, + ); + } + emitResult({ ...response, deployment }, "json"); + }, +}); diff --git a/packages/commands/src/commands/deploy/query-shared.ts b/packages/commands/src/commands/deploy/query-shared.ts new file mode 100644 index 00000000..52020480 --- /dev/null +++ b/packages/commands/src/commands/deploy/query-shared.ts @@ -0,0 +1,75 @@ +import type { FlagsDef } from "bailian-cli-core"; + +export const DEPLOYED_MODEL_FLAG = { + deployedModel: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Deployed model identifier (ModelCode)", + "zh-CN": "部署调用标识(ModelCode)", + }, + }, +} satisfies FlagsDef; + +export const CAPACITY_INSTANCE_FLAG = { + instanceId: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Capacity instance ID returned by the API", + "zh-CN": "接口返回的容量实例 ID", + }, + }, +} satisfies FlagsDef; + +export const CAPACITY_OPERATION_FLAGS = { + ...DEPLOYED_MODEL_FLAG, + operationId: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Capacity operation ID returned by a write request", + "zh-CN": "写请求返回的容量操作 ID", + }, + }, +} satisfies FlagsDef; + +export function validateQueryIds(flags: { + deployedModel?: string; + instanceId?: string; + operationId?: string; +}): string | undefined { + for (const [name, value] of Object.entries(flags)) { + if ( + ["deployedModel", "instanceId", "operationId"].includes(name) && + typeof value === "string" && + !value.trim() + ) { + return "Identifiers must not be empty. / 标识不能为空。"; + } + } + return undefined; +} + +export function validatePagination(flags: { + page?: number; + pageSize?: number; +}): string | undefined { + if (flags.page !== undefined && (!Number.isSafeInteger(flags.page) || flags.page < 1)) { + return "--page must be a positive integer. / --page 必须是正整数。"; + } + if ( + flags.pageSize !== undefined && + (!Number.isSafeInteger(flags.pageSize) || flags.pageSize < 1 || flags.pageSize > 100) + ) { + return "--page-size must be an integer from 1 to 100. / --page-size 必须是 1–100 的整数。"; + } + return undefined; +} + +export function queryValues(value: string | undefined): string[] | undefined { + return value === undefined ? undefined : value.split(",").map((entry) => entry.trim()); +} diff --git a/packages/commands/src/commands/deploy/scale.ts b/packages/commands/src/commands/deploy/scale.ts index c37c38eb..cecc55a8 100644 --- a/packages/commands/src/commands/deploy/scale.ts +++ b/packages/commands/src/commands/deploy/scale.ts @@ -1,5 +1,32 @@ -import { defineCommand, scaleDeployment, type FlagsDef } from "bailian-cli-core"; +import { randomUUID } from "node:crypto"; +import { + defineCommand, + scaleDeployment, + listCapacityInstances, + validateReservationCapacity, + buildReservationCapacity, + hasPrepaidFlags, + validatePrepaidFlags, + buildPrepaidInfo, + BailianError, + ExitCode, + type Client, + type FlagsDef, + type ScaleDeploymentRequest, +} from "bailian-cli-core"; import { emitResult, emitBare } from "bailian-cli-runtime"; +import { + CAPACITY_FLAGS, + PREPAID_FLAGS, + WRITE_OPTIONS, + ORDER_TYPE_FLAG, + MUTATION_NOTES, + validateWriteOptions, + requireCapacityInstance, + finishMutation, + submitCapacityWrite, +} from "./mutation-shared.ts"; +import { validateQueryIds } from "./query-shared.ts"; const SCALE_FLAGS = { deployedModel: { @@ -15,63 +42,172 @@ const SCALE_FLAGS = { type: "number", valueHint: "", description: { - "en-US": "New capacity in plan units (must be a multiple of base_capacity)", - "zh-CN": "以方案单元表示的新容量(必须是 base_capacity 的整数倍)", + "en-US": "MU target capacity: non-negative integer in plan units; must satisfy base_capacity", + "zh-CN": "MU 目标容量:非负整数,单位为方案单元;须满足 base_capacity 约束", }, }, - inputTpm: { - type: "number", - valueHint: "", - description: { - "en-US": "PTU only — input tokens per minute", - "zh-CN": "仅 PTU:每分钟输入 Token 数", - }, - }, - outputTpm: { - type: "number", - valueHint: "", + ...CAPACITY_FLAGS, + instanceId: { + type: "string", + valueHint: "", description: { - "en-US": "PTU only — output tokens per minute", - "zh-CN": "仅 PTU:每分钟输出 Token 数", + "en-US": "PTU capacity instance ID; required when multiple unreleased instances exist", + "zh-CN": "PTU 容量实例 ID;存在多个未释放实例时必填", }, }, + ...PREPAID_FLAGS, + ...WRITE_OPTIONS, + ...ORDER_TYPE_FLAG, } satisfies FlagsDef; -/** - * `bl deploy scale` — adjust capacity (and optional PTU input/output token rates). - * - * Server-side capacity constraint: positive integer, < 1000, must be an - * integer multiple of `base_capacity` (visible via `bl deploy get`). - */ +/** Resolve only a provably unique instance; never choose the first of several. */ +async function resolveInstanceId(client: Client, deployedModel: string): Promise { + const response = await listCapacityInstances(client, deployedModel, { + includeDeleted: false, + pageNo: 1, + pageSize: 2, + }); + const page = response.output ?? response.data; + const records = page?.records; + if ( + !Array.isArray(records) || + records.length !== 1 || + page?.items !== 1 || + (page.page !== undefined && page.page !== 1) || + (page.pageCount !== undefined && page.pageCount !== 1) || + records[0]?.deleted === true || + typeof records[0]?.instance_id !== "string" || + !records[0].instance_id.trim() + ) { + throw new BailianError( + "Cannot confirm exactly one unreleased capacity instance. Specify --instance-id; multiple instances must never be selected implicitly. / 无法确认恰好一个未释放容量实例,请指定 --instance-id;多个实例不能自动选择。", + ExitCode.USAGE, + ); + } + return records[0].instance_id; +} + export default defineCommand({ - description: { "en-US": "Scale a deployment's capacity", "zh-CN": "调整部署容量" }, + description: { + "en-US": "Scale a deployment's capacity", + "zh-CN": "调整部署容量", + }, auth: "apiKey", - usageArgs: "--deployed-model --capacity [--input-tpm ] [--output-tpm ]", + risk: { + level: "high", + message: { + "en-US": + "Scaling changes purchased capacity and may incur charges or reduce serving capacity.", + "zh-CN": "扩缩容会变更购买的容量,可能产生费用或降低服务容量。", + }, + }, + usageArgs: + "--deployed-model (--capacity | --input-tpm --output-tpm ) [--instance-id ] [flags]", flags: SCALE_FLAGS, exampleArgs: [ - "--deployed-model qwen-plus-...-b6d61c71 --capacity 8", - "--deployed-model dep-... --capacity 2", + "--deployed-model dep-... --capacity 8 --dry-run", + "--deployed-model dep-... --instance-id instance-... --input-tpm 20000 --output-tpm 2000 --order-type UPGRADE --dry-run", + ], + notes: [ + ...MUTATION_NOTES, + { + "en-US": + "PTU input/output values are the selected instance's absolute target capacity in kTPM (1 kTPM = 1000 tokens/minute), not deltas or deployment totals. Both zero is valid and does not release the instance. PTU options cannot be combined with --capacity.", + "zh-CN": + "PTU 输入/输出值为选中实例的绝对目标容量,单位 kTPM(1 kTPM = 1000 Token/分钟),不是增量或部署总容量。允许同时为零,但不等于释放实例。PTU 参数不能与 --capacity 混用。", + }, + { + "en-US": + "Without --instance-id, execution must confirm exactly one unreleased instance and recheck can_scale. Dry-run does not resolve an instance or validate its billing type. Pass --yes only after confirming the costs and target.", + "zh-CN": + "省略 --instance-id 时,执行前必须确认恰好一个未释放实例并复查 can_scale。dry-run 不解析实例或验证其付费方式;仅在确认费用及目标后传入 --yes 执行。", + }, ], - validate: (flags) => - flags.capacity === undefined && flags.inputTpm === undefined && flags.outputTpm === undefined - ? "Provide at least one of --capacity / --input-tpm / --output-tpm." - : undefined, + validate(flags) { + const optionError = validateQueryIds(flags) ?? validateWriteOptions(flags); + if (optionError) return optionError; + if (flags.capacity !== undefined) { + if (!Number.isSafeInteger(flags.capacity) || flags.capacity < 0) { + return "--capacity must be a non-negative safe integer. / --capacity 必须是非负安全整数。"; + } + if ( + flags.inputTpm !== undefined || + flags.outputTpm !== undefined || + flags.instanceId !== undefined || + flags.orderType !== undefined || + hasPrepaidFlags(flags) || + flags.wait || + flags.requestId !== undefined || + flags.interval !== undefined || + flags.pollTimeout !== undefined + ) { + return "PTU options cannot be used with --capacity. / PTU 参数不能与 --capacity 混用。"; + } + return undefined; + } + return validateReservationCapacity(flags, true) ?? validatePrepaidFlags(flags, false); + }, async run(ctx) { const { settings, flags } = ctx; const deployedModel = flags.deployedModel; - - const body: Record = {}; - if (flags.capacity !== undefined) body.capacity = flags.capacity; - if (flags.inputTpm !== undefined) body.input_tpm = flags.inputTpm; - if (flags.outputTpm !== undefined) body.output_tpm = flags.outputTpm; + const isPtu = flags.capacity === undefined; + const body: ScaleDeploymentRequest = isPtu + ? { + ptu_capacity: buildReservationCapacity(flags), + ...(flags.instanceId !== undefined ? { instance_id: flags.instanceId } : {}), + ...(flags.orderType !== undefined ? { order_type: flags.orderType } : {}), + ...(hasPrepaidFlags(flags) ? { pre_paid_info: buildPrepaidInfo(flags) } : {}), + } + : { capacity: flags.capacity }; if (settings.dryRun) { - emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, "json"); + emitResult( + { + action: "deploy.scale", + deployed_model: deployedModel, + body, + ...(isPtu && flags.instanceId === undefined + ? { + instance_resolution: + "Execution must resolve exactly one unreleased instance or require --instance-id. / 执行时须确认唯一未释放实例,否则必须指定 --instance-id。", + } + : {}), + }, + "json", + ); return; } - const response = await scaleDeployment(ctx.client, deployedModel, body); + if (isPtu) { + const instanceId = flags.instanceId ?? (await resolveInstanceId(ctx.client, deployedModel)); + const instance = await requireCapacityInstance( + ctx.client, + deployedModel, + instanceId, + "scale", + ); + if (!["pre_paid", "post_paid"].includes(instance.charge_type ?? "")) { + throw new BailianError( + "Unknown instance billing type; cannot safely scale. / 实例付费类型不明确,无法安全变配。", + ExitCode.USAGE, + ); + } + if (instance.charge_type === "post_paid" && hasPrepaidFlags(flags)) { + throw new BailianError( + "Postpaid instances do not accept prepaid options. / 后付费实例不接受预付费参数。", + ExitCode.USAGE, + ); + } + body.instance_id = instanceId; + const requestId = flags.requestId ?? randomUUID(); + const response = await submitCapacityWrite(requestId, () => + scaleDeployment(ctx.client, deployedModel, body, undefined, requestId), + ); + await finishMutation(ctx.client, deployedModel, response, flags, requestId); + return; + } + const response = await scaleDeployment(ctx.client, deployedModel, body); if (settings.quiet) { emitBare(deployedModel); } else { diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index e977b6e5..9feb29d2 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -164,6 +164,18 @@ export { } from "./commands/deploy/create.ts"; export { default as deployList } from "./commands/deploy/list.ts"; export { default as deployGet } from "./commands/deploy/get.ts"; +export { default as deployCapacityList } from "./commands/deploy/capacity-list.ts"; +export { default as deployCapacityGet } from "./commands/deploy/capacity-get.ts"; +export { + deployCapacityCreate, + deployCapacityScale, + deployCapacityRenew, + deployCapacityDelete, +} from "./commands/deploy/capacity-write.ts"; +export { default as deployCapacityUnsubscribe } from "./commands/deploy/capacity-unsubscribe.ts"; +export { default as deployOverflow } from "./commands/deploy/overflow.ts"; +export { default as deployOperationGet } from "./commands/deploy/operation-get.ts"; +export { default as deployOperationWait } from "./commands/deploy/operation-wait.ts"; 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"; diff --git a/packages/commands/tests/deploy-capacity-write.test.ts b/packages/commands/tests/deploy-capacity-write.test.ts new file mode 100644 index 00000000..22362d95 --- /dev/null +++ b/packages/commands/tests/deploy-capacity-write.test.ts @@ -0,0 +1,1013 @@ +import { + BailianError, + ExitCode, + type AnyCommand, + type Client, + type Settings, +} from "bailian-cli-core"; +import { afterEach, beforeEach, describe, expect, test, vi, type Mock } from "vite-plus/test"; +import { + deployCapacityCreate, + deployCapacityScale, + deployCapacityRenew, + deployCapacityDelete, +} from "../src/commands/deploy/capacity-write.ts"; +import deployOverflow from "../src/commands/deploy/overflow.ts"; + +type CommandFlags = Parameters>[0]; +const SETTINGS: Settings = { + output: "json", + outputExplicit: true, + timeout: 30, + watermark: true, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, +}; +const MODEL_CODE = "model/test"; +const INSTANCE_ID = "instance/test"; +const OPERATION_ID = "000900719925474099312345/operation"; +const DEPLOYMENT_PATH = "/api/v1/deployments/model%2Ftest"; +const INSTANCES_PATH = `${DEPLOYMENT_PATH}/capacity-instances`; +const INSTANCE_PATH = `${INSTANCES_PATH}/instance%2Ftest`; +const OPERATION_PATH = `${DEPLOYMENT_PATH}/capacity-operations/000900719925474099312345%2Foperation`; +const REQUEST_ID = "fc9c0b75-7327-4a86-88e1-a3b7d093da89"; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const IDS = { deployedModel: MODEL_CODE, instanceId: INSTANCE_ID }; +const PREPAID = { duration: 31, autoRenewal: false }; +const INSTANCE = { + instance_id: INSTANCE_ID, + charge_type: "post_paid", + status: "RUNNING", + deleted: false, + can_scale: true, + can_renew: true, + can_delete: true, + configured_capacity: { input_tpm: 100, output_tpm: 10 }, +}; +const OPERATION = { + operation_id: OPERATION_ID, + operation_status: "PROCESSING", + instance_id: INSTANCE_ID, +}; +interface MutationCase { + name: string; + command: AnyCommand; + flags: CommandFlags; + method: "POST" | "PUT" | "DELETE"; + path: string; + body?: object; + precheck?: string; + instance: typeof INSTANCE; +} +const MUTATIONS: MutationCase[] = [ + { + name: "create", + command: deployCapacityCreate, + flags: { deployedModel: MODEL_CODE, billingMethod: "POST_PAY", inputTpm: 0, outputTpm: 0 }, + method: "POST", + path: INSTANCES_PATH, + body: { billing_method: "POST_PAY", ptu_capacity: { input_tpm: 0, output_tpm: 0 } }, + instance: INSTANCE, + }, + { + name: "scale", + command: deployCapacityScale, + flags: { ...IDS, inputTpm: 0, outputTpm: 0 }, + method: "PUT", + path: `${INSTANCE_PATH}/scale`, + body: { ptu_capacity: { input_tpm: 0, output_tpm: 0 } }, + precheck: "can_scale", + instance: INSTANCE, + }, + { + name: "renew", + command: deployCapacityRenew, + flags: { ...IDS, ...PREPAID }, + method: "PUT", + path: `${INSTANCE_PATH}/renew`, + body: { pre_paid_info: { duration: 31, auto_renewal: false }, is_change: false }, + precheck: "can_renew/pre_paid/configured_capacity", + instance: { ...INSTANCE, charge_type: "pre_paid" }, + }, + { + name: "delete", + command: deployCapacityDelete, + flags: IDS, + method: "DELETE", + path: INSTANCE_PATH, + precheck: "can_delete/prepaid_unsubscribe", + instance: INSTANCE, + }, +]; +const ALL_COMMANDS = [ + ...MUTATIONS, + { + name: "overflow", + command: deployOverflow, + flags: { deployedModel: MODEL_CODE, strategy: "enable" }, + }, +]; +let stdout = ""; +let stderr = ""; +let interruptListeners: ReturnType; +const mocks: ReturnType[] = []; + +function mockClient(...responses: unknown[]): { + client: Client; + requestJson: Mock; + request: Mock; +} { + const requestJson = vi + .fn() + .mockRejectedValue(new Error("Unexpected request")); + for (const response of responses) { + if (response instanceof Error) requestJson.mockRejectedValueOnce(response); + else requestJson.mockResolvedValueOnce(response); + } + const request = vi.fn().mockRejectedValue(new Error("Unexpected raw request")); + const mock = { client: { requestJson, request } as unknown as Client, requestJson, request }; + mocks.push(mock); + return mock; +} + +function mutationClient(mutation: MutationCase, response: unknown, envelope = "output") { + return mockClient(...(mutation.precheck ? [{ [envelope]: mutation.instance }] : []), response); +} + +async function run( + command: AnyCommand, + flags: CommandFlags, + client: Client, + settings: Partial = {}, +) { + const validation = await command.validate?.(flags); + if (validation) throw new BailianError(validation, ExitCode.USAGE); + // Only settings/flags/client are exercised. No auth/config store or runtime startup is invoked. + await command.run({ + identity: { + binName: "test-cli", + version: "test", + npmPackage: "test-cli", + clientName: "test-cli", + }, + settings: { ...SETTINGS, ...settings }, + flags, + client, + } as Parameters[0]); +} + +function requests(mock: ReturnType) { + return mock.requestJson.mock.calls.map(([request]) => request); +} + +function writes(mock: ReturnType) { + return requests(mock).filter(({ method }) => method !== "GET"); +} + +function expectSingleSubmission(mock: ReturnType, mutation: MutationCase) { + expect(writes(mock)).toHaveLength(1); + expect(writes(mock)[0]).toMatchObject({ method: mutation.method, path: mutation.path }); + const afterWrite = requests(mock).slice(mutation.precheck ? 2 : 1); + expect(afterWrite.every(({ method }) => method === "GET")).toBe(true); + for (const request of afterWrite) expect(request).not.toHaveProperty("body"); +} + +/** Route by HTTP method/path, not by an implementation-specific number of polls. */ +function waitingClient( + mutation: MutationCase, + options: { + initialStatus?: string; + pollStatus?: string; + pollFailure?: Error; + deleted?: boolean; + omitPollId?: boolean; + } = {}, +) { + const mock = mockClient(); + let submitted = false; + const instance = { output: { ...mutation.instance, deleted: options.deleted ?? true } }; + const deployment = { data: { deployed_model: MODEL_CODE, future_flag: false, capacity: 0 } }; + const initial = { + request_id: "submit-request", + output: { ...OPERATION, operation_status: options.initialStatus ?? "PROCESSING" }, + }; + const completed = { + request_id: "poll-request", + output: { + ...OPERATION, + operation_id: options.omitPollId ? undefined : OPERATION_ID, + operation_status: options.pollStatus ?? "SUCCEEDED", + }, + }; + mock.requestJson.mockImplementation(async (request) => { + if (request.method === mutation.method && request.path === mutation.path) { + if (submitted) throw new Error("Write must not be retried"); + submitted = true; + return initial; + } + if (request.method !== "GET") throw new Error(`Unexpected write: ${request.path}`); + if (request.path === INSTANCE_PATH) { + return submitted ? instance : { output: mutation.instance }; + } + if (request.path === OPERATION_PATH) { + if (options.initialStatus === "SUCCEEDED") { + throw new Error("Already-terminal submission must not be polled again"); + } + if (options.pollFailure) throw options.pollFailure; + return completed; + } + if (request.path === DEPLOYMENT_PATH) return deployment; + throw new Error(`Unexpected read: ${request.path}`); + }); + return { mock, initial, completed, instance, deployment }; +} + +beforeEach(() => { + vi.useFakeTimers(); + stdout = ""; + stderr = ""; + mocks.length = 0; + interruptListeners = process.listeners("SIGINT"); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Real network is forbidden"))); +}); + +afterEach(() => { + try { + expect(fetch).not.toHaveBeenCalled(); + for (const mock of mocks) expect(mock.request).not.toHaveBeenCalled(); + expect(process.listeners("SIGINT")).toEqual(interruptListeners); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + } +}); + +describe("capacity mutation metadata and offline previews", () => { + test.each(ALL_COMMANDS)("$name is high risk, bilingual, and owns no yes flag", ({ command }) => { + expect(command.auth).toBe("apiKey"); + expect(command.risk).toMatchObject({ + level: "high", + message: { "en-US": expect.any(String), "zh-CN": expect.any(String) }, + }); + expect(command.flags).not.toHaveProperty("yes"); + }); + + test("parser metadata constrains billing/strategy and renewal has no order-type", () => { + expect(deployCapacityCreate.flags?.billingMethod).toMatchObject({ + required: true, + choices: ["PRE_PAY", "POST_PAY"], + }); + expect(deployOverflow.flags?.strategy).toMatchObject({ + required: true, + choices: ["enable", "disable"], + }); + expect(deployCapacityRenew.flags).not.toHaveProperty("orderType"); + expect(deployCapacityScale.flags?.orderType.choices).toEqual(["UPGRADE", "DOWNGRADE"]); + }); + + test.each(MUTATIONS)( + "$name dry-run does not execute even its can_x precheck or wait", + async (mutation) => { + const mock = mockClient(); + await run(mutation.command, { ...mutation.flags, wait: true }, mock.client, { dryRun: true }); + expect(mock.requestJson).not.toHaveBeenCalled(); + expect(stderr).toBe(""); + const result = JSON.parse(stdout); + expect(result).toMatchObject({ + action: `deploy.capacity.${mutation.name}`, + deployed_model: MODEL_CODE, + client_request_id: "", + wait: true, + }); + if (mutation.precheck) expect(result.precheck).toBe(mutation.precheck); + if (mutation.body) expect(result.body).toEqual(mutation.body); + else expect(result.query).toEqual({}); + }, + ); + + test("prepaid create dry-run keeps duration and explicit false without resolving eligibility", async () => { + const mock = mockClient(); + await run( + deployCapacityCreate, + { + deployedModel: MODEL_CODE, + billingMethod: "PRE_PAY", + inputTpm: 0, + outputTpm: 7, + ...PREPAID, + requestId: REQUEST_ID, + }, + mock.client, + { dryRun: true }, + ); + expect(mock.requestJson).not.toHaveBeenCalled(); + expect(JSON.parse(stdout)).toMatchObject({ + client_request_id: REQUEST_ID, + body: { + billing_method: "PRE_PAY", + ptu_capacity: { input_tpm: 0, output_tpm: 7 }, + pre_paid_info: { duration: 31, auto_renewal: false }, + }, + }); + }); +}); + +describe.each(MUTATIONS)("$name capacity write contract", (mutation) => { + test.each(["output", "data"])( + "preserves the %s server envelope, zero/false, URL and paired headers", + async (envelope) => { + const response = { + request_id: "server-request", + future_envelope: false, + [envelope]: { + ...OPERATION, + future_field: { count: 0, enabled: false }, + effective_capacity: { input_tpm: 0 }, + deleted: false, + }, + }; + const mock = mutationClient(mutation, response, envelope); + await run(mutation.command, { ...mutation.flags, requestId: REQUEST_ID }, mock.client); + expect(requests(mock).map(({ method, path }) => ({ method, path }))).toEqual([ + ...(mutation.precheck ? [{ method: "GET", path: INSTANCE_PATH }] : []), + { method: mutation.method, path: mutation.path }, + ]); + const write = writes(mock)[0]; + expect(write.headers).toEqual({ + "x-acs-req-uuid": REQUEST_ID, + "X-DashScope-RequestId": REQUEST_ID, + }); + if (mutation.body) { + expect(write.body).toEqual(mutation.body); + expect(write.body).not.toHaveProperty("request_id"); + } else expect(write).not.toHaveProperty("body"); + expect(JSON.parse(stdout)).toEqual({ ...response, client_request_id: REQUEST_ID }); + }, + ); + + test("generated request ID is visible and matches both headers", async () => { + const mock = mutationClient(mutation, { output: OPERATION }); + await run(mutation.command, mutation.flags, mock.client); + const requestId = JSON.parse(stdout).client_request_id; + expect(requestId).toMatch(UUID); + expect(writes(mock)[0].headers).toEqual({ + "x-acs-req-uuid": requestId, + "X-DashScope-RequestId": requestId, + }); + expectSingleSubmission(mock, mutation); + }); + + test.each(["output", "data"])( + "HTTP 200 FAILED in %s throws the original message and retains metadata", + async (envelope) => { + const operation = { + ...OPERATION, + operation_status: "FAILED", + error_code: "Vendor.Capacity.Rejected", + error_message: "原始服务错误\nDo not rewrite", + future_flag: false, + }; + const response = { request_id: "server-failure", [envelope]: operation }; + const mock = mutationClient(mutation, response, envelope); + const result = run( + mutation.command, + { ...mutation.flags, requestId: REQUEST_ID, wait: true }, + mock.client, + ); + await expect(result).rejects.toMatchObject({ + message: operation.error_message, + exitCode: ExitCode.GENERAL, + api: { httpStatus: 200, apiCode: operation.error_code, requestId: "server-failure" }, + cause: { ...response, client_request_id: REQUEST_ID }, + }); + const failure = await result.catch((error: BailianError) => error); + expect(failure).toBeInstanceOf(BailianError); + if (failure instanceof BailianError) { + expect(JSON.parse(failure.rawResponse!)).toEqual({ + ...response, + client_request_id: REQUEST_ID, + }); + } + expect(stdout).toBe(""); + expectSingleSubmission(mock, mutation); + expect(requests(mock).at(-1)?.method).toBe(mutation.method); + }, + ); + + test.each(["FUTURE_STATUS", undefined])( + "status=%s after submission is not mistaken for success or retried", + async (operationStatus) => { + const mock = mutationClient(mutation, { + output: { ...OPERATION, operation_status: operationStatus }, + }); + await expect(run(mutation.command, mutation.flags, mock.client)).rejects.toMatchObject({ + exitCode: ExitCode.GENERAL, + message: expect.stringMatching(/submitted|submission|提交/i), + cause: { client_request_id: expect.stringMatching(UUID) }, + }); + expectSingleSubmission(mock, mutation); + expect(stdout).toBe(""); + }, + ); + + test.each([ExitCode.GENERAL, ExitCode.TIMEOUT, ExitCode.NETWORK])( + "transport/service error code=%s keeps message/metadata and generated ID recovery context", + async (exitCode) => { + const cause = { original: false }; + const failure = new BailianError("Original write failure", exitCode, "Original hint", { + api: { httpStatus: 503, apiCode: "Server.Original", requestId: "server-request" }, + rawResponse: "original raw response", + cause, + }); + const mock = mutationClient(mutation, failure); + const result = run(mutation.command, mutation.flags, mock.client); + await expect(result).rejects.toMatchObject({ + message: "Original write failure", + exitCode, + api: failure.api, + rawResponse: failure.rawResponse, + hint: expect.stringContaining("Original hint"), + }); + const requestId = writes(mock)[0].headers?.["x-acs-req-uuid"]; + expect(requestId).toMatch(UUID); + await expect(result).rejects.toMatchObject({ + hint: expect.stringContaining(requestId!), + cause: { client_request_id: requestId, original_cause: cause }, + }); + await vi.advanceTimersByTimeAsync(600_000); + expectSingleSubmission(mock, mutation); + expect(requests(mock).at(-1)?.method).toBe(mutation.method); + expect(stdout).toBe(""); + }, + ); + + test("read-only AbortError (DOMException) is rethrown untouched, never masked by a TypeError", async () => { + const abort = new DOMException("The operation was aborted.", "AbortError"); + const mock = mutationClient(mutation, abort); + // Appending to DOMException.message throws; the helper must swallow that and + // rethrow the original error so the runtime still classifies it as a timeout. + await expect(run(mutation.command, mutation.flags, mock.client)).rejects.toBe(abort); + expectSingleSubmission(mock, mutation); + expect(stdout).toBe(""); + }); + + test("plain Error keeps its identity and gains the recovery hint", async () => { + const failure = new Error("transport failure"); + const mock = mutationClient(mutation, failure); + await expect(run(mutation.command, mutation.flags, mock.client)).rejects.toBe(failure); + expect(failure.message).toContain("No automatic retry"); + expectSingleSubmission(mock, mutation); + expect(stdout).toBe(""); + }); + + test.each([ + { requestId: " " }, + { requestId: "id\ninjected" }, + { requestId: "id\rinjected" }, + { interval: 2 }, + { pollTimeout: 5 }, + { wait: true, interval: 0 }, + { wait: true, interval: Infinity }, + { wait: true, interval: 3601 }, + { wait: true, pollTimeout: 0 }, + { wait: true, pollTimeout: 2_147_484 }, + ])("invalid write options %j fail before any request", async (flags) => { + const mock = mockClient(); + await expect( + run(mutation.command, { ...mutation.flags, ...flags }, mock.client), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + expect(mock.requestJson).not.toHaveBeenCalled(); + }); +}); + +describe("capacity/prepaid validation and purchase mapping", () => { + test.each(MUTATIONS.filter(({ name }) => name !== "delete"))( + "$name rejects unpaired, negative, fractional or unsafe capacity locally", + async (mutation) => { + for (const capacity of [ + { inputTpm: 1, outputTpm: undefined }, + { inputTpm: undefined, outputTpm: 1 }, + { inputTpm: -1, outputTpm: 0 }, + { inputTpm: 0, outputTpm: 0.5 }, + { inputTpm: NaN, outputTpm: 0 }, + { inputTpm: 0, outputTpm: Infinity }, + { inputTpm: Number.MAX_SAFE_INTEGER + 1, outputTpm: 0 }, + ]) { + const mock = mockClient(); + await expect( + run(mutation.command, { ...mutation.flags, ...capacity }, mock.client), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + expect(mock.requestJson).not.toHaveBeenCalled(); + } + }, + ); + + test.each([0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( + "rejects prepaid duration=%s locally", + async (duration) => { + const mock = mockClient(); + await expect( + run(deployCapacityRenew, { ...IDS, ...PREPAID, duration }, mock.client), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + expect(mock.requestJson).not.toHaveBeenCalled(); + }, + ); + + test.each([ + {}, + { duration: 31 }, + { autoRenewal: false }, + { duration: 31, autoRenewal: true }, + { ...PREPAID, autoRenewalDuration: 0 }, + { ...PREPAID, autoRenewalCycle: " " }, + ])("PRE_PAY purchase rejects incomplete prepaid block %j", async (flags) => { + const mock = mockClient(); + await expect( + run( + deployCapacityCreate, + { + deployedModel: MODEL_CODE, + billingMethod: "PRE_PAY", + inputTpm: 0, + outputTpm: 0, + ...flags, + }, + mock.client, + ), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + expect(mock.requestJson).not.toHaveBeenCalled(); + }); + + test.each([ + { autoRenewal: false }, + PREPAID, + { autoRenewalDuration: 31 }, + { autoRenewalCycle: "Day" }, + ])("POST_PAY rejects prepaid fields %j", async (prepaid) => { + const mock = mockClient(); + await expect( + run( + deployCapacityCreate, + { + deployedModel: MODEL_CODE, + billingMethod: "POST_PAY", + inputTpm: 0, + outputTpm: 0, + ...prepaid, + }, + mock.client, + ), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + expect(mock.requestJson).not.toHaveBeenCalled(); + }); + + test.each([false, true])( + "PRE_PAY preserves days, zero capacity and autoRenewal=%s", + async (autoRenewal) => { + const mock = mockClient({ output: OPERATION }); + await run( + deployCapacityCreate, + { + deployedModel: MODEL_CODE, + billingMethod: "PRE_PAY", + inputTpm: 0, + outputTpm: 7, + duration: 31, + autoRenewal, + autoRenewalDuration: 62, + autoRenewalCycle: "Day", + }, + mock.client, + ); + expect(writes(mock)[0].body).toEqual({ + billing_method: "PRE_PAY", + ptu_capacity: { input_tpm: 0, output_tpm: 7 }, + pre_paid_info: { + duration: 31, + auto_renewal: autoRenewal, + auto_renewal_duration: 62, + auto_renewal_cycle: "Day", + }, + }); + expect(requests(mock)).toHaveLength(1); + }, + ); + + test.each([undefined, "UPGRADE", "DOWNGRADE"])( + "prepaid scale maps orderType=%s and never invents absent prepaid settings", + async (orderType) => { + const mock = mockClient( + { output: { ...INSTANCE, charge_type: "pre_paid" } }, + { output: OPERATION }, + ); + await run( + deployCapacityScale, + { ...IDS, inputTpm: 17, outputTpm: 0, orderType }, + mock.client, + ); + expect(writes(mock)[0].body).toEqual({ + ptu_capacity: { input_tpm: 17, output_tpm: 0 }, + ...(orderType ? { order_type: orderType } : {}), + }); + }, + ); + + test("prepaid scale carries an explicit false renewal setting", async () => { + const mock = mockClient( + { output: { ...INSTANCE, charge_type: "pre_paid" } }, + { output: OPERATION }, + ); + await run( + deployCapacityScale, + { ...IDS, inputTpm: 0, outputTpm: 0, ...PREPAID, orderType: "DOWNGRADE" }, + mock.client, + ); + expect(writes(mock)[0].body).toEqual({ + ptu_capacity: { input_tpm: 0, output_tpm: 0 }, + order_type: "DOWNGRADE", + pre_paid_info: { duration: 31, auto_renewal: false }, + }); + }); +}); + +describe.each(MUTATIONS.filter(({ precheck }) => precheck))("$name precheck safety", (mutation) => { + test.each(["false", "missing", "deleted", "missing payload"])( + "%s capability/instance prevents all writes", + async (scenario) => { + const capability = `can_${mutation.name}`; + const response = + scenario === "missing payload" + ? {} + : { + output: { + ...mutation.instance, + [capability]: + scenario === "false" ? false : scenario === "missing" ? undefined : true, + deleted: scenario === "deleted", + }, + }; + const mock = mockClient(response); + await expect(run(mutation.command, mutation.flags, mock.client)).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + }); + expect(requests(mock).map(({ method, path }) => ({ method, path }))).toEqual([ + { method: "GET", path: INSTANCE_PATH }, + ]); + expect(stdout).toBe(""); + }, + ); + + test.each([undefined, "FUTURE_BILLING"])( + "unknown charge_type=%s must fail closed", + async (chargeType) => { + const mock = mockClient( + { output: { ...mutation.instance, charge_type: chargeType } }, + { output: OPERATION }, + ); + await expect(run(mutation.command, mutation.flags, mock.client)).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + }); + expect(writes(mock)).toHaveLength(0); + }, + ); + + test("precheck failure is not swallowed or retried", async () => { + const failure = new BailianError("Original precheck failure", ExitCode.NETWORK); + const mock = mockClient(failure); + await expect(run(mutation.command, mutation.flags, mock.client)).rejects.toBe(failure); + expect(requests(mock)).toHaveLength(1); + expect(writes(mock)).toHaveLength(0); + }); +}); + +describe("renewal and release business constraints", () => { + test.each([deployCapacityScale, deployCapacityRenew])( + "postpaid instance rejects prepaid configuration", + async (command) => { + const mock = mockClient({ output: INSTANCE }); + await expect( + run(command, { ...IDS, inputTpm: 0, outputTpm: 0, ...PREPAID }, mock.client), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + expect(writes(mock)).toHaveLength(0); + }, + ); + + test.each([undefined, false])( + "renewing changed capacity requires isChange=true, not %s", + async (isChange) => { + const mock = mockClient({ output: { ...INSTANCE, charge_type: "pre_paid" } }); + await expect( + run( + deployCapacityRenew, + { ...IDS, ...PREPAID, inputTpm: 0, outputTpm: 0, isChange }, + mock.client, + ), + ).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + message: expect.stringContaining("--is-change true"), + }); + expect(writes(mock)).toHaveLength(0); + }, + ); + + test.each([ + { inputTpm: 0, outputTpm: 0, isChange: true }, + { inputTpm: 100, outputTpm: 10, isChange: false }, + ])("renew maps explicit change intent %j", async (capacity) => { + const mock = mockClient( + { output: { ...INSTANCE, charge_type: "pre_paid" } }, + { output: OPERATION }, + ); + await run(deployCapacityRenew, { ...IDS, ...PREPAID, ...capacity }, mock.client); + expect(writes(mock)[0].body).toEqual({ + pre_paid_info: { duration: 31, auto_renewal: false }, + is_change: capacity.isChange, + ptu_capacity: { input_tpm: capacity.inputTpm, output_tpm: capacity.outputTpm }, + }); + }); + + test("renew cannot assume unchanged capacity when configured_capacity is absent", async () => { + const mock = mockClient({ + output: { ...INSTANCE, charge_type: "pre_paid", configured_capacity: undefined }, + }); + await expect( + run(deployCapacityRenew, { ...IDS, ...PREPAID, inputTpm: 100, outputTpm: 10 }, mock.client), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + expect(writes(mock)).toHaveLength(0); + }); + + test.each(["RUNNING", "STOPPED", "PROCESSING", undefined])( + "prepaid status=%s blocks direct release even with can_delete=true", + async (status) => { + const mock = mockClient({ output: { ...INSTANCE, charge_type: "pre_paid", status } }); + await expect(run(deployCapacityDelete, IDS, mock.client)).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + message: expect.stringMatching(/unsubscribe|退订/), + }); + expect(writes(mock)).toHaveLength(0); + }, + ); + + test.each(["accepted", "rejected"])( + "FAILED prepaid release is %s by the server, not decided from status alone", + async (outcome) => { + const failure = new BailianError("Original refund rule rejection", ExitCode.GENERAL); + const mock = mockClient( + { output: { ...INSTANCE, charge_type: "pre_paid", status: "FAILED" } }, + outcome === "accepted" ? { output: OPERATION } : failure, + ); + const result = run(deployCapacityDelete, IDS, mock.client); + if (outcome === "accepted") await result; + else + await expect(result).rejects.toMatchObject({ + message: failure.message, + exitCode: ExitCode.GENERAL, + }); + expect(writes(mock)).toHaveLength(1); + expect(writes(mock)[0]).toMatchObject({ method: "DELETE", path: INSTANCE_PATH }); + }, + ); + + test.each([ + { reason: undefined, query: "" }, + { reason: "", query: "?reason=" }, + { + reason: "release / capacity & keep model", + query: "?reason=release+%2F+capacity+%26+keep+model", + }, + ])( + "release reason=$reason is a query parameter; never deletes ModelCode", + async ({ reason, query }) => { + const mock = mockClient({ output: INSTANCE }, { output: OPERATION }); + await run(deployCapacityDelete, { ...IDS, reason }, mock.client); + expect(writes(mock)[0]).toMatchObject({ method: "DELETE", path: `${INSTANCE_PATH}${query}` }); + expect(writes(mock)[0]).not.toHaveProperty("body"); + expect(requests(mock).some(({ path }) => path === DEPLOYMENT_PATH)).toBe(false); + }, + ); +}); + +describe.each(MUTATIONS)("$name wait lifecycle", (mutation) => { + test("submits once, waits only with GETs, keeps operation ID and stderr submitted record", async () => { + const { mock, completed, instance, deployment } = waitingClient(mutation); + const result = run( + mutation.command, + { ...mutation.flags, requestId: REQUEST_ID, wait: true }, + mock.client, + ); + const assertion = expect(result).resolves.toBeUndefined(); + await Promise.all([assertion, vi.advanceTimersByTimeAsync(10_000)]); + expectSingleSubmission(mock, mutation); + expect(requests(mock).filter(({ path }) => path === OPERATION_PATH).length).toBeGreaterThan(0); + expect(JSON.parse(stdout)).toEqual({ + ...completed, + instance, + deployment, + client_request_id: REQUEST_ID, + }); + expect(JSON.parse(stderr)).toEqual({ + submitted: true, + deployed_model: MODEL_CODE, + operation_id: OPERATION_ID, + client_request_id: REQUEST_ID, + }); + }); + + test("already-SUCCEEDED submission refreshes without polling that terminal operation again", async () => { + const { mock, initial, instance, deployment } = waitingClient(mutation, { + initialStatus: "SUCCEEDED", + }); + await run( + mutation.command, + { ...mutation.flags, requestId: REQUEST_ID, wait: true }, + mock.client, + ); + expectSingleSubmission(mock, mutation); + expect(requests(mock).some(({ path }) => path === OPERATION_PATH)).toBe(false); + expect(JSON.parse(stdout)).toEqual({ + ...initial, + instance, + deployment, + client_request_id: REQUEST_ID, + }); + expect(JSON.parse(stderr)).toMatchObject({ submitted: true, operation_id: OPERATION_ID }); + }); + + test("preserves submitted operation ID even when poll response omits its echo", async () => { + const { mock } = waitingClient(mutation, { omitPollId: true }); + const result = run(mutation.command, { ...mutation.flags, wait: true }, mock.client); + const assertion = expect(result).resolves.toBeUndefined(); + await Promise.all([assertion, vi.advanceTimersByTimeAsync(10_000)]); + expect(JSON.parse(stdout).output.operation_id).toBe(OPERATION_ID); + expectSingleSubmission(mock, mutation); + }); + + test("missing operation ID says already submitted and never retries", async () => { + const response = { output: { operation_status: "PROCESSING" } }; + const mock = mutationClient(mutation, response); + await expect( + run(mutation.command, { ...mutation.flags, wait: true }, mock.client), + ).rejects.toMatchObject({ + exitCode: ExitCode.GENERAL, + message: expect.stringMatching(/submitted.*operation ID|已提交/), + cause: { ...response, client_request_id: expect.stringMatching(UUID) }, + }); + expectSingleSubmission(mock, mutation); + expect(requests(mock).at(-1)?.method).toBe(mutation.method); + expect(stdout).toBe(""); + }); + + test("wait timeout retains submitted identifiers, removes local timers, and never resubmits", async () => { + const { mock } = waitingClient(mutation, { pollStatus: "PROCESSING" }); + const result = run( + mutation.command, + { ...mutation.flags, requestId: REQUEST_ID, wait: true, interval: 2, pollTimeout: 3 }, + mock.client, + ); + const rejection = expect(result).rejects.toMatchObject({ exitCode: ExitCode.TIMEOUT }); + await Promise.all([rejection, vi.advanceTimersByTimeAsync(3000)]); + expect(JSON.parse(stderr)).toMatchObject({ + submitted: true, + operation_id: OPERATION_ID, + client_request_id: REQUEST_ID, + }); + expect(stdout).toBe(""); + expect(vi.getTimerCount()).toBe(0); + const countAfterTimeout = requests(mock).length; + await vi.advanceTimersByTimeAsync(600_000); + expect(requests(mock)).toHaveLength(countAfterTimeout); + expectSingleSubmission(mock, mutation); + }); + + test("poll error keeps original service message and never repeats the write", async () => { + const failure = new BailianError("Original polling failure", ExitCode.GENERAL, undefined, { + api: { httpStatus: 503, apiCode: "Original.Code", requestId: "poll-error" }, + rawResponse: "original poll body", + }); + const { mock } = waitingClient(mutation, { pollFailure: failure }); + const result = run(mutation.command, { ...mutation.flags, wait: true }, mock.client); + const rejection = expect(result).rejects.toBe(failure); + await Promise.all([rejection, vi.advanceTimersByTimeAsync(10_000)]); + expect(JSON.parse(stderr)).toMatchObject({ submitted: true, operation_id: OPERATION_ID }); + expect(stdout).toBe(""); + expectSingleSubmission(mock, mutation); + }); +}); + +describe("release completion confirmation", () => { + test.each([false, undefined])( + "STOPPED and zero capacity are not release when deleted=%s", + async (deleted) => { + const mutation = MUTATIONS.find(({ name }) => name === "delete")!; + const { mock, instance } = waitingClient(mutation); + Object.assign(instance.output, { + deleted, + status: "STOPPED", + effective_capacity: { input_tpm: 0, output_tpm: 0 }, + }); + const result = run(deployCapacityDelete, { ...IDS, wait: true }, mock.client); + const rejection = expect(result).rejects.toMatchObject({ + exitCode: ExitCode.GENERAL, + message: expect.stringMatching(/deleted|release|释放/i), + }); + await Promise.all([rejection, vi.advanceTimersByTimeAsync(10_000)]); + expect(stdout).toBe(""); + expectSingleSubmission(mock, mutation); + }, + ); +}); + +describe("ModelCode overflow update and read-back", () => { + test.each(["enable", "disable"])( + "dry-run %s performs neither PUT nor confirmation GET", + async (strategy) => { + const mock = mockClient(); + await run(deployOverflow, { deployedModel: MODEL_CODE, strategy }, mock.client, { + dryRun: true, + }); + expect(mock.requestJson).not.toHaveBeenCalled(); + expect(JSON.parse(stdout)).toEqual({ + action: "deploy.overflow", + deployed_model: MODEL_CODE, + body: { overflow_strategy: strategy }, + }); + }, + ); + + test.each(["output", "data"])( + "confirms %s read-back and preserves both raw envelopes", + async (envelope) => { + for (const strategy of ["enable", "disable"]) { + stdout = ""; + const response = { + request_id: "update-request", + [envelope]: { overflow_strategy: strategy, future_flag: false }, + }; + const deployment = { + request_id: "get-request", + [envelope]: { + overflow_strategy: strategy, + ptu_capacity: { input_tpm: 0 }, + future_count: 0, + }, + }; + const mock = mockClient(response, deployment); + await run(deployOverflow, { deployedModel: MODEL_CODE, strategy }, mock.client); + expect(requests(mock).map(({ method, path }) => ({ method, path }))).toEqual([ + { method: "PUT", path: `${DEPLOYMENT_PATH}/update-overflowstrategy` }, + { method: "GET", path: DEPLOYMENT_PATH }, + ]); + expect(writes(mock)[0].body).toEqual({ overflow_strategy: strategy }); + expect(JSON.parse(stdout)).toEqual({ ...response, deployment }); + } + }, + ); + + test.each(["disable", "FUTURE_STRATEGY", undefined])( + "unconfirmed read-back=%s errors without repeating update", + async (strategy) => { + const response = { output: { accepted: true } }; + const deployment = { output: { overflow_strategy: strategy } }; + const mock = mockClient(response, deployment); + await expect( + run(deployOverflow, { deployedModel: MODEL_CODE, strategy: "enable" }, mock.client), + ).rejects.toMatchObject({ + exitCode: ExitCode.GENERAL, + message: expect.stringMatching(/submitted.*read-back|已提交/), + cause: { response, deployment }, + }); + expect(writes(mock)).toHaveLength(1); + expect(requests(mock)).toHaveLength(2); + expect(stdout).toBe(""); + }, + ); + + test.each(["update", "confirmation"])( + "%s error is preserved without an automatic retry", + async (phase) => { + const failure = new BailianError("Original overflow failure", ExitCode.GENERAL, undefined, { + api: { httpStatus: 503, apiCode: "Overflow.Original", requestId: "server-error" }, + }); + const mock = mockClient(...(phase === "confirmation" ? [{ output: {} }] : []), failure); + await expect( + run(deployOverflow, { deployedModel: MODEL_CODE, strategy: "disable" }, mock.client), + ).rejects.toBe(failure); + await vi.advanceTimersByTimeAsync(600_000); + expect(writes(mock)).toHaveLength(1); + expect(requests(mock)).toHaveLength(phase === "update" ? 1 : 2); + expect(stdout).toBe(""); + }, + ); +}); diff --git a/packages/commands/tests/deploy-legacy-write.test.ts b/packages/commands/tests/deploy-legacy-write.test.ts new file mode 100644 index 00000000..c248fd3e --- /dev/null +++ b/packages/commands/tests/deploy-legacy-write.test.ts @@ -0,0 +1,739 @@ +import { + BailianError, + ExitCode, + type AnyCommand, + type Client, + type Settings, +} from "bailian-cli-core"; +import { afterEach, beforeEach, describe, expect, test, vi, type Mock } from "vite-plus/test"; +import { + deployTextCreate, + deployAudioCreate, + deployImageCreate, +} from "../src/commands/deploy/create.ts"; +import deployScale from "../src/commands/deploy/scale.ts"; +import deployDelete from "../src/commands/deploy/delete.ts"; + +const SETTINGS: Settings = { + output: "json", + outputExplicit: true, + timeout: 30, + watermark: true, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, +}; +const MODEL_CODE = "model/test"; +const INSTANCE_ID = "instance/test"; +const DEPLOYMENT_PATH = "/api/v1/deployments/model%2Ftest"; +const INSTANCE_PATH = `${DEPLOYMENT_PATH}/capacity-instances/instance%2Ftest`; +const REQUEST_ID = "fc9c0b75-7327-4a86-88e1-a3b7d093da89"; +const CREATE_COMMANDS = [ + { name: "text", command: deployTextCreate }, + { name: "audio", command: deployAudioCreate }, + { name: "image", command: deployImageCreate }, +]; +const PTU_CREATE = { + modelName: "base-model", + plan: "ptu", + chargeType: "post_paid", + inputTpm: 10, + outputTpm: 1, +}; +const PTU_SCALE = { + deployedModel: MODEL_CODE, + instanceId: INSTANCE_ID, + inputTpm: 0, + outputTpm: 0, +}; +const INSTANCE = { + instance_id: INSTANCE_ID, + charge_type: "post_paid", + can_scale: true, + deleted: false, +}; +const EMPTY_PAGE = { records: [], items: 0, page: 1, itemsPerPage: 1, pageCount: 0 }; +let stdout = ""; +let stderr = ""; +const mocks: ReturnType[] = []; + +function mockClient(...responses: unknown[]): { + client: Client; + requestJson: Mock; + request: Mock; +} { + const requestJson = vi + .fn() + .mockRejectedValue(new Error("Unexpected request")); + for (const response of responses) { + if (response instanceof Error) requestJson.mockRejectedValueOnce(response); + else requestJson.mockResolvedValueOnce(response); + } + const request = vi.fn().mockRejectedValue(new Error("Unexpected raw request")); + const mock = { client: { requestJson, request } as unknown as Client, requestJson, request }; + mocks.push(mock); + return mock; +} + +type CommandFlags = Parameters>[0]; + +function validate(command: AnyCommand, flags: CommandFlags) { + return command.validate?.(flags); +} + +async function run( + command: AnyCommand, + flags: CommandFlags, + client: Client, + settings: Partial = {}, +) { + const validation = await command.validate?.(flags); + if (validation) throw new BailianError(validation, ExitCode.USAGE); + await command.run({ + identity: { + binName: "test-cli", + version: "test", + npmPackage: "test-cli", + clientName: "test-cli", + }, + settings: { ...SETTINGS, ...settings }, + flags, + client, + } as Parameters[0]); +} + +function requests(mock: ReturnType) { + return mock.requestJson.mock.calls.map(([request]) => request); +} + +beforeEach(() => { + vi.useFakeTimers(); + stdout = ""; + stderr = ""; + mocks.length = 0; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Real network is forbidden"))); +}); + +afterEach(() => { + try { + expect(fetch).not.toHaveBeenCalled(); + for (const mock of mocks) expect(mock.request).not.toHaveBeenCalled(); + if (vi.isFakeTimers()) expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + } +}); + +describe("legacy deploy mutation metadata", () => { + test.each([ + ...CREATE_COMMANDS, + { name: "scale", command: deployScale }, + { name: "delete", command: deployDelete }, + ])("$name requires high-risk confirmation in both languages", ({ command }) => { + expect(command.risk).toMatchObject({ + level: "high", + message: { "en-US": expect.any(String), "zh-CN": expect.any(String) }, + }); + expect(command.flags).not.toHaveProperty("yes"); + }); + + test("first create exposes wait but never an idempotency request-id flag", () => { + expect(deployTextCreate.flags).toHaveProperty("wait"); + expect(deployTextCreate.flags).not.toHaveProperty("requestId"); + expect(deployTextCreate.flags?.displayName).not.toHaveProperty("required", true); + expect(deployTextCreate.flags?.autoRenewal.type).toBe("boolean"); + }); +}); + +describe.each(CREATE_COMMANDS)("$name create", ({ command }) => { + test("PTU dry-run accepts no display name and performs no catalog or write request", async () => { + const mock = mockClient(); + await run(command, PTU_CREATE, mock.client, { dryRun: true }); + expect(mock.requestJson).not.toHaveBeenCalled(); + const result = JSON.parse(stdout); + expect(result).toMatchObject({ + action: "deploy.create", + body: { + model_name: "base-model", + plan: "ptu", + charge_type: "post_paid", + ptu_capacity: { input_tpm: 10, output_tpm: 1 }, + }, + }); + expect(result.body).not.toHaveProperty("name"); + expect(result.body).not.toHaveProperty("pre_paid_info"); + }); + + test("MU dry-run skips automatic catalog lookup", async () => { + const mock = mockClient(); + await run( + command, + { modelName: "base-model", displayName: "display", plan: "mu" }, + mock.client, + { dryRun: true }, + ); + expect(mock.requestJson).not.toHaveBeenCalled(); + expect(JSON.parse(stdout).body).toMatchObject({ + plan: "mu", + name: "display", + capacity: 1, + billing_method: "POST_PAY", + }); + }); + + test("PTU prepaid flags build the purchase body and preserve quiet operation output", async () => { + const response = { + request_id: "server-request", + output: { deployed_model: MODEL_CODE, operation_id: "123", status: "WAIT_TO_DEPLOY" }, + }; + const mock = mockClient(response); + await run( + command, + { + ...PTU_CREATE, + chargeType: "pre_paid", + serviceTier: "ptu_default", + suffix: "custom", + duration: 30, + autoRenewal: false, + }, + mock.client, + ); + expect(requests(mock)).toHaveLength(1); + expect(requests(mock)[0]).toMatchObject({ + method: "POST", + path: "/api/v1/deployments", + body: { + charge_type: "pre_paid", + service_tier: "ptu_default", + suffix: "custom", + ptu_capacity: { input_tpm: 10, output_tpm: 1 }, + pre_paid_info: { duration: 30, auto_renewal: false }, + }, + }); + expect(JSON.parse(stdout)).toEqual(response); + expect(requests(mock)[0].body).not.toHaveProperty("request_id"); + }); + + test("initial create transport timeout is propagated without a retry", async () => { + const failure = new BailianError("original timeout", ExitCode.TIMEOUT); + const mock = mockClient(failure); + await expect(run(command, PTU_CREATE, mock.client)).rejects.toBe(failure); + expect(requests(mock)).toHaveLength(1); + }); + + test("PTU rejects thinking-output rather than ignoring it", async () => { + expect(await validate(command, { ...PTU_CREATE, thinkingOutputTpm: 0 })).toMatch( + /thinking-output-tpm/, + ); + }); + + test.each(["lora", "mu"])("%s requires display-name", async (plan) => { + expect(await validate(command, { modelName: "base-model", plan })).toMatch(/--display-name/); + }); + + test.each([true, false])( + "MU explicit input/output stays compatible with quiet=%s", + async (quiet) => { + const response = { + output: { deployed_model: MODEL_CODE, capacity: 2 }, + request_id: "legacy-request", + }; + const mock = mockClient(response); + await run( + command, + { + modelName: "base-model", + displayName: "display", + plan: "mu", + deploySpec: "MU1", + capacity: 2, + billingMethod: "POST_PAY", + }, + mock.client, + { quiet }, + ); + expect(requests(mock)).toHaveLength(1); + expect(requests(mock)[0]).toMatchObject({ method: "POST", path: "/api/v1/deployments" }); + expect(requests(mock)[0].body).toEqual({ + model_name: "base-model", + name: "display", + plan: "mu", + deploy_spec: "MU1", + capacity: 2, + billing_method: "POST_PAY", + }); + if (quiet) expect(stdout.trim()).toBe(MODEL_CODE); + else expect(JSON.parse(stdout)).toEqual(response); + }, + ); +}); + +describe("create plan isolation", () => { + test.each([ + { chargeType: "post_paid" }, + { serviceTier: "ptu_fast" }, + { suffix: "suffix" }, + { duration: 30 }, + { autoRenewal: false }, + { autoRenewalDuration: 30 }, + { autoRenewalCycle: "Day" }, + { wait: true }, + { interval: 2 }, + { pollTimeout: 30 }, + ])("non-PTU rejects new PTU option %j", async (flags) => { + for (const plan of ["mu", "lora"]) { + expect( + await validate(deployTextCreate, { + modelName: "base-model", + displayName: "display", + plan, + ...flags, + }), + ).toMatch(/PTU/); + } + }); + + test.each([ + { chargeType: undefined }, + { chargeType: "post_paid", serviceTier: "ptu_default" }, + { chargeType: "post_paid", duration: 30, autoRenewal: false }, + { chargeType: "pre_paid", duration: 30 }, + { chargeType: "pre_paid", duration: 0, autoRenewal: false }, + { chargeType: "pre_paid", duration: 30, autoRenewal: true }, + { inputTpm: -1 }, + { outputTpm: 1.5 }, + { inputTpm: Number.MAX_SAFE_INTEGER + 1 }, + ])("rejects invalid PTU purchase %j before any request", async (flags) => { + const mock = mockClient(); + await expect( + run(deployTextCreate, { ...PTU_CREATE, ...flags }, mock.client), + ).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + }); + expect(mock.requestJson).not.toHaveBeenCalled(); + }); + + test.each([true, false])("legacy LORA output is preserved with quiet=%s", async (quiet) => { + const response = { output: { deployed_model: MODEL_CODE } }; + const mock = mockClient(response); + await run(deployTextCreate, { modelName: "base-model", displayName: "display" }, mock.client, { + quiet, + }); + expect(requests(mock)[0].body).toEqual({ + model_name: "base-model", + name: "display", + plan: "lora", + capacity: 1, + }); + if (quiet) expect(stdout.trim()).toBe(MODEL_CODE); + else expect(JSON.parse(stdout)).toEqual(response); + }); +}); + +describe("scale capacity validation and dry-run", () => { + test.each([ + {}, + { inputTpm: 1 }, + { outputTpm: 1 }, + { capacity: 1, inputTpm: 1, outputTpm: 1 }, + { capacity: -1 }, + { capacity: 0.5 }, + { capacity: Infinity }, + { capacity: Number.MAX_SAFE_INTEGER + 1 }, + { inputTpm: -1, outputTpm: 0 }, + { inputTpm: 0, outputTpm: NaN }, + { capacity: 1, instanceId: INSTANCE_ID }, + { capacity: 1, orderType: "UPGRADE" }, + { capacity: 1, duration: 30, autoRenewal: false }, + { capacity: 1, autoRenewal: false }, + { capacity: 1, wait: true }, + { capacity: 1, requestId: REQUEST_ID }, + { capacity: 1, interval: 2 }, + { capacity: 1, pollTimeout: 30 }, + { inputTpm: 1, outputTpm: 1, instanceId: " " }, + ])("rejects invalid flags %j locally", async (flags) => { + const mock = mockClient(); + await expect( + run(deployScale, { deployedModel: MODEL_CODE, ...flags }, mock.client), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + expect(mock.requestJson).not.toHaveBeenCalled(); + }); + + test.each([undefined, INSTANCE_ID])( + "PTU dry-run with instanceId=%s never resolves it", + async (instanceId) => { + const mock = mockClient(); + await run(deployScale, { ...PTU_SCALE, instanceId }, mock.client, { dryRun: true }); + expect(mock.requestJson).not.toHaveBeenCalled(); + const result = JSON.parse(stdout); + expect(result.body).toMatchObject({ ptu_capacity: { input_tpm: 0, output_tpm: 0 } }); + expect(result).not.toHaveProperty("client_request_id"); + if (instanceId) expect(result.body.instance_id).toBe(instanceId); + else { + expect(result.body).not.toHaveProperty("instance_id"); + expect(result.instance_resolution).toMatch(/--instance-id/); + } + }, + ); + + test.each([true, false])("MU body and output remain compatible with quiet=%s", async (quiet) => { + const response = { output: { deployed_model: MODEL_CODE, capacity: 0 } }; + const mock = mockClient(response); + await run(deployScale, { deployedModel: MODEL_CODE, capacity: 0 }, mock.client, { quiet }); + expect(requests(mock)).toHaveLength(1); + expect(requests(mock)[0]).toMatchObject({ + method: "PUT", + path: `${DEPLOYMENT_PATH}/scale`, + body: { capacity: 0 }, + }); + expect(requests(mock)[0].body).toEqual({ capacity: 0 }); + if (quiet) expect(stdout.trim()).toBe(MODEL_CODE); + else expect(JSON.parse(stdout)).toEqual(response); + }); +}); + +describe.each(["output", "data"] as const)("PTU scale %s envelope", (envelope) => { + test("explicit ID checks detail, writes nested absolute capacity once and preserves request ID", async () => { + const response = { request_id: "server-request", [envelope]: { operation_id: "123" } }; + const mock = mockClient({ [envelope]: INSTANCE }, response); + await run(deployScale, { ...PTU_SCALE, requestId: REQUEST_ID }, mock.client); + expect(requests(mock).map(({ method, path }) => ({ method, path }))).toEqual([ + { method: "GET", path: INSTANCE_PATH }, + { method: "PUT", path: `${DEPLOYMENT_PATH}/scale` }, + ]); + expect(requests(mock)[1].body).toEqual({ + instance_id: INSTANCE_ID, + ptu_capacity: { input_tpm: 0, output_tpm: 0 }, + }); + expect(requests(mock)[1].headers).toEqual({ + "x-acs-req-uuid": REQUEST_ID, + "X-DashScope-RequestId": REQUEST_ID, + }); + expect(JSON.parse(stdout)).toEqual({ ...response, client_request_id: REQUEST_ID }); + }); + + test("omitted ID resolves a unique page then rechecks detail and generates one UUID", async () => { + const mock = mockClient( + { [envelope]: { records: [INSTANCE], items: 1, page: 1, itemsPerPage: 2, pageCount: 1 } }, + { [envelope]: INSTANCE }, + { [envelope]: { operation_id: "123" } }, + ); + await run(deployScale, { ...PTU_SCALE, instanceId: undefined }, mock.client); + expect(requests(mock)[0].path).toBe( + `${DEPLOYMENT_PATH}/capacity-instances?page_no=1&page_size=2&include_deleted=false`, + ); + expect(requests(mock)[1].path).toBe(INSTANCE_PATH); + expect(requests(mock)[2].body).toHaveProperty("instance_id", INSTANCE_ID); + expect(JSON.parse(stdout).client_request_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + test("prepaid change includes order type and explicit false auto-renewal", async () => { + const mock = mockClient( + { [envelope]: { ...INSTANCE, charge_type: "pre_paid" } }, + { [envelope]: { operation_id: "123" } }, + ); + await run( + deployScale, + { ...PTU_SCALE, duration: 30, autoRenewal: false, orderType: "DOWNGRADE" }, + mock.client, + ); + expect(requests(mock)[1].body).toMatchObject({ + order_type: "DOWNGRADE", + pre_paid_info: { duration: 30, auto_renewal: false }, + }); + }); +}); + +describe("PTU scale prechecks", () => { + test.each([ + {}, + { records: [] }, + { records: [], items: 0 }, + { records: [INSTANCE] }, + { records: [INSTANCE], items: 2 }, + { records: [INSTANCE, { instance_id: "second" }], items: 2 }, + { records: [INSTANCE], items: 1, page: 2 }, + { records: [INSTANCE], items: 1, pageCount: 2 }, + { records: [{ deleted: false }], items: 1 }, + { records: [{ ...INSTANCE, deleted: true }], items: 1 }, + ])("never blindly selects an instance from %j", async (page) => { + const mock = mockClient({ output: page }); + await expect( + run(deployScale, { ...PTU_SCALE, instanceId: undefined }, mock.client), + ).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + message: expect.stringContaining("--instance-id"), + }); + expect(requests(mock)).toHaveLength(1); + expect(requests(mock)[0].method).toBe("GET"); + }); + + test.each([ + { ...INSTANCE, can_scale: false }, + { ...INSTANCE, can_scale: undefined }, + { ...INSTANCE, deleted: true }, + ])("rejects unavailable/deleted instance %j", async (instance) => { + const mock = mockClient({ output: instance }); + await expect(run(deployScale, PTU_SCALE, mock.client)).rejects.toBeInstanceOf(BailianError); + expect(requests(mock)).toHaveLength(1); + }); + + test("rejects prepaid changes for a postpaid instance", async () => { + const mock = mockClient({ output: INSTANCE }); + await expect( + run(deployScale, { ...PTU_SCALE, duration: 30, autoRenewal: false }, mock.client), + ).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + message: expect.stringContaining("Postpaid"), + }); + expect(requests(mock)).toHaveLength(1); + }); + + test.each([{ charge_type: undefined }, { charge_type: "unknown" }, { charge_type: "" }])( + "fails closed on unknown billing type %j before writing", + async (override) => { + const mock = mockClient({ output: { ...INSTANCE, ...override } }); + await expect(run(deployScale, PTU_SCALE, mock.client)).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + message: expect.stringContaining("Unknown instance billing type"), + }); + expect(requests(mock)).toHaveLength(1); + expect(requests(mock)[0].method).toBe("GET"); + }, + ); + + test("write failure is propagated without retrying or issuing another write", async () => { + const failure = new Error("transport failure"); + const mock = mockClient({ output: INSTANCE }, failure); + await expect(run(deployScale, PTU_SCALE, mock.client)).rejects.toBe(failure); + expect(requests(mock).map(({ method }) => method)).toEqual(["GET", "PUT"]); + }); +}); + +describe("PTU asynchronous write handling", () => { + test.each([ + { name: "create", command: deployTextCreate, flags: PTU_CREATE }, + { name: "scale", command: deployScale, flags: PTU_SCALE }, + ])("$name treats HTTP-success FAILED as failure", async ({ name, command, flags }) => { + const response = { + request_id: "server-request", + output: { + operation_id: "123", + operation_status: "FAILED", + error_message: "Original server failure", + }, + }; + const mock = + name === "scale" ? mockClient({ output: INSTANCE }, response) : mockClient(response); + await expect(run(command, flags, mock.client)).rejects.toMatchObject({ + message: "Original server failure", + exitCode: ExitCode.GENERAL, + }); + expect(requests(mock).filter(({ method }) => method !== "GET")).toHaveLength(1); + }); + + test("wait after legacy scale polls the returned operation without reissuing the write", async () => { + const mock = mockClient( + { output: INSTANCE }, + { + output: { deployed_model: MODEL_CODE, operation_id: "123", operation_status: "PROCESSING" }, + }, + { output: { operation_id: "123", operation_status: "SUCCEEDED" } }, + { output: { deployed_model: MODEL_CODE } }, + ); + const result = run( + deployScale, + { ...PTU_SCALE, wait: true, requestId: REQUEST_ID }, + mock.client, + ); + await Promise.all([ + expect(result).resolves.toBeUndefined(), + vi.advanceTimersByTimeAsync(10_000), + ]); + expect(requests(mock).filter(({ method }) => method !== "GET")).toHaveLength(1); + expect(requests(mock)[1]).toMatchObject({ method: "PUT", path: `${DEPLOYMENT_PATH}/scale` }); + expect( + requests(mock) + .slice(2) + .every(({ method }) => method === "GET"), + ).toBe(true); + expect(requests(mock).map(({ path }) => path)).toContain( + `${DEPLOYMENT_PATH}/capacity-operations/123`, + ); + expect(JSON.parse(stdout)).toMatchObject({ + output: { operation_id: "123" }, + client_request_id: REQUEST_ID, + }); + expect(JSON.parse(stderr)).toEqual({ + submitted: true, + deployed_model: MODEL_CODE, + operation_id: "123", + client_request_id: REQUEST_ID, + }); + }); + + test("create wait with a missing operation ID fails without resubmitting creation", async () => { + const mock = mockClient({ output: { deployed_model: MODEL_CODE } }); + await expect( + run(deployTextCreate, { ...PTU_CREATE, wait: true }, mock.client), + ).rejects.toBeInstanceOf(BailianError); + expect(requests(mock)).toHaveLength(1); + }); +}); + +describe("delete safety", () => { + test.each([false, true])( + "dry-run with skipPrecheck=%s sends no GET or DELETE", + async (skipPrecheck) => { + const mock = mockClient(); + await run(deployDelete, { deployedModel: MODEL_CODE, skipPrecheck }, mock.client, { + dryRun: true, + }); + expect(mock.requestJson).not.toHaveBeenCalled(); + expect(JSON.parse(stdout)).toEqual({ action: "deploy.delete", deployed_model: MODEL_CODE }); + }, + ); + + test.each([ + { plan: "ptu" }, + { plan: "ptu_v2" }, + { ptu_capacity: { input_tpm: 0, output_tpm: 0 } }, + { ptu_service_tier: "ptu_fast" }, + ])("recognizes PTU from %j and refuses FAILED", async (feature) => { + const mock = mockClient({ output: { ...feature, status: "FAILED" } }); + await expect( + run(deployDelete, { deployedModel: MODEL_CODE }, mock.client), + ).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + message: expect.stringContaining("STOPPED"), + }); + expect(requests(mock)).toHaveLength(1); + }); + + test.each([ + { records: [INSTANCE], items: 1 }, + { records: [{ ...INSTANCE, status: "STOPPED" }], items: 1 }, + { records: [], items: 1 }, + { records: [], items: 0, total: 1 }, + {}, + { records: [] }, + { items: 0 }, + { records: [], items: "0" }, + { records: [], items: 0, page: 2 }, + { records: [], items: 0, pageCount: 2 }, + ])("fails closed for nonempty or unreliable capacity response %j", async (page) => { + const mock = mockClient({ output: { plan: "ptu", status: "STOPPED" } }, { output: page }); + await expect( + run(deployDelete, { deployedModel: MODEL_CODE }, mock.client), + ).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + }); + expect(requests(mock).map(({ method }) => method)).toEqual(["GET", "GET"]); + }); + + test.each(["output", "data"] as const)( + "deletes an empty STOPPED PTU using %s envelope", + async (envelope) => { + const response = { [envelope]: { deployed_model: MODEL_CODE, deleted: true } }; + const mock = mockClient( + { [envelope]: { plan: "ptu", status: "STOPPED" } }, + { [envelope]: EMPTY_PAGE }, + response, + ); + await run(deployDelete, { deployedModel: MODEL_CODE }, mock.client, { quiet: false }); + expect(requests(mock).map(({ method, path }) => ({ method, path }))).toEqual([ + { method: "GET", path: DEPLOYMENT_PATH }, + { + method: "GET", + path: `${DEPLOYMENT_PATH}/capacity-instances?page_no=1&page_size=1&include_deleted=false`, + }, + { method: "DELETE", path: DEPLOYMENT_PATH }, + ]); + expect(JSON.parse(stdout)).toEqual(response); + }, + ); + + test.each(["PROCESSING", undefined, "FUTURE_STATE"])( + "blocks returned operation status=%s", + async (operationStatus) => { + const mock = mockClient( + { output: { plan: "ptu", status: "STOPPED", operation_id: "123" } }, + { output: EMPTY_PAGE }, + { output: { operation_status: operationStatus } }, + ); + await expect( + run(deployDelete, { deployedModel: MODEL_CODE }, mock.client), + ).rejects.toMatchObject({ + exitCode: ExitCode.USAGE, + }); + expect(requests(mock).map(({ method }) => method)).toEqual(["GET", "GET", "GET"]); + expect(requests(mock)[2].path).toBe(`${DEPLOYMENT_PATH}/capacity-operations/123`); + }, + ); + + test.each(["SUCCEEDED", "FAILED"])( + "terminal returned operation %s defers final decision to service", + async (operationStatus) => { + const failure = new BailianError("Server reports another queued operation", ExitCode.GENERAL); + const mock = mockClient( + { output: { plan: "ptu", status: "STOPPED", operation_id: "123" } }, + { output: EMPTY_PAGE }, + { output: { operation_status: operationStatus } }, + failure, + ); + await expect(run(deployDelete, { deployedModel: MODEL_CODE }, mock.client)).rejects.toBe( + failure, + ); + expect(requests(mock).map(({ method }) => method)).toEqual(["GET", "GET", "GET", "DELETE"]); + }, + ); + + test.each(["STOPPED", "FAILED"])( + "non-PTU %s keeps the original quiet deletion flow", + async (status) => { + const mock = mockClient({ output: { plan: "mu", status } }, { output: { deleted: true } }); + await run(deployDelete, { deployedModel: MODEL_CODE }, mock.client); + expect(requests(mock).map(({ method }) => method)).toEqual(["GET", "DELETE"]); + expect(stdout.trim()).toBe(MODEL_CODE); + }, + ); + + test("skip-precheck skips only local checks and preserves server rejection", async () => { + const failure = new BailianError("Server original rejection", ExitCode.GENERAL); + const mock = mockClient(failure); + await expect( + run(deployDelete, { deployedModel: MODEL_CODE, skipPrecheck: true }, mock.client), + ).rejects.toBe(failure); + expect(requests(mock).map(({ method }) => method)).toEqual(["DELETE"]); + }); + + test.each(["deployment", "instances", "operation"])( + "does not swallow %s precheck network failures", + async (phase) => { + const failure = new Error("network unavailable"); + const responses: unknown[] = []; + if (phase !== "deployment") + responses.push({ output: { plan: "ptu", status: "STOPPED", operation_id: "123" } }); + if (phase === "operation") responses.push({ output: EMPTY_PAGE }); + const mock = mockClient(...responses, failure); + await expect(run(deployDelete, { deployedModel: MODEL_CODE }, mock.client)).rejects.toBe( + failure, + ); + expect(requests(mock).every(({ method }) => method === "GET")).toBe(true); + }, + ); +}); diff --git a/packages/commands/tests/deploy-query.test.ts b/packages/commands/tests/deploy-query.test.ts new file mode 100644 index 00000000..a348a2eb --- /dev/null +++ b/packages/commands/tests/deploy-query.test.ts @@ -0,0 +1,950 @@ +import { + BailianError, + ExitCode, + type AnyCommand, + type Client, + type Settings, +} from "bailian-cli-core"; +import { afterEach, beforeEach, describe, expect, test, vi, type Mock } from "vite-plus/test"; +import deployList from "../src/commands/deploy/list.ts"; +import deployGet from "../src/commands/deploy/get.ts"; +import deployCapacityList from "../src/commands/deploy/capacity-list.ts"; +import deployCapacityGet from "../src/commands/deploy/capacity-get.ts"; +import deployOperationGet from "../src/commands/deploy/operation-get.ts"; +import deployOperationWait, { + waitForCapacityOperation, +} from "../src/commands/deploy/operation-wait.ts"; + +const SETTINGS: Settings = { + output: "json", + outputExplicit: true, + timeout: 30, + watermark: true, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, +}; +const MODEL_CODE = "model/test"; +const INSTANCE_ID = "instance/test"; +const OPERATION_ID = "000900719925474099312345/operation"; +const DEPLOYMENT_PATH = "/api/v1/deployments/model%2Ftest"; +const INSTANCE_PATH = `${DEPLOYMENT_PATH}/capacity-instances/instance%2Ftest`; +const OPERATION_PATH = `${DEPLOYMENT_PATH}/capacity-operations/000900719925474099312345%2Foperation`; +const QUERY_COMMANDS: { + name: string; + command: AnyCommand; + flags: Record; + action: string; +}[] = [ + { name: "list", command: deployList, flags: {}, action: "deploy.list" }, + { name: "get", command: deployGet, flags: { deployedModel: MODEL_CODE }, action: "deploy.get" }, + { + name: "capacity list", + command: deployCapacityList, + flags: { deployedModel: MODEL_CODE }, + action: "deploy.capacity.list", + }, + { + name: "capacity get", + command: deployCapacityGet, + flags: { deployedModel: MODEL_CODE, instanceId: INSTANCE_ID }, + action: "deploy.capacity.get", + }, + { + name: "operation get", + command: deployOperationGet, + flags: { deployedModel: MODEL_CODE, operationId: OPERATION_ID }, + action: "deploy.operation.get", + }, + { + name: "operation wait", + command: deployOperationWait, + flags: { deployedModel: MODEL_CODE, operationId: OPERATION_ID }, + action: "deploy.operation.wait", + }, +]; + +const mockClients: ReturnType[] = []; +let stdout = ""; + +function createMockClient(response: unknown = {}): { + client: Client; + requestJson: Mock; + request: Mock; +} { + const requestJson = vi.fn().mockResolvedValue(response); + const request = vi.fn().mockRejectedValue(new Error("Unexpected raw request")); + const result = { client: { requestJson, request } as unknown as Client, requestJson, request }; + mockClients.push(result); + return result; +} + +function runCommand( + command: AnyCommand, + flags: Record, + client: Client, + dryRun = false, +) { + return command.run({ + identity: { + binName: "bl", + version: "test", + npmPackage: "bailian-cli", + clientName: "bailian-cli", + }, + settings: { ...SETTINGS, dryRun }, + flags, + client, + } as never); +} + +function wait( + client: Client, + options: { interval?: number; pollTimeout?: number; signal?: AbortSignal } = {}, +) { + return waitForCapacityOperation(client, MODEL_CODE, OPERATION_ID, { + interval: 2, + pollTimeout: 600, + ...options, + }); +} + +/** Simulate a transport that remains pending until its supplied signal aborts. */ +function pendingRequestUntilAbort(request: Parameters[0]): Promise { + return new Promise((_resolve, reject) => { + const signal = request.signal; + if (!signal) { + reject(new Error("Expected an abortable request")); + return; + } + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); +} + +function requestPaths(mock: ReturnType): string[] { + return mock.requestJson.mock.calls.map(([request]) => request.path); +} + +beforeEach(() => { + stdout = ""; + mockClients.length = 0; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("Real network is forbidden in deploy query tests")), + ); +}); + +afterEach(() => { + try { + // Enforce the read-only contract for every command, poll, refresh and error path. + for (const mock of mockClients) { + expect(mock.request).not.toHaveBeenCalled(); + for (const [request] of mock.requestJson.mock.calls) { + expect(request.method).toBe("GET"); + expect(request).not.toHaveProperty("body"); + } + } + expect(fetch).not.toHaveBeenCalled(); + if (vi.isFakeTimers()) expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + } +}); + +describe("deploy query dry-run", () => { + test.each(QUERY_COMMANDS)( + "$name emits its plan without any request or SIGINT listener", + async ({ command, flags, action }) => { + const mock = createMockClient(); + const listeners = process.listeners("SIGINT"); + await runCommand(command, flags, mock.client, true); + expect(JSON.parse(stdout)).toMatchObject({ action }); + expect(mock.requestJson).not.toHaveBeenCalled(); + expect(process.listeners("SIGINT")).toEqual(listeners); + }, + ); + + test("list reports server plan and page-local status filtering in dry-run", async () => { + const mock = createMockClient(); + await runCommand( + deployList, + { page: 3, pageSize: 25, plan: " ptu ", status: " RUNNING " }, + mock.client, + true, + ); + expect(JSON.parse(stdout)).toEqual({ + action: "deploy.list", + page: 3, + page_size: 25, + plan: "ptu", + status: "RUNNING", + filter_scope: "page", + }); + expect(mock.requestJson).not.toHaveBeenCalled(); + }); + + test.each([ + { flags: {}, interval: 2, pollTimeout: 600 }, + { flags: { interval: 3.5, pollTimeout: 9 }, interval: 3.5, pollTimeout: 9 }, + ])( + "wait dry-run preserves interval=$interval and pollTimeout=$pollTimeout", + async ({ flags, interval, pollTimeout }) => { + const mock = createMockClient(); + await runCommand( + deployOperationWait, + { deployedModel: MODEL_CODE, operationId: OPERATION_ID, ...flags }, + mock.client, + true, + ); + expect(JSON.parse(stdout)).toEqual({ + action: "deploy.operation.wait", + deployed_model: MODEL_CODE, + operation_id: OPERATION_ID, + interval, + poll_timeout: pollTimeout, + }); + expect(mock.requestJson).not.toHaveBeenCalled(); + }, + ); +}); + +describe.each(["output", "data"] as const)("deploy query %s envelope", (envelope) => { + test("list sends plan but filters status only on the requested page, preserving the server total", async () => { + const matching = { + deployed_model: MODEL_CODE, + status: "RUNNING", + plan: "ptu", + capacity: 0, + gmt_create: "created", + ptu_capacity: { input_tpm: 0, output_tpm: 12 }, + future_flag: false, + }; + const mock = createMockClient({ + request_id: "list-request", + [envelope]: { + deployments: [matching, { deployed_model: "stopped", status: "STOPPED" }], + total: 57, + page_no: 3, + page_size: 2, + }, + }); + await runCommand( + deployList, + { page: 3, pageSize: 2, plan: " ptu ", status: " RUNNING " }, + mock.client, + ); + expect(requestPaths(mock)).toEqual(["/api/v1/deployments?page_no=3&page_size=2&plan=ptu"]); + expect(JSON.parse(stdout)).toEqual({ + items: [{ ...matching, capacity: "0", model_name: "", created_at: "created" }], + total: 57, + page_no: 3, + page_size: 2, + request_id: "list-request", + local_filter: { + status: "RUNNING", + scope: "page", + matched_count: 1, + unfiltered_page_count: 2, + }, + }); + }); + + test("an empty filtered page does not fetch subsequent pages or change total", async () => { + const mock = createMockClient({ + [envelope]: { deployments: [{ status: "STOPPED" }], total: 100 }, + }); + await runCommand(deployList, { status: "RUNNING" }, mock.client); + expect(requestPaths(mock)).toEqual(["/api/v1/deployments?page_no=1&page_size=10"]); + expect(JSON.parse(stdout)).toEqual({ + items: [], + total: 100, + page_no: 1, + page_size: 10, + local_filter: { + status: "RUNNING", + scope: "page", + matched_count: 0, + unfiltered_page_count: 1, + }, + }); + }); + + test("list retains legacy string capacity and stringifies numbers without adding a local filter", async () => { + const mock = createMockClient({ + [envelope]: { + deployments: [{ capacity: "8" }, { capacity: 4 }, { capacity: 0 }, {}], + total: 4, + }, + }); + await runCommand(deployList, {}, mock.client); + const result = JSON.parse(stdout); + expect(result.items.map((item: { capacity: string }) => item.capacity)).toEqual([ + "8", + "4", + "0", + "", + ]); + expect(result).not.toHaveProperty("local_filter"); + expect(result.total).toBe(4); + expect(requestPaths(mock)).toEqual(["/api/v1/deployments?page_no=1&page_size=10"]); + }); + + test("get keeps unknown fields, zeros, false values and nested PTU/prepaid fields", async () => { + const deployment = { + deployed_model: MODEL_CODE, + name: "reservation", + model_name: "base-model", + base_model: "base", + status: "RUNNING", + plan: "ptu", + capacity: 0, + base_capacity: 0, + ready_capacity: 0, + rpm_limit: 0, + tpm_limit: 0, + input_tpm: 0, + output_tpm: 0, + ptu_capacity: { input_tpm: 0, output_tpm: 0, future_capacity: 0 }, + ptu_service_tier: "FUTURE_TIER", + overflow_strategy: "FUTURE_STRATEGY", + pre_paid_info: { auto_renewal: false, auto_renewal_duration: 0, future: { enabled: false } }, + future_field: { enabled: false, quota: 0 }, + can_scale: false, + gmt_create: "created", + gmt_modified: "modified", + }; + const mock = createMockClient({ [envelope]: deployment, request_id: "get-request" }); + await runCommand(deployGet, { deployedModel: MODEL_CODE }, mock.client); + expect(JSON.parse(stdout)).toEqual({ + ...deployment, + deployed_name: "reservation", + created_at: "created", + updated_at: "modified", + request_id: "get-request", + }); + expect(requestPaths(mock)).toEqual([DEPLOYMENT_PATH]); + }); + + test("capacity list preserves native records/items/page/itemsPerPage/pageCount", async () => { + const response = { + request_id: "capacity-list-request", + future_envelope: false, + [envelope]: { + records: [ + { + instance_id: INSTANCE_ID, + status: "STOPPED", + deleted: false, + effective_capacity: { input_tpm: 0 }, + target_capacity: null, + }, + ], + items: 27, + page: 2, + itemsPerPage: 20, + pageCount: 2, + future_page: 0, + }, + }; + const mock = createMockClient(response); + await runCommand(deployCapacityList, { deployedModel: MODEL_CODE, page: 2 }, mock.client); + expect(JSON.parse(stdout)).toEqual(response); + expect(requestPaths(mock)).toEqual([ + `${DEPLOYMENT_PATH}/capacity-instances?page_no=2&page_size=20&include_deleted=true`, + ]); + }); + + test("capacity get preserves the entire envelope, including released instances and false capabilities", async () => { + const response = { + request_id: "instance-request", + future_flag: false, + [envelope]: { + instance_id: INSTANCE_ID, + status: "STOPPED", + deleted: true, + can_scale: false, + can_renew: false, + can_delete: false, + effective_capacity: { input_tpm: 0, output_tpm: 0 }, + configured_capacity: { input_tpm: 10 }, + target_capacity: null, + pre_paid_info: { auto_renewal: false, duration: 0 }, + future_field: 0, + }, + }; + const mock = createMockClient(response); + await runCommand( + deployCapacityGet, + { deployedModel: MODEL_CODE, instanceId: INSTANCE_ID }, + mock.client, + ); + expect(JSON.parse(stdout)).toEqual(response); + expect(requestPaths(mock)).toEqual([INSTANCE_PATH]); + }); + + test("operation get returns FAILED as data without throwing or refreshing", async () => { + const response = { + request_id: "query-request", + future_field: 0, + [envelope]: { + operation_id: OPERATION_ID, + operation_status: "FAILED", + instance_id: INSTANCE_ID, + request_id: "original-write-request", + error_code: "Original.Code", + error_message: "服务端原始错误 / original message", + future_flag: false, + }, + }; + const mock = createMockClient(response); + await runCommand( + deployOperationGet, + { deployedModel: MODEL_CODE, operationId: OPERATION_ID }, + mock.client, + ); + expect(JSON.parse(stdout)).toEqual(response); + expect(requestPaths(mock)).toEqual([OPERATION_PATH]); + }); +}); + +describe("deploy query defaults and filters", () => { + test.each([ + { name: "omitted", flags: {}, expected: true }, + { name: "false", flags: { includeDeleted: false }, expected: false }, + { name: "true", flags: { includeDeleted: true }, expected: true }, + ])( + "capacity list includeDeleted=$name and CSV filters map to query parameters", + async ({ flags, expected }) => { + const mock = createMockClient(); + await runCommand( + deployCapacityList, + { + deployedModel: MODEL_CODE, + page: 4, + pageSize: 7, + statuses: " RUNNING, STOPPED ,FUTURE_STATE ", + chargeTypes: " pre_paid, post_paid ", + ...flags, + }, + mock.client, + ); + expect(requestPaths(mock)).toEqual([ + `${DEPLOYMENT_PATH}/capacity-instances?page_no=4&page_size=7&include_deleted=${expected}&statuses=RUNNING%2CSTOPPED%2CFUTURE_STATE&charge_types=pre_paid%2Cpost_paid`, + ]); + }, + ); + + test("capacity list preserves empty pages and zero totals", async () => { + const response = { output: { records: [], items: 0, page: 1, itemsPerPage: 20, pageCount: 0 } }; + const mock = createMockClient(response); + await runCommand(deployCapacityList, { deployedModel: MODEL_CODE }, mock.client); + expect(JSON.parse(stdout)).toEqual(response); + expect(requestPaths(mock)).toEqual([ + `${DEPLOYMENT_PATH}/capacity-instances?page_no=1&page_size=20&include_deleted=true`, + ]); + }); + + test("get with no payload still reports the queried identifier and request ID", async () => { + const mock = createMockClient({ request_id: "empty-request" }); + await runCommand(deployGet, { deployedModel: MODEL_CODE }, mock.client); + expect(JSON.parse(stdout)).toEqual({ deployed_model: MODEL_CODE, request_id: "empty-request" }); + }); +}); + +describe("deploy query validation", () => { + test.each(QUERY_COMMANDS)( + "$name accepts valid required identifiers and omitted optional flags", + async ({ command, flags }) => { + expect(await command.validate?.(flags)).toBeUndefined(); + }, + ); + + const idCases = QUERY_COMMANDS.flatMap(({ name, command, flags }) => + Object.keys(flags).map((field) => ({ name, command, flags, field })), + ); + test.each(idCases)( + "$name rejects empty or whitespace-only $field", + async ({ command, flags, field }) => { + for (const value of ["", " \t\n "]) { + expect(await command.validate?.({ ...flags, [field]: value })).toMatch( + /Identifiers must not be empty/, + ); + } + }, + ); + + describe.each(QUERY_COMMANDS.filter(({ name }) => name === "list" || name === "capacity list"))( + "$name pagination", + ({ command, flags }) => { + test.each([0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( + "rejects page=%s", + async (page) => { + expect(await command.validate?.({ ...flags, page })).toMatch(/--page must/); + }, + ); + test.each([0, -1, 1.5, 101, NaN, Infinity])("rejects pageSize=%s", async (pageSize) => { + expect(await command.validate?.({ ...flags, pageSize })).toMatch(/--page-size must/); + }); + test.each([1, 100])("accepts pageSize boundary=%s", async (pageSize) => { + expect(await command.validate?.({ ...flags, page: 1, pageSize })).toBeUndefined(); + }); + }, + ); + + test.each(["plan", "status"])("list rejects blank %s", async (field) => { + expect(await deployList.validate?.({ [field]: " \t " })).toMatch(/Filters must not be empty/); + }); + + test.each(["statuses", "chargeTypes"])( + "capacity list rejects empty CSV components in %s", + async (field) => { + for (const value of ["", " ", ",", "RUNNING,", ",RUNNING", "RUNNING, ,STOPPED"]) { + expect( + await deployCapacityList.validate?.({ deployedModel: MODEL_CODE, [field]: value }), + ).toMatch(/must not contain empty values/); + } + }, + ); + + test.each(["POST_PAY", "PRE_PAID", "unknown", "pre_paid,unknown"])( + "capacity list rejects chargeTypes=%s", + async (chargeTypes) => { + expect( + await deployCapacityList.validate?.({ deployedModel: MODEL_CODE, chargeTypes }), + ).toMatch(/--charge-types accepts/); + }, + ); + + test("capacity list accepts trimmed CSVs, future statuses and explicit false", async () => { + expect( + await deployCapacityList.validate?.({ + deployedModel: MODEL_CODE, + includeDeleted: false, + statuses: "RUNNING, FUTURE_STATE", + chargeTypes: " pre_paid, post_paid ", + }), + ).toBeUndefined(); + }); + + test.each([0, -1, 0.5, 3601, NaN, Infinity, -Infinity])( + "wait rejects interval=%s", + async (interval) => { + expect( + await deployOperationWait.validate?.({ + deployedModel: MODEL_CODE, + operationId: OPERATION_ID, + interval, + }), + ).toMatch(/--interval must/); + }, + ); + test.each([0, -1, 2_147_484, NaN, Infinity, -Infinity])( + "wait rejects pollTimeout=%s", + async (pollTimeout) => { + expect( + await deployOperationWait.validate?.({ + deployedModel: MODEL_CODE, + operationId: OPERATION_ID, + pollTimeout, + }), + ).toMatch(/--poll-timeout must/); + }, + ); + test.each([ + { interval: 1, pollTimeout: 0.5 }, + { interval: 1.5, pollTimeout: 600 }, + { interval: 3600, pollTimeout: 2_147_483 }, + ])("wait accepts interval=$interval and pollTimeout=$pollTimeout", async (flags) => { + expect( + await deployOperationWait.validate?.({ + deployedModel: MODEL_CODE, + operationId: OPERATION_ID, + ...flags, + }), + ).toBeUndefined(); + }); +}); + +describe("waitForCapacityOperation read-only polling", () => { + beforeEach(() => vi.useFakeTimers()); + + test.each(["output", "data"] as const)( + "backs off PROCESSING, then preserves %s and refreshes instance followed by deployment", + async (envelope) => { + const mock = createMockClient(); + const response = { + request_id: "poll-request", + future_flag: false, + [envelope]: { + operation_status: "SUCCEEDED", + instance_id: INSTANCE_ID, + operation_id: OPERATION_ID, + }, + }; + const instance = { + data: { instance_id: INSTANCE_ID, effective_capacity: { input_tpm: 0 }, can_scale: false }, + request_id: "instance-request", + }; + const deployment = { + output: { deployed_model: MODEL_CODE, ptu_capacity: { input_tpm: 0, output_tpm: 10 } }, + request_id: "deployment-request", + }; + mock.requestJson + .mockResolvedValueOnce({ [envelope]: { operation_status: "PROCESSING" } }) + .mockResolvedValueOnce({ [envelope]: { operation_status: "PROCESSING" } }) + .mockResolvedValueOnce(response) + .mockResolvedValueOnce(instance) + .mockResolvedValueOnce(deployment); + const result = wait(mock.client); + await vi.advanceTimersByTimeAsync(0); + expect(mock.requestJson).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1999); + expect(mock.requestJson).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(mock.requestJson).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(3999); + expect(mock.requestJson).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + const value = await result; + expect(value).toEqual({ ...response, instance, deployment }); + expect(value[envelope]).toBe(response[envelope]); + expect(value.instance).toBe(instance); + expect(value.deployment).toBe(deployment); + expect(requestPaths(mock)).toEqual([ + OPERATION_PATH, + OPERATION_PATH, + OPERATION_PATH, + INSTANCE_PATH, + DEPLOYMENT_PATH, + ]); + const signal = mock.requestJson.mock.calls[0][0].signal; + expect(signal).toBeInstanceOf(AbortSignal); + for (const [request] of mock.requestJson.mock.calls) expect(request.signal).toBe(signal); + expect(signal?.aborted).toBe(false); + }, + ); + + test.each([ + { interval: 2, delays: [2000, 4000, 8000, 16000, 30000, 30000] }, + { interval: 40, delays: [40000, 40000, 40000] }, + ])( + "caps backoff at max(initial, 30 seconds) for interval=$interval", + async ({ interval, delays }) => { + const mock = createMockClient({ output: { operation_status: "PROCESSING" } }); + delays.forEach(() => { + mock.requestJson.mockResolvedValueOnce({ output: { operation_status: "PROCESSING" } }); + }); + mock.requestJson + .mockResolvedValueOnce({ output: { operation_status: "SUCCEEDED" } }) + .mockResolvedValueOnce({ output: { deployed_model: MODEL_CODE } }); + const result = wait(mock.client, { interval }); + await vi.advanceTimersByTimeAsync(0); + let pollCount = 1; + for (const delay of delays) { + await vi.advanceTimersByTimeAsync(delay - 1); + expect(mock.requestJson).toHaveBeenCalledTimes(pollCount); + await vi.advanceTimersByTimeAsync(1); + pollCount += 1; + } + await result; + expect(requestPaths(mock)).toEqual([ + ...Array(delays.length + 1).fill(OPERATION_PATH), + DEPLOYMENT_PATH, + ]); + }, + ); + + test.each([undefined, ""])( + "SUCCEEDED without instance_id=%s refreshes only the deployment", + async (instanceId) => { + const response = { data: { operation_status: "SUCCEEDED", instance_id: instanceId } }; + const deployment = { data: { deployed_model: MODEL_CODE } }; + const mock = createMockClient(); + mock.requestJson.mockResolvedValueOnce(response).mockResolvedValueOnce(deployment); + expect(await wait(mock.client)).toEqual({ ...response, instance: undefined, deployment }); + expect(requestPaths(mock)).toEqual([OPERATION_PATH, DEPLOYMENT_PATH]); + }, + ); + + test.each(["output", "data"] as const)( + "FAILED in %s keeps original message/code/request ID/cause and never refreshes", + async (envelope) => { + const operation = { + operation_id: OPERATION_ID, + instance_id: INSTANCE_ID, + operation_status: "FAILED", + error_message: "原始错误\nDo not translate", + error_code: "Vendor.OriginalCode", + request_id: "write-request", + future_flag: false, + }; + const response = { request_id: "poll-request", [envelope]: operation }; + const mock = createMockClient(response); + const result = wait(mock.client); + await expect(result).rejects.toMatchObject({ + message: operation.error_message, + exitCode: ExitCode.GENERAL, + api: { httpStatus: 200, apiCode: operation.error_code, requestId: "poll-request" }, + rawResponse: JSON.stringify(response), + }); + await expect(result).rejects.toHaveProperty("cause", operation); + expect(await result.catch((error: BailianError) => error.cause)).toBe(operation); + expect(requestPaths(mock)).toEqual([OPERATION_PATH]); + }, + ); + + test.each([ + { output: { operation_status: "UNKNOWN" } }, + { data: { operation_status: "RUNNING" } }, + { output: { operation_status: "succeeded" } }, + { output: { operation_status: "" } }, + { output: {} }, + { data: {} }, + {}, + ])("rejects unknown or missing status: %j", async (response) => { + const mock = createMockClient(response); + const result = wait(mock.client); + await expect(result).rejects.toMatchObject({ + message: expect.stringContaining("Missing or unsupported operation_status"), + exitCode: ExitCode.GENERAL, + cause: response, + rawResponse: JSON.stringify(response), + }); + expect(requestPaths(mock)).toEqual([OPERATION_PATH]); + }); + + test("total timeout interrupts sleep, retains last response and clears both timers", async () => { + const response = { output: { operation_status: "PROCESSING" }, request_id: "last-request" }; + const mock = createMockClient(response); + const result = wait(mock.client, { interval: 2, pollTimeout: 3 }); + const rejection = expect(result).rejects.toMatchObject({ + exitCode: ExitCode.TIMEOUT, + cause: { deployed_model: MODEL_CODE, operation_id: OPERATION_ID, last_response: response }, + }); + await vi.advanceTimersByTimeAsync(2000); + expect(mock.requestJson).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(2); + await vi.advanceTimersByTimeAsync(999); + expect(mock.requestJson.mock.calls[0][0].signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await rejection; + expect(mock.requestJson.mock.calls[0][0].signal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(requestPaths(mock)).toEqual([OPERATION_PATH, OPERATION_PATH]); + }); + + test.each(["poll", "instance", "deployment"] as const)( + "total timeout aborts an in-flight %s request", + async (phase) => { + const mock = createMockClient(); + if (phase !== "poll") + mock.requestJson.mockResolvedValueOnce({ + output: { operation_status: "SUCCEEDED", instance_id: INSTANCE_ID }, + }); + if (phase === "deployment") + mock.requestJson.mockResolvedValueOnce({ output: { instance_id: INSTANCE_ID } }); + mock.requestJson.mockImplementation(pendingRequestUntilAbort); + const result = wait(mock.client, { pollTimeout: 3 }); + const rejection = expect(result).rejects.toMatchObject({ exitCode: ExitCode.TIMEOUT }); + await vi.advanceTimersByTimeAsync(0); + const request = mock.requestJson.mock.calls.at(-1)![0]; + expect(request.signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(3000); + await rejection; + expect(request.signal?.aborted).toBe(true); + expect(await result.catch((error: unknown) => error)).toBe(request.signal?.reason); + expect(requestPaths(mock)).toEqual( + phase === "poll" + ? [OPERATION_PATH] + : phase === "instance" + ? [OPERATION_PATH, INSTANCE_PATH] + : [OPERATION_PATH, INSTANCE_PATH, DEPLOYMENT_PATH], + ); + }, + ); + + test("request time consumes the same total budget as subsequent sleeps", async () => { + const mock = createMockClient(); + let resolvePoll!: (response: unknown) => void; + mock.requestJson.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePoll = resolve; + }), + ); + const result = wait(mock.client, { interval: 2, pollTimeout: 3 }); + const rejection = expect(result).rejects.toMatchObject({ exitCode: ExitCode.TIMEOUT }); + await vi.advanceTimersByTimeAsync(2500); + resolvePoll({ output: { operation_status: "PROCESSING" } }); + await vi.advanceTimersByTimeAsync(499); + expect(mock.requestJson.mock.calls[0][0].signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await rejection; + expect(requestPaths(mock)).toEqual([OPERATION_PATH]); + }); + + test.each(["already aborted", "poll request", "sleep"] as const)( + "parent signal interrupts during %s and detaches its listener", + async (phase) => { + const controller = new AbortController(); + const reason = new Error("caller cancelled without cancelling the remote operation"); + const addListener = vi.spyOn(controller.signal, "addEventListener"); + const removeListener = vi.spyOn(controller.signal, "removeEventListener"); + const mock = createMockClient({ output: { operation_status: "PROCESSING" } }); + if (phase === "already aborted") controller.abort(reason); + if (phase === "poll request") mock.requestJson.mockImplementation(pendingRequestUntilAbort); + const result = wait(mock.client, { signal: controller.signal }); + const rejection = expect(result).rejects.toBe(reason); + if (phase !== "already aborted") { + await vi.advanceTimersByTimeAsync(0); + expect(mock.requestJson).toHaveBeenCalledTimes(1); + if (phase === "sleep") expect(vi.getTimerCount()).toBe(2); + controller.abort(reason); + } + await rejection; + expect(removeListener).toHaveBeenCalledExactlyOnceWith("abort", addListener.mock.calls[0][1]); + expect(vi.getTimerCount()).toBe(0); + if (phase === "already aborted") expect(mock.requestJson).not.toHaveBeenCalled(); + else expect(mock.requestJson.mock.calls[0][0].signal?.reason).toBe(reason); + await vi.advanceTimersByTimeAsync(600_000); + expect(mock.requestJson).toHaveBeenCalledTimes(phase === "already aborted" ? 0 : 1); + }, + ); + + test("successful wait detaches the parent signal and deadline without later aborting requests", async () => { + const controller = new AbortController(); + const addListener = vi.spyOn(controller.signal, "addEventListener"); + const removeListener = vi.spyOn(controller.signal, "removeEventListener"); + const mock = createMockClient(); + mock.requestJson + .mockResolvedValueOnce({ data: { operation_status: "SUCCEEDED" } }) + .mockResolvedValueOnce({ output: {} }); + await wait(mock.client, { signal: controller.signal }); + expect(removeListener).toHaveBeenCalledExactlyOnceWith("abort", addListener.mock.calls[0][1]); + controller.abort(new Error("late cancellation")); + await vi.advanceTimersByTimeAsync(600_000); + expect(mock.requestJson.mock.calls[0][0].signal?.aborted).toBe(false); + }); + + test.each(["poll", "instance", "deployment"] as const)( + "passes an HTTP failure during %s through unchanged without additional requests", + async (phase) => { + const failure = new BailianError( + "Server original message", + ExitCode.GENERAL, + "Original hint", + { + api: { httpStatus: 503, apiCode: "Service.Unavailable", requestId: "http-request" }, + rawResponse: "original raw body", + cause: new Error("original cause"), + }, + ); + const mock = createMockClient(); + if (phase !== "poll") + mock.requestJson.mockResolvedValueOnce({ + data: { operation_status: "SUCCEEDED", instance_id: INSTANCE_ID }, + }); + if (phase === "deployment") + mock.requestJson.mockResolvedValueOnce({ data: { instance_id: INSTANCE_ID } }); + mock.requestJson.mockRejectedValue(failure); + await expect(wait(mock.client)).rejects.toBe(failure); + expect(requestPaths(mock)).toEqual( + phase === "poll" + ? [OPERATION_PATH] + : phase === "instance" + ? [OPERATION_PATH, INSTANCE_PATH] + : [OPERATION_PATH, INSTANCE_PATH, DEPLOYMENT_PATH], + ); + }, + ); +}); + +describe("deploy operation wait command lifecycle", () => { + beforeEach(() => vi.useFakeTimers()); + const flags = { + deployedModel: MODEL_CODE, + operationId: OPERATION_ID, + interval: 2, + pollTimeout: 3, + }; + + test("success emits the operation and refreshed envelopes and removes its SIGINT listener", async () => { + const listeners = process.listeners("SIGINT"); + const response = { + output: { operation_status: "SUCCEEDED", instance_id: INSTANCE_ID }, + request_id: "poll-request", + }; + const instance = { output: { instance_id: INSTANCE_ID } }; + const deployment = { data: { deployed_model: MODEL_CODE } }; + const mock = createMockClient(); + mock.requestJson + .mockResolvedValueOnce(response) + .mockResolvedValueOnce(instance) + .mockResolvedValueOnce(deployment); + await runCommand(deployOperationWait, flags, mock.client); + expect(JSON.parse(stdout)).toEqual({ ...response, instance, deployment }); + expect(process.listeners("SIGINT")).toEqual(listeners); + }); + + test.each(["poll request", "sleep"] as const)( + "SIGINT during %s aborts local waiting, clears timers/listener and emits no success", + async (phase) => { + const listeners = process.listeners("SIGINT"); + const once = vi.spyOn(process, "once"); + const mock = createMockClient({ output: { operation_status: "PROCESSING" } }); + if (phase === "poll request") mock.requestJson.mockImplementation(pendingRequestUntilAbort); + const result = runCommand(deployOperationWait, flags, mock.client); + await vi.advanceTimersByTimeAsync(0); + expect(process.listeners("SIGINT")).toHaveLength(listeners.length + 1); + const handler = once.mock.calls.find(([event]) => event === "SIGINT")?.[1]; + expect(handler).toBeTypeOf("function"); + // Invoke only the command's handler: never signal Vitest or unrelated runtime handlers. + handler!(); + await result; + expect(mock.requestJson.mock.calls[0][0].signal?.aborted).toBe(true); + expect(stdout).toBe(""); + expect(process.listeners("SIGINT")).toEqual(listeners); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(requestPaths(mock)).toEqual([OPERATION_PATH]); + }, + ); + + test.each(["FAILED", "unknown", "HTTP", "timeout"] as const)( + "%s emits no success and removes the SIGINT listener", + async (scenario) => { + const listeners = process.listeners("SIGINT"); + const failure = new BailianError("HTTP original error", ExitCode.GENERAL); + const mock = createMockClient({ + output: { + operation_status: scenario === "timeout" ? "PROCESSING" : scenario, + error_message: "Operation original error", + }, + }); + if (scenario === "HTTP") mock.requestJson.mockRejectedValue(failure); + const result = runCommand(deployOperationWait, flags, mock.client); + const rejection = + scenario === "HTTP" + ? expect(result).rejects.toBe(failure) + : expect(result).rejects.toMatchObject({ + exitCode: scenario === "timeout" ? ExitCode.TIMEOUT : ExitCode.GENERAL, + }); + if (scenario === "timeout") await vi.advanceTimersByTimeAsync(3000); + await rejection; + expect(stdout).toBe(""); + expect(process.listeners("SIGINT")).toEqual(listeners); + }, + ); +}); diff --git a/packages/commands/tests/deploy-wait-http.test.ts b/packages/commands/tests/deploy-wait-http.test.ts new file mode 100644 index 00000000..adf2550f --- /dev/null +++ b/packages/commands/tests/deploy-wait-http.test.ts @@ -0,0 +1,398 @@ +import { spawn } from "child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { createServer, type RequestListener, type ServerResponse } from "node:http"; +import type { Socket } from "node:net"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vite-plus/test"; + +const WORKSPACE_ROOT = fileURLToPath(new URL("../../../", import.meta.url)); +const CLI_ENTRY = fileURLToPath(new URL("../../cli/src/main.ts", import.meta.url)); +const CONFIG_DIRECTORY = fileURLToPath( + new URL(`./.deploy-wait-no-user-config-${randomUUID()}`, import.meta.url), +); + +// Keep real loopback fetch, but block background npm/telemetry requests before +// they reach the network. Disallow redirects so local responses cannot escape. +const LOCAL_FETCH_ONLY = ` +const realFetch = globalThis.fetch; +globalThis.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1") { + throw new Error("Remote fetch blocked for deploy HTTP regression"); + } + return realFetch(input, { ...init, redirect: "error" }); +}; +`; + +// Do not inherit credentials, preload hooks, or dotenv settings. This directory +// must stay nonexistent: no config, fixtures, or cache files need to be created. +const READ_ONLY_ENV: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + HOME: CONFIG_DIRECTORY, + USERPROFILE: CONFIG_DIRECTORY, + XDG_CONFIG_HOME: CONFIG_DIRECTORY, + BAILIAN_CONFIG_DIR: CONFIG_DIRECTORY, + BAILIAN_E2E: "0", + DO_NOT_TRACK: "1", + TSX_DISABLE_CACHE: "1", + NODE_DISABLE_COMPILE_CACHE: "1", + NODE_OPTIONS: `--import=data:text/javascript,${encodeURIComponent(LOCAL_FETCH_ONLY)}`, + NODE_USE_ENV_PROXY: "0", + HTTP_PROXY: "", + http_proxy: "", + HTTPS_PROXY: "", + https_proxy: "", + ALL_PROXY: "", + all_proxy: "", + NO_PROXY: "", + no_proxy: "", +}; + +const DEPLOYED_MODEL = "dep/local model"; +const OPERATION_ID = "000900719925474099312345"; +const INSTANCE_ID = "instance/local"; +// Literal expectations intentionally do not use production endpoint helpers. +const DEPLOYMENT_PATH = "/api/v1/deployments/dep%2Flocal%20model"; +const OPERATION_PATH = `${DEPLOYMENT_PATH}/capacity-operations/${OPERATION_ID}`; +const INSTANCE_PATH = `${DEPLOYMENT_PATH}/capacity-instances/instance%2Flocal`; +const SERVER_WATCHDOG_MS = 15_000; +const PROCESS_WATCHDOG_MS = 20_000; +const TEST_TIMEOUT_MS = 25_000; + +interface ObservedRequest { + method: string | undefined; + url: string | undefined; + body: string; +} + +interface LocalServer { + url: string; + requests: ObservedRequest[]; + firstRequestAt: number | undefined; +} + +async function withServer( + handler: RequestListener, + run: (local: LocalServer) => Promise, +): Promise { + const sockets = new Set(); + const local: LocalServer = { url: "", requests: [], firstRequestAt: undefined }; + const server = createServer((incoming, response) => { + local.firstRequestAt ??= performance.now(); + const request: ObservedRequest = { method: incoming.method, url: incoming.url, body: "" }; + local.requests.push(request); + incoming.setEncoding("utf8"); + incoming.on("data", (chunk: string) => { + request.body += chunk; + }); + incoming.once("end", () => { + if (request.method !== "GET" || request.body !== "") { + response.writeHead(405).end(); + return; + } + handler(incoming, response); + }); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + let watchdogFired = false; + const watchdog = setTimeout(() => { + watchdogFired = true; + for (const socket of sockets) socket.destroy(); + }, SERVER_WATCHDOG_MS); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a local TCP server"); + local.url = `http://127.0.0.1:${address.port}`; + await run(local); + expect(watchdogFired, "The CLI must exit without the socket watchdog").toBe(false); + } finally { + clearTimeout(watchdog); + const closed = server.listening + ? new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }) + : Promise.resolve(); + for (const socket of sockets) socket.destroy(); + await closed; + } +} + +async function runCli(url: string, args: string[], interruptAfter?: Promise) { + expect(existsSync(CONFIG_DIRECTORY)).toBe(false); + const child = spawn( + process.execPath, + [ + "--import", + "tsx", + CLI_ENTRY, + "deploy", + "operation", + ...args, + "--deployed-model", + DEPLOYED_MODEL, + "--operation-id", + OPERATION_ID, + "--api-key", + "local-dummy-api-key", + "--base-url", + url, + "--output", + "json", + "--quiet", + ], + { cwd: WORKSPACE_ROOT, env: READ_ONLY_ENV, stdio: ["ignore", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + let didClose = false; + let spawnError: Error | undefined; + let interruptSent = false; + let watchdogFired = false; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk; + }); + const closed = new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>( + (resolve) => { + child.once("error", (error: Error) => { + spawnError = error; + }); + child.once("close", (exitCode: number | null, signal: NodeJS.Signals | null) => { + didClose = true; + resolve({ exitCode, signal }); + }); + }, + ); + const watchdog = setTimeout(() => { + watchdogFired = true; + child.kill("SIGKILL"); + }, PROCESS_WATCHDOG_MS); + try { + // The signal is triggered by the server's first PROCESSING response, never + // by a startup delay or a signal broadcast to the test runner/process group. + void interruptAfter?.then(() => { + if (!didClose) interruptSent = child.kill("SIGINT"); + }); + const result = await closed; + if (spawnError) throw spawnError; + expect(watchdogFired, `Child watchdog fired. stderr: ${stderr}`).toBe(false); + return { ...result, stdout, stderr, interruptSent, closedAt: performance.now() }; + } finally { + clearTimeout(watchdog); + if (!didClose) { + child.kill("SIGKILL"); + await closed; + } + expect(existsSync(CONFIG_DIRECTORY), "The CLI must not create config or cache files").toBe( + false, + ); + } +} + +function sendJson(response: ServerResponse, body: unknown): void { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); +} + +function expectGets(local: LocalServer, paths: string[]): void { + expect(local.requests).toEqual(paths.map((url) => ({ method: "GET", url, body: "" }))); +} + +const FAILED_RESPONSE = { + request_id: "local-failed-query-request", + output: { + operation_id: OPERATION_ID, + request_id: "original-write-request", + operation_status: "FAILED", + instance_id: INSTANCE_ID, + error_code: "CapacityRejected.Original", + error_message: "服务端原文 / original service message: capacity=0, retry=false\n原样保留。", + }, +}; + +// No live-E2E gating or shared E2E setup: every request goes to our own server. +describe("deploy operation real CLI over loopback HTTP", () => { + test( + "wait refreshes operation → instance → deployment, preserving envelopes and 0/false", + async () => { + const operation = { + request_id: "local-operation-request", + output: { + operation_id: OPERATION_ID, + operation_status: "SUCCEEDED", + instance_id: INSTANCE_ID, + }, + }; + const instance = { + request_id: "local-instance-request", + data: { + instance_id: INSTANCE_ID, + model_service_id: DEPLOYED_MODEL, + effective_capacity: { input_tpm: 0, output_tpm: 0 }, + configured_capacity: { input_tpm: 10, output_tpm: 20 }, + target_capacity: null, + deleted: false, + can_scale: false, + pre_paid_info: { duration: 0, auto_renewal: false }, + }, + }; + const deployment = { + request_id: "local-deployment-request", + output: { + deployed_model: DEPLOYED_MODEL, + ptu_capacity: { input_tpm: 0, output_tpm: 0 }, + ready_capacity: 0, + enable_thinking: false, + }, + }; + const responses: Record = { + [OPERATION_PATH]: operation, + [INSTANCE_PATH]: instance, + [DEPLOYMENT_PATH]: deployment, + }; + await withServer( + (incoming, response) => sendJson(response, responses[incoming.url ?? ""] ?? {}), + async (local) => { + const result = await runCli(local.url, ["wait", "--timeout", "5", "--poll-timeout", "5"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ ...operation, instance, deployment }); + expectGets(local, [OPERATION_PATH, INSTANCE_PATH, DEPLOYMENT_PATH]); + }, + ); + }, + TEST_TIMEOUT_MS, + ); + + test( + "wait exits 1 on FAILED, preserving the server error without refreshing", + async () => { + await withServer( + (_incoming, response) => sendJson(response, FAILED_RESPONSE), + async (local) => { + const result = await runCli(local.url, ["wait", "--timeout", "5", "--poll-timeout", "5"]); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.signal).toBeNull(); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { + code: 1, + message: FAILED_RESPONSE.output.error_message, + http_status: 200, + api_code: FAILED_RESPONSE.output.error_code, + request_id: FAILED_RESPONSE.request_id, + cause: { message: JSON.stringify(FAILED_RESPONSE.output) }, + }, + }); + expectGets(local, [OPERATION_PATH]); + }, + ); + }, + TEST_TIMEOUT_MS, + ); + + test( + "get returns FAILED unchanged with exit 0 and exactly one GET", + async () => { + await withServer( + (_incoming, response) => sendJson(response, FAILED_RESPONSE), + async (local) => { + const result = await runCli(local.url, ["get", "--timeout", "5"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual(FAILED_RESPONSE); + expectGets(local, [OPERATION_PATH]); + }, + ); + }, + TEST_TIMEOUT_MS, + ); + + test.each([ + { + budget: "total wait", + timeout: "5", + pollTimeout: "0.2", + message: "Capacity operation wait timed out", + }, + { budget: "single request", timeout: "0.2", pollTimeout: "5", message: "Request timed out." }, + ])( + "$budget expires while a 200 JSON body stalls, without waiting for watchdogs", + async ({ timeout, pollTimeout, message }) => { + await withServer( + (_incoming, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.flushHeaders(); + response.write('{"output":{"operation_status":"PROCESSING'); + // Deliberately never end the body; only CLI cancellation or cleanup can close it. + }, + async (local) => { + const result = await runCli(local.url, [ + "wait", + "--timeout", + timeout, + "--poll-timeout", + pollTimeout, + ]); + expect(result.exitCode, result.stderr).toBe(5); + expect(result.signal).toBeNull(); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { code: 5, message: expect.stringContaining(message) }, + }); + expectGets(local, [OPERATION_PATH]); + expect(local.firstRequestAt).toBeDefined(); + // Exclude TypeScript/CLI startup. This bound is also far below the other + // 5-second budget, so the wrong timeout source cannot make the test pass. + expect(result.closedAt - local.firstRequestAt!).toBeLessThan(2_000); + }, + ); + }, + TEST_TIMEOUT_MS, + ); + + test( + "SIGINT after the first PROCESSING request exits 130 without success or further GETs", + async () => { + let processingSent!: () => void; + const firstProcessingResponse = new Promise((resolve) => { + processingSent = resolve; + }); + await withServer( + (_incoming, response) => { + response.once("finish", processingSent); + sendJson(response, { + request_id: "local-processing-request", + output: { operation_id: OPERATION_ID, operation_status: "PROCESSING" }, + }); + }, + async (local) => { + const result = await runCli( + local.url, + ["wait", "--interval", "1", "--timeout", "5", "--poll-timeout", "5"], + firstProcessingResponse, + ); + expect(result.interruptSent).toBe(true); + expect(result.exitCode, result.stderr).toBe(130); + expect(result.signal).toBeNull(); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Interrupted"); + expectGets(local, [OPERATION_PATH]); + }, + ); + }, + TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/commands/tests/e2e/deploy.e2e.test.ts b/packages/commands/tests/e2e/deploy.e2e.test.ts index 7152463f..bd1ba54b 100644 --- a/packages/commands/tests/e2e/deploy.e2e.test.ts +++ b/packages/commands/tests/e2e/deploy.e2e.test.ts @@ -1,9 +1,269 @@ +import { fileURLToPath } from "node:url"; import { describe, expect, test } from "vite-plus/test"; import { isDashScopeE2EReady, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts"; import { DEPLOY_ROUTES } from "./topic-routes.ts"; +// No files are created: isolate user config and block fetch in the child process, +// including the runtime's background npm version check. Never load an env file. +const READ_ONLY_ENV: NodeJS.ProcessEnv = { + BAILIAN_E2E: "0", + DO_NOT_TRACK: "1", + BAILIAN_CONFIG_DIR: fileURLToPath( + new URL("./fixtures/deploy-query-no-user-config", import.meta.url), + ), + NODE_OPTIONS: `--import=data:text/javascript,${encodeURIComponent('globalThis.fetch = async () => { throw new Error("Network disabled for deploy query E2E"); };')}`, +}; + +function runReadOnlyDeploy(args: string[]) { + return runCommandE2e( + DEPLOY_ROUTES, + ["deploy", ...args, "--api-key", "e2e-dummy-key", "--dry-run", "--output", "json", "--quiet"], + READ_ONLY_ENV, + ); +} + +// Deliberately ungated: these checks must also run on machines without credentials. +describe("e2e: deploy read-only queries (offline, no credentials)", () => { + test.each([ + { + path: ["capacity", "list"], + flags: [ + "--deployed-model", + "--include-deleted", + "--statuses", + "--charge-types", + "--page", + "--page-size", + ], + note: "records/items/page/itemsPerPage/pageCount", + }, + { path: ["capacity", "get"], flags: ["--deployed-model", "--instance-id"], note: "Read-only" }, + { path: ["operation", "get"], flags: ["--deployed-model", "--operation-id"], note: "FAILED" }, + { + path: ["operation", "wait"], + flags: ["--deployed-model", "--operation-id", "--interval", "--poll-timeout"], + note: "Ctrl-C", + }, + ])("deploy $path --help exposes query flags and semantics", async ({ path, flags, note }) => { + const { stdout, stderr, exitCode } = await runCommandHelp(DEPLOY_ROUTES, [ + "deploy", + ...path, + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toBe(""); + expect(stderr).toContain(`bl deploy ${path.join(" ")}`); + for (const flag of flags) expect(stderr).toContain(flag); + expect(stderr).toContain(note); + expect(stderr).toContain("--api-key"); + expect(stderr).not.toContain("--yes"); + }); + + test.each([ + { + path: ["capacity", "list"], + args: [ + "--deployed-model", + "dep-test", + "--page", + "2", + "--page-size", + "5", + "--statuses", + " RUNNING, STOPPED ", + "--charge-types", + " pre_paid, post_paid ", + ], + expected: { + action: "deploy.capacity.list", + deployed_model: "dep-test", + query: { + page_no: 2, + page_size: 5, + include_deleted: true, + statuses: "RUNNING,STOPPED", + charge_types: "pre_paid,post_paid", + }, + }, + }, + { + path: ["capacity", "get"], + args: ["--deployed-model", "dep-test", "--instance-id", "instance/test"], + expected: { + action: "deploy.capacity.get", + deployed_model: "dep-test", + instance_id: "instance/test", + }, + }, + { + path: ["operation", "get"], + args: ["--deployed-model", "dep-test", "--operation-id", "000900719925474099312345"], + expected: { + action: "deploy.operation.get", + deployed_model: "dep-test", + operation_id: "000900719925474099312345", + }, + }, + { + path: ["operation", "wait"], + args: [ + "--deployed-model", + "dep-test", + "--operation-id", + "000900719925474099312345", + "--interval", + "3", + "--poll-timeout", + "15", + ], + expected: { + action: "deploy.operation.wait", + deployed_model: "dep-test", + operation_id: "000900719925474099312345", + interval: 3, + poll_timeout: 15, + }, + }, + ])( + "deploy $path --dry-run parses flags with a dummy key and no network", + async ({ path, args, expected }) => { + const { stdout, stderr, exitCode } = await runReadOnlyDeploy([...path, ...args]); + expect(exitCode, stderr).toBe(0); + expect(parseStdoutJson(stdout)).toEqual(expected); + expect(stderr).not.toContain("Network disabled"); + }, + ); + + test.each([ + { path: ["capacity", "list"], args: [], missing: "--deployed-model" }, + { + path: ["capacity", "get"], + args: ["--instance-id", "instance-test"], + missing: "--deployed-model", + }, + { path: ["capacity", "get"], args: ["--deployed-model", "dep-test"], missing: "--instance-id" }, + { + path: ["operation", "get"], + args: ["--operation-id", "operation-test"], + missing: "--deployed-model", + }, + { + path: ["operation", "get"], + args: ["--deployed-model", "dep-test"], + missing: "--operation-id", + }, + { + path: ["operation", "wait"], + args: ["--operation-id", "operation-test"], + missing: "--deployed-model", + }, + { + path: ["operation", "wait"], + args: ["--deployed-model", "dep-test"], + missing: "--operation-id", + }, + ])("deploy $path requires $missing even during dry-run", async ({ path, args, missing }) => { + const { stdout, stderr, exitCode } = await runReadOnlyDeploy([...path, ...args]); + expect(exitCode, stderr).toBe(2); + expect(stdout.trim()).toBe(""); + expect(stderr).toContain(missing); + }); + + test.each([ + { name: "omitted defaults to true", args: [], expected: true }, + { name: "separate false value", args: ["--include-deleted", "false"], expected: false }, + { name: "equals false value", args: ["--include-deleted=false"], expected: false }, + ])("capacity list boolean parsing: $name", async ({ args, expected }) => { + const { stdout, stderr, exitCode } = await runReadOnlyDeploy([ + "capacity", + "list", + "--deployed-model", + "dep-test", + ...args, + ]); + expect(exitCode, stderr).toBe(0); + expect(parseStdoutJson(stdout)).toEqual({ + action: "deploy.capacity.list", + deployed_model: "dep-test", + query: { page_no: 1, page_size: 20, include_deleted: expected }, + }); + }); +}); + +// Unsubscribe is a pure link builder: no auth, no network, no confirmation. +describe("e2e: deploy capacity unsubscribe (offline, no credentials)", () => { + const INSTANCE_ID = "example-instance"; + const REFUND_URL = `https://billing-cost.console.aliyun.com/refund/refund?instanceId=${INSTANCE_ID}`; + + function runUnsubscribe(args: string[]) { + return runCommandE2e( + DEPLOY_ROUTES, + ["deploy", "capacity", "unsubscribe", ...args, "--output", "json"], + READ_ONLY_ENV, + ); + } + + test("--help exposes --instance-id, no --yes and no --api-key requirement", async () => { + const { stdout, stderr, exitCode } = await runCommandHelp(DEPLOY_ROUTES, [ + "deploy", + "capacity", + "unsubscribe", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toBe(""); + expect(stderr).toContain("bl deploy capacity unsubscribe"); + expect(stderr).toContain("--instance-id"); + expect(stderr).toContain("billing"); + expect(stderr).not.toContain("--yes"); + expect(stderr).not.toContain("--api-key"); + }); + + test("builds the refund URL without any network or credentials", async () => { + const { stdout, stderr, exitCode } = await runUnsubscribe(["--instance-id", INSTANCE_ID]); + expect(exitCode, stderr).toBe(0); + expect(parseStdoutJson(stdout)).toEqual({ + action: "deploy.capacity.unsubscribe", + instance_id: INSTANCE_ID, + refund_url: REFUND_URL, + }); + expect(stderr).not.toContain("Network disabled"); + }); + + test("--quiet emits only the bare refund URL", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e( + DEPLOY_ROUTES, + ["deploy", "capacity", "unsubscribe", "--instance-id", INSTANCE_ID, "--quiet"], + READ_ONLY_ENV, + ); + expect(exitCode, stderr).toBe(0); + expect(stdout.trim()).toBe(REFUND_URL); + }); + + test("URL-encodes unusual instance IDs", async () => { + const { stdout, exitCode } = await runUnsubscribe(["--instance-id", "a b&c"]); + expect(exitCode).toBe(0); + expect(parseStdoutJson<{ refund_url: string }>(stdout).refund_url).toBe( + "https://billing-cost.console.aliyun.com/refund/refund?instanceId=a%20b%26c", + ); + }); + + test("requires --instance-id even without credentials", async () => { + const { stdout, stderr, exitCode } = await runUnsubscribe([]); + expect(exitCode, stderr).toBe(2); + expect(stdout.trim()).toBe(""); + expect(stderr).toContain("--instance-id"); + }); + + test("rejects a blank --instance-id", async () => { + const { stdout, stderr, exitCode } = await runUnsubscribe(["--instance-id", " "]); + expect(exitCode, stderr).toBe(2); + expect(stdout.trim()).toBe(""); + }); +}); + /** - * Deploy E2E. + * Legacy Deploy E2E (preserved below). * * The suite exercises command discovery, help text, and the `--dry-run` * structured-output path (arg parsing + body construction) with no network @@ -234,6 +494,311 @@ describe("e2e: deploy high-risk confirmation", () => { }); }); +// Deliberately ungated: risky writes must parse, preview and demand confirmation offline. +describe("e2e: deploy capacity writes (offline, no credentials)", () => { + test.each([ + { + path: ["capacity", "create"], + flags: ["--deployed-model", "--billing-method", "--input-tpm", "--output-tpm", "--duration"], + note: "postpaid", + }, + { + path: ["capacity", "scale"], + flags: ["--deployed-model", "--instance-id", "--input-tpm", "--output-tpm", "--order-type"], + note: "absolute", + }, + { + path: ["capacity", "renew"], + flags: ["--deployed-model", "--instance-id", "--duration", "--auto-renewal", "--is-change"], + note: "prepaid", + }, + { + path: ["capacity", "delete"], + flags: ["--deployed-model", "--instance-id", "--reason"], + note: "unsubscribe", + }, + { path: ["overflow"], flags: ["--deployed-model", "--strategy"], note: "ModelCode" }, + ])( + "deploy $path --help exposes write flags, --yes and semantics", + async ({ path, flags, note }) => { + const { stdout, stderr, exitCode } = await runCommandHelp(DEPLOY_ROUTES, [ + "deploy", + ...path, + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toBe(""); + expect(stderr).toContain(`bl deploy ${path.join(" ")}`); + for (const flag of flags) expect(stderr).toContain(flag); + expect(stderr.toLowerCase()).toContain(note.toLowerCase()); + expect(stderr).toContain("--yes"); + expect(stderr).toContain("--dry-run"); + }, + ); + + test.each([ + { + path: ["capacity", "create"], + args: [ + "--deployed-model", + "dep-test", + "--billing-method", + "POST_PAY", + "--input-tpm", + "10000", + "--output-tpm", + "1000", + ], + expected: { + action: "deploy.capacity.create", + deployed_model: "dep-test", + body: { + billing_method: "POST_PAY", + ptu_capacity: { input_tpm: 10000, output_tpm: 1000 }, + }, + client_request_id: "", + wait: false, + }, + }, + { + path: ["capacity", "scale"], + args: [ + "--deployed-model", + "dep-test", + "--instance-id", + "instance-test", + "--input-tpm", + "0", + "--output-tpm", + "0", + "--request-id", + "11111111-2222-3333-4444-555555555555", + ], + expected: { + action: "deploy.capacity.scale", + deployed_model: "dep-test", + instance_id: "instance-test", + body: { ptu_capacity: { input_tpm: 0, output_tpm: 0 } }, + client_request_id: "11111111-2222-3333-4444-555555555555", + precheck: "can_scale", + wait: false, + }, + }, + { + path: ["capacity", "renew"], + args: [ + "--deployed-model", + "dep-test", + "--instance-id", + "instance-test", + "--duration", + "30", + "--auto-renewal", + "true", + "--auto-renewal-duration", + "30", + ], + expected: { + action: "deploy.capacity.renew", + deployed_model: "dep-test", + instance_id: "instance-test", + body: { + pre_paid_info: { duration: 30, auto_renewal: true, auto_renewal_duration: 30 }, + is_change: false, + }, + client_request_id: "", + precheck: "can_renew/pre_paid/configured_capacity", + wait: false, + }, + }, + { + path: ["capacity", "delete"], + args: [ + "--deployed-model", + "dep-test", + "--instance-id", + "instance-test", + "--reason", + "release capacity", + ], + expected: { + action: "deploy.capacity.delete", + deployed_model: "dep-test", + instance_id: "instance-test", + query: { reason: "release capacity" }, + client_request_id: "", + precheck: "can_delete/prepaid_unsubscribe", + wait: false, + }, + }, + { + path: ["overflow"], + args: ["--deployed-model", "dep-test", "--strategy", "enable"], + expected: { + action: "deploy.overflow", + deployed_model: "dep-test", + body: { overflow_strategy: "enable" }, + }, + }, + ])( + "deploy $path --dry-run previews the write with no network", + async ({ path, args, expected }) => { + const { stdout, stderr, exitCode } = await runReadOnlyDeploy([...path, ...args]); + expect(exitCode, stderr).toBe(0); + expect(parseStdoutJson(stdout)).toEqual(expected); + expect(stderr).not.toContain("Network disabled"); + }, + ); + + test.each([ + { path: ["capacity", "create"], args: [], missing: "--deployed-model" }, + { + path: ["capacity", "create"], + args: ["--deployed-model", "dep-test", "--input-tpm", "1", "--output-tpm", "1"], + missing: "--billing-method", + }, + { path: ["capacity", "scale"], args: [], missing: "--deployed-model" }, + { + path: ["capacity", "scale"], + args: ["--deployed-model", "dep-test"], + missing: "--instance-id", + }, + { path: ["capacity", "renew"], args: [], missing: "--deployed-model" }, + { + path: ["capacity", "renew"], + args: ["--deployed-model", "dep-test", "--instance-id", "instance-test"], + missing: "--duration", + }, + { path: ["capacity", "delete"], args: [], missing: "--deployed-model" }, + { + path: ["capacity", "delete"], + args: ["--deployed-model", "dep-test"], + missing: "--instance-id", + }, + { path: ["overflow"], args: [], missing: "--deployed-model" }, + { path: ["overflow"], args: ["--deployed-model", "dep-test"], missing: "--strategy" }, + ])("deploy $path requires $missing even during dry-run", async ({ path, args, missing }) => { + const { stdout, stderr, exitCode } = await runReadOnlyDeploy([...path, ...args]); + expect(exitCode, stderr).toBe(2); + expect(stdout.trim()).toBe(""); + expect(stderr).toContain(missing); + }); + + test("capacity create rejects unpaired --input-tpm during dry-run", async () => { + const { stdout, stderr, exitCode } = await runReadOnlyDeploy([ + "capacity", + "create", + "--deployed-model", + "dep-test", + "--billing-method", + "POST_PAY", + "--input-tpm", + "10000", + ]); + expect(exitCode, stderr).toBe(2); + expect(stdout.trim()).toBe(""); + expect(stderr).toContain("--output-tpm"); + }); + + test("capacity create rejects prepaid settings on POST_PAY during dry-run", async () => { + const { stdout, stderr, exitCode } = await runReadOnlyDeploy([ + "capacity", + "create", + "--deployed-model", + "dep-test", + "--billing-method", + "POST_PAY", + "--input-tpm", + "10000", + "--output-tpm", + "1000", + "--duration", + "30", + ]); + expect(exitCode, stderr).toBe(2); + expect(stdout.trim()).toBe(""); + expect(stderr).toMatch(/POST_PAY/); + }); + + test("capacity scale rejects --interval without --wait during dry-run", async () => { + const { stdout, stderr, exitCode } = await runReadOnlyDeploy([ + "capacity", + "scale", + "--deployed-model", + "dep-test", + "--instance-id", + "instance-test", + "--input-tpm", + "1", + "--output-tpm", + "1", + "--interval", + "5", + ]); + expect(exitCode, stderr).toBe(2); + expect(stdout.trim()).toBe(""); + expect(stderr).toContain("--wait"); + }); + + test.each([ + { + path: ["capacity", "create"], + args: [ + "--deployed-model", + "dep-test", + "--billing-method", + "POST_PAY", + "--input-tpm", + "1", + "--output-tpm", + "1", + ], + }, + { + path: ["capacity", "scale"], + args: [ + "--deployed-model", + "dep-test", + "--instance-id", + "instance-test", + "--input-tpm", + "1", + "--output-tpm", + "1", + ], + }, + { + path: ["capacity", "renew"], + args: [ + "--deployed-model", + "dep-test", + "--instance-id", + "instance-test", + "--duration", + "30", + "--auto-renewal", + "false", + ], + }, + { + path: ["capacity", "delete"], + args: ["--deployed-model", "dep-test", "--instance-id", "instance-test"], + }, + { path: ["overflow"], args: ["--deployed-model", "dep-test", "--strategy", "disable"] }, + ])("deploy $path without --yes requires confirmation (7)", async ({ path, args }) => { + const { stdout, stderr, exitCode } = await runCommandE2e( + DEPLOY_ROUTES, + ["deploy", ...path, ...args, "--api-key", "e2e-dummy-key", "--output", "json"], + READ_ONLY_ENV, + ); + expect(exitCode, stderr).toBe(7); + expect(stdout.trim()).toBe(""); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); + }); +}); + describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (DashScope)", () => { /** * 不同开发者的 key 状态不一:可能鉴权失败、可能账号下没有任何部署记录、 diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index b4c4fe3e..c4496f83 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -188,6 +188,16 @@ export const DEPLOY_ROUTES: E2eRouteExports = { "deploy image create": "deployImageCreate", "deploy list": "deployList", "deploy get": "deployGet", + "deploy capacity list": "deployCapacityList", + "deploy capacity get": "deployCapacityGet", + "deploy capacity create": "deployCapacityCreate", + "deploy capacity scale": "deployCapacityScale", + "deploy capacity renew": "deployCapacityRenew", + "deploy capacity delete": "deployCapacityDelete", + "deploy capacity unsubscribe": "deployCapacityUnsubscribe", + "deploy overflow": "deployOverflow", + "deploy operation get": "deployOperationGet", + "deploy operation wait": "deployOperationWait", "deploy models": "deployModels", "deploy scale": "deployScale", "deploy update": "deployUpdate", diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 1090d9cb..d7f98cc8 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -232,9 +232,39 @@ export function deploymentPath(deployedModel: string): string { return `/api/v1/deployments/${encodeURIComponent(deployedModel)}`; } +/** GET /api/v1/deployments/{deployed_model}/capacity-instances */ +export function deploymentCapacityInstancesPath(code: string): string { + return `${deploymentPath(code)}/capacity-instances`; +} + +/** GET /api/v1/deployments/{deployed_model}/capacity-instances/{instance_id} */ +export function deploymentCapacityInstancePath(code: string, id: string): string { + return `${deploymentCapacityInstancesPath(code)}/${encodeURIComponent(id)}`; +} + +/** PUT /api/v1/deployments/{deployed_model}/capacity-instances/{instance_id}/scale */ +export function deploymentCapacityInstanceScalePath(code: string, id: string): string { + return `${deploymentCapacityInstancePath(code, id)}/scale`; +} + +/** PUT /api/v1/deployments/{deployed_model}/capacity-instances/{instance_id}/renew */ +export function deploymentCapacityInstanceRenewPath(code: string, id: string): string { + return `${deploymentCapacityInstancePath(code, id)}/renew`; +} + +/** PUT /api/v1/deployments/{deployed_model}/update-overflowstrategy */ +export function deploymentOverflowPath(code: string): string { + return `${deploymentPath(code)}/update-overflowstrategy`; +} + +/** GET /api/v1/deployments/{deployed_model}/capacity-operations/{operation_id} */ +export function deploymentCapacityOperationPath(code: string, id: string): string { + return `${deploymentPath(code)}/capacity-operations/${encodeURIComponent(id)}`; +} + /** PUT /api/v1/deployments/{deployed_model}/scale — capacity adjust. */ export function deploymentScalePath(deployedModel: string): string { - return `/api/v1/deployments/${encodeURIComponent(deployedModel)}/scale`; + return `${deploymentPath(deployedModel)}/scale`; } /** diff --git a/packages/core/src/client/http.ts b/packages/core/src/client/http.ts index d4e37ab4..500d84da 100644 --- a/packages/core/src/client/http.ts +++ b/packages/core/src/client/http.ts @@ -35,6 +35,21 @@ function bodyReferencesOssUrl(body: unknown): boolean { } export async function request(deps: HttpDeps, opts: RequestOpts): Promise { + const timeoutMs = (opts.timeout ?? deps.settings.timeout) * 1000; + const requestSignal = createRequestSignal(timeoutMs, opts.signal); + try { + // Raw/streaming callers retain ownership of successful response bodies. + return await performRequest(deps, opts, requestSignal.signal); + } finally { + requestSignal.cleanup(); + } +} + +async function performRequest( + deps: HttpDeps, + opts: RequestOpts, + signal: AbortSignal, +): Promise { const isFormData = typeof FormData !== "undefined" && opts.body instanceof FormData; const headers: Record = { @@ -62,9 +77,6 @@ export async function request(deps: HttpDeps, opts: RequestOpts): Promise x-dashscope-source-config: ${sourceConfig(deps.identity)}`); } - const timeoutMs = (opts.timeout ?? deps.settings.timeout) * 1000; - - const requestSignal = createRequestSignal(timeoutMs, opts.signal); const res = await fetch(opts.url, { method: opts.method ?? "GET", headers, @@ -73,8 +85,8 @@ export async function request(deps: HttpDeps, opts: RequestOpts): Promise { clearTimeout(timeout); parentSignal?.removeEventListener("abort", abortFromParent); + controller.signal.removeEventListener("abort", cleanup); }; + controller.signal.addEventListener("abort", cleanup, { once: true }); if (parentSignal?.aborted) abortFromParent(); else parentSignal?.addEventListener("abort", abortFromParent, { once: true }); - controller.signal.addEventListener("abort", cleanup, { once: true }); return { signal: controller.signal, cleanup }; } export async function requestJson(deps: HttpDeps, opts: RequestOpts): Promise { - const res = await request(deps, opts); - let data: T & { code?: string; message?: string; request_id?: string }; + const timeoutMs = (opts.timeout ?? deps.settings.timeout) * 1000; + const requestSignal = createRequestSignal(timeoutMs, opts.signal); try { - data = (await res.json()) as T & { code?: string; message?: string; request_id?: string }; - } catch { - const contentType = res.headers.get("content-type") || ""; - throw new BailianError( - `API returned non-JSON response (${contentType || "unknown type"}). Server may be experiencing issues.`, - ExitCode.GENERAL, - ); - } + const res = await performRequest(deps, opts, requestSignal.signal); + let data: T & { code?: string; message?: string; request_id?: string }; + try { + data = (await res.json()) as T & { code?: string; message?: string; request_id?: string }; + } catch { + if (requestSignal.signal.aborted) throw requestSignal.signal.reason; + const contentType = res.headers.get("content-type") || ""; + throw new BailianError( + `API returned non-JSON response (${contentType || "unknown type"}). Server may be experiencing issues.\nAPI 返回了非 JSON 响应(${contentType || "未知类型"}),服务器可能出现问题。`, + ExitCode.GENERAL, + ); + } - // DashScope error format: { code: "ErrorCode", message: "..." } - if ( - data.code && - typeof data.code === "string" && - data.code !== "200" && - data.code !== "Success" - ) { - throw mapApiError(200, { error: { message: data.message, type: data.code } }, opts.url); - } + // DashScope error format: { code: "ErrorCode", message: "..." } + if ( + data.code && + typeof data.code === "string" && + data.code !== "200" && + data.code !== "Success" + ) { + throw mapApiError(200, { error: { message: data.message, type: data.code } }, opts.url); + } - return data; + return data; + } catch (error) { + if (requestSignal.signal.aborted) throw requestSignal.signal.reason; + throw error; + } finally { + requestSignal.cleanup(); + } } diff --git a/packages/core/src/deploy/api.ts b/packages/core/src/deploy/api.ts index 66f9a07e..21d5443f 100644 --- a/packages/core/src/deploy/api.ts +++ b/packages/core/src/deploy/api.ts @@ -10,6 +10,12 @@ import { deploymentScalePath, deploymentUpdatePath, deploymentsModelsPath, + deploymentCapacityInstancesPath, + deploymentCapacityInstancePath, + deploymentCapacityOperationPath, + deploymentCapacityInstanceScalePath, + deploymentCapacityInstanceRenewPath, + deploymentOverflowPath, } from "../client/endpoints.ts"; import type { Client } from "../client/client.ts"; import type { @@ -23,9 +29,29 @@ import type { ScaleDeploymentResponse, UpdateDeploymentRequest, UpdateDeploymentResponse, + ListCapacityInstancesResponse, + GetCapacityInstanceResponse, + GetCapacityOperationResponse, + CreateCapacityInstanceRequest, + ScaleCapacityInstanceRequest, + RenewCapacityInstanceRequest, + CapacityOperationResponse, + UpdateDeploymentOverflowResponse, } from "./types.ts"; -/** POST /api/v1/deployments */ +/** Caller-owned idempotency key; wrappers never generate keys or retry writes. */ +export interface CapacityWriteOptions { + requestId?: string; + signal?: AbortSignal; +} + +function capacityWriteHeaders(requestId?: string): Record | undefined { + return requestId + ? { "x-acs-req-uuid": requestId, "X-DashScope-RequestId": requestId } + : undefined; +} + +/** POST /api/v1/deployments; initial ModelCode creation has no instance-idempotency guarantee. */ export async function createDeployment( client: Client, body: CreateDeploymentRequest, @@ -42,7 +68,7 @@ export async function createDeployment( export interface ListDeploymentsParams { pageNo?: number; pageSize?: number; - status?: string; + plan?: string; signal?: AbortSignal; } @@ -54,7 +80,7 @@ export async function listDeployments( const qs = new URLSearchParams(); 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.plan !== undefined) qs.set("plan", params.plan); const base = deploymentsPath(); const path = qs.toString() ? `${base}?${qs.toString()}` : base; return client.requestJson({ @@ -77,6 +103,152 @@ export async function getDeployment( }); } +export interface ListCapacityInstancesParams { + pageNo?: number; + pageSize?: number; + includeDeleted?: boolean; + statuses?: string[]; + chargeTypes?: string[]; + signal?: AbortSignal; +} + +/** GET /api/v1/deployments/{deployed_model}/capacity-instances */ +export async function listCapacityInstances( + client: Client, + code: string, + params: ListCapacityInstancesParams = {}, +): Promise { + const query = new URLSearchParams(); + if (params.pageNo !== undefined) query.set("page_no", String(params.pageNo)); + if (params.pageSize !== undefined) query.set("page_size", String(params.pageSize)); + if (params.includeDeleted !== undefined) { + query.set("include_deleted", String(params.includeDeleted)); + } + if (params.statuses !== undefined) query.set("statuses", params.statuses.join(",")); + if (params.chargeTypes !== undefined) query.set("charge_types", params.chargeTypes.join(",")); + const base = deploymentCapacityInstancesPath(code); + const path = query.toString() ? `${base}?${query.toString()}` : base; + return client.requestJson({ + path, + method: "GET", + signal: params.signal, + }); +} + +/** GET /api/v1/deployments/{deployed_model}/capacity-instances/{instance_id} */ +export async function getCapacityInstance( + client: Client, + code: string, + id: string, + signal?: AbortSignal, +): Promise { + return client.requestJson({ + path: deploymentCapacityInstancePath(code, id), + method: "GET", + signal, + }); +} + +/** GET /api/v1/deployments/{deployed_model}/capacity-operations/{operation_id} */ +export async function getCapacityOperation( + client: Client, + code: string, + id: string, + signal?: AbortSignal, +): Promise { + return client.requestJson({ + path: deploymentCapacityOperationPath(code, id), + method: "GET", + signal, + }); +} + +/** + * POST /api/v1/deployments/{deployed_model}/capacity-instances + * All instance writes return operations verbatim; callers must check operation_status, even on HTTP 200. + */ +export async function createCapacityInstance( + client: Client, + code: string, + body: CreateCapacityInstanceRequest, + options: CapacityWriteOptions = {}, +): Promise { + return client.requestJson({ + path: deploymentCapacityInstancesPath(code), + method: "POST", + body, + headers: capacityWriteHeaders(options.requestId), + signal: options.signal, + }); +} + +/** PUT /api/v1/deployments/{deployed_model}/capacity-instances/{instance_id}/scale */ +export async function scaleCapacityInstance( + client: Client, + code: string, + id: string, + body: ScaleCapacityInstanceRequest, + options: CapacityWriteOptions = {}, +): Promise { + return client.requestJson({ + path: deploymentCapacityInstanceScalePath(code, id), + method: "PUT", + body, + headers: capacityWriteHeaders(options.requestId), + signal: options.signal, + }); +} + +/** PUT /api/v1/deployments/{deployed_model}/capacity-instances/{instance_id}/renew */ +export async function renewCapacityInstance( + client: Client, + code: string, + id: string, + body: RenewCapacityInstanceRequest, + options: CapacityWriteOptions = {}, +): Promise { + return client.requestJson({ + path: deploymentCapacityInstanceRenewPath(code, id), + method: "PUT", + body, + headers: capacityWriteHeaders(options.requestId), + signal: options.signal, + }); +} + +/** DELETE /api/v1/deployments/{deployed_model}/capacity-instances/{instance_id}; no JSON body. */ +export async function deleteCapacityInstance( + client: Client, + code: string, + id: string, + options: CapacityWriteOptions & { reason?: string } = {}, +): Promise { + const base = deploymentCapacityInstancePath(code, id); + const query = new URLSearchParams(); + if (options.reason !== undefined) query.set("reason", options.reason); + return client.requestJson({ + path: query.toString() ? `${base}?${query.toString()}` : base, + method: "DELETE", + headers: capacityWriteHeaders(options.requestId), + signal: options.signal, + }); +} + +/** PUT /api/v1/deployments/{deployed_model}/update-overflowstrategy */ +export async function updateDeploymentOverflow( + client: Client, + code: string, + strategy: "enable" | "disable", + signal?: AbortSignal, +): Promise { + return client.requestJson({ + path: deploymentOverflowPath(code), + method: "PUT", + body: { overflow_strategy: strategy }, + signal, + }); +} + /** DELETE /api/v1/deployments/{deployed_model} */ export async function deleteDeployment( client: Client, @@ -125,11 +297,13 @@ export async function scaleDeployment( deployedModel: string, body: ScaleDeploymentRequest, signal?: AbortSignal, + requestId?: string, ): Promise { return client.requestJson({ path: deploymentScalePath(deployedModel), method: "PUT", body, + headers: capacityWriteHeaders(requestId), signal, }); } diff --git a/packages/core/src/deploy/index.ts b/packages/core/src/deploy/index.ts index ce3909ac..0df78879 100644 --- a/packages/core/src/deploy/index.ts +++ b/packages/core/src/deploy/index.ts @@ -3,3 +3,12 @@ export * from "./types.ts"; export * from "./constants.ts"; export * from "./plans.ts"; export * from "./lifecycle.ts"; +export * from "./reservation.ts"; +export { + deploymentCapacityInstancesPath, + deploymentCapacityInstancePath, + deploymentCapacityOperationPath, + deploymentCapacityInstanceScalePath, + deploymentCapacityInstanceRenewPath, + deploymentOverflowPath, +} from "../client/endpoints.ts"; diff --git a/packages/core/src/deploy/plans.ts b/packages/core/src/deploy/plans.ts index ab9835cb..7565d03a 100644 --- a/packages/core/src/deploy/plans.ts +++ b/packages/core/src/deploy/plans.ts @@ -19,15 +19,24 @@ import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import type { Client } from "../client/client.ts"; import { DEPLOY_PLAN, BILLING_METHOD, CHARGE_TYPE, DEFAULT_BILLING_METHOD } from "./constants.ts"; +import { + buildPrepaidInfo, + buildReservationCapacity, + hasPrepaidFlags, + validatePrepaidFlags, + validateReservationCapacity, + type ReservationFlags, +} from "./reservation.ts"; /** Plan-relevant subset of `deploy create` flags (parsed flags satisfy this shape). */ -export interface CreatePlanFlags { +export interface CreatePlanFlags extends ReservationFlags { plan?: string; deploySpec?: string; capacity?: number; billingMethod?: string; - inputTpm?: number; - outputTpm?: number; + chargeType?: string; + serviceTier?: string; + suffix?: string; thinkingOutputTpm?: number; } @@ -40,8 +49,8 @@ export interface PlanContext { flags: CreatePlanFlags; /** Underlying model identifier (`--model`). */ model: string; - /** Console display name (`--name`). */ - name: string; + /** Optional console display name (`--name`); PTU can use the generated name. */ + name?: string; } export interface PlanResolved { @@ -79,29 +88,51 @@ const loraStrategy: PlanStrategy = { }, }; -/** - * `ptu` (Token-billed, provisioned throughput). The platform rejects creation - * without `ptu_capacity.input_tpm` / `output_tpm` ("Miss ptu capacity info") - * even though the doc lists 10000/1000 defaults — so the CLI treats them as - * required. - */ +/** PTU reservation creation buys the first instance; capacity is expressed in kTPM. */ const ptuStrategy: PlanStrategy = { name: DEPLOY_PLAN.PTU, validateFlags(flags) { - if (flags.inputTpm === undefined || flags.outputTpm === undefined) { - return "--input-tpm and --output-tpm are required for plan=ptu."; + if ( + flags.capacity !== undefined || + flags.deploySpec !== undefined || + flags.billingMethod !== undefined + ) { + return "--capacity, --deploy-spec and --billing-method are not supported for plan=ptu; use reservation capacity and --charge-type.\nplan=ptu 不支持 --capacity、--deploy-spec 和 --billing-method;请使用预留容量参数和 --charge-type。"; + } + if (flags.thinkingOutputTpm !== undefined) { + return "--thinking-output-tpm is not supported by current reservation models.\n当前吞吐预留模型不支持 --thinking-output-tpm。"; + } + if (flags.chargeType !== CHARGE_TYPE.PRE_PAID && flags.chargeType !== CHARGE_TYPE.POST_PAID) { + return "--charge-type is required for plan=ptu and must be pre_paid or post_paid.\nplan=ptu 必须提供 --charge-type,且只能为 pre_paid 或 post_paid。"; + } + if ( + flags.serviceTier !== undefined && + flags.serviceTier !== "ptu_fast" && + flags.serviceTier !== "ptu_default" + ) { + return "--service-tier must be ptu_fast or ptu_default.\n--service-tier 只能为 ptu_fast 或 ptu_default。"; } - return undefined; + if (flags.serviceTier === "ptu_default" && flags.chargeType === CHARGE_TYPE.POST_PAID) { + return "ptu_default only supports pre_paid.\nptu_default 仅支持 pre_paid。"; + } + if (flags.chargeType === CHARGE_TYPE.POST_PAID && hasPrepaidFlags(flags)) { + return "Prepaid flags are not allowed with --charge-type post_paid.\n--charge-type post_paid 不允许提供预付费参数。"; + } + return ( + validateReservationCapacity(flags, true) ?? + validatePrepaidFlags(flags, flags.chargeType === CHARGE_TYPE.PRE_PAID) + ); }, async resolve(ctx: PlanContext): Promise { - const ptuCapacity: Record = { - input_tpm: ctx.flags.inputTpm!, - output_tpm: ctx.flags.outputTpm!, + const body: Record = { + charge_type: ctx.flags.chargeType, + ptu_capacity: buildReservationCapacity(ctx.flags), }; - if (ctx.flags.thinkingOutputTpm !== undefined) { - ptuCapacity.thinking_output_tpm = ctx.flags.thinkingOutputTpm; - } - return { body: { ptu_capacity: ptuCapacity } }; + if (ctx.flags.serviceTier !== undefined) body.service_tier = ctx.flags.serviceTier; + if (ctx.flags.suffix !== undefined) body.suffix = ctx.flags.suffix; + const prepaid = buildPrepaidInfo(ctx.flags); + if (prepaid !== undefined) body.pre_paid_info = prepaid; + return { body }; }, }; diff --git a/packages/core/src/deploy/reservation.ts b/packages/core/src/deploy/reservation.ts new file mode 100644 index 00000000..c4b5b58c --- /dev/null +++ b/packages/core/src/deploy/reservation.ts @@ -0,0 +1,91 @@ +import type { PrePaidInfo, ReservationCapacity } from "./types.ts"; + +/** Shared flags for reservation creation, scaling and renewal; capacity is in kTPM. */ +export interface ReservationFlags { + inputTpm?: number; + outputTpm?: number; + duration?: number; + autoRenewal?: boolean; + autoRenewalDuration?: number; + autoRenewalCycle?: string; +} + +/** Validate paired capacity locally; model-specific steps and limits belong to the service. */ +export function validateReservationCapacity( + flags: ReservationFlags, + required: boolean, +): string | undefined { + if (!required && flags.inputTpm === undefined && flags.outputTpm === undefined) return undefined; + if (flags.inputTpm === undefined || flags.outputTpm === undefined) { + return "--input-tpm and --output-tpm must be provided together.\n必须同时提供 --input-tpm 和 --output-tpm。"; + } + if (!Number.isSafeInteger(flags.inputTpm) || flags.inputTpm < 0) { + return "--input-tpm must be a non-negative safe integer in kTPM.\n--input-tpm 必须是非负安全整数,单位为 kTPM。"; + } + if (!Number.isSafeInteger(flags.outputTpm) || flags.outputTpm < 0) { + return "--output-tpm must be a non-negative safe integer in kTPM.\n--output-tpm 必须是非负安全整数,单位为 kTPM。"; + } + return undefined; +} + +/** Build validated capacity without converting units or dropping zero. */ +export function buildReservationCapacity(flags: ReservationFlags): ReservationCapacity { + const capacity: ReservationCapacity = {}; + if (flags.inputTpm !== undefined) capacity.input_tpm = flags.inputTpm; + if (flags.outputTpm !== undefined) capacity.output_tpm = flags.outputTpm; + return capacity; +} + +export function hasPrepaidFlags(flags: ReservationFlags): boolean { + return ( + flags.duration !== undefined || + flags.autoRenewal !== undefined || + flags.autoRenewalDuration !== undefined || + flags.autoRenewalCycle !== undefined + ); +} + +/** Validate a complete prepaid block whenever any prepaid field is supplied. */ +export function validatePrepaidFlags( + flags: ReservationFlags, + required: boolean, +): string | undefined { + if (!required && !hasPrepaidFlags(flags)) return undefined; + if ( + flags.duration === undefined || + !Number.isSafeInteger(flags.duration) || + flags.duration <= 0 + ) { + return "--duration must be a positive safe integer in days.\n--duration 必须是正安全整数,单位为天。"; + } + if (typeof flags.autoRenewal !== "boolean") { + return "--auto-renewal must explicitly be true or false.\n必须显式指定 --auto-renewal 为 true 或 false。"; + } + if ( + (flags.autoRenewal || flags.autoRenewalDuration !== undefined) && + (flags.autoRenewalDuration === undefined || + !Number.isSafeInteger(flags.autoRenewalDuration) || + flags.autoRenewalDuration <= 0) + ) { + return "--auto-renewal-duration must be a positive safe integer in days; required when --auto-renewal=true.\n--auto-renewal-duration 必须是正安全整数(天);开启自动续费时必填。"; + } + if ( + flags.autoRenewalCycle !== undefined && + (typeof flags.autoRenewalCycle !== "string" || flags.autoRenewalCycle.trim() === "") + ) { + return "--auto-renewal-cycle must be a non-empty string.\n--auto-renewal-cycle 必须是非空字符串。"; + } + return undefined; +} + +/** Build validated prepaid information, preserving explicit false and omitting absent fields. */ +export function buildPrepaidInfo(flags: ReservationFlags): PrePaidInfo | undefined { + if (!hasPrepaidFlags(flags)) return undefined; + const prepaid: PrePaidInfo = {}; + if (flags.duration !== undefined) prepaid.duration = flags.duration; + if (flags.autoRenewal !== undefined) prepaid.auto_renewal = flags.autoRenewal; + if (flags.autoRenewalDuration !== undefined) + prepaid.auto_renewal_duration = flags.autoRenewalDuration; + if (flags.autoRenewalCycle !== undefined) prepaid.auto_renewal_cycle = flags.autoRenewalCycle; + return prepaid; +} diff --git a/packages/core/src/deploy/types.ts b/packages/core/src/deploy/types.ts index e7927b82..c8677ea7 100644 --- a/packages/core/src/deploy/types.ts +++ b/packages/core/src/deploy/types.ts @@ -33,6 +33,18 @@ export interface Deployment { /** PTU-only token-rate limits. */ input_tpm?: number; output_tpm?: number; + /** Aggregate effective reservation capacity, in kTPM. */ + ptu_capacity?: ReservationCapacity; + /** ptu_fast | ptu_default | future service tiers. */ + ptu_service_tier?: string; + pre_paid_info?: PrePaidInfo; + pre_paid_instance_id?: string; + pre_paid_gmt_expired?: string; + /** enable | disable | future overflow strategies. */ + overflow_strategy?: string; + fail_reason?: string; + operation_id?: string; + instance_id?: string; enable_thinking?: boolean; max_context_length?: number; workspace_id?: string; @@ -44,6 +56,75 @@ export interface Deployment { [k: string]: unknown; } +/** Reservation capacity returned by queries; separate from legacy PTU write input. */ +export interface ReservationCapacity { + /** Input capacity in kTPM (1 kTPM = 1000 tokens/minute); zero is valid. */ + input_tpm?: number; + /** Output capacity in kTPM; zero is valid. */ + output_tpm?: number; + [key: string]: unknown; +} + +/** Prepaid purchase and renewal configuration returned by queries. */ +export interface PrePaidInfo { + /** Purchase / renewal duration in days. */ + duration?: number; + auto_renewal?: boolean; + /** Automatic renewal duration in days. */ + auto_renewal_duration?: number; + /** Renewal cycle unit, e.g. Day. */ + auto_renewal_cycle?: string; + [key: string]: unknown; +} + +/** A capacity instance returned by list/detail queries, including released instances. */ +export interface CapacityInstance { + /** The deployment's ModelCode. Identifiers are opaque strings. */ + model_service_id?: string; + instance_id?: string; + /** pre_paid | post_paid | future charge types. */ + charge_type?: string; + /** Instance lifecycle state; future states are preserved. */ + status?: string; + deleted?: boolean; + /** Currently confirmed serving capacity. */ + effective_capacity?: ReservationCapacity; + /** Configured / contracted capacity, which may remain while stopped or suspended. */ + configured_capacity?: ReservationCapacity; + /** Pending target, not effective capacity; may be absent or null in a stable state. */ + target_capacity?: ReservationCapacity | null; + pre_paid_info?: PrePaidInfo; + gmt_expired?: string; + can_scale?: boolean; + can_renew?: boolean; + can_delete?: boolean; + fail_reason?: string; + gmt_created?: string; + gmt_modified?: string; + gmt_deleted?: string; + [key: string]: unknown; +} + +/** A capacity operation returned by a query; no state or identifier normalization. */ +export interface CapacityOperation { + operation_id?: string; + /** Original operation request ID, distinct from the query response's request_id. */ + request_id?: string; + /** CREATE | SCALE | RENEW | DELETE | STOP | REFUND | future operation types. */ + operation_type?: string; + /** PROCESSING | SUCCEEDED | FAILED | future operation states. */ + operation_status?: string; + model_service_id?: string; + instance_id?: string; + from_status?: string; + current_status?: string; + error_code?: string; + error_message?: string; + gmt_created?: string; + gmt_finished?: string; + [key: string]: unknown; +} + /** A single deployable model record (GET /deployments/models). */ export interface DeployableModel { model_name?: string; @@ -113,20 +194,23 @@ export interface DeployableTemplate { export interface CreateDeploymentRequest { /** Required. The catalog or fine-tuned model identifier. */ model_name: string; - /** Required. Display name shown in the console. */ - name: string; + /** Optional display name; PTU creation generates one when omitted. */ + name?: string; /** Required. Billing plan: mu | cu | ptu | lora. CLI defaults to "lora". */ plan: string; + /** Required for PTU creation; distinct from instance billing_method. */ + charge_type?: "pre_paid" | "post_paid"; + /** PTU performance tier; the service defaults to ptu_fast. */ + service_tier?: "ptu_fast" | "ptu_default"; + /** Optional ModelCode suffix; generated by the service when omitted. */ + suffix?: string; + /** Required for prepaid PTU creation; omitted for postpaid. */ + pre_paid_info?: PrePaidInfo; /** Required by API even for token-billed (lora) plans where it is ignored — CLI injects 1. */ capacity?: number; /** Deploy spec id (e.g. "MU1", "dps-..."), sent as `deploy_spec` in POST body. */ deploy_spec?: string; - /** - * PTU capacity (provisioned throughput limits). Only effective when - * `plan === "ptu"`. The doc says this defaults to 10000/1000 when omitted, - * but the platform currently rejects creation without it ("Miss ptu capacity - * info"), so the CLI treats it as required for ptu. - */ + /** Required PTU capacity in kTPM; values are forwarded without conversion. */ ptu_capacity?: PtuCapacity; /** * AIGC generation config for fine-tuned Wan video (i2v/kf2v) LoRA deployments. @@ -151,13 +235,13 @@ export interface AigcConfig { lora_prompt_default?: string; } -/** PTU throughput limits — only used when `plan === "ptu"`. */ +/** Legacy PTU input shape, retained for source compatibility. */ export interface PtuCapacity { - /** Max input tokens per minute (all models). */ + /** Input capacity in kTPM. */ input_tpm?: number; - /** Max output tokens per minute (all models). */ + /** Output capacity in kTPM. */ output_tpm?: number; - /** Max thinking-output tokens per minute (some models only). */ + /** @deprecated Current reservation models do not support separate thinking-output capacity. */ thinking_output_tpm?: number; } @@ -194,11 +278,74 @@ export interface GetDeploymentResponse { data?: Deployment; } -/** DELETE /api/v1/deployments/{deployed_model} response. */ +/** GET /api/v1/deployments/{deployed_model}/capacity-instances response. */ +export interface ListCapacityInstancesResponse { + request_id?: string; + output?: { + records?: CapacityInstance[]; + /** Total record count, not the current page's records. */ + items?: number; + page?: number; + itemsPerPage?: number; + pageCount?: number; + [key: string]: unknown; + }; + data?: ListCapacityInstancesResponse["output"]; + [key: string]: unknown; +} + +/** GET /api/v1/deployments/{deployed_model}/capacity-instances/{instance_id} response. */ +export interface GetCapacityInstanceResponse { + request_id?: string; + output?: CapacityInstance; + data?: CapacityInstance; + [key: string]: unknown; +} + +/** GET /api/v1/deployments/{deployed_model}/capacity-operations/{operation_id} response. */ +export interface GetCapacityOperationResponse { + request_id?: string; + output?: CapacityOperation; + data?: CapacityOperation; + [key: string]: unknown; +} + +/** Instance writes return an operation, including HTTP 200 responses with status FAILED. */ +export type CapacityOperationResponse = GetCapacityOperationResponse; + +/** POST /api/v1/deployments/{deployed_model}/capacity-instances request body. */ +export interface CreateCapacityInstanceRequest { + billing_method: "PRE_PAY" | "POST_PAY"; + ptu_capacity: ReservationCapacity; + /** Required for PRE_PAY; omitted for POST_PAY. */ + pre_paid_info?: PrePaidInfo; +} + +/** PUT /api/v1/deployments/{deployed_model}/capacity-instances/{instance_id}/scale body. */ +export interface ScaleCapacityInstanceRequest { + /** Absolute target capacity for this instance, not a delta or deployment total. */ + ptu_capacity: ReservationCapacity; + /** Omit to reuse saved prepaid information; do not send for postpaid. */ + pre_paid_info?: PrePaidInfo; + order_type?: "UPGRADE" | "DOWNGRADE"; +} + +/** PUT /api/v1/deployments/{deployed_model}/capacity-instances/{instance_id}/renew body. */ +export interface RenewCapacityInstanceRequest { + pre_paid_info: PrePaidInfo; + /** Required to be true when changing capacity; defaults to false on the service. */ + is_change?: boolean; + ptu_capacity?: ReservationCapacity; +} + +/** PUT /api/v1/deployments/{deployed_model}/update-overflowstrategy response. */ +export type UpdateDeploymentOverflowResponse = GetDeploymentResponse; + +/** DELETE /api/v1/deployments/{deployed_model} response, including legacy deleted flags. */ export interface DeleteDeploymentResponse { request_id?: string; - output?: { deleted?: boolean; deployed_model?: string; [k: string]: unknown }; - data?: { deleted?: boolean; deployed_model?: string; [k: string]: unknown }; + output?: Deployment & { deleted?: boolean }; + data?: Deployment & { deleted?: boolean }; } /** GET /api/v1/deployments/models response. */ @@ -222,9 +369,15 @@ export interface ListDeployableModelsResponse { /** PUT /api/v1/deployments/{deployed_model}/scale request body. */ export interface ScaleDeploymentRequest { - /** New capacity in plan units. Server-side constraint: integer multiple of `base_capacity`, < 1000. */ + /** New capacity in legacy MU plan units. */ capacity?: number; - /** PTU-only token-rate adjustments. */ + /** Required for PTU when more than one undeleted instance exists. */ + instance_id?: string; + /** Absolute PTU target capacity for the selected instance, in kTPM. */ + ptu_capacity?: ReservationCapacity; + pre_paid_info?: PrePaidInfo; + order_type?: "UPGRADE" | "DOWNGRADE"; + /** Legacy top-level PTU fields retained for source compatibility. */ input_tpm?: number; output_tpm?: number; [k: string]: unknown; diff --git a/packages/core/tests/deploy-api.test.ts b/packages/core/tests/deploy-api.test.ts new file mode 100644 index 00000000..92dd0660 --- /dev/null +++ b/packages/core/tests/deploy-api.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, expectTypeOf, test, vi } from "vite-plus/test"; +import { + deploymentCapacityInstancesPath, + deploymentCapacityInstancePath, + deploymentCapacityOperationPath, + listCapacityInstances, + getCapacityInstance, + getCapacityOperation, + listDeployments, + getDeployment, + type Client, + type ReservationCapacity, + type PrePaidInfo, + type CapacityInstance, + type CapacityOperation, + type Deployment, + type CreateDeploymentRequest, + type PtuCapacity, + type ListCapacityInstancesParams, + type ListCapacityInstancesResponse, + type GetCapacityInstanceResponse, + type GetCapacityOperationResponse, + type ListDeploymentsParams, + type ListDeploymentsResponse, + type GetDeploymentResponse, +} from "../src/index.ts"; + +function mockClient(response: unknown = {}) { + const requestJson = vi.fn().mockResolvedValue(response); + return { client: { requestJson } as unknown as Client, requestJson }; +} + +const code = "model /?#%&+"; +const identifier = "000900719925474099312345/实例 ?#%&+"; +const encodedDeployment = "/api/v1/deployments/model%20%2F%3F%23%25%26%2B"; +const encodedIdentifier = "000900719925474099312345%2F%E5%AE%9E%E4%BE%8B%20%3F%23%25%26%2B"; + +const capacity: ReservationCapacity = { input_tpm: 0, output_tpm: 0, future_quota: 0 }; +const prepaid: PrePaidInfo = { + duration: 30, + auto_renewal: false, + auto_renewal_duration: 0, + auto_renewal_cycle: "FUTURE_CYCLE", + future_flag: false, +}; +const instance: CapacityInstance = { + model_service_id: code, + instance_id: identifier, + charge_type: "FUTURE_CHARGE_TYPE", + status: "FUTURE_INSTANCE_STATE", + deleted: false, + effective_capacity: capacity, + configured_capacity: capacity, + target_capacity: null, + pre_paid_info: prepaid, + gmt_expired: "2026-10-01T00:00:00Z", + can_scale: false, + can_renew: false, + can_delete: false, + fail_reason: "original failure reason", + gmt_created: "2026-09-01T00:00:00Z", + gmt_modified: "2026-09-02T00:00:00Z", + gmt_deleted: "", + future_instance_field: { enabled: false, quota: 0 }, +}; + +const queries = [ + { + name: "listCapacityInstances", + path: `${encodedDeployment}/capacity-instances`, + run: (client: Client, signal?: AbortSignal) => listCapacityInstances(client, code, { signal }), + }, + { + name: "getCapacityInstance", + path: `${encodedDeployment}/capacity-instances/${encodedIdentifier}`, + run: (client: Client, signal?: AbortSignal) => + getCapacityInstance(client, code, identifier, signal), + }, + { + name: "getCapacityOperation", + path: `${encodedDeployment}/capacity-operations/${encodedIdentifier}`, + run: (client: Client, signal?: AbortSignal) => + getCapacityOperation(client, code, identifier, signal), + }, +]; + +describe("deployment capacity path builders", () => { + test("encodes each dynamic segment without converting opaque identifiers", () => { + expect(deploymentCapacityInstancesPath(code)).toBe(`${encodedDeployment}/capacity-instances`); + expect(deploymentCapacityInstancePath(code, identifier)).toBe( + `${encodedDeployment}/capacity-instances/${encodedIdentifier}`, + ); + expect(deploymentCapacityOperationPath(code, identifier)).toBe( + `${encodedDeployment}/capacity-operations/${encodedIdentifier}`, + ); + }); +}); + +describe.each(queries)("$name read-only request", ({ path, run }) => { + test.each([false, true])("uses GET without a body; signal supplied: %s", async (withSignal) => { + const { client, requestJson } = mockClient(); + const signal = withSignal ? new AbortController().signal : undefined; + await run(client, signal); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ path, method: "GET", signal }); + expect(requestJson.mock.calls[0][0]).not.toHaveProperty("body"); + expect(requestJson.mock.calls[0][0].signal).toBe(signal); + }); + + test("passes an aborted signal through and preserves the client's rejection", async () => { + const { client, requestJson } = mockClient(); + const controller = new AbortController(); + const error = new Error("client request aborted"); + controller.abort(error); + requestJson.mockRejectedValueOnce(error); + await expect(run(client, controller.signal)).rejects.toBe(error); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path, + method: "GET", + signal: controller.signal, + }); + }); +}); + +describe("listCapacityInstances", () => { + test("omits all unprovided query fields, including when params are omitted", async () => { + const { client, requestJson } = mockClient(); + await listCapacityInstances(client, code); + await listCapacityInstances(client, code, { + pageNo: undefined, + pageSize: undefined, + includeDeleted: undefined, + statuses: undefined, + chargeTypes: undefined, + }); + expect(requestJson).toHaveBeenCalledTimes(2); + for (const [request] of requestJson.mock.calls) { + expect(request).toStrictEqual({ + path: `${encodedDeployment}/capacity-instances`, + method: "GET", + signal: undefined, + }); + } + }); + + test.each([false, true])( + "encodes filters and sends includeDeleted=%s", + async (includeDeleted) => { + const { client, requestJson } = mockClient(); + const signal = new AbortController().signal; + const params: ListCapacityInstancesParams = { + pageNo: 2, + pageSize: 20, + includeDeleted, + statuses: ["RUNNING", "STOPPED", "FUTURE /?&+="], + chargeTypes: ["pre_paid", "post_paid"], + signal, + }; + await listCapacityInstances(client, code, params); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path: + `${encodedDeployment}/capacity-instances?page_no=2&page_size=20` + + `&include_deleted=${includeDeleted}` + + "&statuses=RUNNING%2CSTOPPED%2CFUTURE+%2F%3F%26%2B%3D&charge_types=pre_paid%2Cpost_paid", + method: "GET", + signal, + }); + }, + ); + + test("forwards explicitly provided zero pagination and empty filters without defaults", async () => { + const { client, requestJson } = mockClient(); + await listCapacityInstances(client, code, { + pageNo: 0, + pageSize: 0, + statuses: [], + chargeTypes: [], + }); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path: `${encodedDeployment}/capacity-instances?page_no=0&page_size=0&statuses=&charge_types=`, + method: "GET", + signal: undefined, + }); + }); + + test.each(["output", "data"] as const)( + "preserves %s records and pagination verbatim", + async (envelope) => { + const page = { + records: [instance], + items: 27, + page: 2, + itemsPerPage: 20, + pageCount: 2, + future_page_flag: false, + }; + const response: ListCapacityInstancesResponse = { + request_id: "query-request", + [envelope]: page, + future_response_field: 0, + }; + const { client } = mockClient(response); + const result = await listCapacityInstances(client, code); + expect(result).toBe(response); + expect(result[envelope]).toStrictEqual(page); + expect(result[envelope]?.records?.[0].effective_capacity?.input_tpm).toBe(0); + expect(result[envelope]?.records?.[0].deleted).toBe(false); + expect(result.future_response_field).toBe(0); + }, + ); + + test("preserves empty pages and zero totals", async () => { + const response: ListCapacityInstancesResponse = { + output: { records: [], items: 0, page: 1, itemsPerPage: 20, pageCount: 0 }, + }; + const { client } = mockClient(response); + expect(await listCapacityInstances(client, code)).toBe(response); + }); +}); + +describe("capacity query responses", () => { + test.each(["output", "data"] as const)( + "preserves instance %s, zero capacity, false flags and unknown fields", + async (envelope) => { + const response: GetCapacityInstanceResponse = { + request_id: "instance-query", + [envelope]: instance, + future_response_flag: false, + }; + const { client } = mockClient(response); + const result = await getCapacityInstance(client, code, identifier); + expect(result).toBe(response); + expect(result[envelope]).toStrictEqual(instance); + expect(result[envelope]?.instance_id).toBe(identifier); + expect(result[envelope]?.effective_capacity).toStrictEqual(capacity); + expect(result[envelope]?.pre_paid_info).toStrictEqual(prepaid); + expect(result[envelope]?.target_capacity).toBeNull(); + expect(result[envelope]?.can_scale).toBe(false); + expect(result[envelope]?.can_renew).toBe(false); + expect(result[envelope]?.can_delete).toBe(false); + expect(result.future_response_flag).toBe(false); + }, + ); + + test.each(["output", "data"] as const)( + "preserves operation %s and distinct query/original request IDs", + async (envelope) => { + const operation: CapacityOperation = { + operation_id: "000900719925474099312345", + request_id: "original-request", + operation_type: "FUTURE_OPERATION", + operation_status: "FUTURE_OPERATION_STATE", + model_service_id: code, + instance_id: identifier, + from_status: "FUTURE_FROM_STATE", + current_status: "FUTURE_CURRENT_STATE", + error_code: "original.error.code", + error_message: "Original service error message", + gmt_created: "2026-09-01T00:00:00Z", + gmt_finished: "2026-09-02T00:00:00Z", + future_operation_flag: false, + }; + const response: GetCapacityOperationResponse = { + request_id: "poll-request", + [envelope]: operation, + future_response_field: 0, + }; + const { client } = mockClient(response); + const result = await getCapacityOperation(client, code, operation.operation_id!); + expect(result).toBe(response); + expect(result[envelope]).toStrictEqual(operation); + expect(result.request_id).toBe("poll-request"); + expect(result[envelope]?.request_id).toBe("original-request"); + expect(result[envelope]?.operation_id).toBe("000900719925474099312345"); + expect(result.future_response_field).toBe(0); + }, + ); + + test("returns FAILED operations without translating service errors", async () => { + const response: GetCapacityOperationResponse = { + output: { + operation_status: "FAILED", + error_code: "ORIGINAL_CODE", + error_message: "Original message", + }, + }; + const { client } = mockClient(response); + expect(await getCapacityOperation(client, code, identifier)).toBe(response); + }); +}); + +describe("existing deployment queries", () => { + test("sends plan and existing pagination, but never sends the removed status filter", async () => { + const response: ListDeploymentsResponse = { output: { deployments: [], total: 0 } }; + const { client, requestJson } = mockClient(response); + const signal = new AbortController().signal; + // Extra properties from an older caller must not leak into the request. + const params = { pageNo: 2, pageSize: 10, plan: "ptu", status: "RUNNING", signal }; + expect(await listDeployments(client, params)).toBe(response); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path: "/api/v1/deployments?page_no=2&page_size=10&plan=ptu", + method: "GET", + signal, + }); + expectTypeOf().not.toHaveProperty("status"); + }); + + test("omits absent plan/pagination and URL-encodes provided plan", async () => { + const { client, requestJson } = mockClient(); + await listDeployments(client); + await listDeployments(client, { plan: "future /?&+=" }); + expect(requestJson).toHaveBeenNthCalledWith(1, { + path: "/api/v1/deployments", + method: "GET", + signal: undefined, + }); + expect(requestJson).toHaveBeenNthCalledWith(2, { + path: "/api/v1/deployments?plan=future+%2F%3F%26%2B%3D", + method: "GET", + signal: undefined, + }); + }); + + test.each(["output", "data"] as const)( + "preserves deployment throughput fields in %s without changing legacy PTU input", + async (envelope) => { + const deployment: Deployment = { + deployed_model: code, + ptu_capacity: capacity, + ptu_service_tier: "FUTURE_TIER", + pre_paid_info: prepaid, + pre_paid_instance_id: identifier, + pre_paid_gmt_expired: "2026-10-01T00:00:00Z", + overflow_strategy: "FUTURE_STRATEGY", + fail_reason: "original failure", + operation_id: "000900719925474099312345", + instance_id: identifier, + future_deployment_flag: false, + }; + const response: GetDeploymentResponse = { + request_id: "deployment-query", + [envelope]: deployment, + }; + const { client, requestJson } = mockClient(response); + const signal = new AbortController().signal; + expect(await getDeployment(client, code, signal)).toBe(response); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path: encodedDeployment, + method: "GET", + signal, + }); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + PtuCapacity | undefined + >(); + expectTypeOf().toEqualTypeOf(); + }, + ); +}); diff --git a/packages/core/tests/deploy-write-api.test.ts b/packages/core/tests/deploy-write-api.test.ts new file mode 100644 index 00000000..0f440c90 --- /dev/null +++ b/packages/core/tests/deploy-write-api.test.ts @@ -0,0 +1,667 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, test, vi } from "vite-plus/test"; +import { Client } from "../src/client/client.ts"; +import { + buildPrepaidInfo, + buildReservationCapacity, + createCapacityInstance, + createDeployment, + deleteCapacityInstance, + deleteDeployment, + deploymentCapacityInstanceRenewPath, + deploymentCapacityInstanceScalePath, + deploymentOverflowPath, + hasPrepaidFlags, + pickPlanStrategy, + renewCapacityInstance, + scaleCapacityInstance, + scaleDeployment, + updateDeploymentOverflow, + validatePrepaidFlags, + validateReservationCapacity, + type CapacityOperationResponse, + type CapacityWriteOptions, + type CreateCapacityInstanceRequest, + type CreateDeploymentRequest, + type CreatePlanFlags, + type DeleteDeploymentResponse, + type GetCapacityOperationResponse, + type PlanContext, + type PrePaidInfo, + type RenewCapacityInstanceRequest, + type ReservationCapacity, + type ReservationFlags, + type ScaleCapacityInstanceRequest, + type ScaleDeploymentRequest, +} from "../src/deploy/index.ts"; + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Unexpected network request"))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function mockClient(response: unknown = {}) { + const requestJson = vi.fn().mockResolvedValue(response); + return { client: { requestJson } as unknown as Client, requestJson }; +} + +function offlineClient(): Client { + const baseUrl = "https://offline.invalid"; + return new Client({ + identity: { + binName: "test-cli", + version: "0.0.0-test", + npmPackage: "test-cli", + clientName: "test-client", + }, + settings: { + output: "json", + outputExplicit: true, + timeout: 5, + watermark: true, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, + }, + baseUrl, + apiCred: { token: "offline-test-only", baseUrl, source: "flag" }, + }); +} + +const code = "model /?#%&+"; +const instanceId = "000900719925474099312345/实例 ?#%&+"; +const encodedDeployment = "/api/v1/deployments/model%20%2F%3F%23%25%26%2B"; +const encodedInstance = + `${encodedDeployment}/capacity-instances/` + + "000900719925474099312345%2F%E5%AE%9E%E4%BE%8B%20%3F%23%25%26%2B"; +const capacity: ReservationCapacity = { input_tpm: 0, output_tpm: 0 }; +const prepaid: PrePaidInfo = { duration: 30, auto_renewal: false }; +const createBody: CreateCapacityInstanceRequest = { + billing_method: "PRE_PAY", + ptu_capacity: capacity, + pre_paid_info: prepaid, +}; +const scaleBody: ScaleCapacityInstanceRequest = { + ptu_capacity: capacity, + pre_paid_info: prepaid, + order_type: "DOWNGRADE", +}; +const renewBody: RenewCapacityInstanceRequest = { + pre_paid_info: prepaid, + is_change: false, +}; +const writes = [ + { + name: "createCapacityInstance", + path: `${encodedDeployment}/capacity-instances`, + method: "POST", + body: createBody, + run: (client: Client, options?: CapacityWriteOptions) => + createCapacityInstance(client, code, createBody, options), + }, + { + name: "scaleCapacityInstance", + path: `${encodedInstance}/scale`, + method: "PUT", + body: scaleBody, + run: (client: Client, options?: CapacityWriteOptions) => + scaleCapacityInstance(client, code, instanceId, scaleBody, options), + }, + { + name: "renewCapacityInstance", + path: `${encodedInstance}/renew`, + method: "PUT", + body: renewBody, + run: (client: Client, options?: CapacityWriteOptions) => + renewCapacityInstance(client, code, instanceId, renewBody, options), + }, + { + name: "deleteCapacityInstance", + path: encodedInstance, + method: "DELETE", + body: undefined, + run: (client: Client, options?: CapacityWriteOptions) => + deleteCapacityInstance(client, code, instanceId, options), + }, +]; + +function idHeaders(requestId: string) { + return { "x-acs-req-uuid": requestId, "X-DashScope-RequestId": requestId }; +} + +function expectBilingual(error: string | undefined, flag?: string) { + expect(error).toMatch(/[A-Za-z]/); + expect(error).toMatch(/[\u4e00-\u9fff]/); + if (flag) expect(error).toContain(flag); +} + +describe("reservation write paths", () => { + test("encodes opaque path segments and appends exact action suffixes", () => { + expect(deploymentCapacityInstanceScalePath(code, instanceId)).toBe(`${encodedInstance}/scale`); + expect(deploymentCapacityInstanceRenewPath(code, instanceId)).toBe(`${encodedInstance}/renew`); + expect(deploymentOverflowPath(code)).toBe(`${encodedDeployment}/update-overflowstrategy`); + }); +}); + +describe.each(writes)("$name", ({ path, method, body, run }) => { + test.each([undefined, "", "caller-owned-request"])( + "uses exact request shape and does not invent request IDs: %s", + async (requestId) => { + const { client, requestJson } = mockClient(); + const signal = new AbortController().signal; + const options = Object.freeze({ requestId, signal }); + await run(client, options); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path, + method, + ...(body === undefined ? {} : { body }), + headers: requestId ? idHeaders(requestId) : undefined, + signal, + }); + const request = requestJson.mock.calls[0][0]; + expect(request.signal).toBe(signal); + if (body === undefined) expect(request).not.toHaveProperty("body"); + else expect(request.body).toBe(body); + }, + ); + + test("accepts omitted options without adding an idempotency header", async () => { + const { client, requestJson } = mockClient(); + await run(client); + expect(requestJson).toHaveBeenCalledOnce(); + expect(requestJson.mock.calls[0][0].headers).toBeUndefined(); + expect(requestJson.mock.calls[0][0].signal).toBeUndefined(); + }); + + test.each(["PROCESSING", "SUCCEEDED", "FAILED"])( + "returns %s operations verbatim in either response envelope", + async (operationStatus) => { + for (const envelope of ["output", "data"] as const) { + const response: CapacityOperationResponse = { + request_id: "http-request", + [envelope]: { + operation_id: "000900719925474099312345", + request_id: "original-operation-request", + operation_status: operationStatus, + error_code: "ORIGINAL_CODE", + error_message: "原始错误 / original service message", + future_field: false, + }, + }; + const { client, requestJson } = mockClient(response); + expect(await run(client)).toBe(response); + expect(requestJson).toHaveBeenCalledOnce(); + } + }, + ); + + test("passes cancellation and client errors through without retry", async () => { + const { client, requestJson } = mockClient(); + const controller = new AbortController(); + const error = new Error("client aborted"); + controller.abort(error); + requestJson.mockRejectedValue(error); + await expect(run(client, { signal: controller.signal, requestId: "same-id" })).rejects.toBe( + error, + ); + expect(requestJson).toHaveBeenCalledOnce(); + expect(requestJson.mock.calls[0][0].signal).toBe(controller.signal); + }); + + test.each([undefined, "wire-request-id"])( + "serializes zero/false and both request headers through Client with mocked fetch: %s", + async (requestId) => { + const response: CapacityOperationResponse = { + output: { + operation_status: "FAILED", + error_code: "SERVICE_FAILURE", + error_message: "Original error", + }, + }; + const fetchMock = vi.fn().mockResolvedValue(Response.json(response)); + vi.stubGlobal("fetch", fetchMock); + expect(await run(offlineClient(), { requestId })).toEqual(response); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe(`https://offline.invalid${path}`); + expect(options?.method).toBe(method); + expect(options?.body).toBe(body === undefined ? undefined : JSON.stringify(body)); + const headers = new Headers(options?.headers); + expect(headers.get("x-acs-req-uuid")).toBe(requestId ?? null); + expect(headers.get("X-DashScope-RequestId")).toBe(requestId ?? null); + expect(headers.get("Content-Type")).toBe("application/json"); + }, + ); + + test.each([409, 503])("does not retry HTTP %s service errors", async (status) => { + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json({ code: "ORIGINAL_CODE", message: "原始服务端错误" }, { status }), + ); + vi.stubGlobal("fetch", fetchMock); + await expect(run(offlineClient(), { requestId: "fixed-id" })).rejects.toMatchObject({ + message: "原始服务端错误", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +}); + +describe("specific write bodies and legacy compatibility", () => { + test("postpaid instance creation omits prepaid information", async () => { + const { client, requestJson } = mockClient(); + const body: CreateCapacityInstanceRequest = { + billing_method: "POST_PAY", + ptu_capacity: capacity, + }; + await createCapacityInstance(client, code, body); + expect(requestJson.mock.calls[0][0].body).toStrictEqual(body); + expect(body).not.toHaveProperty("pre_paid_info"); + }); + + test("renew with changed capacity has no order_type or body instance_id", async () => { + const { client, requestJson } = mockClient(); + const body: RenewCapacityInstanceRequest = { + pre_paid_info: prepaid, + is_change: true, + ptu_capacity: capacity, + }; + await renewCapacityInstance(client, code, instanceId, body); + expect(requestJson.mock.calls[0][0].body).toStrictEqual(body); + expectTypeOf().not.toHaveProperty("order_type"); + expectTypeOf().not.toHaveProperty("instance_id"); + expectTypeOf().toEqualTypeOf(); + }); + + test.each([undefined, "", "释放 /?&+#%= 原因"])( + "DELETE sends optional reason only as encoded query: %s", + async (reason) => { + const { client, requestJson } = mockClient(); + const expectedQuery = + reason === undefined + ? "" + : reason === "" + ? "?reason=" + : "?reason=%E9%87%8A%E6%94%BE+%2F%3F%26%2B%23%25%3D+%E5%8E%9F%E5%9B%A0"; + await deleteCapacityInstance(client, code, instanceId, { reason, requestId: "delete-id" }); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path: `${encodedInstance}${expectedQuery}`, + method: "DELETE", + headers: idHeaders("delete-id"), + signal: undefined, + }); + expect(requestJson.mock.calls[0][0]).not.toHaveProperty("body"); + }, + ); + + test.each(["enable", "disable"] as const)( + "updates deployment overflow to %s", + async (strategy) => { + const response = { output: { deployed_model: code, overflow_strategy: strategy } }; + const { client, requestJson } = mockClient(response); + const signal = new AbortController().signal; + expect(await updateDeploymentOverflow(client, code, strategy, signal)).toBe(response); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path: `${encodedDeployment}/update-overflowstrategy`, + method: "PUT", + body: { overflow_strategy: strategy }, + signal, + }); + }, + ); + + test("initial ModelCode creation allows missing name without instance idempotency headers", async () => { + const body: CreateDeploymentRequest = { + model_name: "base-model", + plan: "ptu", + charge_type: "pre_paid", + service_tier: "ptu_default", + suffix: "custom-suffix", + ptu_capacity: capacity, + pre_paid_info: prepaid, + }; + const response = { output: { deployed_model: code, operation_id: "123" } }; + const { client, requestJson } = mockClient(response); + const signal = new AbortController().signal; + expect(await createDeployment(client, body, signal)).toBe(response); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path: "/api/v1/deployments", + method: "POST", + body, + signal, + }); + expect(requestJson.mock.calls[0][0]).not.toHaveProperty("headers"); + }); + + test.each([undefined, "legacy-scale-id"])( + "old scale preserves fourth-argument signal and accepts fifth-argument ID: %s", + async (requestId) => { + for (const body of [ + { capacity: 0 }, + { + instance_id: instanceId, + ptu_capacity: capacity, + pre_paid_info: prepaid, + order_type: "UPGRADE", + }, + ] satisfies ScaleDeploymentRequest[]) { + const response = { output: { deployed_model: code, operation_id: "123" } }; + const { client, requestJson } = mockClient(response); + const signal = new AbortController().signal; + expect(await scaleDeployment(client, code, body, signal, requestId)).toBe(response); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path: `${encodedDeployment}/scale`, + method: "PUT", + body, + headers: requestId ? idHeaders(requestId) : undefined, + signal, + }); + } + }, + ); + + test.each(["output", "data"] as const)( + "delete deployment accepts a deployment in %s", + async (envelope) => { + const response: DeleteDeploymentResponse = { + [envelope]: { + deployed_model: code, + plan: "ptu", + status: "STOPPED", + ptu_capacity: capacity, + deleted: true, + }, + }; + const { client, requestJson } = mockClient(response); + expect(await deleteDeployment(client, code)).toBe(response); + expectTypeOf(response[envelope]?.ptu_capacity).toEqualTypeOf< + ReservationCapacity | undefined + >(); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path: encodedDeployment, + method: "DELETE", + signal: undefined, + }); + }, + ); +}); + +describe("shared reservation capacity", () => { + test("requires a pair only when capacity is supplied or required", () => { + expect(validateReservationCapacity({}, false)).toBeUndefined(); + expectBilingual(validateReservationCapacity({}, true), "--input-tpm"); + for (const required of [false, true]) { + expectBilingual(validateReservationCapacity({ inputTpm: 0 }, required), "--output-tpm"); + expectBilingual(validateReservationCapacity({ outputTpm: 0 }, required), "--input-tpm"); + } + }); + + test.each([-1, 0.5, NaN, Infinity, -Infinity, Number.MAX_SAFE_INTEGER + 1])( + "rejects invalid capacity %s in either dimension", + (value) => { + for (const required of [false, true]) { + expectBilingual( + validateReservationCapacity({ inputTpm: value, outputTpm: 0 }, required), + "--input-tpm", + ); + expectBilingual( + validateReservationCapacity({ inputTpm: 0, outputTpm: value }, required), + "--output-tpm", + ); + } + }, + ); + + test.each([0, 1, 7, Number.MAX_SAFE_INTEGER])( + "keeps kTPM %s without model limits or conversion", + (value) => { + const flags = { inputTpm: value, outputTpm: value }; + expect(validateReservationCapacity(flags, true)).toBeUndefined(); + expect(buildReservationCapacity(flags)).toStrictEqual({ + input_tpm: value, + output_tpm: value, + }); + }, + ); +}); + +describe("shared prepaid information", () => { + test("omits an absent block and retains an explicit false", () => { + expect(hasPrepaidFlags({ inputTpm: 0, outputTpm: 0 })).toBe(false); + expect(buildPrepaidInfo({})).toBeUndefined(); + expect(buildPrepaidInfo({ duration: undefined, autoRenewal: undefined })).toBeUndefined(); + expect(validatePrepaidFlags({}, false)).toBeUndefined(); + expectBilingual(validatePrepaidFlags({}, true), "--duration"); + expect(hasPrepaidFlags({ autoRenewal: false })).toBe(true); + expect(buildPrepaidInfo({ duration: 30, autoRenewal: false })).toStrictEqual(prepaid); + expect(validatePrepaidFlags({ duration: 30, autoRenewal: false }, true)).toBeUndefined(); + }); + + test.each([ + { duration: 30 }, + { autoRenewal: false }, + { autoRenewalDuration: 30 }, + { autoRenewalCycle: "Day" }, + ] satisfies ReservationFlags[])("any prepaid field requires the complete block: %j", (flags) => { + expect(hasPrepaidFlags(flags)).toBe(true); + expectBilingual(validatePrepaidFlags(flags, false)); + }); + + test.each([undefined, 0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( + "requires positive safe purchase and automatic renewal durations: %s", + (duration) => { + expectBilingual(validatePrepaidFlags({ duration, autoRenewal: false }, false), "--duration"); + expectBilingual( + validatePrepaidFlags( + { duration: 30, autoRenewal: true, autoRenewalDuration: duration }, + false, + ), + "--auto-renewal-duration", + ); + }, + ); + + test.each([undefined, "false", 0, null])("requires an explicit boolean: %s", (autoRenewal) => { + const flags = { duration: 30, autoRenewal } as unknown as ReservationFlags; + expectBilingual(validatePrepaidFlags(flags, true), "--auto-renewal"); + }); + + test.each(["", " ", "\t\n"])("rejects an empty cycle: %j", (autoRenewalCycle) => { + expectBilingual( + validatePrepaidFlags({ duration: 30, autoRenewal: false, autoRenewalCycle }, false), + "--auto-renewal-cycle", + ); + }); + + test("passes future cycle names and safe durations through unchanged", () => { + const flags: ReservationFlags = { + duration: Number.MAX_SAFE_INTEGER, + autoRenewal: true, + autoRenewalDuration: 1, + autoRenewalCycle: "FUTURE_CYCLE", + }; + expect(validatePrepaidFlags(flags, true)).toBeUndefined(); + expect(buildPrepaidInfo(flags)).toStrictEqual({ + duration: Number.MAX_SAFE_INTEGER, + auto_renewal: true, + auto_renewal_duration: 1, + auto_renewal_cycle: "FUTURE_CYCLE", + }); + expectBilingual( + validatePrepaidFlags({ duration: 30, autoRenewal: false, autoRenewalDuration: 0 }, false), + "--auto-renewal-duration", + ); + }); +}); + +function planContext(client: Client, flags: CreatePlanFlags = {}, dryRun = false): PlanContext { + return { client, flags, dryRun, model: "base-model", binName: "test-cli" }; +} + +describe("PTU creation strategy", () => { + const strategy = pickPlanStrategy("ptu"); + const validFlags: CreatePlanFlags = { chargeType: "post_paid", inputTpm: 0, outputTpm: 7 }; + + test.each([ + { chargeType: undefined }, + { chargeType: "POST_PAY" }, + { chargeType: "" }, + { serviceTier: "ptu_v2" }, + { serviceTier: "" }, + { serviceTier: "ptu_default" }, + { thinkingOutputTpm: 0 }, + { capacity: 0 }, + { deploySpec: "MU1" }, + { billingMethod: "POST_PAY" }, + { duration: 30 }, + { autoRenewal: false }, + { autoRenewalDuration: 30 }, + { autoRenewalCycle: "Day" }, + { inputTpm: undefined }, + { outputTpm: -1 }, + { chargeType: "pre_paid" }, + { chargeType: "pre_paid", duration: 30 }, + { chargeType: "pre_paid", duration: 30, autoRenewal: true }, + ] satisfies CreatePlanFlags[])( + "rejects invalid combinations with bilingual errors: %j", + (overrides) => { + expectBilingual(strategy.validateFlags({ ...validFlags, ...overrides })); + }, + ); + + test.each([undefined, "ptu_fast"])( + "postpaid omits optional fields without defaults: %s", + async (serviceTier) => { + const flags = { ...validFlags, serviceTier }; + const { client, requestJson } = mockClient(); + expect(strategy.validateFlags(flags)).toBeUndefined(); + expect(await strategy.resolve(planContext(client, flags))).toStrictEqual({ + body: { + charge_type: "post_paid", + ptu_capacity: { input_tpm: 0, output_tpm: 7 }, + ...(serviceTier === undefined ? {} : { service_tier: serviceTier }), + }, + }); + expect(requestJson).not.toHaveBeenCalled(); + }, + ); + + test.each([undefined, "ptu_fast", "ptu_default"])( + "prepaid builds reservation fields for %s", + async (serviceTier) => { + const flags: CreatePlanFlags = { + ...validFlags, + chargeType: "pre_paid", + serviceTier, + suffix: "my-suffix", + duration: 30, + autoRenewal: false, + }; + const { client, requestJson } = mockClient(); + expect(strategy.validateFlags(flags)).toBeUndefined(); + expect(await strategy.resolve(planContext(client, flags))).toStrictEqual({ + body: { + charge_type: "pre_paid", + ptu_capacity: { input_tpm: 0, output_tpm: 7 }, + pre_paid_info: prepaid, + suffix: "my-suffix", + ...(serviceTier === undefined ? {} : { service_tier: serviceTier }), + }, + }); + expect(requestJson).not.toHaveBeenCalled(); + }, + ); +}); + +describe("MU and LoRA regressions", () => { + test("LoRA keeps the placeholder capacity and does not call the catalog", async () => { + const strategy = pickPlanStrategy("lora"); + const { client, requestJson } = mockClient(); + expect(strategy.validateFlags({})).toBeUndefined(); + expect(await strategy.resolve(planContext(client))).toStrictEqual({ body: { capacity: 1 } }); + expect(requestJson).not.toHaveBeenCalled(); + }); + + test.each([ + { dryRun: true, flags: {}, expected: { capacity: 1, billing_method: "POST_PAY" } }, + { + dryRun: false, + flags: { deploySpec: "explicit" }, + expected: { capacity: 1, billing_method: "POST_PAY", deploy_spec: "explicit" }, + }, + { + dryRun: false, + flags: { deploySpec: "explicit", capacity: 0, billingMethod: "PRE_PAY" }, + expected: { capacity: 0, billing_method: "PRE_PAY", deploy_spec: "explicit" }, + }, + ])("MU skips catalog for dry-run or explicit spec: %j", async ({ dryRun, flags, expected }) => { + const strategy = pickPlanStrategy("mu"); + const { client, requestJson } = mockClient(); + expect(strategy.validateFlags(flags)).toBeUndefined(); + expect(await strategy.resolve(planContext(client, flags, dryRun))).toStrictEqual({ + body: expected, + }); + expect(requestJson).not.toHaveBeenCalled(); + }); + + test.each(["output", "data"] as const)( + "MU selects billing-matched catalog templates in %s", + async (envelope) => { + const templates = [ + { + template_id: "prepaid-spec", + charge_type: "pre_paid", + roles: { unified: { capacity_unit_per_instance: 2 } }, + }, + { + deploy_spec: "postpaid-spec", + charge_type: "post_paid", + roles: { unified: { capacity_unit_per_instance: 8 } }, + }, + ]; + for (const billingMethod of [undefined, "PRE_PAY"]) { + const { client, requestJson } = mockClient({ + [envelope]: { + models: [{ model_name: "base-model", plans: [{ plan: "mu", templates }] }], + }, + }); + expect( + await pickPlanStrategy("mu").resolve(planContext(client, { billingMethod })), + ).toStrictEqual({ + body: + billingMethod === "PRE_PAY" + ? { capacity: 2, billing_method: "PRE_PAY", deploy_spec: "prepaid-spec" } + : { capacity: 8, billing_method: "POST_PAY", deploy_spec: "postpaid-spec" }, + }); + expect(requestJson).toHaveBeenCalledExactlyOnceWith({ + path: "/api/v1/deployments/models?page_size=100&version=v1.0&model_source=base", + method: "GET", + signal: undefined, + }); + } + }, + ); + + test("MU keeps explicit capacity and falls back to the first template", async () => { + const { client } = mockClient({ + output: { + models: [ + { + model_name: "base-model", + plans: [ + { plan: "mu", templates: [{ template_id: "fallback", charge_type: "pre_paid" }] }, + ], + }, + ], + }, + }); + expect( + await pickPlanStrategy("mu").resolve(planContext(client, { capacity: 5 })), + ).toStrictEqual({ + body: { capacity: 5, billing_method: "POST_PAY", deploy_spec: "fallback" }, + }); + }); +}); diff --git a/packages/core/tests/http.test.ts b/packages/core/tests/http.test.ts new file mode 100644 index 00000000..c8068161 --- /dev/null +++ b/packages/core/tests/http.test.ts @@ -0,0 +1,324 @@ +import { getEventListeners } from "node:events"; +import { createServer, type RequestListener } from "node:http"; +import type { Socket } from "node:net"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { Client } from "../src/client/client.ts"; +import { request, requestJson, type HttpDeps } from "../src/client/http.ts"; +import { BailianError } from "../src/errors/base.ts"; +import { ExitCode } from "../src/errors/codes.ts"; + +function testDeps(timeout = 5): HttpDeps { + return { + identity: { + binName: "test-cli", + version: "0.0.0-test", + npmPackage: "test-cli", + clientName: "test-client", + }, + settings: { + output: "json", + outputExplicit: true, + timeout, + watermark: true, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, + }, + }; +} + +function testClient(baseUrl: string, deps = testDeps()): Client { + return new Client({ + ...deps, + baseUrl, + apiCred: { token: "local-test-only", baseUrl, source: "flag" }, + }); +} + +async function withServer( + handler: RequestListener, + run: (url: string) => Promise, +): Promise { + const sockets = new Set(); + const server = createServer(handler); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + // Bound failures even if a regression leaves a body read hanging indefinitely. + const watchdog = setTimeout(() => server.closeAllConnections(), 3_000); + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a local TCP server"); + await run(`http://127.0.0.1:${address.port}`); + } finally { + clearTimeout(watchdog); + const closed = new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + for (const socket of sockets) socket.destroy(); + await closed; + } +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("real HTTP response bodies", () => { + test.each([ + { caller: "requestJson", status: 200 }, + { caller: "requestJson", status: 503 }, + { caller: "Client.requestJson", status: 200 }, + { caller: "request", status: 503 }, + ])("$caller times out while reading a partial $status JSON body", async ({ caller, status }) => { + const readBody = vi.spyOn(Response.prototype, "json"); + await withServer( + (_incoming, response) => { + response.writeHead(status, { "content-type": "application/json" }); + response.flushHeaders(); + response.write('{"message":"unfinished'); + }, + async (url) => { + const startedAt = performance.now(); + const pending = + caller === "Client.requestJson" + ? testClient(url, testDeps(0.15)).requestJson({ path: "/" }) + : caller === "request" + ? request(testDeps(), { url, timeout: 0.15 }) + : requestJson(testDeps(), { url, timeout: 0.15 }); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(readBody).toHaveBeenCalledOnce(); + expect(performance.now() - startedAt).toBeLessThan(2_000); + }, + ); + }); + + test.each([200, 503])( + "Client preserves the exact parent reason when aborted after %i headers", + async (status) => { + const parent = new AbortController(); + const reason = new BailianError("Caller wait budget exhausted", ExitCode.TIMEOUT); + const originalJson = Response.prototype.json; + let bodyStarted!: () => void; + const readingBody = new Promise((resolve) => { + bodyStarted = resolve; + }); + vi.spyOn(Response.prototype, "json").mockImplementationOnce(function (this: Response) { + bodyStarted(); + return originalJson.call(this); + }); + await withServer( + (_incoming, response) => { + response.writeHead(status, { "content-type": "application/json" }); + response.flushHeaders(); + response.write('{"message":"unfinished'); + }, + async (url) => { + const pending = testClient(url).requestJson({ path: "/", signal: parent.signal }); + const rejected = expect(pending).rejects.toBe(reason); + await readingBody; + expect(getEventListeners(parent.signal, "abort")).toHaveLength(1); + parent.abort(reason); + await rejected; + expect(getEventListeners(parent.signal, "abort")).toHaveLength(0); + }, + ); + }, + ); + + test.each([undefined, "200", "Success"])("accepts normal JSON with code %s", async (code) => { + const body = { code, output: { text: "正常 JSON" } }; + await withServer( + (_incoming, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }, + async (url) => { + await expect(testClient(url).requestJson({ path: "/" })).resolves.toEqual(body); + }, + ); + }); + + test.each([ + { + label: "top-level service code", + status: 200, + contentType: "application/json", + body: '{"code":"ServiceBusy","message":"服务端原文 / original service message"}', + message: "服务端原文 / original service message", + api: { httpStatus: 200, apiCode: "ServiceBusy" }, + }, + { + label: "HTTP service error", + status: 503, + contentType: "application/json", + body: '{"error":{"type":"Unavailable","message":"服务端原文 / original service message"},"request_id":"local-request"}', + message: "服务端原文 / original service message", + api: { httpStatus: 503, apiCode: "Unavailable", requestId: "local-request" }, + }, + { + label: "non-JSON success response", + status: 200, + contentType: "text/html", + body: "not JSON", + message: expect.stringContaining("API returned non-JSON response (text/html)"), + api: undefined, + }, + { + label: "non-JSON HTTP error", + status: 503, + contentType: "text/html", + body: "unavailable", + message: "HTTP 503", + api: { httpStatus: 503 }, + }, + ])("keeps $label behavior", async ({ status, contentType, body, message, api }) => { + await withServer( + (_incoming, response) => { + response.writeHead(status, { "content-type": contentType }); + response.end(body); + }, + async (url) => { + await expect(requestJson(testDeps(), { url })).rejects.toMatchObject({ + name: "BailianError", + exitCode: ExitCode.GENERAL, + message, + api, + }); + }, + ); + }); +}); + +describe("request signal cleanup", () => { + test.each([ + { label: "success", status: 200, body: '{"ok":true}', fails: false }, + { label: "HTTP error", status: 503, body: '{"message":"service error"}', fails: true }, + { label: "service code", status: 200, body: '{"code":"ServiceError"}', fails: true }, + { label: "non-JSON", status: 200, body: "not JSON", fails: true }, + ])( + "keeps one timer and parent listener through the body, then cleans up on $label", + async ({ status, body, fails }) => { + vi.useFakeTimers(); + const parent = new AbortController(); + let finishBody!: () => void; + const response = new Response(body, { status }); + const originalJson = response.json.bind(response); + let bodyStarted!: () => void; + const readingBody = new Promise((resolve) => { + bodyStarted = resolve; + }); + vi.spyOn(response, "json").mockImplementation(async () => { + bodyStarted(); + await new Promise((resolve) => { + finishBody = resolve; + }); + return originalJson(); + }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(response); + const pending = requestJson(testDeps(), { + url: "http://127.0.0.1/unused", + signal: parent.signal, + }); + await readingBody; + const signal = fetchMock.mock.calls[0][1]!.signal!; + expect(vi.getTimerCount()).toBe(1); + expect(getEventListeners(parent.signal, "abort")).toHaveLength(1); + finishBody(); + if (fails) await expect(pending).rejects.toBeInstanceOf(BailianError); + else await expect(pending).resolves.toEqual({ ok: true }); + expect(vi.getTimerCount()).toBe(0); + expect(getEventListeners(parent.signal, "abort")).toHaveLength(0); + expect(getEventListeners(signal, "abort")).toHaveLength(0); + parent.abort(new Error("Too late")); + await vi.advanceTimersByTimeAsync(10_000); + expect(signal.aborted).toBe(false); + }, + ); + + test.each([false, true])("cleans up a fetch failure (already aborted: %s)", async (aborted) => { + vi.useFakeTimers(); + const parent = new AbortController(); + const reason = { budget: "exhausted" }; + if (aborted) parent.abort(reason); + const failure = new TypeError("fetch failed"); + const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(failure); + await expect( + requestJson(testDeps(), { url: "http://127.0.0.1/unused", signal: parent.signal }), + ).rejects.toBe(aborted ? reason : failure); + const signal = fetchMock.mock.calls[0][1]!.signal!; + expect(vi.getTimerCount()).toBe(0); + expect(getEventListeners(parent.signal, "abort")).toHaveLength(0); + expect(getEventListeners(signal, "abort")).toHaveLength(0); + }); + + test.each(["timeout", "parent"])( + "cleans up during %s while preserving the signal reason", + async (source) => { + vi.useFakeTimers(); + const parent = new AbortController(); + const reason = new Error("Parent budget exhausted"); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => { + const signal = init!.signal!; + const response = new Response("{}"); + vi.spyOn(response, "json").mockImplementation( + () => + new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => reject(new DOMException("Body aborted", "AbortError")), + { once: true }, + ); + }), + ); + return response; + }); + const pending = requestJson(testDeps(), { + url: "http://127.0.0.1/unused", + signal: parent.signal, + }); + const signal = fetchMock.mock.calls[0][1]!.signal!; + const rejected = expect(pending).rejects.toSatisfy( + (error: unknown) => error === signal.reason, + ); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(1); + if (source === "parent") parent.abort(reason); + else await vi.advanceTimersByTimeAsync(5_000); + await rejected; + if (source === "parent") expect(signal.reason).toBe(reason); + expect(vi.getTimerCount()).toBe(0); + expect(getEventListeners(parent.signal, "abort")).toHaveLength(0); + expect(getEventListeners(signal, "abort")).toHaveLength(0); + }, + ); + + test("raw success returns the identical unconsumed Response and detaches at headers", async () => { + vi.useFakeTimers(); + const parent = new AbortController(); + const response = new Response('{"stream":"still readable"}'); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(response); + const result = await request(testDeps(), { + url: "http://127.0.0.1/unused", + signal: parent.signal, + stream: true, + }); + const signal = fetchMock.mock.calls[0][1]!.signal!; + expect(result).toBe(response); + expect(result.bodyUsed).toBe(false); + expect(vi.getTimerCount()).toBe(0); + expect(getEventListeners(parent.signal, "abort")).toHaveLength(0); + expect(getEventListeners(signal, "abort")).toHaveLength(0); + parent.abort(); + await vi.advanceTimersByTimeAsync(10_000); + expect(signal.aborted).toBe(false); + await expect(result.json()).resolves.toEqual({ stream: "still readable" }); + }); +}); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index ce41f46c..e0844208 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -37,6 +37,7 @@ export { MCP_WEBSEARCH_PAGE, mcpMarketplaceDetailPage, VOICE_TTS_PAGE, + refundConsolePage, } from "./urls.ts"; // Output facilities consumed by commands diff --git a/packages/runtime/src/urls.ts b/packages/runtime/src/urls.ts index 16ada5ce..ace2b669 100644 --- a/packages/runtime/src/urls.ts +++ b/packages/runtime/src/urls.ts @@ -31,3 +31,12 @@ export const MCP_WEBSEARCH_PAGE = mcpMarketplaceDetailPage("WebSearch"); /** Voice TTS experience center — browse system and custom voices. */ export const VOICE_TTS_PAGE = "https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list"; + +/** + * Aliyun billing console refund (unsubscribe) page for one prepaid instance. + * Unsubscribing a prepaid PTU capacity instance is a billing action that has + * no API; users complete it here with the account that placed the order. + */ +export function refundConsolePage(instanceId: string): string { + return `https://billing-cost.console.aliyun.com/refund/refund?instanceId=${encodeURIComponent(instanceId)}`; +} diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index 95f7c179..46c1259e 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -37,16 +37,26 @@ description: >- ## When to use which command -| Intent | Command | -| ------------------------------- | ------------------------------------------------------------------------------------------------ | -| Validate / upload training data | `bl dataset validate` / `upload` (`.jsonl` or `.zip`) | -| Dataset list / detail / delete | `bl dataset list` / `get` / `delete` | -| Create a fine-tuning job | `bl finetune text\|audio\|image create` | -| Job list / detail / follow | `bl finetune list` / `get` / `watch` / `logs` | -| Artifacts and export | `bl finetune checkpoints` / `export` | -| Cancel / delete a job | `bl finetune cancel` / `delete` | -| Trainable capability lookup | `bl finetune capability` | -| Deploy / lifecycle | `bl deploy text\|audio\|image create`, `list` / `get` / `update` / `scale` / `delete` / `models` | +| Intent | Command | +| -------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Validate / upload training data | `bl dataset validate` / `upload` (`.jsonl` or `.zip`) | +| Dataset list / detail / delete | `bl dataset list` / `get` / `delete` | +| Create a fine-tuning job | `bl finetune text\|audio\|image create` | +| Job list / detail / follow | `bl finetune list` / `get` / `watch` / `logs` | +| Artifacts and export | `bl finetune checkpoints` / `export` | +| Cancel / delete a job | `bl finetune cancel` / `delete` | +| Trainable capability lookup | `bl finetune capability` | +| Deploy / lifecycle | `bl deploy text\|audio\|image create`, `list` / `get` / `update` / `scale` / `delete` / `models` | +| Query throughput reservations | `bl deploy list --plan ptu` / `bl deploy get` | +| Query capacity instances | `bl deploy capacity list` / `get` | +| Query / wait for a capacity operation | `bl deploy operation get` / `wait` | +| Buy / scale / renew / release capacity | `bl deploy capacity create` / `scale` / `renew` / `delete` | +| Unsubscribe a prepaid instance | `bl deploy capacity unsubscribe` (builds the billing console refund link) | +| Configure ModelCode overflow strategy | `bl deploy overflow` | + +The `capacity list` / `get`, `operation get` / `wait` and `deploy list` / `get` queries are read-only. Capacity values are kTPM; effective, configured and target capacities are distinct. `deploy list --status` filters only the fetched page locally, with the server total left unfiltered. `operation get` reports status as data; `operation wait` exits non-zero on failure or timeout and refreshes capacity after success. Use IDs returned by the API; waiting never retries a write. + +The `capacity create` / `scale` / `renew` / `delete` and `overflow` commands are high-risk writes: preview with `--dry-run`, then confirm with the runtime-injected `--yes`. Each write is submitted once and never auto-retried; HTTP 200 is not success, so pass `--wait` or check `operation_status`. Scale values are one instance's absolute kTPM, not deltas or ModelCode totals, and zero is not release. `capacity delete` releases an instance but keeps the ModelCode; active prepaid instances cannot be DELETEd — run `capacity unsubscribe --instance-id ` to get the billing console refund link and finish there (no API exists for refunds). Release is confirmed by `deleted=true`. `overflow` applies to the whole ModelCode, not one instance. There is no estimator or standalone auto-renewal endpoint — renewal settings ride along purchase/scale/renew. Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl --help` — do not guess flags. diff --git a/skills/bailian-finetune/reference/deploy.md b/skills/bailian-finetune/reference/deploy.md index b411322d..24b80dbb 100644 --- a/skills/bailian-finetune/reference/deploy.md +++ b/skills/bailian-finetune/reference/deploy.md @@ -7,55 +7,82 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Authentication | Description | -| ------------------------ | -------------- | ------------------------------------------------------------- | -| `bl deploy audio create` | API Key | Create an audio (TTS) model deployment | -| `bl deploy delete` | API Key | Delete a model deployment (must be STOPPED or FAILED) | -| `bl deploy get` | API Key | Get details of a single model deployment | -| `bl deploy image create` | API Key | Create an image generation model deployment | -| `bl deploy list` | API Key | List model deployments | -| `bl deploy models` | API Key | List models available for deployment | -| `bl deploy pause` | Console | Pause a running model deployment (stops billing for mu/ptu) | -| `bl deploy resume` | Console | Resume a paused model deployment (brings service back online) | -| `bl deploy scale` | API Key | Scale a deployment's capacity | -| `bl deploy text create` | API Key | Create a text model deployment | -| `bl deploy update` | API Key | Update a deployment's rate limits (rpm_limit / tpm_limit) | +| Command | Authentication | Description | +| -------------------------------- | -------------- | --------------------------------------------------------------------------------- | +| `bl deploy audio create` | API Key | Create an audio (TTS) model deployment | +| `bl deploy capacity create` | API Key | Purchase an additional capacity instance for a ModelCode | +| `bl deploy capacity delete` | API Key | Release a capacity instance without deleting its ModelCode | +| `bl deploy capacity get` | API Key | Get a throughput reservation capacity instance | +| `bl deploy capacity list` | API Key | List throughput reservation capacity instances | +| `bl deploy capacity renew` | API Key | Renew a prepaid capacity instance, optionally changing capacity | +| `bl deploy capacity scale` | API Key | Scale the absolute capacity of one reservation instance | +| `bl deploy capacity unsubscribe` | No Auth | Build the Aliyun billing console unsubscribe link for a prepaid capacity instance | +| `bl deploy delete` | API Key | Delete a model deployment (PTU must be STOPPED with all capacity released) | +| `bl deploy get` | API Key | Get details of a single model deployment | +| `bl deploy image create` | API Key | Create an image generation model deployment | +| `bl deploy list` | API Key | List model deployments and throughput reservations | +| `bl deploy models` | API Key | List models available for deployment | +| `bl deploy operation get` | API Key | Query a throughput reservation capacity operation once | +| `bl deploy operation wait` | API Key | Wait for a capacity operation and refresh confirmed capacity | +| `bl deploy overflow` | API Key | Set the overflow strategy for a throughput reservation ModelCode | +| `bl deploy pause` | Console | Pause a running model deployment (stops billing for mu/ptu) | +| `bl deploy resume` | Console | Resume a paused model deployment (brings service back online) | +| `bl deploy scale` | API Key | Scale a deployment's capacity | +| `bl deploy text create` | API Key | Create a text model deployment | +| `bl deploy update` | API Key | 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 | -| **Authentication** | API Key | -| **Usage** | `bl deploy audio create --model-name --display-name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]` | +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy audio create` | +| **Description** | Create an audio (TTS) model deployment | +| **Authentication** | API Key | +| **Usage** | `bl deploy audio create --model-name [--display-name ] [--plan ] [--charge-type ] [--service-tier ] [--suffix ] [--input-tpm --output-tpm ] [flags]` | +| **Risk** | `high` | +| **Risk message** | Creating a deployment purchases or provisions resources and may incur charges. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags -| Flag | Type | Required | Description | -| ------------------------------- | ------ | -------- | ------------------------------------------------------------------------------- | -| `--model-name ` | string | yes | Model to deploy — fine-tuned output name or catalog model (required) | -| `--display-name ` | string | yes | Console display name for the deployment (required) | -| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu | -| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | -| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | -| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | -| `--input-tpm ` | number | no | PTU max input tokens/min (required for plan=ptu) | -| `--output-tpm ` | number | no | PTU max output tokens/min (required for plan=ptu) | -| `--thinking-output-tpm ` | number | no | PTU max thinking-output tokens/min (optional, some models) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ---------------------------------------- | ------- | -------- | -------------------------------------------------------------------------------- | +| `--model-name ` | string | yes | Model to deploy — fine-tuned output name or catalog model (required) | +| `--display-name ` | string | no | Console display name (optional for PTU; required for other plans) | +| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (throughput reservation) \| mu | +| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | +| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | +| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | +| `--input-tpm ` | number | no | Absolute input capacity in kTPM (1 kTPM = 1000 tokens/minute) | +| `--output-tpm ` | number | no | Absolute output capacity in kTPM | +| `--thinking-output-tpm ` | number | no | Legacy thinking-output capacity flag; not supported for PTU | +| `--charge-type ` | string | no | PTU payment type (required for plan=ptu): pre_paid or post_paid | +| `--service-tier ` | string | no | PTU service tier: ptu_fast (default) or ptu_default (prepaid only) | +| `--suffix ` | string | no | Optional PTU ModelCode suffix; generated by the service when omitted | +| `--duration ` | number | no | Prepaid purchase/renewal duration, positive integer days | +| `--auto-renewal ` | boolean | no | Explicitly enable or disable automatic renewal for prepaid purchases | +| `--auto-renewal-duration ` | number | no | Automatic renewal duration; required when enabled | +| `--auto-renewal-cycle ` | string | no | Optional supported renewal cycle, e.g. Day | +| `--wait` | switch | no | Wait for the returned capacity operation and refresh capacity | +| `--interval ` | number | no | Initial wait poll interval (1–3600, default: 2 seconds) | +| `--poll-timeout ` | number | no | Wait budget after submission (default: 600 seconds) | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Notes - Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-billed) for audio (CosyVoice TTS). Pass --plan to override. -- For plan=ptu (Token-billed, provisioned throughput), --input-tpm and --output-tpm are required (the platform rejects creation without an explicit ptu_capacity despite the doc listing defaults). +- For plan=ptu, --charge-type, --input-tpm and --output-tpm are required. Capacity is in kTPM (1 kTPM = 1000 tokens/minute). Separate thinking-output capacity is unsupported. Prepaid requires --duration and explicit --auto-renewal true|false. - For plan=mu, `capacity`, `billing_method` and `deploy_spec` are required. billing_method defaults to POST_PAY (only supported value); deploy_spec and capacity are auto-picked from GET /deployments/models when omitted. -- Use `bl deploy models --source base` to inspect available templates. -- After creation, status starts at PENDING and transitions to RUNNING. Invoke the deployed model with: bl text chat --model -- 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 `) and lifecycle commands (`deploy get/scale/pause/resume/delete --deployed-model `). +- Use `deploy models --source base` to inspect available templates. +- PTU creation preserves the original JSON and operation_id even with --quiet. --wait only queries the returned operation; pending capacity is not effective capacity. Check the actual status before invoking the model. +- --model-name identifies the source model; the returned deployed_model is the invocation identifier (ModelCode). Use it for inference (`text chat --model `) and deployment lifecycle commands. +- Initial ModelCode creation does not support --request-id or promise idempotency. A timeout is not proof of failure: check whether the deployment was created before trying again. This command never automatically resubmits creation. +- Use --dry-run to preview without requests. Pass --yes only after confirming the costs. #### Examples @@ -71,12 +98,291 @@ bl deploy audio create --model-name my-cosyvoice-ft --display-name my-tts --depl bl deploy audio create --model-name my-cosyvoice-ft --display-name my-tts --dry-run ``` +### `bl deploy capacity create` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy capacity create` | +| **Description** | Purchase an additional capacity instance for a ModelCode | +| **Authentication** | API Key | +| **Usage** | `bl deploy capacity create --deployed-model --billing-method --input-tpm --output-tpm [--duration ] [--auto-renewal ] [--wait]` | +| **Risk** | `high` | +| **Risk message** | Purchases additional throughput capacity and may enable recurring charges. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. + +#### Flags + +| Flag | Type | Required | Description | +| -------------------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (ModelCode) | +| `--input-tpm ` | number | no | Absolute input capacity in kTPM (1 kTPM = 1000 tokens/minute) | +| `--output-tpm ` | number | no | Absolute output capacity in kTPM | +| `--duration ` | number | no | Prepaid purchase/renewal duration, positive integer days | +| `--auto-renewal ` | boolean | no | Explicitly enable or disable automatic renewal for prepaid purchases | +| `--auto-renewal-duration ` | number | no | Automatic renewal duration; required when enabled | +| `--auto-renewal-cycle ` | string | no | Optional supported renewal cycle, e.g. Day | +| `--request-id ` | string | no | Request identifier; generated if omitted. Reuse with identical parameters only for the same capacity operation | +| `--wait` | switch | no | Wait for the returned capacity operation and refresh capacity | +| `--interval ` | number | no | Initial wait poll interval (1–3600, default: 2 seconds) | +| `--poll-timeout ` | number | no | Wait budget after submission (default: 600 seconds) | +| `--billing-method ` | string | yes | Capacity instance billing method (case-sensitive) | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Use --dry-run before confirming with --yes. A write is submitted once, never automatically retried. HTTP 200 is not operation success: inspect operation_status or pass --wait. After interruption or timeout, query the saved ModelCode/operation ID before considering another write. +- Capacities are absolute kTPM for one instance, not increments or ModelCode totals. Zero capacity is not release. Model-specific steps, limits and purchase periods are validated by the server. Automatic renewal configuration is part of purchase/scale/renew, not a standalone free setting change. +- Keeps the ModelCode, model and performance tier. Only one unreleased postpaid instance is allowed per ModelCode; the server validates purchase eligibility and slot limits. + +#### Examples + +```bash +bl deploy capacity create --deployed-model example-code --billing-method POST_PAY --input-tpm 10000 --output-tpm 1000 --dry-run +``` + +```bash +bl deploy capacity create --deployed-model example-code --billing-method PRE_PAY --input-tpm 10000 --output-tpm 1000 --duration 30 --auto-renewal false --dry-run +``` + +### `bl deploy capacity delete` + +| Field | Value | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy capacity delete` | +| **Description** | Release a capacity instance without deleting its ModelCode | +| **Authentication** | API Key | +| **Usage** | `bl deploy capacity delete --deployed-model --instance-id [--reason ] [--wait]` | +| **Risk** | `high` | +| **Risk message** | Releases serving capacity and may interrupt requests; this cannot be undone by this command. This is not a prepaid unsubscribe/refund action. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. + +#### Flags + +| Flag | Type | Required | Description | +| -------------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (ModelCode) | +| `--instance-id ` | string | yes | Capacity instance ID returned by the API | +| `--request-id ` | string | no | Request identifier; generated if omitted. Reuse with identical parameters only for the same capacity operation | +| `--wait` | switch | no | Wait for the returned capacity operation and refresh capacity | +| `--interval ` | number | no | Initial wait poll interval (1–3600, default: 2 seconds) | +| `--poll-timeout ` | number | no | Wait budget after submission (default: 600 seconds) | +| `--reason ` | string | no | Optional release reason, sent as a query parameter | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Use --dry-run before confirming with --yes. A write is submitted once, never automatically retried. HTTP 200 is not operation success: inspect operation_status or pass --wait. After interruption or timeout, query the saved ModelCode/operation ID before considering another write. +- Capacities are absolute kTPM for one instance, not increments or ModelCode totals. Zero capacity is not release. Model-specific steps, limits and purchase periods are validated by the server. Automatic renewal configuration is part of purchase/scale/renew, not a standalone free setting change. +- Active prepaid instances must be unsubscribed via `deploy capacity unsubscribe` (billing console link), even when can_delete=true. Failed prepaid instances may have no associated order; only the server can decide whether direct release is supported. Release is asynchronous; verify deleted=true, not merely STOPPED or zero capacity. + +#### Examples + +```bash +bl deploy capacity delete --deployed-model example-code --instance-id example-instance --dry-run +``` + +### `bl deploy capacity get` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------- | +| **Name** | `deploy capacity get` | +| **Description** | Get a throughput reservation capacity instance | +| **Authentication** | API Key | +| **Usage** | `bl deploy capacity get --deployed-model --instance-id ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------- | ------ | -------- | ---------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (ModelCode) | +| `--instance-id ` | string | yes | Capacity instance ID returned by the API | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Read-only; released instances can also be queried. Preserves the API envelope, capacity fields, can_scale/can_renew/can_delete and deleted. Capacities are kTPM; the instance must belong to this ModelCode. + +#### Examples + +```bash +bl deploy capacity get --deployed-model example-model-code --instance-id example-capacity-instance +``` + +### `bl deploy capacity list` + +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy capacity list` | +| **Description** | List throughput reservation capacity instances | +| **Authentication** | API Key | +| **Usage** | `bl deploy capacity list --deployed-model [--page ] [--page-size ] [--include-deleted ] [--statuses ] [--charge-types ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------------- | ------- | -------- | ----------------------------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (ModelCode) | +| `--page ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Results per page (1–100, default: 20) | +| `--include-deleted ` | boolean | no | Include released instances (default: true) | +| `--statuses ` | string | no | Instance statuses, comma-separated (not ModelCode statuses) | +| `--charge-types ` | string | no | Charge types: pre_paid, post_paid; comma-separated | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Preserves the API envelope and records/items/page/itemsPerPage/pageCount pagination. Capacities are kTPM; effective, configured and target capacities are distinct. STOPPED alone does not mean released: inspect deleted. + +#### Examples + +```bash +bl deploy capacity list --deployed-model example-model-code +``` + +```bash +bl deploy capacity list --deployed-model example-model-code --include-deleted false --statuses RUNNING,STOPPED +``` + +### `bl deploy capacity renew` + +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy capacity renew` | +| **Description** | Renew a prepaid capacity instance, optionally changing capacity | +| **Authentication** | API Key | +| **Usage** | `bl deploy capacity renew --deployed-model --instance-id --duration --auto-renewal [--is-change ] [--input-tpm ] [--output-tpm ] [--wait]` | +| **Risk** | `high` | +| **Risk message** | Renews a paid capacity instance and may enable automatic recurring renewal. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. + +#### Flags + +| Flag | Type | Required | Description | +| -------------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (ModelCode) | +| `--instance-id ` | string | yes | Capacity instance ID returned by the API | +| `--input-tpm ` | number | no | Absolute input capacity in kTPM (1 kTPM = 1000 tokens/minute) | +| `--output-tpm ` | number | no | Absolute output capacity in kTPM | +| `--duration ` | number | no | Prepaid purchase/renewal duration, positive integer days | +| `--auto-renewal ` | boolean | no | Explicitly enable or disable automatic renewal for prepaid purchases | +| `--auto-renewal-duration ` | number | no | Automatic renewal duration; required when enabled | +| `--auto-renewal-cycle ` | string | no | Optional supported renewal cycle, e.g. Day | +| `--request-id ` | string | no | Request identifier; generated if omitted. Reuse with identical parameters only for the same capacity operation | +| `--wait` | switch | no | Wait for the returned capacity operation and refresh capacity | +| `--interval ` | number | no | Initial wait poll interval (1–3600, default: 2 seconds) | +| `--poll-timeout ` | number | no | Wait budget after submission (default: 600 seconds) | +| `--is-change ` | boolean | no | Change capacity during renewal (default: false) | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Use --dry-run before confirming with --yes. A write is submitted once, never automatically retried. HTTP 200 is not operation success: inspect operation_status or pass --wait. After interruption or timeout, query the saved ModelCode/operation ID before considering another write. +- Capacities are absolute kTPM for one instance, not increments or ModelCode totals. Zero capacity is not release. Model-specific steps, limits and purchase periods are validated by the server. Automatic renewal configuration is part of purchase/scale/renew, not a standalone free setting change. +- Prepaid only; refreshes can_renew. Different capacity requires --is-change true; an unchanged capacity may be sent with false. No order_type is accepted by this endpoint. + +#### Examples + +```bash +bl deploy capacity renew --deployed-model example-code --instance-id example-instance --duration 30 --auto-renewal false --dry-run +``` + +```bash +bl deploy capacity renew --deployed-model example-code --instance-id example-instance --duration 30 --auto-renewal true --auto-renewal-duration 30 --dry-run +``` + +### `bl deploy capacity scale` + +| Field | Value | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy capacity scale` | +| **Description** | Scale the absolute capacity of one reservation instance | +| **Authentication** | API Key | +| **Usage** | `bl deploy capacity scale --deployed-model --instance-id --input-tpm --output-tpm [--order-type ] [--wait]` | +| **Risk** | `high` | +| **Risk message** | Changes purchased capacity, may incur charges and can reduce serving capacity to zero. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. + +#### Flags + +| Flag | Type | Required | Description | +| ----------------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (ModelCode) | +| `--instance-id ` | string | yes | Capacity instance ID returned by the API | +| `--input-tpm ` | number | no | Absolute input capacity in kTPM (1 kTPM = 1000 tokens/minute) | +| `--output-tpm ` | number | no | Absolute output capacity in kTPM | +| `--duration ` | number | no | Prepaid purchase/renewal duration, positive integer days | +| `--auto-renewal ` | boolean | no | Explicitly enable or disable automatic renewal for prepaid purchases | +| `--auto-renewal-duration ` | number | no | Automatic renewal duration; required when enabled | +| `--auto-renewal-cycle ` | string | no | Optional supported renewal cycle, e.g. Day | +| `--order-type ` | string | no | Optional order direction; otherwise the server decides | +| `--request-id ` | string | no | Request identifier; generated if omitted. Reuse with identical parameters only for the same capacity operation | +| `--wait` | switch | no | Wait for the returned capacity operation and refresh capacity | +| `--interval ` | number | no | Initial wait poll interval (1–3600, default: 2 seconds) | +| `--poll-timeout ` | number | no | Wait budget after submission (default: 600 seconds) | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Use --dry-run before confirming with --yes. A write is submitted once, never automatically retried. HTTP 200 is not operation success: inspect operation_status or pass --wait. After interruption or timeout, query the saved ModelCode/operation ID before considering another write. +- Capacities are absolute kTPM for one instance, not increments or ModelCode totals. Zero capacity is not release. Model-specific steps, limits and purchase periods are validated by the server. Automatic renewal configuration is part of purchase/scale/renew, not a standalone free setting change. +- Refreshes can_scale before writing. Omitted prepaid settings remain omitted, so the server can reuse saved information. Postpaid instances cannot receive prepaid settings. + +#### Examples + +```bash +bl deploy capacity scale --deployed-model example-code --instance-id example-instance --input-tpm 20000 --output-tpm 2000 --dry-run +``` + +```bash +bl deploy capacity scale --deployed-model example-code --instance-id example-instance --input-tpm 0 --output-tpm 0 --dry-run +``` + +### `bl deploy capacity unsubscribe` + +| Field | Value | +| ------------------ | --------------------------------------------------------------------------------- | +| **Name** | `deploy capacity unsubscribe` | +| **Description** | Build the Aliyun billing console unsubscribe link for a prepaid capacity instance | +| **Authentication** | No Auth | +| **Usage** | `bl deploy capacity unsubscribe --instance-id ` | + +#### Flags + +| Flag | Type | Required | Description | +| -------------------- | ------ | -------- | ---------------------------------------- | +| `--instance-id ` | string | yes | Capacity instance ID returned by the API | + +#### Notes + +- No API is called and nothing is unsubscribed by this command; it only builds the refund page link. Open the link and finish the unsubscribe flow in the billing console with the account that placed the order. +- Only prepaid instances are unsubscribed this way; postpaid instances are released with `deploy capacity delete`. The ID is the capacity instance ID (see `deploy capacity list` / `get`), not the ModelCode. Unsubscription may interrupt serving and is irreversible; refund rules are decided by the billing console. + +#### Examples + +```bash +bl deploy capacity unsubscribe --instance-id example-instance +``` + ### `bl deploy delete` | Field | Value | | ------------------ | ----------------------------------------------------------------------------- | | **Name** | `deploy delete` | -| **Description** | Delete a model deployment (must be STOPPED or FAILED) | +| **Description** | Delete a model deployment (PTU must be STOPPED with all capacity released) | | **Authentication** | API Key | | **Usage** | `bl deploy delete --deployed-model [--skip-precheck]` | | **Risk** | `high` | @@ -86,19 +392,20 @@ bl deploy audio create --model-name my-cosyvoice-ft --display-name my-tts --dry- #### Flags -| Flag | Type | Required | Description | -| ----------------------- | ------ | -------- | --------------------------------------------- | -| `--deployed-model ` | string | yes | Deployed model identifier (required) | -| `--skip-precheck` | switch | no | Skip the local STOPPED/FAILED status precheck | -| `--yes` | switch | no | Confirm this high-risk operation | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ----------------------- | ------ | -------- | -------------------------------------------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (required) | +| `--skip-precheck` | switch | no | Skip local checks only; the service still validates deletion prerequisites | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | -#### Examples +#### Notes -```bash -bl deploy delete --deployed-model dep-... -``` +- PTU deletion requires STOPPED, all capacity instances released, and no processing or queued capacity operations. Pausing or scaling to zero does not release an instance; prepaid capacity may require unsubscription. +- Local checks inspect the deployment, unreleased instances and any returned operation_id. There is no public queued-operation list API, so these checks cannot confirm all operations are finished; the service makes the final decision. --skip-precheck only omits local checks, never service validation or resource release requirements. + +#### Examples ```bash bl deploy delete --deployed-model dep-... --dry-run @@ -106,7 +413,7 @@ bl deploy delete --deployed-model dep-... --dry-run ```bash # Only after explicit user confirmation: -bl deploy delete --deployed-model dep-... --yes +bl deploy delete --deployed-model dep-... --yes # Execute only after confirming deletion ``` ### `bl deploy get` @@ -120,11 +427,15 @@ bl deploy delete --deployed-model dep-... --yes #### Flags -| Flag | Type | Required | Description | -| ----------------------- | ------ | -------- | ------------------------------------ | -| `--deployed-model ` | string | yes | Deployed model identifier (required) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ------------------------- | ------ | -------- | ------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (ModelCode) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Preserves deployment fields including ptu_capacity (aggregate effective kTPM), ptu_service_tier, overflow_strategy and pre_paid_info. For mixed billing or multiple instances, query capacity list/get for each instance's status, expiry and capacity; ModelCode status is not instance status. #### Examples @@ -138,37 +449,54 @@ 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 | -| **Authentication** | API Key | -| **Usage** | `bl deploy image create --model-name --display-name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]` | +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy image create` | +| **Description** | Create an image generation model deployment | +| **Authentication** | API Key | +| **Usage** | `bl deploy image create --model-name [--display-name ] [--plan ] [--charge-type ] [--service-tier ] [--suffix ] [--input-tpm --output-tpm ] [flags]` | +| **Risk** | `high` | +| **Risk message** | Creating a deployment purchases or provisions resources and may incur charges. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags -| Flag | Type | Required | Description | -| ------------------------------- | ------ | -------- | ------------------------------------------------------------------------------- | -| `--model-name ` | string | yes | Model to deploy — fine-tuned output name or catalog model (required) | -| `--display-name ` | string | yes | Console display name for the deployment (required) | -| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu | -| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | -| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | -| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | -| `--input-tpm ` | number | no | PTU max input tokens/min (required for plan=ptu) | -| `--output-tpm ` | number | no | PTU max output tokens/min (required for plan=ptu) | -| `--thinking-output-tpm ` | number | no | PTU max thinking-output tokens/min (optional, some models) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ---------------------------------------- | ------- | -------- | -------------------------------------------------------------------------------- | +| `--model-name ` | string | yes | Model to deploy — fine-tuned output name or catalog model (required) | +| `--display-name ` | string | no | Console display name (optional for PTU; required for other plans) | +| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (throughput reservation) \| mu | +| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | +| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | +| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | +| `--input-tpm ` | number | no | Absolute input capacity in kTPM (1 kTPM = 1000 tokens/minute) | +| `--output-tpm ` | number | no | Absolute output capacity in kTPM | +| `--thinking-output-tpm ` | number | no | Legacy thinking-output capacity flag; not supported for PTU | +| `--charge-type ` | string | no | PTU payment type (required for plan=ptu): pre_paid or post_paid | +| `--service-tier ` | string | no | PTU service tier: ptu_fast (default) or ptu_default (prepaid only) | +| `--suffix ` | string | no | Optional PTU ModelCode suffix; generated by the service when omitted | +| `--duration ` | number | no | Prepaid purchase/renewal duration, positive integer days | +| `--auto-renewal ` | boolean | no | Explicitly enable or disable automatic renewal for prepaid purchases | +| `--auto-renewal-duration ` | number | no | Automatic renewal duration; required when enabled | +| `--auto-renewal-cycle ` | string | no | Optional supported renewal cycle, e.g. Day | +| `--wait` | switch | no | Wait for the returned capacity operation and refresh capacity | +| `--interval ` | number | no | Initial wait poll interval (1–3600, default: 2 seconds) | +| `--poll-timeout ` | number | no | Wait budget after submission (default: 600 seconds) | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Notes - Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-billed) for audio (CosyVoice TTS). Pass --plan to override. -- For plan=ptu (Token-billed, provisioned throughput), --input-tpm and --output-tpm are required (the platform rejects creation without an explicit ptu_capacity despite the doc listing defaults). +- For plan=ptu, --charge-type, --input-tpm and --output-tpm are required. Capacity is in kTPM (1 kTPM = 1000 tokens/minute). Separate thinking-output capacity is unsupported. Prepaid requires --duration and explicit --auto-renewal true|false. - For plan=mu, `capacity`, `billing_method` and `deploy_spec` are required. billing_method defaults to POST_PAY (only supported value); deploy_spec and capacity are auto-picked from GET /deployments/models when omitted. -- Use `bl deploy models --source base` to inspect available templates. -- After creation, status starts at PENDING and transitions to RUNNING. Invoke the deployed model with: bl text chat --model -- 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 `) and lifecycle commands (`deploy get/scale/pause/resume/delete --deployed-model `). +- Use `deploy models --source base` to inspect available templates. +- PTU creation preserves the original JSON and operation_id even with --quiet. --wait only queries the returned operation; pending capacity is not effective capacity. Check the actual status before invoking the model. +- --model-name identifies the source model; the returned deployed_model is the invocation identifier (ModelCode). Use it for inference (`text chat --model `) and deployment lifecycle commands. +- Initial ModelCode creation does not support --request-id or promise idempotency. A timeout is not proof of failure: check whether the deployment was created before trying again. This command never automatically resubmits creation. +- Use --dry-run to preview without requests. Pass --yes only after confirming the costs. #### Examples @@ -186,22 +514,27 @@ bl deploy image create --model-name my-wan-ft --display-name my-wan --dry-run ### `bl deploy list` -| Field | Value | -| ------------------ | -------------------------------------------------------------- | -| **Name** | `deploy list` | -| **Description** | List model deployments | -| **Authentication** | API Key | -| **Usage** | `bl deploy list [--page ] [--page-size ] [--status ]` | +| Field | Value | +| ------------------ | ----------------------------------------------------------------------------------- | +| **Name** | `deploy list` | +| **Description** | List model deployments and throughput reservations | +| **Authentication** | API Key | +| **Usage** | `bl deploy list [--page ] [--page-size ] [--plan ] [--status ]` | #### Flags -| Flag | Type | Required | Description | -| ------------------ | ------ | -------- | ------------------------------------------------------- | -| `--page ` | number | no | Page number (default: 1) | -| `--page-size ` | number | no | Results per page (default: 10, max 100) | -| `--status ` | string | no | Filter by status (PENDING / RUNNING / STOPPED / FAILED) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--page ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Results per page (default: 10, max 100) | +| `--plan ` | string | no | Server-side plan filter; use ptu for throughput reservations | +| `--status ` | string | no | Filter only the fetched page locally by ModelCode status; total remains unfiltered | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- The deployment list API supports page_no/page_size/plan, not status. --status filters only the requested page, not the entire account; an empty filtered page does not mean there are no matches on later pages. total is the server total before local filtering; local_filter reports the current-page match count. PTU capacities are kTPM. #### Examples @@ -210,7 +543,11 @@ bl deploy list ``` ```bash -bl deploy list --status RUNNING +bl deploy list --plan ptu +``` + +```bash +bl deploy list --plan ptu --status RUNNING --page-size 100 ``` ```bash @@ -255,6 +592,106 @@ bl deploy models --source custom --page-size 50 bl deploy models --catalog-version v1.0 --output json ``` +### `bl deploy operation get` + +| Field | Value | +| ------------------ | --------------------------------------------------------------------- | +| **Name** | `deploy operation get` | +| **Description** | Query a throughput reservation capacity operation once | +| **Authentication** | API Key | +| **Usage** | `bl deploy operation get --deployed-model --operation-id ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------- | ------ | -------- | ------------------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (ModelCode) | +| `--operation-id ` | string | yes | Capacity operation ID returned by a write request | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Preserves the API envelope, including FAILED and its error fields; a successful GET returns exit code 0 regardless of operation status. Use operation wait to wait for success or fail with a non-zero exit. Use the actual returned operation ID; do not construct one. + +#### Examples + +```bash +bl deploy operation get --deployed-model example-model-code --operation-id 100001 +``` + +### `bl deploy operation wait` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| **Name** | `deploy operation wait` | +| **Description** | Wait for a capacity operation and refresh confirmed capacity | +| **Authentication** | API Key | +| **Usage** | `bl deploy operation wait --deployed-model --operation-id [--interval ] [--poll-timeout ]` | + +#### Flags + +| Flag | Type | Required | Description | +| -------------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (ModelCode) | +| `--operation-id ` | string | yes | Capacity operation ID returned by a write request | +| `--interval ` | number | no | Initial poll interval (1–3600 seconds, default: 2); doubles up to max(initial, 30) | +| `--poll-timeout ` | number | no | Total wait budget including requests and refresh (default: 600 seconds) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Read-only GET polling: PROCESSING continues, FAILED exits 1 with the server error, SUCCEEDED refreshes the instance (when an ID is returned) and deployment. Output preserves the operation envelope and adds instance/deployment response envelopes. Missing or unknown status fails rather than assuming success. +- --timeout limits each HTTP request; --poll-timeout bounds the entire wait (exit 5 on expiry). Ctrl-C stops local waiting, not the remote operation. No write is retried; use the original operation ID after a timeout or interruption. + +#### Examples + +```bash +bl deploy operation wait --deployed-model example-model-code --operation-id 100001 +``` + +```bash +bl deploy operation wait --deployed-model example-model-code --operation-id 100001 --interval 2 --poll-timeout 600 +``` + +### `bl deploy overflow` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------------------------ | +| **Name** | `deploy overflow` | +| **Description** | Set the overflow strategy for a throughput reservation ModelCode | +| **Authentication** | API Key | +| **Usage** | `bl deploy overflow --deployed-model --strategy ` | +| **Risk** | `high` | +| **Risk message** | Enabling overflow incurs pay-as-you-go charges beyond reserved capacity; disabling it rate-limits excess requests. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ---------------------------------------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (ModelCode) | +| `--strategy ` | string | yes | Allow pay-as-you-go overflow or restrict requests to reserved capacity | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Applies to the whole ModelCode, not one capacity instance. Sends one update then one GET to confirm the strategy. Never retries the update automatically; a read-back failure does not mean the change was not applied. + +#### Examples + +```bash +bl deploy overflow --deployed-model example-code --strategy enable --dry-run +``` + +```bash +bl deploy overflow --deployed-model example-code --strategy disable --dry-run +``` + ### `bl deploy pause` | Field | Value | @@ -335,67 +772,106 @@ bl deploy resume --deployed-model dep-... --dry-run ### `bl deploy scale` -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------------- | -| **Name** | `deploy scale` | -| **Description** | Scale a deployment's capacity | -| **Authentication** | API Key | -| **Usage** | `bl deploy scale --deployed-model --capacity [--input-tpm ] [--output-tpm ]` | +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy scale` | +| **Description** | Scale a deployment's capacity | +| **Authentication** | API Key | +| **Usage** | `bl deploy scale --deployed-model (--capacity \| --input-tpm --output-tpm ) [--instance-id ] [flags]` | +| **Risk** | `high` | +| **Risk message** | Scaling changes purchased capacity and may incur charges or reduce serving capacity. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags -| Flag | Type | Required | Description | -| ----------------------- | ------ | -------- | ---------------------------------------------------------------- | -| `--deployed-model ` | string | yes | Deployed model identifier (required) | -| `--capacity ` | number | no | New capacity in plan units (must be a multiple of base_capacity) | -| `--input-tpm ` | number | no | PTU only — input tokens per minute | -| `--output-tpm ` | number | no | PTU only — output tokens per minute | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ----------------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------- | +| `--deployed-model ` | string | yes | Deployed model identifier (required) | +| `--capacity ` | number | no | MU target capacity: non-negative integer in plan units; must satisfy base_capacity | +| `--input-tpm ` | number | no | Absolute input capacity in kTPM (1 kTPM = 1000 tokens/minute) | +| `--output-tpm ` | number | no | Absolute output capacity in kTPM | +| `--instance-id ` | string | no | PTU capacity instance ID; required when multiple unreleased instances exist | +| `--duration ` | number | no | Prepaid purchase/renewal duration, positive integer days | +| `--auto-renewal ` | boolean | no | Explicitly enable or disable automatic renewal for prepaid purchases | +| `--auto-renewal-duration ` | number | no | Automatic renewal duration; required when enabled | +| `--auto-renewal-cycle ` | string | no | Optional supported renewal cycle, e.g. Day | +| `--request-id ` | string | no | Request identifier; generated if omitted. Reuse with identical parameters only for the same capacity operation | +| `--wait` | switch | no | Wait for the returned capacity operation and refresh capacity | +| `--interval ` | number | no | Initial wait poll interval (1–3600, default: 2 seconds) | +| `--poll-timeout ` | number | no | Wait budget after submission (default: 600 seconds) | +| `--order-type ` | string | no | Optional order direction; otherwise the server decides | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Use --dry-run before confirming with --yes. A write is submitted once, never automatically retried. HTTP 200 is not operation success: inspect operation_status or pass --wait. After interruption or timeout, query the saved ModelCode/operation ID before considering another write. +- Capacities are absolute kTPM for one instance, not increments or ModelCode totals. Zero capacity is not release. Model-specific steps, limits and purchase periods are validated by the server. Automatic renewal configuration is part of purchase/scale/renew, not a standalone free setting change. +- PTU input/output values are the selected instance's absolute target capacity in kTPM (1 kTPM = 1000 tokens/minute), not deltas or deployment totals. Both zero is valid and does not release the instance. PTU options cannot be combined with --capacity. +- Without --instance-id, execution must confirm exactly one unreleased instance and recheck can_scale. Dry-run does not resolve an instance or validate its billing type. Pass --yes only after confirming the costs and target. #### Examples ```bash -bl deploy scale --deployed-model qwen-plus-...-b6d61c71 --capacity 8 +bl deploy scale --deployed-model dep-... --capacity 8 --dry-run ``` ```bash -bl deploy scale --deployed-model dep-... --capacity 2 +bl deploy scale --deployed-model dep-... --instance-id instance-... --input-tpm 20000 --output-tpm 2000 --order-type UPGRADE --dry-run ``` ### `bl deploy text create` -| Field | Value | -| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Name** | `deploy text create` | -| **Description** | Create a text model deployment | -| **Authentication** | API Key | -| **Usage** | `bl deploy text create --model-name --display-name [--plan ] [--deploy-spec ] [--capacity ] [--billing-method ] [--input-tpm ] [--output-tpm ] [--thinking-output-tpm ]` | +| Field | Value | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `deploy text create` | +| **Description** | Create a text model deployment | +| **Authentication** | API Key | +| **Usage** | `bl deploy text create --model-name [--display-name ] [--plan ] [--charge-type ] [--service-tier ] [--suffix ] [--input-tpm --output-tpm ] [flags]` | +| **Risk** | `high` | +| **Risk message** | Creating a deployment purchases or provisions resources and may incur charges. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags -| Flag | Type | Required | Description | -| ------------------------------- | ------ | -------- | ------------------------------------------------------------------------------- | -| `--model-name ` | string | yes | Model to deploy — fine-tuned output name or catalog model (required) | -| `--display-name ` | string | yes | Console display name for the deployment (required) | -| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (Token-billed) \| mu | -| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | -| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | -| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | -| `--input-tpm ` | number | no | PTU max input tokens/min (required for plan=ptu) | -| `--output-tpm ` | number | no | PTU max output tokens/min (required for plan=ptu) | -| `--thinking-output-tpm ` | number | no | PTU max thinking-output tokens/min (optional, some models) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ---------------------------------------- | ------- | -------- | -------------------------------------------------------------------------------- | +| `--model-name ` | string | yes | Model to deploy — fine-tuned output name or catalog model (required) | +| `--display-name ` | string | no | Console display name (optional for PTU; required for other plans) | +| `--plan ` | string | no | Billing plan: lora (default, Token-billed) \| ptu (throughput reservation) \| mu | +| `--deploy-spec ` | string | no | Deploy spec (only used by plan=mu; auto-picked if omitted) | +| `--capacity ` | number | no | Resource units (plan=mu only; required by API; defaults to the template's unit) | +| `--billing-method ` | string | no | Billing method (plan=mu only; default "POST_PAY", the only supported value) | +| `--input-tpm ` | number | no | Absolute input capacity in kTPM (1 kTPM = 1000 tokens/minute) | +| `--output-tpm ` | number | no | Absolute output capacity in kTPM | +| `--thinking-output-tpm ` | number | no | Legacy thinking-output capacity flag; not supported for PTU | +| `--charge-type ` | string | no | PTU payment type (required for plan=ptu): pre_paid or post_paid | +| `--service-tier ` | string | no | PTU service tier: ptu_fast (default) or ptu_default (prepaid only) | +| `--suffix ` | string | no | Optional PTU ModelCode suffix; generated by the service when omitted | +| `--duration ` | number | no | Prepaid purchase/renewal duration, positive integer days | +| `--auto-renewal ` | boolean | no | Explicitly enable or disable automatic renewal for prepaid purchases | +| `--auto-renewal-duration ` | number | no | Automatic renewal duration; required when enabled | +| `--auto-renewal-cycle ` | string | no | Optional supported renewal cycle, e.g. Day | +| `--wait` | switch | no | Wait for the returned capacity operation and refresh capacity | +| `--interval ` | number | no | Initial wait poll interval (1–3600, default: 2 seconds) | +| `--poll-timeout ` | number | no | Wait budget after submission (default: 600 seconds) | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Notes - Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-billed) for audio (CosyVoice TTS). Pass --plan to override. -- For plan=ptu (Token-billed, provisioned throughput), --input-tpm and --output-tpm are required (the platform rejects creation without an explicit ptu_capacity despite the doc listing defaults). +- For plan=ptu, --charge-type, --input-tpm and --output-tpm are required. Capacity is in kTPM (1 kTPM = 1000 tokens/minute). Separate thinking-output capacity is unsupported. Prepaid requires --duration and explicit --auto-renewal true|false. - For plan=mu, `capacity`, `billing_method` and `deploy_spec` are required. billing_method defaults to POST_PAY (only supported value); deploy_spec and capacity are auto-picked from GET /deployments/models when omitted. -- Use `bl deploy models --source base` to inspect available templates. -- After creation, status starts at PENDING and transitions to RUNNING. Invoke the deployed model with: bl text chat --model -- 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 `) and lifecycle commands (`deploy get/scale/pause/resume/delete --deployed-model `). +- Use `deploy models --source base` to inspect available templates. +- PTU creation preserves the original JSON and operation_id even with --quiet. --wait only queries the returned operation; pending capacity is not effective capacity. Check the actual status before invoking the model. +- --model-name identifies the source model; the returned deployed_model is the invocation identifier (ModelCode). Use it for inference (`text chat --model `) and deployment lifecycle commands. +- Initial ModelCode creation does not support --request-id or promise idempotency. A timeout is not proof of failure: check whether the deployment was created before trying again. This command never automatically resubmits creation. +- Use --dry-run to preview without requests. Pass --yes only after confirming the costs. #### Examples @@ -404,7 +880,11 @@ bl deploy text create --model-name my-qwen-sft --display-name my-sft-test ``` ```bash -bl deploy text create --model-name qwen3.6-flash-2026-04-16 --display-name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000 +bl deploy text create --model-name qwen3.6-flash-2026-04-16 --plan ptu --charge-type post_paid --input-tpm 10000 --output-tpm 1000 --dry-run +``` + +```bash +bl deploy text create --model-name qwen3.6-flash-2026-04-16 --plan ptu --charge-type pre_paid --service-tier ptu_default --input-tpm 10000 --output-tpm 1000 --duration 30 --auto-renewal false --dry-run ``` ```bash diff --git a/skills/bailian-finetune/reference/index.md b/skills/bailian-finetune/reference/index.md index 3dff1281..b0901457 100644 --- a/skills/bailian-finetune/reference/index.md +++ b/skills/bailian-finetune/reference/index.md @@ -9,46 +9,56 @@ Use this index for the skill-scoped quick index and global flags. ## Quick index -| Command | Authentication | Description | Detail | -| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -| `bl dataset delete` | API Key | Delete a dataset file by ID | [dataset.md](dataset.md) | -| `bl dataset get` | API Key | Get details of a single dataset file | [dataset.md](dataset.md) | -| `bl dataset list` | API Key | List uploaded dataset files | [dataset.md](dataset.md) | -| `bl dataset upload` | API Key | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) | -| `bl dataset validate` | No Auth | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) | -| `bl deploy audio create` | API Key | Create an audio (TTS) model deployment | [deploy.md](deploy.md) | -| `bl deploy delete` | API Key | Delete a model deployment (must be STOPPED or FAILED) | [deploy.md](deploy.md) | -| `bl deploy get` | API Key | Get details of a single model deployment | [deploy.md](deploy.md) | -| `bl deploy image create` | API Key | Create an image generation model deployment | [deploy.md](deploy.md) | -| `bl deploy list` | API Key | List model deployments | [deploy.md](deploy.md) | -| `bl deploy models` | API Key | List models available for deployment | [deploy.md](deploy.md) | -| `bl deploy pause` | Console | Pause a running model deployment (stops billing for mu/ptu) | [deploy.md](deploy.md) | -| `bl deploy resume` | Console | Resume a paused model deployment (brings service back online) | [deploy.md](deploy.md) | -| `bl deploy scale` | API Key | Scale a deployment's capacity | [deploy.md](deploy.md) | -| `bl deploy text create` | API Key | Create a text model deployment | [deploy.md](deploy.md) | -| `bl deploy update` | API Key | Update a deployment's rate limits (rpm_limit / tpm_limit) | [deploy.md](deploy.md) | -| `bl finetune audio create` | API Key | Create an audio TTS model fine-tune job (sft-lora) | [finetune.md](finetune.md) | -| `bl finetune cancel` | API Key | Cancel a running fine-tune job | [finetune.md](finetune.md) | -| `bl finetune capability` | No Auth | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | [finetune.md](finetune.md) | -| `bl finetune checkpoints` | API Key | List checkpoints produced by a fine-tune job | [finetune.md](finetune.md) | -| `bl finetune delete` | API Key | Delete a fine-tune job record | [finetune.md](finetune.md) | -| `bl finetune export` | API Key | Publish a checkpoint as a deployable model | [finetune.md](finetune.md) | -| `bl finetune get` | API Key | Get details of a single fine-tune job | [finetune.md](finetune.md) | -| `bl finetune image create` | API Key | Create an image generation model fine-tune job (sft-lora) | [finetune.md](finetune.md) | -| `bl finetune list` | API Key | List fine-tune jobs | [finetune.md](finetune.md) | -| `bl finetune logs` | API Key | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) | -| `bl finetune price` | Console | Estimate the training cost for a fine-tune job (token billing) | [finetune.md](finetune.md) | -| `bl finetune text create` | API Key | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) | -| `bl finetune video create` | API Key | Create a video generation model fine-tune job (Wan i2v/kf2v, efficient_sft) | [finetune.md](finetune.md) | -| `bl finetune watch` | API Key | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [finetune.md](finetune.md) | +| Command | Authentication | Description | Detail | +| -------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| `bl dataset delete` | API Key | Delete a dataset file by ID | [dataset.md](dataset.md) | +| `bl dataset get` | API Key | Get details of a single dataset file | [dataset.md](dataset.md) | +| `bl dataset list` | API Key | List uploaded dataset files | [dataset.md](dataset.md) | +| `bl dataset upload` | API Key | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) | +| `bl dataset validate` | No Auth | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) | +| `bl deploy audio create` | API Key | Create an audio (TTS) model deployment | [deploy.md](deploy.md) | +| `bl deploy capacity create` | API Key | Purchase an additional capacity instance for a ModelCode | [deploy.md](deploy.md) | +| `bl deploy capacity delete` | API Key | Release a capacity instance without deleting its ModelCode | [deploy.md](deploy.md) | +| `bl deploy capacity get` | API Key | Get a throughput reservation capacity instance | [deploy.md](deploy.md) | +| `bl deploy capacity list` | API Key | List throughput reservation capacity instances | [deploy.md](deploy.md) | +| `bl deploy capacity renew` | API Key | Renew a prepaid capacity instance, optionally changing capacity | [deploy.md](deploy.md) | +| `bl deploy capacity scale` | API Key | Scale the absolute capacity of one reservation instance | [deploy.md](deploy.md) | +| `bl deploy capacity unsubscribe` | No Auth | Build the Aliyun billing console unsubscribe link for a prepaid capacity instance | [deploy.md](deploy.md) | +| `bl deploy delete` | API Key | Delete a model deployment (PTU must be STOPPED with all capacity released) | [deploy.md](deploy.md) | +| `bl deploy get` | API Key | Get details of a single model deployment | [deploy.md](deploy.md) | +| `bl deploy image create` | API Key | Create an image generation model deployment | [deploy.md](deploy.md) | +| `bl deploy list` | API Key | List model deployments and throughput reservations | [deploy.md](deploy.md) | +| `bl deploy models` | API Key | List models available for deployment | [deploy.md](deploy.md) | +| `bl deploy operation get` | API Key | Query a throughput reservation capacity operation once | [deploy.md](deploy.md) | +| `bl deploy operation wait` | API Key | Wait for a capacity operation and refresh confirmed capacity | [deploy.md](deploy.md) | +| `bl deploy overflow` | API Key | Set the overflow strategy for a throughput reservation ModelCode | [deploy.md](deploy.md) | +| `bl deploy pause` | Console | Pause a running model deployment (stops billing for mu/ptu) | [deploy.md](deploy.md) | +| `bl deploy resume` | Console | Resume a paused model deployment (brings service back online) | [deploy.md](deploy.md) | +| `bl deploy scale` | API Key | Scale a deployment's capacity | [deploy.md](deploy.md) | +| `bl deploy text create` | API Key | Create a text model deployment | [deploy.md](deploy.md) | +| `bl deploy update` | API Key | Update a deployment's rate limits (rpm_limit / tpm_limit) | [deploy.md](deploy.md) | +| `bl finetune audio create` | API Key | Create an audio TTS model fine-tune job (sft-lora) | [finetune.md](finetune.md) | +| `bl finetune cancel` | API Key | Cancel a running fine-tune job | [finetune.md](finetune.md) | +| `bl finetune capability` | No Auth | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | [finetune.md](finetune.md) | +| `bl finetune checkpoints` | API Key | List checkpoints produced by a fine-tune job | [finetune.md](finetune.md) | +| `bl finetune delete` | API Key | Delete a fine-tune job record | [finetune.md](finetune.md) | +| `bl finetune export` | API Key | Publish a checkpoint as a deployable model | [finetune.md](finetune.md) | +| `bl finetune get` | API Key | Get details of a single fine-tune job | [finetune.md](finetune.md) | +| `bl finetune image create` | API Key | Create an image generation model fine-tune job (sft-lora) | [finetune.md](finetune.md) | +| `bl finetune list` | API Key | List fine-tune jobs | [finetune.md](finetune.md) | +| `bl finetune logs` | API Key | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) | +| `bl finetune price` | Console | Estimate the training cost for a fine-tune job (token billing) | [finetune.md](finetune.md) | +| `bl finetune text create` | API Key | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) | +| `bl finetune video create` | API Key | Create a video generation model fine-tune job (Wan i2v/kf2v, efficient_sft) | [finetune.md](finetune.md) | +| `bl finetune watch` | API Key | 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`, `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) | +| Group | Commands | Reference | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| `dataset` | `delete`, `get`, `list`, `upload`, `validate` | [dataset.md](dataset.md) | +| `deploy` | `audio create`, `capacity create`, `capacity delete`, `capacity get`, `capacity list`, `capacity renew`, `capacity scale`, `capacity unsubscribe`, `delete`, `get`, `image create`, `list`, `models`, `operation get`, `operation wait`, `overflow`, `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