Skip to content

Commit 30f32bb

Browse files
committed
feat: Enhance error handling and command usage for free-tier models
1 parent fa8330c commit 30f32bb

10 files changed

Lines changed: 274 additions & 32 deletions

File tree

‎docs/agents/error-hint-change.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ runtime/create-cli.ts: 每次调用创建 reporter,统一捕获初始化/解
4343
↓
4444
runtime/error-handler.ts:
4545
- 服务端错误(BailianError(GENERAL)) → text 直接打 message
46-
- 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 errorHint(只 AUTH 还有增强)
46+
- 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 errorHint(AUTH 增强;另可对已知可恢复服务端错误追加 hint)
4747
- TypeError("fetch failed") → 读 err.cause.code 翻成 NETWORK
4848
- Node fs errno → 翻成 GENERAL + errno hint
4949
- reporter:JSON 错误、warning、debug 各占 stderr 一行,无空行;stdout 仅保留最终业务结果
@@ -104,7 +104,10 @@ process.exit(err.exitCode)
104104

105105
### B. runtime 增强(`errorHint`)
106106

107-
- [ ] `packages/runtime/src/error-handler.ts:errorHint`:**当前只为 internal AUTH 增强**(因为 resolver / authStage 等内部位置会发 AUTH)
107+
- [ ] `packages/runtime/src/error-handler.ts:errorHint`:**主要为 internal AUTH 增强**(resolver / authStage 等内部位置会发 AUTH)
108+
- [ ] **允许的有限扩展**:对已知可恢复的服务端错误,**追加**恢复 hint(保留原 message / exitCode)。当前仅 `AllocationQuota.FreeTierOnly`(及同义英文 message 短语)。message / apiCode 匹配**只用于追加**,不参与分类、归并或替换 message(勿与下方反面 case 3 的关键词归并混淆)
109+
- [ ] 追加时若已有 hint(如 video task id),拼接并按标记去重;不要 `hint` 非空就跳过
110+
- [ ] 渲染产品命令前须经能力门控(命令表是否暴露对应 path),不要对 `kscli` 等缺命令的产品硬拼 `usage freetier`
108111
- [ ] 命令名使用 `commandPrefix`,不要硬编码 `bl`
109112
- [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"`
110113

‎packages/commands/src/commands/usage/freetier.ts‎

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ export default defineCommand({
2121
"zh-CN": "启用或停用免费额度模型自动停止。默认启用,使用 --off 停用",
2222
},
2323
auth: "console",
24+
risk: {
25+
level: "high",
26+
reason: "billing",
27+
message: {
28+
"en-US":
29+
"This changes free-tier auto-stop. Disabling it allows pay-as-you-go calls after free quota is exhausted.",
30+
"zh-CN": "此操作会变更免费额度自动停止。停用后,免费额度用尽将继续按量计费调用。",
31+
},
32+
},
2433
usageArgs: "<--model <model>[,model2,...] | --all> [--off] [flags]",
2534
flags: {
2635
model: {
@@ -48,12 +57,37 @@ export default defineCommand({
4857
},
4958
},
5059
exampleArgs: [
51-
"--model qwen3-max",
52-
"--model qwen3-max,qwen-turbo",
53-
"--all",
54-
"--on --model qwen3-max",
55-
"--off --model qwen3-max",
56-
"--off --all",
60+
{
61+
"en-US": "--model qwen3-max --yes",
62+
"zh-CN": "--model qwen3-max --yes",
63+
},
64+
{
65+
"en-US": "--model qwen3-max,qwen-turbo --yes",
66+
"zh-CN": "--model qwen3-max,qwen-turbo --yes",
67+
},
68+
{
69+
"en-US": "--all --yes",
70+
"zh-CN": "--all --yes",
71+
},
72+
{
73+
"en-US": "--on --model qwen3-max --yes",
74+
"zh-CN": "--on --model qwen3-max --yes",
75+
},
76+
{
77+
"en-US": "--off --model qwen3-max --yes",
78+
"zh-CN": "--off --model qwen3-max --yes",
79+
},
80+
{
81+
"en-US": "--off --all --yes",
82+
"zh-CN": "--off --all --yes",
83+
},
84+
],
85+
notes: [
86+
{
87+
"en-US":
88+
"Requires `--yes` (exit code 7 / requires_confirmation without it). Applies to both enabling and disabling auto-stop.",
89+
"zh-CN": "需要 `--yes`(缺少时退出码 7 / requires_confirmation)。启用与停用自动停止均适用。",
90+
},
5791
],
5892
validate: (flags) =>
5993
!flags.model && !flags.all ? "Provide --model <model>[,model2,...] or --all." : undefined,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, test } from "vite-plus/test";
2+
import { runCommandE2e, runCommandHelp } from "./helpers.ts";
3+
import { USAGE_ROUTES } from "./topic-routes.ts";
4+
5+
describe("e2e: usage freetier", () => {
6+
test("usage freetier --help 展示 risk、--yes 与确认示例", async () => {
7+
const { stderr, exitCode } = await runCommandHelp(USAGE_ROUTES, [
8+
"usage",
9+
"freetier",
10+
"--help",
11+
]);
12+
expect(exitCode, stderr).toBe(0);
13+
expect(stderr).toContain("--yes");
14+
expect(stderr).toMatch(/Risk:\s*high/i);
15+
expect(stderr).toContain("This changes free-tier auto-stop");
16+
expect(stderr).toContain("bl usage freetier --model qwen3-max --yes");
17+
expect(stderr).toContain("bl usage freetier --off --model qwen3-max --yes");
18+
});
19+
20+
test("usage freetier 无 --yes 返回确认请求 (7)", async () => {
21+
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
22+
"usage",
23+
"freetier",
24+
"--model",
25+
"qwen-plus",
26+
"--output",
27+
"json",
28+
]);
29+
expect(exitCode).toBe(7);
30+
expect(JSON.parse(stderr)).toMatchObject({
31+
error: { code: 7, type: "requires_confirmation" },
32+
});
33+
});
34+
35+
test("usage freetier --yes 通过确认门槛后落到 AUTH (3)", async () => {
36+
// Clear OpenAPI env so console auth cannot exchange a host AK/SK into a session.
37+
const { stderr, exitCode } = await runCommandE2e(
38+
USAGE_ROUTES,
39+
["usage", "freetier", "--model", "qwen-plus", "--yes", "--output", "json"],
40+
{
41+
BAILIAN_ACCESS_KEY_ID: undefined,
42+
BAILIAN_ACCESS_KEY_SECRET: undefined,
43+
ALIBABA_CLOUD_ACCESS_KEY_ID: undefined,
44+
ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined,
45+
},
46+
);
47+
expect(exitCode).toBe(3);
48+
expect(JSON.parse(stderr)).toMatchObject({
49+
error: { code: 3 },
50+
});
51+
});
52+
});

‎packages/runtime/src/create-cli.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
174174
...(opts.commandInvocation ? { commandInvocation: opts.commandInvocation } : {}),
175175
};
176176
const commandPrefix = formatCommand(identity);
177+
// Product capability gate: only render usage free / freetier recovery when those paths exist.
178+
const supportsFreeTierRecovery = "usage free" in commands && "usage freetier" in commands;
177179
const commandPackPolicy = opts.commandPacks ?? { supported: {} };
178180
let loadedCommandPacksPromise: ReturnType<typeof loadCommandPacks> | undefined;
179181

