/** * Main proxy handler — routes requests, injects auth, forwards, and streams responses. * * **v2.6 upstream reality (post-PR #34)**: BOTH plan tiers post an * Anthropic-format upstream — coding-plan mirrors the real ZCode client * (api.z.ai/api/anthropic → ultra via endpoint routing); start-plan posts to * zcode.z.ai's Anthropic gateway with the plan JWT. Consequently: * - OpenAI clients are translated OpenAI→Anthropic on the way up and * Anthropic→OpenAI on the way down ("translation" mode). * - Anthropic clients speak the upstream's native format — requests are * forwarded with body transforms only ("passthrough" mode, * `decompress: false`). * * @see .omo/plans/zcode-proxy.md Task 6 */ import type { Format } from "../translator/types.js"; import type { ProxyConfig } from "../config/types.js"; import type { AuthManager } from "../auth/manager.js"; import { getProvider } from "../provider/providers.js"; import { buildUpstreamHeaderPairs, buildUpstreamRequest, type UpstreamHeaderPair } from "./upstream.js"; import { getDefaultEndpointRouting, type EndpointRoutingService } from "./endpoint-routing.js"; import { getDefaultClientSigning, sendWithClientSigning, type ClientSigningManager } from "./client-signing.js"; import { credentialString, type Credential } from "../auth/types.js"; import { sendOrderedUpstreamRequest } from "./ordered-transport.js"; import { transformRequestBody } from "./body-transformer.js"; import { isCaptchaChallenged, retryOnCaptchaChallenge } from "./captcha-retry.js"; import { type ClientSessionResult } from "./client-session.js"; import { resolveSessionContext } from "./session-context.js"; import { gzipSync } from "node:zlib"; // captcha.ts is loaded lazily inside the `startPlan` branch (only path that // touches it). The solver itself (captcha-happy.ts) is dynamically imported // by captcha-solver.ts, so non-start-plan processes never pay its startup // cost. Desktop Bun keeps the same code path; the dynamic import resolves // synchronously enough on Bun's warm cache. type CaptchaModule = typeof import("./captcha.js"); let captchaModule: CaptchaModule | null = null; async function loadCaptcha(): Promise { if (!captchaModule) captchaModule = await import("./captcha.js"); return captchaModule; } import { translateRequestOpenAIToAnthropic, translateResponseAnthropicToOpenAI } from "../translator/openai-to-anthropic.js"; import { translateRequestAnthropicToOpenAI, translateResponseOpenAIToAnthropic } from "../translator/anthropic-to-openai.js"; import { anthropicSseToOpenaiSse, openaiSseToAnthropicSse } from "../translator/sse-translator.js"; import type { OpenAIChatRequest, OpenAIChatResponse, AnthropicMessagesRequest, AnthropicMessagesResponse } from "../translator/types.js"; import { dumpPhase, dumpHeaders, dumpBody, dumpEnabled } from "./dump.js"; import { inflateWithCap } from "./inflate.js"; /** Options for the proxy handler. */ export interface ProxyHandlerOptions { config: ProxyConfig; auth: AuthManager; /** Override the global fetch (for testing). Defaults to global `fetch`. */ fetchImpl?: typeof fetch; /** * When true, emit additional per-request diagnostic lines: upstream URL, * redacted request headers, body preview, upstream response status and * selected response headers. Activated by `zcode-proxy serve debug`. */ debug?: boolean; /** Override the process-wide endpoint routing service (for testing). `null` disables. */ endpointRouting?: EndpointRoutingService | null; /** Override the process-wide client signing manager (for testing). `null` disables. */ clientSigning?: ClientSigningManager | null; } /** * Forward a client request to the upstream provider with injected auth. * * Upstream fetch options differ by mode: * - **Passthrough** (OpenAI client): `{ decompress: false }` — compressed * response bodies (gzip/deflate/br) pass through untouched; raw bytes and the * Content-Encoding header are forwarded as-is, letting the client decompress. * - **Translation** (Anthropic client): no options — Bun decompresses so the proxy * can read the body and translate OpenAI→Anthropic (then re-gzip if the client * accepts). * * No upstream timeout is applied — matches ZCode desktop client behaviour * (the bundle has no automatic timer on LLM calls, only user-initiated abort). * Connection-level errors (ECONNREFUSED, DNS failure) still surface as 502. */ export async function proxyRequest( clientReq: Request, format: Format, opts: ProxyHandlerOptions, ): Promise { const { config, auth } = opts; const fetchImpl = opts.fetchImpl ?? fetch; const hasCustomFetchImpl = opts.fetchImpl !== undefined; const debug = opts.debug === true; const started = Date.now(); const reqId = nextReqId(); let body: string | undefined; try { body = await readBody(clientReq); } catch (err) { if (err instanceof InflatedBodyTooLargeError) { return errorResponse(413, "request_too_large", err.message); } return errorResponse(400, "invalid_request_error", (err as Error).message); } const meta = peekBody(body); if (dumpEnabled()) { dumpPhase(reqId, "client_in", { method: clientReq.method, url: clientReq.url, headers: dumpHeaders(clientReq.headers), body: dumpBody(body), }); } const staticProvider = getProvider(config.provider); const provider = { ...staticProvider, anthropicBaseURL: config.providers[config.provider].anthropicBase, openaiBaseURL: config.providers[config.provider].openaiBase, }; let cred: Credential; try { cred = await auth.getCredential(); } catch (err) { if (debug) debugError(reqId, "credential_unavailable", (err as Error).message); printRow(reqId, format, meta, 503, started, Date.now(), 0, 0, 0); return errorResponse(503, "credential_unavailable", (err as Error).message); } // v2.6: both plans use the Anthropic upstream. coding-plan mirrors the real // ZCode client (api.z.ai/api/anthropic → ultra via endpoint routing); // start-plan's old OpenAI gateway (/api/v1/zcode-plan/chat/completions) was // retired server-side (404 as of 2026-08-28) — the live desktop client now // posts Anthropic messages to /api/v1/zcode-plan/anthropic/v1/messages with // the start-plan JWT, so we do the same (no OpenAI translation either way). const startPlan = config.plan === "start-plan"; const translateAnthropicToOpenAI = false; const translateOpenAIToAnthropic = format === "openai"; const upstreamFormat: Format = "anthropic"; const clientSession = resolveSessionContext({ clientReq, body, upstreamFormat, model: meta.model, config }); if (debug && clientSession) { const shortSession = clientSession.sessionId ? clientSession.sessionId.slice(0, 10) : "-"; debugLine(reqId, `clientIdentity source=${clientSession.source} action=${clientSession.action} confidence=${clientSession.confidence.toFixed(2)} session=${shortSession}`); } let upstreamBody = body; if (translateOpenAIToAnthropic) { const translated = translateOpenAIBody(body); if (translated instanceof Response) return translated; upstreamBody = translated; if (debug) debugLine(reqId, `translated OpenAI→Anthropic (bytes=${upstreamBody?.length ?? 0})`); } else if (translateAnthropicToOpenAI) { const translated = translateAnthropicBody(body); if (translated instanceof Response) return translated; upstreamBody = translated; if (debug) debugLine(reqId, `translated Anthropic→OpenAI (bytes=${upstreamBody?.length ?? 0})`); } let transformedBody = transformRequestBody(upstreamBody, { format: upstreamFormat, userId: startPlan ? undefined : cred.userId, startPlan }); if (debug && transformedBody !== upstreamBody) { debugLine(reqId, `body transformed (upstreamFormat=${upstreamFormat}, startPlan=${startPlan}, bytes=${transformedBody?.length ?? 0})`); } let captchaHeaders: Record | undefined; if (startPlan) { try { const captcha = await loadCaptcha(); const token = await captcha.getCaptchaToken(config.identity.appVersion); captchaHeaders = { [captcha.RETRY_HEADERS.PARAM]: token.verifyParam, [captcha.RETRY_HEADERS.REGION]: token.region }; } catch { // Will solve on 403 fallback below } } const useOrderedTransport = shouldUseOrderedTransport(config, clientSession, hasCustomFetchImpl); let upstreamHeaderPairs = buildUpstreamHeaderPairs(clientReq, upstreamFormat, cred, config.identity, config.plan, captchaHeaders, clientSession); let upstreamReq = buildUpstreamRequest(clientReq, upstreamFormat, provider, cred, transformedBody, config.identity, config.plan, captchaHeaders, clientSession); const routing = opts.endpointRouting !== undefined ? opts.endpointRouting : getDefaultEndpointRouting(config); const signer = opts.clientSigning !== undefined ? opts.clientSigning : getDefaultClientSigning(config); const translateMode = translateOpenAIToAnthropic || translateAnthropicToOpenAI; const dispatch = async (req: Request, pairs: UpstreamHeaderPair[]): Promise => { let sendUrl = req.url; if (routing) { const routed = await routing.resolve(req.url, credentialString(cred)); if (routed.routed) { sendUrl = routed.url; if (debug) debugLine(reqId, `endpoint routing: ${req.url} -> ${routed.url}`); } } // Signing decisions (exempt-path, handshake origin, bypass keying) run // against the PRE-routing provider URL — the client's signer wraps the // routing transport, so its checks see the original URL too. return sendWithClientSigning(signer, { url: req.url, headerPairs: pairs, credential: credentialString(cred), appVersion: config.identity.appVersion, debug: debug ? (message) => debugLine(reqId, message) : undefined, send: (finalPairs) => { const sendReq = sendUrl === req.url && finalPairs === pairs ? req : new Request(sendUrl, { method: req.method, headers: Object.fromEntries(finalPairs), body: transformedBody ?? undefined, }); return sendUpstreamRequest(sendReq, finalPairs, transformedBody, translateMode, useOrderedTransport, fetchImpl, clientReq.signal, hasCustomFetchImpl); }, }); }; if (debug) { debugLine(reqId, `→ POST ${upstreamReq.url}`); debugLine(reqId, ` ${formatHeaderPairs(upstreamReq.headers)}`); if (transformedBody) debugLine(reqId, ` body preview: ${previewBody(transformedBody)}`); } if (dumpEnabled()) { dumpPhase(reqId, "upstream_out", { method: upstreamReq.method, url: upstreamReq.url, headers: dumpHeaders(upstreamReq.headers), body: dumpBody(transformedBody), upstreamFormat, translateMode: translateOpenAIToAnthropic || translateAnthropicToOpenAI, useOrderedTransport, startPlan, }); } let upstreamResp: Response; try { // Transient connect failures (DNS blip, TLS reset, Bun "Unable to // connect") happen a few times a day against the gateway. Retry the // CONNECT twice with a short backoff before surfacing a 502 — the // request never reached upstream, so resending is side-effect-free. // Guard rails: skip retry when the client already aborted or the ordered // transport flagged the failure postWrite; re-dispatch a FRESH Request // each attempt — a reused Request has its body stream marked used after // the first fetch (start-plan hits the plain pass-through path where // dispatch does NOT rebuild the Request). let dispatchAttempt = 0; upstreamResp = await dispatchWithConnectRetry( () => { dispatchAttempt += 1; const currentReq = dispatchAttempt === 1 ? upstreamReq : buildUpstreamRequest(clientReq, upstreamFormat, provider, cred, transformedBody, config.identity, config.plan, captchaHeaders, clientSession); return dispatch(currentReq, upstreamHeaderPairs); }, { isAborted: () => clientReq.signal.aborted, onRetry: (attempt, err) => { if (debug) debugError(reqId, "upstream_connect_retry", `attempt ${attempt}/${MAX_CONNECT_ATTEMPTS - 1} failed (${err.message}), retrying in ${500 * attempt}ms`); console.log(`${reqId} upstream connect failed (${err.message}), retry ${attempt + 1}/${MAX_CONNECT_ATTEMPTS} in ${500 * attempt}ms`); }, }, ); } catch (err) { if (debug) debugError(reqId, "upstream_unreachable", (err as Error).message); printRow(reqId, format, meta, 502, started, Date.now(), 0, 0, 0); return errorResponse(502, "upstream_unreachable", (err as Error).message); } // 401/429 reject the request before a generation can start. Cool the // rejected account and retry exactly once with the next account so the // configured rotation pool also provides real failover. if ((upstreamResp.status === 401 || upstreamResp.status === 429) && auth.getCredentialCount() > 1) { const rejected = cred; auth.markCooldown(rejected, upstreamResp.status === 401 ? 5 * 60_000 : 60_000); try { await upstreamResp.body?.cancel().catch(() => {}); cred = await auth.getCredential([rejected]); transformedBody = transformRequestBody(upstreamBody, { format: upstreamFormat, userId: startPlan ? undefined : cred.userId, startPlan }); if (startPlan) { try { const captcha = await loadCaptcha(); const token = await captcha.getCaptchaToken(config.identity.appVersion); captchaHeaders = { [captcha.RETRY_HEADERS.PARAM]: token.verifyParam, [captcha.RETRY_HEADERS.REGION]: token.region }; } catch { captchaHeaders = undefined; } } upstreamHeaderPairs = buildUpstreamHeaderPairs(clientReq, upstreamFormat, cred, config.identity, config.plan, captchaHeaders, clientSession); upstreamReq = buildUpstreamRequest(clientReq, upstreamFormat, provider, cred, transformedBody, config.identity, config.plan, captchaHeaders, clientSession); if (debug) debugLine(reqId, `upstream ${upstreamResp.status}; retrying with next credential`); let failoverAttempt = 0; upstreamResp = await dispatchWithConnectRetry(() => { failoverAttempt += 1; const currentReq = failoverAttempt === 1 ? upstreamReq : buildUpstreamRequest(clientReq, upstreamFormat, provider, cred, transformedBody, config.identity, config.plan, captchaHeaders, clientSession); return dispatch(currentReq, upstreamHeaderPairs); }, { isAborted: () => clientReq.signal.aborted }); } catch (err) { if (debug) debugError(reqId, "alternate_credential_unavailable", (err as Error).message); } } const headersAt = Date.now(); if (debug) { debugLine(reqId, `← ${upstreamResp.status} ${upstreamResp.statusText}`); debugLine(reqId, ` ${formatResponseHeaders(upstreamResp.headers)}`); } if (dumpEnabled()) { dumpPhase(reqId, "upstream_in", { status: upstreamResp.status, statusText: upstreamResp.statusText, headers: dumpHeaders(upstreamResp.headers), isSSE: upstreamResp.headers.get("content-type")?.includes("text/event-stream") ?? false, ttfbMs: headersAt - started, }); } if (upstreamResp.status === 401 && startPlan) { if (debug) debugError(reqId, "start_plan_jwt_invalid", "JWT rejected upstream"); printRow(reqId, format, meta, 401, started, headersAt, 0, 0, 0); return errorResponse(401, "start_plan_jwt_invalid", "Start-plan JWT was rejected. Re-run: zcode-proxy auth login"); } // start-plan: on explicit captcha challenge, retry once with a fresh // pooled token (the challenged token was already consumed by this request; // getCaptchaToken takes the next pre-solved one). Detection covers the // response-header variant AND the in-body `{"code":3007}` variant (observed // 2026-08-29 as HTTP 400 JSON with no captcha header) via the shared // captcha-retry seam (used by /v1/responses too). const captcha = startPlan ? await loadCaptcha() : null; const captchaChallenge = captcha ? await isCaptchaChallenged(upstreamResp, captcha) : false; if (captchaChallenge && captcha) { console.log(`${reqId} captcha challenge, re-solving...`); const outcome = await retryOnCaptchaChallenge({ captcha, appVersion: config.identity.appVersion, challengedResp: upstreamResp, debug: debug ? (message) => debugLine(reqId, message) : undefined, solveAndRetry: (retryHeaders) => { console.log(`${reqId} captcha re-solved (token ${retryHeaders[captcha.RETRY_HEADERS.PARAM].length} chars), retrying...`); upstreamHeaderPairs = buildUpstreamHeaderPairs(clientReq, upstreamFormat, cred, config.identity, config.plan, retryHeaders, clientSession); upstreamReq = buildUpstreamRequest(clientReq, upstreamFormat, provider, cred, transformedBody, config.identity, config.plan, retryHeaders, clientSession); return dispatch(upstreamReq, upstreamHeaderPairs).then((resp) => { if (debug) debugLine(reqId, `← retry ${resp.status} ${resp.statusText}`); return resp; }); }, mapError: (err, phase) => { if (phase === "solver") { if (debug) debugError(reqId, "captcha_solver_failed", err.message); printRow(reqId, format, meta, 503, started, Date.now(), 0, 0, 0); return errorResponse(503, "captcha_solver_failed", err.message); } if (debug) debugError(reqId, "upstream_unreachable", err.message); printRow(reqId, format, meta, 502, started, Date.now(), 0, 0, 0); return errorResponse(502, "upstream_unreachable", err.message); }, }); if (!outcome.ok) return outcome.resp; upstreamResp = outcome.resp; } const isSSE = upstreamResp.headers.get("content-type")?.includes("text/event-stream") ?? false; if (translateOpenAIToAnthropic) { if (!upstreamResp.ok) { const errBody = await upstreamResp.text().catch(() => ""); printRow(reqId, format, meta, 502, started, headersAt, 0, 0, 0); return errorResponse(502, "translation_failed", `upstream returned ${upstreamResp.status}: ${errBody.slice(0, 200)}`); } if (isSSE && upstreamResp.body) { const translated = anthropicSseToOpenaiSse(upstreamResp.body, meta.model); const [clientBody, statsBody] = translated.tee(); observeStream(reqId, format, meta, upstreamResp.status, started, statsBody, null); return translatedSseResponse(clientBody); } return await translatedBatchResponse(clientReq, upstreamResp, meta.model, reqId, format, meta, started, headersAt); } if (translateAnthropicToOpenAI) { if (!upstreamResp.ok) { const errBody = await upstreamResp.text().catch(() => ""); printRow(reqId, format, meta, 502, started, headersAt, 0, 0, 0); return errorResponse(502, "translation_failed", `upstream returned ${upstreamResp.status}: ${errBody.slice(0, 200)}`); } if (isSSE && upstreamResp.body) { const translated = openaiSseToAnthropicSse(upstreamResp.body, meta.model); const [clientBody, statsBody] = translated.tee(); observeStream(reqId, format, meta, upstreamResp.status, started, statsBody, null); return translatedSseResponse(clientBody); } return await translatedOpenAIToAnthropicBatchResponse(clientReq, upstreamResp, reqId, format, meta, started, headersAt); } if (isSSE && upstreamResp.body) { const [clientBody, statsBody] = upstreamResp.body.tee(); observeStream(reqId, format, meta, upstreamResp.status, started, statsBody, upstreamResp.headers.get("content-encoding")); return passthroughResponse(upstreamResp, clientAcceptsGzip(clientReq), clientBody); } printRow(reqId, format, meta, upstreamResp.status, started, headersAt, 0, 0, 0); return passthroughResponse(upstreamResp, clientAcceptsGzip(clientReq)); } export function shouldUseOrderedTransport(config: ProxyConfig, clientSession: ClientSessionResult | undefined, hasCustomFetchImpl: boolean): boolean { if (hasCustomFetchImpl) return false; return clientSession?.action === "enforce" || clientSession?.source === "explicit"; } /** Max attempts (initial + 2 retries) for transient CONNECT-level failures. */ export const MAX_CONNECT_ATTEMPTS = 3; /** * Connect-level retry ladder shared by the chat hot path and /v1/responses. * Transient connect failures (DNS blip, TLS reset, Bun "Unable to connect") * happen a few times a day against the gateway; the request never reached * upstream, so resending is side-effect-free. * * Contract (review P1/P2, PR #34/#35): * - `attemptDispatch` must dispatch a FRESH request each call — a reused * Request has its body stream marked used after the first fetch. * - failures flagged `postWrite` (ordered transport already wrote the full * request) are never retried — the upstream may have processed it. * - no retry once the client aborted (`opts.isAborted`). */ export async function dispatchWithConnectRetry( attemptDispatch: () => Promise, opts: { isAborted?: () => boolean; onRetry?: (attempt: number, err: Error) => void } = {}, ): Promise { for (let attempt = 1; ; attempt++) { if (opts.isAborted?.()) throw new Error("client aborted before upstream connect"); try { return await attemptDispatch(); } catch (err) { if ((err as { postWrite?: boolean }).postWrite) throw err; if (attempt >= MAX_CONNECT_ATTEMPTS) throw err; const backoffMs = 500 * attempt; opts.onRetry?.(attempt, err as Error); await new Promise((r) => setTimeout(r, backoffMs)); } } } /** * True on runtimes whose fetch ignores Bun's `decompress: false` extension and * transparently inflates compressed response bodies while KEEPING the * `content-encoding`/`content-length` headers (verified empirically against * Node 22/26 undici and Bun 1.3: gzip, deflate and br are all decoded, headers * unchanged). Bun honors `decompress: false` (raw bytes + truthful header), so * no normalization is needed there. */ const FETCH_AUTO_DECOMPRESSES = typeof Bun === "undefined"; /** Content codings a `FETCH_AUTO_DECOMPRESSES` runtime inflates transparently. */ const AUTO_DECODED_ENCODINGS = new Set(["gzip", "x-gzip", "deflate", "br"]); /** * Strip `content-encoding`/`content-length` from a Response whose body the * runtime fetch has ALREADY inflated. Without this, passthrough on Node would * forward a decoded body still labeled `content-encoding: gzip` — clients that * advertise gzip then fail to decompress it, and the `passthroughResponse` * safety net would double-decompress an already-inflated stream for clients * that don't. No-op for encodings the runtime leaves untouched. Returns a new * Response because a fetch Response's headers can be immutable. */ export function stripAutoDecodedEncoding(resp: Response): Response { const encoding = resp.headers.get("content-encoding")?.toLowerCase().trim() ?? ""; if (!encoding) return resp; const codings = encoding.split(",").map((c) => c.trim()); if (!codings.every((c) => AUTO_DECODED_ENCODINGS.has(c))) return resp; const headers = new Headers(resp.headers); headers.delete("content-encoding"); headers.delete("content-length"); return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers, }); } async function sendUpstreamRequest( upstreamReq: Request, headerPairs: UpstreamHeaderPair[], body: string | undefined, translateMode: boolean, useOrderedTransport: boolean, fetchImpl: typeof fetch, abortSignal?: AbortSignal, hasCustomFetchImpl = false, ): Promise { if (useOrderedTransport) { return sendOrderedUpstreamRequest({ url: upstreamReq.url, method: upstreamReq.method, headers: headerPairs, body, decompress: translateMode, signal: abortSignal, }); } const fetchOpts: RequestInit & { decompress?: boolean } = translateMode ? {} : { decompress: false }; if (abortSignal) fetchOpts.signal = abortSignal; const resp = await fetchImpl(upstreamReq, fetchOpts); // Passthrough on a runtime whose fetch auto-decompresses (Node/undici in the // Android bundle): the body arrives inflated while its headers still claim // compression. Drop the stale labels so the body/header pairing downstream // stays truthful. Skipped for injected fetch impls (tests) — their bodies are // genuinely compressed and their decompression semantics are their own. if (!translateMode && FETCH_AUTO_DECOMPRESSES && !hasCustomFetchImpl) { return stripAutoDecodedEncoding(resp); } return resp; } /** * Read the request body as a string, returning undefined for empty bodies. * Transparently inflates `content-encoding: gzip` request bodies (the OpenAI / * Anthropic upstreams accept gzipped request bodies; without this, clients * that send them got a misleading "body is not valid JSON" 400). Corrupt gzip * throws a descriptive Error; inflation past `MAX_INFLATED_BODY_BYTES` throws * `InflatedBodyTooLargeError` (streamed + aborted early, so a small wire * payload cannot expand into unbounded proxy memory). */ export async function readBody(req: Request): Promise { if (req.method === "GET" || req.method === "HEAD") return undefined; const bytes = new Uint8Array(await req.arrayBuffer()); if (bytes.byteLength === 0) return undefined; const encoding = req.headers.get("content-encoding")?.toLowerCase().trim() ?? ""; if (encoding === "gzip" || encoding === "x-gzip") { return new TextDecoder().decode(await inflateGzipBody(bytes)); } return new TextDecoder().decode(bytes); } /** * Decompressed-size ceiling for gzip request bodies. Generous by design: * plain bodies on `/v1/*` routes are intentionally uncapped (long-context LLM * requests reach several MB), so this only rejects pathological amplification. */ const MAX_INFLATED_BODY_BYTES = 64 * 1024 * 1024; /** Thrown when a gzip request body expands past MAX_INFLATED_BODY_BYTES. */ export class InflatedBodyTooLargeError extends Error { constructor(limit: number) { super(`gzip request body exceeds ${limit} bytes after decompression`); this.name = "InflatedBodyTooLargeError"; } } async function inflateGzipBody(bytes: Uint8Array): Promise { const result = await inflateWithCap(bytes, MAX_INFLATED_BODY_BYTES); if (!result.ok) { if (result.reason === "too_large") throw new InflatedBodyTooLargeError(MAX_INFLATED_BODY_BYTES); throw new Error(`request body is marked content-encoding: gzip but failed to decompress: ${result.detail}`); } return result.bytes; } /** * Create a passthrough response that streams the upstream body to the client. * Preserves status and the allowlisted headers, and honors the client's * `Accept-Encoding` for gzip. * * The upstream request FORWARDS the client's `accept-encoding` (only * defaulting to "gzip" when the client sent none — see * `buildUpstreamHeaderPairs`), so the upstream compresses only when the * client can decode it. If THIS client did not advertise gzip but the body * arrived gzip-compressed anyway, we decompress before forwarding and drop * the now-mismatched `content-encoding`/`content-length` headers — otherwise * clients whose HTTP stack does not auto-decompress (e.g. some Tauri-based * clients) receive raw gzip bytes and fail to parse the JSON body with * "non-JSON body" errors despite a 200 status. */ function passthroughResponse( upstream: Response, clientAcceptsGzip: boolean, body?: ReadableStream, ): Response { const headers = new Headers(); const forwardHeaders = [ "content-type", "content-encoding", "cache-control", "x-request-id", "anthropic-ratelimit-requests-limit", "anthropic-ratelimit-requests-remaining", "anthropic-ratelimit-requests-reset", "anthropic-ratelimit-tokens-limit", "anthropic-ratelimit-tokens-remaining", "anthropic-ratelimit-tokens-reset", ]; for (const h of forwardHeaders) { const v = upstream.headers.get(h); if (v) headers.set(h, v); } const upstreamEncoding = headers.get("content-encoding")?.toLowerCase() ?? ""; const source = body ?? upstream.body; if (upstreamEncoding.includes("gzip") && !clientAcceptsGzip && source) { const gunzip = new DecompressionStream("gzip") as unknown as ReadableWritablePair; const decompressed = source.pipeThrough(gunzip); headers.delete("content-encoding"); headers.delete("content-length"); return new Response(decompressed, { status: upstream.status, statusText: upstream.statusText, headers, }); } return new Response(source, { status: upstream.status, statusText: upstream.statusText, headers, }); } /** Build a JSON error response. */ export function errorResponse(status: number, type: string, message: string): Response { const body = JSON.stringify({ error: { type, message }, }); return new Response(body, { status, headers: { "content-type": "application/json" }, }); } /** Translate an OpenAI request body string to Anthropic JSON. Returns error Response on failure. */ function translateOpenAIBody(body: string | undefined): Response | string | undefined { if (body === undefined || body.length === 0) { return errorResponse(400, "translation_failed", "OpenAI request body is empty; cannot translate."); } let parsed: unknown; try { parsed = JSON.parse(body); } catch (err) { return errorResponse(400, "translation_failed", `OpenAI request body is not valid JSON: ${(err as Error).message}`); } try { const translated = translateRequestOpenAIToAnthropic(parsed as OpenAIChatRequest); return JSON.stringify(translated); } catch (err) { return errorResponse(400, "translation_failed", `OpenAI→Anthropic translation failed: ${(err as Error).message}`); } } /** True when the client request explicitly accepts gzip (and has not disabled it via q=0). */ function clientAcceptsGzip(req: Request): boolean { const ae = req.headers.get("accept-encoding"); if (!ae) return false; return /\bgzip\b(?!\s*;\s*q=0(?:\.0+)?\s*(?:,|$))/i.test(ae); } /** Build a translated batch (non-streaming) OpenAI response. Gzip if client accepts. */ async function translatedBatchResponse( clientReq: Request, upstream: Response, model: string, reqId: string, format: Format, meta: RequestMeta, started: number, headersAt: number, ): Promise { const raw = await upstream.text(); let parsedAnthropic: AnthropicMessagesResponse; try { parsedAnthropic = JSON.parse(raw) as AnthropicMessagesResponse; } catch (err) { printRow(reqId, format, meta, 502, started, headersAt, 0, 0, 0); return errorResponse(502, "translation_failed", `upstream returned non-JSON body: ${(err as Error).message}`); } if (!isAnthropicMessagesResponse(parsedAnthropic)) { printRow(reqId, format, meta, 502, started, headersAt, 0, 0, 0); return errorResponse(502, "translation_failed", `upstream returned invalid Anthropic message: ${raw.slice(0, 200)}`); } const openaiResp = translateResponseAnthropicToOpenAI(parsedAnthropic, model); const json = JSON.stringify(openaiResp); const payload = new TextEncoder().encode(json); const respHeaders = new Headers(); respHeaders.set("content-type", "application/json"); for (const h of forwardedUpstreamHeaders()) { const v = upstream.headers.get(h); if (v) respHeaders.set(h, v); } if (clientAcceptsGzip(clientReq)) { respHeaders.set("content-encoding", "gzip"); printRow(reqId, format, meta, upstream.status, started, headersAt, openaiResp.usage?.completion_tokens ?? 0, 0, 0); return new Response(gzipSync(payload), { status: upstream.status, headers: respHeaders, }); } printRow(reqId, format, meta, upstream.status, started, headersAt, openaiResp.usage?.completion_tokens ?? 0, 0, 0); return new Response(payload, { status: upstream.status, headers: respHeaders, }); } async function translatedOpenAIToAnthropicBatchResponse( clientReq: Request, upstream: Response, reqId: string, format: Format, meta: RequestMeta, started: number, headersAt: number, ): Promise { const raw = await upstream.text(); let parsedOpenAI: OpenAIChatResponse; try { parsedOpenAI = JSON.parse(raw) as OpenAIChatResponse; } catch (err) { printRow(reqId, format, meta, 502, started, headersAt, 0, 0, 0); return errorResponse(502, "translation_failed", `upstream returned non-JSON body: ${(err as Error).message}`); } const anthropicResp = translateResponseOpenAIToAnthropic(parsedOpenAI); const json = JSON.stringify(anthropicResp); const payload = new TextEncoder().encode(json); const respHeaders = new Headers(); respHeaders.set("content-type", "application/json"); for (const h of forwardedUpstreamHeaders()) { const v = upstream.headers.get(h); if (v) respHeaders.set(h, v); } if (clientAcceptsGzip(clientReq)) { respHeaders.set("content-encoding", "gzip"); printRow(reqId, format, meta, upstream.status, started, headersAt, anthropicResp.usage.output_tokens, 0, 0); return new Response(gzipSync(payload), { status: upstream.status, headers: respHeaders, }); } printRow(reqId, format, meta, upstream.status, started, headersAt, anthropicResp.usage.output_tokens, 0, 0); return new Response(payload, { status: upstream.status, headers: respHeaders, }); } function translateAnthropicBody(body: string | undefined): Response | string | undefined { if (body === undefined || body.length === 0) { return errorResponse(400, "translation_failed", "Anthropic request body is empty; cannot translate."); } let parsed: unknown; try { parsed = JSON.parse(body); } catch (err) { return errorResponse(400, "translation_failed", `Anthropic request body is not valid JSON: ${(err as Error).message}`); } try { const translated = translateRequestAnthropicToOpenAI(parsed as AnthropicMessagesRequest); return JSON.stringify(translated); } catch (err) { return errorResponse(400, "translation_failed", `Anthropic→OpenAI translation failed: ${(err as Error).message}`); } } function isAnthropicMessagesResponse(value: unknown): value is AnthropicMessagesResponse { if (typeof value !== "object" || value === null) return false; const candidate = value as Partial; return candidate.type === "message" && candidate.role === "assistant" && Array.isArray(candidate.content); } function forwardedUpstreamHeaders(): string[] { return [ "x-request-id", "anthropic-ratelimit-requests-limit", "anthropic-ratelimit-requests-remaining", "anthropic-ratelimit-requests-reset", "anthropic-ratelimit-tokens-limit", "anthropic-ratelimit-tokens-remaining", "anthropic-ratelimit-tokens-reset", ]; } function translatedSseResponse(body: ReadableStream): Response { return new Response(body, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache", }, }); } interface RequestMeta { model: string; stream: boolean; } function peekBody(body: string | undefined): RequestMeta { if (!body) return { model: "-", stream: false }; try { const p = JSON.parse(body) as Record; return { model: typeof p.model === "string" ? p.model : "-", stream: p.stream === true, }; } catch { return { model: "-", stream: false }; } } let reqCounter = 0; let headerPrinted = false; /** Format a unix-ms timestamp as local HH:MM:SS in the host's timezone (not UTC). */ function localTime(ms: number): string { const d = new Date(ms); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); const ss = String(d.getSeconds()).padStart(2, "0"); return `${hh}:${mm}:${ss}`; } function nextReqId(): string { return `#${String(++reqCounter).padStart(3, "0")}`; } const DEBUG_BODY_PREVIEW = 200; const SENSITIVE_HEADERS = new Set(["authorization", "x-api-key", "cookie", "set-cookie", "proxy-authorization"]); function debugLine(reqId: string, msg: string): void { console.log(`${reqId} debug: ${msg}`); } function debugError(reqId: string, kind: string, msg: string): void { console.log(`${reqId} debug: ERROR ${kind}: ${msg}`); } function redactHeaderVal(key: string, val: string): string { const k = key.toLowerCase(); if (!SENSITIVE_HEADERS.has(k)) return val; if (k === "authorization") { const sp = val.indexOf(" "); return sp > 0 ? `${val.slice(0, sp)} ` : ""; } if (val.length <= 10) return ""; return `${val.slice(0, 6)}...${val.slice(-4)}`; } function formatHeaderPairs(headers: Headers): string { const pairs: string[] = []; for (const [k, v] of headers.entries()) { pairs.push(`${k}=${redactHeaderVal(k, v)}`); } return pairs.join(" "); } function formatResponseHeaders(headers: Headers): string { const interesting = [ "content-type", "content-encoding", "content-length", "x-request-id", "anthropic-ratelimit-requests-remaining", "anthropic-ratelimit-tokens-remaining", ]; const pairs: string[] = []; for (const h of interesting) { const v = headers.get(h); if (v) pairs.push(`${h}=${v}`); } return pairs.length > 0 ? pairs.join(" ") : "(no notable headers)"; } function previewBody(body: string): string { const flat = body.replace(/\s+/g, " ").trim(); if (flat.length <= DEBUG_BODY_PREVIEW) return flat; return `${flat.slice(0, DEBUG_BODY_PREVIEW)}…(${flat.length} bytes total)`; } const COMPACT_LOG = process.env.ZCODE_LOG_FORMAT === "compact"; function printHeader(): void { if (headerPrinted) return; headerPrinted = true; if (COMPACT_LOG) return; console.log( "| # | Time | Fmt | Model | Mode | Stat | TTFB | Tok | tok/s | Total |", ); console.log( "|------|------------|-----|-------------|--------|------|---------|-------|--------|---------|", ); } function printRow( reqId: string, format: Format, meta: RequestMeta, status: number, started: number, headersAt: number, tokens: number, avgTps: number, streamEndAt: number, ): void { printHeader(); const tag = format === "anthropic" ? "ANT" : "OAI"; const mode = meta.stream ? "stream" : "batch"; if (COMPACT_LOG) { const ttfbMs = headersAt - started; const totalMs = streamEndAt > started ? streamEndAt - started : ttfbMs; const ttfbStr = fmtMs(ttfbMs); const tokStr = tokens > 0 ? `${tokens}tok` : ""; const tpsStr = avgTps > 0 ? `${avgTps.toFixed(0)}t/s` : ""; const parts = [reqId, tag, meta.model, String(status), mode]; if (meta.stream && streamEndAt > started) { parts.push(`${ttfbStr}→${fmtMs(totalMs)}`); } else { parts.push(ttfbStr); } if (tokStr) parts.push(tokStr); if (tpsStr) parts.push(tpsStr); console.log(parts.join(" ")); return; } const ts = localTime(started); const ttfb = `${headersAt - started}ms`; const total = streamEndAt > started ? `${streamEndAt - started}ms` : "-"; const tok = tokens > 0 ? String(tokens) : "-"; const tps = avgTps > 0 ? avgTps.toFixed(1) : "-"; console.log( `| ${reqId.padEnd(4)} | ${ts.padEnd(10)} | ${tag} | ${meta.model.padEnd(11)} | ${mode.padEnd(6)} | ${String(status).padStart(4)} | ${ttfb.padStart(7)} | ${tok.padStart(5)} | ${tps.padStart(6)} | ${total.padStart(7)} |`, ); } function fmtMs(ms: number): string { if (ms < 1000) return `${ms}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`; } function observeStream( reqId: string, format: Format, meta: RequestMeta, status: number, requestSentAt: number, body: ReadableStream, contentEncoding: string | null, ): void { const compressed = contentEncoding !== null; const dumpOn = dumpEnabled(); let tokens = 0; let sseBuffer = ""; let firstChunkAt = 0; let totalBytes = 0; let firstBytesSample = ""; function parseSse(text: string): void { for (const line of text.split("\n")) { if (!line.startsWith("data:") || line.includes("[DONE]")) continue; try { const j = JSON.parse(line.slice(5).trim()); if (j.usage?.completion_tokens) { tokens = j.usage.completion_tokens; continue; } if (j.usage?.output_tokens) { tokens = j.usage.output_tokens; continue; } // OpenAI content delta: choices[0].delta.content const oai = j.choices?.[0]?.delta?.content; if (typeof oai === "string" && oai.length > 0) { tokens++; continue; } // Anthropic content delta: type=content_block_delta, delta.type=text_delta if (j.type === "content_block_delta" && j.delta?.type === "text_delta") { const t = j.delta?.text; if (typeof t === "string" && t.length > 0) tokens++; } } catch {} } } (async () => { const reader = body.getReader(); const decoder = new TextDecoder(); try { while (true) { const { done, value } = await reader.read(); if (done) break; if (firstChunkAt === 0) firstChunkAt = Date.now(); if (dumpOn && value) { totalBytes += value.byteLength; if (firstBytesSample.length < 4096) { firstBytesSample += decoder.decode(value.slice(0, 4096 - firstBytesSample.length), { stream: true }); } } if (!compressed) { sseBuffer += decoder.decode(value, { stream: true }); const idx = sseBuffer.lastIndexOf("\n"); if (idx >= 0) { parseSse(sseBuffer.slice(0, idx)); sseBuffer = sseBuffer.slice(idx + 1); } } } if (!compressed && sseBuffer) parseSse(sseBuffer); } catch {} const endAt = Date.now(); const ttfbMs = (firstChunkAt > 0 ? firstChunkAt : endAt) - requestSentAt; const totalMs = endAt - requestSentAt; const avgTps = tokens > 0 && totalMs > 0 ? tokens / (totalMs / 1000) : 0; printRow(reqId, format, meta, status, requestSentAt, requestSentAt + ttfbMs, tokens, avgTps, endAt); if (dumpOn) { dumpPhase(reqId, "upstream_stream_summary", { status, contentEncoding, compressed, totalBytes, tokensObserved: tokens, ttfbMs, totalMs, firstBytesSample: firstBytesSample.length > 0 ? firstBytesSample.slice(0, 4096) : "(empty stream)", }); } })().catch(() => {}); }