From f1e7148c192a638783ac01afccaf8b6c94ed180c Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Wed, 16 Sep 2026 22:01:51 +0200 Subject: [PATCH] fix(routing): preserve reasoning overrides across transports and fallbacks (#13556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fixes/13556-reasoning-rule-propagation.md | 1 + open-sse/executors/codex.ts | 24 +- open-sse/handlers/chatCore.ts | 54 ++- open-sse/utils/reasoningRuleContext.ts | 28 ++ .../api/internal/codex-responses-ws/route.ts | 10 +- src/lib/reasoningRouting/policy.ts | 13 +- src/sse/handlers/chat.ts | 8 + .../reasoning-routing-reliability.test.ts | 389 ++++++++++++++++++ 8 files changed, 509 insertions(+), 18 deletions(-) create mode 100644 changelog.d/fixes/13556-reasoning-rule-propagation.md create mode 100644 open-sse/utils/reasoningRuleContext.ts create mode 100644 tests/integration/reasoning-routing-reliability.test.ts diff --git a/changelog.d/fixes/13556-reasoning-rule-propagation.md b/changelog.d/fixes/13556-reasoning-rule-propagation.md new file mode 100644 index 0000000000..c49c7a6a96 --- /dev/null +++ b/changelog.d/fixes/13556-reasoning-rule-propagation.md @@ -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 diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index c7b3206b32..644a67c08b 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -35,6 +35,7 @@ import { import { getAccessToken } from "../services/tokenRefresh.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts"; +import { getForcedReasoningEffort } from "../utils/reasoningRuleContext.ts"; import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; @@ -819,6 +820,22 @@ export class CodexExecutor extends BaseExecutor { requestInput.body ); const nextInput = { ...requestInput, credentials }; + const forcedEffort = getForcedReasoningEffort(credentials); + if (forcedEffort) { + const nextBody = + nextInput.body && typeof nextInput.body === "object" + ? (nextInput.body as Record) + : {}; + nextInput.body = { + ...nextBody, + reasoning: { + ...(nextBody.reasoning && typeof nextBody.reasoning === "object" + ? nextBody.reasoning + : {}), + effort: forcedEffort, + }, + }; + } if (isCodexAppServerRequired(nextInput.credentials)) { 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 // explicit model selection, so they must override client-injected defaults such // as OpenCode's automatic reasoning.effort=medium for GPT-5-family requests. + // A server-selected force rule is stronger than either source. const rawEffort = - modelEffort || explicitReasoning || requestReasoningEffort || fallbackReasoningEffort; + getForcedReasoningEffort(credentials) || + modelEffort || + explicitReasoning || + requestReasoningEffort || + fallbackReasoningEffort; if (rawEffort) { const clampedEffort = clampEffort(cleanModel, rawEffort); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 45c0c134db..aa830bc91a 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -125,6 +125,8 @@ import { stripStore, usesClaudeBridge } from "./chatCore/agentRouterProtocol.ts" import { normalizeClaudeToolsForDispatch } from "./chatCore/claudeToolDefaults.ts"; import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.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 { collectCustomToolNamesForSourceFormat } from "../translator/request/openai-responses/additionalTools.ts"; import { sanitizeKiroTools } from "../utils/kiroSanitizer.ts"; @@ -499,6 +501,9 @@ export async function handleChatCore({ fallbackAttempts = undefined, }) { 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 // cue into a replaced part of this request. Gates both request- and // response-derived Memory extraction @@ -1214,6 +1219,22 @@ export async function handleChatCore({ 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), + _omnirouteReasoningRule: reasoningRuleDirective, + }; + } + // Preserve original body for cache signature — the body variable is mutated // multiple times below (sanitization, memory/skills injection) before the // cache store path runs at Phase 9.1 (non-streaming) / Phase 9.2 (streaming). @@ -2267,7 +2288,7 @@ export async function handleChatCore({ try { if (nativeResponsesPassthrough) { translatedBody = stampNativeResponsesPassthroughBody( - body, + applyReasoningRuleDirective(body, "openai-responses") as Record, nativeCodexPassthrough ? "codex" : nativeXaiResponsesPassthrough @@ -2288,6 +2309,10 @@ export async function handleChatCore({ // Claude Code-compatible providers expect Anthropic Messages-shaped payloads, // but we extract only role/text/max_tokens/effort from an OpenAI-like view first. if (sourceFormat === FORMATS.CLAUDE && isClaudeCodeSemanticPassthrough) { + normalizedForCc = applyReasoningRuleDirective( + normalizedForCc, + "claude" + ) as typeof normalizedForCc; log?.debug?.("FORMAT", "claude-code semantic passthrough enabled for compatible bridge"); } else if (sourceFormat !== FORMATS.OPENAI) { const normalizeToolCallId = getModelNormalizeToolCallId( @@ -2323,6 +2348,10 @@ export async function handleChatCore({ const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults( credentials?.providerSpecificData ); + // OpenAI-shaped bridge requests skip translateRequest too. + if (sourceFormat === FORMATS.OPENAI) { + normalizedForCc = applyReasoningRuleDirective(normalizedForCc) as typeof normalizedForCc; + } translatedBody = buildClaudeCodeCompatibleRequest({ sourceBody: body, normalizedBody: normalizedForCc, @@ -2352,7 +2381,7 @@ export async function handleChatCore({ // payloads at high context (150+ msgs, 100+ tools). Fix: #1359. // Claude Code sends well-formed Messages API payloads — trust them // regardless of combo strategy or cache_control settings. - translatedBody = { ...body }; + translatedBody = applyReasoningRuleDirective({ ...body }, "claude"); translatedBody._disableToolPrefix = true; // 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) const executor = await resolveExecutorWithProxy(provider); const getExecutionCredentials = () => - resolveExecutionCredentialsFor({ - credentials, - nativeCodexPassthrough: nativeResponsesPassthrough, - endpointPath, - targetFormat, - provider, - ccSessionId, - modelInfo, - }); + withReasoningRuleContext( + resolveExecutionCredentialsFor({ + credentials, + nativeCodexPassthrough: nativeResponsesPassthrough, + endpointPath, + targetFormat, + provider, + ccSessionId, + modelInfo, + }), + reasoningRuleDirective + ); let onPipelineStreamError: streamFailure.PipelineStreamErrorHandler | null = null; let onClientDisconnectFinalize: diff --git a/open-sse/utils/reasoningRuleContext.ts b/open-sse/utils/reasoningRuleContext.ts new file mode 100644 index 0000000000..7302d3902f --- /dev/null +++ b/open-sse/utils/reasoningRuleContext.ts @@ -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(credentials: T, directive: unknown): T { + if ( + !credentials || + typeof credentials !== "object" || + !directive || + typeof directive !== "object" + ) + return credentials; + const rule = directive as Record; + 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]; +} diff --git a/src/app/api/internal/codex-responses-ws/route.ts b/src/app/api/internal/codex-responses-ws/route.ts index cbb8d652d9..4b26af0316 100644 --- a/src/app/api/internal/codex-responses-ws/route.ts +++ b/src/app/api/internal/codex-responses-ws/route.ts @@ -23,6 +23,7 @@ import { logger } from "@omniroute/open-sse/utils/logger.ts"; import { resolveProxy } from "@omniroute/open-sse/utils/networkProxy.ts"; import { withCodexFingerprintCredentials } from "@omniroute/open-sse/config/codexIdentity.ts"; import { proxyConfigToUrl } from "@omniroute/open-sse/utils/proxyDispatcher.ts"; +import { withReasoningRuleContext } from "@omniroute/open-sse/utils/reasoningRuleContext.ts"; import { attachReasoningRuleDirective, applyReasoningRuleDirective, @@ -544,12 +545,17 @@ async function prepare(body: JsonRecord) { let responseBodyWithMemory = await maybeInjectResponsesWsMemory(responseBody, metadata); let reasoningRouting: JsonRecord | null = null; + let reasoningRuleDirective: unknown; if (reasoningDecision) { const withDirective = attachReasoningRuleDirective(responseBodyWithMemory, reasoningDecision); + reasoningRuleDirective = withDirective._omnirouteReasoningRule; reasoningRouting = isRecord(withDirective._omnirouteReasoningRouteTrace) ? withDirective._omnirouteReasoningRouteTrace : null; - responseBodyWithMemory = applyReasoningRuleDirective(withDirective) as JsonRecord; + responseBodyWithMemory = applyReasoningRuleDirective( + withDirective, + "openai-responses" + ) as JsonRecord; delete responseBodyWithMemory._omnirouteReasoningRouteTrace; } // #8052: the WS bridge previously skipped the whole prompt-compression pipeline that the @@ -561,7 +567,7 @@ async function prepare(body: JsonRecord) { requestId: randomUUID(), }); const credentialsWithFingerprint = withCodexFingerprintCredentials( - refreshedCredentials, + withReasoningRuleContext(refreshedCredentials, reasoningRuleDirective), context.clientHeaders, responseBodyWithMemory ); diff --git a/src/lib/reasoningRouting/policy.ts b/src/lib/reasoningRouting/policy.ts index 54647a9bad..1dd31ddff0 100644 --- a/src/lib/reasoningRouting/policy.ts +++ b/src/lib/reasoningRouting/policy.ts @@ -531,7 +531,10 @@ export function attachReasoningRuleDirective( return body; } -export function applyReasoningRuleDirective(bodyInput: unknown): unknown { +export function applyReasoningRuleDirective( + bodyInput: unknown, + targetFormat?: "openai-responses" | "claude" +): unknown { const source = asRecord(bodyInput); const directive = asRecord(source._omnirouteReasoningRule); if (!directive.id) return bodyInput; @@ -542,9 +545,11 @@ export function applyReasoningRuleDirective(bodyInput: unknown): unknown { if (effortMode === "force" && targetEffort === "none") clearReasoning(body); else if ((effortMode === "force" || effortMode === "default") && targetEffort) { if (effortMode === "force") clearDiscreteReasoning(body); - body.reasoning_effort = targetEffort; - body.reasoning = { ...asRecord(body.reasoning), effort: targetEffort }; - body.output_config = { ...asRecord(body.output_config), effort: targetEffort }; + if (!targetFormat) body.reasoning_effort = targetEffort; + if (targetFormat !== "claude") + body.reasoning = { ...asRecord(body.reasoning), effort: targetEffort }; + if (targetFormat !== "openai-responses") + body.output_config = { ...asRecord(body.output_config), effort: targetEffort }; } applyBudget( body, diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 3613e05071..81d4006a51 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -448,6 +448,14 @@ async function handleChatImplementation( 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 // per-provider reasoning fields (reasoning_effort / reasoning.effort / thinking) that the // existing translators already consume. Done here — right after the body is first diff --git a/tests/integration/reasoning-routing-reliability.test.ts b/tests/integration/reasoning-routing-reliability.test.ts new file mode 100644 index 0000000000..ef09e00729 --- /dev/null +++ b/tests/integration/reasoning-routing-reliability.test.ts @@ -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 & { 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); +});