diff --git a/changelog.d/features/6872-relay-routing-fallback-reason-header.md b/changelog.d/features/6872-relay-routing-fallback-reason-header.md new file mode 100644 index 0000000000..85856e2245 --- /dev/null +++ b/changelog.d/features/6872-relay-routing-fallback-reason-header.md @@ -0,0 +1 @@ +- feat(api): add a structured `X-Routing-Fallback-Reason` header to relay routing responses, exposing a stable machine-readable reason code alongside the legacy `X-Routing-Fallback` detail string (#6872) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 7e9b2b0773..f2f861d661 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1077,7 +1077,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When true, the Bifrost sidecar route streams responses back via SSE through the gateway rather than the TS streaming executor. Set to `0` to force non-streaming JSON responses through the gateway. | | `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Per-request timeout when proxying to the Bifrost gateway (ms). On timeout the route returns the TS relay path via the `X-Bifrost-Fallback` header. | | `OMNIROUTE_BIFROST_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Alias for `BIFROST_API_KEY` (used by scripts that read the env via `OMNIROUTE_*`). `BIFROST_API_KEY` takes precedence when both are set. | -| `OMNIROUTE_RELAY_BACKEND` | `ts` / `auto` | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | Relay backend for `/api/v1/relay/chat/completions`: `ts \| bifrost \| auto`. `ts` = TypeScript relay (default when Bifrost unconfigured); `auto` selects Bifrost when `BIFROST_BASE_URL` is set and `BIFROST_ENABLED` ≠ `0`, with automatic TS fallback if the sidecar is unreachable; `bifrost` forces Bifrost (strict, no fallback). Auth/rate-limit/injection-guard/allowlist always run in the Next route first. Responses carry `X-Routing-Backend` / `X-Routing-Fallback`. | +| `OMNIROUTE_RELAY_BACKEND` | `ts` / `auto` | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | Relay backend for `/api/v1/relay/chat/completions`: `ts \| bifrost \| auto`. `ts` = TypeScript relay (default when Bifrost unconfigured); `auto` selects Bifrost when `BIFROST_BASE_URL` is set and `BIFROST_ENABLED` ≠ `0`, with automatic TS fallback if the sidecar is unreachable; `bifrost` forces Bifrost (strict, no fallback). Auth/rate-limit/injection-guard/allowlist always run in the Next route first. Responses carry `X-Routing-Backend` / `X-Routing-Fallback` / `X-Routing-Fallback-Reason`. | | `RELAY_ROUTING_BACKEND` | _(unset)_ | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | Accepted alias for `OMNIROUTE_RELAY_BACKEND` (same `ts \| bifrost \| auto` values). `OMNIROUTE_RELAY_BACKEND` takes precedence when both are set. | | `OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS` | `5000` | `src/app/api/v1/relay/chat/completions/bifrostCooldown.ts` | Cooldown (ms) after a Bifrost sidecar hop fails in `auto` mode before the relay re-attempts the sidecar; it routes straight to the TS path while the cooldown lasts, then probes again. `0` disables. Only applies when `OMNIROUTE_RELAY_BACKEND=auto`. | | `OMNIROUTE_TLS_CERT` | _(unset)_ | `bin/cli/commands/serve.mjs` | Path to a PEM TLS certificate to serve `omniroute serve` over HTTPS (equivalent to `--tls-cert`). Must be paired with `OMNIROUTE_TLS_KEY`; the standalone server then terminates TLS on the same listener (`wss://` works unchanged). Unset → plain HTTP. Providing only one of cert/key, or an unreadable path, logs a warning and stays HTTP. | diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index 27cf3bf414..b92b9cbfe0 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -21,6 +21,7 @@ import { import { getBifrostRoutingConfig, getRoutingFallbackHeader, + getRoutingFallbackReasonHeader, resolveRelayRoutingBackend, shouldTryBifrostForRequest, type BifrostRoutingConfig, @@ -378,6 +379,12 @@ export async function POST(request: Request) { // #5526 helper gates emission (auto + enabled); #5519 dynamic cooldown/error // reason wins as the value when set, else falls back to the static "bifrost". newHeaders.set("X-Routing-Fallback", bifrostFallbackReason ?? routingFallback); + // #6872: stable, machine-readable companion header — one of the 4 enum + // reason codes, or unset when the legacy value has no specific reason. + const fallbackReasonCode = getRoutingFallbackReasonHeader(bifrostFallbackReason); + if (fallbackReasonCode) { + newHeaders.set("X-Routing-Fallback-Reason", fallbackReasonCode); + } } return new Response(response.body, { diff --git a/src/app/api/v1/relay/chat/completions/routingBackend.ts b/src/app/api/v1/relay/chat/completions/routingBackend.ts index ed1459c5d0..e0a3fb66eb 100644 --- a/src/app/api/v1/relay/chat/completions/routingBackend.ts +++ b/src/app/api/v1/relay/chat/completions/routingBackend.ts @@ -102,3 +102,33 @@ export function getRoutingFallbackHeader( ): "bifrost" | undefined { return backend === "auto" && config?.enabled ? "bifrost" : undefined; } + +export type RoutingFallbackReasonCode = + | "bifrost-cooldown" + | "bifrost-error" + | "bifrost-ineligible" + | "bifrost-provider-unknown"; + +const ROUTING_FALLBACK_REASON_CODES = new Set([ + "bifrost-cooldown", + "bifrost-error", + "bifrost-ineligible", + "bifrost-provider-unknown", +]); + +/** + * Derives the stable, machine-readable reason code for X-Routing-Fallback-Reason + * from the existing (possibly parameterized) X-Routing-Fallback detail string. + * #6872: splits the enum token from the legacy ad-hoc detail (e.g. strips the + * "; remaining=" suffix on the cooldown case) without changing the legacy + * X-Routing-Fallback value itself. + */ +export function getRoutingFallbackReasonHeader( + fallbackReason: string | null | undefined +): RoutingFallbackReasonCode | undefined { + if (!fallbackReason) return undefined; + const code = fallbackReason.split(";", 1)[0]?.trim(); + return code && ROUTING_FALLBACK_REASON_CODES.has(code as RoutingFallbackReasonCode) + ? (code as RoutingFallbackReasonCode) + : undefined; +} diff --git a/tests/unit/api/v1/relay-routing-backend.test.ts b/tests/unit/api/v1/relay-routing-backend.test.ts index 64cca27097..170a8f1540 100644 --- a/tests/unit/api/v1/relay-routing-backend.test.ts +++ b/tests/unit/api/v1/relay-routing-backend.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import { getBifrostRoutingConfig, getRoutingFallbackHeader, + getRoutingFallbackReasonHeader, resolveRelayRoutingBackend, shouldTryBifrost, shouldTryBifrostForRequest, @@ -176,3 +177,29 @@ test("automatic relay keeps the Bifrost timeout active until an SSE stream final assert.match(streamBranch, /error && backend === "auto"/); assert.match(streamBranch, /recordBifrostFailure\(/); }); + +test("relay routing fallback reason header strips dynamic cooldown detail to the stable code", () => { + assert.equal( + getRoutingFallbackReasonHeader("bifrost-cooldown; remaining=1500"), + "bifrost-cooldown" + ); +}); + +test("relay routing fallback reason header passes already-stable reasons through unchanged", () => { + assert.equal(getRoutingFallbackReasonHeader("bifrost-error"), "bifrost-error"); + assert.equal(getRoutingFallbackReasonHeader("bifrost-ineligible"), "bifrost-ineligible"); + assert.equal( + getRoutingFallbackReasonHeader("bifrost-provider-unknown"), + "bifrost-provider-unknown" + ); +}); + +test("relay routing fallback reason header stays unset for the bare static legacy value", () => { + assert.equal(getRoutingFallbackReasonHeader("bifrost"), undefined); +}); + +test("relay routing fallback reason header stays unset for null/undefined/unrecognized input", () => { + assert.equal(getRoutingFallbackReasonHeader(null), undefined); + assert.equal(getRoutingFallbackReasonHeader(undefined), undefined); + assert.equal(getRoutingFallbackReasonHeader("something-unrecognized"), undefined); +});