fix(coding-agent): handle z.ai network_error closes #2313
This commit is contained in:
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user