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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user