mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
fix(routing): preserve reasoning overrides across transports and fallbacks (#13556)
Merged. The failure mode was concrete — a matched reasoning rule dropped on native Responses/Anthropic paths, model-suffix/account defaults, or fallback preparation, and `_omnirouteReasoningRule` leaking upstream as `Unsupported parameter` — and the fix is carried in the request-local credential context through dispatch, refreshed credentials and fallbacks, with forced effort winning over defaults and client-forged markers dropped at ingress. The 11-case integration suite exercises the real routing/translation modules. Validated as a combined board first (this PR merged with the 4 siblings of the JxnLexn wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 77 passing / 0 failing focused node:test cases across the test files the wave touches. The wave's i18n fill (new keys carried to all 66 locales), free-tier doc counts and file-size rebaseline land in one follow-up PR right after the wave, as with #13904. Thank you — and for keeping this a runtime-only change with the editor and service-tier work in their own PRs.
This commit is contained in:
1
changelog.d/fixes/13556-reasoning-rule-propagation.md
Normal file
1
changelog.d/fixes/13556-reasoning-rule-propagation.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(routing):** Preserve forced reasoning effort across native requests, account defaults and combo fallbacks while keeping internal routing directives out of upstream payloads. ([#13556](https://github.com/diegosouzapw/OmniRoute/pull/13556)) — thanks @JxnLexn
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { getAccessToken } from "../services/tokenRefresh.ts";
|
import { getAccessToken } from "../services/tokenRefresh.ts";
|
||||||
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
||||||
import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
|
import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
|
||||||
|
import { getForcedReasoningEffort } from "../utils/reasoningRuleContext.ts";
|
||||||
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
|
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
|
||||||
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
||||||
import { CORS_HEADERS } from "../utils/cors.ts";
|
import { CORS_HEADERS } from "../utils/cors.ts";
|
||||||
@@ -819,6 +820,22 @@ export class CodexExecutor extends BaseExecutor {
|
|||||||
requestInput.body
|
requestInput.body
|
||||||
);
|
);
|
||||||
const nextInput = { ...requestInput, credentials };
|
const nextInput = { ...requestInput, credentials };
|
||||||
|
const forcedEffort = getForcedReasoningEffort(credentials);
|
||||||
|
if (forcedEffort) {
|
||||||
|
const nextBody =
|
||||||
|
nextInput.body && typeof nextInput.body === "object"
|
||||||
|
? (nextInput.body as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
nextInput.body = {
|
||||||
|
...nextBody,
|
||||||
|
reasoning: {
|
||||||
|
...(nextBody.reasoning && typeof nextBody.reasoning === "object"
|
||||||
|
? nextBody.reasoning
|
||||||
|
: {}),
|
||||||
|
effort: forcedEffort,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (isCodexAppServerRequired(nextInput.credentials)) {
|
if (isCodexAppServerRequired(nextInput.credentials)) {
|
||||||
if (!this.appServer) {
|
if (!this.appServer) {
|
||||||
@@ -1380,8 +1397,13 @@ export class CodexExecutor extends BaseExecutor {
|
|||||||
// Issue #2331: model suffix aliases (for example gpt-5.5-xhigh) represent an
|
// Issue #2331: model suffix aliases (for example gpt-5.5-xhigh) represent an
|
||||||
// explicit model selection, so they must override client-injected defaults such
|
// explicit model selection, so they must override client-injected defaults such
|
||||||
// as OpenCode's automatic reasoning.effort=medium for GPT-5-family requests.
|
// as OpenCode's automatic reasoning.effort=medium for GPT-5-family requests.
|
||||||
|
// A server-selected force rule is stronger than either source.
|
||||||
const rawEffort =
|
const rawEffort =
|
||||||
modelEffort || explicitReasoning || requestReasoningEffort || fallbackReasoningEffort;
|
getForcedReasoningEffort(credentials) ||
|
||||||
|
modelEffort ||
|
||||||
|
explicitReasoning ||
|
||||||
|
requestReasoningEffort ||
|
||||||
|
fallbackReasoningEffort;
|
||||||
|
|
||||||
if (rawEffort) {
|
if (rawEffort) {
|
||||||
const clampedEffort = clampEffort(cleanModel, rawEffort);
|
const clampedEffort = clampEffort(cleanModel, rawEffort);
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ import { stripStore, usesClaudeBridge } from "./chatCore/agentRouterProtocol.ts"
|
|||||||
import { normalizeClaudeToolsForDispatch } from "./chatCore/claudeToolDefaults.ts";
|
import { normalizeClaudeToolsForDispatch } from "./chatCore/claudeToolDefaults.ts";
|
||||||
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
|
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
|
||||||
import { translateRequest, needsTranslation } from "../translator/index.ts";
|
import { translateRequest, needsTranslation } from "../translator/index.ts";
|
||||||
|
import { applyReasoningRuleDirective } from "@/lib/reasoningRouting/policy";
|
||||||
|
import { withReasoningRuleContext } from "../utils/reasoningRuleContext.ts";
|
||||||
import { FORMATS } from "../translator/formats.ts";
|
import { FORMATS } from "../translator/formats.ts";
|
||||||
import { collectCustomToolNamesForSourceFormat } from "../translator/request/openai-responses/additionalTools.ts";
|
import { collectCustomToolNamesForSourceFormat } from "../translator/request/openai-responses/additionalTools.ts";
|
||||||
import { sanitizeKiroTools } from "../utils/kiroSanitizer.ts";
|
import { sanitizeKiroTools } from "../utils/kiroSanitizer.ts";
|
||||||
@@ -499,6 +501,9 @@ export async function handleChatCore({
|
|||||||
fallbackAttempts = undefined,
|
fallbackAttempts = undefined,
|
||||||
}) {
|
}) {
|
||||||
let { provider, model, extendedContext } = modelInfo;
|
let { provider, model, extendedContext } = modelInfo;
|
||||||
|
// Keep the selected rule across format conversion, retries and refreshed credentials.
|
||||||
|
// Each combo leg gets its own execution context; nothing is written to shared accounts.
|
||||||
|
const reasoningRuleDirective = body?._omnirouteReasoningRule;
|
||||||
// #12150 P1b: true iff the video-bridge guardrail rendered >=1 transcript
|
// #12150 P1b: true iff the video-bridge guardrail rendered >=1 transcript
|
||||||
// cue into a replaced part of this request. Gates both request- and
|
// cue into a replaced part of this request. Gates both request- and
|
||||||
// response-derived Memory extraction
|
// response-derived Memory extraction
|
||||||
@@ -1214,6 +1219,22 @@ export async function handleChatCore({
|
|||||||
|
|
||||||
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
||||||
|
|
||||||
|
if (reasoningRuleDirective) {
|
||||||
|
// Cache identity must use the effective effort, not the overridden client value.
|
||||||
|
// Retain the directive for the translation step, where general thinking defaults run.
|
||||||
|
body = {
|
||||||
|
...(applyReasoningRuleDirective(
|
||||||
|
body,
|
||||||
|
sourceFormat === FORMATS.OPENAI_RESPONSES
|
||||||
|
? "openai-responses"
|
||||||
|
: sourceFormat === FORMATS.CLAUDE
|
||||||
|
? "claude"
|
||||||
|
: undefined
|
||||||
|
) as Record<string, unknown>),
|
||||||
|
_omnirouteReasoningRule: reasoningRuleDirective,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Preserve original body for cache signature — the body variable is mutated
|
// Preserve original body for cache signature — the body variable is mutated
|
||||||
// multiple times below (sanitization, memory/skills injection) before the
|
// multiple times below (sanitization, memory/skills injection) before the
|
||||||
// cache store path runs at Phase 9.1 (non-streaming) / Phase 9.2 (streaming).
|
// cache store path runs at Phase 9.1 (non-streaming) / Phase 9.2 (streaming).
|
||||||
@@ -2267,7 +2288,7 @@ export async function handleChatCore({
|
|||||||
try {
|
try {
|
||||||
if (nativeResponsesPassthrough) {
|
if (nativeResponsesPassthrough) {
|
||||||
translatedBody = stampNativeResponsesPassthroughBody(
|
translatedBody = stampNativeResponsesPassthroughBody(
|
||||||
body,
|
applyReasoningRuleDirective(body, "openai-responses") as Record<string, unknown>,
|
||||||
nativeCodexPassthrough
|
nativeCodexPassthrough
|
||||||
? "codex"
|
? "codex"
|
||||||
: nativeXaiResponsesPassthrough
|
: nativeXaiResponsesPassthrough
|
||||||
@@ -2288,6 +2309,10 @@ export async function handleChatCore({
|
|||||||
// Claude Code-compatible providers expect Anthropic Messages-shaped payloads,
|
// Claude Code-compatible providers expect Anthropic Messages-shaped payloads,
|
||||||
// but we extract only role/text/max_tokens/effort from an OpenAI-like view first.
|
// but we extract only role/text/max_tokens/effort from an OpenAI-like view first.
|
||||||
if (sourceFormat === FORMATS.CLAUDE && isClaudeCodeSemanticPassthrough) {
|
if (sourceFormat === FORMATS.CLAUDE && isClaudeCodeSemanticPassthrough) {
|
||||||
|
normalizedForCc = applyReasoningRuleDirective(
|
||||||
|
normalizedForCc,
|
||||||
|
"claude"
|
||||||
|
) as typeof normalizedForCc;
|
||||||
log?.debug?.("FORMAT", "claude-code semantic passthrough enabled for compatible bridge");
|
log?.debug?.("FORMAT", "claude-code semantic passthrough enabled for compatible bridge");
|
||||||
} else if (sourceFormat !== FORMATS.OPENAI) {
|
} else if (sourceFormat !== FORMATS.OPENAI) {
|
||||||
const normalizeToolCallId = getModelNormalizeToolCallId(
|
const normalizeToolCallId = getModelNormalizeToolCallId(
|
||||||
@@ -2323,6 +2348,10 @@ export async function handleChatCore({
|
|||||||
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
|
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
|
||||||
credentials?.providerSpecificData
|
credentials?.providerSpecificData
|
||||||
);
|
);
|
||||||
|
// OpenAI-shaped bridge requests skip translateRequest too.
|
||||||
|
if (sourceFormat === FORMATS.OPENAI) {
|
||||||
|
normalizedForCc = applyReasoningRuleDirective(normalizedForCc) as typeof normalizedForCc;
|
||||||
|
}
|
||||||
translatedBody = buildClaudeCodeCompatibleRequest({
|
translatedBody = buildClaudeCodeCompatibleRequest({
|
||||||
sourceBody: body,
|
sourceBody: body,
|
||||||
normalizedBody: normalizedForCc,
|
normalizedBody: normalizedForCc,
|
||||||
@@ -2352,7 +2381,7 @@ export async function handleChatCore({
|
|||||||
// payloads at high context (150+ msgs, 100+ tools). Fix: #1359.
|
// payloads at high context (150+ msgs, 100+ tools). Fix: #1359.
|
||||||
// Claude Code sends well-formed Messages API payloads — trust them
|
// Claude Code sends well-formed Messages API payloads — trust them
|
||||||
// regardless of combo strategy or cache_control settings.
|
// regardless of combo strategy or cache_control settings.
|
||||||
translatedBody = { ...body };
|
translatedBody = applyReasoningRuleDirective({ ...body }, "claude");
|
||||||
translatedBody._disableToolPrefix = true;
|
translatedBody._disableToolPrefix = true;
|
||||||
|
|
||||||
// Sanitize historical thinking-block signatures for Anthropic-native Claude OAuth.
|
// Sanitize historical thinking-block signatures for Anthropic-native Claude OAuth.
|
||||||
@@ -2996,15 +3025,18 @@ export async function handleChatCore({
|
|||||||
// Get executor for this provider (with optional upstream proxy routing)
|
// Get executor for this provider (with optional upstream proxy routing)
|
||||||
const executor = await resolveExecutorWithProxy(provider);
|
const executor = await resolveExecutorWithProxy(provider);
|
||||||
const getExecutionCredentials = () =>
|
const getExecutionCredentials = () =>
|
||||||
resolveExecutionCredentialsFor({
|
withReasoningRuleContext(
|
||||||
credentials,
|
resolveExecutionCredentialsFor({
|
||||||
nativeCodexPassthrough: nativeResponsesPassthrough,
|
credentials,
|
||||||
endpointPath,
|
nativeCodexPassthrough: nativeResponsesPassthrough,
|
||||||
targetFormat,
|
endpointPath,
|
||||||
provider,
|
targetFormat,
|
||||||
ccSessionId,
|
provider,
|
||||||
modelInfo,
|
ccSessionId,
|
||||||
});
|
modelInfo,
|
||||||
|
}),
|
||||||
|
reasoningRuleDirective
|
||||||
|
);
|
||||||
|
|
||||||
let onPipelineStreamError: streamFailure.PipelineStreamErrorHandler | null = null;
|
let onPipelineStreamError: streamFailure.PipelineStreamErrorHandler | null = null;
|
||||||
let onClientDisconnectFinalize:
|
let onClientDisconnectFinalize:
|
||||||
|
|||||||
28
open-sse/utils/reasoningRuleContext.ts
Normal file
28
open-sse/utils/reasoningRuleContext.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Request-local execution metadata, not a provider credential or a wire field.
|
||||||
|
// Symbol keys survive credential object spreads but cannot be supplied through JSON.
|
||||||
|
const FORCED_EFFORT = Symbol.for("omniroute.forcedReasoningEffort");
|
||||||
|
const EFFORTS = new Set(["none", "low", "medium", "high", "xhigh", "max", "ultra"]);
|
||||||
|
|
||||||
|
export function withReasoningRuleContext<T>(credentials: T, directive: unknown): T {
|
||||||
|
if (
|
||||||
|
!credentials ||
|
||||||
|
typeof credentials !== "object" ||
|
||||||
|
!directive ||
|
||||||
|
typeof directive !== "object"
|
||||||
|
)
|
||||||
|
return credentials;
|
||||||
|
const rule = directive as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
!rule.id ||
|
||||||
|
rule.effortMode !== "force" ||
|
||||||
|
typeof rule.targetEffort !== "string" ||
|
||||||
|
!EFFORTS.has(rule.targetEffort)
|
||||||
|
)
|
||||||
|
return credentials;
|
||||||
|
return { ...credentials, [FORCED_EFFORT]: rule.targetEffort };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getForcedReasoningEffort(credentials: unknown): string | undefined {
|
||||||
|
if (!credentials || typeof credentials !== "object") return undefined;
|
||||||
|
return (credentials as { [FORCED_EFFORT]?: string })[FORCED_EFFORT];
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { logger } from "@omniroute/open-sse/utils/logger.ts";
|
|||||||
import { resolveProxy } from "@omniroute/open-sse/utils/networkProxy.ts";
|
import { resolveProxy } from "@omniroute/open-sse/utils/networkProxy.ts";
|
||||||
import { withCodexFingerprintCredentials } from "@omniroute/open-sse/config/codexIdentity.ts";
|
import { withCodexFingerprintCredentials } from "@omniroute/open-sse/config/codexIdentity.ts";
|
||||||
import { proxyConfigToUrl } from "@omniroute/open-sse/utils/proxyDispatcher.ts";
|
import { proxyConfigToUrl } from "@omniroute/open-sse/utils/proxyDispatcher.ts";
|
||||||
|
import { withReasoningRuleContext } from "@omniroute/open-sse/utils/reasoningRuleContext.ts";
|
||||||
import {
|
import {
|
||||||
attachReasoningRuleDirective,
|
attachReasoningRuleDirective,
|
||||||
applyReasoningRuleDirective,
|
applyReasoningRuleDirective,
|
||||||
@@ -544,12 +545,17 @@ async function prepare(body: JsonRecord) {
|
|||||||
|
|
||||||
let responseBodyWithMemory = await maybeInjectResponsesWsMemory(responseBody, metadata);
|
let responseBodyWithMemory = await maybeInjectResponsesWsMemory(responseBody, metadata);
|
||||||
let reasoningRouting: JsonRecord | null = null;
|
let reasoningRouting: JsonRecord | null = null;
|
||||||
|
let reasoningRuleDirective: unknown;
|
||||||
if (reasoningDecision) {
|
if (reasoningDecision) {
|
||||||
const withDirective = attachReasoningRuleDirective(responseBodyWithMemory, reasoningDecision);
|
const withDirective = attachReasoningRuleDirective(responseBodyWithMemory, reasoningDecision);
|
||||||
|
reasoningRuleDirective = withDirective._omnirouteReasoningRule;
|
||||||
reasoningRouting = isRecord(withDirective._omnirouteReasoningRouteTrace)
|
reasoningRouting = isRecord(withDirective._omnirouteReasoningRouteTrace)
|
||||||
? withDirective._omnirouteReasoningRouteTrace
|
? withDirective._omnirouteReasoningRouteTrace
|
||||||
: null;
|
: null;
|
||||||
responseBodyWithMemory = applyReasoningRuleDirective(withDirective) as JsonRecord;
|
responseBodyWithMemory = applyReasoningRuleDirective(
|
||||||
|
withDirective,
|
||||||
|
"openai-responses"
|
||||||
|
) as JsonRecord;
|
||||||
delete responseBodyWithMemory._omnirouteReasoningRouteTrace;
|
delete responseBodyWithMemory._omnirouteReasoningRouteTrace;
|
||||||
}
|
}
|
||||||
// #8052: the WS bridge previously skipped the whole prompt-compression pipeline that the
|
// #8052: the WS bridge previously skipped the whole prompt-compression pipeline that the
|
||||||
@@ -561,7 +567,7 @@ async function prepare(body: JsonRecord) {
|
|||||||
requestId: randomUUID(),
|
requestId: randomUUID(),
|
||||||
});
|
});
|
||||||
const credentialsWithFingerprint = withCodexFingerprintCredentials(
|
const credentialsWithFingerprint = withCodexFingerprintCredentials(
|
||||||
refreshedCredentials,
|
withReasoningRuleContext(refreshedCredentials, reasoningRuleDirective),
|
||||||
context.clientHeaders,
|
context.clientHeaders,
|
||||||
responseBodyWithMemory
|
responseBodyWithMemory
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -531,7 +531,10 @@ export function attachReasoningRuleDirective(
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyReasoningRuleDirective(bodyInput: unknown): unknown {
|
export function applyReasoningRuleDirective(
|
||||||
|
bodyInput: unknown,
|
||||||
|
targetFormat?: "openai-responses" | "claude"
|
||||||
|
): unknown {
|
||||||
const source = asRecord(bodyInput);
|
const source = asRecord(bodyInput);
|
||||||
const directive = asRecord(source._omnirouteReasoningRule);
|
const directive = asRecord(source._omnirouteReasoningRule);
|
||||||
if (!directive.id) return bodyInput;
|
if (!directive.id) return bodyInput;
|
||||||
@@ -542,9 +545,11 @@ export function applyReasoningRuleDirective(bodyInput: unknown): unknown {
|
|||||||
if (effortMode === "force" && targetEffort === "none") clearReasoning(body);
|
if (effortMode === "force" && targetEffort === "none") clearReasoning(body);
|
||||||
else if ((effortMode === "force" || effortMode === "default") && targetEffort) {
|
else if ((effortMode === "force" || effortMode === "default") && targetEffort) {
|
||||||
if (effortMode === "force") clearDiscreteReasoning(body);
|
if (effortMode === "force") clearDiscreteReasoning(body);
|
||||||
body.reasoning_effort = targetEffort;
|
if (!targetFormat) body.reasoning_effort = targetEffort;
|
||||||
body.reasoning = { ...asRecord(body.reasoning), effort: targetEffort };
|
if (targetFormat !== "claude")
|
||||||
body.output_config = { ...asRecord(body.output_config), effort: targetEffort };
|
body.reasoning = { ...asRecord(body.reasoning), effort: targetEffort };
|
||||||
|
if (targetFormat !== "openai-responses")
|
||||||
|
body.output_config = { ...asRecord(body.output_config), effort: targetEffort };
|
||||||
}
|
}
|
||||||
applyBudget(
|
applyBudget(
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -448,6 +448,14 @@ async function handleChatImplementation(
|
|||||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only the server's policy resolver may attach execution directives or route traces.
|
||||||
|
// Discard lookalike JSON fields supplied by callers before evaluating any rule.
|
||||||
|
if (body && typeof body === "object") {
|
||||||
|
body = { ...body };
|
||||||
|
delete body._omnirouteReasoningRule;
|
||||||
|
delete body._omnirouteReasoningRouteTrace;
|
||||||
|
}
|
||||||
|
|
||||||
// Feature #6241: fold the canonical `effort` / `thinking` request params onto the
|
// Feature #6241: fold the canonical `effort` / `thinking` request params onto the
|
||||||
// per-provider reasoning fields (reasoning_effort / reasoning.effort / thinking) that the
|
// per-provider reasoning fields (reasoning_effort / reasoning.effort / thinking) that the
|
||||||
// existing translators already consume. Done here — right after the body is first
|
// existing translators already consume. Done here — right after the body is first
|
||||||
|
|||||||
389
tests/integration/reasoning-routing-reliability.test.ts
Normal file
389
tests/integration/reasoning-routing-reliability.test.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createChatPipelineHarness } from "./_chatPipelineHarness.ts";
|
||||||
|
|
||||||
|
const h = await createChatPipelineHarness("reasoning-reliability");
|
||||||
|
const providers = await import("../../src/lib/db/providers.ts");
|
||||||
|
const { flushProxyLogsSync } = await import("../../src/lib/proxyLogger.ts");
|
||||||
|
type RecordedBody = Record<string, unknown> & { reasoning?: { effort?: string } };
|
||||||
|
let calls: RecordedBody[] = [];
|
||||||
|
let firstConnectionId: string;
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await h.resetStorage();
|
||||||
|
h.BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
||||||
|
calls = [];
|
||||||
|
const connection = await providers.createProviderConnection({
|
||||||
|
provider: "codex",
|
||||||
|
authType: "oauth",
|
||||||
|
name: "Reasoning fixture",
|
||||||
|
accessToken: "fixture-access-token",
|
||||||
|
refreshToken: "fixture-refresh-token",
|
||||||
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||||
|
isActive: true,
|
||||||
|
testStatus: "active",
|
||||||
|
providerSpecificData: { requestDefaults: { reasoningEffort: "high" } },
|
||||||
|
});
|
||||||
|
firstConnectionId = String(connection.id);
|
||||||
|
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||||
|
assert.match(String(url), /chatgpt\.com\/backend-api\/codex\/responses/);
|
||||||
|
calls.push(JSON.parse(String(init.body)) as RecordedBody);
|
||||||
|
return new Response(
|
||||||
|
"data: " +
|
||||||
|
JSON.stringify({
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
id: "resp_fixture",
|
||||||
|
object: "response",
|
||||||
|
status: "completed",
|
||||||
|
model: "gpt-5.6-luna",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: "msg_fixture",
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "ok", annotations: [] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||||
|
},
|
||||||
|
}) +
|
||||||
|
"\n\ndata: [DONE]\n\n",
|
||||||
|
{ headers: { "Content-Type": "text/event-stream" } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
test.afterEach(async () => {
|
||||||
|
flushProxyLogsSync();
|
||||||
|
h.BaseExecutor.RETRY_CONFIG.delayMs = h.originalRetryDelayMs;
|
||||||
|
await h.resetStorage();
|
||||||
|
});
|
||||||
|
test.after(() => {
|
||||||
|
flushProxyLogsSync();
|
||||||
|
return h.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function forceLow(modelPattern: string, apiKeyId: string) {
|
||||||
|
return h.reasoningRulesDb.createReasoningRoutingRule({
|
||||||
|
name: "Force low",
|
||||||
|
description: "",
|
||||||
|
scope: "apiKey",
|
||||||
|
apiKeyId,
|
||||||
|
comboId: null,
|
||||||
|
connectionId: null,
|
||||||
|
modelPattern,
|
||||||
|
sourceEffort: "any",
|
||||||
|
requestTags: [],
|
||||||
|
tagMatchMode: "any",
|
||||||
|
effortMode: "force",
|
||||||
|
targetEffort: "low",
|
||||||
|
targetKind: "keep",
|
||||||
|
targetModel: null,
|
||||||
|
targetComboId: null,
|
||||||
|
budgetAction: "preserve",
|
||||||
|
budgetTokens: null,
|
||||||
|
priority: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("reliable reasoning: forced effort wins over explicit effort and adaptive header opt-in", async () => {
|
||||||
|
const key = await h.seedApiKey();
|
||||||
|
await forceLow("codex/gpt-5.6-luna", key.id);
|
||||||
|
const response = await h.handleChat(
|
||||||
|
h.buildRequest({
|
||||||
|
authKey: key.key,
|
||||||
|
headers: { "x-omniroute-effort": "auto" },
|
||||||
|
body: {
|
||||||
|
model: "codex/gpt-5.6-luna",
|
||||||
|
stream: false,
|
||||||
|
reasoning_effort: "high",
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "Prove the theorem step by step and verify every edge case." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await response.text();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(calls.at(-1)?.reasoning?.effort, "low");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reliable reasoning: explicit client effort survives adaptive header opt-in", async () => {
|
||||||
|
const key = await h.seedApiKey();
|
||||||
|
await providers.updateProviderConnection(firstConnectionId, { providerSpecificData: {} });
|
||||||
|
const response = await h.handleChat(
|
||||||
|
h.buildRequest({
|
||||||
|
authKey: key.key,
|
||||||
|
headers: { "x-omniroute-effort": "auto" },
|
||||||
|
body: {
|
||||||
|
model: "codex/gpt-5.6-luna",
|
||||||
|
stream: false,
|
||||||
|
reasoning_effort: "medium",
|
||||||
|
messages: [{ role: "user", content: "Reply ok." }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await response.text();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(calls.at(-1)?.reasoning?.effort, "medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reliable reasoning: native Responses honors key force without leaking cross-format fields", async () => {
|
||||||
|
const key = await h.seedApiKey();
|
||||||
|
await forceLow("codex/gpt-5.6-luna", key.id);
|
||||||
|
const input = [
|
||||||
|
{ type: "message", role: "user", content: [{ type: "input_text", text: "Reply ok" }] },
|
||||||
|
];
|
||||||
|
const response = await h.handleChat(
|
||||||
|
h.buildRequest({
|
||||||
|
url: "http://localhost/v1/responses",
|
||||||
|
authKey: key.key,
|
||||||
|
body: { model: "codex/gpt-5.6-luna", input, stream: false, reasoning: { effort: "high" } },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await response.text();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].reasoning?.effort, "low");
|
||||||
|
assert.deepEqual(calls[0].input, input);
|
||||||
|
for (const name of [
|
||||||
|
"_omnirouteReasoningRule",
|
||||||
|
"_omnirouteReasoningRouteTrace",
|
||||||
|
"output_config",
|
||||||
|
"reasoning_effort",
|
||||||
|
]) {
|
||||||
|
assert.equal(calls[0][name], undefined, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reliable reasoning: combo member effort suffix cannot override the selected key rule", async () => {
|
||||||
|
const key = await h.seedApiKey();
|
||||||
|
await h.combosDb.createCombo({
|
||||||
|
name: "luna-combo",
|
||||||
|
models: ["codex/gpt-5.6-luna-high"],
|
||||||
|
strategy: "priority",
|
||||||
|
});
|
||||||
|
await forceLow("luna-combo", key.id);
|
||||||
|
const response = await h.handleChat(
|
||||||
|
h.buildRequest({
|
||||||
|
authKey: key.key,
|
||||||
|
body: {
|
||||||
|
model: "luna-combo",
|
||||||
|
stream: false,
|
||||||
|
reasoning_effort: "high",
|
||||||
|
messages: [{ role: "user", content: "Reply ok" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await response.text();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].model, "gpt-5.6-luna");
|
||||||
|
assert.equal(calls[0].reasoning?.effort, "low");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reliable reasoning: client metadata cannot impersonate an administrator rule", async () => {
|
||||||
|
const response = await h.handleChat(
|
||||||
|
h.buildRequest({
|
||||||
|
body: {
|
||||||
|
model: "codex/gpt-5.6-luna",
|
||||||
|
stream: false,
|
||||||
|
reasoning_effort: "high",
|
||||||
|
messages: [{ role: "user", content: "Reply ok" }],
|
||||||
|
_omnirouteReasoningRule: { id: "forged", effortMode: "force", targetEffort: "low" },
|
||||||
|
_omnirouteReasoningRouteTrace: { ruleId: "forged" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await response.text();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(calls[0].reasoning?.effort, "high");
|
||||||
|
assert.equal(calls[0]._omnirouteReasoningRule, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reliable reasoning: fallback attempts retain the forced effort", async () => {
|
||||||
|
const key = await h.seedApiKey();
|
||||||
|
const second = await providers.createProviderConnection({
|
||||||
|
provider: "codex",
|
||||||
|
authType: "oauth",
|
||||||
|
name: "Fallback fixture",
|
||||||
|
accessToken: "fixture-fallback-token",
|
||||||
|
isActive: true,
|
||||||
|
testStatus: "active",
|
||||||
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||||
|
providerSpecificData: {},
|
||||||
|
});
|
||||||
|
await h.combosDb.createCombo({
|
||||||
|
name: "fallback-combo",
|
||||||
|
models: [
|
||||||
|
{ model: "codex/gpt-5.6-luna-high", connectionId: firstConnectionId },
|
||||||
|
{ model: "codex/gpt-5.5-xhigh", connectionId: String(second.id) },
|
||||||
|
],
|
||||||
|
strategy: "priority",
|
||||||
|
});
|
||||||
|
await forceLow("fallback-combo", key.id);
|
||||||
|
const successFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||||
|
const body = JSON.parse(String(init.body)) as RecordedBody;
|
||||||
|
if (body.model === "gpt-5.6-luna") {
|
||||||
|
calls.push(body);
|
||||||
|
return new Response(JSON.stringify({ error: { message: "Fixture unavailable" } }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return successFetch(url, init);
|
||||||
|
};
|
||||||
|
const response = await h.handleChat(
|
||||||
|
h.buildRequest({
|
||||||
|
authKey: key.key,
|
||||||
|
body: {
|
||||||
|
model: "fallback-combo",
|
||||||
|
stream: false,
|
||||||
|
reasoning_effort: "high",
|
||||||
|
messages: [{ role: "user", content: "Reply ok" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await response.text();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.ok(calls.length >= 2);
|
||||||
|
assert.equal(calls.at(-1)?.model, "gpt-5.5");
|
||||||
|
assert.ok(calls.every((call) => call.reasoning?.effort === "low"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reliable reasoning: force is request-local and does not affect another API key", async () => {
|
||||||
|
const restricted = await h.seedApiKey({ name: "Forced key" });
|
||||||
|
const ordinary = await h.seedApiKey({ name: "Ordinary key" });
|
||||||
|
await forceLow("codex/gpt-5.6-luna", restricted.id);
|
||||||
|
for (const key of [restricted, ordinary]) {
|
||||||
|
const response = await h.handleChat(
|
||||||
|
h.buildRequest({
|
||||||
|
authKey: key.key,
|
||||||
|
body: {
|
||||||
|
model: "codex/gpt-5.6-luna",
|
||||||
|
stream: false,
|
||||||
|
reasoning_effort: "high",
|
||||||
|
messages: [{ role: "user", content: "Reply " + key.id }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await response.text();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map((call) => call.reasoning?.effort),
|
||||||
|
["low", "high"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reliable reasoning: force none survives Codex account defaults", async () => {
|
||||||
|
const key = await h.seedApiKey();
|
||||||
|
const rule = await forceLow("codex/gpt-5.6-luna", key.id);
|
||||||
|
await h.reasoningRulesDb.updateReasoningRoutingRule(rule.id, { targetEffort: "none" });
|
||||||
|
const response = await h.handleChat(
|
||||||
|
h.buildRequest({
|
||||||
|
authKey: key.key,
|
||||||
|
url: "http://localhost/v1/responses",
|
||||||
|
body: {
|
||||||
|
model: "codex/gpt-5.6-luna",
|
||||||
|
input: "Reply ok",
|
||||||
|
stream: false,
|
||||||
|
reasoning: { effort: "high" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await response.text();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(calls[0].reasoning?.effort, "none");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reliable reasoning: native default applies only when client effort is missing", async () => {
|
||||||
|
const key = await h.seedApiKey();
|
||||||
|
const rule = await forceLow("codex/gpt-5.6-luna", key.id);
|
||||||
|
await h.reasoningRulesDb.updateReasoningRoutingRule(rule.id, { effortMode: "default" });
|
||||||
|
for (const effort of [undefined, "high"]) {
|
||||||
|
const response = await h.handleChat(
|
||||||
|
h.buildRequest({
|
||||||
|
url: "http://localhost/v1/responses",
|
||||||
|
authKey: key.key,
|
||||||
|
body: {
|
||||||
|
model: "codex/gpt-5.6-luna",
|
||||||
|
input: "Default " + effort,
|
||||||
|
stream: false,
|
||||||
|
...(effort ? { reasoning: { effort } } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await response.text();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map((call) => call.reasoning?.effort),
|
||||||
|
["low", "high"]
|
||||||
|
);
|
||||||
|
assert.ok(calls.every((call) => call.output_config === undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reliable reasoning: WebSocket prepare preserves force in the final Codex payload", async () => {
|
||||||
|
process.env.OMNIROUTE_WS_BRIDGE_SECRET = "fixture-bridge-secret";
|
||||||
|
const { POST } = await import("../../src/app/api/internal/codex-responses-ws/route.ts");
|
||||||
|
const key = await h.seedApiKey();
|
||||||
|
await forceLow("codex/gpt-5.6-luna", key.id);
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://localhost/api/internal/codex-responses-ws", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-omniroute-ws-bridge-secret": "fixture-bridge-secret",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "prepare",
|
||||||
|
requestUrl: "http://localhost/v1/responses",
|
||||||
|
headers: { authorization: "Bearer " + key.key },
|
||||||
|
response: {
|
||||||
|
model: "codex/gpt-5.6-luna-high",
|
||||||
|
input: "Reply ok",
|
||||||
|
reasoning: { effort: "high" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(data.response.reasoning.effort, "low");
|
||||||
|
assert.equal(data.response.output_config, undefined);
|
||||||
|
assert.equal(data.response._omnirouteReasoningRule, undefined);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reliable reasoning: native Anthropic applies effort without OpenAI wire fields", async () => {
|
||||||
|
await h.seedConnection("anthropic");
|
||||||
|
const key = await h.seedApiKey();
|
||||||
|
await forceLow("anthropic/claude-sonnet-4-6", key.id);
|
||||||
|
globalThis.fetch = async (_url, init: RequestInit = {}) => {
|
||||||
|
calls.push(JSON.parse(String(init.body)));
|
||||||
|
return h.buildClaudeResponse("ok");
|
||||||
|
};
|
||||||
|
const response = await h.handleChat(
|
||||||
|
h.buildRequest({
|
||||||
|
url: "http://localhost/v1/messages",
|
||||||
|
authKey: key.key,
|
||||||
|
body: {
|
||||||
|
model: "anthropic/claude-sonnet-4-6",
|
||||||
|
max_tokens: 128,
|
||||||
|
stream: false,
|
||||||
|
thinking: { type: "adaptive" },
|
||||||
|
output_config: { effort: "high" },
|
||||||
|
messages: [{ role: "user", content: "Reply ok" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await response.text();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal((calls[0].output_config as { effort?: string })?.effort, "low");
|
||||||
|
assert.equal(calls[0].reasoning, undefined);
|
||||||
|
assert.equal(calls[0].reasoning_effort, undefined);
|
||||||
|
assert.equal(calls[0]._omnirouteReasoningRule, undefined);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user