From e8a6123169c37c3cdccd9105467315b38db16e63 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:52:50 -0300 Subject: [PATCH] feat(sse): add X-OmniRoute-Decision routing trace header (#6022) (#7765) --- .../features/6022-decision-trace-header.md | 1 + docs/openapi.yaml | 7 ++ docs/reference/API_REFERENCE.md | 1 + open-sse/handlers/chatCore.ts | 2 + .../chatCore/nonStreamingResponseHeaders.ts | 2 + .../chatCore/streamingResponseHeaders.ts | 2 + src/domain/omnirouteResponseMeta.ts | 44 +++++++++++ src/shared/constants/headers.ts | 1 + tests/unit/omniroute-decision-header.test.ts | 74 +++++++++++++++++++ 9 files changed, 134 insertions(+) create mode 100644 changelog.d/features/6022-decision-trace-header.md create mode 100644 tests/unit/omniroute-decision-header.test.ts diff --git a/changelog.d/features/6022-decision-trace-header.md b/changelog.d/features/6022-decision-trace-header.md new file mode 100644 index 0000000000..ea841e5553 --- /dev/null +++ b/changelog.d/features/6022-decision-trace-header.md @@ -0,0 +1 @@ +- **feat(sse):** every completion response now carries an `X-OmniRoute-Decision: strategy=; provider=; latency_ms=` header exposing the routing decision — `` is the combo strategy (`priority`, `weighted`, `fusion`, etc.) or `single` for a non-combo request — for client-side debugging/analytics without server log access (#6022 — thanks @chirag127). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 6c9241849b..a3a989d6bb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1126,6 +1126,13 @@ paths: schema: type: string description: Number of fallback attempts (only present when > 0). + X-OmniRoute-Decision: + schema: + type: string + description: >- + Routing decision trace: `strategy=; provider=; latency_ms=`. + `` is the combo strategy (e.g. `priority`, `weighted`, `fusion`) or + `single` for a non-combo request. Emitted on every completion response. X-OmniRoute-Request-Id: schema: type: string diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 2733369e9a..24ab2fc1c2 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -77,6 +77,7 @@ Content-Type: application/json | `X-OmniRoute-Request-Id` | Response | Request correlation id (when known) | | `X-OmniRoute-Version` | Response | OmniRoute build version (always present) | | `X-OmniRoute-Cost-Saved` | Response | USD the cache avoided on a HIT (cache hits only) | +| `X-OmniRoute-Decision` | Response | Routing trace: `strategy=; provider=; latency_ms=` (`` is the combo strategy, or `single` for a non-combo request) — always present on completion responses | > Nginx note: if you rely on underscore headers (for example `x_session_id`), enable `underscores_in_headers on;`. diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 7a4e11d7ad..19eb81bcca 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -4318,6 +4318,7 @@ export async function handleChatCore({ estimatedCost, requestId: skillRequestId, compressionResponseMeta, + comboStrategy, }); // #6426: align response body `model` with the `X-OmniRoute-Model` header // (both must be the resolved backend model). Some upstreams (notably legacy @@ -4414,6 +4415,7 @@ export async function handleChatCore({ model, pendingRequestId, compressionResponseMeta, + comboStrategy, }); // Create transform stream with logger for streaming response diff --git a/open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts b/open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts index 4306ace464..62ad018ccb 100644 --- a/open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts +++ b/open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts @@ -20,6 +20,7 @@ export function buildNonStreamingResponseHeaders( estimatedCost: number; requestId: unknown; compressionResponseMeta?: string | null | undefined; + comboStrategy?: string | null | undefined; }, deps: { attachOmniRouteMetaHeaders: typeof defaultAttachMeta; now: () => number } = { attachOmniRouteMetaHeaders: defaultAttachMeta, @@ -38,6 +39,7 @@ export function buildNonStreamingResponseHeaders( usage: args.responseUsage, costUsd: args.estimatedCost, requestId: args.requestId, + strategy: args.comboStrategy ?? "single", }); if (args.compressionResponseMeta) { responseHeaders[OMNIROUTE_RESPONSE_HEADERS.compression] = args.compressionResponseMeta; diff --git a/open-sse/handlers/chatCore/streamingResponseHeaders.ts b/open-sse/handlers/chatCore/streamingResponseHeaders.ts index a978a0a212..d9ba555fc9 100644 --- a/open-sse/handlers/chatCore/streamingResponseHeaders.ts +++ b/open-sse/handlers/chatCore/streamingResponseHeaders.ts @@ -18,6 +18,7 @@ export function assembleStreamingResponseHeaders( model: string | null | undefined; pendingRequestId: string; compressionResponseMeta?: string | null | undefined; + comboStrategy?: string | null | undefined; }, buildStreamingResponseHeaders: typeof defaultBuildStreaming = defaultBuildStreaming ): Record { @@ -29,6 +30,7 @@ export function assembleStreamingResponseHeaders( latencyMs: 0, usage: null, costUsd: 0, + strategy: args.comboStrategy ?? "single", }), "x-omniroute-request-id": args.pendingRequestId, }; diff --git a/src/domain/omnirouteResponseMeta.ts b/src/domain/omnirouteResponseMeta.ts index aac287a213..6a6a05e958 100644 --- a/src/domain/omnirouteResponseMeta.ts +++ b/src/domain/omnirouteResponseMeta.ts @@ -79,6 +79,39 @@ export function formatOmniRouteCost(costUsd: unknown): string { return normalized > 0 ? normalized.toFixed(10) : "0.0000000000"; } +/** + * Build the `X-OmniRoute-Decision` composite header value: `strategy=; + * provider=; latency_ms=`. Returns `null` when both `strategy` and + * `provider` are absent/blank (mirrors the per-field guard pattern used for the + * other optional headers). Reuses `getProviderAlias()` for the provider segment + * (same alias normalization the `X-OmniRoute-Provider` header already applies) + * and `toNonNegativeInteger()` for latency. The whole formatted string is passed + * through `toHeaderValue()` before returning, so a strategy/provider id + * containing control chars cannot corrupt the header line (Hard Rule #12 — this + * header only ever carries a routing strategy name, the already-public provider + * alias, and a latency integer; never an error message, stack trace, or secret). + */ +export function buildOmniRouteDecisionHeaderValue({ + strategy = null, + provider = null, + latencyMs = 0, +}: { + strategy?: string | null; + provider?: string | null; + latencyMs?: unknown; +}): string | null { + const hasStrategy = typeof strategy === "string" && strategy.trim().length > 0; + const hasProvider = typeof provider === "string" && provider.trim().length > 0; + if (!hasStrategy && !hasProvider) return null; + + const parts: string[] = []; + if (hasStrategy) parts.push(`strategy=${strategy}`); + if (hasProvider) parts.push(`provider=${getProviderAlias(provider as string)}`); + parts.push(`latency_ms=${toNonNegativeInteger(latencyMs)}`); + + return toHeaderValue(parts.join("; ")); +} + export function buildOmniRouteResponseMetaHeaders({ cacheHit = false, costUsd = 0, @@ -88,6 +121,7 @@ export function buildOmniRouteResponseMetaHeaders({ model = null, provider = null, requestId = null, + strategy = null, usage = null, }: { cacheHit?: boolean; @@ -105,6 +139,11 @@ export function buildOmniRouteResponseMetaHeaders({ model?: string | null; provider?: string | null; requestId?: string | null; + /** + * Routing decision (combo strategy name, or `"single"` for a non-combo + * request) surfaced via `X-OmniRoute-Decision`. See #6022. + */ + strategy?: string | null; usage?: UsageLike; }): Record { const tokens = getOmniRouteTokenCounts(usage); @@ -142,6 +181,11 @@ export function buildOmniRouteResponseMetaHeaders({ headers[OMNIROUTE_RESPONSE_HEADERS.fallbackAttempts] = toHeaderValue(String(attempts)); } + const decisionValue = buildOmniRouteDecisionHeaderValue({ strategy, provider, latencyMs }); + if (decisionValue !== null) { + headers[OMNIROUTE_RESPONSE_HEADERS.decision] = decisionValue; + } + return headers; } diff --git a/src/shared/constants/headers.ts b/src/shared/constants/headers.ts index e8184efe28..4d25068eb7 100644 --- a/src/shared/constants/headers.ts +++ b/src/shared/constants/headers.ts @@ -3,6 +3,7 @@ export const OMNIROUTE_RESPONSE_HEADERS = { cacheHit: "X-OmniRoute-Cache-Hit", compression: "X-OmniRoute-Compression", costSaved: "X-OmniRoute-Cost-Saved", + decision: "X-OmniRoute-Decision", fallbackAttempts: "X-OmniRoute-Fallback-Attempts", latencyMs: "X-OmniRoute-Latency-Ms", model: "X-OmniRoute-Model", diff --git a/tests/unit/omniroute-decision-header.test.ts b/tests/unit/omniroute-decision-header.test.ts new file mode 100644 index 0000000000..b83bed165c --- /dev/null +++ b/tests/unit/omniroute-decision-header.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { OMNIROUTE_RESPONSE_HEADERS } from "../../src/shared/constants/headers.ts"; +import { + buildOmniRouteDecisionHeaderValue, + buildOmniRouteResponseMetaHeaders, +} from "../../src/domain/omnirouteResponseMeta.ts"; +import { assembleStreamingResponseHeaders } from "../../open-sse/handlers/chatCore/streamingResponseHeaders.ts"; +import { buildNonStreamingResponseHeaders } from "../../open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts"; + +test("headers constant exposes the decision key", () => { + assert.equal(OMNIROUTE_RESPONSE_HEADERS.decision, "X-OmniRoute-Decision"); +}); + +test("buildOmniRouteResponseMetaHeaders emits X-OmniRoute-Decision for a combo strategy", () => { + const headers = buildOmniRouteResponseMetaHeaders({ + strategy: "priority", + provider: "openai", + model: "gpt-4o", + latencyMs: 42, + }); + assert.equal(headers["X-OmniRoute-Decision"], "strategy=priority; provider=openai; latency_ms=42"); +}); + +test("strategy: single (non-combo request) still emits the header", () => { + const headers = buildOmniRouteResponseMetaHeaders({ + strategy: "single", + provider: "anthropic", + latencyMs: 10, + }); + assert.equal(headers["X-OmniRoute-Decision"], "strategy=single; provider=anthropic; latency_ms=10"); +}); + +test("omitted strategy AND provider -> header absent entirely", () => { + const headers = buildOmniRouteResponseMetaHeaders({ model: "gpt-4o" }); + assert.equal("X-OmniRoute-Decision" in headers, false); +}); + +test("control characters in strategy are stripped, no header-injection / leak surface", () => { + const value = buildOmniRouteDecisionHeaderValue({ + strategy: "prio\r\nrity", + provider: "openai", + latencyMs: 5, + }); + assert.ok(value !== null); + assert.equal(/[\r\n]/.test(value as string), false); + assert.equal((value as string).includes("Error:"), false); + assert.equal((value as string).includes(" at /"), false); +}); + +test("assembleStreamingResponseHeaders includes X-OmniRoute-Decision with strategy=fusion", () => { + const providerHeaders = new Headers(); + const headers = assembleStreamingResponseHeaders({ + providerHeaders, + provider: "openai", + model: "gpt-4o", + pendingRequestId: "req-1", + comboStrategy: "fusion", + }); + assert.equal(headers["X-OmniRoute-Decision"], "strategy=fusion; provider=openai; latency_ms=0"); +}); + +test("buildNonStreamingResponseHeaders falls back to strategy=single when comboStrategy is null", () => { + const headers = buildNonStreamingResponseHeaders({ + provider: "openai", + model: "gpt-4o", + startTime: Date.now(), + responseUsage: null, + estimatedCost: 0, + requestId: "req-2", + comboStrategy: null, + }); + assert.match(headers["X-OmniRoute-Decision"], /^strategy=single; provider=openai; latency_ms=\d+$/); +});