diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 89db3d18c..31cd9af33 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI-compat streams ending with a bare `finish_reason: "error"` (gateways like OpenRouter reporting upstream failures, e.g. Gemini `MALFORMED_FUNCTION_CALL`) surfacing as a non-retryable `Provider finish_reason: error`. The reason is now mapped to `Provider returned error finish_reason`, which the session retry classifier recognizes as transient, so the turn auto-retries instead of stopping with a pinned error banner. + ## [15.12.1] - 2026-06-12 ### Added diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 2cc542744..8e12c3919 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -2109,6 +2109,13 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | str return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" }; case "network_error": return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" }; + case "error": + // Gateways (OpenRouter, Vercel AI Gateway, …) report upstream model + // failures as a bare `finish_reason: "error"` with no detail. These are + // almost always transient (e.g. Gemini MALFORMED_FUNCTION_CALL), so word + // the message to match the session retry classifier's transient-transport + // pattern (`provider.?returned.?error`) and get the turn auto-retried. + return { stopReason: "error", errorMessage: "Provider returned error finish_reason" }; default: return { stopReason: "error", diff --git a/packages/ai/test/openai-completions-error-finish-reason.test.ts b/packages/ai/test/openai-completions-error-finish-reason.test.ts new file mode 100644 index 000000000..0603f2d18 --- /dev/null +++ b/packages/ai/test/openai-completions-error-finish-reason.test.ts @@ -0,0 +1,109 @@ +// Regression coverage for gateways (OpenRouter, Vercel AI Gateway, …) that +// report upstream model failures as a bare `finish_reason: "error"` — e.g. +// Gemini MALFORMED_FUNCTION_CALL behind an OpenAI-compat endpoint. The mapped +// error message must match the session retry classifier's transient-transport +// pattern (`provider.?returned.?error` in agent-session's +// #isTransientTransportErrorMessage) so the turn is auto-retried instead of +// stopping with a pinned error banner. +import { describe, expect, it } from "bun:test"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; + +// Mirrors the transient-transport alternative the session retry gate matches on. +const RETRYABLE_PATTERN = /provider.?returned.?error/i; + +const completionsModel = { + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), + api: "openai-completions", +} satisfies Model<"openai-completions">; + +function baseContext(): Context { + return { + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; +} + +function createSseFetch(events: unknown[]): FetchImpl { + async function mockFetch(_input: string | URL | Request, _init?: RequestInit): Promise { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const event of events) { + const data = typeof event === "string" ? event : JSON.stringify(event); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + } + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return mockFetch as typeof fetch; +} + +function completionChunk(extra: Record): unknown { + return { + id: "chatcmpl-error-finish", + object: "chat.completion.chunk", + created: 0, + model: completionsModel.id, + ...extra, + }; +} + +describe("finish_reason: error", () => { + it("maps to a retryable error message", async () => { + const fetchMock = createSseFetch([ + completionChunk({ choices: [{ index: 0, delta: { role: "assistant", content: "Hel" } }] }), + completionChunk({ choices: [{ index: 0, delta: {}, finish_reason: "error" }] }), + "[DONE]", + ]); + + const result = await streamOpenAICompletions(completionsModel, baseContext(), { + apiKey: "test-key", + fetch: fetchMock, + }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(RETRYABLE_PATTERN); + }, 10_000); + + it("stays an error even when the stream carried tool calls", async () => { + // The user-visible failure mode: the model garbles a tool call, the + // gateway ends the stream with `finish_reason: "error"`. Tool-call + // promotion (stop → toolUse) must not paper over the error finish. + const fetchMock = createSseFetch([ + completionChunk({ + choices: [ + { + index: 0, + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: "read", arguments: '{"pattern":"x"}' }, + }, + ], + }, + }, + ], + }), + completionChunk({ choices: [{ index: 0, delta: {}, finish_reason: "error" }] }), + "[DONE]", + ]); + + const result = await streamOpenAICompletions(completionsModel, baseContext(), { + apiKey: "test-key", + fetch: fetchMock, + }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(RETRYABLE_PATTERN); + }, 10_000); +});