mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 10:12:11 +03:00
* refactor(cursor): extracts token extraction into shared lib
Moves tryIdeAuth/tryAgentAuth and supporting helpers out of the
auto-import route into src/lib/cursor/tokenExtractor.ts, and adds
an agent-cli-state.json fallback candidate path to tryAgentAuth
(alongside the existing auth.json candidate) so the extraction
logic can be reused by the upcoming renewal orchestrator.
* feat(cursor): adds cursor-agent-backed token renewal orchestrator
Builds the renewal orchestrator in src/lib/cursor/renewal.ts: a
bounded, unattended-safe --list-models nudge, a side-effect-free
status availability check, an in-flight spawn lock keyed by
command, and renewCursorConnection() which nudges cursor-agent
then independently re-scrapes the IDE and cursor-agent credential
sources to detect whichever refreshed. Extends cursorAgent.ts's
binary resolution and spawn helper with fixed-paths-only mode and
a SIGKILL follow-up for background use. Adds a generic keyed-mutex
utility (src/shared/utils/keyedMutex.ts) for serializing a
connection's renew-then-persist cycle, and forwards a busy-timeout
through driverFactory's node:sqlite fallback path.
* feat(cursor): proactively renews Cursor sessions in the sweep
Adds src/lib/tokenHealthCheckCursor.ts, sweep-side glue that calls
the renewal orchestrator and persists the result, wired into
tokenHealthCheck.ts's checkConnection() via a new Cursor-specific
branch placed ahead of the generic no-refresh-token fallthrough.
Carves out a non-terminal exception for a Cursor connection that
already landed at testStatus "expired" via the request-time 401
path, excluding permanently-dead account_deactivated connections.
Extends buildRefreshFailureUpdate() with an overrides param so
Cursor's failure path can use a distinct, non-terminal errorCode
instead of the generic refresh_failed/expired taxonomy.
* feat(cursor): adds local-only manual refresh route
Adds POST /api/providers/[id]/refresh-cursor, a dedicated
loopback-only route that calls the renewal orchestrator on demand
for a single Cursor connection, bounded by a 30s per-connection
cooldown. Classifies the new route in LOCAL_ONLY_API_PATTERNS and
closes the manage-scope-bypass gap for dynamic-segment spawn-capable
routes under /api/providers/ via a new SPAWN_CAPABLE_PATTERNS /
SPAWN_CAPABLE_PATTERN_ANCESTORS mechanism, which also retroactively
covers the pre-existing /login route. The existing shared
/api/providers/[id]/refresh route is untouched and stays
remote-reachable for every other provider.
* feat(cursor): surfaces a dismissible cursor-agent nudge
Adds GET /api/providers/cursor/agent-availability, a credential-free
LOCAL_ONLY route returning only { cursorAgentAvailable: boolean },
backed by a 5-minute cached wrapper around the renewal orchestrator's
existing availability check. Surfaces a dismissible dashboard banner
on the Cursor provider page suggesting cursor-agent installation
when it isn't detected, following the existing dismissible-banner
convention. Also fixes a pre-existing bracket character in a
routeGuard.ts comment that was silently truncating
check-openapi-security-tiers.mjs's view of LOCAL_ONLY_API_PREFIXES.
* fix(cursor): wires manual refresh button to the new route
Branches handleRefreshToken to call the dedicated Cursor refresh
route instead of the generic /refresh route, which silently 502s
for Cursor connections today since they carry no refresh token.
Every other provider's refresh behavior is unaffected. Adds the
cursorSessionUnchanged i18n key and syncs it (plus a pre-existing,
unrelated 28-key backlog) across all 42 locale files.
* fix(cursor): addresses Phase 4/4.5 review findings
Restores the legacy stdout/stderr auth-pattern fallback in
checkCursorAgentAvailability() that the plan's Task 2 Step 4
required but the implementation had dropped. Threads an optional
deps parameter through checkCursorConnectionIfNeeded() so its
error branch is reachable in tests, and switches both it and the
manual-refresh route to exhaustive switch statements over the
renewal result. Adds a short-lived host-keyed dedup cache around
tryIdeAuth() so multiple due Cursor connections sharing a host
don't each open the same state.vscdb file in one sweep tick.
Adds opportunistic eviction to the manual-refresh cooldown map,
an outer try/catch to the availability route for defense-in-depth
consistency with the plan's other routes, and corrects a stale
JSDoc claim about the /login route's auth check. Documents the
now-empirically-confirmed agent-cli-state.json schema mismatch
found while validating against a real cursor-agent install.
* docs(cursor): adds changelog fragments for the renewal plan
Adds one fragment per user-facing outcome per changelog.d/README.md's
convention for a PR that both fixes and adds. PR number placeholder
to be filled in once the PR is opened.
* fix(i18n): translates the new Cursor keys into Vietnamese
The i18n:sync-ui run in an earlier commit left __MISSING__
sentinels for the 4 new Cursor keys in every locale, but
Vietnamese has a dedicated completeness test requiring zero
internal missing markers. Provides real translations for
cursorSessionUnchanged, cursorAgentNudgeTitle,
cursorAgentNudgeBody, and cursorAgentNudgeDismiss.
* fix(cursor): addresses quality-gate Layer 1.5 findings
Restores a comment that misrepresented execFile's actual argv shape
after an earlier bracket-removal fix, this time avoiding literal
closing-bracket characters entirely so the openapi checker's naive
array parser can't be broken by either version. Bounds the sweep-
and manual-route-triggered tryIdeAuth() busy-timeout to 250ms
(down from the interactive auto-import path's 2000ms), since both
share the main event loop with all other in-flight requests and
should fail fast on a WAL-lock collision rather than block the
whole instance for up to ~4s. Has the manual refresh route bypass
the sweep's IDE-auth dedup cache so a click always sees a fresh
read, consistent with this plan's existing "manual actions never
see stale cached data" convention. Documents the previously-missing
agent-availability route in ROUTE_GUARD_TIERS.md's spawn-capable
table.
* fix(cursor): adds SIGKILL follow-up to the status-check spawn
Matches the nudge spawn's existing SIGTERM+SIGKILL pattern so an
unresponsive cursor-agent status check can't leak a lingering
process if it ignores SIGTERM.
* docs(cursor): fills in the PR number for changelog fragments
Renames the 3 changelog.d fragments to their PR-numbered filenames and replaces the (#PR) placeholder with #9173, now that the PR exists.
* fix(cursor): corrects changelog fragments to reference PR #9173
The prior commit only staged the git mv rename — a git add invocation with a stale (pre-rename) pathspec aborted before the actual (#PR) -> (#9173) content edit was staged, so the rename landed without the fix it was meant to carry. This captures the actual content change.
* docs(cursor): regenerates the agent-skills catalog for the new route
check:agent-skills-sync (CI's Merge integrity gate) requires SKILL.md files to stay in sync with the live route catalog. Adding /api/providers/cursor/agent-availability in an earlier commit needed a regen this branch never ran.
* chore(quality): rebaselines file-size caps grown by agentrouter merges
Two already-merged agentrouter commits (564c204ef, ec150a006) on release/v3.8.50 grew open-sse/executors/base.ts, open-sse/handlers/chatCore.ts, and tests/unit/chatcore-translation-paths.test.ts past their frozen caps before this PR branched — unrelated to the Cursor renewal changes here. No PR branch is left to fix the growth in-place, so the caps are bumped to the current real sizes, following the existing release-green rebaseline precedent in this file.
* fix(sse): imports getModel helpers from db/models, not localDb
A recently-merged agentrouter commit added a @/lib/localDb import in chatCore.ts, violating the no-restricted-imports rule (Hard Rule #2 — never barrel-import from localDb.ts). Points the import at the owning module, src/lib/db/models.ts, where both functions are actually defined, and prunes the now-stale suppression entry.
* fix(sse): scopes CC-relay anthropic-beta to its own requestDefaults
Two already-merged agentrouter commits widened usesClaudeCodeProtocol()'s native-Claude system-transform block (billing header + selectBetaFlags-derived anthropic-beta) to also run for generic CC-compatible relay connections, not just real claude traffic and agentrouter's own wire-image mimicry. selectBetaFlags() has no visibility into a relay's own providerSpecificData.requestDefaults, so its header replacement silently wiped out an earlier context-1m append and force-included redact-thinking regardless of the relay's own opt-in. Restores both for plain CC-compatible relays only; real claude/agentrouter traffic is unaffected.
Also bumps four stale hardcoded Codex/Claude Code CLI version-string test assertions (0.144.1->0.146.0, 2.1.219->2.1.220) that drifted when the same two commits bumped the version constants without updating their tests, and rebaselines base.ts's frozen file-size cap for this fix's own +35 lines.
* fix(sse): preserves bare CC-relay native treatment and context-1m
The previous commit's fix was too broad in one direction: excluding ALL CC-compatible relays from the native-Claude header block broke two pre-existing tests (cc-compatible-provider.test.ts, v3.6.6) that rely on that treatment for a 'vanilla' relay with no providerSpecificData.requestDefaults configured.
Refines the gate to this whole native-Claude header-replacement block: replace headers for real claude traffic, agentrouter's wire-image mimicry, OR a CC-relay with no requestDefaults at all — only a relay with EXPLICIT requestDefaults (context1m/redactThinking/summarizeThinking) gets to keep buildHeaders()'s own correctly-computed header set. A redact-thinking-beta strip (unconditional, a no-op when native treatment didn't apply) covers the one remaining gap: selectBetaFlags() force-includes it for a bare relay's opaque client, which a bare relay never explicitly opted into.
Verified against all three previously-conflicting pre-existing tests simultaneously: executor-default-base.test.ts's '1M beta' test, both cc-compatible-provider.test.ts SSE-forcing tests, and provider-request-failure-pipeline.test.ts's 'keeps request beta headers' test (the last of which was already broken by the raw agentrouter merge, confirmed via direct comparison against that exact commit).
* fix(sse): fills in remaining stale CLI version literals
The same two agentrouter commits bumped Codex/Claude Code CLI version constants (0.144.1->0.146.0, 2.1.219->2.1.220) without updating every hardcoded test assertion. This round covers the ones the previous version-string commit missed: the anthropic-cache-fingerprint billing-version constant, a cc-bridge-transforms body assertion, the UI-mirror parity test's own snapshot plus its RoutingTab.tsx source of truth, an integration test's User-Agent assertion (inconsistent with its own dynamic Version assertion two lines up), and the translate-path golden snapshot. Also updates a stale doc comment referencing the old literal by value instead of by constant name.
* fix(cursor): imports from db/ modules, not the localDb barrel
Both files violated Hard Rule #2 (never barrel-import from localDb.ts) — a genuine lint error that had gone uncaught locally. refresh-cursor/route.ts imported getCachedProviderConnectionById from @/lib/localDb instead of its owning module, @/lib/db/readCache. tokenHealthCheckCursor.ts copied the same pattern from its sibling tokenHealthCheckCopilot.ts (an existing, already-suppressed violation) for updateProviderConnection; imports it from @/lib/db/providers instead, with no circular-import fallout (verified via the existing token-health-check-cursor and refresh-cursor-route test suites).
* fix(db): removes stale raw-SQL allowlist entry for cursor route
The cursor auto-import route no longer contains raw SQL — that query
now lives in src/lib/cursor/tokenExtractor.ts, outside the
route/handler scope check-db-rules scans. The allowlist entry was
stale, tripping the stale-enforcement gate.
* fix(test): registers cursor test files in stryker tap.testFiles
Three unit test files covering mutation-tested modules
(route-guard-cursor-agent-availability, route-guard-cursor-refresh,
cursor-renewal) were missing from stryker.conf.json's tap.testFiles,
tripping the mutation-test-coverage gate's drift detection.
* chore(ci): retriggers checks (stuck GH Actions runner on shard 2/4)
* fix(sse): restores CC-relay context1m/redact-thinking test coverage
Rebasing onto release/v3.8.50's new tip (35405be60, an unrelated
agentrouter protocol-inference commit) silently flipped two assertions
this branch's own earlier fix (687fbda62) depends on, in the same test
files that commit touched for other reasons:
- executor-default-base.test.ts: calls[0] (a bare CC-relay with no
requestDefaults) expected redact-thinking-beta absent; flipped to
present. calls[1] (context1m+redactThinking requestDefaults) expected
the context-1m beta preserved; flipped to absent.
- provider-request-failure-pipeline.test.ts: expected Accept:
text/event-stream and the context-1m beta present for a relay with
explicit requestDefaults; flipped to application/json and absent.
35405be60 did not touch open-sse/executors/base.ts at all, so these
were test-only edits made without visibility into the still-unmerged
CC-relay header-preservation fix on this branch — they quietly matched
the assertions back to the pre-fix (buggy) behavior instead. Restores
the original, validated expectations; all three interdependent test
files (executor-default-base, cc-compatible-provider,
provider-request-failure-pipeline) verified passing together again.
* ci: re-trigger checks after GitHub Actions incident (2026-08-07, resolved)
* ci: re-trigger checks (previous push event was dropped)
* fix(quality): restore dropped vi.json cursor-renewal keys + rebaseline test growth
vi.json was missing 4 keys (cursorSessionUnchanged, cursorAgentNudgeTitle/Body/Dismiss) that this PR's own pre-merge branch had translated -- the original merge's 'git checkout --theirs' resolution for the 7 conflicted locale files discarded them since upstream's vi.json has no cursor-token-renewal feature. Restored from pre-merge tip a38003e30. Also rebaselines combo-routing-engine.test.ts (3457->3464) for the comment growth from the ALL_ACCOUNTS_INACTIVE fix, caught by CI's PR-mode check:file-size.
* chore(tests): drop explanatory comments on ALL_TARGETS_SKIPPED assertions
Kept the assertion value fix (ALL_ACCOUNTS_INACTIVE -> ALL_TARGETS_SKIPPED); the comments were unnecessary. Reverts the file-size baseline bump these comments caused (combo-routing-engine.test.ts back to its original 3457).
1621 lines
71 KiB
TypeScript
1621 lines
71 KiB
TypeScript
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,
|
|
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA,
|
|
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<string, unknown>;
|
|
|
|
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<string, string>;
|
|
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<string, string> | null;
|
|
/** Original client request headers (read-only). Executors may forward select headers upstream. */
|
|
clientHeaders?: Record<string, string> | 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 `</think>` 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<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;
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
import {
|
|
hasActiveClaudeThinking,
|
|
readNestedThinkingBudget,
|
|
clampNestedThinkingBudget,
|
|
} from "../utils/thinkingBudget.ts";
|
|
|
|
/**
|
|
* 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<Record<string, unknown>>) {
|
|
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<string, string>;
|
|
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<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 || "";
|
|
}
|
|
|
|
/**
|
|
* 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<string, unknown> | 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<string, unknown>).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<string, string>; effectiveKey: string | undefined } {
|
|
const alternate = this.resolveAlternate(credentials);
|
|
const headers: Record<string, string> = {
|
|
"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<string, string> | null,
|
|
model?: string,
|
|
health?: Record<string, KeyHealth>
|
|
): Record<string, string> {
|
|
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<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 };
|
|
// 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<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): Promise<ExecutorExecuteResult> {
|
|
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>();
|
|
// 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<string, unknown>
|
|
) 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<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)) ||
|
|
usesClaudeCodeProtocol) &&
|
|
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 && 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<string, unknown> | 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<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 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<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.
|
|
//
|
|
// This whole header shape (billing/session headers, Stainless
|
|
// metadata, selectBetaFlags()-derived anthropic-beta) mimics a
|
|
// genuine Claude Code CLI request — correct for real `claude`
|
|
// traffic, agentrouter's wire-image mimicry, and a "vanilla" (no
|
|
// requestDefaults) CC-compatible relay, none of which have their
|
|
// own per-connection header preferences to defer to. A relay with
|
|
// explicit providerSpecificData.requestDefaults (context1m /
|
|
// redactThinking / summarizeThinking) is different: it already got
|
|
// its own correctly-configured header set from
|
|
// buildClaudeCodeCompatibleHeaders() above, which selectBetaFlags()
|
|
// has no visibility into (it only reasons about the request body
|
|
// shape) — replacing those headers here would silently discard the
|
|
// relay's own opt-in configuration (#agentrouter regression: this
|
|
// whole block used to run only for real `claude` clients, where
|
|
// this distinction didn't exist).
|
|
const hasCcRequestDefaults = Object.keys(ccRequestDefaults).length > 0;
|
|
const isNativeClaudeHeaderShape =
|
|
this.provider === "claude" || usesCcWireImage(this.provider) || !hasCcRequestDefaults;
|
|
if (isNativeClaudeHeaderShape) {
|
|
// 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);
|
|
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"];
|
|
}
|
|
// selectBetaFlags() above always includes redact-thinking for an
|
|
// "opaque" client (no client-negotiated anthropic-beta) — correct
|
|
// for real `claude` traffic and agentrouter's wire-image mimicry.
|
|
// A plain CC-compatible relay (bare or configured) never opts into
|
|
// that "opaque client" default implicitly; it's an explicit
|
|
// requestDefaults.redactThinking choice. Strip it back out unless
|
|
// this relay's own requestDefaults opted in.
|
|
if (usesClaudeCodeProtocol && !usesCcWireImage(this.provider)) {
|
|
const betaKey = Object.keys(headers).find(
|
|
(key) => key.toLowerCase() === "anthropic-beta"
|
|
);
|
|
if (betaKey && ccRequestDefaults.redactThinking !== true) {
|
|
headers[betaKey] = headers[betaKey]
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.filter((value) => value && value !== CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA)
|
|
.join(",");
|
|
}
|
|
}
|
|
|
|
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" || 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<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" || usesClaudeCodeProtocol) &&
|
|
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(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<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,
|
|
};
|
|
|
|
// 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<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 (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<string, unknown>)[offending] !== undefined
|
|
) {
|
|
strippedFields.add(offending);
|
|
delete (transformedBody as Record<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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;
|