From 0c8c7704978a15cc690cd84df63728a15da9f2da Mon Sep 17 00:00:00 2001 From: "R.D." Date: Tue, 14 Apr 2026 17:34:46 -0400 Subject: [PATCH 1/4] fix(cc-compatible): restore SSE upstream and active stream timeouts --- README.md | 36 +++++------ open-sse/config/constants.ts | 4 +- open-sse/executors/base.ts | 60 ++++++++++++++----- open-sse/handlers/chatCore.ts | 8 +-- src/app/(dashboard)/dashboard/combos/page.tsx | 2 +- .../settings/components/ComboDefaultsTab.tsx | 4 +- src/shared/validation/schemas.ts | 2 +- tests/unit/cc-compatible-provider.test.mjs | 9 +-- .../unit/chatcore-translation-paths.test.mjs | 4 +- tests/unit/combo-config.test.mjs | 16 +++++ tests/unit/executor-default-base.test.mjs | 41 ++++++++++++- 11 files changed, 136 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index e4411783c1a7..4084284a4929 100644 --- a/README.md +++ b/README.md @@ -792,10 +792,10 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo For most deployments, you only need: -| Variable | Default | Purpose | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| Variable | Default | Purpose | +| ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. @@ -803,19 +803,21 @@ For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute al Advanced overrides are available if you need finer control: -| Variable | Default | Purpose | -| ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | -| `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | -| `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | -| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | -| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Undici idle keep-alive socket timeout | -| `TLS_CLIENT_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Timeout for TLS fingerprint requests made through `wreq-js` | -| `API_BRIDGE_PROXY_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` or `30000` | Timeout for `/v1` proxy forwarding from API port to dashboard port | -| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | Incoming request timeout on the API bridge server | -| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server | -| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | -| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +| Variable | Default | Purpose | +| -------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------ | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | +| `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | +| `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | +| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | +| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Undici idle keep-alive socket timeout | +| `TLS_CLIENT_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Timeout for TLS fingerprint requests made through `wreq-js` | +| `API_BRIDGE_PROXY_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` or `30000` | Timeout for `/v1` proxy forwarding from API port to dashboard port | +| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | Incoming request timeout on the API bridge server | + +For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). +| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server | +| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | +| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts. diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 52ae37916ee7..be34098c099b 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -6,7 +6,9 @@ const upstreamTimeouts = getUpstreamTimeoutConfig(process.env, (message) => { console.warn(`[open-sse] ${message}`); }); -// Timeout for non-streaming fetch requests (ms). Prevents stalled connections. +// Timeout for receiving the initial upstream response (ms). +// After headers arrive, active SSE streams are governed by STREAM_IDLE_TIMEOUT_MS +// and Undici's bodyTimeout instead of this one-shot startup timer. export const FETCH_TIMEOUT_MS = upstreamTimeouts.fetchTimeoutMs; // Idle timeout for SSE streams (ms). Closes stream if no data for this duration. diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 20e149f8dcf3..0c52b0e20282 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -109,19 +109,23 @@ export function applyConfiguredUserAgent( export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal { const controller = new AbortController(); - const abortBoth = () => { + const abortFrom = (source: AbortSignal) => { if (!controller.signal.aborted) { - controller.abort(); + controller.abort(source.reason); } }; - if (primary.aborted || secondary.aborted) { - abortBoth(); + if (primary.aborted) { + abortFrom(primary); + return controller.signal; + } + if (secondary.aborted) { + abortFrom(secondary); return controller.signal; } - primary.addEventListener("abort", abortBoth, { once: true }); - secondary.addEventListener("abort", abortBoth, { once: true }); + primary.addEventListener("abort", () => abortFrom(primary), { once: true }); + secondary.addEventListener("abort", () => abortFrom(secondary), { once: true }); return controller.signal; } @@ -231,6 +235,9 @@ export class BaseExecutor { // Intra-URL retry config: retry same URL before falling back to next node static readonly RETRY_CONFIG = { maxAttempts: 2, delayMs: 2000 }; + // Timeout for receiving the initial upstream response headers. Once the response + // starts streaming, STREAM_IDLE_TIMEOUT_MS / Undici bodyTimeout handle stalls. + static FETCH_START_TIMEOUT_MS = FETCH_TIMEOUT_MS; // Override in subclass for provider-specific refresh async refreshCredentials(credentials: ProviderCredentials, log: ExecutorLog | null) { @@ -313,12 +320,26 @@ export class BaseExecutor { const transformedBody = await this.transformRequest(model, body, stream, activeCredentials); try { - // Apply timeout to all requests. Non-streaming requests need this to prevent - // stalled connections. Streaming requests also need it for the initial fetch() call - // to prevent hanging on unresponsive providers (e.g. 300s TCP default timeout — #769). - // Stream idle detection (STREAM_IDLE_TIMEOUT_MS) handles stalls after data starts flowing. - const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); - const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; + // Only enforce the timeout while waiting for the initial fetch() response. + // Once headers arrive, active streams must not be cut off by total elapsed time; + // post-start stalls are handled separately by STREAM_IDLE_TIMEOUT_MS / bodyTimeout. + const fetchStartTimeoutMs = BaseExecutor.FETCH_START_TIMEOUT_MS; + const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null; + let timeoutId: ReturnType | null = null; + if (timeoutController) { + timeoutId = setTimeout(() => { + const timeoutError = new Error( + `Fetch timeout after ${fetchStartTimeoutMs}ms on ${url}` + ); + timeoutError.name = "TimeoutError"; + timeoutController.abort(timeoutError); + }, fetchStartTimeoutMs); + } + const timeoutSignal = timeoutController?.signal ?? null; + const combinedSignal = + signal && timeoutSignal + ? mergeAbortSignals(signal, timeoutSignal) + : signal || timeoutSignal; // Apply CLI fingerprint ordering if enabled for this provider let finalHeaders = headers; @@ -346,7 +367,15 @@ export class BaseExecutor { }; if (combinedSignal) fetchOptions.signal = combinedSignal; - const response = await fetch(url, fetchOptions); + let response; + try { + response = await fetch(url, fetchOptions); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + } // Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL if ( @@ -375,7 +404,10 @@ export class BaseExecutor { // Distinguish timeout errors from other abort errors const err = error instanceof Error ? error : new Error(String(error)); if (err.name === "TimeoutError") { - log?.warn?.("TIMEOUT", `Fetch timeout after ${FETCH_TIMEOUT_MS}ms on ${url}`); + log?.warn?.( + "TIMEOUT", + `Fetch timeout after ${BaseExecutor.FETCH_START_TIMEOUT_MS}ms on ${url}` + ); } lastError = err; if (urlIndex + 1 < fallbackCount) { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 1f377892199f..bf582afe2812 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -962,10 +962,10 @@ export async function handleChatCore({ let translatedBody = body; const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE; const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider); - // Respect the client's explicit non-streaming intent for CC-compatible providers. - // Most upstreams can answer JSON directly; the SSE->JSON fallback remains as a - // compatibility path when an upstream still responds with event-stream. - const upstreamStream = stream; + // CC-compatible providers are most reliable when OmniRoute always requests SSE + // upstream. If the client asked for JSON, chatCore will still collect the SSE + // response and return a non-streaming payload after the stream finishes. + const upstreamStream = isClaudeCodeCompatible ? true : stream; let ccSessionId: string | null = null; // Determine if we should preserve client-side cache_control headers diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index a94d9b7576df..b460615de6d0 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -3195,7 +3195,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index f99fb200e295..3f2fe06f9ef3 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -204,7 +204,7 @@ const comboRuntimeConfigSchema = z strategy: comboStrategySchema.optional(), maxRetries: z.coerce.number().int().min(0).max(10).optional(), retryDelayMs: z.coerce.number().int().min(0).max(60000).optional(), - timeoutMs: z.coerce.number().int().min(1000).max(600000).optional(), + timeoutMs: z.coerce.number().int().min(1000).max(1200000).optional(), concurrencyPerModel: z.coerce.number().int().min(1).max(20).optional(), queueTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional(), healthCheckEnabled: z.boolean().optional(), diff --git a/tests/unit/cc-compatible-provider.test.mjs b/tests/unit/cc-compatible-provider.test.mjs index 0f273c913aa0..d7a4aa664a22 100644 --- a/tests/unit/cc-compatible-provider.test.mjs +++ b/tests/unit/cc-compatible-provider.test.mjs @@ -443,7 +443,7 @@ test("validateProviderApiKey uses CC skeleton request after /models fallback", a assert.equal(calls[1].headers.Accept, "text/event-stream"); }); -test("handleChatCore respects non-streaming upstream requests for CC compatible providers", async () => { +test("handleChatCore forces SSE upstream for CC compatible providers while returning JSON to non-stream clients", async () => { const calls = []; globalThis.fetch = async (url, init = {}) => { calls.push({ @@ -518,11 +518,8 @@ test("handleChatCore respects non-streaming upstream requests for CC compatible assert.equal(result.success, true); assert.equal(calls.length, 1); - assert.equal(calls[0].headers.Accept, "application/json"); - assert.equal(calls[0].body.stream, undefined); - // PR #1188: billing header system block carries cache_control: ephemeral for - // proper billing attribution. Only user-facing message blocks should be free of - // auto-injected cache markers (non-preserve mode). + assert.equal(calls[0].headers.Accept, "text/event-stream"); + assert.equal(calls[0].body.stream, true); assert.equal( calls[0].body.messages.some((message) => message.content.some((block) => block.cache_control !== undefined) diff --git a/tests/unit/chatcore-translation-paths.test.mjs b/tests/unit/chatcore-translation-paths.test.mjs index ff1948ae6b9c..42a9909072b0 100644 --- a/tests/unit/chatcore-translation-paths.test.mjs +++ b/tests/unit/chatcore-translation-paths.test.mjs @@ -457,8 +457,8 @@ test("chatCore builds Claude Code-compatible upstream requests for CC providers" }); assert.equal(result.success, true); - assert.equal(call.headers.Accept ?? call.headers.accept, "application/json"); - assert.equal(call.body.stream, undefined); + assert.equal(call.headers.Accept ?? call.headers.accept, "text/event-stream"); + assert.equal(call.body.stream, true); assert.equal(call.body.context_management.edits[0].type, "clear_thinking_20251015"); assert.equal(typeof call.body.metadata.user_id, "string"); assert.equal(call.body.messages[0].role, "user"); diff --git a/tests/unit/combo-config.test.mjs b/tests/unit/combo-config.test.mjs index f26725bbc3ee..f90e1d316aac 100644 --- a/tests/unit/combo-config.test.mjs +++ b/tests/unit/combo-config.test.mjs @@ -82,6 +82,22 @@ test("resolveComboConfig ignores null and undefined overrides", () => { assert.equal(result.strategy, "priority"); }); +test("updateComboDefaultsSchema accepts 20 minute timeout defaults and provider overrides", () => { + const parsed = updateComboDefaultsSchema.parse({ + comboDefaults: { + timeoutMs: 1200000, + }, + providerOverrides: { + anthropic: { + timeoutMs: 1200000, + }, + }, + }); + + assert.equal(parsed.comboDefaults.timeoutMs, 1200000); + assert.equal(parsed.providerOverrides.anthropic.timeoutMs, 1200000); +}); + test("resolveComboConfig preserves explicit empty handoffProviders overrides", () => { const result = resolveComboConfig( { diff --git a/tests/unit/executor-default-base.test.mjs b/tests/unit/executor-default-base.test.mjs index 9b374a8e64f0..0fa4a12423de 100644 --- a/tests/unit/executor-default-base.test.mjs +++ b/tests/unit/executor-default-base.test.mjs @@ -371,14 +371,19 @@ test("BaseExecutor.mergeAbortSignals aborts when either source signal aborts", ( const merged = mergeAbortSignals(primary.signal, secondary.signal); assert.equal(merged.aborted, false); - primary.abort(); + const primaryReason = new Error("primary timeout"); + primaryReason.name = "TimeoutError"; + primary.abort(primaryReason); assert.equal(merged.aborted, true); + assert.equal(merged.reason, primaryReason); const otherPrimary = new AbortController(); const otherSecondary = new AbortController(); const merged2 = mergeAbortSignals(otherPrimary.signal, otherSecondary.signal); - otherSecondary.abort(); + const secondaryReason = new Error("client closed"); + otherSecondary.abort(secondaryReason); assert.equal(merged2.aborted, true); + assert.equal(merged2.reason, secondaryReason); }); test("BaseExecutor.needsRefresh returns true only when expiry is near", () => { @@ -629,3 +634,35 @@ test("BaseExecutor.execute propagates aborted requests through the merged signal globalThis.fetch = originalFetch; } }); + +test("BaseExecutor.execute clears the startup timeout after headers arrive", async () => { + const executor = new TestExecutor({ baseUrls: ["https://single.example/v1/chat/completions"] }); + const originalFetch = globalThis.fetch; + const originalFetchStartTimeoutMs = BaseExecutor.FETCH_START_TIMEOUT_MS; + let capturedSignal; + + BaseExecutor.FETCH_START_TIMEOUT_MS = 20; + globalThis.fetch = async (_url, options) => { + capturedSignal = options.signal; + return new Response("ok", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await executor.execute({ + model: "gpt-4.1", + body: {}, + stream: true, + credentials: {}, + }); + + assert.equal(capturedSignal?.aborted, false); + await new Promise((resolve) => setTimeout(resolve, 40)); + assert.equal(capturedSignal?.aborted, false); + } finally { + BaseExecutor.FETCH_START_TIMEOUT_MS = originalFetchStartTimeoutMs; + globalThis.fetch = originalFetch; + } +}); From 9a282a4726e1c0c7e7caa82f336661fbdb056470 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Tue, 14 Apr 2026 17:39:49 -0400 Subject: [PATCH 2/4] fix(combo-timeouts): remove timeout caps from combo settings --- src/app/(dashboard)/dashboard/combos/page.tsx | 1 - .../dashboard/settings/components/ComboDefaultsTab.tsx | 3 +-- src/shared/validation/schemas.ts | 2 +- tests/unit/combo-config.test.mjs | 10 +++++----- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index b460615de6d0..5cd52d6bf66f 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -3195,7 +3195,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 3f2fe06f9ef3..d22a53784f03 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -204,7 +204,7 @@ const comboRuntimeConfigSchema = z strategy: comboStrategySchema.optional(), maxRetries: z.coerce.number().int().min(0).max(10).optional(), retryDelayMs: z.coerce.number().int().min(0).max(60000).optional(), - timeoutMs: z.coerce.number().int().min(1000).max(1200000).optional(), + timeoutMs: z.coerce.number().int().min(1000).optional(), concurrencyPerModel: z.coerce.number().int().min(1).max(20).optional(), queueTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional(), healthCheckEnabled: z.boolean().optional(), diff --git a/tests/unit/combo-config.test.mjs b/tests/unit/combo-config.test.mjs index f90e1d316aac..014ff4e44adb 100644 --- a/tests/unit/combo-config.test.mjs +++ b/tests/unit/combo-config.test.mjs @@ -82,20 +82,20 @@ test("resolveComboConfig ignores null and undefined overrides", () => { assert.equal(result.strategy, "priority"); }); -test("updateComboDefaultsSchema accepts 20 minute timeout defaults and provider overrides", () => { +test("updateComboDefaultsSchema accepts arbitrarily large timeout defaults and provider overrides", () => { const parsed = updateComboDefaultsSchema.parse({ comboDefaults: { - timeoutMs: 1200000, + timeoutMs: 3600000, }, providerOverrides: { anthropic: { - timeoutMs: 1200000, + timeoutMs: 5400000, }, }, }); - assert.equal(parsed.comboDefaults.timeoutMs, 1200000); - assert.equal(parsed.providerOverrides.anthropic.timeoutMs, 1200000); + assert.equal(parsed.comboDefaults.timeoutMs, 3600000); + assert.equal(parsed.providerOverrides.anthropic.timeoutMs, 5400000); }); test("resolveComboConfig preserves explicit empty handoffProviders overrides", () => { From be03076e744ae3e61e6801a73bcab48073561c95 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Tue, 14 Apr 2026 21:11:07 -0400 Subject: [PATCH 3/4] docs(cc-compatible): clarify compatibility-first bridge intent --- open-sse/handlers/chatCore.ts | 6 ++++-- open-sse/services/claudeCodeCompatible.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index bf582afe2812..94ad75c21fc1 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -992,8 +992,10 @@ export async function handleChatCore({ } else if (isClaudeCodeCompatible) { let normalizedForCc = { ...body }; - // Claude Code-compatible providers expect Anthropic Messages-shaped payloads, - // but we extract only role/text/max_tokens/effort from an OpenAI-like view first. + // CC-compatible relays are optimized for gateway compatibility, not for + // lossless request preservation. Normalize through an OpenAI-like view, + // then rebuild a Claude Code-shaped payload that is more likely to pass + // upstream client fingerprint checks than a field-for-field passthrough. if (sourceFormat !== FORMATS.OPENAI) { const normalizeToolCallId = getModelNormalizeToolCallId( provider || "", diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 1430bd406556..323a35722529 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -13,6 +13,16 @@ import { } from "./claudeCodeConstraints.ts"; import { obfuscateInBody } from "./claudeCodeObfuscation.ts"; +/** + * `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept + * traffic which looks like the official Claude Code client, often because those + * gateways resell the same models at materially lower prices than the direct API. + * + * This bridge is intentionally compatibility-first, not lossless. We normalize + * requests into the smallest Claude Code-shaped surface that consistently passes + * provider-side client checks, instead of trying to preserve every original + * field one-to-one. + */ export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models"; @@ -112,6 +122,9 @@ export function buildClaudeCodeCompatibleHeaders( stream = false, sessionId?: string | null ): Record { + // These headers intentionally mirror Claude Code's wire image closely. + // For CC-compatible relays, passing the upstream's client-gating checks is + // more important than forwarding arbitrary caller-specific header shapes. return { "Content-Type": "application/json", Accept: stream ? "text/event-stream" : "application/json", From fa55c776a42a22677e5eaa4c3ce9526f51a5935b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 15 Apr 2026 00:06:21 -0300 Subject: [PATCH 4/4] fix: repair broken markdown table in README timeout docs --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4084284a4929..afeef9e3debc 100644 --- a/README.md +++ b/README.md @@ -813,11 +813,11 @@ Advanced overrides are available if you need finer control: | `TLS_CLIENT_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Timeout for TLS fingerprint requests made through `wreq-js` | | `API_BRIDGE_PROXY_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` or `30000` | Timeout for `/v1` proxy forwarding from API port to dashboard port | | `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | Incoming request timeout on the API bridge server | +| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server | +| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | +| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). -| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server | -| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | -| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy timeouts are also higher than your OmniRoute stream/fetch timeouts.