From 74c006e2456f60c2a08c1bb04caf870f5fc3f476 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Sun, 19 Jul 2026 03:09:47 +0200 Subject: [PATCH] Add reasoning-based model and effort routing (#7607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(routing): add reasoning-based model and effort routing * refactor(routing): modularize reasoning and auto-routing pipeline * fix(routing): remove redundant DB re-export and prevent SQL scan false positives * fix(routing): resolve reasoning routing review blockers * fix(i18n): keep release ranking fallbacks outside reasoning * fix(db): renumber reasoning-routing migration past release tip (124→125) 124_generic_session_affinity_ttl.sql (#7274) has since landed on release/v3.8.49 at version 124, colliding with this PR's own 124_reasoning_routing_rules.sql. Renumbers to 125 (the next free slot past the current release tip) and updates the one filename reference in docs/routing/REASONING_ROUTING.md. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(db): renumber reasoning-routing migration 125→126 (slot taken by #7360) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(api): compact temp-path decls in exportAll GET (complexity-ratchet lines budget) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(api): single-statement auth guard in exportAll GET (function under 80-line cap) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- config/quality/eslint-suppressions.json | 5 - docs/routing/REASONING_ROUTING.md | 74 ++ open-sse/handlers/chatCore.ts | 9 + open-sse/handlers/chatCore/attemptLogging.ts | 7 +- open-sse/translator/index.ts | 4 + open-sse/utils/requestLogger.ts | 12 +- package.json | 2 +- scripts/check/check-db-rules.mjs | 7 +- scripts/dev/responses-ws-proxy.mjs | 6 + .../api-manager/ApiManagerPageClient.tsx | 3 + .../dashboard/settings/routing/page.tsx | 2 + src/app/api/db-backups/exportAll/route.ts | 11 +- .../internal/codex-responses-ws/history.ts | 253 +++++++ .../api/internal/codex-responses-ws/route.ts | 431 ++++++----- .../reasoning-routing-rules/[id]/route.ts | 56 ++ .../settings/reasoning-routing-rules/route.ts | 30 + .../reasoning-routing-rules/simulate/route.ts | 108 +++ src/i18n/messages/ar.json | 77 ++ src/i18n/messages/az.json | 77 ++ src/i18n/messages/bg.json | 77 ++ src/i18n/messages/bn.json | 77 ++ src/i18n/messages/cs.json | 77 ++ src/i18n/messages/da.json | 77 ++ src/i18n/messages/de.json | 77 ++ src/i18n/messages/en.json | 77 ++ src/i18n/messages/es.json | 77 ++ src/i18n/messages/fa.json | 77 ++ src/i18n/messages/fi.json | 77 ++ src/i18n/messages/fr.json | 77 ++ src/i18n/messages/gu.json | 77 ++ src/i18n/messages/he.json | 77 ++ src/i18n/messages/hi.json | 77 ++ src/i18n/messages/hu.json | 77 ++ src/i18n/messages/id.json | 77 ++ src/i18n/messages/in.json | 77 ++ src/i18n/messages/it.json | 77 ++ src/i18n/messages/ja.json | 94 ++- src/i18n/messages/ko.json | 94 ++- src/i18n/messages/mr.json | 94 ++- src/i18n/messages/ms.json | 94 ++- src/i18n/messages/nl.json | 94 ++- src/i18n/messages/no.json | 94 ++- src/i18n/messages/phi.json | 94 ++- src/i18n/messages/pl.json | 94 ++- src/i18n/messages/pt-BR.json | 77 ++ src/i18n/messages/pt.json | 94 ++- src/i18n/messages/ro.json | 94 ++- src/i18n/messages/ru.json | 94 ++- src/i18n/messages/sk.json | 94 ++- src/i18n/messages/sv.json | 94 ++- src/i18n/messages/sw.json | 77 ++ src/i18n/messages/ta.json | 77 ++ src/i18n/messages/te.json | 77 ++ src/i18n/messages/th.json | 77 ++ src/i18n/messages/tr.json | 77 ++ src/i18n/messages/uk-UA.json | 77 ++ src/i18n/messages/ur.json | 77 ++ src/i18n/messages/vi.json | 77 ++ src/i18n/messages/zh-CN.json | 77 ++ src/i18n/messages/zh-TW.json | 77 ++ src/lib/copilot/codegraphKnowledge.ts | 9 + src/lib/db/apiKeys.ts | 27 +- src/lib/db/backup.ts | 17 +- src/lib/db/combos.ts | 2 + .../126_reasoning_routing_rules.sql | 56 ++ src/lib/db/providers.ts | 5 + src/lib/db/reasoningRoutingRules.ts | 243 +++++++ src/lib/localDb.ts | 2 +- src/lib/reasoningRouting/input.ts | 54 ++ src/lib/reasoningRouting/policy.ts | 602 +++++++++++++++ src/lib/sync/bundle.ts | 95 ++- src/lib/usage/routeExplain.ts | 8 + src/mitm/dns/dnsConfig.ts | 55 +- .../components/ReasoningRoutingRules.tsx | 588 +++++++++++++++ src/shared/components/index.tsx | 1 + src/shared/utils/apiKeyPolicy.ts | 684 +++++++++--------- src/shared/validation/schemas.ts | 1 + .../validation/schemas/reasoningRouting.ts | 98 +++ src/sse/handlers/autoRouting.ts | 133 ++++ src/sse/handlers/chat.ts | 168 ++--- src/sse/handlers/reasoningRouting.ts | 235 ++++++ tests/integration/_chatPipelineCallLogs.ts | 11 + tests/integration/_chatPipelineHarness.ts | 75 +- tests/integration/chat-pipeline.test.ts | 17 +- .../reasoning-routing-pipeline.test.ts | 440 +++++++++++ .../apikeypolicy-disable-non-public.test.ts | 20 + .../codex-ws-policy-enforcement-6564.test.ts | 80 ++ .../unit/copilot-codegraph-knowledge.test.ts | 44 +- tests/unit/dns-config-generic.test.ts | 23 +- tests/unit/reasoning-routing-api.test.ts | 150 ++++ tests/unit/reasoning-routing.test.ts | 239 ++++++ tests/unit/sync-bundle.test.ts | 24 + 92 files changed, 7887 insertions(+), 871 deletions(-) create mode 100644 docs/routing/REASONING_ROUTING.md create mode 100644 src/app/api/internal/codex-responses-ws/history.ts create mode 100644 src/app/api/settings/reasoning-routing-rules/[id]/route.ts create mode 100644 src/app/api/settings/reasoning-routing-rules/route.ts create mode 100644 src/app/api/settings/reasoning-routing-rules/simulate/route.ts create mode 100644 src/lib/db/migrations/126_reasoning_routing_rules.sql create mode 100644 src/lib/db/reasoningRoutingRules.ts create mode 100644 src/lib/reasoningRouting/input.ts create mode 100644 src/lib/reasoningRouting/policy.ts create mode 100644 src/shared/components/ReasoningRoutingRules.tsx create mode 100644 src/shared/validation/schemas/reasoningRouting.ts create mode 100644 src/sse/handlers/autoRouting.ts create mode 100644 src/sse/handlers/reasoningRouting.ts create mode 100644 tests/integration/_chatPipelineCallLogs.ts create mode 100644 tests/integration/reasoning-routing-pipeline.test.ts create mode 100644 tests/unit/reasoning-routing-api.test.ts create mode 100644 tests/unit/reasoning-routing.test.ts diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 676fd1d9f3..73daf5a475 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -327,11 +327,6 @@ "count": 2 } }, - "tests/integration/_chatPipelineHarness.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, "tests/integration/_comboRoutingHarness.ts": { "@typescript-eslint/no-explicit-any": { "count": 3 diff --git a/docs/routing/REASONING_ROUTING.md b/docs/routing/REASONING_ROUTING.md new file mode 100644 index 0000000000..c8ffaed693 --- /dev/null +++ b/docs/routing/REASONING_ROUTING.md @@ -0,0 +1,74 @@ +# Reasoning Routing + +Reasoning routing rules extend the existing model and combo routing. When no active rule matches, +the existing thinking, suffix, connection-default, and provider-translation behavior remains +unchanged. + +## Management + +Rule management is available under **Settings → Global Routing**. The API-key editor provides the +same management UI filtered to the selected key. + +The management API is exposed by these routes: + +- `GET` and `POST` at `/api/settings/reasoning-routing-rules` +- `GET`, `PATCH`, and `DELETE` at `/api/settings/reasoning-routing-rules/[id]` +- `POST` at `/api/settings/reasoning-routing-rules/simulate` + +All routes use `requireManagementAuth`. Inputs are validated with the schemas in +`src/shared/validation/schemas/reasoningRouting.ts`. The simulator never makes an upstream call. + +## Rule Resolution + +The early evaluation selects exactly one rule. Scopes are checked in this order: + +1. `apiKey` +2. `combo` +3. `model` +4. `global` + +Within a scope, higher `priority` wins first, followed by an exact model match over a glob pattern, +then stable `createdAt` and `id` ordering. `requestTags` are read exclusively from `metadata.tags` +and support `any` or `all` matching. + +A `connection` rule is evaluated only when no early rule won and a concrete provider connection has +already been selected. It may change effort and budget only. + +## Effort and Budget + +`sourceEffort` accepts `any`, `missing`, `none`, `low`, `medium`, `high`, `xhigh`, `max`, and +`ultra`. `missing` means that the request contains neither a discrete effort nor a thinking toggle +or thinking budget. A budget-only signal is therefore matched only by `any`. + +`effortMode` has three variants: + +- `inherit` keeps the client effort while still allowing the model or combo to change. +- `default` sets `targetEffort` only when no explicit reasoning signal is present. +- `force` replaces the discrete effort with `targetEffort`. + +Independently, `budgetAction` can be `preserve`, `remove`, or `set`. `force` with `none` removes +all recognized effort and budget fields. `none` together with `set` is invalid. + +Requests targeting known-incompatible models are rejected before the upstream call. For combo +targets, incompatible entries are removed; if none remain, the request returns status `400`. +Unknown capability data produces a warning and leaves the rule active. + +## Security and Transports + +The source and target model, or source and target combo, remain subject to the existing API-key +policy. A reasoning rule never expands model, combo, or quota permissions. + +The engine is integrated into Chat Completions, Responses, Anthropic Messages, and the internal +Codex WebSocket path. The WebSocket path accepts Codex target models only; combo targets cannot be +executed there. The rule decision is stored in the existing route trace without secrets. + +## Persistence + +The migration `src/lib/db/migrations/125_reasoning_routing_rules.sql` creates the +`reasoning_routing_rules` table. Rules reference stored API keys, combos, and provider connections. +Deletes clean up related rules. The database access layer in +`src/lib/db/reasoningRoutingRules.ts` maintains an invalidatable cache for the request path. + +Rules are included in SQLite backups, the full database export, and the config-sync bundle. +`reconcileReasoningRulesForSync` disables imported rules with missing references and reports those +conflicts. diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 4b90ea0f7e..d717e50559 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -965,6 +965,15 @@ export async function handleChatCore({ clientRawRequest.headers ); } + const reasoningRouteDecision = + body && typeof body === "object" + ? (body as Record)._omnirouteReasoningRouteTrace + : null; + if (reasoningRouteDecision) { + reqLogger.logRouteDecision(reasoningRouteDecision); + body = { ...(body as Record) }; + delete (body as Record)._omnirouteReasoningRouteTrace; + } log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`); diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index 9db3efbc74..40b8f8531a 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -138,9 +138,12 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt }); } + const capturedPipeline = reqLogger?.getPipelinePayloads?.() ?? null; const pipelinePayloads = detailedLoggingEnabled - ? (reqLogger?.getPipelinePayloads?.() ?? {}) - : null; + ? (capturedPipeline ?? {}) + : capturedPipeline?.routeDecision + ? { routeDecision: capturedPipeline.routeDecision } + : null; if (pipelinePayloads) { if (providerRequest !== undefined && !pipelinePayloads.providerRequest) { diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 3a4eaf9b1f..559afeccce 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -24,6 +24,7 @@ import { getRequestTranslator, getResponseTranslator } from "./registry.ts"; import { bootstrapTranslatorRegistry } from "./bootstrap.ts"; import { hasThinkingConfig, normalizeThinkingConfig } from "../services/provider.ts"; import { applyThinkingBudget } from "../services/thinkingBudget.ts"; +import { applyReasoningRuleDirective } from "@/lib/reasoningRouting/policy"; import { getResolvedModelCapabilities, supportsReasoning } from "../services/modelCapabilities.ts"; import { normalizeRoles } from "../services/roleNormalizer.ts"; import { hoistLeadingSystemMessage } from "./helpers/strictSystemHoist.ts"; @@ -210,6 +211,9 @@ export function translateRequest( // Phase 2: Apply thinking budget control before normalization result = applyThinkingBudget(result); + // Explicit reasoning-routing policies are final. The marker is internal and is + // consumed here before any provider translation can see it. + result = applyReasoningRuleDirective(result); // Normalize thinking config: remove if lastMessage is not user normalizeThinkingConfig(result); diff --git a/open-sse/utils/requestLogger.ts b/open-sse/utils/requestLogger.ts index 350d50e4f2..4c8dc3f660 100644 --- a/open-sse/utils/requestLogger.ts +++ b/open-sse/utils/requestLogger.ts @@ -11,6 +11,7 @@ type HeaderInput = | undefined; export type RequestPipelinePayloads = { + routeDecision?: JsonRecord; clientRawRequest?: JsonRecord; openaiRequest?: JsonRecord; providerRequest?: JsonRecord; @@ -27,6 +28,7 @@ export type RequestPipelinePayloads = { type RequestLogger = { sessionPath: null; logClientRawRequest: (endpoint: unknown, body: unknown, headers?: HeaderInput) => void; + logRouteDecision: (decision: unknown) => void; logOpenAIRequest: (body: unknown) => void; logTargetRequest: (url: unknown, headers: HeaderInput, body: unknown) => void; logProviderResponse: ( @@ -309,9 +311,13 @@ export async function createRequestLogger( const chunkMethods = makeStreamChunkMethods(options, captureStreamChunks); if (options.enabled === false) { + let routeDecision: JsonRecord | null = null; return { sessionPath: null, logClientRawRequest() {}, + logRouteDecision(decision) { + routeDecision = cloneBoundedForLog(decision) as JsonRecord; + }, logOpenAIRequest() {}, logTargetRequest() {}, logProviderResponse() {}, @@ -321,7 +327,7 @@ export async function createRequestLogger( appendConvertedChunk: chunkMethods.appendConvertedChunk, logError() {}, getPipelinePayloads() { - return null; + return routeDecision ? { routeDecision } : null; }, }; } @@ -342,6 +348,10 @@ export async function createRequestLogger( }; }, + logRouteDecision(decision) { + payloads.routeDecision = cloneBoundedForLog(decision) as JsonRecord; + }, + logOpenAIRequest(body) { payloads.openaiRequest = { timestamp: new Date().toISOString(), diff --git a/package.json b/package.json index fa272ed3d6..0fbeaf37a6 100644 --- a/package.json +++ b/package.json @@ -371,7 +371,7 @@ "lint-staged": { "*.{js,jsx,ts,tsx,mjs}": [ "prettier --write", - "eslint --fix --no-error-on-unmatched-pattern --suppressions-location config/quality/eslint-suppressions.json" + "eslint --fix --no-error-on-unmatched-pattern --no-warn-ignored --suppressions-location config/quality/eslint-suppressions.json" ], "*.{json,md,yml,yaml,css}": [ "prettier --write" diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index d91d7f52c0..18015b0bc6 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -206,8 +206,11 @@ export function findRawSql(files, allowlist = KNOWN_RAW_SQL) { } catch { continue; } - const literals = extractStringLiterals(stripComments(src)); - if (SQL_PATTERNS.some((rx) => rx.test(literals))) { + // Match each literal independently. Joining literals before scanning would + // turn harmless code such as `update(...)` plus a later `"set"` string into + // a false UPDATE ... SET SQL match. + const literals = extractStringLiterals(stripComments(src)).split("\n\0\n"); + if (literals.some((literal) => SQL_PATTERNS.some((rx) => rx.test(literal)))) { offenders.push(rel); } } diff --git a/scripts/dev/responses-ws-proxy.mjs b/scripts/dev/responses-ws-proxy.mjs index 1d66c57cb1..8825283aac 100644 --- a/scripts/dev/responses-ws-proxy.mjs +++ b/scripts/dev/responses-ws-proxy.mjs @@ -623,6 +623,12 @@ class ResponsesWsSession { provider: toStringOrNull(prepared.json?.provider) || "codex", model: toStringOrNull(prepared.json?.model) || toStringOrNull(responseBody.model), requestedModel: toStringOrNull(responseBody.model), + reasoningRouting: + prepared.json?.reasoningRouting && + typeof prepared.json.reasoningRouting === "object" && + !Array.isArray(prepared.json.reasoningRouting) + ? prepared.json.reasoningRouting + : null, serviceTier: toStringOrNull(responseBody.service_tier) || toStringOrNull(responseBody.serviceTier), }; diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 67e0c1e43e..c9ad9b8be3 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -27,6 +27,7 @@ import { hasProviderQuotaBypassScope } from "@/shared/constants/apiKeyPolicyScop import { UsageLimitSettings } from "./components/UsageLimitSettings"; import { ChaosModeAccessToggle } from "./components/ChaosModeAccessToggle"; import { BypassProviderQuotaToggle } from "./components/BypassProviderQuotaToggle"; +import ReasoningRoutingRules from "@/shared/components/ReasoningRoutingRules"; // Constants for validation const MAX_KEY_NAME_LENGTH = 200; @@ -2044,6 +2045,8 @@ const PermissionsModal = memo(function PermissionsModal({ )} + {apiKey?.id && } + {/* Access Mode Toggle */}
+ +
+ ))} + + +
+ setForm({ ...form, name: e.target.value })} + required + /> + setForm({ ...form, description: e.target.value })} + /> + {!apiKeyId && ( + setForm({ ...form, apiKeyId: e.target.value })} + options={keys.map((key) => ({ value: key.id, label: key.name }))} + /> + )} + {form.scope === "combo" && ( + setForm({ ...form, connectionId: e.target.value })} + options={connections.map((connection) => ({ + value: connection.id, + label: + connection.displayName || + connection.name || + `${connection.provider} · ${connection.id.slice(0, 8)}`, + }))} + /> + )} + {(form.scope === "model" || form.scope === "apiKey") && ( + setForm({ ...form, modelPattern: e.target.value })} + placeholder={ + form.scope === "apiKey" ? t("sourceModelOptional") : t("sourceModelExample") + } + /> + )} + setForm({ ...form, requestTags: e.target.value })} + placeholder={t("requestTagsExample")} + /> + setForm({ ...form, effortMode: e.target.value as EffortMode })} + options={["inherit", "default", "force"].map((mode) => ({ + value: mode, + label: t(`mode.${mode}`), + }))} + /> + {form.effortMode !== "inherit" && ( + setForm({ ...form, targetKind: e.target.value as TargetKind })} + options={[ + { value: "keep", label: t("keepModel") }, + { value: "model", label: t("otherModel") }, + { value: "combo", label: t("combo") }, + ]} + /> + {form.targetKind === "model" && form.scope !== "connection" && ( + setForm({ ...form, targetModel: e.target.value })} + /> + )} + {form.targetKind === "combo" && form.scope !== "connection" && ( + setForm({ ...form, budgetAction: e.target.value as BudgetAction })} + options={["preserve", "remove", "set"].map((action) => ({ + value: action, + label: t(`budget.${action}`), + }))} + /> + {form.budgetAction === "set" && ( + setForm({ ...form, budgetTokens: e.target.value })} + /> + )} + setForm({ ...form, priority: e.target.value })} + /> +
+ {capabilityWarning && ( +

{capabilityWarning}

+ )} +
+ + {editingId && ( + + )} +
+ {message &&

{message}

} + +
+

{t("simulateTitle")}

+
+ setSimulator({ ...simulator, model: e.target.value })} + /> + setSimulator({ ...simulator, requestTags: e.target.value })} + /> +