Skip to content

Commit a6f9e8e

Browse files
committed
fix(runtime): pin recovery hints after API Key fallback and fail freetier on error
1 parent 3b3cb92 commit a6f9e8e

15 files changed

Lines changed: 256 additions & 72 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,11 @@ process.exit(err.exitCode)
106106

107107
- [ ] `packages/runtime/src/error-handler.ts:errorHint`:**主要为 internal AUTH 增强**(resolver / authStage 等内部位置会发 AUTH)
108108
- [ ] **允许的有限扩展**:对已知可恢复的服务端错误,**追加**恢复 hint(保留原 message / exitCode)。当前仅 `AllocationQuota.FreeTierOnly`(及同义英文 message 短语)。message / apiCode 匹配**只用于追加**,不参与分类、归并或替换 message(勿与下方反面 case 3 的关键词归并混淆)
109-
- [ ] 追加时若已有 hint(如 video task id),拼接并按标记去重;不要 `hint` 非空就跳过
110-
- [ ] 渲染产品命令前须经能力门控(命令表是否暴露对应 path),不要对 `kscli` 等缺命令的产品硬拼 `usage freetier`
109+
- [ ] 追加时若已有 hint(如 video task id),拼接并对**完整恢复文本**去重;不要 `hint` 非空就跳过,也不要用 `"usage freetier"` 子串匹配
110+
- [ ] 命令层不得自行拼 `usage free` / `usage freetier` 恢复指引;该指引只由 runtime `errorHint` 生成
111+
- [ ] 渲染产品命令前须经能力门控(`hasCommand(path)`),不要对 `kscli` 等缺命令的产品硬拼 `usage freetier`
111112
- [ ] 命令名使用 `commandPrefix`,不要硬编码 `bl`
112-
- [ ] 恢复命令经 `recoveryCommand` 渲染;Profile 名用 `resolveRecoveryConfigName`(显式 `--config default` 保留,隐式 default 不加)
113+
- [ ] 恢复命令经 `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 名
113114
- [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"`
114115

115116
### C. command / runtime 直接抛错

‎docs/agents/output-contract.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ OpenAgentPack SDK 没有 logger 注入接口:仅在 SDK 调用上下文内,
8282
- [ ] JSON 诊断每条一行;stdout 为单个业务 JSON 文档,stderr 单独解析
8383
- [ ] speech synthesize 的原始音频仅在 text 流式模式下直接写 stdout;JSON 流式需 --out 文件并返回元数据,未指定文件时在请求和文件操作前报用法错误;dry-run 仍允许 JSON 预览。--list-voices 的 JSON 返回音色条目与已有文档链接,内置列表未知时 voices/total 为 null
8484
- [ ] speech recognize 的同步 JSON 返回完整服务响应;异步完成后返回原始任务响应并附加 transcriptions 数组,保留失败子任务和 request_id。text 保留转写、说话人和批量分隔;--out 仍保存原有转写文件结构,下载及写盘失败不提前输出最终结果;部分子任务失败不改变原有退出码
85-
- [ ] usage freetier 的启用 JSON 保留原始响应数组;停用 JSON 返回 `{ processed: [{ model, result }], skipped: [model] }`,跳过项仍不发起停用请求。失败与超时用 warning 提示,保留原有退出策略,不把部分失败丢出结果。
85+
- [ ] usage freetier 的启用 JSON 保留原始响应数组;停用 JSON 返回 `{ processed: [{ model, result }], skipped: [model] }`,跳过项仍不发起停用请求。失败与超时先用 warning 提示并保留业务结果,再抛摘要:仅轮询超时 → `TIMEOUT(5)`(文案用「超时未确认」);仅服务端拒绝或混合 → `GENERAL(1)`;成功文案(Enabled / Disabled)仅在该模型操作成功时输出
8686
- [ ] Token Plan 创建密钥的完整响应和一次性明文仍属于业务结果,quiet 不裁剪;不得通过 warning/debug 泄露明文。
8787
- [ ] 原始流与原生交互仅限上表;Command Pack API 1 由宿主缓存并提交一次结果,不再直接转发 stdout
8888
- [ ] 每个调用使用独立 reporter,不共享结果状态、不替换全局 stdout/stderr;SDK console 桥接只能读取调用上下文,不缓存上一个 host

