diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1e43c..f831c1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,67 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.2.1] - 2026-08-19 + +### Fixed + +- **Anthropic passthrough — `connectTimeoutMs` now genuinely bounds TCP connection + establishment; new `headerTimeoutMs` knob bounds time to first byte** (fixes #27). + Previously `upstream.setTimeout(connectTimeoutMs)` was called on the `ClientRequest` + object, but Node's implementation defers that call via an internal `'connect'` + listener — so it fired only after TCP connect, in the same tick as the + `headerTimeoutMs` rearm, meaning `connectTimeoutMs` was never in force for any + measurable interval. Measured against a blackholed IP (`192.0.2.1`, TEST-NET-1) + with `connectTimeoutMs=700` and `headerTimeoutMs=5000`, the request previously + failed after **75 019 ms** (the macOS kernel TCP timeout) — neither budget fired. + The fix arms the timer **directly on the socket** inside the `'socket'` event + handler, before `'connect'` fires, so the connect-phase budget is in force for + the full TCP handshake window. The same measured scenario now fails at ~700 ms. + On HTTPS, `'connect'` fires after TCP establishment but before the TLS handshake, + so TLS negotiation falls under `headerTimeoutMs`, not `connectTimeoutMs` — this is + documented in the JSDoc. The fix also introduces a three-budget design: + `connectTimeoutMs` (10 s, TCP establishment only), `headerTimeoutMs` (660 s + default, connect→response-headers — defaults to 60 s above Anthropic's own ~600 s + server-side ceiling; the extra headroom accounts for the relay's clock starting at + TCP connect while the origin's starts only after the full request body is received, + so equal budgets would let the relay pre-empt the origin by the upload time plus + RTT), and `streamIdleTimeoutMs` (300 s, headers→stream-end, reset by every chunk). + **A hung upstream that accepts the TCP connection but never sends headers now takes + up to `headerTimeoutMs` (default 660 s) to fail, not 10 s** — tune this knob down + if you need faster detection of stalled upstreams. + +- **Anthropic passthrough — `x-subswitch-synthesized: 1` header marks every relay-generated response; stripped from proxied responses** (stacks on #30). + Previously a relay fault (502 connection failure, 504 timeout, 500 internal proxy error, 413 body too large, + 503 concurrency gate) was indistinguishable from an upstream outage on the client side. Every response the + relay synthesises itself now carries `x-subswitch-synthesized: 1`; this covers the Anthropic-leg error + paths, all codex-leg responses (both streaming SSE and aggregated JSON — the codex leg translates + Codex→Anthropic format so every byte it returns is relay-synthesised), and every relay-generated error in + `server.ts`. On the proxied path the header is actively stripped from upstream responses via the hop-by-hop + filter so an origin that sets it cannot impersonate the relay. Bodies and status codes are unchanged — the + header is purely additive. See the new `## x-subswitch-synthesized` section in the README for the + operator-facing wire contract. + +- **Anthropic passthrough — client abort no longer logs a spurious `anthropic_upstream_error` warn** (stacks on #30). + `res.on("close")` was calling `upstream.destroy()` without first setting `settled = true`. The destroy + emits `'error'` on the next tick; with `settled = false` the error handler logged `anthropic_upstream_error` + and attempted to write a 502 into an already-closed response. A client hanging up is a normal event; + it now sets `settled = true` before the destroy so the error handler returns early. Additionally, the + error handler now sets `settled = true` before writing the 502 so a theoretically possible second + `'error'` emission cannot double-write. + +- **Anthropic passthrough — duplicate `anthropic_upstream_error` warn eliminated** + (fixes #27). When a pre-header timeout fired, the timeout handler called + `upstream.destroy()` before writing the 504. Destroying an in-flight + `ClientRequest` causes it to emit `error` (ECONNRESET) on the next tick; the + error handler's `responded` guard was inert on the pre-header path, so both + `anthropic_upstream_timeout` and `anthropic_upstream_error` were logged. On + `main` the actual response sequence was `writeHead(504)` → `res.end()` + (synchronous, in the timeout handler) → next tick, error handler sees + `headersSent === true` → `res.destroy()`; the client received a complete 504 body + and the real defect was the duplicate warn log only. A new `settled` flag is now + set by whichever handler responds first; the error handler returns early when + `settled` is true, ensuring exactly one warn event per timeout. + ## [0.2.0] - 2026-08-09 ### Upgrading from 0.1.0 diff --git a/README.md b/README.md index 060a985..b422170 100644 --- a/README.md +++ b/README.md @@ -277,8 +277,9 @@ All keys and their defaults: | `port` | `4141` | Port the proxy listens on | | `logLevel` | `"info"` | Log verbosity: `debug`, `info`, `warn`, or `error` | | `anthropic.baseUrl` | `"https://api.anthropic.com"` | Anthropic passthrough base URL | -| `anthropic.connectTimeoutMs` | `10000` (10 s) | **Anthropic leg only** — TCP connection timeout (see note below) | -| `anthropic.streamIdleTimeoutMs` | `300000` (5 min) | Anthropic stream idle timeout | +| `anthropic.connectTimeoutMs` | `10000` (10 s) | **Anthropic leg only** — TCP connection establishment timeout (see note below) | +| `anthropic.headerTimeoutMs` | `660000` (11 min) | **Anthropic leg only** — time from TCP connect to first response byte; defaults to 60 s above Anthropic's own ~600 s server-side ceiling so the relay never fires before the origin does (the relay's clock starts earlier than the origin's — see note below) | +| `anthropic.streamIdleTimeoutMs` | `300000` (5 min) | Anthropic stream idle timeout (headers→stream-end, reset by every chunk) | | `anthropic.maxUpstreamSockets` | `32` | **Anthropic leg only** — max sockets in the keep-alive pool (see note below) | | `anthropic.allowInsecureBaseUrl` | `false` | **Security opt-in** — when false (the default), `subswitch serve` refuses to start if `anthropic.baseUrl` points at a host other than `api.anthropic.com`. Set to `true` only when routing through a trusted proxy in front of Anthropic's API. Loopback addresses are always exempt. | | `providers.codex.baseUrl` | `"https://chatgpt.com/backend-api/codex"` | Codex backend base URL — override to route subswitch through the wire recorder | @@ -297,11 +298,11 @@ All keys and their defaults: | `limits.pingIntervalMs` | `15000` (15 s) | Interval between SSE ping frames sent to clients during long Codex streams | | `limits.maxConcurrentRequests` | `32` | In-flight request ceiling; requests above this limit receive a 503 | -> **Why `connectTimeoutMs` and `maxUpstreamSockets` are Anthropic-leg-only**: the +> **Why `connectTimeoutMs`, `headerTimeoutMs`, and `maxUpstreamSockets` are Anthropic-leg-only**: the > Anthropic passthrough uses a node:http agent with an explicit keep-alive pool, so -> both knobs have meaningful effect there. The Codex leg uses Node's global `fetch` -> (undici's global dispatcher), which `maxUpstreamSockets` does not control — shipping -> them as per-provider keys would be config that bounds nothing on the Codex side. +> all three knobs have meaningful effect there. The Codex leg uses Node's global `fetch` +> (undici's global dispatcher), which these knobs do not control — shipping them as +> per-provider keys would be config that bounds nothing on the Codex side. ### `subswitch models --json` @@ -394,6 +395,36 @@ is not a TTY (useful in terminals that misreport TTY state); `CI` — also suppr color and disables interactive `init` prompts (treated as a non-interactive environment). +## `x-subswitch-synthesized` + +Every HTTP response that subswitch generates itself — rather than proxying +verbatim from an upstream — carries the response header: + +``` +x-subswitch-synthesized: 1 +``` + +This header is present on: + +- **Anthropic-leg relay errors**: 502 (upstream connection failure), 504 + (upstream timeout), 413 (request body too large), 503 (concurrency gate), + 500 (internal proxy error). +- **Codex-leg responses**: every byte returned on the codex leg is synthesized + by the relay (it translates OpenAI Responses format → Anthropic Messages + format), so the header is present on both streaming and non-streaming codex + responses, and on all codex-leg error responses. +- **Relay management endpoints**: `/__subswitch/health`, `/__subswitch/404`, + and all other relay-internal routes. + +The header is **absent** on responses proxied verbatim from the Anthropic +origin — including upstream errors (429 rate-limit, 529 overloaded, 500 +upstream internal error, etc.). The header is also **stripped** from any +upstream response that carries it, so the marker is authoritative: its presence +means the relay synthesised the response; its absence means the upstream did. + +Operators can use this header in load-balancer health rules, log filters, or +alerting to distinguish relay faults from upstream outages. + ## Testing ```sh diff --git a/src/anthropic-passthrough.ts b/src/anthropic-passthrough.ts index 892ab98..fcae3df 100644 --- a/src/anthropic-passthrough.ts +++ b/src/anthropic-passthrough.ts @@ -12,6 +12,9 @@ import type { Logger } from "./logger.js"; * * Per RFC 7230 §6.1. `host` is also excluded: Node sets it on the outbound * connection. `connection` is managed by the keep-alive agent. + * + * `x-subswitch-synthesized` is also stripped from proxied responses so that + * the marker is authoritative: only the relay itself can assert it. */ const HOP_BY_HOP = new Set([ "host", @@ -24,6 +27,7 @@ const HOP_BY_HOP = new Set([ "trailer", "transfer-encoding", "upgrade", + "x-subswitch-synthesized", ]); /** @@ -53,7 +57,29 @@ const filterRawHeaders = (rawHeaders: readonly string[]): string[] => { export interface PassthroughOptions { readonly baseUrl: string; + /** + * Bounds TCP connection establishment only (milliseconds). + * The timer is armed directly on the socket (not via `ClientRequest.setTimeout`, + * which defers internally and cannot bound the connect phase). Once TCP connects, + * the timer is re-armed to `headerTimeoutMs`. + * + * On HTTPS connections, `'connect'` fires after TCP establishment but before the + * TLS handshake, so TLS negotiation falls under `headerTimeoutMs`, not this budget. + * + * For pooled/keep-alive sockets (no connect phase), `headerTimeoutMs` is armed + * immediately and this budget has no effect. + */ readonly connectTimeoutMs: number; + /** + * Bounds the connect→response-headers phase (time to first byte), in milliseconds. + * Armed on socket connect (or immediately for pooled sockets). + * Re-armed to `streamIdleTimeoutMs` once headers arrive. + */ + readonly headerTimeoutMs: number; + /** + * Bounds the headers→stream-end phase, in milliseconds. + * Reset by every received chunk. + */ readonly streamIdleTimeoutMs: number; readonly logger: Logger; /** Maximum sockets in the keep-alive pool. Config key: limits.maxUpstreamSockets. */ @@ -75,16 +101,17 @@ export const createAnthropicForwarder = (options: PassthroughOptions): Anthropic // Create a keep-alive agent for persistent connections to the upstream. // This matches Claude Code's own direct connection behaviour (parity). - // - // Residual risk: a stale pooled socket can ECONNRESET a POST; the existing - // upstream.on("error") handler returns a clean 502 and Claude Code retries. - // This is the same behaviour as any keep-alive HTTP client. const agentOpts: http.AgentOptions = { keepAlive: true, maxSockets: options.maxUpstreamSockets, scheduling: "lifo" }; const agent = options.agent ?? (target.protocol === "https:" ? new https.Agent(agentOpts) : new http.Agent(agentOpts)); return (req, res, body) => { const path = `${basePath}${req.url ?? "/"}`; - let responded = false; + // `settled` is set by whichever handler wins the race — the response + // callback (headers received) or the timeout handler (504 written). + // The error handler uses it as its early-return guard so that a + // destroy() issued by the timeout handler does not produce a duplicate + // `anthropic_upstream_error` warn after the 504 has already been sent. + let settled = false; const upstream = client.request( { @@ -100,11 +127,13 @@ export const createAnthropicForwarder = (options: PassthroughOptions): Anthropic // (cross-name position of interleaved duplicates is not guaranteed). }, (upstreamRes) => { - responded = true; + settled = true; upstream.setTimeout(options.streamIdleTimeoutMs); // Response direction: writeHead accepts a flat [name, value, ...] array // directly (Node's _storeHeader Array branch), preserving the upstream's // original header casing, order, and duplicates byte-for-byte. + // filterRawHeaders strips x-subswitch-synthesized so an origin that sets + // it cannot impersonate the relay's synthesized-response marker. res.writeHead(upstreamRes.statusCode ?? 502, filterRawHeaders(upstreamRes.rawHeaders)); res.socket?.setNoDelay(true); upstreamRes.pipe(res); @@ -112,8 +141,48 @@ export const createAnthropicForwarder = (options: PassthroughOptions): Anthropic }, ); - upstream.setTimeout(options.connectTimeoutMs); - upstream.on("socket", (socket) => socket.setNoDelay(true)); + // Timer arming — three-budget design: + // + // 1. connectTimeoutMs — bounds TCP establishment, armed DIRECTLY on the + // socket (not via upstream.setTimeout). ClientRequest.setTimeout() + // defers internally via its own 'connect' listener, so it would fire + // only after connect — in the same tick as the headerTimeoutMs rearm, + // leaving connectTimeoutMs never actually in force. + // + // Node v22's internal socket-timeout handler (onTimeout) skips + // req.emit('timeout') when socket.connecting is true, so we must + // propagate the timeout manually via upstream.emit('timeout'). + // On HTTPS, 'connect' fires after TCP but BEFORE the TLS handshake, so + // TLS negotiation falls under headerTimeoutMs, not connectTimeoutMs. + // + // 2. headerTimeoutMs — armed on 'connect' (or immediately for a pooled + // socket where no 'connect' event will ever fire). Bounds the window + // from TCP-connected to first response byte. upstream.setTimeout() in + // the connected state propagates normally via Node's internal handler. + // + // 3. streamIdleTimeoutMs — armed in the response callback once headers + // arrive; reset by every received chunk. + upstream.on("socket", (socket) => { + socket.setNoDelay(true); + if (socket.connecting) { + socket.setTimeout(options.connectTimeoutMs); + const onConnectTimeout = () => { + socket.removeListener("connect", onConnect); + socket.setTimeout(0); // disarm before manual propagation + upstream.emit("timeout"); // triggers our handler → 504 + }; + const onConnect = () => { + socket.removeListener("timeout", onConnectTimeout); + socket.setTimeout(0); // cancel connect budget + upstream.setTimeout(options.headerTimeoutMs); + }; + socket.once("timeout", onConnectTimeout); + socket.once("connect", onConnect); + } else { + // Pooled/keep-alive socket — no connect phase; arm header budget immediately. + upstream.setTimeout(options.headerTimeoutMs); + } + }); // Request direction: build a Map from the filtered rawHeaders so that // duplicates (same lowercase key, different values) are preserved as array @@ -140,29 +209,52 @@ export const createAnthropicForwarder = (options: PassthroughOptions): Anthropic } upstream.on("timeout", () => { - upstream.destroy(); + // Log and write the 504 BEFORE destroy() to narrow the race window with + // the 'error' handler. destroy() on an in-flight ClientRequest emits + // 'error' (ECONNRESET) on the next tick; setting `settled = true` here + // prevents that error from producing a duplicate warn log. options.logger.log("warn", "anthropic_upstream_timeout", { path: req.url ?? "/" }); if (!res.headersSent) { - res.writeHead(504, { "content-type": "application/json" }); + settled = true; + res.writeHead(504, { "content-type": "application/json", "x-subswitch-synthesized": "1" }); res.end(toAnthropicErrorBody("api_error", "upstream timed out")); } else { res.destroy(); } + upstream.destroy(); }); upstream.on("error", () => { - if (responded) return; + if (settled) return; + // Log and surface as 502 only when the client response is still open. + // Set settled before writing so a second 'error' emission (practically + // unreachable after socket destroy, but possible in theory) cannot + // double-write. options.logger.log("warn", "anthropic_upstream_error", { path: req.url ?? "/" }); if (!res.headersSent) { - res.writeHead(502, { "content-type": "application/json" }); + settled = true; + res.writeHead(502, { "content-type": "application/json", "x-subswitch-synthesized": "1" }); res.end(toAnthropicErrorBody("api_error", "upstream connection failed")); } else { res.destroy(); } }); + // Change 3: set settled before destroy() so the error event emitted by + // destroy() on the next tick does not trigger a spurious + // anthropic_upstream_error warn or attempt to write a 502 into an already- + // closed response. A client abort is an entirely normal event — it must + // produce no warn log and no synthetic HTTP response. + // + // Note: the timeout handler does not check `settled` before calling + // upstream.destroy() because it sets `settled = true` itself before the + // destroy, so by the time the 'error' event fires on the next tick the + // guard has already been set and the error handler returns early. res.on("close", () => { - if (!res.writableFinished) upstream.destroy(); + if (!res.writableFinished) { + settled = true; + upstream.destroy(); + } }); if (body !== undefined) { diff --git a/src/codex-handler.ts b/src/codex-handler.ts index 72f303d..258ea15 100644 --- a/src/codex-handler.ts +++ b/src/codex-handler.ts @@ -349,7 +349,7 @@ export const createCodexHandler =