@@ -338,6 +340,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
338340
settings: initialSettings,
339341
localize,
340342
commandPrefix,
343+
supportsFreeTierRecovery,
341344
});
342345
const warnings: OutputDiagnostic[] = [];
343346
const cleanup = installProcessHandlers(() => reporter, localize);
@@ -348,11 +351,17 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
348351
settings: buildOutputSettings(sources),
349352
localize,
350353
commandPrefix,
354+
supportsFreeTierRecovery,
351355
});
352356
setupProxyFromEnv();
353357
const registry = await getRegistry(translator);
354358
await dispatch(registry, argv, localize, sources, (settings) => {
355-
reporter = createOutputReporter({ settings, localize, commandPrefix });
359+
reporter = createOutputReporter({
360+
settings,
361+
localize,
362+
commandPrefix,
363+
supportsFreeTierRecovery,
364+
});
356365
for (const warning of warnings.splice(0)) reporter.output.warning(warning);
357366
return reporter;
358367
});

‎packages/runtime/src/error-handler.ts‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,53 @@ export function normalizeError(
2929
return new BailianError(message, ExitCode.GENERAL);
3030
}
3131

32+
type Localize = (text: LocalizedText) => string;
33+
const english: Localize = (text) => (typeof text === "string" ? text : text["en-US"]);
34+
3235
/** Complete recovery text supplied before Console errors can be wrapped by commands. */
3336
export function consoleRecoveryHint(commandPrefix: string): string {
3437
return `Run \`${formatCommand(commandPrefix, "auth login --console")}\` to sign in or refresh your console session.`;
3538
}
3639

