diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index da8b4f4e03..12ff30bea4 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -33,6 +33,7 @@ import { import { getProviderPluginManifestEntryForModel } from "@omniroute/open-sse/config/providerPluginManifestRegistry.ts"; import { getProviderPluginManifestHeader } from "@omniroute/open-sse/config/providerPluginManifestUrl.ts"; import { finalizeReadableStream } from "./streamFinalizer"; +import { stripStaleEncodingHeaders } from "@omniroute/open-sse/utils/upstreamResponseHeaders.ts"; import { clearBifrostFailure, getActiveBifrostCooldown, @@ -125,7 +126,7 @@ async function forwardToBifrost( sanitizeErrorMessage(parsed.message), parsed.responseBody ); - const errorHeaders = new Headers(headers); + const errorHeaders = stripStaleEncodingHeaders(headers); errorHeaders.set("Content-Type", "application/json"); if (parsed.retryAfterMs && parsed.retryAfterMs > 0) { errorHeaders.set("Retry-After", String(Math.ceil(parsed.retryAfterMs / 1000))); @@ -174,7 +175,8 @@ async function forwardToBifrost( startTime, clientIp, userAgent, - upstream.status >= 200 && upstream.status < 300 ? "success" : "error", + // upstream.ok is guaranteed true here (the !upstream.ok branch above returns early). + "success", upstream.status ); diff --git a/tests/unit/api/v1/relay-completions-errors.test.ts b/tests/unit/api/v1/relay-completions-errors.test.ts index 85d64ec6ee..96b19d5d6f 100644 --- a/tests/unit/api/v1/relay-completions-errors.test.ts +++ b/tests/unit/api/v1/relay-completions-errors.test.ts @@ -163,6 +163,59 @@ test("relay route: normalizes HTML 502 from Bifrost into JSON error (Issue #1)", restoreEnv(); }); +test("relay route: strips stale upstream content-length before serializing JSON error body", async () => { + setupBifrostEnv(); + const relayToken = seedRelayToken(`relay_err_${Date.now()}`); + + // The upstream Response carries an EXPLICIT content-length for its own (HTML) + // body. Once the route replaces that body with a freshly-serialized JSON error, + // a stale content-length copied verbatim onto the outgoing Response would + // mismatch the real byte length of the new body. + globalThis.fetch = async () => { + const html = "404 page not found, upstream sidecar unreachable"; + return new Response(html, { + status: 404, + headers: { + "content-type": "text/html", + "content-length": String(Buffer.byteLength(html)), + "content-encoding": "gzip", + "transfer-encoding": "chunked", + }, + }); + }; + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "relay-err-stale-length", + }, + body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }), + }); + + const res = await POST(req); + assert.equal(res.status, 404); + assert.equal(res.headers.get("content-encoding"), null, "stale content-encoding must be stripped"); + assert.equal(res.headers.get("transfer-encoding"), null, "stale transfer-encoding must be stripped"); + + const raw = await res.text(); + const declaredLength = res.headers.get("content-length"); + if (declaredLength !== null) { + assert.equal( + Number(declaredLength), + Buffer.byteLength(raw), + "content-length, if present, must match the actual serialized JSON error body" + ); + } + + restoreEnv(); +}); + test("relay route: upstream 401 recorded as analytics error not success (Issue #3)", async () => { setupBifrostEnv(); const relayToken = seedRelayToken(`relay_err_${Date.now()}`);