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) {
|
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) {
|
if (choice.delta) {
|
||||||
@@ -273,8 +277,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
|
|||||||
throw new Error("Request was aborted");
|
throw new Error("Request was aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
if (output.stopReason === "aborted") {
|
||||||
throw new Error("An unknown error occurred");
|
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 });
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||||
@@ -736,23 +743,29 @@ function parseChunkUsage(
|
|||||||
return usage;
|
return usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): StopReason {
|
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
|
||||||
if (reason === null) return "stop";
|
stopReason: StopReason;
|
||||||
|
errorMessage?: string;
|
||||||
|
} {
|
||||||
|
if (reason === null) return { stopReason: "stop" };
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case "stop":
|
case "stop":
|
||||||
case "end":
|
case "end":
|
||||||
return "stop";
|
return { stopReason: "stop" };
|
||||||
case "length":
|
case "length":
|
||||||
return "length";
|
return { stopReason: "length" };
|
||||||
case "function_call":
|
case "function_call":
|
||||||
case "tool_calls":
|
case "tool_calls":
|
||||||
return "toolUse";
|
return { stopReason: "toolUse" };
|
||||||
case "content_filter":
|
case "content_filter":
|
||||||
return "error";
|
return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" };
|
||||||
default: {
|
case "network_error":
|
||||||
const _exhaustive: never = reason as never;
|
return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" };
|
||||||
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
|
default:
|
||||||
}
|
return {
|
||||||
|
stopReason: "error",
|
||||||
|
errorMessage: `Provider finish_reason: ${reason}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
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 { getModel } from "../src/models.js";
|
||||||
import { streamSimple } from "../src/stream.js";
|
import { streamSimple } from "../src/stream.js";
|
||||||
import type { Tool } from "../src/types.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", () => {
|
vi.mock("openai", () => {
|
||||||
class FakeOpenAI {
|
class FakeOpenAI {
|
||||||
@@ -14,7 +27,8 @@ vi.mock("openai", () => {
|
|||||||
mockState.lastParams = params;
|
mockState.lastParams = params;
|
||||||
return {
|
return {
|
||||||
async *[Symbol.asyncIterator]() {
|
async *[Symbol.asyncIterator]() {
|
||||||
yield {
|
const chunks = mockState.chunks ?? [
|
||||||
|
{
|
||||||
choices: [{ delta: {}, finish_reason: "stop" }],
|
choices: [{ delta: {}, finish_reason: "stop" }],
|
||||||
usage: {
|
usage: {
|
||||||
prompt_tokens: 1,
|
prompt_tokens: 1,
|
||||||
@@ -22,7 +36,11 @@ vi.mock("openai", () => {
|
|||||||
prompt_tokens_details: { cached_tokens: 0 },
|
prompt_tokens_details: { cached_tokens: 0 },
|
||||||
completion_tokens_details: { reasoning_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", () => {
|
describe("openai-completions tool_choice", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockState.lastParams = undefined;
|
||||||
|
mockState.chunks = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards toolChoice from simple options to payload", async () => {
|
it("forwards toolChoice from simple options to payload", async () => {
|
||||||
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
|
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
|
||||||
const model = { ...baseModel, api: "openai-completions" } as const;
|
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 };
|
const params = (payload ?? mockState.lastParams) as { reasoning_effort?: string };
|
||||||
expect(params.reasoning_effort).toBe("medium");
|
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;
|
if (isContextOverflow(message, contextWindow)) return false;
|
||||||
|
|
||||||
const err = message.errorMessage;
|
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
|
// 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|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|terminated|retry delay/i.test(
|
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,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,4 +168,62 @@ describe("AgentSession retry", () => {
|
|||||||
expect(created.getCallCount()).toBe(2);
|
expect(created.getCallCount()).toBe(2);
|
||||||
expect(created.session.isRetrying).toBe(false);
|
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