(deps: CodexHandlerDeps< resetIdle(); if (wantStream) { - res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache" }); + res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", "x-subswitch-synthesized": "1" }); res.socket?.setNoDelay(true); // res is written manually rather than placed inside pipeline(): on an // upstream error, pipeline destroys every stream it owns, which would diff --git a/src/config.ts b/src/config.ts index 78e2e1c..8633114 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,9 +59,36 @@ const AnthropicSchema = z .url() .refine(requireHttpsOrLoopback, { message: `anthropic.baseUrl ${HTTPS_REQUIRED_MESSAGE}` }) .default("https://api.anthropic.com"), - /** Connection timeout for all upstream requests to the Anthropic leg. */ + /** + * TCP connection-establishment timeout for the Anthropic leg (milliseconds). + * Bounds only the time to establish a new TCP connection; the timer is armed + * directly on the socket (not via `ClientRequest.setTimeout`, which defers + * internally and cannot bound the connect phase). + * Once TCP connects the timer is re-armed to `headerTimeoutMs`. + * + * On HTTPS connections, `'connect'` fires after TCP establishment but before + * the TLS handshake, so TLS negotiation falls under `headerTimeoutMs`. + * + * Has no effect on reused pooled sockets (no connect phase). + */ connectTimeoutMs: z.number().int().positive().default(10_000), - /** Stream idle timeout for the Anthropic passthrough. */ + /** + * Time from TCP connection established to first response byte (headers), in + * milliseconds. Bounds only the connect→response-headers phase; once headers + * arrive the timer is re-armed to `streamIdleTimeoutMs` for the stream body. + * Defaults to 660 000 ms — 60 s above Anthropic's own ~600 s server-side + * ceiling — so the relay never fires before the origin does on a legitimate + * long-running request (e.g. a non-streaming Opus completion with large + * max_tokens). The extra 60 s headroom exists because the relay's clock + * starts at TCP connect whereas the origin's starts when it has received the + * full request body; equal budgets with an earlier start would let the relay + * pre-empt the origin by the request-upload time plus RTT. + */ + headerTimeoutMs: z.number().int().positive().default(660_000), + /** + * Stream idle timeout for the Anthropic passthrough (milliseconds). + * Bounds the headers→stream-end phase; reset by every received chunk. + */ streamIdleTimeoutMs: z.number().int().positive().default(300_000), /** Maximum sockets in the keep-alive pool for the Anthropic passthrough. */ maxUpstreamSockets: z.number().int().positive().default(32), @@ -296,6 +323,7 @@ export interface Config { readonly anthropic: { readonly baseUrl: string; readonly connectTimeoutMs: number; + readonly headerTimeoutMs: number; readonly streamIdleTimeoutMs: number; readonly maxUpstreamSockets: number; /** @@ -588,6 +616,7 @@ export const resolveConfig = (file: FileConfig): Config => ({ anthropic: { baseUrl: file.anthropic.baseUrl, connectTimeoutMs: file.anthropic.connectTimeoutMs, + headerTimeoutMs: file.anthropic.headerTimeoutMs, streamIdleTimeoutMs: file.anthropic.streamIdleTimeoutMs, maxUpstreamSockets: file.anthropic.maxUpstreamSockets, allowInsecureBaseUrl: file.anthropic.allowInsecureBaseUrl, diff --git a/src/provider-transport.ts b/src/provider-transport.ts index 614cc2c..a714162 100644 --- a/src/provider-transport.ts +++ b/src/provider-transport.ts @@ -6,6 +6,11 @@ import { proxyErrorToAnthropic, toAnthropicErrorBody, type ProxyError } from "./ /** * Send a JSON response, no-op if headers were already sent. + * + * Every response emitted by a provider handler is synthesized by the relay + * (the codex leg translates Codex→Anthropic; no byte is forwarded verbatim). + * `x-subswitch-synthesized: 1` is therefore always correct here and is + * included by default so callers cannot forget it. */ export const respondJson = ( res: ServerResponse, @@ -14,7 +19,7 @@ export const respondJson = ( extraHeaders: Record = {}, ): void => { if (res.headersSent) return; - res.writeHead(status, { "content-type": "application/json", ...extraHeaders }); + res.writeHead(status, { "content-type": "application/json", "x-subswitch-synthesized": "1", ...extraHeaders }); res.end(body); }; diff --git a/src/server.ts b/src/server.ts index 90f2a86..2d9d853 100644 --- a/src/server.ts +++ b/src/server.ts @@ -215,6 +215,7 @@ export const buildDeps = (config: Config, logger: Logger = createConsoleLogger(c forwardAnthropic: createAnthropicForwarder({ baseUrl: config.anthropic.baseUrl, connectTimeoutMs: config.anthropic.connectTimeoutMs, + headerTimeoutMs: config.anthropic.headerTimeoutMs, streamIdleTimeoutMs: config.anthropic.streamIdleTimeoutMs, maxUpstreamSockets: config.anthropic.maxUpstreamSockets, logger, @@ -308,6 +309,20 @@ const peekModel = (parsed: unknown): string | undefined => { return result.success ? result.data.model : undefined; }; +/** + * Base headers for every response the relay generates itself (as opposed to + * responses proxied verbatim from an upstream). The `x-subswitch-synthesized` + * marker is included here so callers cannot forget it and future synthesized + * response sites are correct by default. + * + * Pass extra headers (e.g. `{ connection: "close" }`) as `extra`. + */ +const synthesizedHeaders = (extra: Record = {}): Record => ({ + "content-type": "application/json", + "x-subswitch-synthesized": "1", + ...extra, +}); + export const createProxyServer = (deps: ServerDeps): Server => { const { config, logger } = deps; let activeRequests = 0; @@ -333,11 +348,11 @@ export const createProxyServer = (deps: ServerDeps): Server => { // /__subswitch/* namespace: handled locally, never forwarded upstream. if (pathname.startsWith("/__subswitch/")) { if (req.method === "GET" && pathname === "/__subswitch/health") { - res.writeHead(200, { "content-type": "application/json" }); + res.writeHead(200, synthesizedHeaders()); res.end(buildHealthBody(config)); return; } - res.writeHead(404, { "content-type": "application/json" }); + res.writeHead(404, synthesizedHeaders()); res.end(JSON.stringify({ error: "not found" })); return; } @@ -348,7 +363,7 @@ export const createProxyServer = (deps: ServerDeps): Server => { res.on("close", () => { activeRequests--; }); if (activeRequests > config.limits.maxConcurrentRequests) { route = "rate_limited"; - res.writeHead(503, { "content-type": "application/json" }); + res.writeHead(503, synthesizedHeaders()); res.end(toAnthropicErrorBody("overloaded_error", "too many concurrent requests — try again shortly")); return; } @@ -363,7 +378,7 @@ export const createProxyServer = (deps: ServerDeps): Server => { const body = await bufferBody(req, config.limits.maxBodyBytes); if (!body.ok) { if (body.error.kind === "body_too_large") { - res.writeHead(413, { "content-type": "application/json", connection: "close" }); + res.writeHead(413, synthesizedHeaders({ connection: "close" })); res.end(toAnthropicErrorBody("invalid_request_error", body.error.message)); req.destroy(); } @@ -416,7 +431,7 @@ export const createProxyServer = (deps: ServerDeps): Server => { // Two providers claim the same family name. Reject with 400 naming both. route = "ambiguous"; logger.log("warn", "ambiguous_model_name", { model: decision.name }); - res.writeHead(400, { "content-type": "application/json" }); + res.writeHead(400, synthesizedHeaders()); res.end( toAnthropicErrorBody( "invalid_request_error", @@ -430,7 +445,7 @@ export const createProxyServer = (deps: ServerDeps): Server => { // "kimee:k2" — provider prefix not in PROVIDER_IDS. Reject with 400. route = "unknown_provider"; logger.log("warn", "unknown_provider_qualifier", { model: decision.qualifier }); - res.writeHead(400, { "content-type": "application/json" }); + res.writeHead(400, synthesizedHeaders()); res.end( toAnthropicErrorBody( "invalid_request_error", @@ -452,7 +467,7 @@ export const createProxyServer = (deps: ServerDeps): Server => { dispatch().catch((cause: unknown) => { logger.log("error", "request_failed", { path: pathname, errorCode: cause instanceof Error ? cause.name : "unknown" }); if (!res.headersSent) { - res.writeHead(500, { "content-type": "application/json" }); + res.writeHead(500, synthesizedHeaders()); res.end(toAnthropicErrorBody("api_error", "internal proxy error")); } else if (!res.writableEnded) { res.destroy(); diff --git a/subswitch.config.example.json b/subswitch.config.example.json index dfdac41..ca75f21 100644 --- a/subswitch.config.example.json +++ b/subswitch.config.example.json @@ -4,6 +4,7 @@ "anthropic": { "baseUrl": "https://api.anthropic.com", "connectTimeoutMs": 10000, + "headerTimeoutMs": 660000, "streamIdleTimeoutMs": 300000, "maxUpstreamSockets": 32, "allowInsecureBaseUrl": false diff --git a/test/integration/codex-leg.test.ts b/test/integration/codex-leg.test.ts index a03be77..7f0346e 100644 --- a/test/integration/codex-leg.test.ts +++ b/test/integration/codex-leg.test.ts @@ -914,4 +914,44 @@ describe("codex leg", () => { assert.equal(sent["model"], "gpt-9-sol", "config override target must be sent upstream"); assert.equal(anthropic.requests.length, 0, "config override must route to Codex, not Anthropic"); }); + + // --------------------------------------------------------------------------- + // L3: x-subswitch-synthesized marker on codex-leg responses + // --------------------------------------------------------------------------- + // + // Every byte the codex leg returns is synthesized by the relay (Codex SSE is + // translated to Anthropic format; nothing is forwarded verbatim). Both the + // streaming path (res.writeHead(200)) and the non-streaming path (respondJson) + // must carry x-subswitch-synthesized: 1. + + it("L3: codex-leg streaming response carries x-subswitch-synthesized: 1", async () => { + const rig = await setupRig(sseHandler(loadSse("text-only.sse"))); + const response = await postMessages(rig.subswitch, loadRequest("simple-text.json")); + assert.equal(response.status, 200); + // Without fix: null (SSE writeHead had no marker). + // With fix: "1" (added to the SSE writeHead in codex-handler.ts). + assert.equal( + response.headers.get("x-subswitch-synthesized"), + "1", + "codex-leg streaming response must carry x-subswitch-synthesized: 1", + ); + await response.text(); // drain + }); + + it("L3: codex-leg non-streaming response carries x-subswitch-synthesized: 1", async () => { + const rig = await setupRig(sseHandler(loadSse("text-only.sse"))); + const response = await postMessages( + rig.subswitch, + JSON.stringify({ model: "gpt-5.5", max_tokens: 64, messages: [{ role: "user", content: "hi" }] }), + ); + assert.equal(response.status, 200); + // Without fix: null (respondJson did not include the marker). + // With fix: "1" (added to respondJson default headers in provider-transport.ts). + assert.equal( + response.headers.get("x-subswitch-synthesized"), + "1", + "codex-leg non-streaming response must carry x-subswitch-synthesized: 1", + ); + await response.json(); // drain + }); }); diff --git a/test/integration/passthrough.test.ts b/test/integration/passthrough.test.ts index 499650a..19ced38 100644 --- a/test/integration/passthrough.test.ts +++ b/test/integration/passthrough.test.ts @@ -1,5 +1,9 @@ import { describe, it, after } from "node:test"; import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import type { LogLevel } from "../../src/logger.js"; +import { createAnthropicForwarder } from "../../src/anthropic-passthrough.js"; import { startSubswitch, startFakeUpstream, rawHttpRequest, type SubswitchInstance, type FakeUpstream } from "./fake-upstreams.js"; const cleanups: (() => Promise)[] = []; @@ -335,4 +339,483 @@ describe("anthropic passthrough", () => { "response headers must reach client in original upstream order with original casing", ); }); + + // --------------------------------------------------------------------------- + // Timeout semantics — regression tests for issue #27 + // --------------------------------------------------------------------------- + // + // connectTimeoutMs must bound only TCP connection establishment. Once + // connected (or when a pooled socket is reused), the timer is re-armed to + // streamIdleTimeoutMs so that upstream think-time beyond connectTimeoutMs + // is not spuriously cut off with a 504. + + it("does not 504 when upstream think-time exceeds connectTimeoutMs but is under headerTimeoutMs", async () => { + // Upstream delays its response by 200 ms — intentionally longer than + // connectTimeoutMs (50 ms) but shorter than headerTimeoutMs (500 ms). + // Before the fix the 50 ms timer fired immediately after connect and + // produced a 504; after the fix the timer is re-armed to headerTimeoutMs + // (500 ms) on connect, so the 200 ms delay is within budget. + const anthropic = await startFakeUpstream((_req, res) => { + setTimeout(() => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ id: "msg_think_time" })); + }, 200); + }); + const subswitch = await startSubswitch({ + anthropic: { + baseUrl: anthropic.url, + connectTimeoutMs: 50, + headerTimeoutMs: 500, + streamIdleTimeoutMs: 500, + }, + }); + cleanups.push(subswitch.close, anthropic.close); + + const response = await fetch(`${subswitch.url}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }); + assert.equal( + response.status, + 200, + "response must be 200 — connectTimeoutMs must not cut off upstream think-time after connect", + ); + const body = (await response.json()) as { id: string }; + assert.equal(body.id, "msg_think_time"); + }); + + it("headerTimeoutMs fires and emits exactly one warn when upstream stalls before sending headers (no duplicate anthropic_upstream_error)", async () => { + // Upstream accepts the connection but never sends headers. + // After headerTimeoutMs (100 ms) the timeout handler fires, writes a + // 504, and calls upstream.destroy(). That destroy() makes the ClientRequest + // emit 'error'; the error handler must return early (via `settled`) and must + // NOT log a second anthropic_upstream_error warn. + const captured: Array<{ level: LogLevel; event: string }> = []; + const anthropic = await startFakeUpstream((_req, _res) => { + // Never responds — stall indefinitely so the header timer fires. + }); + const subswitch = await startSubswitch( + { + anthropic: { + baseUrl: anthropic.url, + connectTimeoutMs: 50, + headerTimeoutMs: 100, + }, + }, + { + logger: { + log(level, event) { + captured.push({ level, event }); + }, + }, + }, + ); + cleanups.push(subswitch.close, anthropic.close); + + const response = await fetch(`${subswitch.url}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }); + assert.equal(response.status, 504, "timed-out upstream must produce a 504"); + await response.text(); // drain + + // Allow one extra event-loop turn for any stray error events to land. + await new Promise((resolve) => setTimeout(resolve, 50)); + + const upstreamEvents = captured.filter((e) => e.event.startsWith("anthropic_upstream")); + assert.equal(upstreamEvents.length, 1, `expected exactly 1 upstream warn event, got: ${JSON.stringify(upstreamEvents)}`); + assert.equal(upstreamEvents[0]!.event, "anthropic_upstream_timeout", "the single warn must be anthropic_upstream_timeout"); + assert.equal(upstreamEvents[0]!.level, "warn"); + }); + + it("re-arms headerTimeoutMs immediately on a pooled (keep-alive) socket", async () => { + // The pooled socket path takes the `else rearm()` branch because + // socket.connecting is false on a reused socket — no 'connect' event fires. + // Verify that a second request whose upstream think-time exceeds + // connectTimeoutMs but is under headerTimeoutMs still succeeds. + let requestIndex = 0; + const anthropic = await startFakeUpstream((_req, res) => { + const idx = requestIndex++; + if (idx === 0) { + // First request: respond immediately to establish the pooled socket. + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ id: "first" })); + } else { + // Second request: delay 200 ms — longer than connectTimeoutMs (50 ms), + // shorter than headerTimeoutMs (500 ms). On a reused socket the + // `else rearm()` branch must arm the 500 ms headerTimeoutMs budget immediately. + setTimeout(() => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ id: "pooled" })); + }, 200); + } + }); + const subswitch = await startSubswitch({ + anthropic: { + baseUrl: anthropic.url, + connectTimeoutMs: 50, + headerTimeoutMs: 500, + streamIdleTimeoutMs: 500, + }, + }); + cleanups.push(subswitch.close, anthropic.close); + + const postOpts = { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + } as const; + + // First request: builds the pooled connection. + const r1 = await fetch(`${subswitch.url}/v1/messages`, postOpts); + assert.equal(r1.status, 200); + await r1.text(); + + // Second request: reuses the pooled socket (else rearm() branch). + const r2 = await fetch(`${subswitch.url}/v1/messages`, postOpts); + assert.equal( + r2.status, + 200, + "pooled socket: headerTimeoutMs must be armed immediately via else rearm(), not cut off at connectTimeoutMs", + ); + const body2 = (await r2.json()) as { id: string }; + assert.equal(body2.id, "pooled"); + + // Both requests must have used one TCP connection (keep-alive reuse). + assert.equal(anthropic.connectionCount, 1, "keep-alive: both requests must share one TCP connection"); + }); + + it("streamIdleTimeoutMs fires when the stream goes idle after headers are received", async () => { + // headerTimeoutMs (5 s) cannot be the knob that fires here — only streamIdleTimeoutMs + // (100 ms) can. We send 6 chunks ~50 ms apart (~300 ms of active streaming) before + // stalling. If chunks did NOT reset the idle timer, the 100 ms budget would fire + // during the active-streaming window and the body would be truncated before we receive + // all 6 chunks — the assertion on chunk count would then catch it. This pins the + // "reset by every received chunk" invariant as a falsifiable property, not mere prose. + const captured: Array<{ level: LogLevel; event: string }> = []; + const anthropic = await startFakeUpstream((_req, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }); + let sent = 0; + const interval = setInterval(() => { + sent++; + res.write(`event: ping\ndata: ${sent}\n\n`); + if (sent >= 6) { + // Stop sending — stream goes idle; res.end() is never called. + clearInterval(interval); + } + }, 50); + }); + const subswitch = await startSubswitch( + { + anthropic: { + baseUrl: anthropic.url, + connectTimeoutMs: 50, + headerTimeoutMs: 5_000, + streamIdleTimeoutMs: 100, + }, + }, + { + logger: { + log(level, event) { + captured.push({ level, event }); + }, + }, + }, + ); + cleanups.push(subswitch.close, anthropic.close); + + const response = await fetch(`${subswitch.url}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }); + + // The upstream sent headers before stalling — client must see 200. + assert.equal(response.status, 200, "upstream sent headers before going idle"); + + // Collect the body; the server calls res.destroy() once streamIdleTimeoutMs fires, + // so the body is truncated mid-stream. Node fetch (undici) rejects the read with + // a network error on a destroyed socket. + // + // Timing bound: after the last chunk (~300 ms of streaming), the 100 ms idle + // timer fires, so the body read must complete (by throwing) within 2 s. If + // streamIdleTimeoutMs were huge (e.g. 10 s), the read would hang for 10 s and + // this assertion would fail — making the test non-vacuous. + const bodyReadStart = Date.now(); + let receivedBody = ""; + let bodyThrew = false; + try { + receivedBody = await response.text(); + } catch { + bodyThrew = true; + } + const bodyElapsedMs = Date.now() - bodyReadStart; + + // The body must be incomplete: either the read threw (connection reset) or the + // received text contains the streamed chunks but no clean termination. + assert.ok( + bodyThrew || receivedBody.includes("event: ping"), + "body must be truncated (threw) or contain the partial SSE chunks", + ); + assert.ok(bodyThrew, "reading the body must throw — res.destroy() tears down the socket mid-stream"); + assert.ok( + bodyElapsedMs < 2_000, + `body read must complete within 2 s (elapsed: ${bodyElapsedMs} ms) — streamIdleTimeoutMs must fire promptly, not after a huge delay`, + ); + + // Allow one event-loop turn for any stray events to settle. + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Exactly one streamIdleTimeoutMs warn must be emitted. headerTimeoutMs (5 s) + // cannot have fired — the total test duration is well under 5 s. + const timeoutEvents = captured.filter((e) => e.event === "anthropic_upstream_timeout"); + assert.equal( + timeoutEvents.length, + 1, + `expected exactly 1 anthropic_upstream_timeout event, got: ${JSON.stringify(timeoutEvents)}`, + ); + assert.equal(timeoutEvents[0]!.level, "warn"); + }); + + // --------------------------------------------------------------------------- + // connectTimeoutMs non-vacuity — proves the timer fires during TCP connect + // --------------------------------------------------------------------------- + // + // This test requires a host/port that accepts SYN packets but never sends + // SYN-ACK, keeping socket.connecting === true for the duration of the budget. + // 192.0.2.1 (TEST-NET-1, RFC 5737) is used: it is documentation-only, has no + // real host, and on systems with a default route the SYN is forwarded but + // never answered — a true blackhole. + // + // CI note: on isolated containers with NO default route the kernel returns + // ENETUNREACH immediately, producing a 502 error rather than a 504 timeout. + // If this test fails with 502 on CI, the host has no route to 192.0.2.1 and + // the blackhole approach is not viable there. + // + // Non-vacuity: without the fix (upstream.setTimeout instead of socket.setTimeout), + // this test hangs until the macOS/Linux kernel TCP timeout (~75 s) or the + // 30 s --test-timeout limit, confirming the test discriminates the two states. + + it("connectTimeoutMs fires during TCP connect to a non-routable upstream (192.0.2.1)", async () => { + const CONNECT_MS = 250; + const captured: Array<{ level: LogLevel; event: string }> = []; + + // Call createAnthropicForwarder directly so we can use plain HTTP without + // the config-layer HTTPS-or-loopback validation. The agent test seam is + // not needed here — the real socket must connect (and fail) so socket.connecting + // is genuinely true during the budget window. + const forwarder = createAnthropicForwarder({ + baseUrl: "http://192.0.2.1:80", + connectTimeoutMs: CONNECT_MS, + headerTimeoutMs: 30_000, + streamIdleTimeoutMs: 30_000, + maxUpstreamSockets: 1, + logger: { + log(level: LogLevel, event: string) { + captured.push({ level, event }); + }, + }, + }); + + const server = http.createServer((req, res) => forwarder(req, res)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + + const start = Date.now(); + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "test" }), + }); + const elapsed = Date.now() - start; + + await new Promise((resolve) => server.close(() => resolve())); + + assert.equal( + response.status, + 504, + `expected 504 (connect timeout after ${CONNECT_MS} ms); got ${response.status} after ${elapsed} ms. ` + + `If 502: 192.0.2.1 returned ENETUNREACH (no default route — host has no path to TEST-NET-1). ` + + `If 504 but elapsed >> ${CONNECT_MS}: connectTimeoutMs did not actually bound TCP connect.`, + ); + + // Without the fix this hangs ~75 000 ms (macOS kernel TCP timeout) or until + // the 30 s test timeout — well above the 4× upper bound below. + assert.ok( + elapsed < CONNECT_MS * 4, + `connect should time out at ~${CONNECT_MS} ms; elapsed ${elapsed} ms is too high — ` + + `connectTimeoutMs is not bounding TCP connect (unfixed code hangs ~75 000 ms or test-timeout)`, + ); + assert.ok( + elapsed >= CONNECT_MS * 0.5, + `timer fired before the connectTimeoutMs budget (elapsed: ${elapsed} ms < ${CONNECT_MS * 0.5} ms)`, + ); + + // The settled de-dup guard must hold on the connect-timeout path too: + // exactly one anthropic_upstream_timeout warn, no anthropic_upstream_error. + const upstreamEvents = captured.filter((e) => e.event.startsWith("anthropic_upstream")); + assert.equal( + upstreamEvents.length, + 1, + `expected exactly 1 upstream warn event; got: ${JSON.stringify(upstreamEvents)}`, + ); + assert.equal(upstreamEvents[0]!.event, "anthropic_upstream_timeout"); + assert.equal(upstreamEvents[0]!.level, "warn"); + }); + + // --------------------------------------------------------------------------- + // L3: x-subswitch-synthesized marker header + // --------------------------------------------------------------------------- + // + // Every response the relay generates itself — 502 (connection failure), + // 504 (timeout), 500 (internal proxy error), 413 (body too large), 503 + // (concurrency gate) — carries x-subswitch-synthesized: 1 so operators can + // distinguish relay faults from upstream faults. Responses proxied from the + // origin (including upstream errors such as 429) must NOT carry this header. + // Additionally, x-subswitch-synthesized is stripped from proxied responses + // so that an origin setting it cannot impersonate the relay's marker. + + it("L3: x-subswitch-synthesized: 1 is present on a relay-synthesised 504 timeout response", async () => { + const anthropic = await startFakeUpstream((_req, _res) => { + // Never responds — triggers headerTimeoutMs. + }); + const subswitch = await startSubswitch({ + anthropic: { baseUrl: anthropic.url, headerTimeoutMs: 80 }, + }); + cleanups.push(subswitch.close, anthropic.close); + + const response = await fetch(`${subswitch.url}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }); + + // Without fix: header absent. With fix: "1". + assert.equal(response.status, 504); + assert.equal( + response.headers.get("x-subswitch-synthesized"), + "1", + "relay-synthesised 504 must carry x-subswitch-synthesized: 1", + ); + }); + + it("L3: x-subswitch-synthesized is ABSENT on a successfully proxied origin response", async () => { + const { subswitch } = await setup((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ id: "ok" })); + }); + + const response = await fetch(`${subswitch.url}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }); + + assert.equal(response.status, 200); + // Must be absent — the origin produced this response, not the relay. + assert.equal( + response.headers.get("x-subswitch-synthesized"), + null, + "proxied origin 200 must NOT carry x-subswitch-synthesized", + ); + }); + + it("L3: x-subswitch-synthesized is ABSENT on a proxied origin ERROR response such as 429", async () => { + // A 429 that the upstream produced must reach the client unchanged — + // the relay must not inject x-subswitch-synthesized on responses it merely forwarded. + const { subswitch } = await setup((_req, res) => { + res.writeHead(429, { "content-type": "application/json", "retry-after": "30" }); + res.end(JSON.stringify({ type: "error", error: { type: "rate_limit_error", message: "too many requests" } })); + }); + + const response = await fetch(`${subswitch.url}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }); + + assert.equal(response.status, 429); + // Without the marker: null. The relay must only mark what IT produces. + assert.equal( + response.headers.get("x-subswitch-synthesized"), + null, + "proxied upstream 429 error must NOT carry x-subswitch-synthesized", + ); + }); + + it("L3: x-subswitch-synthesized set by the upstream is STRIPPED from the proxied response", async () => { + // An upstream that sets x-subswitch-synthesized must not have it forwarded + // to the client — the relay strips it so the marker is authoritative (only + // the relay can assert it). + const { subswitch } = await setup((_req, res) => { + res.writeHead(200, { "content-type": "application/json", "x-subswitch-synthesized": "upstream-injected" }); + res.end(JSON.stringify({ id: "ok" })); + }); + + const response = await fetch(`${subswitch.url}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }); + + assert.equal(response.status, 200); + // Without fix: "upstream-injected" (forwarded verbatim). + // With fix: null (stripped by filterRawHeaders adding it to HOP_BY_HOP). + assert.equal( + response.headers.get("x-subswitch-synthesized"), + null, + "upstream-set x-subswitch-synthesized must be stripped from the proxied response", + ); + }); + + // --------------------------------------------------------------------------- + // Change 3: client abort must not produce anthropic_upstream_error warn + // --------------------------------------------------------------------------- + + it("Change 3: aborting the client mid-request produces no anthropic_upstream_error warn", async () => { + // Upstream never responds (stalls); client aborts after 60 ms. + // res.on("close") fires → settled=true → upstream.destroy() → error fires + // → settled guard prevents spurious warn and 502 attempt. + const captured: Array<{ level: string; event: string }> = []; + + const anthropic = await startFakeUpstream((_req, _res) => { + // Stall — never send a response. + }); + const subswitch = await startSubswitch( + { anthropic: { baseUrl: anthropic.url, headerTimeoutMs: 10_000 } }, + { logger: { log(level, event) { captured.push({ level, event }); } } }, + ); + cleanups.push(subswitch.close, anthropic.close); + + const controller = new AbortController(); + const fetchPromise = fetch(`${subswitch.url}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + signal: controller.signal, + }); + + // Abort before upstream has a chance to respond. + setTimeout(() => controller.abort(), 60); + + try { + await fetchPromise; + } catch { + // Expected: AbortError from the client-side abort. + } + + // Allow the event loop to drain so any stray error events settle. + await new Promise((resolve) => setTimeout(resolve, 100)); + + const warnLogs = captured.filter((e) => e.event === "anthropic_upstream_error"); + // Without fix: settled=false when res closes → upstream.destroy() → error fires → warn logged. + // With fix: settled=true before destroy() → error fires → settled guard returns early → no warn. + assert.equal( + warnLogs.length, + 0, + `client abort must not produce anthropic_upstream_error warn; got: ${JSON.stringify(warnLogs)}`, + ); + }); }); diff --git a/test/integration/server-wiring.test.ts b/test/integration/server-wiring.test.ts index 1e2abb7..b7c4efa 100644 --- a/test/integration/server-wiring.test.ts +++ b/test/integration/server-wiring.test.ts @@ -53,6 +53,7 @@ const makeMinimalConfig = (overrides: { anthropic: { baseUrl: anthropicBaseUrl, connectTimeoutMs: 10_000, + headerTimeoutMs: 600_000, streamIdleTimeoutMs: 300_000, maxUpstreamSockets: 32, allowInsecureBaseUrl: anthropicAllowInsecureBaseUrl, diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 3d3da95..0e8152a 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -27,6 +27,7 @@ describe("loadConfig", () => { assert.equal(result.value.config.logLevel, "info"); assert.equal(result.value.config.anthropic.baseUrl, "https://api.anthropic.com"); assert.equal(result.value.config.anthropic.connectTimeoutMs, 10_000); + assert.equal(result.value.config.anthropic.headerTimeoutMs, 660_000); assert.equal(result.value.config.anthropic.streamIdleTimeoutMs, 300_000); assert.equal(result.value.config.anthropic.maxUpstreamSockets, 32); assert.equal(result.value.config.providers.codex.baseUrl, "https://chatgpt.com/backend-api/codex"); diff --git a/test/unit/doctor.test.ts b/test/unit/doctor.test.ts index 6cf6938..d829605 100644 --- a/test/unit/doctor.test.ts +++ b/test/unit/doctor.test.ts @@ -132,6 +132,7 @@ const makeTestConfig = (): Config => ({ anthropic: { baseUrl: "https://api.anthropic.com", connectTimeoutMs: 10_000, + headerTimeoutMs: 600_000, streamIdleTimeoutMs: 300_000, maxUpstreamSockets: 32, allowInsecureBaseUrl: false,