From 2a7f2c892a7c27003fa98ea97237af84cac2865f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89der=20Costa?= Date: Tue, 23 Jun 2026 23:15:02 -0300 Subject: [PATCH] feat(routing): honor X-Route-Model header to override body.model (#4863) Integrated into release/v3.8.36 --- config/quality/file-size-baseline.json | 2 +- src/sse/handlers/chat.ts | 6 ++++- src/sse/handlers/resolveRoutingModel.ts | 17 +++++++++++++ tests/unit/resolve-routing-model.test.ts | 32 ++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 src/sse/handlers/resolveRoutingModel.ts create mode 100644 tests/unit/resolve-routing-model.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 75622029d5..cb897203d3 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -217,7 +217,7 @@ "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, - "src/sse/handlers/chat.ts": 1521, + "src/sse/handlers/chat.ts": 1525, "src/sse/services/auth.ts": 2289 }, "testCap": 800, diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 03ef7f5d05..d52a9fab00 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1,5 +1,6 @@ import { randomUUID } from "crypto"; import { resolveChatRequestBody } from "./requestBody"; +import { resolveRoutingModel } from "./resolveRoutingModel"; import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, @@ -254,7 +255,10 @@ export async function handleChat( log.debug("NO_THINKING", `Resolved no-thinking alias → ${noThinking.realModel}`); } - let modelStr = body.model; + // X-Route-Model header overrides body.model for routing purposes (see + // resolveRoutingModel). The resolved model still passes through + // enforceApiKeyPolicy below, so it cannot bypass per-key allowlists. + let modelStr = resolveRoutingModel(request, body); // Count messages (support both messages[] and input[] formats) const msgCount = body.messages?.length || body.input?.length || 0; diff --git a/src/sse/handlers/resolveRoutingModel.ts b/src/sse/handlers/resolveRoutingModel.ts new file mode 100644 index 0000000000..c4c466b88e --- /dev/null +++ b/src/sse/handlers/resolveRoutingModel.ts @@ -0,0 +1,17 @@ +// Resolve the model used for routing. The `X-Route-Model` header, when present, +// overrides `body.model` — letting a caller/proxy force a specific combo/alias/model +// regardless of what the client CLI sent. This is useful when a CLI hardcodes +// `body.model` to a fixed provider/model (bypassing combo routing): an upstream +// proxy can send `X-Route-Model` to restore routing control without mutating the +// request body. The resolved value still flows through `enforceApiKeyPolicy`, so +// it cannot bypass per-key model/combo allowlists. See PR #4863. + +type HeaderCarrier = { headers: { get(name: string): string | null } }; + +export function resolveRoutingModel( + request: HeaderCarrier, + body: { model?: string | null } +): string | null | undefined { + const headerModel = request.headers.get("x-route-model")?.trim(); + return headerModel || body.model; +} diff --git a/tests/unit/resolve-routing-model.test.ts b/tests/unit/resolve-routing-model.test.ts new file mode 100644 index 0000000000..b9a3c53b9e --- /dev/null +++ b/tests/unit/resolve-routing-model.test.ts @@ -0,0 +1,32 @@ +// Regression guard for #4863: X-Route-Model header overrides body.model for routing. +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resolveRoutingModel } from "../../src/sse/handlers/resolveRoutingModel.ts"; + +function req(headers: Record) { + return { headers: { get: (n: string) => headers[n.toLowerCase()] ?? null } }; +} + +describe("resolveRoutingModel (#4863)", () => { + it("uses body.model when no X-Route-Model header is present", () => { + assert.equal(resolveRoutingModel(req({}), { model: "gpt-5.3-codex" }), "gpt-5.3-codex"); + }); + + it("X-Route-Model header overrides body.model", () => { + assert.equal( + resolveRoutingModel(req({ "x-route-model": "my-combo" }), { model: "codex/gpt-5.3-codex" }), + "my-combo" + ); + }); + + it("trims surrounding whitespace from the header value", () => { + assert.equal( + resolveRoutingModel(req({ "x-route-model": " alias-x " }), { model: "fallback" }), + "alias-x" + ); + }); + + it("falls back to body.model when the header is empty/whitespace-only", () => { + assert.equal(resolveRoutingModel(req({ "x-route-model": " " }), { model: "fallback" }), "fallback"); + }); +});