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 })} + /> +