diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index bcb7d7ef..bd2ccf20 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -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}`, + }; } } diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 339e3f21..315832b9 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -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; 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"); + }); }); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 5dca6ae2..111adcd2 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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, ); } diff --git a/packages/coding-agent/test/agent-session-retry.test.ts b/packages/coding-agent/test/agent-session-retry.test.ts index 91bd799c..962e5005 100644 --- a/packages/coding-agent/test/agent-session-retry.test.ts +++ b/packages/coding-agent/test/agent-session-retry.test.ts @@ -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"]); + }); });