fix(coding-agent): handle z.ai network_error closes #2313

This commit is contained in:
Mario Zechner
2026-03-17 22:34:49 +01:00
parent bbd8392ef9
commit d914d1c199
4 changed files with 154 additions and 25 deletions

View File

@@ -144,7 +144,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
}
if (choice.finish_reason) {
output.stopReason = mapStopReason(choice.finish_reason);
const finishReasonResult = mapStopReason(choice.finish_reason);
output.stopReason = finishReasonResult.stopReason;
if (finishReasonResult.errorMessage) {
output.errorMessage = finishReasonResult.errorMessage;
}
}
if (choice.delta) {
@@ -273,8 +277,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
if (output.stopReason === "aborted") {
throw new Error("Request was aborted");
}
if (output.stopReason === "error") {
throw new Error(output.errorMessage || "Provider returned an error stop reason");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
@@ -736,23 +743,29 @@ function parseChunkUsage(
return usage;
}
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): StopReason {
if (reason === null) return "stop";
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
stopReason: StopReason;
errorMessage?: string;
} {
if (reason === null) return { stopReason: "stop" };
switch (reason) {
case "stop":
case "end":
return "stop";
return { stopReason: "stop" };
case "length":
return "length";
return { stopReason: "length" };
case "function_call":
case "tool_calls":
return "toolUse";
return { stopReason: "toolUse" };
case "content_filter":
return "error";
default: {
const _exhaustive: never = reason as never;
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
}
return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" };
case "network_error":
return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" };
default:
return {
stopReason: "error",
errorMessage: `Provider finish_reason: ${reason}`,
};
}
}

View File

@@ -1,10 +1,23 @@
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getModel } from "../src/models.js";
import { streamSimple } from "../src/stream.js";
import type { Tool } from "../src/types.js";
const mockState = vi.hoisted(() => ({ lastParams: undefined as unknown }));
const mockState = vi.hoisted(() => ({
lastParams: undefined as unknown,
chunks: undefined as
| Array<{
choices: Array<{ delta: Record<string, unknown>; finish_reason: string | null }>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
prompt_tokens_details: { cached_tokens: number };
completion_tokens_details: { reasoning_tokens: number };
};
}>
| undefined,
}));
vi.mock("openai", () => {
class FakeOpenAI {
@@ -14,15 +27,20 @@ vi.mock("openai", () => {
mockState.lastParams = params;
return {
async *[Symbol.asyncIterator]() {
yield {
choices: [{ delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
const chunks = mockState.chunks ?? [
{
choices: [{ delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
};
];
for (const chunk of chunks) {
yield chunk;
}
},
};
},
@@ -34,6 +52,11 @@ vi.mock("openai", () => {
});
describe("openai-completions tool_choice", () => {
beforeEach(() => {
mockState.lastParams = undefined;
mockState.chunks = undefined;
});
it("forwards toolChoice from simple options to payload", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;
@@ -175,4 +198,39 @@ describe("openai-completions tool_choice", () => {
const params = (payload ?? mockState.lastParams) as { reasoning_effort?: string };
expect(params.reasoning_effort).toBe("medium");
});
it("maps non-standard provider finish_reason values to stopReason error", async () => {
mockState.chunks = [
{
choices: [{ delta: { content: "partial" }, finish_reason: null }],
},
{
choices: [{ delta: {}, finish_reason: "network_error" }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
];
const model = getModel("zai", "glm-5")!;
const response = await streamSimple(
model,
{
messages: [
{
role: "user",
content: "Hi",
timestamp: Date.now(),
},
],
},
{ apiKey: "test" },
).result();
expect(response.stopReason).toBe("error");
expect(response.errorMessage).toBe("Provider finish_reason: network_error");
});
});

View File

@@ -2314,8 +2314,8 @@ export class AgentSession {
if (isContextOverflow(message, contextWindow)) return false;
const err = message.errorMessage;
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection errors, fetch failed, terminated, retry delay exceeded
return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|terminated|retry delay/i.test(
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors, fetch failed, terminated, retry delay exceeded
return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay/i.test(
err,
);
}

View File

@@ -168,4 +168,62 @@ describe("AgentSession retry", () => {
expect(created.getCallCount()).toBe(2);
expect(created.session.isRetrying).toBe(false);
});
it("retries provider network_error failures", async () => {
const created = createSession({ failCount: 0 });
let callCount = 0;
const streamFn = () => {
callCount++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
if (callCount === 1) {
const msg = createAssistantMessage("", {
stopReason: "error",
errorMessage: "Provider finish_reason: network_error",
});
stream.push({ type: "start", partial: msg });
stream.push({ type: "error", reason: "error", error: msg });
return;
}
const msg = createAssistantMessage("Recovered after retry");
stream.push({ type: "start", partial: msg });
stream.push({ type: "done", reason: "stop", message: msg });
});
return stream;
};
created.session.dispose();
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: "Test", tools: [] },
streamFn,
});
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } });
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
resourceLoader: createTestResourceLoader(),
});
const events: string[] = [];
session.subscribe((event) => {
if (event.type === "auto_retry_start") events.push(`start:${event.attempt}`);
if (event.type === "auto_retry_end") events.push(`end:success=${event.success}`);
});
await session.prompt("Test");
expect(callCount).toBe(2);
expect(events).toEqual(["start:1", "end:success=true"]);
});
});