fix(ai): mapped bare finish_reason error to retryable error message

- Mapped OpenAI completions streams ending with `finish_reason: "error"` to `stopReason: "error"` with the message `Provider returned error finish_reason`.
- Added regression tests for bare `finish_reason: "error"` streams, including tool-call payloads, to verify the error remains retryable.
This commit is contained in:
can1357
2026-06-12 22:27:04 +02:00
parent e71447ef80
commit 2f8718188f
3 changed files with 120 additions and 0 deletions
+4
View File
@@ -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
@@ -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",
@@ -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<Response> {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
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<string, unknown>): 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);
});