mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
This commit is contained in:
committed by
GitHub
parent
44e57a1ec1
commit
e8a6123169
1
changelog.d/features/6022-decision-trace-header.md
Normal file
1
changelog.d/features/6022-decision-trace-header.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(sse):** every completion response now carries an `X-OmniRoute-Decision: strategy=<name>; provider=<alias>; latency_ms=<n>` header exposing the routing decision — `<name>` 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).
|
||||
@@ -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=<name>; provider=<alias>; latency_ms=<n>`.
|
||||
`<name>` 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
|
||||
|
||||
@@ -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=<name>; provider=<alias>; latency_ms=<n>` (`<name>` 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;`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> {
|
||||
@@ -29,6 +30,7 @@ export function assembleStreamingResponseHeaders(
|
||||
latencyMs: 0,
|
||||
usage: null,
|
||||
costUsd: 0,
|
||||
strategy: args.comboStrategy ?? "single",
|
||||
}),
|
||||
"x-omniroute-request-id": args.pendingRequestId,
|
||||
};
|
||||
|
||||
@@ -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=<name>;
|
||||
* provider=<alias>; latency_ms=<n>`. 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<string, string> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
74
tests/unit/omniroute-decision-header.test.ts
Normal file
74
tests/unit/omniroute-decision-header.test.ts
Normal file
@@ -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+$/);
|
||||
});
|
||||
Reference in New Issue
Block a user