Skip to content

Commit 00d9ab2

Browse files
committed
fix(runtime): improve free-tier model operation confirmation and error handling
1 parent a6f9e8e commit 00d9ab2

3 files changed

Lines changed: 253 additions & 27 deletions

File tree

‎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 提示并保留业务结果,再抛摘要:仅轮询超时 → `TIMEOUT(5)`(文案用「超时未确认」);仅服务端拒绝或混合 → `GENERAL(1)`;成功文案(Enabled / Disabled)仅在该模型操作成功时输出
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/usage/freetier.ts‎

Lines changed: 111 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ 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":
@@ -79,6 +97,8 @@ export default defineCommand({
7997
const modelFlag = flags.model || undefined;
8098
const off = Boolean(flags.off);
8199
const format = settings.output;
100+
const action = off ? "disable" : "enable";
101+
const actionZh = off ? "停用" : "启用";
82102

83103
let models: string[];
84104
if (modelFlag) {
@@ -113,6 +133,7 @@ export default defineCommand({
113133

114134
const skipped: string[] = [];
115135
const processed: { model: string; result: unknown }[] = [];
136+
const confirmed: string[] = [];
116137
const failed: string[] = [];
117138
const timedOut: string[] = [];
118139
const textLines: string[] = [];
@@ -125,6 +146,38 @@ export default defineCommand({
125146
stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
126147
}
127148

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+
128181
for (const name of models) {
129182
if (off && stopMap.get(name) === false) {
130183
skipped.push(name);
@@ -134,10 +187,33 @@ export default defineCommand({
134187
});
135188
continue;
136189
}
137-
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+
138213
processed.push({ model: name, result });
139214
const resultData = result ? unwrapResponse(result as Record<string, unknown>) : undefined;
140215
const failureModels = (resultData?.failureModels as BatchResultFailure[] | undefined) ?? [];
216+
141217
if (!result) {
142218
timedOut.push(name);
143219
ctx.output.warning({
@@ -148,7 +224,10 @@ export default defineCommand({
148224
},
149225
details: { model: name },
150226
});
151-
} else if (failureModels.length > 0) {
227+
break;
228+
}
229+
230+
if (failureModels.length > 0) {
152231
failed.push(name);
153232
const errorCode = failureModels[0].errorCode;
154233
ctx.output.warning({
@@ -164,34 +243,42 @@ export default defineCommand({
164243
},
165244
details: { model: name, error_code: errorCode },
166245
});
167-
} else {
168-
textLines.push(
169-
ctx.localize(
170-
off
171-
? {
172-
"en-US": `Disabled auto-stop for "${name}".`,
173-
"zh-CN": `已为模型 "${name}" 停用自动停止。`,
174-
}
175-
: {
176-
"en-US": `Enabled auto-stop for "${name}".`,
177-
"zh-CN": `已为模型 "${name}" 启用自动停止。`,
178-
},
179-
),
180-
);
246+
break;
181247
}
182-
}
183-
if (format === "json" || textLines.length > 0) {
184-
ctx.output.result(
185-
off ? { processed, skipped } : processed.map((item) => item.result),
186-
format === "text" ? { text: textLines.join("\n") } : undefined,
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;
260+
}
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+
),
187275
);
188276
}
277+
278+
writeBusinessResult();
189279
if (failed.length === 0 && timedOut.length === 0) return;
190280

191281
// 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 ? "停用" : "启用";
195282
const summaryPartsEn: string[] = [];
196283
const summaryPartsZh: string[] = [];
197284
if (failed.length > 0) {

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

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,9 @@ let calls: Array<{ api: string; data: Record<string, unknown> }> = [];
224224
let openCalls: Record<string, unknown>[] = [];
225225
let failure = false;
226226
let alreadyOff = false;
227+
let emptyModelList = false;
227228
let batchResult: unknown = batch;
229+
let batchHandler: ((api: string, data: Record<string, unknown>) => unknown) | undefined;
228230
let overviewResult: unknown = stat;
229231
let modelRows: unknown[] = [
230232
{ model: "qwen-test", callSuccessCount: 3, usage: { input_tokens: 2 } },
@@ -239,7 +241,9 @@ afterEach(() => {
239241
openCalls = [];
240242
failure = false;
241243
alreadyOff = false;
244+
emptyModelList = false;
242245
batchResult = batch;
246+
batchHandler = undefined;
243247
overviewResult = stat;
244248
modelRows = [{ model: "qwen-test", callSuccessCount: 3, usage: { input_tokens: 2 } }];
245249
});
@@ -291,7 +295,9 @@ async function invoke(
291295
calls.push({ api, data: structuredClone(data) });
292296
if (failure) throw new BailianError("Original console failure", ExitCode.GENERAL);
293297
if (api === MODEL_LIST_API)
294-
return { list: [{ model: "qwen-test", capabilities: ["TG"] }], total: 1 };
298+
return emptyModelList
299+
? { list: [], total: 0 }
300+
: { list: [{ model: "qwen-test", capabilities: ["TG"] }], total: 1 };
295301
if (api.includes("queryFreeTierQuota"))
296302
return { data: { data: { freeTierQuotas: [quota] } } };
297303
if (api.includes("queryFreeTierOnlyStatus"))
@@ -300,8 +306,13 @@ async function invoke(
300306
data: { freeTierOnlyStatuses: [{ model: "qwen-test", freeTierOnly: !alreadyOff }] },
301307
},
302308
};
303-
if (api.includes("batchActivateFreeTierOnly") || api.includes("batchDeactivateFreeTierOnly"))
309+
if (
310+
api.includes("batchActivateFreeTierOnly") ||
311+
api.includes("batchDeactivateFreeTierOnly")
312+
) {
313+
if (batchHandler) return batchHandler(api, data);
304314
return structuredClone(batchResult);
315+
}
305316
if (api.includes("getModelUsageStatistic")) return structuredClone(overviewResult);
306317
if (api.includes("listModelUsageStatisticData"))
307318
return { list: structuredClone(modelRows), totalCount: modelRows.length };
@@ -642,3 +653,131 @@ test.each(["text", "json"] as const)(
642653
}
643654
},
644655
);
656+
657+
test("auto-stop fail-fast stops after the first model failure", async () => {
658+
batchResult = { failureModels: [{ failureModelId: "qwen-a", errorCode: "Rejected" }] };
659+
const result = await invoke(
660+
{ ...cases[1]!, flags: { model: "qwen-a,qwen-b,qwen-c", off: true } },
661+
{ format: "json", quiet: true },
662+
);
663+
expect(result.exitCode).toBe(ExitCode.GENERAL);
664+
expect(calls.filter((call) => call.api.includes("batchDeactivate"))).toHaveLength(1);
665+
expect(calls.find((call) => call.api.includes("batchDeactivate"))!.data).toEqual({
666+
BatchDeactivateFreeTierOnlyRequest: { models: ["qwen-a"] },
667+
});
668+
expect(JSON.parse(result.stdout)).toEqual({
669+
processed: [{ model: "qwen-a", result: batchResult }],
670+
skipped: [],
671+
});
672+
expect(result.stderr).toContain("Failed to disable auto-stop for 1 model(s): qwen-a");
673+
});
674+
675+
test("auto-stop empty success list is not treated as success", async () => {
676+
batchResult = { successCount: 0, successModels: [], failureModels: [] };
677+
const result = await invoke(
678+
{ ...cases[1]!, flags: { model: "qwen-test", off: true } },
679+
{ format: "json", quiet: true },
680+
);
681+
expect(result.exitCode).toBe(ExitCode.GENERAL);
682+
expect(result.stdout).not.toContain("Disabled auto-stop");
683+
expect(JSON.parse(result.stdout)).toEqual({
684+
processed: [{ model: "qwen-test", result: batchResult }],
685+
skipped: [],
686+
});
687+
expect(result.stderr).toContain("FREETIER_OPERATION_FAILED");
688+
expect(result.stderr).toContain("was not confirmed by the server");
689+
expect(result.stderr).toContain("Failed to disable auto-stop for 1 model(s): qwen-test");
690+
});
691+
692+
test("auto-stop network error keeps prior confirmed progress", async () => {
693+
let mutateCalls = 0;
694+
batchHandler = () => {
695+
mutateCalls += 1;
696+
if (mutateCalls === 1) {
697+
return {
698+
success: true,
699+
successCount: 1,
700+
successModels: ["qwen-test"],
701+
failureModels: [],
702+
};
703+
}
704+
throw new BailianError("Upstream network failure", ExitCode.NETWORK);
705+
};
706+
const result = await invoke(
707+
{ ...cases[1]!, flags: { model: "qwen-test,qwen-other" } },
708+
{ format: "json", quiet: true },
709+
);
710+
expect(result.exitCode).toBe(ExitCode.NETWORK);
711+
expect(mutateCalls).toBe(2);
712+
expect(JSON.parse(result.stdout)).toEqual([
713+
{
714+
success: true,
715+
successCount: 1,
716+
successModels: ["qwen-test"],
717+
failureModels: [],
718+
},
719+
]);
720+
expect(result.stderr).toContain("Upstream network failure");
721+
expect(result.stderr).toContain("FREETIER_PARTIAL_PROGRESS");
722+
expect(result.stderr).toContain("Confirmed enable auto-stop for 1 model(s): qwen-test");
723+
expect(result.stderr).toContain("qwen-other");
724+
expect(result.stderr).toContain("outcome is unconfirmed");
725+
expect(result.stderr).toContain("Later models were not executed");
726+
});
727+
728+
test.each([
729+
{ off: false, want: [] },
730+
{ off: true, want: { processed: [], skipped: [] } },
731+
] as const)(
732+
"auto-stop --all with zero models still emits empty JSON (off=$off)",
733+
async ({ off, want }) => {
734+
emptyModelList = true;
735+
const result = await invoke(
736+
{ ...cases[1]!, flags: { all: true, ...(off ? { off: true } : {}) } },
737+
{ format: "json", quiet: true },
738+
);
739+
expect(result.exitCode).toBe(0);
740+
expect(JSON.parse(result.stdout)).toEqual(want);
741+
expect(calls.some((call) => call.api.includes("batchActivate"))).toBe(false);
742+
expect(calls.some((call) => call.api.includes("batchDeactivate"))).toBe(false);
743+
},
744+
);
745+
746+
test("auto-stop transport error preserves cause.code and verbose ERROR_STACK", async () => {
747+
let mutateCalls = 0;
748+
batchHandler = () => {
749+
mutateCalls += 1;
750+
if (mutateCalls === 1) {
751+
return {
752+
success: true,
753+
successCount: 1,
754+
successModels: ["qwen-test"],
755+
failureModels: [],
756+
};
757+
}
758+
const cause = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), {
759+
code: "ENOTFOUND",
760+
});
761+
throw new TypeError("fetch failed", { cause });
762+
};
763+
const result = await invoke(
764+
{ ...cases[1]!, flags: { model: "qwen-test,qwen-other" } },
765+
{ format: "json", quiet: true, verbose: true },
766+
);
767+
expect(result.exitCode).toBe(ExitCode.NETWORK);
768+
expect(JSON.parse(result.stdout)).toHaveLength(1);
769+
const stderrLines = result.stderr
770+
.trim()
771+
.split("\n")
772+
.map((line) => JSON.parse(line) as Record<string, unknown>);
773+
expect(
774+
stderrLines.some(
775+
(line) => line.debug && (line.debug as { code?: string }).code === "ERROR_STACK",
776+
),
777+
).toBe(true);
778+
const errorLine = stderrLines.find((line) => line.error) as {
779+
error: { cause?: { code?: string; message?: string } };
780+
};
781+
expect(errorLine.error.cause?.code).toBe("ENOTFOUND");
782+
expect(result.stderr).toContain("FREETIER_PARTIAL_PROGRESS");
783+
});

0 commit comments

Comments
 (0)