Files
OmniRoute/open-sse/executors/base.ts
Diego Rodrigues de Sa e Souza 7b139fdb5e Release v3.8.38 (#5078)
* chore(release): open v3.8.38 development cycle

* fix(executors): strip client_metadata for cerebras and mistral (#4727)

Integrated into release/v3.8.38 (leva 5)

* fix(codebuddy): only send reasoning params when client requests reasoning (#5019)

Integrated into release/v3.8.38 (leva 5)

* fix(sse): keep streaming for forceStream providers when client requests JSON (#5021)

Integrated into release/v3.8.38 (leva 5)

* fix(sse): guard non-JSON SSE lines and duplicate [DONE] (#4937)

Integrated into release/v3.8.38 (leva 5)

* feat(blackbox): refresh provider model catalog (#4935)

Integrated into release/v3.8.38 (leva 5)

* fix(sse): dedupe case-variant Anthropic version/beta headers (#4846)

Integrated into release/v3.8.38 (leva 5)

* feat(sse): Kiro inline <thinking> stream splitter (#4911)

Integrated into release/v3.8.38 (leva 5)

* feat(cursor): parse Composer DeepSeek-style inline tool calls (#4912)

Integrated into release/v3.8.38 (leva 5)

* feat(proxy): auth-less host:port batch import (#4938)

Integrated into release/v3.8.38 (leva 5)

* fix(oauth): support Kiro IDC (organization) token import (#4944)

Integrated into release/v3.8.38 (leva 5)

* fix(translator): preserve cache_control for DashScope OpenAI-compat providers (port from 9router#2069) (#5013)

Integrated into release/v3.8.38 (leva 5)

* fix(tts): resolve Gemini TTS models from catalog (#4934)

Integrated into release/v3.8.38 (leva 5)

* fix(sse): don't cool down the connection on a self-inflicted upstream timeout (504) (#5064)

Integrated into release/v3.8.38 (leva 5)

* fix(sse): robust Anthropic /v1/messages streaming — real ping keepalive + client-disconnect guard (#5063)

Integrated into release/v3.8.38 (leva 5)

* feat(video): add Alibaba DashScope (wan2.7-t2v) provider (#5051)

Integrated into release/v3.8.38 (leva 5)

* fix: preserve model hidden flags (isHidden) across model sync (#5086)

Integrated into release/v3.8.38 (leva 5)

* fix(models): derive model discovery config from registry modelsUrl (#5087)

Integrated into release/v3.8.38 (leva 5)

* fix(compression): replace fileURLToPath(import.meta.url) with runtime anchors for standalone bundle (#5089)

Integrated into release/v3.8.38 (leva 5)

* feat(cc): add summarized thinking display toggle (#5055)

Integrated into release/v3.8.38 (leva 5)

* Harden selected API error responses (#5032)

Integrated into release/v3.8.38 (leva 5)

* chore(quality): rebaseline file-size for leva 5 PR batch drift

6 frozen files grew from merged leva-5 PRs (cursor #4912, kiro #4911,
videoGeneration #5051, default #4727, base #4846, chat #5064); all covered
by per-PR tests. See _rebaseline_2026_06_26_leva5 in the baseline.

* feat(compression): compression playground (Play + Compare tabs) in the studio (#5080)

Integrated into release/v3.8.38

* fix(combo): fail over on empty-content 502 instead of exhausting the provider (#5085) (#5104)

* fix(dashboard): surface detailed credential-validation error in add-connection modal (#5088) (#5106)

* feat(providers): allow local/private provider URLs by default with scoped metadata-safe guard (#5066) (#5107)

* fix(diagnostics): treat non-streaming Claude messages shape as valid output (#5108) (#5116)

* fix(db): translate pt-BR SQLite driver-fallback log lines to English (#5103) (#5115)

* fix(sse): repair release base-reds — malformed-response false positives + header casing + stale tests (#5117)

Repairs the release/v3.8.38 base-reds; unblocks #5078.

* chore(quality): rebaseline file-size for responseSanitizer (#5117) + AddApiKeyModal drift

* fix(translator): forward image tool_result blocks as image_url (#5100)

Base-reds fixed (#5117); image tool_result→image_url. Integrated into release/v3.8.38.

* fix(responses): default text.format for openai-compatible responses providers (#5101)

Base-reds fixed (#5117); default text.format + file-size rebaseline. Integrated into release/v3.8.38.

* feat(dashboard): expose Fusion judgeModel + fusionTuning in the combo editor (#5074)

Base-reds fixed (#5117); Fusion editor + file-size rebaseline. Integrated into release/v3.8.38.

* feat(quota): add opt-in Codex/Claude auto-ping keepalive (#5102)

Base-reds fixed (#5117); auto-ping keepalive + file-size rebaseline. Integrated into release/v3.8.38.

* test(release): relocate 2 orphan test files into the collected flat tests/unit dir (#5120)

Unblocks Lint (test-discovery) on #5078. Integrated into release/v3.8.38.

* fix(translator): preserve reasoning-replay reasoning_content + repair 3 release-green test reds (#5122)

Repairs 3 release-green test reds + test-masking; unblocks #5078.

* test(golden): redact live Node version from provider translate-path snapshot (#5125)

Final golden unblock for #5078.

* test(golden): redact OmniRoute app version from translate-path snapshot (#5126)

Coverage shard golden unblock for #5078.

* Ignore disconnect races during in-band stream error handling (#5007)

Integrated into release/v3.8.38

* Track final connection IDs in failover logs (#5016)

Integrated into release/v3.8.38

* fix(sse): convert Gemini body to OpenAI format in antigravity MITM handler (#4845)

Integrated into release/v3.8.38 (rebased on tip, CHANGELOG re-injected)

* feat(providers): add ZenMux Free session-cookie provider (#5105)

Integrated into release/v3.8.38 (rebased on tip, CHANGELOG re-injected)

* feat(dashboard): click-to-edit model alias in provider page (#5119)

Integrated into release/v3.8.38 (rebased on tip, i18n scope verified, CHANGELOG re-injected)

* feat(mcp): web-session robustness — cookie dedup (PR6) + browser-pool observability (PR7) (#3368) (#5121)

Integrated into release/v3.8.38 (rebased on tip; cookie-dedup branch extracted to findExistingCookieConnection helper → complexity-neutral; CHANGELOG added)

* fix(usage): dedupe request-usage logging and debounce stats (#4940)

Integrated into release/v3.8.38 (rebased on tip; DB-handle hang was stale-base artifact — resetDbInstance already closes the handle, test green 5/5; file-size drift consolidated at release; CHANGELOG re-injected)

* fix(dashboard): key model visibility toggle on canonical providerId (#5091)

Integrated into release/v3.8.38 (retargeted main→release; .tsx visibility-key test green 2/2)

* chore(deps): bump actions/cache from 5.0.5 to 6.0.0 (#5112)

Integrated into release/v3.8.38 (retargeted main→release; workflow-only actions/cache bump — unit failures were stale main base-reds)

* fix(streaming): harden long OpenAI-compatible SSE streams (#5124)

Integrated into release/v3.8.38 (rebased on tip; streamHandler conflict with #5007 disconnect-guard resolved — both coexist, stream-handler 22/22 green)

* feat: Add Grok Build (xAI) provider with OAuth import-token flow (#5020)

Integrated into release/v3.8.38 (rebased on tip; Hard Rule #11 fix — Grok public client_id now via resolvePublicCred(grok_id), 3 literals removed; grok-oauth 7/7 + check:public-creds green)

* feat(providers): add Factory (factory.ai) as a subscription gateway provider (#5065)

Integrated into release/v3.8.38 (rebased on tip; added factory registry test for PR Test Policy + fixed check:env-doc-sync phantom FACTORY_API_KEY; factory loads in PROVIDERS, no Zod issue — that flag was a false positive)

* chore(test): reconcile golden snapshot + apikey count for new providers

#5020 (grok-cli), #5065 (factory), #5105 (zenmux-free) added providers but did
not regenerate tests/snapshots/provider/translate-path.json (now +3 entries) nor
bump the APIKEY_PROVIDERS count (159->160 for the factory gateway). Test-only
reconciliation; no production change.

* fix(resilience): harden quota and model lockout edge cases (#5093)

Integrated into release/v3.8.38 (rebased on tip). TRUST-BUT-VERIFY: dropped the PR's 0dd7df641 'fix unit gates' commit which reverted #5122 reasoning-replay (preserveReasoningContent) + re-introduced #4849 O(n^2) growth, and restored 5 tests it had realigned. Kept only the 3 declared resilience fixes (quota cutoff guard, gemini MIME, model-lockout maxCooldownMs); 23/23 green.

* Hydrate quota cache and scope auto combo candidates (#5015)

Integrated into release/v3.8.38 (rebased on tip). Kept core quota-cache hydration + auto-combo candidate scoping + combos UI; dropped out-of-scope toolCloaking refactor (conflicted with #4813 stripEnumDescriptions — took tip) and the unrelated sse-auth test split. Added quota-cache-hydrate-5015 regression test (Rule #18); combo-account-allowlist 8/8 + hydration 2/2 green.

* chore(quality): reconcile complexity + file-size baselines for v3.8.38 owner-PR batch

complexity 1972->1978 (+6) and file-size providers.ts 1093->1107 / usageHistory.ts
934->983 — drift from the /review-prs merge batch (#4845/#5105/#5020/#4940/#5093/
#5015 + #5121 cookie-dedup helper extraction). check:complexity/check:file-size do
not run on the PR->release fast-path, so the branch accrued unmeasured; all legit
feature/fix growth, not regression. See per-key justifications in each baseline.

* fix(security): exact-host Anthropic baseUrl check (CodeQL js/incomplete-url-substring-sanitization #674) (#5130)

The anthropic-compatible Bearer-fallback gate decided whether a configured baseUrl
targeted the official api.anthropic.com host via a substring `.includes("api.anthropic.com")`.
A look-alike upstream such as `https://api.anthropic.com.evil.test` or
`https://evil.test/?x=api.anthropic.com` matched the substring and was wrongly treated as
official, suppressing the Bearer fallback meant for third-party gateways
(CodeQL #674, js/incomplete-url-substring-sanitization, high).

Replace the substring test with an exported `isOfficialAnthropicBaseUrl()` helper that
parses the URL and compares the hostname for exact equality. Empty baseUrl stays official;
scheme-less hosts are parsed with an assumed https://; an unparseable baseUrl falls back to
third-party (Bearer emitted) as the safer default. Behavior for legitimate official/third-party
baseUrls is unchanged.

Adds tests/unit/anthropic-official-baseurl-host.test.ts covering official, look-alike,
scheme-less, and unparseable inputs plus a static guard that the substring pattern is gone.

* fix(proxy): repair one-click Deno & Cloudflare relay deployments (#5128) (#5132)

* fix(services): embed WS proxy honours LIVE_WS_HOST; reject empty messages early (#5110) (#5133)

* fix(api): resolve /v1/models/{id} case-insensitively (#5082) (#5135)

* fix(providers): add MiniMax M3 & Nemotron 3 Ultra to Cline catalog (#3321) (#5136)

* fix(proxy): make SOCKS5 handshake timeout tunable via SOCKS_HANDSHAKE_TIMEOUT_MS (#5109) (#5137)

* feat(sidebar): add support for colored menu icons (#3812)

Integrated into release/v3.8.38 (recreated on tip — fork had unrelated history; added getSidebarIconAccent regression test, Rule #18). Clean 2-file UI feature.

* fix(providers): complete grok-cli OAuth wiring + zenmux-free web-session metadata

Base-red repair for #5020 (grok-cli) and #5105 (zenmux-free), surfaced by the
full CI on the release PR (#5078) — the PR->release fast-path does not run the
oauth-providers-config / web-session-credentials / provider-consistency gates.

- grok-cli: register in OAUTH_PROVIDERS (providers.ts canonical list, fixes
  check:provider-consistency), add OAUTH_PROVIDER_IDS.GROK_CLI + GROK_CLI_CONFIG
  in oauth constants (provider config now sourced there, not a local literal),
  align oauth-providers-config.test.ts (EXPECTED_PROVIDER_KEYS + config map).
- zenmux-free: declare its web-session credential requirement (full Cookie header)
  in WEB_SESSION_CREDENTIAL_REQUIREMENTS.

Local: oauth-providers-config 27/27, web-session-credentials 4/4, grok-cli-oauth
7/7, check:provider-consistency OK, +115 OAUTH_PROVIDERS tests green.

* Fix resilience settings page response mapping (#5139)

Integrated into release/v3.8.38. Thanks @rdself for the fix and the regression test.

* fix(kiro): retire claude-sonnet-4.5 from catalog + pin 400 model-unavailable test (#5140)

Extracted the real change from #5140 (the bot PR regenerated the entire
freeModelCatalog.data.ts + touched package-lock.json; only the targeted
edits are kept here):
- remove claude-sonnet-4.5 from the Kiro registry entry
- remove the matching kiro free-model catalog row
- pin Kiro's verbatim 400 "Invalid model..." to isModelUnavailableError

Closes #4484

* fix(sidebar): drop orphan `settings` accent color (typecheck:core red) (#5142)

SIDEBAR_ICON_ACCENTS is typed Partial<Record<HideableSidebarItemId, string>>,
but `settings` is not a hideable item id (only `settings-general`,
`settings-appearance`, … and `context-settings` exist; there is no item with
`id: "settings"`), so the accent was unreachable. It broke `typecheck:core`
on the release tip ("'settings' does not exist in type …", introduced by
#3812 colored menu icons). Removing the orphan key restores a clean
typecheck:core (rc=0).

* feat: salvage batch 2 — diagnostics null-guard (#5096) + observed quota reset windows (#5025) (#5141)

* fix(diagnostics): null-guard content blocks in detectMalformedNonStream

A null (or non-object) entry in a Claude-native `content` array made the
non-stream classifier throw `TypeError: Cannot read properties of null
(reading 'type')`, crashing the malformed-response detection path. Guard
before type-asserting each block: a null/non-object block is simply skipped.

Two regression tests added (null block among valid blocks → null; only-null
blocks → empty_choices).

Salvaged from closed PR #5096 (base-stale; only the defensive guard — the
Claude-shape recognition it also carried already landed via #5108).

Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>

* feat(quota): persist observed provider quota reset windows

Adds `provider_quota_reset_events` (migration 108) + `db/quotaResetEvents.ts`
to record real upstream weekly-quota window transitions whenever a quota
refresh shows the reset rolling to a new cycle (different day, later resetAt).
`apiKeyUsageLimits` now prefers the observed window start over the inferred
`resetAt − 7d`, falling back to snapshot inference when no event is recorded
yet. `quotaCache.setQuotaCache` records the transition opportunistically.

`recordProviderQuotaResetEventIfChanged` only fires for the primary weekly
window (not daily/sonnet), is idempotent (INSERT OR IGNORE on the unique
window key), and no-ops when the reset didn't actually roll. 4 unit tests
(tests/unit/lib/quota-reset-events.test.ts).

Salvaged from closed PR #5025 (which bundled this with two unrelated
features + a colliding migration 104). Renumbered to 108; module re-exported
from localDb (Rule #2).

Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>

---------

Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>

* docs(i18n): sync 3.8.38 CHANGELOG section to 41 mirrors (unblock docs-accuracy) (#5144)

The root CHANGELOG [3.8.38] section grew with this cycle's merged PRs, but the
docs/i18n/<lang>/CHANGELOG.md mirrors were not re-synced — drifting >25% in body
size and failing check:docs-sync (the "Docs accuracy" fast-gate step) for every
open PR against the release.

Ran scripts/release/sync-changelog-i18n.mjs 3.8.38 3.8.37 to copy the root
[3.8.38] section into all 41 mirrors. check:docs-all now passes (exit 0).

Sections are copied verbatim; the per-language translation pass runs at release
time via i18n:run — this only restores the size-sync the gate enforces.

* feat(compression): pure per-step fidelity checker (4 invariants, fail-open)

* feat(compression): fidelityGate config + rejected breakdown fields

* feat(compression): wire per-step fidelity gate into stacked pipeline (opt-in)

* feat(compression): preview route accepts fidelityGate flag (playground)

* feat(compression): playground fidelity-gate toggle + lane rejection display

* docs(compression): note fidelityGate advanced thresholds are intentionally API-omitted

* refactor(compression): extract fidelity-gate step helpers to shrink strategySelector (file-size gate)

bodyToText and gateAdvance moved to fidelityGateStep.ts; StackAccumulator exported.
strategySelector: 889->854 (-35). Residual +6 vs pre-Milestone-B frozen 848 is the
irreducible StackOptions.fidelityGate field + two stacked-loop dispatch reads + import.
Baseline updated to 854 with justification. No cycle introduced (import type only).
940 compression tests pass; typecheck clean.

* test(usage): wire usageHistoryDedup under unit runner brace-list (#5145)

Integrated into release/v3.8.38.

* feat: salvage batch from closed stale PRs (#5038, #5057, #5076) (#5138)

Integrated into release/v3.8.38.

* test(combo): deterministic routing-decision matrix for all 17 strategies (#5146)

Integrated into release/v3.8.38.

* feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass + playground toggle) (#5143)

Integrated into release/v3.8.38.

* chore(quality): rebaseline file-size for sidebarVisibility.ts + chat.ts drift (#5147)

Mid-cycle drift on release/v3.8.38 from already-merged PRs that the fast-path
(PR->release skips check:file-size) let accumulate without a bump:

- src/shared/constants/sidebarVisibility.ts 1100->1198 (#3812 colored menu
  icons, per-item accent map; #5142 dropped one orphan, net still above frozen)
- src/sse/handlers/chat.ts 1560->1575 (#5064 self-inflicted-timeout cooldown
  skip + #5124 long OpenAI-compatible SSE hardening + #5110 embed-WS
  LIVE_WS_HOST honour / early empty-message reject)

Each covered by its own PR tests; structural shrink of chat.ts tracked in #3501.
Unblocks the Fast Quality Gates for PRs targeting release/v3.8.38.

* chore(release): finalize v3.8.38 CHANGELOG + cycle reconciliation

- Reconcile [3.8.38]: +18 bullets (compression fidelity-gate/fuzzy-dedup #5143,
  quota keepalive #5102, web-session robustness #5121, MiniMax/Nemotron #5136,
  model-visibility #5091, failover logs #5016, disconnect races #5007, sidebar
  orphan #5142, SRE playbooks salvage #5138, new Security #5130 + Maintenance roll-up)
- Credit salvaged-PR authors (@JxnLexn / @KooshaPari / @herjarsa / @Witroch4)
- Remove phantom bullet for CLOSED-not-merged #5092 (setup aggregator never landed)
- Fix isHidden bullet PR citation #4389 -> #5086 (@herjarsa)
- Back-fill forgotten v3.8.36 bullet: #5026 crypto.randomUUID ID-gen (@hamsa0x7)
- Sync 41 i18n CHANGELOG mirrors; README What's New -> v3.8.38
- Rebaseline cycle drift: eslint 3987->4002, cognitive 833->841, dead-exports
  345->346, cyclomatic 1978->1980 (file-size handled by #5147)

* fix(i18n): add missing English UI labels (#5153)

Integrated into release/v3.8.38

* Preserve non-stream reasoning fields for compatible clients (#5155)

Integrated into release/v3.8.38

* feat(compression): ionizer engine — lossy JSON-array sampling reversible via CCR (#5148)

Integrated into release/v3.8.38

* test(combo): gated live smoke for combo strategies (in-process + VPS HTTP) (#5151)

Integrated into release/v3.8.38

* test: refresh release expectations to match current code (#5150)

Integrated into release/v3.8.38 (test-only base-red alignment extracted from #5150)

---------

Co-authored-by: Éder Costa <eder.almeida.costa@gmail.com>
Co-authored-by: José Victor Ferreira <root@josevictor.me>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: fulorgnas <46461624+fulorgnas@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
Co-authored-by: R. Beltran <rbeltran8000@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Ramel Tecnologia - Rafa Martins <146174365+rafacpti23@users.noreply.github.com>
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>
2026-06-27 09:07:12 -03:00

1475 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
import {
mergeClientAnthropicBeta,
normalizeAnthropicHeaderVariants,
} from "../config/anthropicHeaders.ts";
import { applyContextEditingToBody } from "../config/contextEditing.ts";
import { findOffendingField, stripGroqUnsupportedFields } from "../config/providerFieldStrips.ts";
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.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 {
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,
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,
buildHashFor,
buildUserIdJson,
getSessionId,
parseUpstreamMetadataUserId,
passthroughUpstreamSessionId,
resolveAccountUUID,
resolveCliUserID,
selectBetaFlags,
stainlessArch,
stainlessOS,
stainlessRuntimeVersion,
stripProxyToolPrefix,
} from "./claudeIdentity.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<string, unknown>;
export type ProviderConfig = {
id?: string;
baseUrl?: string;
baseUrls?: string[];
responsesBaseUrl?: string;
chatPath?: string;
clientVersion?: string;
clientId?: string;
clientSecret?: string;
tokenUrl?: string;
refreshUrl?: string;
authUrl?: string;
headers?: Record<string, string>;
requestDefaults?: ProviderRequestDefaults;
timeoutMs?: number;
format?: string;
};
export type ProviderCredentials = {
accessToken?: string;
refreshToken?: string;
apiKey?: string;
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<string, string> | null;
/** Original client request headers (read-only). Executors may forward select headers upstream. */
clientHeaders?: Record<string, 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<ProviderCredentials> & Record<string, unknown>
) => Promise<void> | 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<string, unknown>;
credentials: ProviderCredentials;
log?: ExecutorLog | null;
model: string;
signal?: AbortSignal | null;
};
/** Apply model-level extra upstream headers (e.g. Authentication, X-Custom-Auth). */
export function mergeUpstreamExtraHeaders(
headers: Record<string, string>,
extra?: Record<string, string> | null
): void {
if (!extra) return;
for (const [k, v] of Object.entries(extra)) {
if (typeof k === "string" && k.length > 0 && typeof v === "string") {
if (k.toLowerCase() === "user-agent") {
setUserAgentHeader(headers, v);
continue;
}
headers[k] = v;
}
}
}
export function getCustomUserAgent(providerSpecificData?: JsonRecord | null): string | null {
const customUserAgent =
typeof providerSpecificData?.customUserAgent === "string"
? providerSpecificData.customUserAgent.trim()
: "";
return customUserAgent || null;
}
export function setUserAgentHeader(headers: Record<string, string>, userAgent: string): void {
headers["User-Agent"] = userAgent;
if ("user-agent" in headers) {
headers["user-agent"] = userAgent;
}
}
export function applyConfiguredUserAgent(
headers: Record<string, string>,
providerSpecificData?: JsonRecord | null
): void {
const customUserAgent = getCustomUserAgent(providerSpecificData);
if (customUserAgent) {
setUserAgentHeader(headers, customUserAgent);
}
}
/**
* Returns true when the outbound request targets an OpenAI-compatible endpoint
* (a `openai-compatible-*` provider, or a Chat Completions / Responses URL).
* Used to scope the X-Stainless strip narrowly so genuine SDK-spoofing paths
* (e.g. Claude Code compat, which legitimately ADDS X-Stainless-*) are untouched.
*/
export function isOpenAICompatibleEndpoint(provider: string, url: string): boolean {
if (provider?.startsWith?.("openai-compatible-")) return true;
return url.includes("/v1/chat/completions") || url.includes("/v1/responses");
}
/**
* Strip OpenAI SDK (`X-Stainless-*`) metadata headers and normalize an SDK-derived
* User-Agent for OpenAI-compatible passthrough requests. Some upstream gateways
* 403 on these SDK-identifying headers. Only applied to OpenAI-compatible endpoints —
* other providers (Claude/Claude Code compat) may legitimately send X-Stainless-*.
*
* Mutates `headers` in place and returns the list of stripped header keys (for logging).
*/
export function stripStainlessHeadersForOpenAICompat(
headers: Record<string, string>,
provider: string,
url: string
): string[] {
if (!isOpenAICompatibleEndpoint(provider, url)) return [];
const strippedKeys: string[] = [];
for (const key of Object.keys(headers)) {
if (key.toLowerCase().startsWith("x-stainless-")) {
delete headers[key];
strippedKeys.push(key);
}
}
// Normalize User-Agent: SDK-based clients send verbose product strings that some
// upstreams block. Replace with a clean browser-like UA only when it looks SDK-derived.
const ua = (headers["User-Agent"] || headers["user-agent"] || "").toLowerCase();
if (ua.includes("openai") && (ua.includes("node") || ua.includes("axios") || ua.includes("undici"))) {
setUserAgentHeader(headers, "Mozilla/5.0 (compatible; OpenAI Compatible)");
}
return strippedKeys;
}
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<string, unknown>): boolean {
const thinking = body.thinking as Record<string, unknown> | undefined;
return thinking?.type === "enabled" || thinking?.type === "adaptive";
}
/**
* Sanitize reasoning_effort for providers that don't accept all values.
*
* The claude→openai translator may emit reasoning_effort=max/xhigh when the
* client sends output_config.effort=max on a Claude-shape request. Combined with
* runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this
* routes xhigh to OpenAI-shape providers that don't accept the value:
*
* xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh
* mistral : devstral models reject reasoning_effort entirely
* github : claude/haiku/oswe models reject reasoning_effort entirely
*
* Each rejection burns a combo fallback attempt before reaching a working
* provider. Apply provider-aware sanitation here (after transformRequest, so
* reintroductions by per-provider transforms are also caught) before fetch.
* xhigh support is opt-out: pass through unchanged unless the registry marks
* a model as unsupported. Literal max support is provider-specific and
* intentionally separate: some upstreams accept max even when they do not
* accept xhigh. For OpenAI-shape providers, max normalizes to xhigh by default
* and falls back to high only for explicit xhigh opt-outs.
*/
const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i;
// GitHub Copilot Claude routing is granular (upstream port: decolua/9router#791):
// ✅ Pass through — Claude Opus 4.6, Claude Sonnet 4.6. Copilot routes both to
// Anthropic's chat/completions surface, which honors reasoning_effort and
// emits visible reasoning tokens (verified upstream: 3× token increase
// between low/medium/high).
// ❌ Strip — Claude Haiku 4.5 and Claude Opus 4.7 (rejected upstream by
// Copilot's Claude backend), older Claude variants, all `haiku`-named
// models, and the `oswe-*` family (Raptor) which still rejects
// reasoning_effort.
// Order matters: the opt-in check must run BEFORE the broad Claude/haiku/oswe strip.
const GITHUB_REASONING_EFFORT_OPT_IN_PATTERN = /claude[-_.]?(?:opus|sonnet)[-_.]?4[-_.]6/i;
const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i;
function supportsMaxEffortForProvider(provider: string, model: string): boolean {
const isClaude =
(provider === PROVIDER_CLAUDE || isClaudeCodeCompatible(provider)) &&
supportsClaudeMaxEffort(model);
// opencode-go proxies DeepSeek with the native DeepSeek API contract, which
// accepts {high, max} literally. Without this opt-in, max would be
// normalized to xhigh (the OmniRoute-internal top tier) and rejected by the
// upstream. Scoped to opencode-go deliberately: OpenRouter's DeepSeek path
// (pi#4055) is the documented inverse and expects xhigh, not max.
// Ollama Cloud also accepts literal max (for example GLM 5.2 supports
// low|medium|high|max|none) and rejects xhigh.
const isOpencodeGoDeepSeek =
provider === "opencode-go" && model.toLowerCase().includes("deepseek");
const isOllamaCloud = provider === "ollama-cloud";
return isClaude || isOpencodeGoDeepSeek || isOllamaCloud;
}
export function sanitizeReasoningEffortForProvider(
body: unknown,
provider: string,
model: string | undefined,
log?: { info?: (tag: string, msg: string) => void } | null
): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const b = body as Record<string, unknown>;
const reasoning =
b.reasoning && typeof b.reasoning === "object" && !Array.isArray(b.reasoning)
? (b.reasoning as Record<string, unknown>)
: null;
const hasTopLevelReasoningEffort = Object.prototype.hasOwnProperty.call(b, "reasoning_effort");
const effort = b.reasoning_effort ?? reasoning?.effort;
if (effort === undefined) return body;
const effortStr = typeof effort === "string" ? effort.toLowerCase() : "";
const modelStr = model || "";
const githubOptIn =
provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr);
const rejecting =
(provider === "mistral" && MISTRAL_NO_REASONING_EFFORT_PATTERN.test(modelStr)) ||
(provider === "github" && !githubOptIn && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr));
if (rejecting) {
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: removed unsupported reasoning_effort`
);
const next: Record<string, unknown> = { ...b };
delete next.reasoning_effort;
if (reasoning) {
const r = { ...reasoning };
delete r.effort;
if (Object.keys(r).length === 0) delete next.reasoning;
else next.reasoning = r;
}
return next;
}
// Native DeepSeek (api.deepseek.com) — V4 thinking mode accepts reasoning_effort
// ONLY as {high, max} (its own top tier is literally "max"). OmniRoute's internal
// scale is low|medium|high|xhigh where xhigh is the top, so map onto DeepSeek's
// vocabulary: xhigh → max (top→top), low|medium → high (below the enum floor).
// high/max pass through unchanged. Without this, the claude→openai translator's
// xhigh (and max-normalized-to-xhigh below) reaches DeepSeek as an unknown value,
// silently dropping the client's requested effort. This is the INVERSE of the
// OpenRouter-DeepSeek path, whose normalized API expects xhigh, not max (pi#4055).
if (provider === "deepseek") {
const mapped =
effortStr === "xhigh" ? "max" : effortStr === "low" || effortStr === "medium" ? "high" : null;
if (mapped && mapped !== effortStr) {
log?.info?.(
"REASONING_SANITIZE",
`deepseek/${modelStr}: normalized reasoning_effort ${effortStr}${mapped}`
);
const next: Record<string, unknown> = { ...b };
if (hasTopLevelReasoningEffort) next.reasoning_effort = mapped;
if (reasoning) next.reasoning = { ...reasoning, effort: mapped };
return next;
}
return body;
}
const supportsXHigh = supportsXHighEffort(provider, modelStr);
const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHigh;
const supportsXHighForMax = supportsXHigh;
const supportsMax = supportsMaxEffortForProvider(provider, modelStr);
const shouldNormalizeMaxToXHigh = effortStr === "max" && !supportsMax && supportsXHighForMax;
const shouldDowngradeMax = effortStr === "max" && !supportsMax && !supportsXHighForMax;
if (shouldNormalizeMaxToXHigh) {
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: normalized reasoning_effort max → xhigh`
);
const next: Record<string, unknown> = { ...b };
if (hasTopLevelReasoningEffort) {
next.reasoning_effort = "xhigh";
}
if (reasoning) {
next.reasoning = { ...reasoning, effort: "xhigh" };
}
return next;
}
if (shouldDowngradeXHigh || shouldDowngradeMax) {
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: downgraded reasoning_effort ${effortStr} → high`
);
const next: Record<string, unknown> = { ...b };
if (hasTopLevelReasoningEffort) {
next.reasoning_effort = "high";
}
if (reasoning) {
next.reasoning = { ...reasoning, effort: "high" };
}
return next;
}
return body;
}
/**
* Strip the OmniRoute provider prefix from versioned built-in 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`);
* the real Claude CLI sends a bare model id there, never a prefixed one, so a
* leaked OmniRoute prefix makes Anthropic reject the request. Mutates in place.
*/
export function stripVersionedToolModelPrefix(tools: unknown): void {
if (!Array.isArray(tools)) return;
for (const t of tools as Array<Record<string, unknown>>) {
if (
typeof t.type === "string" &&
/^[a-z][a-z0-9_]*_\d{8}$/.test(t.type) &&
typeof t.model === "string" &&
t.model.includes("/")
) {
t.model = t.model.split("/").pop();
}
}
}
/**
* BaseExecutor - Base class for provider executors.
* Implements the Strategy pattern: subclasses override specific methods
* (buildUrl, buildHeaders, transformRequest, etc.) for each provider.
*/
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<string, string> {
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 || "";
}
buildHeaders(
credentials: ProviderCredentials,
stream = true,
clientHeaders?: Record<string, string> | null,
model?: string,
health?: Record<string, KeyHealth>
): Record<string, string> {
void clientHeaders;
void model;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.config.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);
}
}
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
} else if (credentials.apiKey) {
const extraKeys =
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
const selectedKeyId = (
credentials.providerSpecificData as Record<string, unknown> | undefined
)?.selectedKeyId as string | undefined;
let effectiveKey = credentials.apiKey;
if (extraKeys.length > 0 && credentials.connectionId) {
const resolved = resolveKeyForRequest(
credentials.connectionId,
credentials.apiKey,
extraKeys,
selectedKeyId ?? null
);
effectiveKey = resolved?.key ?? credentials.apiKey;
if (resolved && credentials.providerSpecificData) {
(credentials.providerSpecificData as Record<string, unknown>).selectedKeyId =
resolved.keyId;
}
}
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<string, unknown>;
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 };
// 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<Partial<ProviderCredentials> | 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<typeof setTimeout> | 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) {
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<number, number> = {};
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<string, unknown>) => {
proactivePersistRan = true;
activeCredentials = {
...credentials,
...(refreshResult as Partial<ProviderCredentials>),
};
await onCredentialsRefreshed(refreshResult as Partial<ProviderCredentials>);
}
: 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<string, unknown>).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<string>();
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, activeCredentials);
const headers = this.buildHeaders(activeCredentials, stream, clientHeaders, model);
applyConfiguredUserAgent(headers, activeCredentials?.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 ccRequestDefaults = isClaudeCodeCompatible(this.provider)
? getClaudeCodeCompatibleRequestDefaults(activeCredentials?.providerSpecificData)
: {};
const shouldForwardExtendedContext =
extendedContext &&
modelSupportsContext1mBeta(model) &&
!isClaudeCodeCompatible(this.provider);
const shouldForwardCcCompatibleContext1m =
isClaudeCodeCompatible(this.provider) && ccRequestDefaults.context1m === true;
if (shouldForwardExtendedContext || shouldForwardCcCompatibleContext1m) {
appendAnthropicBetaHeader(headers, CONTEXT_1M_BETA_HEADER);
}
const rawTransformedBody = await this.transformRequest(
model,
body,
stream,
activeCredentials
);
let transformedBody = sanitizeReasoningEffortForProvider(
rawTransformedBody,
this.provider,
model,
log
);
if (this.provider === "groq") {
transformedBody = stripGroqUnsupportedFields(
transformedBody as Record<string, unknown>
) as typeof transformedBody;
}
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<typeof setTimeout> | 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) &&
typeof transformedBody === "object" &&
transformedBody !== null
) {
const tb = transformedBody as Record<string, unknown>;
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<Record<string, unknown>>) {
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<string, unknown>).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<string, unknown>)
: {};
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<string, unknown>).type === "any" ||
(tb.tool_choice as Record<string, unknown>).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) {
// Default CC logic when no override headers are present
const isHaiku = typeof tb.model === "string" && tb.model.includes("haiku");
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 (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" };
}
}
// 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<string, unknown>
| 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<string, string | undefined> | 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 dayStamp = new Date().toISOString().slice(0, 10);
const buildHash = buildHashFor(CLAUDE_CODE_VERSION, dayStamp);
const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_VERSION}.${buildHash}; cc_entrypoint=cli; cch=00000;`;
const SENTINEL = "You are Claude Code, Anthropic's official CLI for Claude.";
const sysBlocks: Array<Record<string, unknown>> = Array.isArray(tb.system)
? (tb.system as Array<Record<string, unknown>>)
: 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<string, unknown>).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<string, string> = {
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);
delete headers["X-Stainless-Helper-Method"];
// Stainless OS/Arch/Runtime are host-derived (Stainless SDK does the
// same at runtime). Hardcoding them was a unique-per-deployment tell.
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"] = stainlessRuntimeVersion();
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<string, unknown>)[
"_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<string, unknown>;
if (Array.isArray(tb?.messages)) {
const fixed = fixToolPairs(tb.messages as Record<string, unknown>[]);
// 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" || isClaudeCodeCompatible(this.provider);
// 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" || isClaudeCodeCompatible(this.provider)) {
enforceThinkingTemperature(transformedBody as Record<string, unknown>);
}
// 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" || isClaudeCodeCompatible(this.provider)) &&
contextEditing?.enabled &&
!contextEditingDisabled
) {
applyContextEditingToBody(transformedBody as Record<string, unknown>, {
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(this.provider) ||
(this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken));
if (shouldFingerprint) {
const fingerprinted = applyFingerprint(this.provider, 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 (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
bodyString = await signRequestBody(bodyString);
}
mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders);
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<string, unknown>)._toolNameMap;
if (
liveToolNameMap instanceof Map &&
liveToolNameMap.size > 0 &&
!((serializedBody as Record<string, unknown>)._toolNameMap instanceof Map)
) {
Object.defineProperty(serializedBody, "_toolNameMap", {
value: liveToolNameMap,
enumerable: false,
configurable: true,
writable: true,
});
}
}
const fetchOptions: RequestInit = {
method: "POST",
headers: finalHeaders,
body: bodyString,
};
let response = await fetchWithStartTimeout(url, fetchOptions);
// 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<string, unknown>).context_management !== undefined
) {
const errText = await response
.clone()
.text()
.catch(() => "");
if (/context[_-]management|context editing/i.test(errText)) {
contextEditingDisabled = true;
delete (transformedBody as Record<string, unknown>).context_management;
let retryBody = JSON.stringify(transformedBody);
if (isClaudeCodeCompatible(this.provider) || 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 });
}
}
// 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<string, unknown>)[offending] !== undefined
) {
strippedFields.add(offending);
delete (transformedBody as Record<string, unknown>)[offending];
let retryBody = JSON.stringify(transformedBody);
if (isClaudeCodeCompatible(this.provider) || 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 });
}
}
// 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;