diff --git a/docs/agents/error-hint-change.md b/docs/agents/error-hint-change.md index 56352b18..947b9bbf 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,8 +104,13 @@ 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` 非空就跳过,也不要用 `"usage freetier"` 子串匹配 +- [ ] 命令层不得自行拼 `usage free` / `usage freetier` 恢复指引;该指引只由 runtime `errorHint` 生成 +- [ ] 渲染产品命令前须经能力门控(`hasCommand(path)`),不要对 `kscli` 等缺命令的产品硬拼 `usage freetier` - [ ] 命令名使用 `commandPrefix`,不要硬编码 `bl` +- [ ] 恢复命令经 `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 aab563b4..9c6e40dc 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] }`(含空对象),跳过项仍不发起停用请求。按模型串行修改:业务失败 / 超时 / 未在 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/knowledge/chunk-delete.ts b/packages/commands/src/commands/knowledge/chunk-delete.ts index 358975fd..894f6fc7 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.recoveryConfigName, + ); 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 42a8203d..82ff3c13 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, @@ -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": @@ -21,6 +39,16 @@ export default defineCommand({ "zh-CN": "启用或停用免费额度模型自动停止。默认启用,使用 --off 停用", }, auth: "console", + risk: { + level: "high", + reason: "billing", + message: { + "en-US": + "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]", flags: { model: { @@ -48,12 +76,19 @@ export default defineCommand({ }, }, exampleArgs: [ - "--model qwen3-max", - "--model qwen3-max,qwen-turbo", - "--all", - "--on --model qwen3-max", - "--off --model qwen3-max", - "--off --all", + "--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: [ + { + "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, @@ -62,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) { @@ -96,6 +133,9 @@ export default defineCommand({ const skipped: string[] = []; const processed: { model: string; result: unknown }[] = []; + const confirmed: string[] = []; + const failed: string[] = []; + const timedOut: string[] = []; const textLines: string[] = []; let stopMap = new Map(); if (off) { @@ -106,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); @@ -115,11 +187,35 @@ 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({ code: "FREETIER_OPERATION_TIMEOUT", message: { @@ -128,7 +224,11 @@ 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({ code: "FREETIER_OPERATION_FAILED", @@ -143,29 +243,64 @@ export default defineCommand({ }, details: { model: name, error_code: errorCode }, }); + break; } - // Keep the existing disable response policy; the JSON report retains the raw response. - if (off || (result && failureModels.length === 0)) { - 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}" 启用自动停止。`, - }, - ), - ); + + 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}" 启用自动停止。`, + }, + ), + ); } - if (format === "json" || textLines.length > 0) { - ctx.output.result( - off ? { processed, skipped } : processed.map((item) => item.result), - format === "text" ? { text: textLines.join("\n") } : undefined, + + writeBusinessResult(); + if (failed.length === 0 && timedOut.length === 0) return; + + // Client poll timeout is internal TIMEOUT; server rejection stays GENERAL. + 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/e2e/usage-freetier.e2e.test.ts b/packages/commands/tests/e2e/usage-freetier.e2e.test.ts new file mode 100644 index 00000000..e88916b2 --- /dev/null +++ b/packages/commands/tests/e2e/usage-freetier.e2e.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "vite-plus/test"; +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, [ + "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("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"); + }); + + test("usage freetier 无 --yes 返回确认请求 (7)", async () => { + const { stderr, exitCode } = await runCommandInProcess(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 () => { + const { stderr, exitCode } = await runCommandInProcess( + USAGE_ROUTES, + ["usage", "freetier", "--model", "qwen-plus", "--yes", "--output", "json"], + 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/commands/tests/output/usage-output.test.ts b/packages/commands/tests/output/usage-output.test.ts index 95ddc941..6161dbb6 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 }; @@ -431,18 +442,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 +592,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 +600,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 +619,165 @@ 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(); + } + }, +); + +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"); +}); diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 293dc9f6..3b90bbcd 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 c9748caa..5abbbb07 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 1fe8362b..c6382030 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 481bbd1e..a2371855 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 cbaa26a6..80b6038f 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, @@ -174,6 +175,8 @@ export function createCli(commands: Record, opts: CliOptions ...(opts.commandInvocation ? { commandInvocation: opts.commandInvocation } : {}), }; const commandPrefix = formatCommand(identity); + // 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; @@ -305,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, @@ -338,21 +342,31 @@ export function createCli(commands: Record, opts: CliOptions settings: initialSettings, localize, commandPrefix, + hasCommand, }); const warnings: OutputDiagnostic[] = []; 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, + hasCommand, + configName: recoveryConfigName, }); setupProxyFromEnv(); const registry = await getRegistry(translator); await dispatch(registry, argv, localize, sources, (settings) => { - reporter = createOutputReporter({ settings, localize, commandPrefix }); + reporter = createOutputReporter({ + settings, + localize, + commandPrefix, + hasCommand, + 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 275ef34b..6b93fe11 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 }) ); } @@ -29,36 +30,109 @@ 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"]); + +/** + * 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; + 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.`; } -/** 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 { + /** + * Product command existence check. Free-tier recovery renders only when both + * `usage free` and `usage freetier` exist in the product command map. + */ + hasCommand?: (path: string) => boolean; + /** + * Profile to pin in recovery commands (via `resolveRecoveryConfigName`). + * Implicit default is undefined; explicit `--config default` is `"default"`. + */ + configName?: string; +} + +/** 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, + configName?: string, +): string { + const statusCommand = recoveryCommand(commandPrefix, "usage free --model ", configName); + // Omit --yes so the caller hits the confirmation gate (exit 7) first. + 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}`, + }); +} + +/** Add runtime-owned recovery guidance without modifying service error messages. */ export function errorHint( error: BailianError, commandPrefix: string, 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; } } + // 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. + // 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); + if (!hint?.includes(recovery)) { + hint = hint ? `${hint}\n${recovery}` : recovery; + } + } if (error.exitCode !== ExitCode.AUTH) return hint; if ( hint && @@ -72,27 +146,25 @@ 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) .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, 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" || @@ -103,8 +175,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 22b8f3ae..3b3d26f8 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -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,7 +192,11 @@ export const authStage: Middleware = async (ctx, next) => { const base = { identity: ctx.identity, settings, - consoleRecoveryHint: consoleRecoveryHint(formatCommand(ctx.identity)), + // Console credentials do not fall back; keep the selected profile pin. + consoleRecoveryHint: consoleRecoveryHint( + formatCommand(ctx.identity), + settings.recoveryConfigName, + ), debug: (diagnostic: Parameters[0]) => ctx.output.debug(diagnostic), ...resolveModelBaseUrlState(sources), }; @@ -204,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 262b5b39..eef08776 100644 --- a/packages/runtime/src/output/reporter.ts +++ b/packages/runtime/src/output/reporter.ts @@ -23,10 +23,16 @@ export interface OutputReporterOptions { stderr?: OutputWriter; localize?: (text: LocalizedText) => string; commandPrefix?: string; + /** 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; } @@ -40,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); @@ -93,10 +100,21 @@ 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); - const hint = errorHint(failure, options.commandPrefix ?? "cli", localize); + const failure = normalizeError( + error, + options.commandPrefix ?? "cli", + localize, + recoveryConfigName, + ); + const hint = errorHint(failure, options.commandPrefix ?? "cli", localize, { + hasCommand: options.hasCommand, + configName: recoveryConfigName, + }); if (verbose && error instanceof Error && !(error instanceof BailianError)) { diagnostic("debug", { code: "ERROR_STACK", diff --git a/packages/runtime/tests/auth-profile-fallback.test.ts b/packages/runtime/tests/auth-profile-fallback.test.ts index de54b88a..5b28cd61 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 4aa75ba5..b40a6e59 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -1,20 +1,35 @@ -import { BailianError, ExitCode } from "bailian-cli-core"; +import { BailianError, ExitCode, mapApiError, resolveRecoveryConfigName } 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") { +const blHasCommand = (path: string) => path === "usage free" || path === "usage freetier"; +const kscliHasCommand = () => false; + +function render( + error: unknown, + output: "text" | "json" = "json", + options: { + commandPrefix?: string; + hasCommand?: (path: string) => boolean; + configName?: string; + } = {}, + beforeError?: (reporter: ReturnType) => void, +) { let stderr = ""; const reporter = createOutputReporter({ settings: { output, quiet: true, verbose: false }, - commandPrefix: "kscli", + commandPrefix: options.commandPrefix ?? "kscli", + hasCommand: options.hasCommand, + configName: options.configName, stderr: { write: (chunk) => { stderr += chunk; }, }, }); + beforeError?.(reporter); const exit = vi.spyOn(process, "exit").mockImplementation((code) => { throw new Error(`process.exit:${code ?? 0}`); }); @@ -26,6 +41,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 +87,201 @@ 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", hasCommand: blHasCommand }), + ).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 "); + expect(payload.hint).not.toContain("--yes"); + expect(payload.hint).not.toContain("--all"); + const text = render(error, "text", { commandPrefix: "bl", hasCommand: blHasCommand }); + 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", () => { + 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"]), { + hasCommand: blHasCommand, + }); + 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", hasCommand: blHasCommand }), + ).error; + 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", () => { + 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", hasCommand: blHasCommand }), + ).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", hasCommand: kscliHasCommand }), + ).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", 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 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", hasCommand: blHasCommand }), + ).error; + 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", () => { + 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", + }), + ).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 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"); +}); + +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"); +}); diff --git a/skills/bailian-cli/reference/usage.md b/skills/bailian-cli/reference/usage.md index 0554c818..07948e8c 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. 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. #### 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 04350f0b..435ea7fe 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` (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.