mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 08:02:14 +03:00
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:
@@ -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))
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
Reference in New Issue
Block a user