From 30f32bbee05f65f978e80cccddbf2055ad172d9a Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Tue, 10 Nov 2026 19:04:27 +0800 Subject: [PATCH 1/6] feat: Enhance error handling and command usage for free-tier models --- docs/agents/error-hint-change.md | 7 +- .../commands/src/commands/usage/freetier.ts | 46 +++++++-- .../tests/e2e/usage-freetier.e2e.test.ts | 52 +++++++++++ packages/runtime/src/create-cli.ts | 11 ++- packages/runtime/src/error-handler.ts | 48 +++++++++- packages/runtime/src/index.ts | 2 +- packages/runtime/src/output/reporter.ts | 6 +- packages/runtime/tests/error-handler.test.ts | 93 ++++++++++++++++++- skills/bailian-cli/reference/usage.md | 39 +++++--- skills/bailian-protocol/SKILL.md | 2 +- 10 files changed, 274 insertions(+), 32 deletions(-) create mode 100644 packages/commands/tests/e2e/usage-freetier.e2e.test.ts diff --git a/docs/agents/error-hint-change.md b/docs/agents/error-hint-change.md index 56352b18f..853ac5857 100644 --- a/docs/agents/error-hint-change.md +++ b/docs/agents/error-hint-change.md @@ -43,7 +43,7 @@ runtime/create-cli.ts: 每次调用创建 reporter,统一捕获初始化/解 ↓ runtime/error-handler.ts: - 服务端错误(BailianError(GENERAL)) → text 直接打 message - - 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 errorHint(只 AUTH 还有增强) + - 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 errorHint(AUTH 增强;另可对已知可恢复服务端错误追加 hint) - TypeError("fetch failed") → 读 err.cause.code 翻成 NETWORK - Node fs errno → 翻成 GENERAL + errno hint - reporter:JSON 错误、warning、debug 各占 stderr 一行,无空行;stdout 仅保留最终业务结果 @@ -104,7 +104,10 @@ process.exit(err.exitCode) ### B. runtime 增强(`errorHint`) -- [ ] `packages/runtime/src/error-handler.ts:errorHint`:**当前只为 internal AUTH 增强**(因为 resolver / authStage 等内部位置会发 AUTH) +- [ ] `packages/runtime/src/error-handler.ts:errorHint`:**主要为 internal AUTH 增强**(resolver / authStage 等内部位置会发 AUTH) +- [ ] **允许的有限扩展**:对已知可恢复的服务端错误,**追加**恢复 hint(保留原 message / exitCode)。当前仅 `AllocationQuota.FreeTierOnly`(及同义英文 message 短语)。message / apiCode 匹配**只用于追加**,不参与分类、归并或替换 message(勿与下方反面 case 3 的关键词归并混淆) +- [ ] 追加时若已有 hint(如 video task id),拼接并按标记去重;不要 `hint` 非空就跳过 +- [ ] 渲染产品命令前须经能力门控(命令表是否暴露对应 path),不要对 `kscli` 等缺命令的产品硬拼 `usage freetier` - [ ] 命令名使用 `commandPrefix`,不要硬编码 `bl` - [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"` diff --git a/packages/commands/src/commands/usage/freetier.ts b/packages/commands/src/commands/usage/freetier.ts index 42a8203dd..3cea7dbd4 100644 --- a/packages/commands/src/commands/usage/freetier.ts +++ b/packages/commands/src/commands/usage/freetier.ts @@ -21,6 +21,15 @@ export default defineCommand({ "zh-CN": "启用或停用免费额度模型自动停止。默认启用,使用 --off 停用", }, auth: "console", + risk: { + level: "high", + reason: "billing", + message: { + "en-US": + "This changes free-tier auto-stop. Disabling it allows pay-as-you-go calls after free quota is exhausted.", + "zh-CN": "此操作会变更免费额度自动停止。停用后,免费额度用尽将继续按量计费调用。", + }, + }, usageArgs: "<--model [,model2,...] | --all> [--off] [flags]", flags: { model: { @@ -48,12 +57,37 @@ export default defineCommand({ }, }, exampleArgs: [ - "--model qwen3-max", - "--model qwen3-max,qwen-turbo", - "--all", - "--on --model qwen3-max", - "--off --model qwen3-max", - "--off --all", + { + "en-US": "--model qwen3-max --yes", + "zh-CN": "--model qwen3-max --yes", + }, + { + "en-US": "--model qwen3-max,qwen-turbo --yes", + "zh-CN": "--model qwen3-max,qwen-turbo --yes", + }, + { + "en-US": "--all --yes", + "zh-CN": "--all --yes", + }, + { + "en-US": "--on --model qwen3-max --yes", + "zh-CN": "--on --model qwen3-max --yes", + }, + { + "en-US": "--off --model qwen3-max --yes", + "zh-CN": "--off --model qwen3-max --yes", + }, + { + "en-US": "--off --all --yes", + "zh-CN": "--off --all --yes", + }, + ], + notes: [ + { + "en-US": + "Requires `--yes` (exit code 7 / requires_confirmation without it). Applies to both enabling and disabling auto-stop.", + "zh-CN": "需要 `--yes`(缺少时退出码 7 / requires_confirmation)。启用与停用自动停止均适用。", + }, ], validate: (flags) => !flags.model && !flags.all ? "Provide --model [,model2,...] or --all." : undefined, diff --git a/packages/commands/tests/e2e/usage-freetier.e2e.test.ts b/packages/commands/tests/e2e/usage-freetier.e2e.test.ts new file mode 100644 index 000000000..ac6a47be0 --- /dev/null +++ b/packages/commands/tests/e2e/usage-freetier.e2e.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vite-plus/test"; +import { runCommandE2e, runCommandHelp } from "./helpers.ts"; +import { USAGE_ROUTES } from "./topic-routes.ts"; + +describe("e2e: usage freetier", () => { + test("usage freetier --help 展示 risk、--yes 与确认示例", async () => { + const { stderr, exitCode } = await runCommandHelp(USAGE_ROUTES, [ + "usage", + "freetier", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("--yes"); + expect(stderr).toMatch(/Risk:\s*high/i); + expect(stderr).toContain("This changes free-tier auto-stop"); + expect(stderr).toContain("bl usage freetier --model qwen3-max --yes"); + expect(stderr).toContain("bl usage freetier --off --model qwen3-max --yes"); + }); + + test("usage freetier 无 --yes 返回确认请求 (7)", async () => { + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "freetier", + "--model", + "qwen-plus", + "--output", + "json", + ]); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); + }); + + test("usage freetier --yes 通过确认门槛后落到 AUTH (3)", async () => { + // Clear OpenAPI env so console auth cannot exchange a host AK/SK into a session. + const { stderr, exitCode } = await runCommandE2e( + USAGE_ROUTES, + ["usage", "freetier", "--model", "qwen-plus", "--yes", "--output", "json"], + { + BAILIAN_ACCESS_KEY_ID: undefined, + BAILIAN_ACCESS_KEY_SECRET: undefined, + ALIBABA_CLOUD_ACCESS_KEY_ID: undefined, + ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined, + }, + ); + expect(exitCode).toBe(3); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 3 }, + }); + }); +}); diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index cbaa26a6e..0f9382f16 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -174,6 +174,8 @@ export function createCli(commands: Record, opts: CliOptions ...(opts.commandInvocation ? { commandInvocation: opts.commandInvocation } : {}), }; const commandPrefix = formatCommand(identity); + // Product capability gate: only render usage free / freetier recovery when those paths exist. + const supportsFreeTierRecovery = "usage free" in commands && "usage freetier" in commands; const commandPackPolicy = opts.commandPacks ?? { supported: {} }; let loadedCommandPacksPromise: ReturnType | undefined; @@ -338,6 +340,7 @@ export function createCli(commands: Record, opts: CliOptions settings: initialSettings, localize, commandPrefix, + supportsFreeTierRecovery, }); const warnings: OutputDiagnostic[] = []; const cleanup = installProcessHandlers(() => reporter, localize); @@ -348,11 +351,17 @@ export function createCli(commands: Record, opts: CliOptions settings: buildOutputSettings(sources), localize, commandPrefix, + supportsFreeTierRecovery, }); setupProxyFromEnv(); const registry = await getRegistry(translator); await dispatch(registry, argv, localize, sources, (settings) => { - reporter = createOutputReporter({ settings, localize, commandPrefix }); + reporter = createOutputReporter({ + settings, + localize, + commandPrefix, + supportsFreeTierRecovery, + }); for (const warning of warnings.splice(0)) reporter.output.warning(warning); return reporter; }); diff --git a/packages/runtime/src/error-handler.ts b/packages/runtime/src/error-handler.ts index 275ef34bc..943d048ce 100644 --- a/packages/runtime/src/error-handler.ts +++ b/packages/runtime/src/error-handler.ts @@ -29,16 +29,53 @@ export function normalizeError( return new BailianError(message, ExitCode.GENERAL); } +type Localize = (text: LocalizedText) => string; +const english: Localize = (text) => (typeof text === "string" ? text : text["en-US"]); + /** Complete recovery text supplied before Console errors can be wrapped by commands. */ export function consoleRecoveryHint(commandPrefix: string): string { return `Run \`${formatCommand(commandPrefix, "auth login --console")}\` to sign in or refresh your console session.`; } -/** Add runtime-owned model-auth guidance without modifying service errors. */ +/** Native DashScope code for free-tier-only exhaustion (not TPS/TPM insufficient_quota). */ +const FREE_TIER_ONLY_API_CODE = "AllocationQuota.FreeTierOnly"; + +export interface ErrorHintOptions { + /** + * When true, free-tier recovery may render `usage free` / `usage freetier` + * under this product's commandPrefix. Derived from the product command map. + */ + supportsFreeTierRecovery?: boolean; +} + +/** Detect AllocationQuota.FreeTierOnly without matching TPS/TPM insufficient_quota. */ +function isFreeTierOnlyExhausted(error: BailianError): boolean { + if (error.api?.apiCode === FREE_TIER_ONLY_API_CODE) return true; + const message = error.message; + // Documented FreeTierOnly copy, plus OpenAI-compat wrappers that keep the + // distinctive "use free tier only" phrase when type/code are remapped. + return ( + /use free tier only/i.test(message) || + /The free tier of the model has been exhausted/i.test(message) + ); +} + +/** Localized free-tier recovery steps; only call when the product exposes usage commands. */ +function freeTierRecoveryHint(commandPrefix: string, localize: Localize): string { + const statusCommand = formatCommand(commandPrefix, "usage free --model "); + const disableCommand = formatCommand(commandPrefix, "usage freetier --off --model --yes"); + return localize({ + "en-US": `Check status: ${statusCommand}\nIf Auto-Stop is ON and you accept pay-as-you-go: ${disableCommand}`, + "zh-CN": `查看状态:${statusCommand}\n若 Auto-Stop 为 ON 且接受按量计费:${disableCommand}`, + }); +} + +/** Add runtime-owned recovery guidance without modifying service error messages. */ export function errorHint( error: BailianError, commandPrefix: string, localize: Localize, + options: ErrorHintOptions = {}, ): string | undefined { let hint = error.hint; if (error instanceof CredentialError) { @@ -59,6 +96,12 @@ export function errorHint( return hint; } } + // Append free-tier recovery before the AUTH-only early return so GENERAL + // service errors (and async paths that already set a hint) still get guidance. + if (options.supportsFreeTierRecovery && isFreeTierOnlyExhausted(error)) { + const recovery = freeTierRecoveryHint(commandPrefix, localize); + hint = hint ? `${hint}\n${recovery}` : recovery; + } if (error.exitCode !== ExitCode.AUTH) return hint; if ( hint && @@ -80,9 +123,6 @@ export function errorHint( .join("\n"); } -type Localize = (text: LocalizedText) => string; -const english: Localize = (text) => (typeof text === "string" ? text : text["en-US"]); - /** Shared local-error classification; service errors are never reclassified. */ export function classifyRuntimeError( error: Error, diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6203e5f08..84f0e05e0 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -27,7 +27,7 @@ export type { ParsePathResult } from "./args.ts"; // Process-level setup / error handling export { setupProxyFromEnv } from "./proxy.ts"; -export { handleError, normalizeError } from "./error-handler.ts"; +export { handleError, normalizeError, errorHint, type ErrorHintOptions } from "./error-handler.ts"; export { CLI_VERSION } from "./version.ts"; // Console URLs referenced by commands (e.g. auth/status, banner) diff --git a/packages/runtime/src/output/reporter.ts b/packages/runtime/src/output/reporter.ts index 262b5b390..697aecd54 100644 --- a/packages/runtime/src/output/reporter.ts +++ b/packages/runtime/src/output/reporter.ts @@ -23,6 +23,8 @@ export interface OutputReporterOptions { stderr?: OutputWriter; localize?: (text: LocalizedText) => string; commandPrefix?: string; + /** When true, free-tier recovery may render usage free / usage freetier commands. */ + supportsFreeTierRecovery?: boolean; } export interface OutputReporter { @@ -96,7 +98,9 @@ export function createOutputReporter(options: OutputReporterOptions): OutputRepo error(error) { if (errorCode !== undefined) return errorCode; const failure = normalizeError(error, options.commandPrefix ?? "cli", localize); - const hint = errorHint(failure, options.commandPrefix ?? "cli", localize); + const hint = errorHint(failure, options.commandPrefix ?? "cli", localize, { + supportsFreeTierRecovery: options.supportsFreeTierRecovery, + }); if (verbose && error instanceof Error && !(error instanceof BailianError)) { diagnostic("debug", { code: "ERROR_STACK", diff --git a/packages/runtime/tests/error-handler.test.ts b/packages/runtime/tests/error-handler.test.ts index 4aa75ba5b..305527527 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -1,14 +1,19 @@ -import { BailianError, ExitCode } from "bailian-cli-core"; +import { BailianError, ExitCode, mapApiError } from "bailian-cli-core"; import { expect, test, vi } from "vite-plus/test"; -import { handleError } from "../src/error-handler.ts"; +import { errorHint, handleError } from "../src/error-handler.ts"; import { createOutputReporter } from "../src/output/reporter.ts"; import { ConfirmationRequiredError } from "../src/confirm.ts"; -function render(error: unknown, output: "text" | "json" = "json") { +function render( + error: unknown, + output: "text" | "json" = "json", + options: { commandPrefix?: string; supportsFreeTierRecovery?: boolean } = {}, +) { let stderr = ""; const reporter = createOutputReporter({ settings: { output, quiet: true, verbose: false }, - commandPrefix: "kscli", + commandPrefix: options.commandPrefix ?? "kscli", + supportsFreeTierRecovery: options.supportsFreeTierRecovery, stderr: { write: (chunk) => { stderr += chunk; @@ -26,6 +31,12 @@ function render(error: unknown, output: "text" | "json" = "json") { return stderr; } +const FREE_TIER_ONLY_API_CODE = "AllocationQuota.FreeTierOnly"; +const FREE_TIER_MESSAGE = + 'The free tier of the model has been exhausted. If you wish to continue access the model on a paid basis, please disable the "use free tier only" mode in the management console.'; +const FREE_QUOTA_MESSAGE = + 'Free quota exhausted. To continue accessing the model on a paid basis, please add funds or disable the "use free tier only" mode in the management console.'; + test("handleError: fetch failed JSON includes cause.code from errno", () => { const cause = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), { code: "ENOTFOUND", @@ -66,3 +77,77 @@ test("model-auth guidance uses the invocation binary and does not leak into cons ); expect(JSON.parse(render(consoleError))).toEqual(consoleError.toJSON()); }); + +test("free-tier hint appends for AllocationQuota.FreeTierOnly when product supports usage", () => { + const error = new BailianError(FREE_TIER_MESSAGE, ExitCode.GENERAL, undefined, { + api: { httpStatus: 403, apiCode: FREE_TIER_ONLY_API_CODE }, + }); + const payload = JSON.parse( + render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), + ).error; + expect(payload.code).toBe(ExitCode.GENERAL); + expect(payload.message).toBe(FREE_TIER_MESSAGE); + expect(payload.hint).toContain("bl usage free --model "); + expect(payload.hint).toContain("bl usage freetier --off --model --yes"); + expect(payload.hint).not.toContain("--all"); + const text = render(error, "text", { commandPrefix: "bl", supportsFreeTierRecovery: true }); + expect(text).toContain("Hint:"); + expect(text).toContain("usage freetier --off"); +}); + +test("free-tier hint uses message fallback when type shadows AllocationQuota.FreeTierOnly code", () => { + const error = mapApiError(400, { + error: { + message: FREE_TIER_MESSAGE, + type: "invalid_request_error", + code: FREE_TIER_ONLY_API_CODE, + }, + }); + expect(error.api?.apiCode).toBe("invalid_request_error"); + const hint = errorHint(error, "bl", (text) => (typeof text === "string" ? text : text["en-US"]), { + supportsFreeTierRecovery: true, + }); + expect(hint).toContain("bl usage freetier --off"); +}); + +test("OpenAI-compat Free quota exhausted copy matches via use free tier only phrase", () => { + const error = new BailianError(FREE_QUOTA_MESSAGE, ExitCode.GENERAL); + const payload = JSON.parse( + render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), + ).error; + expect(payload.hint).toContain("bl usage freetier --off --model --yes"); +}); + +test("insufficient_quota TPS/TPM message does not get free-tier recovery", () => { + const error = new BailianError( + "You exceeded your current quota, please check your plan and billing details.", + ExitCode.GENERAL, + undefined, + { api: { apiCode: "insufficient_quota" } }, + ); + const payload = JSON.parse( + render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), + ).error; + expect(payload.hint).toBeUndefined(); +}); + +test("free-tier hint is skipped when the product does not expose usage commands", () => { + const error = new BailianError(FREE_TIER_MESSAGE, ExitCode.GENERAL, undefined, { + api: { apiCode: FREE_TIER_ONLY_API_CODE }, + }); + const payload = JSON.parse( + render(error, "json", { commandPrefix: "kscli", supportsFreeTierRecovery: false }), + ).error; + expect(payload.hint).toBeUndefined(); + expect(payload.message).toBe(FREE_TIER_MESSAGE); +}); + +test("free-tier hint appends after an existing hint without replacing it", () => { + const existing = "Task id: task-demo"; + const error = new BailianError(FREE_QUOTA_MESSAGE, ExitCode.GENERAL, existing); + const payload = JSON.parse( + render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), + ).error; + expect(payload.hint?.startsWith(existing)).toBe(true); + expect(payload.hint).toContain("usage freetier --off"); +}); diff --git a/skills/bailian-cli/reference/usage.md b/skills/bailian-cli/reference/usage.md index 0554c818c..0e5f1e6b0 100644 --- a/skills/bailian-cli/reference/usage.md +++ b/skills/bailian-cli/reference/usage.md @@ -104,12 +104,16 @@ bl usage free --model qwen3-max --console-region cn-beijing ### `bl usage freetier` -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------------ | -| **Name** | `usage freetier` | -| **Description** | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | -| **Authentication** | Console | -| **Usage** | `bl usage freetier <--model [,model2,...] \| --all> [--off] [flags]` | +| Field | Value | +| ------------------ | -------------------------------------------------------------------------------------------------------- | +| **Name** | `usage freetier` | +| **Description** | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | +| **Authentication** | Console | +| **Usage** | `bl usage freetier <--model [,model2,...] \| --all> [--off] [flags]` | +| **Risk** | `high` | +| **Risk message** | This changes free-tier auto-stop. Disabling it allows pay-as-you-go calls after free quota is exhausted. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. #### Flags @@ -119,35 +123,46 @@ bl usage free --model qwen3-max --console-region cn-beijing | `--all` | switch | no | Apply to all free-tier models | | `--on` | switch | no | Enable auto-stop (default behavior) | | `--off` | switch | no | Disable auto-stop | +| `--yes` | switch | no | Confirm this high-risk operation | | `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | | `--console-site ` | string | no | Console site: domestic, international | | `--console-switch-agent ` | number | no | Switch agent UID for delegated access | | `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | +#### Notes + +- Requires `--yes` (exit code 7 / requires_confirmation without it). Applies to both enabling and disabling auto-stop. + #### Examples ```bash -bl usage freetier --model qwen3-max +# Only after explicit user confirmation: +bl usage freetier --model qwen3-max --yes ``` ```bash -bl usage freetier --model qwen3-max,qwen-turbo +# Only after explicit user confirmation: +bl usage freetier --model qwen3-max,qwen-turbo --yes ``` ```bash -bl usage freetier --all +# Only after explicit user confirmation: +bl usage freetier --all --yes ``` ```bash -bl usage freetier --on --model qwen3-max +# Only after explicit user confirmation: +bl usage freetier --on --model qwen3-max --yes ``` ```bash -bl usage freetier --off --model qwen3-max +# Only after explicit user confirmation: +bl usage freetier --off --model qwen3-max --yes ``` ```bash -bl usage freetier --off --all +# Only after explicit user confirmation: +bl usage freetier --off --all --yes ``` ### `bl usage stats` diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index 04350f0bd..3d718dd73 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -226,7 +226,7 @@ If the task actually ran one or more `bl` commands, **proactively add a one-line Examples (match the user's language): -> I used `bl usage free` to check the free quota status, and then used `bl usage freetier --off` to disable automatic deactivation. +> I used `bl usage free` to check the free quota status, and then used `bl usage freetier --off --yes` to disable automatic deactivation. > I used `bl image generate` to generate 3 posters to ./out/, and then used `bl video generate` to combine the header. > I first upgraded bl to the latest version, and then used `bl text chat` to complete the translation. From 50987484319cdc9c13ca4ec3216a8f15fed2df2b Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 11 Nov 2026 09:40:32 +0800 Subject: [PATCH 2/6] fix: keep free-tier recovery behind confirmation gate --- .../commands/src/commands/usage/freetier.ts | 5 +++-- .../tests/e2e/usage-freetier.e2e.test.ts | 1 + packages/runtime/src/error-handler.ts | 14 +++++++++---- packages/runtime/tests/error-handler.test.ts | 20 +++++++++++++++++-- skills/bailian-cli/reference/usage.md | 16 +++++++-------- skills/bailian-protocol/SKILL.md | 2 +- 6 files changed, 41 insertions(+), 17 deletions(-) diff --git a/packages/commands/src/commands/usage/freetier.ts b/packages/commands/src/commands/usage/freetier.ts index 3cea7dbd4..bdb5385d1 100644 --- a/packages/commands/src/commands/usage/freetier.ts +++ b/packages/commands/src/commands/usage/freetier.ts @@ -26,8 +26,9 @@ export default defineCommand({ reason: "billing", message: { "en-US": - "This changes free-tier auto-stop. Disabling it allows pay-as-you-go calls after free quota is exhausted.", - "zh-CN": "此操作会变更免费额度自动停止。停用后,免费额度用尽将继续按量计费调用。", + "This changes free-tier auto-stop. Enabling it makes calls fail once free quota is exhausted; disabling it allows pay-as-you-go charges after free quota is exhausted.", + "zh-CN": + "此操作会变更免费额度自动停止。启用后,免费额度用尽时调用将失败;停用后,免费额度用尽将继续按量计费调用。", }, }, usageArgs: "<--model [,model2,...] | --all> [--off] [flags]", diff --git a/packages/commands/tests/e2e/usage-freetier.e2e.test.ts b/packages/commands/tests/e2e/usage-freetier.e2e.test.ts index ac6a47be0..06e17d17e 100644 --- a/packages/commands/tests/e2e/usage-freetier.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-freetier.e2e.test.ts @@ -13,6 +13,7 @@ describe("e2e: usage freetier", () => { expect(stderr).toContain("--yes"); expect(stderr).toMatch(/Risk:\s*high/i); expect(stderr).toContain("This changes free-tier auto-stop"); + expect(stderr).toContain("Enabling it makes calls fail once free quota is exhausted"); expect(stderr).toContain("bl usage freetier --model qwen3-max --yes"); expect(stderr).toContain("bl usage freetier --off --model qwen3-max --yes"); }); diff --git a/packages/runtime/src/error-handler.ts b/packages/runtime/src/error-handler.ts index 943d048ce..6fba971eb 100644 --- a/packages/runtime/src/error-handler.ts +++ b/packages/runtime/src/error-handler.ts @@ -63,10 +63,11 @@ function isFreeTierOnlyExhausted(error: BailianError): boolean { /** Localized free-tier recovery steps; only call when the product exposes usage commands. */ function freeTierRecoveryHint(commandPrefix: string, localize: Localize): string { const statusCommand = formatCommand(commandPrefix, "usage free --model "); - const disableCommand = formatCommand(commandPrefix, "usage freetier --off --model --yes"); + // 不带 --yes:调用方先撞确认门槛(exit 7),再按 skill 规范向用户确认。 + const disableCommand = formatCommand(commandPrefix, "usage freetier --off --model "); return localize({ - "en-US": `Check status: ${statusCommand}\nIf Auto-Stop is ON and you accept pay-as-you-go: ${disableCommand}`, - "zh-CN": `查看状态:${statusCommand}\n若 Auto-Stop 为 ON 且接受按量计费:${disableCommand}`, + "en-US": `Check status: ${statusCommand}\nTo keep calling after free quota runs out (pay-as-you-go), disable auto-stop — confirmation required: ${disableCommand}`, + "zh-CN": `查看状态:${statusCommand}\n若要在免费额度用尽后按量继续调用,请停用自动停止(需确认):${disableCommand}`, }); } @@ -98,7 +99,12 @@ export function errorHint( } // Append free-tier recovery before the AUTH-only early return so GENERAL // service errors (and async paths that already set a hint) still get guidance. - if (options.supportsFreeTierRecovery && isFreeTierOnlyExhausted(error)) { + // 已含恢复指引(命令自带或上游已追加)时不重复追加。 + if ( + options.supportsFreeTierRecovery && + isFreeTierOnlyExhausted(error) && + !hint?.includes("usage freetier") + ) { const recovery = freeTierRecoveryHint(commandPrefix, localize); hint = hint ? `${hint}\n${recovery}` : recovery; } diff --git a/packages/runtime/tests/error-handler.test.ts b/packages/runtime/tests/error-handler.test.ts index 305527527..d97d83785 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -88,11 +88,13 @@ test("free-tier hint appends for AllocationQuota.FreeTierOnly when product suppo expect(payload.code).toBe(ExitCode.GENERAL); expect(payload.message).toBe(FREE_TIER_MESSAGE); expect(payload.hint).toContain("bl usage free --model "); - expect(payload.hint).toContain("bl usage freetier --off --model --yes"); + expect(payload.hint).toContain("bl usage freetier --off --model "); + expect(payload.hint).not.toContain("--yes"); expect(payload.hint).not.toContain("--all"); const text = render(error, "text", { commandPrefix: "bl", supportsFreeTierRecovery: true }); expect(text).toContain("Hint:"); expect(text).toContain("usage freetier --off"); + expect(text).not.toContain("--yes"); }); test("free-tier hint uses message fallback when type shadows AllocationQuota.FreeTierOnly code", () => { @@ -115,7 +117,8 @@ test("OpenAI-compat Free quota exhausted copy matches via use free tier only phr const payload = JSON.parse( render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), ).error; - expect(payload.hint).toContain("bl usage freetier --off --model --yes"); + expect(payload.hint).toContain("bl usage freetier --off --model "); + expect(payload.hint).not.toContain("--yes"); }); test("insufficient_quota TPS/TPM message does not get free-tier recovery", () => { @@ -150,4 +153,17 @@ test("free-tier hint appends after an existing hint without replacing it", () => ).error; expect(payload.hint?.startsWith(existing)).toBe(true); expect(payload.hint).toContain("usage freetier --off"); + expect(payload.hint).not.toContain("--yes"); +}); + +test("free-tier hint does not duplicate when existing hint already mentions usage freetier", () => { + const existing = "Already guided: bl usage freetier --off --model demo"; + const error = new BailianError(FREE_TIER_MESSAGE, ExitCode.GENERAL, existing, { + api: { apiCode: FREE_TIER_ONLY_API_CODE }, + }); + const payload = JSON.parse( + render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), + ).error; + expect(payload.hint).toBe(existing); + expect(payload.hint?.split("usage freetier").length - 1).toBe(1); }); diff --git a/skills/bailian-cli/reference/usage.md b/skills/bailian-cli/reference/usage.md index 0e5f1e6b0..07948e8c2 100644 --- a/skills/bailian-cli/reference/usage.md +++ b/skills/bailian-cli/reference/usage.md @@ -104,14 +104,14 @@ bl usage free --model qwen3-max --console-region cn-beijing ### `bl usage freetier` -| Field | Value | -| ------------------ | -------------------------------------------------------------------------------------------------------- | -| **Name** | `usage freetier` | -| **Description** | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | -| **Authentication** | Console | -| **Usage** | `bl usage freetier <--model [,model2,...] \| --all> [--off] [flags]` | -| **Risk** | `high` | -| **Risk message** | This changes free-tier auto-stop. Disabling it allows pay-as-you-go calls after free quota is exhausted. | +| Field | Value | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `usage freetier` | +| **Description** | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | +| **Authentication** | Console | +| **Usage** | `bl usage freetier <--model [,model2,...] \| --all> [--off] [flags]` | +| **Risk** | `high` | +| **Risk message** | This changes free-tier auto-stop. Enabling it makes calls fail once free quota is exhausted; disabling it allows pay-as-you-go charges after free quota is exhausted. | > **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index 3d718dd73..435ea7fe2 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -226,7 +226,7 @@ If the task actually ran one or more `bl` commands, **proactively add a one-line Examples (match the user's language): -> I used `bl usage free` to check the free quota status, and then used `bl usage freetier --off --yes` to disable automatic deactivation. +> I used `bl usage free` to check the free quota status, and then used `bl usage freetier --off` (after user confirmation) to disable automatic deactivation. > I used `bl image generate` to generate 3 posters to ./out/, and then used `bl video generate` to combine the header. > I first upgraded bl to the latest version, and then used `bl text chat` to complete the translation. From f694e37eabbe0e1987ead2b1e045981665324c6c Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 11 Nov 2026 14:08:09 +0800 Subject: [PATCH 3/6] fix(runtime): append --config to recovery hints for named profiles --- docs/agents/error-hint-change.md | 1 + packages/runtime/src/create-cli.ts | 2 + packages/runtime/src/error-handler.ts | 58 ++++++++++++++------ packages/runtime/src/middleware.ts | 2 +- packages/runtime/src/output/reporter.ts | 10 +++- packages/runtime/tests/error-handler.test.ts | 48 +++++++++++++++- 6 files changed, 100 insertions(+), 21 deletions(-) diff --git a/docs/agents/error-hint-change.md b/docs/agents/error-hint-change.md index 853ac5857..18aded25f 100644 --- a/docs/agents/error-hint-change.md +++ b/docs/agents/error-hint-change.md @@ -109,6 +109,7 @@ process.exit(err.exitCode) - [ ] 追加时若已有 hint(如 video task id),拼接并按标记去重;不要 `hint` 非空就跳过 - [ ] 渲染产品命令前须经能力门控(命令表是否暴露对应 path),不要对 `kscli` 等缺命令的产品硬拼 `usage freetier` - [ ] 命令名使用 `commandPrefix`,不要硬编码 `bl` +- [ ] 恢复命令经 `recoveryCommand` 渲染:命名 Profile 激活时追加 `--config `(default / 未指定不加) - [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"` ### C. command / runtime 直接抛错 diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 0f9382f16..bcb7f2615 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -352,6 +352,7 @@ export function createCli(commands: Record, opts: CliOptions localize, commandPrefix, supportsFreeTierRecovery, + configName: sources.configName, }); setupProxyFromEnv(); const registry = await getRegistry(translator); @@ -361,6 +362,7 @@ export function createCli(commands: Record, opts: CliOptions localize, commandPrefix, supportsFreeTierRecovery, + configName: sources.configName, }); for (const warning of warnings.splice(0)) reporter.output.warning(warning); return reporter; diff --git a/packages/runtime/src/error-handler.ts b/packages/runtime/src/error-handler.ts index 6fba971eb..c87a19a62 100644 --- a/packages/runtime/src/error-handler.ts +++ b/packages/runtime/src/error-handler.ts @@ -14,11 +14,12 @@ export function normalizeError( error: unknown, commandPrefix: string, localize: Localize, + configName?: string, ): BailianError { if (error instanceof BailianError) return error; if (error instanceof Error) { return ( - classifyRuntimeError(error, commandPrefix, localize) ?? + classifyRuntimeError(error, commandPrefix, localize, configName) ?? new BailianError(error.message, ExitCode.GENERAL, undefined, { cause: error }) ); } @@ -32,9 +33,18 @@ export function normalizeError( type Localize = (text: LocalizedText) => string; const english: Localize = (text) => (typeof text === "string" ? text : text["en-US"]); +/** + * 恢复命令统一入口:命名 Profile 激活时追加 `--config `, + * 避免用户按 hint 操作打到错误的凭证/计费配置。 + */ +function recoveryCommand(commandPrefix: string, args: string, configName?: string): string { + const withConfig = configName ? `${args} --config ${configName}` : args; + return formatCommand(commandPrefix, withConfig); +} + /** Complete recovery text supplied before Console errors can be wrapped by commands. */ -export function consoleRecoveryHint(commandPrefix: string): string { - return `Run \`${formatCommand(commandPrefix, "auth login --console")}\` to sign in or refresh your console session.`; +export function consoleRecoveryHint(commandPrefix: string, configName?: string): string { + return `Run \`${recoveryCommand(commandPrefix, "auth login --console", configName)}\` to sign in or refresh your console session.`; } /** Native DashScope code for free-tier-only exhaustion (not TPS/TPM insufficient_quota). */ @@ -46,6 +56,8 @@ export interface ErrorHintOptions { * under this product's commandPrefix. Derived from the product command map. */ supportsFreeTierRecovery?: boolean; + /** 当前命名 Profile;未指定或 default 时为 undefined,恢复命令不加 `--config`。 */ + configName?: string; } /** Detect AllocationQuota.FreeTierOnly without matching TPS/TPM insufficient_quota. */ @@ -61,10 +73,18 @@ function isFreeTierOnlyExhausted(error: BailianError): boolean { } /** Localized free-tier recovery steps; only call when the product exposes usage commands. */ -function freeTierRecoveryHint(commandPrefix: string, localize: Localize): string { - const statusCommand = formatCommand(commandPrefix, "usage free --model "); +function freeTierRecoveryHint( + commandPrefix: string, + localize: Localize, + configName?: string, +): string { + const statusCommand = recoveryCommand(commandPrefix, "usage free --model ", configName); // 不带 --yes:调用方先撞确认门槛(exit 7),再按 skill 规范向用户确认。 - const disableCommand = formatCommand(commandPrefix, "usage freetier --off --model "); + const disableCommand = recoveryCommand( + commandPrefix, + "usage freetier --off --model ", + configName, + ); return localize({ "en-US": `Check status: ${statusCommand}\nTo keep calling after free quota runs out (pay-as-you-go), disable auto-stop — confirmation required: ${disableCommand}`, "zh-CN": `查看状态:${statusCommand}\n若要在免费额度用尽后按量继续调用,请停用自动停止(需确认):${disableCommand}`, @@ -78,20 +98,21 @@ export function errorHint( localize: Localize, options: ErrorHintOptions = {}, ): string | undefined { + const configName = options.configName; let hint = error.hint; if (error instanceof CredentialError) { switch (error.reason) { case "missing-api-key": - hint = `Set DASHSCOPE_API_KEY, pass --api-key, or run \`${formatCommand(commandPrefix, "auth login")}\`.`; + hint = `Set DASHSCOPE_API_KEY, pass --api-key, or run \`${recoveryCommand(commandPrefix, "auth login", configName)}\`.`; break; case "missing-console-token": - return `Run \`${formatCommand(commandPrefix, "auth login --console")}\`.`; + return `Run \`${recoveryCommand(commandPrefix, "auth login --console", configName)}\`.`; case "expired-console-session": - return consoleRecoveryHint(commandPrefix); + return consoleRecoveryHint(commandPrefix, configName); case "missing-openapi-pair": - return `Set BAILIAN_ACCESS_KEY_ID and BAILIAN_ACCESS_KEY_SECRET (or ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET), pass --access-key-id and --access-key-secret, or run \`${formatCommand(commandPrefix, "auth login --open-api")}\`.`; + return `Set BAILIAN_ACCESS_KEY_ID and BAILIAN_ACCESS_KEY_SECRET (or ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET), pass --access-key-id and --access-key-secret, or run \`${recoveryCommand(commandPrefix, "auth login --open-api", configName)}\`.`; case "incomplete-openapi-config": - return `Run \`${formatCommand(commandPrefix, "auth login --open-api --access-key-id --access-key-secret ")}\` again to save a complete pair.`; + return `Run \`${recoveryCommand(commandPrefix, "auth login --open-api --access-key-id --access-key-secret ", configName)}\` again to save a complete pair.`; case "incomplete-openapi-flag": case "incomplete-openapi-env": return hint; @@ -105,7 +126,7 @@ export function errorHint( isFreeTierOnlyExhausted(error) && !hint?.includes("usage freetier") ) { - const recovery = freeTierRecoveryHint(commandPrefix, localize); + const recovery = freeTierRecoveryHint(commandPrefix, localize, configName); hint = hint ? `${hint}\n${recovery}` : recovery; } if (error.exitCode !== ExitCode.AUTH) return hint; @@ -121,8 +142,8 @@ export function errorHint( return hint; return [ hint, - `${formatCommand(commandPrefix, "auth login --api-key ")}`, - `${formatCommand(commandPrefix, "auth status")}`, + `${recoveryCommand(commandPrefix, "auth login --api-key ", configName)}`, + `${recoveryCommand(commandPrefix, "auth status", configName)}`, localize({ "en-US": `Get API Key: ${API_KEY_PAGE}`, "zh-CN": `获取 API Key:${API_KEY_PAGE}` }), ] .filter((line) => line !== undefined) @@ -134,11 +155,12 @@ export function classifyRuntimeError( error: Error, commandPrefix: string, localize: Localize = english, + configName?: string, ): BailianError | undefined { if (error instanceof BailianError) return error; const baseUrlHint = localize({ - "en-US": `If the DashScope host is wrong, check baseUrl (--base-url, ${formatCommand(commandPrefix, "config show")}, or DASHSCOPE_BASE_URL).`, - "zh-CN": `如果 DashScope 主机地址有误,请检查 baseUrl(--base-url、${formatCommand(commandPrefix, "config show")} 或 DASHSCOPE_BASE_URL)。`, + "en-US": `If the DashScope host is wrong, check baseUrl (--base-url, ${recoveryCommand(commandPrefix, "config show", configName)}, or DASHSCOPE_BASE_URL).`, + "zh-CN": `如果 DashScope 主机地址有误,请检查 baseUrl(--base-url、${recoveryCommand(commandPrefix, "config show", configName)} 或 DASHSCOPE_BASE_URL)。`, }); if ( error.name === "AbortError" || @@ -149,8 +171,8 @@ export function classifyRuntimeError( localize({ "en-US": "Request timed out.", "zh-CN": "请求超时。" }), ExitCode.TIMEOUT, localize({ - "en-US": `Try increasing --timeout (e.g. --timeout 60).\n${baseUrlHint}\nRun: ${formatCommand(commandPrefix, "auth status")} — to check credentials.`, - "zh-CN": `请尝试增大 --timeout(例如 --timeout 60)。\n${baseUrlHint}\n运行 ${formatCommand(commandPrefix, "auth status")} 检查凭证。`, + "en-US": `Try increasing --timeout (e.g. --timeout 60).\n${baseUrlHint}\nRun: ${recoveryCommand(commandPrefix, "auth status", configName)} — to check credentials.`, + "zh-CN": `请尝试增大 --timeout(例如 --timeout 60)。\n${baseUrlHint}\n运行 ${recoveryCommand(commandPrefix, "auth status", configName)} 检查凭证。`, }), { cause: error }, ); diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index 22b8f3ae2..fa62050ad 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -190,7 +190,7 @@ export const authStage: Middleware = async (ctx, next) => { const base = { identity: ctx.identity, settings, - consoleRecoveryHint: consoleRecoveryHint(formatCommand(ctx.identity)), + consoleRecoveryHint: consoleRecoveryHint(formatCommand(ctx.identity), sources.configName), debug: (diagnostic: Parameters[0]) => ctx.output.debug(diagnostic), ...resolveModelBaseUrlState(sources), }; diff --git a/packages/runtime/src/output/reporter.ts b/packages/runtime/src/output/reporter.ts index 697aecd54..74087555b 100644 --- a/packages/runtime/src/output/reporter.ts +++ b/packages/runtime/src/output/reporter.ts @@ -25,6 +25,8 @@ export interface OutputReporterOptions { commandPrefix?: string; /** When true, free-tier recovery may render usage free / usage freetier commands. */ supportsFreeTierRecovery?: boolean; + /** 当前命名 Profile;未指定或 default 时为 undefined。 */ + configName?: string; } export interface OutputReporter { @@ -97,9 +99,15 @@ export function createOutputReporter(options: OutputReporterOptions): OutputRepo }, error(error) { if (errorCode !== undefined) return errorCode; - const failure = normalizeError(error, options.commandPrefix ?? "cli", localize); + const failure = normalizeError( + error, + options.commandPrefix ?? "cli", + localize, + options.configName, + ); const hint = errorHint(failure, options.commandPrefix ?? "cli", localize, { supportsFreeTierRecovery: options.supportsFreeTierRecovery, + configName: options.configName, }); if (verbose && error instanceof Error && !(error instanceof BailianError)) { diagnostic("debug", { diff --git a/packages/runtime/tests/error-handler.test.ts b/packages/runtime/tests/error-handler.test.ts index d97d83785..18f2475d8 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -7,13 +7,18 @@ import { ConfirmationRequiredError } from "../src/confirm.ts"; function render( error: unknown, output: "text" | "json" = "json", - options: { commandPrefix?: string; supportsFreeTierRecovery?: boolean } = {}, + options: { + commandPrefix?: string; + supportsFreeTierRecovery?: boolean; + configName?: string; + } = {}, ) { let stderr = ""; const reporter = createOutputReporter({ settings: { output, quiet: true, verbose: false }, commandPrefix: options.commandPrefix ?? "kscli", supportsFreeTierRecovery: options.supportsFreeTierRecovery, + configName: options.configName, stderr: { write: (chunk) => { stderr += chunk; @@ -167,3 +172,44 @@ test("free-tier hint does not duplicate when existing hint already mentions usag expect(payload.hint).toBe(existing); expect(payload.hint?.split("usage freetier").length - 1).toBe(1); }); + +test("recovery hints append --config when a named profile is active", () => { + const freeTier = new BailianError(FREE_TIER_MESSAGE, ExitCode.GENERAL, undefined, { + api: { httpStatus: 403, apiCode: FREE_TIER_ONLY_API_CODE }, + }); + const freeTierHint = JSON.parse( + render(freeTier, "json", { + commandPrefix: "bl", + supportsFreeTierRecovery: true, + configName: "prod", + }), + ).error.hint as string; + expect(freeTierHint).toContain("bl usage free --model --config prod"); + expect(freeTierHint).toContain("bl usage freetier --off --model --config prod"); + + const authHint = JSON.parse( + render(new BailianError("No key", ExitCode.AUTH), "json", { + commandPrefix: "bl", + configName: "prod", + }), + ).error.hint as string; + expect(authHint).toContain("bl auth login --api-key --config prod"); + expect(authHint).toContain("bl auth status --config prod"); + + const timeoutHint = JSON.parse( + render(Object.assign(new Error("timed out"), { name: "TimeoutError" }), "json", { + commandPrefix: "bl", + configName: "prod", + }), + ).error.hint as string; + expect(timeoutHint).toContain("bl config show --config prod"); + expect(timeoutHint).toContain("bl auth status --config prod"); +}); + +test("recovery hints omit --config for the default profile", () => { + const payload = JSON.parse( + render(new BailianError("No key", ExitCode.AUTH), "json", { commandPrefix: "bl" }), + ).error; + expect(payload.hint).toContain("bl auth status"); + expect(payload.hint).not.toContain("--config"); +}); From 3b3cb92be39dde523908e043d825899fb6d4e0ce Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 11 Nov 2026 16:16:20 +0800 Subject: [PATCH 4/6] fix(runtime): preserve explicit --config default in recovery hints --- docs/agents/error-hint-change.md | 2 +- .../src/commands/knowledge/chunk-delete.ts | 7 ++- .../commands/src/commands/usage/freetier.ts | 30 ++------- .../tests/e2e/usage-freetier.e2e.test.ts | 62 ++++++++++++++++--- packages/runtime/src/create-cli.ts | 7 ++- packages/runtime/src/error-handler.ts | 28 +++++++-- packages/runtime/src/index.ts | 2 +- packages/runtime/src/middleware.ts | 7 ++- packages/runtime/src/output/reporter.ts | 2 +- packages/runtime/tests/error-handler.test.ts | 23 ++++++- 10 files changed, 120 insertions(+), 50 deletions(-) diff --git a/docs/agents/error-hint-change.md b/docs/agents/error-hint-change.md index 18aded25f..aee90b68d 100644 --- a/docs/agents/error-hint-change.md +++ b/docs/agents/error-hint-change.md @@ -109,7 +109,7 @@ process.exit(err.exitCode) - [ ] 追加时若已有 hint(如 video task id),拼接并按标记去重;不要 `hint` 非空就跳过 - [ ] 渲染产品命令前须经能力门控(命令表是否暴露对应 path),不要对 `kscli` 等缺命令的产品硬拼 `usage freetier` - [ ] 命令名使用 `commandPrefix`,不要硬编码 `bl` -- [ ] 恢复命令经 `recoveryCommand` 渲染:命名 Profile 激活时追加 `--config `(default / 未指定不加) +- [ ] 恢复命令经 `recoveryCommand` 渲染;Profile 名用 `resolveRecoveryConfigName`(显式 `--config default` 保留,隐式 default 不加) - [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"` ### C. command / runtime 直接抛错 diff --git a/packages/commands/src/commands/knowledge/chunk-delete.ts b/packages/commands/src/commands/knowledge/chunk-delete.ts index 358975fd9..20b5ec2ea 100644 --- a/packages/commands/src/commands/knowledge/chunk-delete.ts +++ b/packages/commands/src/commands/knowledge/chunk-delete.ts @@ -103,7 +103,12 @@ export default defineCommand({ deletedCount += batchIds.length; completedBatches.push(batchIds); } catch (error) { - const original = normalizeError(error, formatCommand(ctx.identity), ctx.localize); + const original = normalizeError( + error, + formatCommand(ctx.identity), + ctx.localize, + ctx.settings.configName, + ); const failedBatch = completedBatches.length + 1; const progress = [ ctx.localize({ diff --git a/packages/commands/src/commands/usage/freetier.ts b/packages/commands/src/commands/usage/freetier.ts index bdb5385d1..459ca8131 100644 --- a/packages/commands/src/commands/usage/freetier.ts +++ b/packages/commands/src/commands/usage/freetier.ts @@ -58,30 +58,12 @@ export default defineCommand({ }, }, exampleArgs: [ - { - "en-US": "--model qwen3-max --yes", - "zh-CN": "--model qwen3-max --yes", - }, - { - "en-US": "--model qwen3-max,qwen-turbo --yes", - "zh-CN": "--model qwen3-max,qwen-turbo --yes", - }, - { - "en-US": "--all --yes", - "zh-CN": "--all --yes", - }, - { - "en-US": "--on --model qwen3-max --yes", - "zh-CN": "--on --model qwen3-max --yes", - }, - { - "en-US": "--off --model qwen3-max --yes", - "zh-CN": "--off --model qwen3-max --yes", - }, - { - "en-US": "--off --all --yes", - "zh-CN": "--off --all --yes", - }, + "--model qwen3-max --yes", + "--model qwen3-max,qwen-turbo --yes", + "--all --yes", + "--on --model qwen3-max --yes", + "--off --model qwen3-max --yes", + "--off --all --yes", ], notes: [ { diff --git a/packages/commands/tests/e2e/usage-freetier.e2e.test.ts b/packages/commands/tests/e2e/usage-freetier.e2e.test.ts index 06e17d17e..e88916b2a 100644 --- a/packages/commands/tests/e2e/usage-freetier.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-freetier.e2e.test.ts @@ -1,7 +1,15 @@ import { describe, expect, test } from "vite-plus/test"; -import { runCommandE2e, runCommandHelp } from "./helpers.ts"; +import { parseStdoutJson, runCommandHelp, runCommandInProcess } from "./helpers.ts"; import { USAGE_ROUTES } from "./topic-routes.ts"; +/** Clear host OpenAPI env so console auth cannot exchange a local AK/SK into a session. */ +const CLEAR_OPENAPI_ENV = { + BAILIAN_ACCESS_KEY_ID: undefined, + BAILIAN_ACCESS_KEY_SECRET: undefined, + ALIBABA_CLOUD_ACCESS_KEY_ID: undefined, + ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined, +}; + describe("e2e: usage freetier", () => { test("usage freetier --help 展示 risk、--yes 与确认示例", async () => { const { stderr, exitCode } = await runCommandHelp(USAGE_ROUTES, [ @@ -19,7 +27,7 @@ describe("e2e: usage freetier", () => { }); test("usage freetier 无 --yes 返回确认请求 (7)", async () => { - const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(USAGE_ROUTES, [ "usage", "freetier", "--model", @@ -34,20 +42,54 @@ describe("e2e: usage freetier", () => { }); test("usage freetier --yes 通过确认门槛后落到 AUTH (3)", async () => { - // Clear OpenAPI env so console auth cannot exchange a host AK/SK into a session. - const { stderr, exitCode } = await runCommandE2e( + const { stderr, exitCode } = await runCommandInProcess( USAGE_ROUTES, ["usage", "freetier", "--model", "qwen-plus", "--yes", "--output", "json"], - { - BAILIAN_ACCESS_KEY_ID: undefined, - BAILIAN_ACCESS_KEY_SECRET: undefined, - ALIBABA_CLOUD_ACCESS_KEY_ID: undefined, - ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined, - }, + CLEAR_OPENAPI_ENV, ); expect(exitCode).toBe(3); expect(JSON.parse(stderr)).toMatchObject({ error: { code: 3 }, }); }); + + test("usage freetier --dry-run 跳过确认并输出计划(零网络)", async () => { + const { stdout, stderr, exitCode } = await runCommandInProcess(USAGE_ROUTES, [ + "usage", + "freetier", + "--model", + "qwen-plus", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).not.toContain("requires_confirmation"); + const data = parseStdoutJson<{ + api: string; + data: { BatchActivateFreeTierOnlyRequest: { models: string[] } }; + }>(stdout); + expect(data.api).toBe("zeldaEasy.bailian-commerce.freeTrial.batchActivateFreeTierOnly"); + expect(data.data.BatchActivateFreeTierOnlyRequest.models).toEqual(["qwen-plus"]); + }); + + test("usage freetier --off --dry-run 输出停用计划", async () => { + const { stdout, stderr, exitCode } = await runCommandInProcess(USAGE_ROUTES, [ + "usage", + "freetier", + "--off", + "--model", + "qwen-plus", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + api: string; + data: { BatchDeactivateFreeTierOnlyRequest: { models: string[] } }; + }>(stdout); + expect(data.api).toBe("zeldaEasy.bailian-commerce.freeTrial.batchDeactivateFreeTierOnly"); + expect(data.data.BatchDeactivateFreeTierOnlyRequest.models).toEqual(["qwen-plus"]); + }); }); diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index bcb7f2615..56b7598d6 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -40,7 +40,7 @@ import { Client, } from "bailian-cli-core"; import { setupProxyFromEnv } from "./proxy.ts"; -import { handleError } from "./error-handler.ts"; +import { handleError, resolveRecoveryConfigName } from "./error-handler.ts"; import { type OutputReporter, createOutputReporter } from "./output/reporter.ts"; import { loadCommandPacks } from "./command-packs/load.ts"; import { createCommandPackManager } from "./command-packs/manager.ts"; @@ -346,13 +346,14 @@ export function createCli(commands: Record, opts: CliOptions const cleanup = installProcessHandlers(() => reporter, localize); try { const sources = buildSources(flags, (warning) => warnings.push(warning)); + const recoveryConfigName = resolveRecoveryConfigName(sources); translator = createTranslator(sources.file.language ?? DEFAULT_LANGUAGE); reporter = createOutputReporter({ settings: buildOutputSettings(sources), localize, commandPrefix, supportsFreeTierRecovery, - configName: sources.configName, + configName: recoveryConfigName, }); setupProxyFromEnv(); const registry = await getRegistry(translator); @@ -362,7 +363,7 @@ export function createCli(commands: Record, opts: CliOptions localize, commandPrefix, supportsFreeTierRecovery, - configName: sources.configName, + configName: recoveryConfigName, }); for (const warning of warnings.splice(0)) reporter.output.warning(warning); return reporter; diff --git a/packages/runtime/src/error-handler.ts b/packages/runtime/src/error-handler.ts index c87a19a62..7ce179a2b 100644 --- a/packages/runtime/src/error-handler.ts +++ b/packages/runtime/src/error-handler.ts @@ -34,8 +34,23 @@ type Localize = (text: LocalizedText) => string; const english: Localize = (text) => (typeof text === "string" ? text : text["en-US"]); /** - * 恢复命令统一入口:命名 Profile 激活时追加 `--config `, - * 避免用户按 hint 操作打到错误的凭证/计费配置。 + * Config name for recovery hints (does not change IO-layer `configName` semantics). + * Explicit `--config default` is normalized to undefined in the loader; keep + * `"default"` here so copying the hint does not fall back to an active named profile. + */ +export function resolveRecoveryConfigName(sources: { + flags: { config?: unknown }; + configName?: string; +}): string | undefined { + if (sources.flags.config !== undefined) { + return sources.configName ?? "default"; + } + return sources.configName; +} + +/** + * Format a recovery command; append `--config ` when a profile must be pinned + * so following the hint does not hit the wrong credentials/billing config. */ function recoveryCommand(commandPrefix: string, args: string, configName?: string): string { const withConfig = configName ? `${args} --config ${configName}` : args; @@ -56,7 +71,10 @@ export interface ErrorHintOptions { * under this product's commandPrefix. Derived from the product command map. */ supportsFreeTierRecovery?: boolean; - /** 当前命名 Profile;未指定或 default 时为 undefined,恢复命令不加 `--config`。 */ + /** + * Profile to pin in recovery commands (via `resolveRecoveryConfigName`). + * Implicit default is undefined; explicit `--config default` is `"default"`. + */ configName?: string; } @@ -79,7 +97,7 @@ function freeTierRecoveryHint( configName?: string, ): string { const statusCommand = recoveryCommand(commandPrefix, "usage free --model ", configName); - // 不带 --yes:调用方先撞确认门槛(exit 7),再按 skill 规范向用户确认。 + // Omit --yes so the caller hits the confirmation gate (exit 7) first. const disableCommand = recoveryCommand( commandPrefix, "usage freetier --off --model ", @@ -120,7 +138,7 @@ export function errorHint( } // Append free-tier recovery before the AUTH-only early return so GENERAL // service errors (and async paths that already set a hint) still get guidance. - // 已含恢复指引(命令自带或上游已追加)时不重复追加。 + // Skip when recovery guidance is already present (command-owned or upstream). if ( options.supportsFreeTierRecovery && isFreeTierOnlyExhausted(error) && diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 84f0e05e0..6203e5f08 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -27,7 +27,7 @@ export type { ParsePathResult } from "./args.ts"; // Process-level setup / error handling export { setupProxyFromEnv } from "./proxy.ts"; -export { handleError, normalizeError, errorHint, type ErrorHintOptions } from "./error-handler.ts"; +export { handleError, normalizeError } from "./error-handler.ts"; export { CLI_VERSION } from "./version.ts"; // Console URLs referenced by commands (e.g. auth/status, banner) diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index fa62050ad..484fb58f0 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -1,4 +1,4 @@ -import { consoleRecoveryHint } from "./error-handler.ts"; +import { consoleRecoveryHint, resolveRecoveryConfigName } from "./error-handler.ts"; import type { AnyCommand, CommandOutput, @@ -190,7 +190,10 @@ export const authStage: Middleware = async (ctx, next) => { const base = { identity: ctx.identity, settings, - consoleRecoveryHint: consoleRecoveryHint(formatCommand(ctx.identity), sources.configName), + consoleRecoveryHint: consoleRecoveryHint( + formatCommand(ctx.identity), + resolveRecoveryConfigName(sources), + ), debug: (diagnostic: Parameters[0]) => ctx.output.debug(diagnostic), ...resolveModelBaseUrlState(sources), }; diff --git a/packages/runtime/src/output/reporter.ts b/packages/runtime/src/output/reporter.ts index 74087555b..317711552 100644 --- a/packages/runtime/src/output/reporter.ts +++ b/packages/runtime/src/output/reporter.ts @@ -25,7 +25,7 @@ export interface OutputReporterOptions { commandPrefix?: string; /** When true, free-tier recovery may render usage free / usage freetier commands. */ supportsFreeTierRecovery?: boolean; - /** 当前命名 Profile;未指定或 default 时为 undefined。 */ + /** Profile pinned in recovery hints (via resolveRecoveryConfigName). */ configName?: string; } diff --git a/packages/runtime/tests/error-handler.test.ts b/packages/runtime/tests/error-handler.test.ts index 18f2475d8..8f09175fa 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -1,6 +1,6 @@ import { BailianError, ExitCode, mapApiError } from "bailian-cli-core"; import { expect, test, vi } from "vite-plus/test"; -import { errorHint, handleError } from "../src/error-handler.ts"; +import { errorHint, handleError, resolveRecoveryConfigName } from "../src/error-handler.ts"; import { createOutputReporter } from "../src/output/reporter.ts"; import { ConfirmationRequiredError } from "../src/confirm.ts"; @@ -206,10 +206,29 @@ test("recovery hints append --config when a named profile is active", () => { expect(timeoutHint).toContain("bl auth status --config prod"); }); -test("recovery hints omit --config for the default profile", () => { +test("recovery hints omit --config for the implicit default profile", () => { const payload = JSON.parse( render(new BailianError("No key", ExitCode.AUTH), "json", { commandPrefix: "bl" }), ).error; expect(payload.hint).toContain("bl auth status"); expect(payload.hint).not.toContain("--config"); }); + +test("recovery hints keep explicit --config default when active profile is named", () => { + // Loader normalizes explicit default to undefined; resolveRecoveryConfigName must restore it. + expect(resolveRecoveryConfigName({ flags: { config: "default" }, configName: undefined })).toBe( + "default", + ); + expect(resolveRecoveryConfigName({ flags: { config: "prod" }, configName: "prod" })).toBe("prod"); + expect(resolveRecoveryConfigName({ flags: {}, configName: "prod" })).toBe("prod"); + expect(resolveRecoveryConfigName({ flags: {}, configName: undefined })).toBeUndefined(); + + const hint = JSON.parse( + render(new BailianError("No key", ExitCode.AUTH), "json", { + commandPrefix: "bl", + configName: "default", + }), + ).error.hint as string; + expect(hint).toContain("bl auth login --api-key --config default"); + expect(hint).toContain("bl auth status --config default"); +}); From a6f9e8efa273d70ddd2745f2da32765197378737 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Thu, 12 Nov 2026 14:34:16 +0800 Subject: [PATCH 5/6] fix(runtime): pin recovery hints after API Key fallback and fail freetier on error --- docs/agents/error-hint-change.md | 7 +- docs/agents/output-contract.md | 2 +- .../src/commands/knowledge/chunk-delete.ts | 2 +- .../commands/src/commands/usage/freetier.ts | 39 ++++++++- .../tests/output/usage-output.test.ts | 82 ++++++++++++++++-- packages/core/src/config/index.ts | 1 + packages/core/src/config/loader.ts | 16 ++++ packages/core/src/config/schema.ts | 5 ++ packages/core/tests/config-priority.test.ts | 7 ++ packages/runtime/src/create-cli.ts | 14 +-- packages/runtime/src/error-handler.ts | 36 +++----- packages/runtime/src/middleware.ts | 10 ++- packages/runtime/src/output/reporter.ts | 16 ++-- .../tests/auth-profile-fallback.test.ts | 6 ++ packages/runtime/tests/error-handler.test.ts | 85 +++++++++++++++---- 15 files changed, 256 insertions(+), 72 deletions(-) diff --git a/docs/agents/error-hint-change.md b/docs/agents/error-hint-change.md index aee90b68d..947b9bbf2 100644 --- a/docs/agents/error-hint-change.md +++ b/docs/agents/error-hint-change.md @@ -106,10 +106,11 @@ process.exit(err.exitCode) - [ ] `packages/runtime/src/error-handler.ts:errorHint`:**主要为 internal AUTH 增强**(resolver / authStage 等内部位置会发 AUTH) - [ ] **允许的有限扩展**:对已知可恢复的服务端错误,**追加**恢复 hint(保留原 message / exitCode)。当前仅 `AllocationQuota.FreeTierOnly`(及同义英文 message 短语)。message / apiCode 匹配**只用于追加**,不参与分类、归并或替换 message(勿与下方反面 case 3 的关键词归并混淆) -- [ ] 追加时若已有 hint(如 video task id),拼接并按标记去重;不要 `hint` 非空就跳过 -- [ ] 渲染产品命令前须经能力门控(命令表是否暴露对应 path),不要对 `kscli` 等缺命令的产品硬拼 `usage freetier` +- [ ] 追加时若已有 hint(如 video task id),拼接并对**完整恢复文本**去重;不要 `hint` 非空就跳过,也不要用 `"usage freetier"` 子串匹配 +- [ ] 命令层不得自行拼 `usage free` / `usage freetier` 恢复指引;该指引只由 runtime `errorHint` 生成 +- [ ] 渲染产品命令前须经能力门控(`hasCommand(path)`),不要对 `kscli` 等缺命令的产品硬拼 `usage freetier` - [ ] 命令名使用 `commandPrefix`,不要硬编码 `bl` -- [ ] 恢复命令经 `recoveryCommand` 渲染;Profile 名用 `resolveRecoveryConfigName`(显式 `--config default` 保留,隐式 default 不加) +- [ ] 恢复命令经 `recoveryCommand` 渲染;Profile 名用 `resolveRecoveryConfigName` / `Settings.recoveryConfigName`(显式 `--config default` 保留,隐式 default 不加)。API Key capability fallback 到 `default` 时,runtime 须把恢复 hint 的 profile 改写为 `default`(当前命令自身的控制台会话 hint `consoleRecoveryHint` 不改)。理由:恢复命令要跟随实际报错的 API Key 所属 Profile——此时报错(如 `AllocationQuota.FreeTierOnly`)来自 `default` 的 API Key 所属账号,免费额度恢复虽是 Console 命令,也须钉 `default`;钉回原命名 Profile 可能用另一账号的 Console token 静默改错账号的计费设置。`default` 缺 Console token 时报 AUTH 并给出 `auth login --console --config default`,可恢复且账号正确。不要拆成 api-key / console 两套 Profile 名 - [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"` ### C. command / runtime 直接抛错 diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index aab563b4a..b00aa2c49 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -82,7 +82,7 @@ OpenAgentPack SDK 没有 logger 注入接口:仅在 SDK 调用上下文内, - [ ] JSON 诊断每条一行;stdout 为单个业务 JSON 文档,stderr 单独解析 - [ ] speech synthesize 的原始音频仅在 text 流式模式下直接写 stdout;JSON 流式需 --out 文件并返回元数据,未指定文件时在请求和文件操作前报用法错误;dry-run 仍允许 JSON 预览。--list-voices 的 JSON 返回音色条目与已有文档链接,内置列表未知时 voices/total 为 null - [ ] speech recognize 的同步 JSON 返回完整服务响应;异步完成后返回原始任务响应并附加 transcriptions 数组,保留失败子任务和 request_id。text 保留转写、说话人和批量分隔;--out 仍保存原有转写文件结构,下载及写盘失败不提前输出最终结果;部分子任务失败不改变原有退出码 -- [ ] usage freetier 的启用 JSON 保留原始响应数组;停用 JSON 返回 `{ processed: [{ model, result }], skipped: [model] }`,跳过项仍不发起停用请求。失败与超时用 warning 提示,保留原有退出策略,不把部分失败丢出结果。 +- [ ] usage freetier 的启用 JSON 保留原始响应数组;停用 JSON 返回 `{ processed: [{ model, result }], skipped: [model] }`,跳过项仍不发起停用请求。失败与超时先用 warning 提示并保留业务结果,再抛摘要:仅轮询超时 → `TIMEOUT(5)`(文案用「超时未确认」);仅服务端拒绝或混合 → `GENERAL(1)`;成功文案(Enabled / Disabled)仅在该模型操作成功时输出 - [ ] Token Plan 创建密钥的完整响应和一次性明文仍属于业务结果,quiet 不裁剪;不得通过 warning/debug 泄露明文。 - [ ] 原始流与原生交互仅限上表;Command Pack API 1 由宿主缓存并提交一次结果,不再直接转发 stdout - [ ] 每个调用使用独立 reporter,不共享结果状态、不替换全局 stdout/stderr;SDK console 桥接只能读取调用上下文,不缓存上一个 host diff --git a/packages/commands/src/commands/knowledge/chunk-delete.ts b/packages/commands/src/commands/knowledge/chunk-delete.ts index 20b5ec2ea..894f6fc7e 100644 --- a/packages/commands/src/commands/knowledge/chunk-delete.ts +++ b/packages/commands/src/commands/knowledge/chunk-delete.ts @@ -107,7 +107,7 @@ export default defineCommand({ error, formatCommand(ctx.identity), ctx.localize, - ctx.settings.configName, + ctx.settings.recoveryConfigName, ); const failedBatch = completedBatches.length + 1; const progress = [ diff --git a/packages/commands/src/commands/usage/freetier.ts b/packages/commands/src/commands/usage/freetier.ts index 459ca8131..3449c89ba 100644 --- a/packages/commands/src/commands/usage/freetier.ts +++ b/packages/commands/src/commands/usage/freetier.ts @@ -1,4 +1,4 @@ -import { defineCommand, unwrapResponse } from "bailian-cli-core"; +import { BailianError, ExitCode, defineCommand, unwrapResponse } from "bailian-cli-core"; import { FREE_TIER_ONLY_STATUS_API, extractFreeTierOnlyStatuses, @@ -113,6 +113,8 @@ export default defineCommand({ const skipped: string[] = []; const processed: { model: string; result: unknown }[] = []; + const failed: string[] = []; + const timedOut: string[] = []; const textLines: string[] = []; let stopMap = new Map(); if (off) { @@ -137,6 +139,7 @@ export default defineCommand({ const resultData = result ? unwrapResponse(result as Record) : undefined; const failureModels = (resultData?.failureModels as BatchResultFailure[] | undefined) ?? []; if (!result) { + timedOut.push(name); ctx.output.warning({ code: "FREETIER_OPERATION_TIMEOUT", message: { @@ -146,6 +149,7 @@ export default defineCommand({ details: { model: name }, }); } else if (failureModels.length > 0) { + failed.push(name); const errorCode = failureModels[0].errorCode; ctx.output.warning({ code: "FREETIER_OPERATION_FAILED", @@ -160,9 +164,7 @@ export default defineCommand({ }, details: { model: name, error_code: errorCode }, }); - } - // Keep the existing disable response policy; the JSON report retains the raw response. - if (off || (result && failureModels.length === 0)) { + } else { textLines.push( ctx.localize( off @@ -184,5 +186,34 @@ export default defineCommand({ format === "text" ? { text: textLines.join("\n") } : undefined, ); } + if (failed.length === 0 && timedOut.length === 0) return; + + // Client poll timeout is internal TIMEOUT; server rejection stays GENERAL. + // Mixed batches use GENERAL and list both outcomes in the summary. + const action = off ? "disable" : "enable"; + const actionZh = off ? "停用" : "启用"; + const summaryPartsEn: string[] = []; + const summaryPartsZh: string[] = []; + if (failed.length > 0) { + summaryPartsEn.push( + `Failed to ${action} auto-stop for ${failed.length} model(s): ${failed.join(", ")}.`, + ); + summaryPartsZh.push(`${failed.length} 个模型${actionZh}自动停止失败:${failed.join("、")}。`); + } + if (timedOut.length > 0) { + summaryPartsEn.push( + `Timed out waiting to confirm ${action} auto-stop for ${timedOut.length} model(s): ${timedOut.join(", ")}.`, + ); + summaryPartsZh.push( + `${timedOut.length} 个模型${actionZh}自动停止超时未确认:${timedOut.join("、")}。`, + ); + } + throw new BailianError( + ctx.localize({ + "en-US": summaryPartsEn.join(" "), + "zh-CN": summaryPartsZh.join(""), + }), + failed.length > 0 ? ExitCode.GENERAL : ExitCode.TIMEOUT, + ); }, }); diff --git a/packages/commands/tests/output/usage-output.test.ts b/packages/commands/tests/output/usage-output.test.ts index 95ddc9412..293cb0d11 100644 --- a/packages/commands/tests/output/usage-output.test.ts +++ b/packages/commands/tests/output/usage-output.test.ts @@ -431,18 +431,48 @@ test("auto-stop already-off skip is retained in the JSON report", async () => { expect(JSON.parse(result.stdout)).toEqual({ processed: [], skipped: ["qwen-test"] }); expect(calls.some((call) => call.api.includes("batchDeactivate"))).toBe(false); }); -test("auto-stop failure retains raw result and a structured warning under quiet", async () => { +test("auto-stop enable failure retains raw result, warning, and non-zero exit", async () => { batchResult = { failureModels: [{ failureModelId: "qwen-test", errorCode: "Rejected" }] }; const result = await invoke(cases[1]!, { format: "json", quiet: true }); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(ExitCode.GENERAL); expect(JSON.parse(result.stdout)).toEqual([batchResult]); expect(result.strayOut).toBe(""); expect(result.strayErr).toBe(""); - expect(JSON.parse(result.stderr).warning).toMatchObject({ + const stderrLines = result.stderr.trim().split("\n"); + expect(JSON.parse(stderrLines[0]!).warning).toMatchObject({ code: "FREETIER_OPERATION_FAILED", details: { model: "qwen-test", error_code: "Rejected" }, }); + expect(JSON.parse(stderrLines[1]!).error.message).toContain( + "Failed to enable auto-stop for 1 model(s): qwen-test", + ); }); + +test.each(["text", "json"] as const)( + "auto-stop --off failure omits Disabled success copy and exits non-zero in %s", + async (format) => { + batchResult = { failureModels: [{ failureModelId: "qwen-test", errorCode: "Rejected" }] }; + const result = await invoke( + { ...cases[1]!, flags: { model: "qwen-test", off: true } }, + { format, quiet: true }, + ); + expect(result.exitCode).toBe(ExitCode.GENERAL); + expect(result.strayOut + result.strayErr).toBe(""); + expect(result.stdout).not.toContain("Disabled auto-stop"); + expect(result.stderr).toContain("Failed to disable auto-stop for"); + expect(result.stderr).toContain("qwen-test"); + expect(result.stderr).toContain("Failed to disable auto-stop for 1 model(s): qwen-test"); + if (format === "json") { + expect(JSON.parse(result.stdout)).toEqual({ + processed: [{ model: "qwen-test", result: batchResult }], + skipped: [], + }); + expect(result.stderr).toContain("FREETIER_OPERATION_FAILED"); + } else { + expect(result.stdout).toBe(""); + } + }, +); test("OpenAPI parameters and one-time key disclosure stay unchanged", async () => { const scenario = cases.find((item) => item.name === "token-plan create-key")!; const result = await invoke(scenario, { format: "json", quiet: true }); @@ -551,7 +581,7 @@ test.each(["text", "json"] as const)( }, ); test.each(["text", "json"] as const)( - "timeout warnings retain the original exit and result policy in %s", + "timeout warnings keep the result then exit TIMEOUT in %s", async (format) => { batchResult = { taskId: "task-test" }; vi.useFakeTimers({ toFake: ["setTimeout"] }); @@ -559,16 +589,16 @@ test.each(["text", "json"] as const)( const pending = invoke(cases[1]!, { format, quiet: true }); await vi.runAllTimersAsync(); const result = await pending; - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(ExitCode.TIMEOUT); expect(result.strayOut + result.strayErr).toBe(""); expect(calls).toHaveLength(20); expect(calls[1]!.data).toEqual({ BatchActivateFreeTierOnlyRequest: { taskId: "task-test" } }); + expect(result.stderr).toContain( + "Timed out waiting to confirm enable auto-stop for 1 model(s): qwen-test", + ); if (format === "json") { expect(JSON.parse(result.stdout)).toEqual([null]); - expect(JSON.parse(result.stderr).warning).toMatchObject({ - code: "FREETIER_OPERATION_TIMEOUT", - details: { model: "qwen-test" }, - }); + expect(result.stderr).toContain("FREETIER_OPERATION_TIMEOUT"); } else { expect(result.stdout).toBe(""); expect(result.stderr).toContain('Operation timed out for "qwen-test".'); @@ -578,3 +608,37 @@ test.each(["text", "json"] as const)( } }, ); + +test.each(["text", "json"] as const)( + "auto-stop --off timeout omits Disabled success copy and exits TIMEOUT in %s", + async (format) => { + batchResult = { taskId: "task-test" }; + vi.useFakeTimers({ toFake: ["setTimeout"] }); + try { + const pending = invoke( + { ...cases[1]!, flags: { model: "qwen-test", off: true } }, + { format, quiet: true }, + ); + await vi.runAllTimersAsync(); + const result = await pending; + expect(result.exitCode).toBe(ExitCode.TIMEOUT); + expect(result.stdout).not.toContain("Disabled auto-stop"); + expect(result.stderr).toContain("Operation timed out for"); + expect(result.stderr).toContain("qwen-test"); + expect(result.stderr).toContain( + "Timed out waiting to confirm disable auto-stop for 1 model(s): qwen-test", + ); + if (format === "json") { + expect(JSON.parse(result.stdout)).toEqual({ + processed: [{ model: "qwen-test", result: null }], + skipped: [], + }); + expect(result.stderr).toContain("FREETIER_OPERATION_TIMEOUT"); + } else { + expect(result.stdout).toBe(""); + } + } finally { + vi.useRealTimers(); + } + }, +); diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 293dc9f6e..3b90bbcda 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -23,6 +23,7 @@ export { buildSources, buildSettings, buildOutputSettings, + resolveRecoveryConfigName, selectApiKeyResolutionSources, type ApiKeyResolutionSourceSelection, type ResolutionSources, diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index c9748caaf..5abbbb073 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -286,6 +286,21 @@ export function buildOutputSettings({ }; } +/** + * Config name for recovery hints (does not change IO-layer `configName` semantics). + * Explicit `--config default` is normalized to undefined in the loader; keep + * `"default"` here so copying the hint does not fall back to an active named profile. + */ +export function resolveRecoveryConfigName(sources: { + flags: { config?: unknown }; + configName?: string; +}): string | undefined { + if (sources.flags.config !== undefined) { + return sources.configName ?? "default"; + } + return sources.configName; +} + /** * 纯解析 sources → Settings(命令唯一会读的配置面)。不含身份、baseUrl、鉴权。 * 各字段链序 flag > env > file > 默认;锁定表 tests/config-priority.test.ts。 @@ -304,6 +319,7 @@ export function buildSettings(s: ResolutionSources): Settings { return { configPath: s.configPath ?? getConfigPath(), configName: s.configName, + recoveryConfigName: resolveRecoveryConfigName(s), ...buildOutputSettings(s), outputDir: file.output_dir || undefined, timeout, diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 1fe8362b6..c6382030d 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -220,6 +220,11 @@ export interface Identity { export interface Settings { configPath?: string; configName?: string; + /** + * Profile name pinned in recovery command hints. + * Explicit `--config default` stays `"default"`; implicit default is undefined. + */ + recoveryConfigName?: string; output: "text" | "json"; /** * Whether `output` came from an explicit source (flag/env/file) rather than diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index 481bbd1ee..a2371855c 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -214,7 +214,14 @@ test("命名 config 仍保持 flag > env > selected file", () => { expect(buildSettings(sources)).toMatchObject({ defaultTextModel: "custom-text", defaultImageModel: "custom-image", + recoveryConfigName: "token-plan", }); + expect( + buildSettings(src({ flags: { config: "default" }, configName: undefined, file: {} })), + ).toMatchObject({ recoveryConfigName: "default" }); + expect( + buildSettings(src({ flags: {}, configName: undefined, file: {} })).recoveryConfigName, + ).toBe(undefined); expect( resolveApiKey( src({ diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 56b7598d6..80b6038f2 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -32,6 +32,7 @@ import { buildSources, buildSettings, buildOutputSettings, + resolveRecoveryConfigName, describeAuthState, resolveModelBaseUrlState, makeConfigStore, @@ -40,7 +41,7 @@ import { Client, } from "bailian-cli-core"; import { setupProxyFromEnv } from "./proxy.ts"; -import { handleError, resolveRecoveryConfigName } from "./error-handler.ts"; +import { handleError } from "./error-handler.ts"; import { type OutputReporter, createOutputReporter } from "./output/reporter.ts"; import { loadCommandPacks } from "./command-packs/load.ts"; import { createCommandPackManager } from "./command-packs/manager.ts"; @@ -174,8 +175,8 @@ export function createCli(commands: Record, opts: CliOptions ...(opts.commandInvocation ? { commandInvocation: opts.commandInvocation } : {}), }; const commandPrefix = formatCommand(identity); - // Product capability gate: only render usage free / freetier recovery when those paths exist. - const supportsFreeTierRecovery = "usage free" in commands && "usage freetier" in commands; + // Static product command map only (Command Packs are not included). + const hasCommand = (path: string) => path in commands; const commandPackPolicy = opts.commandPacks ?? { supported: {} }; let loadedCommandPacksPromise: ReturnType | undefined; @@ -307,6 +308,7 @@ export function createCli(commands: Record, opts: CliOptions configStore: makeConfigStore(sources.configName), authStore: makeAuthStore(sources), commandPacks: createCommandPackManager(identity, commandPackPolicy, reporter.output), + setRecoveryConfigName: (name) => reporter.setRecoveryConfigName(name), client: new Client({ identity, settings, @@ -340,7 +342,7 @@ export function createCli(commands: Record, opts: CliOptions settings: initialSettings, localize, commandPrefix, - supportsFreeTierRecovery, + hasCommand, }); const warnings: OutputDiagnostic[] = []; const cleanup = installProcessHandlers(() => reporter, localize); @@ -352,7 +354,7 @@ export function createCli(commands: Record, opts: CliOptions settings: buildOutputSettings(sources), localize, commandPrefix, - supportsFreeTierRecovery, + hasCommand, configName: recoveryConfigName, }); setupProxyFromEnv(); @@ -362,7 +364,7 @@ export function createCli(commands: Record, opts: CliOptions settings, localize, commandPrefix, - supportsFreeTierRecovery, + hasCommand, configName: recoveryConfigName, }); for (const warning of warnings.splice(0)) reporter.output.warning(warning); diff --git a/packages/runtime/src/error-handler.ts b/packages/runtime/src/error-handler.ts index 7ce179a2b..6b93fe11b 100644 --- a/packages/runtime/src/error-handler.ts +++ b/packages/runtime/src/error-handler.ts @@ -33,21 +33,6 @@ export function normalizeError( type Localize = (text: LocalizedText) => string; const english: Localize = (text) => (typeof text === "string" ? text : text["en-US"]); -/** - * Config name for recovery hints (does not change IO-layer `configName` semantics). - * Explicit `--config default` is normalized to undefined in the loader; keep - * `"default"` here so copying the hint does not fall back to an active named profile. - */ -export function resolveRecoveryConfigName(sources: { - flags: { config?: unknown }; - configName?: string; -}): string | undefined { - if (sources.flags.config !== undefined) { - return sources.configName ?? "default"; - } - return sources.configName; -} - /** * Format a recovery command; append `--config ` when a profile must be pinned * so following the hint does not hit the wrong credentials/billing config. @@ -67,10 +52,10 @@ const FREE_TIER_ONLY_API_CODE = "AllocationQuota.FreeTierOnly"; export interface ErrorHintOptions { /** - * When true, free-tier recovery may render `usage free` / `usage freetier` - * under this product's commandPrefix. Derived from the product command map. + * Product command existence check. Free-tier recovery renders only when both + * `usage free` and `usage freetier` exist in the product command map. */ - supportsFreeTierRecovery?: boolean; + hasCommand?: (path: string) => boolean; /** * Profile to pin in recovery commands (via `resolveRecoveryConfigName`). * Implicit default is undefined; explicit `--config default` is `"default"`. @@ -138,14 +123,15 @@ export function errorHint( } // Append free-tier recovery before the AUTH-only early return so GENERAL // service errors (and async paths that already set a hint) still get guidance. - // Skip when recovery guidance is already present (command-owned or upstream). - if ( - options.supportsFreeTierRecovery && - isFreeTierOnlyExhausted(error) && - !hint?.includes("usage freetier") - ) { + // Dedup against the full recovery text only — commands must not assemble this + // guidance themselves (see docs/agents/error-hint-change.md). + const supportsFreeTier = + options.hasCommand?.("usage free") === true && options.hasCommand?.("usage freetier") === true; + if (supportsFreeTier && isFreeTierOnlyExhausted(error)) { const recovery = freeTierRecoveryHint(commandPrefix, localize, configName); - hint = hint ? `${hint}\n${recovery}` : recovery; + if (!hint?.includes(recovery)) { + hint = hint ? `${hint}\n${recovery}` : recovery; + } } if (error.exitCode !== ExitCode.AUTH) return hint; if ( diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index 484fb58f0..3b3d26f8b 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -1,4 +1,4 @@ -import { consoleRecoveryHint, resolveRecoveryConfigName } from "./error-handler.ts"; +import { consoleRecoveryHint } from "./error-handler.ts"; import type { AnyCommand, CommandOutput, @@ -74,6 +74,8 @@ export interface RunContext { commandPacks: CommandPackManager; /** Network surface with the credential baked in — set by {@link authStage}. */ client: Client; + /** Rewrite the profile pinned in runtime recovery hints (API Key fallback). */ + setRecoveryConfigName?: (name: string | undefined) => void; } /** Koa-style onion middleware: do work, call `next()`, do work after it returns. */ @@ -190,9 +192,10 @@ export const authStage: Middleware = async (ctx, next) => { const base = { identity: ctx.identity, settings, + // Console credentials do not fall back; keep the selected profile pin. consoleRecoveryHint: consoleRecoveryHint( formatCommand(ctx.identity), - resolveRecoveryConfigName(sources), + settings.recoveryConfigName, ), debug: (diagnostic: Parameters[0]) => ctx.output.debug(diagnostic), ...resolveModelBaseUrlState(sources), @@ -207,6 +210,9 @@ export const authStage: Middleware = async (ctx, next) => { const apiSources = selection.sources; if (selection.fallbackFrom) { writeApiKeyFallbackNotice(ctx, selection.fallbackFrom); + // API Key now comes from default; recovery commands must pin default, not + // the profile that was selected and then bypassed. + ctx.setRecoveryConfigName?.("default"); } const provenance = resolveModelConnectionProvenance(apiSources); if ( diff --git a/packages/runtime/src/output/reporter.ts b/packages/runtime/src/output/reporter.ts index 317711552..eef087761 100644 --- a/packages/runtime/src/output/reporter.ts +++ b/packages/runtime/src/output/reporter.ts @@ -23,14 +23,16 @@ export interface OutputReporterOptions { stderr?: OutputWriter; localize?: (text: LocalizedText) => string; commandPrefix?: string; - /** When true, free-tier recovery may render usage free / usage freetier commands. */ - supportsFreeTierRecovery?: boolean; + /** Product command existence check for recovery hint capability gating. */ + hasCommand?: (path: string) => boolean; /** Profile pinned in recovery hints (via resolveRecoveryConfigName). */ configName?: string; } export interface OutputReporter { output: CommandOutput; + /** Override the profile pinned in recovery hints (e.g. after API Key fallback). */ + setRecoveryConfigName(name: string | undefined): void; /** Runtime-only terminal error rendering. The lifecycle owns process exit. */ error(error: unknown): ExitCode; } @@ -44,6 +46,7 @@ export function createOutputReporter(options: OutputReporterOptions): OutputRepo const localize = options.localize ?? ((text: LocalizedText) => translator.localize(text)); let resultWritten = false; let errorCode: ExitCode | undefined; + let recoveryConfigName = options.configName; function diagnostic(level: "warning" | "debug", input: OutputMessage): void { const payload = diagnosticPayload(input, localize); @@ -97,17 +100,20 @@ export function createOutputReporter(options: OutputReporterOptions): OutputRepo if (verbose) diagnostic("debug", details); }, }, + setRecoveryConfigName(name) { + recoveryConfigName = name; + }, error(error) { if (errorCode !== undefined) return errorCode; const failure = normalizeError( error, options.commandPrefix ?? "cli", localize, - options.configName, + recoveryConfigName, ); const hint = errorHint(failure, options.commandPrefix ?? "cli", localize, { - supportsFreeTierRecovery: options.supportsFreeTierRecovery, - configName: options.configName, + hasCommand: options.hasCommand, + configName: recoveryConfigName, }); if (verbose && error instanceof Error && !(error instanceof BailianError)) { diagnostic("debug", { diff --git a/packages/runtime/tests/auth-profile-fallback.test.ts b/packages/runtime/tests/auth-profile-fallback.test.ts index de54b88ac..5b28cd611 100644 --- a/packages/runtime/tests/auth-profile-fallback.test.ts +++ b/packages/runtime/tests/auth-profile-fallback.test.ts @@ -146,9 +146,14 @@ test("authStage 为不支持的 capability 注入 default API Key,同时保留 "company-plan", ); const context = makeContext(["text", "chat"]); + const recoveryPins: Array = []; + context.setRecoveryConfigName = (name) => { + recoveryPins.push(name); + }; await runAuth(context); + expect(recoveryPins).toEqual(["default"]); expect(context.client.exportApiCredential()).toMatchObject({ token: "sk-default", baseUrl: "https://default.example.com", @@ -156,6 +161,7 @@ test("authStage 为不支持的 capability 注入 default API Key,同时保留 }); expect(context.settings).toMatchObject({ configName: "company-plan", + recoveryConfigName: "company-plan", defaultTextModel: "plan-text-model", }); }); diff --git a/packages/runtime/tests/error-handler.test.ts b/packages/runtime/tests/error-handler.test.ts index 8f09175fa..b40a6e59a 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -1,23 +1,27 @@ -import { BailianError, ExitCode, mapApiError } from "bailian-cli-core"; +import { BailianError, ExitCode, mapApiError, resolveRecoveryConfigName } from "bailian-cli-core"; import { expect, test, vi } from "vite-plus/test"; -import { errorHint, handleError, resolveRecoveryConfigName } from "../src/error-handler.ts"; +import { errorHint, handleError } from "../src/error-handler.ts"; import { createOutputReporter } from "../src/output/reporter.ts"; import { ConfirmationRequiredError } from "../src/confirm.ts"; +const blHasCommand = (path: string) => path === "usage free" || path === "usage freetier"; +const kscliHasCommand = () => false; + function render( error: unknown, output: "text" | "json" = "json", options: { commandPrefix?: string; - supportsFreeTierRecovery?: boolean; + hasCommand?: (path: string) => boolean; configName?: string; } = {}, + beforeError?: (reporter: ReturnType) => void, ) { let stderr = ""; const reporter = createOutputReporter({ settings: { output, quiet: true, verbose: false }, commandPrefix: options.commandPrefix ?? "kscli", - supportsFreeTierRecovery: options.supportsFreeTierRecovery, + hasCommand: options.hasCommand, configName: options.configName, stderr: { write: (chunk) => { @@ -25,6 +29,7 @@ function render( }, }, }); + beforeError?.(reporter); const exit = vi.spyOn(process, "exit").mockImplementation((code) => { throw new Error(`process.exit:${code ?? 0}`); }); @@ -88,7 +93,7 @@ test("free-tier hint appends for AllocationQuota.FreeTierOnly when product suppo api: { httpStatus: 403, apiCode: FREE_TIER_ONLY_API_CODE }, }); const payload = JSON.parse( - render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), + render(error, "json", { commandPrefix: "bl", hasCommand: blHasCommand }), ).error; expect(payload.code).toBe(ExitCode.GENERAL); expect(payload.message).toBe(FREE_TIER_MESSAGE); @@ -96,7 +101,7 @@ test("free-tier hint appends for AllocationQuota.FreeTierOnly when product suppo expect(payload.hint).toContain("bl usage freetier --off --model "); expect(payload.hint).not.toContain("--yes"); expect(payload.hint).not.toContain("--all"); - const text = render(error, "text", { commandPrefix: "bl", supportsFreeTierRecovery: true }); + const text = render(error, "text", { commandPrefix: "bl", hasCommand: blHasCommand }); expect(text).toContain("Hint:"); expect(text).toContain("usage freetier --off"); expect(text).not.toContain("--yes"); @@ -112,7 +117,7 @@ test("free-tier hint uses message fallback when type shadows AllocationQuota.Fre }); expect(error.api?.apiCode).toBe("invalid_request_error"); const hint = errorHint(error, "bl", (text) => (typeof text === "string" ? text : text["en-US"]), { - supportsFreeTierRecovery: true, + hasCommand: blHasCommand, }); expect(hint).toContain("bl usage freetier --off"); }); @@ -120,7 +125,7 @@ test("free-tier hint uses message fallback when type shadows AllocationQuota.Fre test("OpenAI-compat Free quota exhausted copy matches via use free tier only phrase", () => { const error = new BailianError(FREE_QUOTA_MESSAGE, ExitCode.GENERAL); const payload = JSON.parse( - render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), + render(error, "json", { commandPrefix: "bl", hasCommand: blHasCommand }), ).error; expect(payload.hint).toContain("bl usage freetier --off --model "); expect(payload.hint).not.toContain("--yes"); @@ -134,7 +139,7 @@ test("insufficient_quota TPS/TPM message does not get free-tier recovery", () => { api: { apiCode: "insufficient_quota" } }, ); const payload = JSON.parse( - render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), + render(error, "json", { commandPrefix: "bl", hasCommand: blHasCommand }), ).error; expect(payload.hint).toBeUndefined(); }); @@ -144,7 +149,7 @@ test("free-tier hint is skipped when the product does not expose usage commands" api: { apiCode: FREE_TIER_ONLY_API_CODE }, }); const payload = JSON.parse( - render(error, "json", { commandPrefix: "kscli", supportsFreeTierRecovery: false }), + render(error, "json", { commandPrefix: "kscli", hasCommand: kscliHasCommand }), ).error; expect(payload.hint).toBeUndefined(); expect(payload.message).toBe(FREE_TIER_MESSAGE); @@ -154,23 +159,43 @@ test("free-tier hint appends after an existing hint without replacing it", () => const existing = "Task id: task-demo"; const error = new BailianError(FREE_QUOTA_MESSAGE, ExitCode.GENERAL, existing); const payload = JSON.parse( - render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), + render(error, "json", { commandPrefix: "bl", hasCommand: blHasCommand }), ).error; expect(payload.hint?.startsWith(existing)).toBe(true); expect(payload.hint).toContain("usage freetier --off"); expect(payload.hint).not.toContain("--yes"); }); -test("free-tier hint does not duplicate when existing hint already mentions usage freetier", () => { +test("free-tier hint does not duplicate when existing hint already contains full recovery text", () => { + const recovery = errorHint( + new BailianError(FREE_TIER_MESSAGE, ExitCode.GENERAL, undefined, { + api: { apiCode: FREE_TIER_ONLY_API_CODE }, + }), + "bl", + (text) => (typeof text === "string" ? text : text["en-US"]), + { hasCommand: blHasCommand }, + ); + expect(recovery).toBeTruthy(); + const error = new BailianError(FREE_TIER_MESSAGE, ExitCode.GENERAL, recovery, { + api: { apiCode: FREE_TIER_ONLY_API_CODE }, + }); + const payload = JSON.parse( + render(error, "json", { commandPrefix: "bl", hasCommand: blHasCommand }), + ).error; + expect(payload.hint).toBe(recovery); + expect(payload.hint?.split("usage freetier").length - 1).toBe(1); +}); + +test("free-tier hint still appends when existing hint only mentions usage freetier loosely", () => { const existing = "Already guided: bl usage freetier --off --model demo"; const error = new BailianError(FREE_TIER_MESSAGE, ExitCode.GENERAL, existing, { api: { apiCode: FREE_TIER_ONLY_API_CODE }, }); const payload = JSON.parse( - render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }), + render(error, "json", { commandPrefix: "bl", hasCommand: blHasCommand }), ).error; - expect(payload.hint).toBe(existing); - expect(payload.hint?.split("usage freetier").length - 1).toBe(1); + expect(payload.hint?.startsWith(existing)).toBe(true); + expect(payload.hint?.split("usage freetier").length - 1).toBe(2); }); test("recovery hints append --config when a named profile is active", () => { @@ -180,7 +205,7 @@ test("recovery hints append --config when a named profile is active", () => { const freeTierHint = JSON.parse( render(freeTier, "json", { commandPrefix: "bl", - supportsFreeTierRecovery: true, + hasCommand: blHasCommand, configName: "prod", }), ).error.hint as string; @@ -232,3 +257,31 @@ test("recovery hints keep explicit --config default when active profile is named expect(hint).toContain("bl auth login --api-key --config default"); expect(hint).toContain("bl auth status --config default"); }); + +test("setRecoveryConfigName retargets free-tier and missing-api-key hints to default", () => { + const freeTier = new BailianError(FREE_TIER_MESSAGE, ExitCode.GENERAL, undefined, { + api: { httpStatus: 403, apiCode: FREE_TIER_ONLY_API_CODE }, + }); + const freeTierHint = JSON.parse( + render( + freeTier, + "json", + { commandPrefix: "bl", hasCommand: blHasCommand, configName: "prod" }, + (reporter) => reporter.setRecoveryConfigName("default"), + ), + ).error.hint as string; + expect(freeTierHint).toContain("bl usage freetier --off --model --config default"); + expect(freeTierHint).not.toContain("--config prod"); + + const authHint = JSON.parse( + render( + new BailianError("No key", ExitCode.AUTH), + "json", + { commandPrefix: "bl", configName: "prod" }, + (reporter) => reporter.setRecoveryConfigName("default"), + ), + ).error.hint as string; + expect(authHint).toContain("bl auth login --api-key --config default"); + expect(authHint).toContain("bl auth status --config default"); + expect(authHint).not.toContain("--config prod"); +}); From 00d9ab24cf37cc963d10a6b83cac8e75863048fd Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Thu, 12 Nov 2026 16:10:54 +0800 Subject: [PATCH 6/6] fix(runtime): improve free-tier model operation confirmation and error handling --- docs/agents/output-contract.md | 2 +- .../commands/src/commands/usage/freetier.ts | 135 ++++++++++++++--- .../tests/output/usage-output.test.ts | 143 +++++++++++++++++- 3 files changed, 253 insertions(+), 27 deletions(-) diff --git a/docs/agents/output-contract.md b/docs/agents/output-contract.md index b00aa2c49..9c6e40dc9 100644 --- a/docs/agents/output-contract.md +++ b/docs/agents/output-contract.md @@ -82,7 +82,7 @@ OpenAgentPack SDK 没有 logger 注入接口:仅在 SDK 调用上下文内, - [ ] JSON 诊断每条一行;stdout 为单个业务 JSON 文档,stderr 单独解析 - [ ] speech synthesize 的原始音频仅在 text 流式模式下直接写 stdout;JSON 流式需 --out 文件并返回元数据,未指定文件时在请求和文件操作前报用法错误;dry-run 仍允许 JSON 预览。--list-voices 的 JSON 返回音色条目与已有文档链接,内置列表未知时 voices/total 为 null - [ ] speech recognize 的同步 JSON 返回完整服务响应;异步完成后返回原始任务响应并附加 transcriptions 数组,保留失败子任务和 request_id。text 保留转写、说话人和批量分隔;--out 仍保存原有转写文件结构,下载及写盘失败不提前输出最终结果;部分子任务失败不改变原有退出码 -- [ ] usage freetier 的启用 JSON 保留原始响应数组;停用 JSON 返回 `{ processed: [{ model, result }], skipped: [model] }`,跳过项仍不发起停用请求。失败与超时先用 warning 提示并保留业务结果,再抛摘要:仅轮询超时 → `TIMEOUT(5)`(文案用「超时未确认」);仅服务端拒绝或混合 → `GENERAL(1)`;成功文案(Enabled / Disabled)仅在该模型操作成功时输出 +- [ ] usage freetier 的启用 JSON 保留原始响应数组(含零模型时的 `[]`);停用 JSON 返回 `{ processed: [{ model, result }], skipped: [model] }`(含空对象),跳过项仍不发起停用请求。按模型串行修改:业务失败 / 超时 / 未在 successModels(或 successCount)确认成功时 fail-fast,不再请求后续模型;HTTP/网络异常先输出已确认进度并用 warning 记录进度,再原样抛出原始错误(不二次包装,保留 cause.code / ERROR_STACK)。失败与超时先用 warning 提示并保留业务结果,再抛摘要:仅轮询超时 → `TIMEOUT(5)`(文案用「超时未确认」);服务端拒绝 / 未确认成功 → `GENERAL(1)`;成功文案(Enabled / Disabled)仅在该模型操作成功时输出 - [ ] Token Plan 创建密钥的完整响应和一次性明文仍属于业务结果,quiet 不裁剪;不得通过 warning/debug 泄露明文。 - [ ] 原始流与原生交互仅限上表;Command Pack API 1 由宿主缓存并提交一次结果,不再直接转发 stdout - [ ] 每个调用使用独立 reporter,不共享结果状态、不替换全局 stdout/stderr;SDK console 桥接只能读取调用上下文,不缓存上一个 host diff --git a/packages/commands/src/commands/usage/freetier.ts b/packages/commands/src/commands/usage/freetier.ts index 3449c89ba..82ff3c130 100644 --- a/packages/commands/src/commands/usage/freetier.ts +++ b/packages/commands/src/commands/usage/freetier.ts @@ -14,6 +14,24 @@ interface BatchResultFailure { errorCode: string; } +/** Require a positive success signal; empty successModels must not count as success. */ +function isModelOperationConfirmed( + resultData: Record, + modelName: string, +): boolean { + const failureModels = (resultData.failureModels as BatchResultFailure[] | undefined) ?? []; + if (failureModels.length > 0) return false; + const successModels = resultData.successModels; + if (Array.isArray(successModels)) { + return successModels.includes(modelName); + } + if (typeof resultData.successCount === "number") { + return resultData.successCount > 0; + } + // Legacy responses without a success list still use the boolean flag. + return resultData.success === true; +} + export default defineCommand({ description: { "en-US": @@ -79,6 +97,8 @@ export default defineCommand({ const modelFlag = flags.model || undefined; const off = Boolean(flags.off); const format = settings.output; + const action = off ? "disable" : "enable"; + const actionZh = off ? "停用" : "启用"; let models: string[]; if (modelFlag) { @@ -113,6 +133,7 @@ export default defineCommand({ const skipped: string[] = []; const processed: { model: string; result: unknown }[] = []; + const confirmed: string[] = []; const failed: string[] = []; const timedOut: string[] = []; const textLines: string[] = []; @@ -125,6 +146,38 @@ export default defineCommand({ stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly])); } + let resultWritten = false; + /** Empty JSON is a valid success; only skip empty output when catching with no progress. */ + const writeBusinessResult = (options?: { requireProgress?: boolean }) => { + if (resultWritten) return; + if (format === "text" && textLines.length === 0) return; + if ( + options?.requireProgress && + processed.length === 0 && + skipped.length === 0 && + textLines.length === 0 + ) { + return; + } + ctx.output.result( + off ? { processed, skipped } : processed.map((item) => item.result), + format === "text" ? { text: textLines.join("\n") } : undefined, + ); + resultWritten = true; + }; + + const progressMessage = (failedModel: string) => + ctx.localize({ + "en-US": [ + `Confirmed ${action} auto-stop for ${confirmed.length} model(s)${confirmed.length > 0 ? `: ${confirmed.join(", ")}` : ""}.`, + `Model "${failedModel}" failed; its outcome is unconfirmed. Later models were not executed.`, + ].join("\n"), + "zh-CN": [ + `已确认${actionZh}自动停止 ${confirmed.length} 个模型${confirmed.length > 0 ? `:${confirmed.join("、")}` : ""}。`, + `模型 "${failedModel}" 失败,结果未确认。后续模型未执行。`, + ].join("\n"), + }); + for (const name of models) { if (off && stopMap.get(name) === false) { skipped.push(name); @@ -134,10 +187,33 @@ export default defineCommand({ }); continue; } - const result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]); + + let result: unknown; + try { + result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]); + } catch (error) { + // Keep stdout progress, but do not wrap the original error (preserves cause.code / + // ERROR_STACK). Surface confirmed work via warning instead. + writeBusinessResult({ requireProgress: true }); + if (confirmed.length > 0 || processed.length > 0 || skipped.length > 0) { + ctx.output.warning({ + code: "FREETIER_PARTIAL_PROGRESS", + message: progressMessage(name), + details: { + confirmed, + failed_model: name, + processed_count: processed.length, + skipped_count: skipped.length, + }, + }); + } + throw error; + } + processed.push({ model: name, result }); const resultData = result ? unwrapResponse(result as Record) : undefined; const failureModels = (resultData?.failureModels as BatchResultFailure[] | undefined) ?? []; + if (!result) { timedOut.push(name); ctx.output.warning({ @@ -148,7 +224,10 @@ export default defineCommand({ }, details: { model: name }, }); - } else if (failureModels.length > 0) { + break; + } + + if (failureModels.length > 0) { failed.push(name); const errorCode = failureModels[0].errorCode; ctx.output.warning({ @@ -164,34 +243,42 @@ export default defineCommand({ }, details: { model: name, error_code: errorCode }, }); - } else { - textLines.push( - ctx.localize( - off - ? { - "en-US": `Disabled auto-stop for "${name}".`, - "zh-CN": `已为模型 "${name}" 停用自动停止。`, - } - : { - "en-US": `Enabled auto-stop for "${name}".`, - "zh-CN": `已为模型 "${name}" 启用自动停止。`, - }, - ), - ); + break; } - } - if (format === "json" || textLines.length > 0) { - ctx.output.result( - off ? { processed, skipped } : processed.map((item) => item.result), - format === "text" ? { text: textLines.join("\n") } : undefined, + + if (!resultData || !isModelOperationConfirmed(resultData, name)) { + failed.push(name); + ctx.output.warning({ + code: "FREETIER_OPERATION_FAILED", + message: { + "en-US": `Auto-stop ${action} for "${name}" was not confirmed by the server.`, + "zh-CN": `服务端未确认模型 "${name}" 的自动停止${actionZh}。`, + }, + details: { model: name }, + }); + break; + } + + confirmed.push(name); + textLines.push( + ctx.localize( + off + ? { + "en-US": `Disabled auto-stop for "${name}".`, + "zh-CN": `已为模型 "${name}" 停用自动停止。`, + } + : { + "en-US": `Enabled auto-stop for "${name}".`, + "zh-CN": `已为模型 "${name}" 启用自动停止。`, + }, + ), ); } + + writeBusinessResult(); if (failed.length === 0 && timedOut.length === 0) return; // Client poll timeout is internal TIMEOUT; server rejection stays GENERAL. - // Mixed batches use GENERAL and list both outcomes in the summary. - const action = off ? "disable" : "enable"; - const actionZh = off ? "停用" : "启用"; const summaryPartsEn: string[] = []; const summaryPartsZh: string[] = []; if (failed.length > 0) { diff --git a/packages/commands/tests/output/usage-output.test.ts b/packages/commands/tests/output/usage-output.test.ts index 293cb0d11..6161dbb68 100644 --- a/packages/commands/tests/output/usage-output.test.ts +++ b/packages/commands/tests/output/usage-output.test.ts @@ -224,7 +224,9 @@ let calls: Array<{ api: string; data: Record }> = []; let openCalls: Record[] = []; let failure = false; let alreadyOff = false; +let emptyModelList = false; let batchResult: unknown = batch; +let batchHandler: ((api: string, data: Record) => unknown) | undefined; let overviewResult: unknown = stat; let modelRows: unknown[] = [ { model: "qwen-test", callSuccessCount: 3, usage: { input_tokens: 2 } }, @@ -239,7 +241,9 @@ afterEach(() => { openCalls = []; failure = false; alreadyOff = false; + emptyModelList = false; batchResult = batch; + batchHandler = undefined; overviewResult = stat; modelRows = [{ model: "qwen-test", callSuccessCount: 3, usage: { input_tokens: 2 } }]; }); @@ -291,7 +295,9 @@ async function invoke( calls.push({ api, data: structuredClone(data) }); if (failure) throw new BailianError("Original console failure", ExitCode.GENERAL); if (api === MODEL_LIST_API) - return { list: [{ model: "qwen-test", capabilities: ["TG"] }], total: 1 }; + return emptyModelList + ? { list: [], total: 0 } + : { list: [{ model: "qwen-test", capabilities: ["TG"] }], total: 1 }; if (api.includes("queryFreeTierQuota")) return { data: { data: { freeTierQuotas: [quota] } } }; if (api.includes("queryFreeTierOnlyStatus")) @@ -300,8 +306,13 @@ async function invoke( data: { freeTierOnlyStatuses: [{ model: "qwen-test", freeTierOnly: !alreadyOff }] }, }, }; - if (api.includes("batchActivateFreeTierOnly") || api.includes("batchDeactivateFreeTierOnly")) + if ( + api.includes("batchActivateFreeTierOnly") || + api.includes("batchDeactivateFreeTierOnly") + ) { + if (batchHandler) return batchHandler(api, data); return structuredClone(batchResult); + } if (api.includes("getModelUsageStatistic")) return structuredClone(overviewResult); if (api.includes("listModelUsageStatisticData")) return { list: structuredClone(modelRows), totalCount: modelRows.length }; @@ -642,3 +653,131 @@ test.each(["text", "json"] as const)( } }, ); + +test("auto-stop fail-fast stops after the first model failure", async () => { + batchResult = { failureModels: [{ failureModelId: "qwen-a", errorCode: "Rejected" }] }; + const result = await invoke( + { ...cases[1]!, flags: { model: "qwen-a,qwen-b,qwen-c", off: true } }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(ExitCode.GENERAL); + expect(calls.filter((call) => call.api.includes("batchDeactivate"))).toHaveLength(1); + expect(calls.find((call) => call.api.includes("batchDeactivate"))!.data).toEqual({ + BatchDeactivateFreeTierOnlyRequest: { models: ["qwen-a"] }, + }); + expect(JSON.parse(result.stdout)).toEqual({ + processed: [{ model: "qwen-a", result: batchResult }], + skipped: [], + }); + expect(result.stderr).toContain("Failed to disable auto-stop for 1 model(s): qwen-a"); +}); + +test("auto-stop empty success list is not treated as success", async () => { + batchResult = { successCount: 0, successModels: [], failureModels: [] }; + const result = await invoke( + { ...cases[1]!, flags: { model: "qwen-test", off: true } }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(ExitCode.GENERAL); + expect(result.stdout).not.toContain("Disabled auto-stop"); + expect(JSON.parse(result.stdout)).toEqual({ + processed: [{ model: "qwen-test", result: batchResult }], + skipped: [], + }); + expect(result.stderr).toContain("FREETIER_OPERATION_FAILED"); + expect(result.stderr).toContain("was not confirmed by the server"); + expect(result.stderr).toContain("Failed to disable auto-stop for 1 model(s): qwen-test"); +}); + +test("auto-stop network error keeps prior confirmed progress", async () => { + let mutateCalls = 0; + batchHandler = () => { + mutateCalls += 1; + if (mutateCalls === 1) { + return { + success: true, + successCount: 1, + successModels: ["qwen-test"], + failureModels: [], + }; + } + throw new BailianError("Upstream network failure", ExitCode.NETWORK); + }; + const result = await invoke( + { ...cases[1]!, flags: { model: "qwen-test,qwen-other" } }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(ExitCode.NETWORK); + expect(mutateCalls).toBe(2); + expect(JSON.parse(result.stdout)).toEqual([ + { + success: true, + successCount: 1, + successModels: ["qwen-test"], + failureModels: [], + }, + ]); + expect(result.stderr).toContain("Upstream network failure"); + expect(result.stderr).toContain("FREETIER_PARTIAL_PROGRESS"); + expect(result.stderr).toContain("Confirmed enable auto-stop for 1 model(s): qwen-test"); + expect(result.stderr).toContain("qwen-other"); + expect(result.stderr).toContain("outcome is unconfirmed"); + expect(result.stderr).toContain("Later models were not executed"); +}); + +test.each([ + { off: false, want: [] }, + { off: true, want: { processed: [], skipped: [] } }, +] as const)( + "auto-stop --all with zero models still emits empty JSON (off=$off)", + async ({ off, want }) => { + emptyModelList = true; + const result = await invoke( + { ...cases[1]!, flags: { all: true, ...(off ? { off: true } : {}) } }, + { format: "json", quiet: true }, + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(want); + expect(calls.some((call) => call.api.includes("batchActivate"))).toBe(false); + expect(calls.some((call) => call.api.includes("batchDeactivate"))).toBe(false); + }, +); + +test("auto-stop transport error preserves cause.code and verbose ERROR_STACK", async () => { + let mutateCalls = 0; + batchHandler = () => { + mutateCalls += 1; + if (mutateCalls === 1) { + return { + success: true, + successCount: 1, + successModels: ["qwen-test"], + failureModels: [], + }; + } + const cause = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), { + code: "ENOTFOUND", + }); + throw new TypeError("fetch failed", { cause }); + }; + const result = await invoke( + { ...cases[1]!, flags: { model: "qwen-test,qwen-other" } }, + { format: "json", quiet: true, verbose: true }, + ); + expect(result.exitCode).toBe(ExitCode.NETWORK); + expect(JSON.parse(result.stdout)).toHaveLength(1); + const stderrLines = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect( + stderrLines.some( + (line) => line.debug && (line.debug as { code?: string }).code === "ERROR_STACK", + ), + ).toBe(true); + const errorLine = stderrLines.find((line) => line.error) as { + error: { cause?: { code?: string; message?: string } }; + }; + expect(errorLine.error.cause?.code).toBe("ENOTFOUND"); + expect(result.stderr).toContain("FREETIER_PARTIAL_PROGRESS"); +});