diff --git a/changelog.d/fixes/10959-single-target-reasoning-fallback.md b/changelog.d/fixes/10959-single-target-reasoning-fallback.md new file mode 100644 index 0000000000..eb6e9c1903 --- /dev/null +++ b/changelog.d/fixes/10959-single-target-reasoning-fallback.md @@ -0,0 +1 @@ +- fix(sse): default single-target incompatible reasoning to drop for agentic replay — single-target requests to opaque reasoning targets now gracefully strip incompatible plaintext reasoning history instead of returning HTTP 400, matching combo default behavior while preserving operator and per-request overrides ([#10959](https://github.com/diegosouzapw/OmniRoute/issues/10959)) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index fea8640f9d..10e6c32ae6 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -33,7 +33,10 @@ import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHe import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts"; import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; -import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts"; +import { + applyReasoningInputPolicy, + resolveIncompatibleReasoningAction, +} from "../services/reasoningInputPolicy.ts"; import { createRoutingEvent, emitRoutingEvent, @@ -1213,7 +1216,11 @@ export async function handleChatCore({ provider, preserveEncryptedReasoning: credentials?.providerSpecificData?.preserveEncryptedReasoning === true, - onIncompatibleReasoning: reasoningTransportFallback === "skip" ? "reject" : "drop", + onIncompatibleReasoning: resolveIncompatibleReasoningAction({ + reasoningTransportFallback, + isComboStep: Boolean(comboStepId || comboExecutionKey), + headers: clientRawRequest?.headers ?? null, + }), } ); if (policy.incompatibleReasoning) { diff --git a/open-sse/services/reasoningInputPolicy.ts b/open-sse/services/reasoningInputPolicy.ts index 0e9e8d194d..e6c049f614 100644 --- a/open-sse/services/reasoningInputPolicy.ts +++ b/open-sse/services/reasoningInputPolicy.ts @@ -344,3 +344,65 @@ export function applyReasoningInputPolicy( } return { incompatibleReasoning: false }; } + +export function createReasoningTransportIncompatibleError(): Error & { + statusCode: number; + errorType: string; +} { + const error = new Error( + "Reasoning continuation is not compatible with the selected target" + ) as Error & { statusCode: number; errorType: string }; + error.statusCode = 400; + error.errorType = "reasoning_transport_incompatible"; + return error; +} + +export const REASONING_FALLBACK_HEADER = "x-omniroute-reasoning-fallback"; + +function readFallbackHeader( + headers: Headers | Record | null | undefined +): string | null { + if (!headers) return null; + if (headers instanceof Headers) { + const value = headers.get(REASONING_FALLBACK_HEADER); + return typeof value === "string" ? value : null; + } + if (typeof headers !== "object") return null; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === REASONING_FALLBACK_HEADER && typeof value === "string") { + return value; + } + } + return null; +} + +/** + * Resolves the action taken when inbound continuation reasoning is incompatible with the selected + * target's reasoning transport. Combo steps keep their explicit configuration. Single-target + * requests default to "drop" so replayed summary-only reasoning from agentic clients does not + * hard-fail every continuation turn; an operator (OMNIROUTE_SINGLE_TARGET_REASONING_FALLBACK=reject) + * or caller (x-omniroute-reasoning-fallback: reject) may explicitly enforce "reject". + */ +export function resolveIncompatibleReasoningAction(options: { + reasoningTransportFallback?: string | null; + isComboStep?: boolean; + headers?: Headers | Record | null; + env?: Record; +}): "drop" | "reject" { + if (options.reasoningTransportFallback === "drop") return "drop"; + if (options.isComboStep && options.reasoningTransportFallback === "skip") return "reject"; + + const headerRaw = readFallbackHeader(options.headers)?.trim().toLowerCase(); + if (headerRaw === "reject") return "reject"; + if (headerRaw === "drop") return "drop"; + + const envRaw = ( + options.env ?? process.env + ).OMNIROUTE_SINGLE_TARGET_REASONING_FALLBACK?.trim().toLowerCase(); + if (envRaw === "reject") return "reject"; + if (envRaw === "drop") return "drop"; + + // Default to "drop" for single-target requests so multi-turn agentic loops on direct + // Codex / OpenAI targets work seamlessly out of the box. + return "drop"; +} diff --git a/tests/unit/reasoning-input-policy-single-target-fallback.test.ts b/tests/unit/reasoning-input-policy-single-target-fallback.test.ts new file mode 100644 index 0000000000..766d23a18f --- /dev/null +++ b/tests/unit/reasoning-input-policy-single-target-fallback.test.ts @@ -0,0 +1,95 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + applyReasoningInputPolicy, + resolveIncompatibleReasoningAction, +} from "../../open-sse/services/reasoningInputPolicy.ts"; + +// Agentic clients replay summary-text reasoning on continuation turns. Direct +// single-target requests to opaque transports (codex family) must default to +// dropping incompatible reasoning so continuation turns do not hard-fail. + +test("single-target default is drop when nothing is configured", () => { + const action = resolveIncompatibleReasoningAction({ + reasoningTransportFallback: "skip", + isComboStep: false, + headers: null, + env: {}, + }); + assert.equal(action, "drop"); +}); + +test("env OMNIROUTE_SINGLE_TARGET_REASONING_FALLBACK=reject enforces rejection", () => { + const action = resolveIncompatibleReasoningAction({ + reasoningTransportFallback: "skip", + isComboStep: false, + headers: null, + env: { OMNIROUTE_SINGLE_TARGET_REASONING_FALLBACK: "reject" }, + }); + assert.equal(action, "reject"); +}); + +test("x-omniroute-reasoning-fallback header overrides env and default", () => { + const rejectHeader = resolveIncompatibleReasoningAction({ + reasoningTransportFallback: "skip", + isComboStep: false, + headers: { "x-omniroute-reasoning-fallback": "reject" }, + env: {}, + }); + assert.equal(rejectHeader, "reject"); + + const dropOverridesEnv = resolveIncompatibleReasoningAction({ + reasoningTransportFallback: "skip", + isComboStep: false, + headers: new Headers({ "X-OmniRoute-Reasoning-Fallback": "drop" }), + env: { OMNIROUTE_SINGLE_TARGET_REASONING_FALLBACK: "reject" }, + }); + assert.equal(dropOverridesEnv, "drop"); +}); + +test("combo steps keep their explicit configuration", () => { + const comboSkip = resolveIncompatibleReasoningAction({ + reasoningTransportFallback: "skip", + isComboStep: true, + headers: null, + env: {}, + }); + assert.equal(comboSkip, "reject"); + + const comboDrop = resolveIncompatibleReasoningAction({ + reasoningTransportFallback: "drop", + isComboStep: true, + headers: null, + env: {}, + }); + assert.equal(comboDrop, "drop"); +}); + +test("default single-target policy strips plaintext reasoning when targeting opaque provider", () => { + const body: Record = { + messages: [ + { role: "user", content: "research this project" }, + { + role: "assistant", + content: "Here is the summary.", + reasoning_content: "**Planning multi-project analysis and inspection**", + }, + ], + }; + + const result = applyReasoningInputPolicy(body, "chat", { + provider: "codex", + onIncompatibleReasoning: resolveIncompatibleReasoningAction({ + reasoningTransportFallback: "skip", + isComboStep: false, + headers: null, + env: {}, + }), + }); + + assert.equal(result.incompatibleReasoning, false); + const assistantMsg = (body.messages as Array>)[1]; + assert.equal(assistantMsg.reasoning_content, undefined); + assert.equal(assistantMsg.content, "Here is the summary."); +});