Skip to content

Commit 17033c1

Browse files
authored
Merge pull request #313 from modelstudioai/feat/freetier-autostop-guard
Guide FreeTierOnly recovery and gate usage freetier with --yes
2 parents b06c4c0 + 00d9ab2 commit 17033c1

18 files changed

Lines changed: 907 additions & 82 deletions

File tree

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

Lines changed: 7 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,8 +104,13 @@ 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` 非空就跳过,也不要用 `"usage freetier"` 子串匹配
110+
- [ ] 命令层不得自行拼 `usage free` / `usage freetier` 恢复指引;该指引只由 runtime `errorHint` 生成
111+
- [ ] 渲染产品命令前须经能力门控(`hasCommand(path)`),不要对 `kscli` 等缺命令的产品硬拼 `usage freetier`
108112
- [ ] 命令名使用 `commandPrefix`,不要硬编码 `bl`
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 名
109114
- [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"`
110115

111116
### 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] }`(含空对象),跳过项仍不发起停用请求。按模型串行修改:业务失败 / 超时 / 未在 successModels(或 successCount)确认成功时 fail-fast,不再请求后续模型;HTTP/网络异常先输出已确认进度并用 warning 记录进度,再原样抛出原始错误(不二次包装,保留 cause.code / ERROR_STACK)。失败与超时先用 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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,12 @@ export default defineCommand({
103103
deletedCount += batchIds.length;
104104
completedBatches.push(batchIds);
105105
} catch (error) {
106-
const original = normalizeError(error, formatCommand(ctx.identity), ctx.localize);
106+
const original = normalizeError(
107+
error,
108+
formatCommand(ctx.identity),
109+
ctx.localize,
110+
ctx.settings.recoveryConfigName,
111+
);
107112
const failedBatch = completedBatches.length + 1;
108113
const progress = [
109114
ctx.localize({

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

Lines changed: 163 additions & 28 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,
@@ -14,13 +14,41 @@ interface BatchResultFailure {
1414
errorCode: string;
1515
}
1616

17+
/** Require a positive success signal; empty successModels must not count as success. */
18+
function isModelOperationConfirmed(
19+
resultData: Record<string, unknown>,
20+
modelName: string,
21+
): boolean {
22+
const failureModels = (resultData.failureModels as BatchResultFailure[] | undefined) ?? [];
23+
if (failureModels.length > 0) return false;
24+
const successModels = resultData.successModels;
25+
if (Array.isArray(successModels)) {
26+
return successModels.includes(modelName);
27+
}
28+
if (typeof resultData.successCount === "number") {
29+
return resultData.successCount > 0;
30+
}
31+
// Legacy responses without a success list still use the boolean flag.
32+
return resultData.success === true;
33+
}
34+
1735
export default defineCommand({
1836
description: {
1937
"en-US":
2038
"Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable",
2139
"zh-CN": "启用或停用免费额度模型自动停止。默认启用,使用 --off 停用",
2240
},
2341
auth: "console",
42+
risk: {
43+
level: "high",
44+
reason: "billing",
45+
message: {
46+
"en-US":
47+
"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.",
48+
"zh-CN":
49+
"此操作会变更免费额度自动停止。启用后,免费额度用尽时调用将失败;停用后,免费额度用尽将继续按量计费调用。",
50+
},
51+
},
2452
usageArgs: "<--model <model>[,model2,...] | --all> [--off] [flags]",
2553
flags: {
2654
model: {
@@ -48,12 +76,19 @@ export default defineCommand({
4876
},
4977
},
5078
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",
79+
"--model qwen3-max --yes",
80+
"--model qwen3-max,qwen-turbo --yes",
81+
"--all --yes",
82+
"--on --model qwen3-max --yes",
83+
"--off --model qwen3-max --yes",
84+
"--off --all --yes",
85+
],
86+
notes: [
87+
{
88+
"en-US":
89+
"Requires `--yes` (exit code 7 / requires_confirmation without it). Applies to both enabling and disabling auto-stop.",
90+
"zh-CN": "需要 `--yes`(缺少时退出码 7 / requires_confirmation)。启用与停用自动停止均适用。",
91+
},
5792
],
5893
validate: (flags) =>
5994
!flags.model && !flags.all ? "Provide --model <model>[,model2,...] or --all." : undefined,
@@ -62,6 +97,8 @@ export default defineCommand({
6297
const modelFlag = flags.model || undefined;
6398
const off = Boolean(flags.off);
6499
const format = settings.output;
100+
const action = off ? "disable" : "enable";
101+
const actionZh = off ? "停用" : "启用";
65102

66103
let models: string[];
67104
if (modelFlag) {
@@ -96,6 +133,9 @@ export default defineCommand({
96133

97134
const skipped: string[] = [];
98135
const processed: { model: string; result: unknown }[] = [];
136+
const confirmed: string[] = [];
137+
const failed: string[] = [];
138+
const timedOut: string[] = [];
99139
const textLines: string[] = [];
100140
let stopMap = new Map<string, boolean>();
101141
if (off) {
@@ -106,6 +146,38 @@ export default defineCommand({
106146
stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
107147
}
108148

149+
let resultWritten = false;
150+
/** Empty JSON is a valid success; only skip empty output when catching with no progress. */
151+
const writeBusinessResult = (options?: { requireProgress?: boolean }) => {
152+
if (resultWritten) return;
153+
if (format === "text" && textLines.length === 0) return;
154+
if (
155+
options?.requireProgress &&
156+
processed.length === 0 &&
157+
skipped.length === 0 &&
158+
textLines.length === 0
159+
) {
160+
return;
161+
}
162+
ctx.output.result(
163+
off ? { processed, skipped } : processed.map((item) => item.result),
164+
format === "text" ? { text: textLines.join("\n") } : undefined,
165+
);
166+
resultWritten = true;
167+
};
168+
169+
const progressMessage = (failedModel: string) =>
170+
ctx.localize({
171+
"en-US": [
172+
`Confirmed ${action} auto-stop for ${confirmed.length} model(s)${confirmed.length > 0 ? `: ${confirmed.join(", ")}` : ""}.`,
173+
`Model "${failedModel}" failed; its outcome is unconfirmed. Later models were not executed.`,
174+
].join("\n"),
175+
"zh-CN": [
176+
`已确认${actionZh}自动停止 ${confirmed.length} 个模型${confirmed.length > 0 ? `:${confirmed.join("、")}` : ""}。`,
177+
`模型 "${failedModel}" 失败,结果未确认。后续模型未执行。`,
178+
].join("\n"),
179+
});
180+
109181
for (const name of models) {
110182
if (off && stopMap.get(name) === false) {
111183
skipped.push(name);
@@ -115,11 +187,35 @@ export default defineCommand({
115187
});
116188
continue;
117189
}
118-
const result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
190+
191+
let result: unknown;
192+
try {
193+
result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
194+
} catch (error) {
195+
// Keep stdout progress, but do not wrap the original error (preserves cause.code /
196+
// ERROR_STACK). Surface confirmed work via warning instead.
197+
writeBusinessResult({ requireProgress: true });
198+
if (confirmed.length > 0 || processed.length > 0 || skipped.length > 0) {
199+
ctx.output.warning({
200+
code: "FREETIER_PARTIAL_PROGRESS",
201+
message: progressMessage(name),
202+
details: {
203+
confirmed,
204+
failed_model: name,
205+
processed_count: processed.length,
206+
skipped_count: skipped.length,
207+
},
208+
});
209+
}
210+
throw error;
211+
}
212+
119213
processed.push({ model: name, result });
120214
const resultData = result ? unwrapResponse(result as Record<string, unknown>) : undefined;
121215
const failureModels = (resultData?.failureModels as BatchResultFailure[] | undefined) ?? [];
216+
122217
if (!result) {
218+
timedOut.push(name);
123219
ctx.output.warning({
124220
code: "FREETIER_OPERATION_TIMEOUT",
125221
message: {
@@ -128,7 +224,11 @@ export default defineCommand({
128224
},
129225
details: { model: name },
130226
});
131-
} else if (failureModels.length > 0) {
227+
break;
228+
}
229+
230+
if (failureModels.length > 0) {
231+
failed.push(name);
132232
const errorCode = failureModels[0].errorCode;
133233
ctx.output.warning({
134234
code: "FREETIER_OPERATION_FAILED",
@@ -143,29 +243,64 @@ export default defineCommand({
143243
},
144244
details: { model: name, error_code: errorCode },
145245
});
246+
break;
146247
}
147-
// Keep the existing disable response policy; the JSON report retains the raw response.
148-
if (off || (result && failureModels.length === 0)) {
149-
textLines.push(
150-
ctx.localize(
151-
off
152-
? {
153-
"en-US": `Disabled auto-stop for "${name}".`,
154-
"zh-CN": `已为模型 "${name}" 停用自动停止。`,
155-
}
156-
: {
157-
"en-US": `Enabled auto-stop for "${name}".`,
158-
"zh-CN": `已为模型 "${name}" 启用自动停止。`,
159-
},
160-
),
161-
);
248+
249+
if (!resultData || !isModelOperationConfirmed(resultData, name)) {
250+
failed.push(name);
251+
ctx.output.warning({
252+
code: "FREETIER_OPERATION_FAILED",
253+
message: {
254+
"en-US": `Auto-stop ${action} for "${name}" was not confirmed by the server.`,
255+
"zh-CN": `服务端未确认模型 "${name}" 的自动停止${actionZh}。`,
256+
},
257+
details: { model: name },
258+
});
259+
break;
162260
}
261+
262+
confirmed.push(name);
263+
textLines.push(
264+
ctx.localize(
265+
off
266+
? {
267+
"en-US": `Disabled auto-stop for "${name}".`,
268+
"zh-CN": `已为模型 "${name}" 停用自动停止。`,
269+
}
270+
: {
271+
"en-US": `Enabled auto-stop for "${name}".`,
272+
"zh-CN": `已为模型 "${name}" 启用自动停止。`,
273+
},
274+
),
275+
);
163276
}
164-
if (format === "json" || textLines.length > 0) {
165-
ctx.output.result(
166-
off ? { processed, skipped } : processed.map((item) => item.result),
167-
format === "text" ? { text: textLines.join("\n") } : undefined,
277+
278+
writeBusinessResult();
279+
if (failed.length === 0 && timedOut.length === 0) return;
280+
281+
// Client poll timeout is internal TIMEOUT; server rejection stays GENERAL.
282+
const summaryPartsEn: string[] = [];
283+
const summaryPartsZh: string[] = [];
284+
if (failed.length > 0) {
285+
summaryPartsEn.push(
286+
`Failed to ${action} auto-stop for ${failed.length} model(s): ${failed.join(", ")}.`,
287+
);
288+
summaryPartsZh.push(`${failed.length} 个模型${actionZh}自动停止失败:${failed.join("、")}。`);
289+
}
290+
if (timedOut.length > 0) {
291+
summaryPartsEn.push(
292+
`Timed out waiting to confirm ${action} auto-stop for ${timedOut.length} model(s): ${timedOut.join(", ")}.`,
293+
);
294+
summaryPartsZh.push(
295+
`${timedOut.length} 个模型${actionZh}自动停止超时未确认:${timedOut.join("、")}。`,
168296
);
169297
}
298+
throw new BailianError(
299+
ctx.localize({
300+
"en-US": summaryPartsEn.join(" "),
301+
"zh-CN": summaryPartsZh.join(""),
302+
}),
303+
failed.length > 0 ? ExitCode.GENERAL : ExitCode.TIMEOUT,
304+
);
170305
},
171306
});

0 commit comments

Comments
 (0)