mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
`fetch()` always transparently decompresses the upstream body before exposing it via `.text()` or the stream reader, so forwarding the upstream `content-encoding` header (e.g. `gzip`) to the downstream OpenAI/Anthropic-compatible client makes the client attempt to gunzip plain text and fail with `ZlibError: incorrect header check`. Similarly, `content-length` becomes stale once we transform the response body (non-stream path repacks via `new Response()`; stream path re-streams through our SSE heartbeat / shape transforms), and `transfer-encoding` is owned by the Node/Next runtime, not us. This patch adds `open-sse/utils/upstreamResponseHeaders.ts` exporting: - `stripStaleEncodingHeaders(input: Headers)` — returns a new `Headers` with content-encoding, content-length, transfer-encoding removed (case-insensitive, does not mutate input). - `filterUpstreamResponseHeaderEntries(entries, extraToStrip)` — same semantics for the entries-array path used by the streaming response builder, plus user-supplied additional names (case-insensitive). - `STRIP_UPSTREAM_HEADER_NAMES` — the canonical set, exported for tests. `open-sse/handlers/chatCore.ts` now uses these helpers in two places: 1. Non-stream path (~L3211): replaces `new Headers(rawResult.response.headers)` with `stripStaleEncodingHeaders(...)` so the repacked Response below doesn't claim a stale encoding/length. 2. Stream path (~L4371): replaces an inline IIFE+filter that only removed `content-type` with `filterUpstreamResponseHeaderEntries(..., ["content-type"])` so the stale encoding/length are also stripped before we set our own `text/event-stream` content-type. Both call sites carry a comment explaining the underlying fetch decompression behavior. Tests ----- New `tests/unit/upstream-response-headers-strip.test.ts` adds 10 cases covering: lowercase strip, mixed-case strip, input non-mutation, empty input, default-set filter, case-insensitive extraToStrip, empty extraToStrip preservation, empty entries, mixed-case default names, and canonical set identity. Verified locally ---------------- - `npx eslint open-sse/utils/upstreamResponseHeaders.ts open-sse/handlers/chatCore.ts tests/unit/upstream-response-headers-strip.test.ts` — clean. - `npm run typecheck:core` — clean. - `node --import tsx/esm --test tests/unit/upstream-response-headers-strip.test.ts tests/unit/upstream-headers-sanitize.test.ts tests/unit/chatcore-sanitization.test.ts tests/unit/chat-route-edge-cases.test.ts tests/unit/chatcore-translation-paths.test.ts tests/unit/chatcore-compression-integration.test.ts` — 95/95 pass. - Full `npm run test:unit` / `test:coverage` deferred to upstream CI (122 test files, exceeds local timeout window).
48 lines
1.6 KiB
TypeScript
48 lines
1.6 KiB
TypeScript
/**
|
|
* Header-strip helper for upstream provider responses.
|
|
*
|
|
* `fetch()` always decompresses the upstream body before exposing it via
|
|
* `.text()` or the stream reader, so forwarding the upstream `content-encoding`
|
|
* to the downstream client (e.g. `gzip`) makes the client attempt to gunzip
|
|
* plain text and fail with `ZlibError: incorrect header check`.
|
|
*
|
|
* Similarly, `content-length` becomes stale once we transform or repack the
|
|
* response stream, and `transfer-encoding` is managed by the runtime
|
|
* (Next.js / Node), not us.
|
|
*/
|
|
|
|
const STRIP_HEADER_NAMES: ReadonlySet<string> = new Set([
|
|
"content-encoding",
|
|
"content-length",
|
|
"transfer-encoding",
|
|
]);
|
|
|
|
/**
|
|
* Return a new `Headers` instance with stale encoding/length headers removed.
|
|
* Does not mutate the input.
|
|
*/
|
|
export function stripStaleEncodingHeaders(input: Headers): Headers {
|
|
const out = new Headers(input);
|
|
for (const name of STRIP_HEADER_NAMES) out.delete(name);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Return a new entries array with stale encoding/length headers removed and
|
|
* (optionally) additional header names removed. Case-insensitive.
|
|
*/
|
|
export function filterUpstreamResponseHeaderEntries(
|
|
entries: Iterable<[string, string]>,
|
|
extraToStrip: ReadonlyArray<string> = []
|
|
): Array<[string, string]> {
|
|
const drop = new Set<string>(STRIP_HEADER_NAMES);
|
|
for (const h of extraToStrip) drop.add(h.toLowerCase());
|
|
const result: Array<[string, string]> = [];
|
|
for (const [k, v] of entries) {
|
|
if (!drop.has(k.toLowerCase())) result.push([k, v]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export const STRIP_UPSTREAM_HEADER_NAMES: ReadonlySet<string> = STRIP_HEADER_NAMES;
|