37-
/** Add runtime-owned model-auth guidance without modifying service errors. */
40+
/** Native DashScope code for free-tier-only exhaustion (not TPS/TPM insufficient_quota). */
41+
const FREE_TIER_ONLY_API_CODE = "AllocationQuota.FreeTierOnly";
42+
43+
export interface ErrorHintOptions {
44+
/**
45+
* When true, free-tier recovery may render `usage free` / `usage freetier`
46+
* under this product's commandPrefix. Derived from the product command map.
47+
*/
48+
supportsFreeTierRecovery?: boolean;
49+
}
50+
51+
/** Detect AllocationQuota.FreeTierOnly without matching TPS/TPM insufficient_quota. */
52+
function isFreeTierOnlyExhausted(error: BailianError): boolean {
53+
if (error.api?.apiCode === FREE_TIER_ONLY_API_CODE) return true;
54+
const message = error.message;
55+
// Documented FreeTierOnly copy, plus OpenAI-compat wrappers that keep the
56+
// distinctive "use free tier only" phrase when type/code are remapped.
57+
return (
58+
/use free tier only/i.test(message) ||
59+
/The free tier of the model has been exhausted/i.test(message)
60+
);
61+
}
62+
63+
/** Localized free-tier recovery steps; only call when the product exposes usage commands. */
64+
function freeTierRecoveryHint(commandPrefix: string, localize: Localize): string {
65+
const statusCommand = formatCommand(commandPrefix, "usage free --model <model>");
66+
const disableCommand = formatCommand(commandPrefix, "usage freetier --off --model <model> --yes");
67+
return localize({
68+
"en-US": `Check status: ${statusCommand}\nIf Auto-Stop is ON and you accept pay-as-you-go: ${disableCommand}`,
69+
"zh-CN": `查看状态:${statusCommand}\n若 Auto-Stop 为 ON 且接受按量计费:${disableCommand}`,
70+
});
71+
}
72+
73+
/** Add runtime-owned recovery guidance without modifying service error messages. */
3874
export function errorHint(
3975
error: BailianError,
4076
commandPrefix: string,
4177
localize: Localize,
78+
options: ErrorHintOptions = {},
4279
): string | undefined {
4380
let hint = error.hint;
4481
if (error instanceof CredentialError) {
@@ -59,6 +96,12 @@ export function errorHint(
5996
return hint;
6097
}
6198
}
99+
// Append free-tier recovery before the AUTH-only early return so GENERAL
100+
// service errors (and async paths that already set a hint) still get guidance.
101+
if (options.supportsFreeTierRecovery && isFreeTierOnlyExhausted(error)) {
102+
const recovery = freeTierRecoveryHint(commandPrefix, localize);
103+
hint = hint ? `${hint}\n${recovery}` : recovery;
104+
}
62105
if (error.exitCode !== ExitCode.AUTH) return hint;
63106
if (
64107
hint &&
@@ -80,9 +123,6 @@ export function errorHint(
80123
.join("\n");
81124
}
82125

83-
type Localize = (text: LocalizedText) => string;
84-
const english: Localize = (text) => (typeof text === "string" ? text : text["en-US"]);
85-
86126
/** Shared local-error classification; service errors are never reclassified. */
87127
export function classifyRuntimeError(
88128
error: Error,

‎packages/runtime/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export type { ParsePathResult } from "./args.ts";
2727

2828
// Process-level setup / error handling
2929
export { setupProxyFromEnv } from "./proxy.ts";
30-
export { handleError, normalizeError } from "./error-handler.ts";
30+
export { handleError, normalizeError, errorHint, type ErrorHintOptions } from "./error-handler.ts";
3131
export { CLI_VERSION } from "./version.ts";
3232

3333
// Console URLs referenced by commands (e.g. auth/status, banner)

‎packages/runtime/src/output/reporter.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface OutputReporterOptions {
2323
stderr?: OutputWriter;
2424
localize?: (text: LocalizedText) => string;
2525
commandPrefix?: string;
26+
/** When true, free-tier recovery may render usage free / usage freetier commands. */
27+
supportsFreeTierRecovery?: boolean;
2628
}
2729

2830
export interface OutputReporter {
@@ -96,7 +98,9 @@ export function createOutputReporter(options: OutputReporterOptions): OutputRepo
9698
error(error) {
9799
if (errorCode !== undefined) return errorCode;
98100
const failure = normalizeError(error, options.commandPrefix ?? "cli", localize);
99-
const hint = errorHint(failure, options.commandPrefix ?? "cli", localize);
101+
const hint = errorHint(failure, options.commandPrefix ?? "cli", localize, {
102+
supportsFreeTierRecovery: options.supportsFreeTierRecovery,
103+
});
100104
if (verbose && error instanceof Error && !(error instanceof BailianError)) {
101105
diagnostic("debug", {
102106
code: "ERROR_STACK",

‎packages/runtime/tests/error-handler.test.ts‎

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
import { BailianError, ExitCode } from "bailian-cli-core";
1+
import { BailianError, ExitCode, mapApiError } from "bailian-cli-core";
22
import { expect, test, vi } from "vite-plus/test";
3-
import { handleError } from "../src/error-handler.ts";
3+
import { errorHint, handleError } from "../src/error-handler.ts";
44
import { createOutputReporter } from "../src/output/reporter.ts";
55
import { ConfirmationRequiredError } from "../src/confirm.ts";
66

7-
function render(error: unknown, output: "text" | "json" = "json") {
7+
function render(
8+
error: unknown,
9+
output: "text" | "json" = "json",
10+
options: { commandPrefix?: string; supportsFreeTierRecovery?: boolean } = {},
11+
) {
812
let stderr = "";
913
const reporter = createOutputReporter({
1014
settings: { output, quiet: true, verbose: false },
11-
commandPrefix: "kscli",
15+
commandPrefix: options.commandPrefix ?? "kscli",
16+
supportsFreeTierRecovery: options.supportsFreeTierRecovery,
1217
stderr: {
1318
write: (chunk) => {
1419
stderr += chunk;
@@ -26,6 +31,12 @@ function render(error: unknown, output: "text" | "json" = "json") {
2631
return stderr;
2732
}
2833

34+
const FREE_TIER_ONLY_API_CODE = "AllocationQuota.FreeTierOnly";
35+
const FREE_TIER_MESSAGE =
36+
'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.';
37+
const FREE_QUOTA_MESSAGE =
38+
'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.';
39+
2940
test("handleError: fetch failed JSON includes cause.code from errno", () => {
3041
const cause = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), {
3142
code: "ENOTFOUND",
@@ -66,3 +77,77 @@ test("model-auth guidance uses the invocation binary and does not leak into cons
6677
);
6778
expect(JSON.parse(render(consoleError))).toEqual(consoleError.toJSON());
6879
});
80+
81+
test("free-tier hint appends for AllocationQuota.FreeTierOnly when product supports usage", () => {
82+
const error = new BailianError(FREE_TIER_MESSAGE, ExitCode.GENERAL, undefined, {
83+
api: { httpStatus: 403, apiCode: FREE_TIER_ONLY_API_CODE },
84+
});
85+
const payload = JSON.parse(
86+
render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }),
87+
).error;
88+
expect(payload.code).toBe(ExitCode.GENERAL);
89+
expect(payload.message).toBe(FREE_TIER_MESSAGE);
90+
expect(payload.hint).toContain("bl usage free --model <model>");
91+
expect(payload.hint).toContain("bl usage freetier --off --model <model> --yes");
92+
expect(payload.hint).not.toContain("--all");
93+
const text = render(error, "text", { commandPrefix: "bl", supportsFreeTierRecovery: true });
94+
expect(text).toContain("Hint:");
95+
expect(text).toContain("usage freetier --off");
96+
});
97+
98+
test("free-tier hint uses message fallback when type shadows AllocationQuota.FreeTierOnly code", () => {
99+
const error = mapApiError(400, {
100+
error: {
101+
message: FREE_TIER_MESSAGE,
102+
type: "invalid_request_error",
103+
code: FREE_TIER_ONLY_API_CODE,
104+
},
105+
});
106+
expect(error.api?.apiCode).toBe("invalid_request_error");
107+
const hint = errorHint(error, "bl", (text) => (typeof text === "string" ? text : text["en-US"]), {
108+
supportsFreeTierRecovery: true,
109+
});
110+
expect(hint).toContain("bl usage freetier --off");
111+
});
112+
113+
test("OpenAI-compat Free quota exhausted copy matches via use free tier only phrase", () => {
114+
const error = new BailianError(FREE_QUOTA_MESSAGE, ExitCode.GENERAL);
115+
const payload = JSON.parse(
116+
render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }),
117+
).error;
118+
expect(payload.hint).toContain("bl usage freetier --off --model <model> --yes");
119+
});
120+
121+
test("insufficient_quota TPS/TPM message does not get free-tier recovery", () => {
122+
const error = new BailianError(
123+
"You exceeded your current quota, please check your plan and billing details.",
124+
ExitCode.GENERAL,
125+
undefined,
126+
{ api: { apiCode: "insufficient_quota" } },
127+
);
128+
const payload = JSON.parse(
129+
render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }),
130+
).error;
131+
expect(payload.hint).toBeUndefined();
132+
});
133+
134+
test("free-tier hint is skipped when the product does not expose usage commands", () => {
135+
const error = new BailianError(FREE_TIER_MESSAGE, ExitCode.GENERAL, undefined, {
136+
api: { apiCode: FREE_TIER_ONLY_API_CODE },
137+
});
138+
const payload = JSON.parse(
139+
render(error, "json", { commandPrefix: "kscli", supportsFreeTierRecovery: false }),
140+
).error;
141+
expect(payload.hint).toBeUndefined();
142+
expect(payload.message).toBe(FREE_TIER_MESSAGE);
143+
});
144+
145+
test("free-tier hint appends after an existing hint without replacing it", () => {
146+
const existing = "Task id: task-demo";
147+
const error = new BailianError(FREE_QUOTA_MESSAGE, ExitCode.GENERAL, existing);
148+
const payload = JSON.parse(
149+
render(error, "json", { commandPrefix: "bl", supportsFreeTierRecovery: true }),
150+
).error;
151+
expect(payload.hint?.startsWith(existing)).toBe(true);
152+
expect(payload.hint).toContain("usage freetier --off");
153+
});

0 commit comments

Comments
 (0)