fix(sse): default single-target incompatible reasoning to drop for agentic replay (#10959) (#11178)

Merged after conflict resolution onto the post-#10961 tip (verified no clobber of today's plaintext-wins work — the resolver functions were appended and the chatCore call-site swapped; a first --theirs attempt was caught reverting #10961 and redone hunk-by-hunk). resolveIncompatibleReasoningAction now defaults single-target incompatible reasoning to drop while combos keep their explicit strategy, with the x-omniroute-reasoning-fallback header override. 21/21 reasoning suites green, typecheck clean. Fixes #10959. Thank you @adevwithpurpose!
This commit is contained in:
adevwithpurpose
2026-08-23 18:00:17 +05:00
committed by GitHub
parent 84bd382d94
commit 90ba281e7f
4 changed files with 167 additions and 2 deletions

View File

@@ -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))

View File

@@ -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) {

View File

@@ -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<string, unknown> | 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<string, unknown> | null;
env?: Record<string, string | undefined>;
}): "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";
}

View File

@@ -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<string, unknown> = {
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<Record<string, unknown>>)[1];
assert.equal(assistantMsg.reasoning_content, undefined);
assert.equal(assistantMsg.content, "Here is the summary.");
});