‎packages/commands/src/commands/knowledge/chunk-delete.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export default defineCommand({
107107
error,
108108
formatCommand(ctx.identity),
109109
ctx.localize,
110-
ctx.settings.configName,
110+
ctx.settings.recoveryConfigName,
111111
);
112112
const failedBatch = completedBatches.length + 1;
113113
const progress = [

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

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineCommand, unwrapResponse } from "bailian-cli-core";
1+
import { BailianError, ExitCode, defineCommand, unwrapResponse } from "bailian-cli-core";
22
import {
33
FREE_TIER_ONLY_STATUS_API,
44
extractFreeTierOnlyStatuses,
@@ -113,6 +113,8 @@ export default defineCommand({
113113

114114
const skipped: string[] = [];
115115
const processed: { model: string; result: unknown }[] = [];
116+
const failed: string[] = [];
117+
const timedOut: string[] = [];
116118
const textLines: string[] = [];
117119
let stopMap = new Map<string, boolean>();
118120
if (off) {
@@ -137,6 +139,7 @@ export default defineCommand({
137139
const resultData = result ? unwrapResponse(result as Record<string, unknown>) : undefined;
138140
const failureModels = (resultData?.failureModels as BatchResultFailure[] | undefined) ?? [];
139141
if (!result) {
142+
timedOut.push(name);
140143
ctx.output.warning({
141144
code: "FREETIER_OPERATION_TIMEOUT",
142145
message: {
@@ -146,6 +149,7 @@ export default defineCommand({
146149
details: { model: name },
147150
});
148151
} else if (failureModels.length > 0) {
152+
failed.push(name);
149153
const errorCode = failureModels[0].errorCode;
150154
ctx.output.warning({
151155
code: "FREETIER_OPERATION_FAILED",
@@ -160,9 +164,7 @@ export default defineCommand({
160164
},
161165
details: { model: name, error_code: errorCode },
162166
});
163-
}
164-
// Keep the existing disable response policy; the JSON report retains the raw response.
165-
if (off || (result && failureModels.length === 0)) {
167+
} else {
166168
textLines.push(
167169
ctx.localize(
168170
off
@@ -184,5 +186,34 @@ export default defineCommand({
184186
format === "text" ? { text: textLines.join("\n") } : undefined,
185187
);
186188
}
189+
if (failed.length === 0 && timedOut.length === 0) return;
190+
191+
// Client poll timeout is internal TIMEOUT; server rejection stays GENERAL.
192+
// Mixed batches use GENERAL and list both outcomes in the summary.
193+
const action = off ? "disable" : "enable";
194+
const actionZh = off ? "停用" : "启用";
195+
const summaryPartsEn: string[] = [];
196+
const summaryPartsZh: string[] = [];
197+
if (failed.length > 0) {
198+
summaryPartsEn.push(
199+
`Failed to ${action} auto-stop for ${failed.length} model(s): ${failed.join(", ")}.`,
200+
);
201+
summaryPartsZh.push(`${failed.length} 个模型${actionZh}自动停止失败:${failed.join("、")}。`);
202+
}
203+
if (timedOut.length > 0) {
204+
summaryPartsEn.push(
205+
`Timed out waiting to confirm ${action} auto-stop for ${timedOut.length} model(s): ${timedOut.join(", ")}.`,
206+
);
207+
summaryPartsZh.push(
208+
`${timedOut.length} 个模型${actionZh}自动停止超时未确认:${timedOut.join("、")}。`,
209+
);
210+
}
211+
throw new BailianError(
212+
ctx.localize({
213+
"en-US": summaryPartsEn.join(" "),
214+
"zh-CN": summaryPartsZh.join(""),
215+
}),
216+
failed.length > 0 ? ExitCode.GENERAL : ExitCode.TIMEOUT,
217+
);
187218
},
188219
});

‎packages/commands/tests/output/usage-output.test.ts‎

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -431,18 +431,48 @@ test("auto-stop already-off skip is retained in the JSON report", async () => {
431431
expect(JSON.parse(result.stdout)).toEqual({ processed: [], skipped: ["qwen-test"] });
432432
expect(calls.some((call) => call.api.includes("batchDeactivate"))).toBe(false);
433433
});
434-
test("auto-stop failure retains raw result and a structured warning under quiet", async () => {
434+
test("auto-stop enable failure retains raw result, warning, and non-zero exit", async () => {
435435
batchResult = { failureModels: [{ failureModelId: "qwen-test", errorCode: "Rejected" }] };
436436
const result = await invoke(cases[1]!, { format: "json", quiet: true });
437-
expect(result.exitCode).toBe(0);
437+
expect(result.exitCode).toBe(ExitCode.GENERAL);
438438
expect(JSON.parse(result.stdout)).toEqual([batchResult]);
439439
expect(result.strayOut).toBe("");
440440
expect(result.strayErr).toBe("");
441-
expect(JSON.parse(result.stderr).warning).toMatchObject({
441+
const stderrLines = result.stderr.trim().split("\n");
442+
expect(JSON.parse(stderrLines[0]!).warning).toMatchObject({
442443
code: "FREETIER_OPERATION_FAILED",
443444
details: { model: "qwen-test", error_code: "Rejected" },
444445
});
446+
expect(JSON.parse(stderrLines[1]!).error.message).toContain(
447+
"Failed to enable auto-stop for 1 model(s): qwen-test",
448+
);
445449
});
450+
451+
test.each(["text", "json"] as const)(
452+
"auto-stop --off failure omits Disabled success copy and exits non-zero in %s",
453+
async (format) => {
454+
batchResult = { failureModels: [{ failureModelId: "qwen-test", errorCode: "Rejected" }] };
455+
const result = await invoke(
456+
{ ...cases[1]!, flags: { model: "qwen-test", off: true } },
457+
{ format, quiet: true },
458+
);
459+
expect(result.exitCode).toBe(ExitCode.GENERAL);
460+
expect(result.strayOut + result.strayErr).toBe("");
461+
expect(result.stdout).not.toContain("Disabled auto-stop");
462+
expect(result.stderr).toContain("Failed to disable auto-stop for");
463+
expect(result.stderr).toContain("qwen-test");
464+
expect(result.stderr).toContain("Failed to disable auto-stop for 1 model(s): qwen-test");
465+
if (format === "json") {
466+
expect(JSON.parse(result.stdout)).toEqual({
467+
processed: [{ model: "qwen-test", result: batchResult }],
468+
skipped: [],
469+
});
470+
expect(result.stderr).toContain("FREETIER_OPERATION_FAILED");
471+
} else {
472+
expect(result.stdout).toBe("");
473+
}
474+
},
475+
);
446476
test("OpenAPI parameters and one-time key disclosure stay unchanged", async () => {
447477
const scenario = cases.find((item) => item.name === "token-plan create-key")!;
448478
const result = await invoke(scenario, { format: "json", quiet: true });
@@ -551,24 +581,24 @@ test.each(["text", "json"] as const)(
551581
},
552582
);
553583
test.each(["text", "json"] as const)(
554-
"timeout warnings retain the original exit and result policy in %s",
584+
"timeout warnings keep the result then exit TIMEOUT in %s",
555585
async (format) => {
556586
batchResult = { taskId: "task-test" };
557587
vi.useFakeTimers({ toFake: ["setTimeout"] });
558588
try {
559589
const pending = invoke(cases[1]!, { format, quiet: true });
560590
await vi.runAllTimersAsync();
561591
const result = await pending;
562-
expect(result.exitCode).toBe(0);
592+
expect(result.exitCode).toBe(ExitCode.TIMEOUT);
563593
expect(result.strayOut + result.strayErr).toBe("");
564594
expect(calls).toHaveLength(20);
565595
expect(calls[1]!.data).toEqual({ BatchActivateFreeTierOnlyRequest: { taskId: "task-test" } });
596+
expect(result.stderr).toContain(
597+
"Timed out waiting to confirm enable auto-stop for 1 model(s): qwen-test",
598+
);
566599
if (format === "json") {
567600
expect(JSON.parse(result.stdout)).toEqual([null]);
568-
expect(JSON.parse(result.stderr).warning).toMatchObject({
569-
code: "FREETIER_OPERATION_TIMEOUT",
570-
details: { model: "qwen-test" },
571-
});
601+
expect(result.stderr).toContain("FREETIER_OPERATION_TIMEOUT");
572602
} else {
573603
expect(result.stdout).toBe("");
574604
expect(result.stderr).toContain('Operation timed out for "qwen-test".');
@@ -578,3 +608,37 @@ test.each(["text", "json"] as const)(
578608
}
579609
},
580610
);
611+
612+
test.each(["text", "json"] as const)(
613+
"auto-stop --off timeout omits Disabled success copy and exits TIMEOUT in %s",
614+
async (format) => {
615+
batchResult = { taskId: "task-test" };
616+
vi.useFakeTimers({ toFake: ["setTimeout"] });
617+
try {
618+
const pending = invoke(
619+
{ ...cases[1]!, flags: { model: "qwen-test", off: true } },
620+
{ format, quiet: true },
621+
);
622+
await vi.runAllTimersAsync();
623+
const result = await pending;
624+
expect(result.exitCode).toBe(ExitCode.TIMEOUT);
625+
expect(result.stdout).not.toContain("Disabled auto-stop");
626+
expect(result.stderr).toContain("Operation timed out for");
627+
expect(result.stderr).toContain("qwen-test");
628+
expect(result.stderr).toContain(
629+
"Timed out waiting to confirm disable auto-stop for 1 model(s): qwen-test",
630+
);
631+
if (format === "json") {
632+
expect(JSON.parse(result.stdout)).toEqual({
633+
processed: [{ model: "qwen-test", result: null }],
634+
skipped: [],
635+
});
636+
expect(result.stderr).toContain("FREETIER_OPERATION_TIMEOUT");
637+
} else {
638+
expect(result.stdout).toBe("");
639+
}
640+
} finally {
641+
vi.useRealTimers();
642+
}
643+
},
644+
);

‎packages/core/src/config/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export {
2323
buildSources,
2424
buildSettings,
2525
buildOutputSettings,
26+
resolveRecoveryConfigName,
2627
selectApiKeyResolutionSources,
2728
type ApiKeyResolutionSourceSelection,
2829
type ResolutionSources,

‎packages/core/src/config/loader.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,21 @@ export function buildOutputSettings({
286286
};
287287
}
288288

289+
/**
290+
* Config name for recovery hints (does not change IO-layer `configName` semantics).
291+
* Explicit `--config default` is normalized to undefined in the loader; keep
292+
* `"default"` here so copying the hint does not fall back to an active named profile.
293+
*/
294+
export function resolveRecoveryConfigName(sources: {
295+
flags: { config?: unknown };
296+
configName?: string;
297+
}): string | undefined {
298+
if (sources.flags.config !== undefined) {
299+
return sources.configName ?? "default";
300+
}
301+
return sources.configName;
302+
}
303+
289304
/**
290305
* 纯解析 sources → Settings(命令唯一会读的配置面)。不含身份、baseUrl、鉴权。
291306
* 各字段链序 flag > env > file > 默认;锁定表 tests/config-priority.test.ts。
@@ -304,6 +319,7 @@ export function buildSettings(s: ResolutionSources): Settings {
304319
return {
305320
configPath: s.configPath ?? getConfigPath(),
306321
configName: s.configName,
322+
recoveryConfigName: resolveRecoveryConfigName(s),
307323
...buildOutputSettings(s),
308324
outputDir: file.output_dir || undefined,
309325
timeout,

‎packages/core/src/config/schema.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,11 @@ export interface Identity {
220220
export interface Settings {
221221
configPath?: string;
222222
configName?: string;
223+
/**
224+
* Profile name pinned in recovery command hints.
225+
* Explicit `--config default` stays `"default"`; implicit default is undefined.
226+
*/
227+
recoveryConfigName?: string;
223228
output: "text" | "json";
224229
/**
225230
* Whether `output` came from an explicit source (flag/env/file) rather than

‎packages/core/tests/config-priority.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,14 @@ test("命名 config 仍保持 flag > env > selected file", () => {
214214
expect(buildSettings(sources)).toMatchObject({
215215
defaultTextModel: "custom-text",
216216
defaultImageModel: "custom-image",
217+
recoveryConfigName: "token-plan",
217218
});
219+
expect(
220+
buildSettings(src({ flags: { config: "default" }, configName: undefined, file: {} })),
221+
).toMatchObject({ recoveryConfigName: "default" });
222+
expect(
223+
buildSettings(src({ flags: {}, configName: undefined, file: {} })).recoveryConfigName,
224+
).toBe(undefined);
218225
expect(
219226
resolveApiKey(
220227
src({

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
buildSources,
3333
buildSettings,
3434
buildOutputSettings,
35+
resolveRecoveryConfigName,
3536
describeAuthState,
3637
resolveModelBaseUrlState,
3738
makeConfigStore,
@@ -40,7 +41,7 @@ import {
4041
Client,
4142
} from "bailian-cli-core";
4243
import { setupProxyFromEnv } from "./proxy.ts";
43-
import { handleError, resolveRecoveryConfigName } from "./error-handler.ts";
44+
import { handleError } from "./error-handler.ts";
4445
import { type OutputReporter, createOutputReporter } from "./output/reporter.ts";
4546
import { loadCommandPacks } from "./command-packs/load.ts";
4647
import { createCommandPackManager } from "./command-packs/manager.ts";
@@ -174,8 +175,8 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
174175
...(opts.commandInvocation ? { commandInvocation: opts.commandInvocation } : {}),
175176
};
176177
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;
178+
// Static product command map only (Command Packs are not included).
179+
const hasCommand = (path: string) => path in commands;
179180
const commandPackPolicy = opts.commandPacks ?? { supported: {} };
180181
let loadedCommandPacksPromise: ReturnType<typeof loadCommandPacks> | undefined;
181182

@@ -307,6 +308,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
307308
configStore: makeConfigStore(sources.configName),
308309
authStore: makeAuthStore(sources),
309310
commandPacks: createCommandPackManager(identity, commandPackPolicy, reporter.output),
311+
setRecoveryConfigName: (name) => reporter.setRecoveryConfigName(name),
310312
client: new Client({
311313
identity,
312314
settings,
@@ -340,7 +342,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
340342
settings: initialSettings,
341343
localize,
342344
commandPrefix,
343-
supportsFreeTierRecovery,
345+
hasCommand,
344346
});
345347
const warnings: OutputDiagnostic[] = [];
346348
const cleanup = installProcessHandlers(() => reporter, localize);
@@ -352,7 +354,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
352354
settings: buildOutputSettings(sources),
353355
localize,
354356
commandPrefix,
355-
supportsFreeTierRecovery,
357+
hasCommand,
356358
configName: recoveryConfigName,
357359
});
358360
setupProxyFromEnv();
@@ -362,7 +364,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
362364
settings,
363365
localize,
364366
commandPrefix,
365-
supportsFreeTierRecovery,
367+
hasCommand,
366368
configName: recoveryConfigName,
367369
});
368370
for (const warning of warnings.splice(0)) reporter.output.warning(warning);

0 commit comments

Comments
 (0)