diff --git a/changelog.d/fixes/12129-context-handoff-native-passthrough-shape.md b/changelog.d/fixes/12129-context-handoff-native-passthrough-shape.md new file mode 100644 index 0000000000..f2668facbd --- /dev/null +++ b/changelog.d/fixes/12129-context-handoff-native-passthrough-shape.md @@ -0,0 +1 @@ +- fix(sse): require Responses-shaped body before native OpenAI-compatible passthrough (#12129) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index a091ad4b00..0b1ac2e3f9 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -732,6 +732,7 @@ export async function handleChatCore({ sourceFormat, endpointPath, providerSpecificData: credentials?.providerSpecificData, + body, }); const responsesInputItems = Array.isArray(body?.input) ? body.input : []; const customToolNames = collectCustomToolNamesForSourceFormat( diff --git a/open-sse/handlers/chatCore/passthroughHelpers.ts b/open-sse/handlers/chatCore/passthroughHelpers.ts index 352415ed89..213f2d7a9f 100644 --- a/open-sse/handlers/chatCore/passthroughHelpers.ts +++ b/open-sse/handlers/chatCore/passthroughHelpers.ts @@ -53,19 +53,35 @@ export function stampNativeResponsesPassthroughBody( return { ...body, _nativeOpenAICompatibleResponsesPassthrough: true }; } +// A body only qualifies for the native-Responses passthrough fast path when it is +// actually shaped like a Responses API request (`input`, no `messages`). Endpoint +// path alone is not sufficient: an internally-synthesized Chat Completions-shaped +// body (e.g. the context-handoff summary request) can be dispatched through a +// closure that still carries the original client request's `/responses` endpoint, +// which otherwise makes `sourceFormat` resolve to "openai-responses" even though +// the body itself was never translated. See issue #12129. +function isResponsesShapedBody(body: unknown): boolean { + if (!body || typeof body !== "object") return false; + const candidate = body as Record; + return candidate.input !== undefined && candidate.messages === undefined; +} + export function shouldUseNativeOpenAICompatibleResponsesPassthrough({ provider, sourceFormat, endpointPath, providerSpecificData, + body, }: { provider?: string | null; sourceFormat?: string | null; endpointPath?: string | null; providerSpecificData?: unknown; + body?: unknown; }): boolean { if (!provider?.startsWith("openai-compatible-")) return false; if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; + if (body !== undefined && !isResponsesShapedBody(body)) return false; if (providerSpecificData && typeof providerSpecificData === "object") { const psd = providerSpecificData as Record; if (psd.apiType === "responses" || psd._omnirouteForceResponsesUpstream === true) { diff --git a/tests/unit/context-handoff-native-passthrough-bug.test.ts b/tests/unit/context-handoff-native-passthrough-bug.test.ts new file mode 100644 index 0000000000..d142b60649 --- /dev/null +++ b/tests/unit/context-handoff-native-passthrough-bug.test.ts @@ -0,0 +1,86 @@ +// Regression test for issue #12129: an internal context-handoff summary request (built in +// Chat Completions shape -- `messages`, no `input`) is dispatched through the SAME +// handleSingleModel closure that carries the ORIGINAL client request's endpoint. +// When that original endpoint matched `/responses` and the resolved handoff-model +// provider is an openai-compatible-* connection configured with apiType "responses", +// the pipeline used to decide the body was already native-Responses-shaped and skip +// chat->responses translation entirely (`_nativeOpenAICompatibleResponsesPassthrough`), +// so the upstream received `messages` on `/v1/responses` and rejected it with zero input. +// +// Fix: `shouldUseNativeOpenAICompatibleResponsesPassthrough` now requires the body to +// actually look Responses-shaped (`input` present, `messages` absent) before allowing +// the passthrough fast path, so an internally-synthesized chat-shaped body is routed +// through the normal chat->responses translation layer instead. +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { resolveChatCoreRequestFormat } from "../../open-sse/handlers/chatCore/requestFormat.ts"; +import { shouldUseNativeOpenAICompatibleResponsesPassthrough } from "../../open-sse/handlers/chatCore/passthroughHelpers.ts"; + +test("internal chat-shaped handoff body is no longer treated as native Responses passthrough", () => { + const clientRawRequest = { + endpoint: "/v1/responses", + headers: new Headers(), + }; + + const summaryBody = { + model: "some-handoff-model", + messages: [{ role: "user", content: "Summarize this conversation." }], + stream: false, + max_tokens: 800, + temperature: 0.1, + _omnirouteSkipContextRelay: true, + _omnirouteInternalRequest: "context-handoff", + }; + + const { sourceFormat, endpointPath } = resolveChatCoreRequestFormat({ + clientRawRequest, + body: summaryBody, + provider: "openai-compatible-responses-cliproxy", + userAgent: null, + }); + + assert.equal(sourceFormat, "openai-responses"); + assert.equal(endpointPath, "/v1/responses"); + + const providerSpecificData = { apiType: "responses" }; + + const nativePassthrough = shouldUseNativeOpenAICompatibleResponsesPassthrough({ + provider: "openai-compatible-responses-cliproxy", + sourceFormat, + endpointPath, + providerSpecificData, + body: summaryBody, + }); + + assert.equal( + nativePassthrough, + false, + "fixed: chat-shaped internal body must not take the native-Responses passthrough shortcut" + ); + + assert.equal((summaryBody as Record).input, undefined); + assert.ok(Array.isArray(summaryBody.messages) && summaryBody.messages.length > 0); +}); + +test("genuine Responses-shaped body still takes the native passthrough fast path", () => { + const genuineResponsesBody = { + model: "gpt-5.6-sol", + input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }], + stream: false, + }; + + const nativePassthrough = shouldUseNativeOpenAICompatibleResponsesPassthrough({ + provider: "openai-compatible-responses-cliproxy", + sourceFormat: "openai-responses", + endpointPath: "/v1/responses", + providerSpecificData: { apiType: "responses" }, + body: genuineResponsesBody, + }); + + assert.equal( + nativePassthrough, + true, + "a genuine Responses-shaped client body must keep the zero-translation fast path" + ); +});