import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; import { resolveAlternateFormat, type AlternateFormat, } from "../config/providers/alternateFormats.ts"; import { CLAUDE_CLI_BILLING_VERSION, CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, mergeClientAnthropicBeta, normalizeAnthropicHeaderVariants, } from "../config/anthropicHeaders.ts"; import { applyContextEditingToBody } from "../config/contextEditing.ts"; import { findOffendingField, detectUnsupportedParam, stripGroqUnsupportedFields, } from "../config/providerFieldStrips.ts"; import { recordLearnedThinkingCap, parseThinkingBudgetMax, } from "../services/learnedThinkingCaps.ts"; import { getParamFilterConfig, addParamToBlocklist, isAutoLearnGloballyEnabled, } from "@/lib/db/paramFilters"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { recordFreeWindowAttempt, correctFromRateLimitHeaders, resolveAccountKey, isFreeVariantModel, } from "../services/openrouterFreeWindow.ts"; import { gateOutboundRequest } from "../services/wafRateLimit.ts"; import type { PoolConfig } from "../services/sessionPool/types.ts"; import type { Session } from "../services/sessionPool/session.ts"; import { SessionPool } from "../services/sessionPool/sessionPool.ts"; import { PoolRegistry } from "../services/sessionPool/poolRegistry.ts"; import { getRotatingApiKey, getValidApiKey, resolveKeyForRequest, } from "../services/apiKeyRotator.ts"; import type { KeyHealth } from "../services/apiKeyRotator.ts"; import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts"; import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts"; import { runWithOnPersist, getRefreshLeadMs, isUnrecoverableRefreshError, } from "../services/tokenRefresh.ts"; import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { signRequestBody } from "../services/claudeCodeCCH.ts"; import { appendAnthropicBetaHeader, CONTEXT_1M_BETA_HEADER, enforceThinkingTemperature, modelHasNativeContext1m, modelSupportsContext1mBeta, } from "../services/claudeCodeCompatible.ts"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; import { cloakThirdPartyToolNames, remapToolNamesInRequest, } from "../services/claudeCodeToolRemapper.ts"; import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts"; import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; import { applySystemTransformPipeline, PROVIDER_CLAUDE } from "../services/systemTransforms.ts"; import * as prl from "../utils/providerRequestLogging.ts"; import { fixToolPairs, fixToolAdjacency, stripTrailingAssistantOrphanToolUse, stripTrailingAssistantForProvider, } from "../services/contextManager.ts"; import { randomUUID } from "node:crypto"; import { CLAUDE_CODE_VERSION, CLAUDE_CODE_STAINLESS_VERSION, buildUserIdJson, getSessionId, parseUpstreamMetadataUserId, passthroughUpstreamSessionId, resolveAccountUUID, resolveCliUserID, selectBetaFlags, stainlessArch, stainlessOS, stripProxyToolPrefix, } from "./claudeIdentity.ts"; import { withForcedResponsesUpstream } from "./forceResponsesUpstream.ts"; import { mergeUpstreamExtraHeaders, setUserAgentHeader, applyConfiguredUserAgent, stripStainlessHeadersForOpenAICompat, } from "./base/headers.ts"; import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting"; import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth"; // Header helpers extracted to a pure leaf; re-exported for external importers // (executors + tests) that import them from "./base.ts". export { mergeUpstreamExtraHeaders, getCustomUserAgent, setUserAgentHeader, applyConfiguredUserAgent, isOpenAICompatibleEndpoint, stripStainlessHeadersForOpenAICompat, } from "./base/headers.ts"; import { sanitizeReasoningEffortForProvider } from "./base/reasoningEffort.ts"; // Reasoning-effort sanitation extracted to a pure leaf; re-exported for external // importers (mimoThinking service + tests) that import it from "./base.ts". export { sanitizeReasoningEffortForProvider } from "./base/reasoningEffort.ts"; /** * Sanitizes a custom API path to prevent path traversal attacks. * Valid paths must start with '/', contain no '..' segments, * no null bytes, and be reasonable in length. */ function sanitizePath(path: string): boolean { if (typeof path !== "string") return false; if (!path.startsWith("/")) return false; if (path.includes("\0")) return false; // null byte if (path.includes("..")) return false; // path traversal if (path.length > 512) return false; // sanity limit return true; } type JsonRecord = Record; export type ProviderConfig = { id?: string; baseUrl?: string; baseUrls?: string[]; responsesBaseUrl?: string; messagesUrl?: string; chatPath?: string; clientVersion?: string; clientId?: string; clientSecret?: string; tokenUrl?: string; refreshUrl?: string; authUrl?: string; headers?: Record; requestDefaults?: ProviderRequestDefaults; timeoutMs?: number; format?: string; }; export type ProviderCredentials = { accessToken?: string; refreshToken?: string; apiKey?: string; email?: string | null; projectId?: string | null; expiresAt?: string; connectionId?: string; // T07: used for API key rotation index maxConcurrent?: number | null; providerSpecificData?: JsonRecord; requestEndpointPath?: string; }; export type ExecutorLog = { debug?: (tag: string, message: string) => void; info?: (tag: string, message: string) => void; warn?: (tag: string, message: string) => void; error?: (tag: string, message: string) => void; }; export type ExecuteInput = { model: string; body: unknown; stream: boolean; credentials: ProviderCredentials; signal?: AbortSignal | null; log?: ExecutorLog | null; extendedContext?: boolean; /** Merged after auth + CLI fingerprint headers (values override same-named defaults). */ upstreamExtraHeaders?: Record | null; /** Original client request headers (read-only). Executors may forward select headers upstream. */ clientHeaders?: Record | null; /** Response format the end client expects (e.g. "openai-responses"). Executors * that do their own Claude→OpenAI stream translation (GLM, zed-hosted) use * this to apply client-format-aware policies such as `` close-marker * suppression. */ clientResponseFormat?: string | null; /** Callback to persist tokens that are proactively refreshed during execution. * Accepts a partial credentials patch (e.g. `{ accessToken, refreshToken }` or * `{ testStatus: "expired", isActive: false }`); the caller merges into the * stored connection row. */ onCredentialsRefreshed?: ( newCredentials: Partial & Record ) => Promise | void; /** When true, skip the intra-URL 429 retry in execute() so the caller handles fallback. */ skipUpstreamRetry?: boolean; /** Delegated Context Editing (Claude only): when enabled, attach the * `context_management.clear_tool_uses` strategy so the provider clears stale * tool-use blocks server-side. Honored only on the genuine `claude` path. */ contextEditing?: { enabled: boolean } | null; }; export type CountTokensInput = { body: Record; credentials: ProviderCredentials; log?: ExecutorLog | null; model: string; signal?: AbortSignal | null; }; export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal { const controller = new AbortController(); const abortFrom = (source: AbortSignal) => { if (!controller.signal.aborted) { controller.abort(source.reason); } }; if (primary.aborted) { abortFrom(primary); return controller.signal; } if (secondary.aborted) { abortFrom(secondary); return controller.signal; } primary.addEventListener("abort", () => abortFrom(primary), { once: true }); secondary.addEventListener("abort", () => abortFrom(secondary), { once: true }); return controller.signal; } function hasActiveClaudeThinking(body: Record): boolean { const thinking = body.thinking as Record | undefined; return thinking?.type === "enabled" || thinking?.type === "adaptive"; } /** * Collect every `thinkingConfig` object in a transformed request body that holds * a thinking budget, wherever the provider's envelope nests it: * - body.generationConfig.thinkingConfig (native Gemini / openai→gemini) * - body.request.generationConfig.thinkingConfig (Antigravity Cloud Code envelope) * Returns only objects that actually carry a `thinkingBudget`/`thinking_budget` * field — a request without thinking config is never mutated. */ function collectThinkingConfigs(body: unknown): Array> { if (!body || typeof body !== "object") return []; const root = body as Record; const configs: Array> = []; const envelopes: unknown[] = [ root.generationConfig, (root.request as Record | undefined)?.generationConfig, ]; for (const env of envelopes) { if (!env || typeof env !== "object") continue; const tc = (env as Record).thinkingConfig; if (tc && typeof tc === "object") { const tcr = tc as Record; if ("thinkingBudget" in tcr || "thinking_budget" in tcr) configs.push(tcr); } } return configs; } /** * Read the first thinking budget found in the body (any supported nest / naming). * Returns null when the body carries no readable numeric budget. */ function readNestedThinkingBudget(body: unknown): number | null { for (const tc of collectThinkingConfigs(body)) { const raw = tc.thinkingBudget ?? tc.thinking_budget; const n = Number(raw); if (Number.isFinite(n)) return n; } return null; } /** * Clamp every thinking budget in the body down to `max` (only lowers; never * raises a budget already below max). Mutates in place. Returns true when at * least one budget was actually lowered (i.e. a retry would send a different * body) — false means the 400 was not caused by an over-max budget we hold, so * retrying would resend an identical body and loop. */ function clampNestedThinkingBudget(body: unknown, max: number): boolean { let changed = false; for (const tc of collectThinkingConfigs(body)) { for (const key of ["thinkingBudget", "thinking_budget"] as const) { const n = Number(tc[key]); if (Number.isFinite(n) && n > max) { tc[key] = max; changed = true; } } } return changed; } /** * Strip the OmniRoute provider prefix from tool model fields (e.g. * `cc/claude-opus-4-8` → `claude-opus-4-8`). Versioned built-in tool types carry * an 8-digit date suffix (`advisor_20260301`, `bash_20250124`); non-versioned * server tools (Task/subagent, web_search) carry the same prefixed model. The * real Claude CLI sends a bare model id there, never a prefixed one, so a leaked * OmniRoute prefix makes Anthropic reject the request. * * Two mechanisms, applied to any tool with a string `model`: * 1. Versioned built-in types (`type` matches `_\d{8}$`): strip the last path * segment (`model.split("/").pop()`), matching legacy behavior for kiro/ etc. * 2. Any tool whose model starts with a 9router Claude provider prefix * (`cc/`, `claude/`): strip exactly that prefix (`slice`), preserving foreign * providers such as `openrouter/anthropic/...` — mirrors upstream * normalizeClaudeServerToolModels (9router#2649). * Mutates in place. */ const CLAUDE_TOOL_MODEL_PREFIXES = ["cc/", "claude/"] as const; export function stripVersionedToolModelPrefix(tools: unknown): void { if (!Array.isArray(tools)) return; for (const t of tools as Array>) { if (typeof t.model !== "string") continue; const model = t.model; if (typeof t.type === "string" && /^[a-z][a-z0-9_]*_\d{8}$/.test(t.type) && model.includes("/")) { t.model = model.split("/").pop(); } else { const prefix = CLAUDE_TOOL_MODEL_PREFIXES.find((candidate) => model.startsWith(candidate)); if (prefix) t.model = model.slice(prefix.length); } } } /** * BaseExecutor - Base class for provider executors. * Implements the Strategy pattern: subclasses override specific methods * (buildUrl, buildHeaders, transformRequest, etc.) for each provider. */ /** * What an executor's `execute()` may resolve to. * * Both arms are real: the web/scraping executors return a bare `Response` from their * error and passthrough paths, while the HTTP executors return the richer capture * object used for upstream request logging. `normalizeExecutorResult()` accepts * exactly this union and wraps the bare form, so the contract is the union — not the * object shape that `BaseExecutor.execute` happens to infer from its single return. */ export type ExecutorExecuteResult = | Response | { response: Response; url?: string; headers?: Record; transformedBody?: unknown; transport?: string; }; export class BaseExecutor { provider: string; config: ProviderConfig; // Session pool support — subclasses can set poolConfig to opt in protected poolConfig?: PoolConfig; private _pool: import("../services/sessionPool/sessionPool.ts").SessionPool | null = null; constructor(provider: string, config: ProviderConfig) { this.provider = provider; this.config = config; } getProvider() { return this.provider; } protected getPool(): SessionPool | null { if (!this.poolConfig) return null; if (!this._pool) { const pool = new SessionPool(this.provider, this.poolConfig); pool.warmUp(this.poolConfig.minSessions).catch(() => {}); PoolRegistry.register(this.provider, pool); this._pool = pool; } return this._pool; } protected buildPoolHeaders(session: Session | null): Record { if (!session) return {}; return session.buildHeaders(); } getBaseUrls() { return this.config.baseUrls || (this.config.baseUrl ? [this.config.baseUrl] : []); } getFallbackCount() { return this.getBaseUrls().length || 1; } getTimeoutMs() { const configured = this.config?.timeoutMs; if (typeof configured !== "number" || !Number.isFinite(configured)) { return FETCH_TIMEOUT_MS; } return Math.max(1, Math.floor(configured)); } getCountTokensTimeoutMs() { return this.getTimeoutMs(); } buildUrl( model: string, stream: boolean, urlIndex = 0, credentials: ProviderCredentials | null = null ) { void model; void stream; if (this.provider?.startsWith?.("openai-compatible-")) { const psd = credentials?.providerSpecificData; const baseUrl = typeof psd?.baseUrl === "string" ? psd.baseUrl : "https://api.openai.com/v1"; const normalized = baseUrl.replace(/\/$/, ""); // Sanitize custom path: must start with '/', no path traversal, no null bytes const rawPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; const customPath = rawPath && sanitizePath(rawPath) ? rawPath : null; if (customPath) return `${normalized}${customPath}`; const path = getOpenAICompatibleType(this.provider, psd) === "responses" ? "/responses" : "/chat/completions"; return `${normalized}${path}`; } const baseUrls = this.getBaseUrls(); return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl || ""; } /** * Resolve the effective base URL for a request, preferring per-connection * providerSpecificData.baseUrl over the static provider config baseUrl. */ protected resolveBaseUrl(credentials: ProviderCredentials | null, fallback?: string): string { const psdBaseUrl = credentials?.providerSpecificData?.baseUrl; // Operator's manual override always wins (#6147). if (typeof psdBaseUrl === "string" && psdBaseUrl) return psdBaseUrl; // An alternate protocol selected on the connection carries its own URL. const alternate = this.resolveAlternate(credentials); if (alternate?.baseUrl) return alternate.baseUrl; return fallback || this.config.baseUrl || ""; } /** * Alternate protocol selected on this connection, if the provider declares one * that matches. Centralizes the registry lookup so every call-site resolves the * same way. */ protected resolveAlternate(credentials: ProviderCredentials | null): AlternateFormat | null { return resolveAlternateFormat( getRegistryEntry(this.provider), credentials?.providerSpecificData ); } protected usesClaudeCodeProtocol(credentials: ProviderCredentials | null): boolean { if (!isClaudeCodeCompatible(this.provider)) return false; const format = this.resolveAlternate(credentials)?.format; return format !== "openai" && format !== "openai-responses"; } /** * Resolve the effective API key via extra-keys round-robin rotation. * Mutates `credentials.providerSpecificData.selectedKeyId` on rotation. */ protected resolveEffectiveKey(credentials: ProviderCredentials): string | undefined { const extraKeys = (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; const selectedKeyId = (credentials.providerSpecificData as Record | undefined) ?.selectedKeyId as string | undefined; const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0); let effectiveKey = credentials.apiKey; // Rotate whenever extras exist — including empty primary + populated extras (#8467). // getValidApiKey already skips a blank primary and round-robins the extras alone. if (validExtras.length > 0 && credentials.connectionId) { const resolved = resolveKeyForRequest( credentials.connectionId, credentials.apiKey || "", validExtras, selectedKeyId ?? null ); effectiveKey = resolved?.key ?? credentials.apiKey; if (resolved && credentials.providerSpecificData) { (credentials.providerSpecificData as Record).selectedKeyId = resolved.keyId; } } return effectiveKey; } /** * Build the common header preamble shared by BaseExecutor and DefaultExecutor: * Content-Type, config.headers, per-provider User-Agent env override, and * resolved effective key (via extra-keys round-robin). */ protected buildHeadersPreamble( credentials: ProviderCredentials, stream: boolean ): { headers: Record; effectiveKey: string | undefined } { const alternate = this.resolveAlternate(credentials); const headers: Record = { "Content-Type": "application/json", ...this.config.headers, ...(alternate?.headers || {}), }; // Allow per-provider User-Agent override via environment variable. // Example: CLAUDE_USER_AGENT="my-agent/2.0" overrides the default for the Claude provider. const providerId = this.config?.id || this.provider; if (providerId) { const envKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`; const envUA = process.env[envKey]?.trim(); if (envUA) { setUserAgentHeader(headers, envUA); } } const effectiveKey = this.resolveEffectiveKey(credentials); void stream; return { headers, effectiveKey }; } buildHeaders( credentials: ProviderCredentials, stream = true, clientHeaders?: Record | null, model?: string, health?: Record ): Record { void clientHeaders; void model; const { headers, effectiveKey } = this.buildHeadersPreamble(credentials, stream); if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; } else if (effectiveKey) { headers["Authorization"] = `Bearer ${effectiveKey}`; } headers["Accept"] = stream ? "text/event-stream" : "application/json"; normalizeAnthropicHeaderVariants(headers); return headers; } // Override in subclass for provider-specific transformations transformRequest( model: string, body: unknown, stream: boolean, credentials: ProviderCredentials ): unknown { void model; void stream; void credentials; // Fix #1674: Remove empty string values from optional parameters // like tool descriptions to avoid upstream validation failures. if (body && typeof body === "object" && !Array.isArray(body)) { const cloned = { ...body } as Record; if (Array.isArray(cloned.input)) { cloned.input = sanitizeResponsesInputItems(cloned.input, false); } if (Array.isArray(cloned.tools)) { cloned.tools = cloned.tools.map((tool: unknown) => { if (tool && typeof tool === "object" && !Array.isArray(tool)) { const toolRecord = tool as JsonRecord; const toolFunction = toolRecord.function; if (toolFunction && typeof toolFunction === "object" && !Array.isArray(toolFunction)) { const func = { ...(toolFunction as JsonRecord) }; if (func.description === "") delete func.description; if (typeof func.name !== "string" || func.name.trim() === "") { func.name = "unnamed_tool"; } return { ...toolRecord, function: func }; } } return tool; }); } // Fix #1884: Cursor sends prompt_cache_retention which breaks strict upstream endpoints delete cloned.prompt_cache_retention; // Also clean up top level optional fields that commonly cause issues when empty const optionalKeys = ["user", "stop", "seed", "response_format"]; for (const key of optionalKeys) { if (cloned[key] === "") delete cloned[key]; } return cloned; } return body; } shouldRetry(status: number, urlIndex: number) { return status === HTTP_STATUS.RATE_LIMITED && urlIndex + 1 < this.getFallbackCount(); } // Intra-URL retry config: retry same URL before falling back to next node static readonly RETRY_CONFIG = { maxAttempts: 2, delayMs: 2000 }; // WAF (400 content-blocked) retry config: agentrouter.org's WAF is burst-sensitive // and recovers after a short cooldown. Use exponential backoff with a higher // starting delay than the generic 429 retry (which is 2s) because the WAF // needs more time to clear its per-IP suspicion bucket. static readonly WAF_RETRY_CONFIG = { maxAttempts: 2, delayMs: 1500, backoffMultiplier: 2, }; // 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 ): Promise | null> { void credentials; void log; return null; } needsRefresh(credentials?: ProviderCredentials | null) { if (!credentials?.expiresAt) return false; const expiresAtMs = new Date(credentials.expiresAt).getTime(); // Use the provider-specific lead time (REFRESH_LEAD_MS) so rotating-token // providers like Codex refresh proactively far ahead of expiry. Keeping the // refresh_token "warm" prevents Auth0 from marking it as stale and revoking // the token family on first use after long idle. const lead = getRefreshLeadMs(this.provider); return expiresAtMs - Date.now() < lead; } parseError(response: Response, bodyText: string) { return { status: response.status, message: bodyText || `HTTP ${response.status}` }; } buildCountTokensUrl(model: string, credentials: ProviderCredentials | null = null) { void model; void credentials; const baseUrl = this.buildUrl(model, false, 0, credentials); if (typeof baseUrl !== "string" || baseUrl.length === 0) return null; if (this.config?.format !== "claude" || !baseUrl.includes("/messages")) return null; const [path, query = ""] = baseUrl.split("?"); const normalizedPath = path.endsWith("/messages") ? `${path}/count_tokens` : `${path}/count_tokens`; return query ? `${normalizedPath}?${query}` : normalizedPath; } async countTokens({ model, body, credentials, signal, log }: CountTokensInput) { const url = this.buildCountTokensUrl(model, credentials); if (!url) return null; const headers = this.buildHeaders(credentials, false); const requestBody = body && typeof body === "object" ? { ...body, model, } : { model }; let timeoutId: ReturnType | null = null; let activeSignal = signal || null; let controller: AbortController | null = null; const timeoutMs = this.getCountTokensTimeoutMs(); if (timeoutMs > 0) { controller = new AbortController(); timeoutId = setTimeout(() => controller?.abort(), timeoutMs); activeSignal = signal ? mergeAbortSignals(signal, controller.signal) : controller.signal; } try { const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(requestBody), signal: activeSignal || undefined, }); const text = await response.text(); if (!response.ok) { const parsedError = this.parseError(response, text); throw new Error(parsedError.message); } const parsed = text ? JSON.parse(text) : {}; const inputTokens = Number(parsed?.input_tokens); if (!Number.isFinite(inputTokens)) { throw new Error("Provider count_tokens response missing input_tokens"); } return { input_tokens: inputTokens, provider: this.provider, source: "provider" }; } catch (error) { log?.debug?.( "COUNT_TOKENS", `${this.provider}/${model} real count unavailable: ${error instanceof Error ? error.message : String(error)}` ); return null; } finally { if (timeoutId) clearTimeout(timeoutId); } } async execute(input: ExecuteInput): Promise { const { model, body, stream, credentials, signal, log, extendedContext, upstreamExtraHeaders, clientHeaders, skipUpstreamRetry = false, onCredentialsRefreshed, contextEditing, } = input; const fallbackCount = this.getFallbackCount(); let lastError: unknown = null; let lastStatus = 0; let activeCredentials = credentials; // Track per-URL intra-retry attempts to avoid infinite loops const retryAttemptsByUrl: Record = {}; if (this.needsRefresh(credentials)) { try { // Fix A: wire onCredentialsRefreshed through runWithOnPersist so it runs // INSIDE the per-connection mutex inside getAccessToken. Not every // executor routes through getAccessToken (e.g. github.ts), so use a flag // to detect whether the persist callback actually fired and fall back to // post-refresh mutation when it didn't. let proactivePersistRan = false; const proactiveOnPersist = onCredentialsRefreshed ? async (refreshResult: Record) => { proactivePersistRan = true; activeCredentials = { ...credentials, ...(refreshResult as Partial), }; await onCredentialsRefreshed(refreshResult as Partial); } : null; const refreshed = await runWithOnPersist(proactiveOnPersist, () => this.refreshCredentials(credentials, log || null) ); if (refreshed && !proactivePersistRan) { // ───────────────────────────────────────────────────────────────────── // ⚠️ SOURCE OF TRUTH — do not flip the proactive path back to // "persist expired+inactive". Ask the operator first. // // History (do not repeat past regressions): // - ad3d4b696 (#2718, 2026-05-25): per-connection mutex + onPersist // wiring so multi-account Codex (rotating refresh tokens) stops // hitting refresh_token_reused under concurrent load. // - 0c94c397d (#2743, 2026-05-26): a multi-agent review added a // `await onCredentialsRefreshed({ testStatus: "expired", // isActive: false })` here. That BROKE multi-account Codex — // transient sentinels (refresh_token_reused recoverable via // rotation map; generic invalid_request blips) were treated as // terminal, so the proactive path sequentially disabled // working accounts in the DB before any upstream call confirmed // the failure. Reverted intentionally. // // Contract for the PROACTIVE refresh path: // - Classify the sentinel ONLY to avoid spreading it into // activeCredentials (which would send a non-token upstream). // - DO NOT persist `{ testStatus: "expired", isActive: false }` // from here. That decision belongs to the REACTIVE path in // open-sse/handlers/chatCore.ts:~3912, which runs AFTER the // upstream confirmed the auth failure. By then the rotation // map (tokenRefresh.ts:~1541) and the DB-staleness check have // already had their chance to recover the request. // // If a future review/agent thinks the expired-flip is "missing" // here, STOP — flipping it here re-introduces the multi-account // Codex regression. Discuss with the operator before touching. // ───────────────────────────────────────────────────────────────────── if (isUnrecoverableRefreshError(refreshed)) { const refreshCode = (refreshed as Record).code; log?.warn?.( "TOKEN", `${this.provider.toUpperCase()} | proactive refresh returned unrecoverable sentinel (code=${String(refreshCode ?? "unknown")}); keeping stale credentials, deferring to reactive path.` ); // Intentionally NOT spreading the sentinel and NOT persisting // expired status. The next upstream call either succeeds (rotation // map / DB-staleness saved us) or fails — chatCore.ts then marks // the account expired with confidence. } else { activeCredentials = { ...credentials, ...refreshed, }; if (onCredentialsRefreshed) { await onCredentialsRefreshed(refreshed); } } } } catch (error) { // tokenRefresh.ts:1352 documents that onPersist throws are re-thrown so // the caller is aware of the persistence failure. Honor that contract: // log at error level (not warn), with sanitized message — and let the // request continue with stale credentials so the user-visible error // surfaces upstream rather than being silently absorbed here. log?.error?.( "TOKEN", `Credential refresh failed for ${this.provider}: ${error instanceof Error ? error.message : String(error)}` ); } } // Set by the Context Editing 400-fallback below: once an upstream rejects the // `context_management` param, suppress its re-injection on every later // retry/fallback URL (each iteration rebuilds a fresh `transformedBody`). let contextEditingDisabled = false; // Tracks which request fields have already been stripped via the generic 400 // field-downgrade below, so each known field is stripped at most once across // all fallback URLs (bounded retry loop). const strippedFields = new Set(); // Set by the thinking_budget 400 clamp-and-retry below: the upstream's // advertised max (parsed from the error) is applied to every later // retry/fallback URL so they don't re-hit the same 400. The clamp itself // fires at most once per URL (guarded inline) so a persistent 400 cannot // loop. The learned cap is also recorded process-wide via // recordLearnedThinkingCap so future requests skip the 400 entirely. let thinkingBudgetClampedMax: number | null = null; for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const requestCredentials = withForcedResponsesUpstream( this.provider, body, activeCredentials ); const url = this.buildUrl(model, stream, urlIndex, requestCredentials); const headers = this.buildHeaders(requestCredentials, stream, clientHeaders, model); applyConfiguredUserAgent(headers, requestCredentials?.providerSpecificData); // Strip OpenAI SDK (X-Stainless-*) metadata + normalize SDK-derived User-Agent // on OpenAI-compatible passthrough requests — some upstream gateways 403 on them. const strippedStainless = stripStainlessHeadersForOpenAICompat(headers, this.provider, url); if (strippedStainless.length > 0) { log?.debug?.( "HEADERS", `Stripped X-Stainless-* from OpenAI-compatible request: ${strippedStainless.join(", ")}` ); } const usesClaudeCodeProtocol = this.usesClaudeCodeProtocol(requestCredentials); const fingerprintProvider = usesCcWireImage(this.provider) && !usesClaudeCodeProtocol ? "codex" : this.provider; const ccRequestDefaults = usesClaudeCodeProtocol ? getClaudeCodeCompatibleRequestDefaults(requestCredentials?.providerSpecificData) : {}; const shouldForwardExtendedContext = extendedContext && modelSupportsContext1mBeta(model) && !usesClaudeCodeProtocol; const shouldForwardCcCompatibleContext1m = usesClaudeCodeProtocol && ccRequestDefaults.context1m === true && !modelHasNativeContext1m(model); if (shouldForwardExtendedContext || shouldForwardCcCompatibleContext1m) { appendAnthropicBetaHeader(headers, CONTEXT_1M_BETA_HEADER); } const rawTransformedBody = await this.transformRequest( model, body, stream, requestCredentials ); let transformedBody = sanitizeReasoningEffortForProvider( rawTransformedBody, this.provider, model, log ); if (this.provider === "groq") { transformedBody = stripGroqUnsupportedFields( transformedBody as Record ) as typeof transformedBody; } // A previous URL in this execute() already hit a thinking_budget 400 and // recorded the upstream's max. Pre-clamp this URL's fresh transformedBody // so it doesn't re-hit the same 400 (avoids one wasted round-trip per // fallback URL). No-op when nothing has been learned this execute(). if (thinkingBudgetClampedMax !== null) { clampNestedThinkingBudget(transformedBody, thinkingBudgetClampedMax); } try { // Timeout only covers response start; stream stalls are handled downstream. const fetchStartTimeoutMs = this.getTimeoutMs(); const fetchWithStartTimeout = async (requestUrl: string, requestOptions: RequestInit) => { 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 ${requestUrl}` ); timeoutError.name = "TimeoutError"; timeoutController.abort(timeoutError); }, fetchStartTimeoutMs); } const timeoutSignal = timeoutController?.signal ?? null; const combinedSignal = signal && timeoutSignal ? mergeAbortSignals(signal, timeoutSignal) : signal || timeoutSignal; const optionsWithSignal = combinedSignal ? { ...requestOptions, signal: combinedSignal } : requestOptions; try { return await fetch(requestUrl, optionsWithSignal); } finally { if (timeoutId) clearTimeout(timeoutId); } }; const isClaudeCodeClient = clientHeaders?.["x-app"] === "cli" || (clientHeaders?.["user-agent"] && clientHeaders["user-agent"].toLowerCase().includes("claude-code")) || (clientHeaders?.["user-agent"] && clientHeaders["user-agent"].toLowerCase().includes("claude-cli")); // Anthropic's user:sessions:claude_code OAuth scope expects CLI-shaped // traffic. Apply the cloak whenever we have an OAuth token, regardless // of upstream client. const hasClaudeOAuthToken = typeof activeCredentials?.accessToken === "string" && activeCredentials.accessToken.startsWith("sk-ant-oat") && !activeCredentials?.apiKey; if ( ((this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken)) || usesClaudeCodeProtocol) && typeof transformedBody === "object" && transformedBody !== null ) { const tb = transformedBody as Record; stripProxyToolPrefix(tb); remapToolNamesInRequest(tb); // Cloak third-party tool names + sanitize invalid tool schemas so // Anthropic does not refuse native Claude OAuth traffic with a // misleading "out of extra usage" placeholder. See Spec E. cloakThirdPartyToolNames(tb); if (Array.isArray(tb.tools)) { tb.tools = sanitizeClaudeToolSchemas(tb.tools); } obfuscateInBody(tb); // NOTE (issue #2260): This is the native `claude` provider OAuth path. // It is intentionally NOT routed through applyCcBridgeTransformPipeline. // The native OAuth path already prepends its own billing line + sentinel // (see lines ~744-773 below, dayStamp-based, cc_entrypoint=cli, cch=00000 // placeholder, signed at body level). The CC bridge transforms DSL is // wired into buildAndSignClaudeCodeRequest (claudeCodeCompatible.ts step 5b) // which is the anthropic-compatible-cc-* relay path — a different, // separately classified surface. Do not double-prepend here. // Real CLI never sets cache_control on tools. if (Array.isArray(tb.tools)) { for (const t of tb.tools as Array>) { delete t.cache_control; } // Also strip OmniRoute provider prefix from versioned built-in tool // model fields (e.g. cc/claude-opus-4-8 → claude-opus-4-8). stripVersionedToolModelPrefix(tb.tools); } // Per-request behavior overrides via custom client headers. // x-omniroute-effort: low | medium | high | xhigh | max | off // x-omniroute-thinking: adaptive | off // A header value applies only when the corresponding body field is // not already set; "off" force-strips the field. const headerEffort = ( clientHeaders?.["x-omniroute-effort"] ?? clientHeaders?.["X-OmniRoute-Effort"] ) ?.trim() .toLowerCase(); const headerThinking = ( clientHeaders?.["x-omniroute-thinking"] ?? clientHeaders?.["X-OmniRoute-Thinking"] ) ?.trim() .toLowerCase(); let appliedEffort: string | null = null; let appliedThinking: string | null = null; if (headerEffort === "off") { if (tb.output_config && typeof tb.output_config === "object") { delete (tb.output_config as Record).effort; } appliedEffort = "off"; } else if ( headerEffort && ["low", "medium", "high", "xhigh", "max"].includes(headerEffort) ) { const oc = tb.output_config && typeof tb.output_config === "object" ? (tb.output_config as Record) : {}; if (oc.effort === undefined) { oc.effort = headerEffort; tb.output_config = oc; appliedEffort = headerEffort; } } // Anthropic rejects `thinking` (enabled/adaptive) when tool_choice forces a // specific tool ({type:"any"|"tool"}): "Thinking may not be enabled when // tool_choice forces tool use". Treat forced tool_choice as an implicit // `thinking: off` so neither the explicit-adaptive branch nor the default CC // injection below produces the invalid combination (incl. client-sent thinking). const toolChoiceForced = tb.tool_choice === "any" || (typeof tb.tool_choice === "object" && tb.tool_choice !== null && ((tb.tool_choice as Record).type === "any" || (tb.tool_choice as Record).type === "tool")); const effThinking = toolChoiceForced ? "off" : headerThinking; if (effThinking === "adaptive") { if (tb.thinking === undefined) { tb.thinking = { type: "adaptive" }; appliedThinking = "adaptive"; } if (tb.context_management === undefined) { tb.context_management = { edits: [{ type: "clear_thinking_20251015", keep: "all" }], }; } } else if (effThinking === "off") { delete tb.thinking; delete tb.context_management; appliedThinking = "off"; } else if (!effThinking && !headerEffort && isClaudeCodeClient) { // Default Claude Code logic when no override headers are present. // Generic OpenAI-compatible clients that route through native Claude OAuth // must opt in with x-omniroute-thinking; force-injecting adaptive thinking // leaks non-standard reasoning replay fields back into those clients. const isHaiku = typeof tb.model === "string" && tb.model.includes("haiku"); // #5312 RC-B: honor the operator's proxy-level Thinking-Budget mode. // `auto` means "strip — let the provider decide", so suppress the default // adaptive injection. Passthrough/no-config keeps the native Claude Code // behavior (adaptive) so #4633 does not regress (request-side only). const tbMode = getThinkingBudgetConfig().mode; if (isHaiku) { // Keep tb.thinking — real Claude Desktop keeps thinking enabled for Haiku // (issue #2454). Only strip output_config (effort) which Haiku rejects; // context_management is re-paired with the preserved thinking below. delete tb.output_config; delete tb.context_management; } else if (tbMode === ThinkingMode.AUTO) { delete tb.thinking; delete tb.context_management; delete tb.output_config; } else if (tb.thinking === undefined && tb.output_config === undefined) { tb.thinking = { type: "adaptive" }; tb.context_management = { edits: [{ type: "clear_thinking_20251015", keep: "all" }], }; tb.output_config = { effort: "high" }; } // #5312: Opus 4.7/4.8 accept only thinking.type="adaptive" ("enabled" → 400). // When an operator budget (custom/adaptive mode) produced an enabled block // upstream, remap it to adaptive + output_config.effort here. const th = tb.thinking as Record | undefined; if (th?.type === "enabled" && tbMode !== ThinkingMode.PASSTHROUGH) { const b = typeof th.budget_tokens === "number" ? th.budget_tokens : 0; tb.thinking = { type: "adaptive" }; tb.output_config = { effort: b <= 1024 ? "low" : b <= 10240 ? "medium" : b >= 131072 ? "max" : "high", }; tb.context_management = { edits: [{ type: "clear_thinking_20251015", keep: "all" }] }; } } // Real CLI always pairs context_management with thinking. Mirror // that invariant so long sessions don't accumulate thinking blocks // toward the context cap. if (hasActiveClaudeThinking(tb) && !tb.context_management) { tb.context_management = { edits: [{ type: "clear_thinking_20251015", keep: "all" }], }; } const seed = activeCredentials?.accessToken || activeCredentials?.apiKey || "anon"; const psd = activeCredentials?.providerSpecificData as Record | undefined; let identitySource: "upstream-metadata" | "upstream-header" | "synthesized" | "synthesized-cloaked" = "synthesized"; let sessionId: string; let deviceId: string; let accountUUID: string; // For any Claude OAuth request, ignore client-supplied metadata.user_id / // X-Claude-Code-Session-Id and synthesize per-account: the CC device_id from // ~/.claude.json is shared across every account on one machine, which lets // Anthropic correlate accounts behind one OmniRoute. const cloakIdentity = isClaudeCodeClient || hasClaudeOAuthToken; const upstreamUserId = cloakIdentity ? null : parseUpstreamMetadataUserId(tb); if (upstreamUserId) { sessionId = upstreamUserId.session_id; deviceId = upstreamUserId.device_id; accountUUID = upstreamUserId.account_uuid; identitySource = "upstream-metadata"; } else { const headerSid = cloakIdentity ? null : passthroughUpstreamSessionId( clientHeaders as Record | undefined ); sessionId = headerSid ?? getSessionId(seed); deviceId = resolveCliUserID(psd, seed); accountUUID = resolveAccountUUID(psd, seed, activeCredentials?.accessToken); identitySource = headerSid ? "upstream-header" : cloakIdentity ? "synthesized-cloaked" : "synthesized"; } // system[0] (billing) and system[1] (sentinel) must not carry // cache_control — that belongs on upstream prompt blocks at [2..]. const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CLI_BILLING_VERSION}; cc_entrypoint=cli; cch=00000;`; const SENTINEL = "You are Claude Code, Anthropic's official CLI for Claude."; const sysBlocks: Array> = Array.isArray(tb.system) ? (tb.system as Array>) : typeof tb.system === "string" ? [{ type: "text", text: tb.system }] : []; // Strip any pre-existing billing/sentinel before re-prepending — keeps // retries idempotent and avoids stacking that breaks prompt-cache prefix // matching (see issue #1712). for (let i = sysBlocks.length - 1; i >= 0; i--) { const t = sysBlocks[i]?.text; if (typeof t === "string" && t.startsWith("x-anthropic-billing-header:")) { sysBlocks.splice(i, 1); } } for (let i = sysBlocks.length - 1; i >= 0; i--) { const t = sysBlocks[i]?.text; if (typeof t === "string" && t.startsWith(SENTINEL)) { sysBlocks.splice(i, 1); } } sysBlocks.unshift({ type: "text", text: billingLine }, { type: "text", text: SENTINEL }); tb.system = sysBlocks; // Run the configurable system-transforms pipeline for the native // `claude` provider (issue #2260 / comment 4459544580). The default // claude pipeline runs cosmetic ops only (Open WebUI paragraph // anchors, identity-prefix paragraph drop, ZWJ obfuscation of // sensitive words). It deliberately does NOT include // `inject_billing_header` — billing + sentinel are already // prepended above. Users can extend the pipeline via Settings UI. { const transformResult = applySystemTransformPipeline(PROVIDER_CLAUDE, tb); if (transformResult.appliedOpKinds.length > 0) { console.log( `[SystemTransforms] claude-native: ${transformResult.appliedOpKinds.join(", ")}` ); } } if (!tb.metadata || typeof tb.metadata !== "object") tb.metadata = {}; (tb.metadata as Record).user_id = buildUserIdJson({ deviceId, accountUUID, sessionId, }); // Headers. Accept stays application/json even on streams (Stainless // convention; SSE decoding is gated on body.stream). anthropic-beta // is selected per request shape; the full set on a quota probe is // itself a fingerprint. // Respect the client's negotiated anthropic-beta (real Claude Code) instead // of force-injecting thinking/effort betas it never requested (#3415). const clientAnthropicBeta = clientHeaders?.["anthropic-beta"] ?? clientHeaders?.["Anthropic-Beta"] ?? null; const ccHeaders: Record = { Accept: "application/json", "anthropic-version": "2023-06-01", // #3974: merge the client's allowlisted betas (e.g. tool-search-tool) // on top of the shape-derived set so deferred-tool requests are not // rejected; selectBetaFlags still gates thinking/effort per #3415. "anthropic-beta": mergeClientAnthropicBeta( selectBetaFlags(tb, null, clientAnthropicBeta), clientAnthropicBeta ), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, "X-Stainless-Package-Version": CLAUDE_CODE_STAINLESS_VERSION, "X-Stainless-Timeout": "600", "accept-encoding": "gzip, deflate, br, zstd", connection: "keep-alive", "x-client-request-id": randomUUID(), "X-Claude-Code-Session-Id": sessionId, }; // Drop case variants of the same header name before merging — undici // would otherwise concatenate them (issue #1454). const ccKeysLower = new Set(Object.keys(ccHeaders).map((k) => k.toLowerCase())); for (const key of Object.keys(headers)) { if (ccKeysLower.has(key.toLowerCase())) delete headers[key]; } Object.assign(headers, ccHeaders); if (usesCcWireImage(this.provider) && usesClaudeCodeProtocol) { delete headers["Authorization"]; headers["x-api-key"] = activeCredentials?.apiKey || activeCredentials?.accessToken || ""; } delete headers["X-Stainless-Helper-Method"]; // OS/arch follow the host running the signed binary. Runtime version // is pinned to the captured CLI wire image, not OmniRoute's Node. headers["X-Stainless-Arch"] = stainlessArch(); headers["X-Stainless-Lang"] = "js"; headers["X-Stainless-OS"] = stainlessOS(); headers["X-Stainless-Runtime"] = "node"; headers["X-Stainless-Runtime-Version"] = CLAUDE_CLI_STAINLESS_RUNTIME_VERSION; headers["X-Stainless-Retry-Count"] = "0"; delete headers["X-Stainless-Os"]; const overrideTag = appliedEffort || appliedThinking ? ` overrides=effort:${appliedEffort ?? "-"},thinking:${appliedThinking ?? "-"}` : ""; log?.debug?.( "CLAUDE", `identity=${identitySource} sid=${sessionId.slice(0, 8)} dev=${deviceId.slice(0, 8)} acct=${accountUUID.slice(0, 8)}${overrideTag}` ); } // CLI fingerprint ordering — always-on for native Claude OAuth, opt-in // for other providers. Header + body field order is itself a fingerprint. let finalHeaders = headers; // Strip internal sentinel fields set by remapToolNamesInRequest before // serializing — Anthropic rejects unknown top-level fields (issue #2260). delete (transformedBody as Record)[ "_claudeCodeRequiresLowercaseToolNames" ]; // Guard against orphan tool_use / tool_result pairs. Clients can ship // truncated histories mid-tool-call which Anthropic rejects with // `messages.N: tool_use ids were found without tool_result blocks // immediately after: toolu_...`. fixToolPairs strips orphans, then // stripTrailingAssistantOrphanToolUse catches the case where the // request body itself ends on an unmatched assistant(tool_use) — // invalid for an upstream-send turn since the body must end on a // user message. Both are idempotent on clean histories. { const tb = transformedBody as Record; if (Array.isArray(tb?.messages)) { const fixed = fixToolPairs(tb.messages as Record[]); // fixToolAdjacency enforces Claude's strict adjacency rule // (tool_result must be in immediately next message). // Only apply for Claude/Claude-compatible — OpenAI allows results // spread across multiple subsequent messages. const isClaude = this.provider === "claude" || usesClaudeCodeProtocol; // For Claude, fixToolAdjacency may strip tool_use blocks whose // tool_result isn't in the next message; re-run fixToolPairs to // drop any tool_result orphaned by that strip (discussion #2410). const adjacent = isClaude ? fixToolPairs(fixToolAdjacency(fixed)) : fixed; const stripped = stripTrailingAssistantOrphanToolUse(adjacent); // Some providers (e.g. Mistral) require the last message to be user // or tool and reject trailing assistant text messages with 400 (#3396). tb.messages = stripTrailingAssistantForProvider(stripped, this.provider); } } // Anthropic's extended-thinking contract forbids non-default sampling // params: temperature must be 1 and top_p >= 0.95 (or unset) whenever // thinking is enabled/adaptive. Thinking can be injected by per-model // requestDefaults *after* the translator/constraint passes, so normalize // at this final dispatch point — the single chokepoint every Claude // routing mode (grouped/raw/combo) and the native passthrough share, // before fingerprinting and CCH signing serialize the body. if (this.provider === "claude" || usesClaudeCodeProtocol) { enforceThinkingTemperature(transformedBody as Record); } // Delegated Context Editing (opt-in): attach the clear_tool_uses strategy so // the provider clears stale tool-use blocks server-side. Runs at this same // chokepoint, composing with the clear_thinking edit the fingerprint path may // have already set. Scoped to genuine `claude` (real Anthropic key/OAuth) and // `anthropic-compatible-cc-*` relays — the latter advertise Claude Code // compatibility, so they are the relays most likely to accept the beta. A // rejecting upstream is caught by the 400-fallback below. Deliberately // EXCLUDED: `claude-web` (a browser relay with a `create_conversation_params` // request shape that never sees `context_management`) and generic // `anthropic-compatible-*` (third-party endpoints with uncertain beta support). // `contextEditingDisabled` (set by the 400-fallback) suppresses re-injection // when a fresh `transformedBody` is built for a retry/fallback URL. if ( (this.provider === "claude" || usesClaudeCodeProtocol) && contextEditing?.enabled && !contextEditingDisabled ) { applyContextEditingToBody(transformedBody as Record, { enabled: true, }); log?.debug?.( "CONTEXT_EDITING", "Delegated context editing on — attached clear_tool_uses to the Claude request" ); } let bodyString = JSON.stringify(transformedBody); const shouldFingerprint = isCliCompatEnabled(fingerprintProvider) || (this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken)); if (shouldFingerprint) { const fingerprinted = applyFingerprint(fingerprintProvider, headers, transformedBody); finalHeaders = fingerprinted.headers; bodyString = fingerprinted.bodyString; } // CCH signing — replaces the cch=00000 placeholder in the billing // header with an xxHash64 integrity token over the serialized body. if (usesClaudeCodeProtocol || this.provider === "claude") { bodyString = await signRequestBody(bodyString); } mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders); if (this.provider === "cline" || this.provider === "clinepass") { applyClineProtocolHeaders(finalHeaders, { taskId: headers["X-Task-ID"], }); } // Enforce peer tracing after all configurable headers have been merged so // operator/provider metadata cannot accidentally erase the loop guard. applyPeerTraceHeader(finalHeaders, clientHeaders, url); const serializedBody = prl.parseBody(bodyString); // #4307 — Preserve the non-enumerable tool-name cloak/remap reverse map // (`_toolNameMap`, set on the live `transformedBody` by // remapToolNamesInRequest / cloakThirdPartyToolNames) that the JSON // round-trip above drops. chatCore's response-side un-cloak reads it off // `result.transformedBody` to restore the client's original tool-name // casing (e.g. `read`, not the cloaked `Read`). Without this re-attach the // map is lost and the client receives the cloaked casing — a regression // from #3941's serialized-body capture. Mirrors antigravity.ts's // `attachToolNameMap`; non-enumerable so it never re-serializes upstream. if ( transformedBody && typeof transformedBody === "object" && serializedBody && typeof serializedBody === "object" ) { const liveToolNameMap = (transformedBody as Record)._toolNameMap; if ( liveToolNameMap instanceof Map && liveToolNameMap.size > 0 && !((serializedBody as Record)._toolNameMap instanceof Map) ) { Object.defineProperty(serializedBody, "_toolNameMap", { value: liveToolNameMap, enumerable: false, configurable: true, writable: true, }); } } const fetchOptions: RequestInit = { method: "POST", headers: finalHeaders, body: bodyString, }; // OpenRouter `:free`-variant local window (#6842): record every real // dispatch attempt (failed attempts still consume a request slot per // OpenRouter's own accounting) and self-correct the local counters // from the upstream `X-RateLimit-*` headers on the response. Scoped // to `:free` models only — no-op (and no extra work) for every other // OpenRouter request or provider. const openrouterFreeWindowAccountKey = this.provider === "openrouter" && isFreeVariantModel(model) && activeCredentials.connectionId ? resolveAccountKey(activeCredentials.connectionId, activeCredentials) : null; if (openrouterFreeWindowAccountKey) { recordFreeWindowAttempt(openrouterFreeWindowAccountKey); } // WAF burst guard: agentrouter.org's content filter becomes more // aggressive after rapid requests. Enforce a small inter-request gap // to avoid tripping it. See open-sse/services/wafRateLimit.ts. if (this.provider === "agentrouter") { await gateOutboundRequest(`agentrouter:${url}`); } let response = await fetchWithStartTimeout(url, fetchOptions); if (openrouterFreeWindowAccountKey) { correctFromRateLimitHeaders(openrouterFreeWindowAccountKey, response.headers); } // Context Editing 400-fallback for Claude-compatible relays. if ( response.status === HTTP_STATUS.BAD_REQUEST && contextEditing?.enabled && !contextEditingDisabled && transformedBody && typeof transformedBody === "object" && (transformedBody as Record).context_management !== undefined ) { const errText = await response .clone() .text() .catch(() => ""); if (/context[_-]management|context editing/i.test(errText)) { contextEditingDisabled = true; delete (transformedBody as Record).context_management; let retryBody = JSON.stringify(transformedBody); if (usesClaudeCodeProtocol || this.provider === "claude") { retryBody = await signRequestBody(retryBody); } log?.debug?.( "CONTEXT_EDITING", `Upstream 400 rejected context_management on ${url} — retrying without it` ); response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody }); } } // Thinking-budget 400 clamp-and-retry (Gemini-family, any provider). // The proactive capThinkingBudget clamp misses models outside MODEL_SPECS, // so an xhigh budget (131072) can reach the upstream and bounce with // "thinking_budget must be in the range [-1, N]". When that happens: parse // the advertised max, record it process-wide (so FUTURE requests clamp // proactively via capThinkingBudget → getLearnedThinkingCap), clamp the // nested budget in the live transformedBody, and retry the same URL once. // Subsequent requests never pay this 400→retry round-trip. if ( response.status === HTTP_STATUS.BAD_REQUEST && thinkingBudgetClampedMax === null && transformedBody && typeof transformedBody === "object" ) { const errText = await response .clone() .text() .catch(() => ""); const upstreamMax = parseThinkingBudgetMax(errText); if (upstreamMax !== null) { const currentBudget = readNestedThinkingBudget(transformedBody); // Record under the budget that actually failed so the learned step // ladder advances correctly; fall back to upstreamMax when the body // carries no readable budget (still record so we stop re-hitting). recordLearnedThinkingCap(this.provider, model, currentBudget ?? upstreamMax + 1); thinkingBudgetClampedMax = upstreamMax; if (clampNestedThinkingBudget(transformedBody, upstreamMax)) { let retryBody = JSON.stringify(transformedBody); if (usesClaudeCodeProtocol || this.provider === "claude") { retryBody = await signRequestBody(retryBody); } log?.info?.( "THINKING_BUDGET", `Upstream 400 rejected thinking_budget on ${url} — clamped to ${upstreamMax} and retrying (learned for ${this.provider}/${model})` ); response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody }); } } } // Generic reactive 400 field-downgrade; each field is stripped at most once. if ( response.status === HTTP_STATUS.BAD_REQUEST && transformedBody && typeof transformedBody === "object" ) { const errText = await response .clone() .text() .catch(() => ""); const offending = findOffendingField(errText); if ( offending && !strippedFields.has(offending) && (transformedBody as Record)[offending] !== undefined ) { strippedFields.add(offending); delete (transformedBody as Record)[offending]; let retryBody = JSON.stringify(transformedBody); if (usesClaudeCodeProtocol || this.provider === "claude") { retryBody = await signRequestBody(retryBody); } log?.debug?.( "FIELD_400", `Upstream 400 rejected ${offending} on ${url} — retrying without it` ); response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody }); } else { // Auto-learn: detect "Unsupported parameter" errors and persist to DB // when the provider config has autoLearn enabled (#6625). const autoLearned = detectUnsupportedParam(errText); if ( autoLearned && !strippedFields.has(autoLearned) && (transformedBody as Record)[autoLearned] !== undefined ) { try { const config = getParamFilterConfig(this.provider); const shouldAutoLearn = isAutoLearnGloballyEnabled() || config?.autoLearn === true; if (shouldAutoLearn) { strippedFields.add(autoLearned); addParamToBlocklist(this.provider, autoLearned, model); delete (transformedBody as Record)[autoLearned]; let retryBody = JSON.stringify(transformedBody); if (usesClaudeCodeProtocol || this.provider === "claude") { retryBody = await signRequestBody(retryBody); } log?.info?.( "AUTO_LEARN", `Auto-learned "${autoLearned}" for provider ${this.provider} (model: ${model}) from 400 on ${url} — retrying` ); response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody }); } } catch (learnError) { log?.warn?.( "AUTO_LEARN", `Failed to persist auto-learned param "${autoLearned}" for ${this.provider}: ${String(learnError)}` ); } } } } // Intra-URL retry: agentrouter.org WAF returns 400 content-blocked // intermittently (burst-sensitive, recovers after cooldown). Retry the // same URL with exponential backoff before falling through to the // 429/401/fallback chain. See docs/security/AGENTROUTER_WAF.md. if ( !skipUpstreamRetry && response.status === HTTP_STATUS.BAD_REQUEST && (retryAttemptsByUrl[urlIndex] ?? 0) < BaseExecutor.WAF_RETRY_CONFIG.maxAttempts ) { const wafErrText = await response .clone() .text() .catch(() => ""); if (/content[_-]blocked/i.test(wafErrText)) { retryAttemptsByUrl[urlIndex] = (retryAttemptsByUrl[urlIndex] ?? 0) + 1; const wafAttempt = retryAttemptsByUrl[urlIndex]; const wafBackoff = BaseExecutor.WAF_RETRY_CONFIG.delayMs * Math.pow(BaseExecutor.WAF_RETRY_CONFIG.backoffMultiplier, wafAttempt - 1); log?.debug?.( "WAF_RETRY", `400 content-blocked intra-retry ${wafAttempt}/${BaseExecutor.WAF_RETRY_CONFIG.maxAttempts} on ${url} — waiting ${wafBackoff}ms` ); await new Promise((resolve) => setTimeout(resolve, wafBackoff)); urlIndex--; // re-run this urlIndex on the next loop iteration continue; } } // Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL if ( !skipUpstreamRetry && response.status === HTTP_STATUS.RATE_LIMITED && (retryAttemptsByUrl[urlIndex] ?? 0) < BaseExecutor.RETRY_CONFIG.maxAttempts ) { retryAttemptsByUrl[urlIndex] = (retryAttemptsByUrl[urlIndex] ?? 0) + 1; const attempt = retryAttemptsByUrl[urlIndex]; log?.debug?.( "RETRY", `429 intra-retry ${attempt}/${BaseExecutor.RETRY_CONFIG.maxAttempts} on ${url} — waiting ${BaseExecutor.RETRY_CONFIG.delayMs}ms` ); await new Promise((resolve) => setTimeout(resolve, BaseExecutor.RETRY_CONFIG.delayMs)); urlIndex--; // re-run this urlIndex on the next loop iteration continue; } // T07: Handle 401 authentication errors — log and continue to fallback if (response.status === 401 && credentials.connectionId && credentials.apiKey) { log?.warn?.("AUTH", `401 on ${url} - API key may be invalid`); } if (!skipUpstreamRetry && this.shouldRetry(response.status, urlIndex)) { log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`); lastStatus = response.status; continue; } return { response, url, headers: finalHeaders, transformedBody: serializedBody }; } catch (error) { // 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 ${this.getTimeoutMs()}ms on ${url}`); } lastError = err; if (!skipUpstreamRetry && urlIndex + 1 < fallbackCount) { log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`); continue; } throw err; } } throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`); } } export default BaseExecutor;