import { resolveCallSiteConfig } from "../config/llm-resolver.js"; import { getConfig } from "../config/loader.js"; import { resolveUsageAttribution, sanitizeUsageMetadataValue, } from "../usage/attribution.js"; import { resolveSubagentAttribution } from "../usage/subagent-attribution.js"; import { type ProviderCredentialSource, ProviderError, type ProviderErrorReason, } from "../util/errors.js"; import { getLogger } from "../util/logger.js"; import { computeRetryDelay, DEFAULT_BASE_DELAY_MS, DEFAULT_MAX_RETRIES, isRetryableNetworkError, sleep, } from "../util/retry.js"; import { isAnthropicDelegatingGateway, isAnthropicModel, } from "./anthropic-gateway-shared.js"; import { resolveLogitBiasPreset } from "./inference/logit-bias.js"; import { isAdaptiveThinkingOnlyModel, isAdaptiveThinkingUnsupportedModel, } from "./model-catalog.js"; import { isThinkingConfigAdaptive, isThinkingConfigDisabled, normalizeThinkingConfigForWire, } from "./thinking-config.js"; import { isContextOverflowError, type Message, type Provider, type ProviderResponse, type SendMessageOptions, } from "./types.js"; import { UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE } from "./unparseable-tool-args.js"; const log = getLogger("retry"); const USAGE_ATTRIBUTION_HEADER_NAMES = { callSite: "X-Vellum-LLM-Call-Site", inferenceProfile: "X-Vellum-Inference-Profile", inferenceProfileSource: "X-Vellum-Inference-Profile-Source", resolvedProvider: "X-Vellum-Resolved-Provider", resolvedModel: "X-Vellum-Resolved-Model", resolvedMixArm: "X-Vellum-Resolved-Mix-Arm", // Delegated-work attribution. Every subagent variety shares // `llm_call_site = "subagentSpawn"`, so on the authoritative billing path // these two orthogonal dimensions are the only way to tell an advisor // consult from a fork from a regular spawn. subagentRole: "X-Vellum-Subagent-Role", subagentSpawnMode: "X-Vellum-Subagent-Spawn-Mode", } as const; /** Providers whose transports consume `promptCacheKey` (OpenAI Responses * `prompt_cache_key`); `RetryProvider` derives it from `selectionSeed` for * these only. */ const PROMPT_CACHE_KEY_PROVIDERS = new Set(["openai", "openrouter"]); /** Providers that support the `effort` config (extended thinking / reasoning). */ const EFFORT_SUPPORTED_PROVIDERS = new Set([ "anthropic", "openai", "openrouter", "vercel-ai-gateway", "fireworks", "together", "baseten", "poolside", ]); // For these providers, disabling reasoning is encoded through the same effort // knob their transports send on the wire. Non-"none" tiers can still vary by // model and are handled by the provider client. const DISABLED_THINKING_USES_EFFORT_PROVIDERS = new Set([ "openai", "fireworks", "together", "openrouter", "vercel-ai-gateway", "baseten", "poolside", ]); // Whether a disabled `thinking` config must be encoded as `effort: "none"` // for this provider/model. Gateway calls that delegate `anthropic/*` models // to the Anthropic Messages API are excluded: the delegate honors a disabled // `thinking` natively and `effort` keeps its Anthropic meaning there, so // forcing it would diverge from the direct `anthropic` provider. function disabledThinkingForcesEffortNone( providerName: string, model: unknown, ): boolean { if (!DISABLED_THINKING_USES_EFFORT_PROVIDERS.has(providerName)) { return false; } return !( isAnthropicDelegatingGateway(providerName) && typeof model === "string" && isAnthropicModel(model) ); } /** * Providers that consume the `thinking` config. Anthropic uses it directly on * the wire; OpenRouter forwards it on its Anthropic delegate path and * translates it into `reasoning` for OpenAI-compat calls; the Vercel AI * Gateway consumes it only on its `anthropic/*` delegate path (no wire effect * for its other models); Gemini reads `thinking.level` to populate * `thinkingConfig.thinkingLevel`. */ const THINKING_AWARE_PROVIDERS = new Set([ "anthropic", "openrouter", "vercel-ai-gateway", "gemini", ]); /** * Providers that consume Gemini-only thinking extras (`level`, * `streamThinking`). For other thinking-aware providers, we scrub these from * the normalized wire payload because Anthropic's SDK rejects unknown keys * inside the `thinking` object with "Extra inputs are not permitted". */ const THINKING_EXTRA_FIELDS_AWARE_PROVIDERS = new Set(["gemini"]); /** * Providers that consume the `verbosity` config. Currently OpenAI (mapped to * `text.verbosity` on the Responses API — a GPT-5-series parameter). */ const VERBOSITY_SUPPORTED_PROVIDERS = new Set(["openai"]); /** Patterns that indicate a transient streaming corruption from the SDK. */ const RETRYABLE_STREAM_PATTERNS = [ "Unexpected event order", "stream ended without producing", "request ended without sending any chunks", "stream has ended, this shouldn't happen", // The SDK's stream accumulator throws this when the model emits tool-call // arguments that don't parse as JSON (e.g. an unquoted string value). The // Anthropic client salvages most of these into a `_raw`-wrapped tool call // before they surface (see anthropic/stream-content-shadow.ts); ones that // still reach here retry with a corrective note // (`withUnparseableToolArgsHint`) because the malformation can be // conditioned on the request context — a byte-identical resend can // reproduce it indefinitely. UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE, ]; /** * One-shot note appended to the retried request after a tool-argument JSON * parse failure. Appended as a trailing text block on the latest user * message: the request tail sits after every prompt-cache anchor, so the * hint costs no cache reuse (a system-prompt edit would invalidate the whole * cached prefix). */ const UNPARSEABLE_TOOL_ARGS_RETRY_HINT = "[assistant runtime] The previous attempt at this response was discarded: " + "a tool call's arguments were not valid JSON (typically an unquoted string " + "value). Respond again, emitting tool-call arguments as strict JSON — " + "every string value double-quoted, including values that begin with '[' " + "or '{'."; function isUnparseableToolArgsError(error: unknown): boolean { if (!(error instanceof ProviderError)) { return false; } if (error.statusCode !== undefined) { return false; } return error.message.includes(UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE); } /** * Copy of `messages` with the corrective note appended to the latest user * message. When the request doesn't end on a user message (assistant * prefill), returns `messages` unchanged — appending anything there would * change prefill semantics. */ function withUnparseableToolArgsHint(messages: Message[]): Message[] { const last = messages[messages.length - 1]; if (last === undefined || last.role !== "user") { return messages; } return [ ...messages.slice(0, -1), { ...last, content: [ ...last.content, { type: "text", text: UNPARSEABLE_TOOL_ARGS_RETRY_HINT }, ], }, ]; } /** * Patterns that indicate a transient provider error even when no HTTP status * code is available (e.g. overloaded errors delivered as SSE events mid-stream * where the initial HTTP response was 200). */ const RETRYABLE_PROVIDER_MESSAGE_PATTERNS = [/overloaded/i]; /** * Patterns that indicate the Anthropic provider SDK reported a transport-level * abort (TCP close mid-stream, edge LB idle cutoff, Bun fetch deadline) rather * than a caller-initiated cancellation or inner-timeout deadline. The SDK * surfaces all three cases as ``Request was aborted`` with ``error.status === * undefined``; the catch-site in ``providers/anthropic/client.ts`` separates * them by: * - tagging caller cancellations with ``abortReason`` (short-circuits in * {@link isRetryableError} before reaching this predicate) * - rewriting the inner-timeout message to ``"Anthropic stream timed out * after Xs (inner streamTimeoutMs)"`` (doesn't start with ``Anthropic API * error:`` so it falls through to network-error classification) * - leaving the transport-abort message verbatim as * ``"Anthropic API error: Request was aborted."`` * * Pattern is intentionally anchored to the Anthropic-specific message prefix. * The OpenAI / Gemini / OpenRouter catch-sites format their errors as * ``" API error (undefined): Request was aborted."`` (note the * ``(undefined)`` parenthetical) and — crucially — do **not** rewrite * inner-timeout failures, so a provider-agnostic ``/request was aborted/i`` * predicate would erroneously retry their 30-minute deadline failures three * additional times. Once those catch-sites grow the same * ``innerTimeoutFired`` distinction the Anthropic one has, the pattern set * here can be expanded to cover them too. * * This is the daemon-side counterpart to the vembda graceful-close behavior * for upstream disconnects (LUM-1536) — together they collapse the 45 s * silent-stall window the web client used to observe whenever Anthropic's * stream was cut mid-token. */ const RETRYABLE_TRANSPORT_ABORT_PATTERNS = [ /^anthropic api error:\s*request was aborted/i, ]; /** Semantic provider-error reasons that are safe to retry. */ const RETRYABLE_PROVIDER_ERROR_REASONS = new Set([ "rate_limited", "overloaded", "server_error", ]); function isRetryableStreamError(error: unknown): boolean { if (!(error instanceof ProviderError)) { return false; } if (error.statusCode !== undefined) { return false; } // has a real HTTP status — not a stream error return RETRYABLE_STREAM_PATTERNS.some((p) => error.message.includes(p)); } function isRetryableProviderMessage(error: unknown): boolean { if (!(error instanceof ProviderError)) { return false; } if (error.statusCode !== undefined) { return false; } // has a real HTTP status — handled by status check return RETRYABLE_PROVIDER_MESSAGE_PATTERNS.some((p) => p.test(error.message)); } function isRetryableTransportAbort(error: unknown): boolean { if (!(error instanceof ProviderError)) { return false; } // Transport aborts surface with ``status === undefined`` (the SDK never // saw an HTTP response). A real HTTP status here means a server error, // which is handled by the status check. if (error.statusCode !== undefined) { return false; } return RETRYABLE_TRANSPORT_ABORT_PATTERNS.some((p) => p.test(error.message)); } function isRetryableError(error: unknown): boolean { // Context overflow is deterministic — retrying the same oversized prompt // will never succeed. Short-circuit before the generic 429/5xx check so // ContextOverflowError (which extends ProviderError and may carry a 429 // statusCode on Gemini/Vertex) never triggers exponential backoff. if (isContextOverflowError(error)) { return false; } // Daemon/user-initiated aborts are never retryable. The catch-site tags // these with `abortReason` exactly when `signal.aborted` was true at the // time of failure, so this short-circuits before any message-based pattern // matches — which matters because transport-level aborts (retryable) and // caller-cancels both surface as "Request was aborted" from the SDK. if (error instanceof ProviderError && error.abortReason !== undefined) { return false; } // Prefer the provider-stamped semantic reason: a known reason decides // retryability outright, superseding the status/regex fallback below. Only // `unknown` (and a reason-less error) falls through. if ( error instanceof ProviderError && error.reason && error.reason !== "unknown" ) { return RETRYABLE_PROVIDER_ERROR_REASONS.has(error.reason); } if (error instanceof ProviderError && error.statusCode !== undefined) { if (error.statusCode === 429 || error.statusCode >= 500) { return true; } } if (isRetryableProviderMessage(error)) { return true; } if (isRetryableStreamError(error)) { return true; } if (isRetryableTransportAbort(error)) { return true; } return isRetryableNetworkError(error); } /** * Whether the request lands on Anthropic's Messages API wire: direct Anthropic * calls, plus OpenRouter / Vercel AI Gateway calls that delegate `anthropic/*` * models to it. Anthropic's thinking wire constraints (forced tool_choice, * temperature ≠ 1, top_p) apply exactly to these requests. */ function targetsAnthropicWire(providerName: string, model: string): boolean { if (providerName === "anthropic") { return true; } if (isAnthropicDelegatingGateway(providerName)) { return isAnthropicModel(model); } return false; } /** * Normalize per-call options before handing them to the wrapped provider. * * When `config.callSite` is set, resolves model/maxTokens/effort/speed/ * verbosity/temperature/thinking via `resolveCallSiteConfig` and writes them * into `nextConfig` using the wire-format names that downstream provider * clients consume (`max_tokens` snake-case for the token cap; camelCase for * the rest, which matches the resolver's shape). Per-call explicit overrides * on the original `config` object win over the resolved values, so callers can * pin a model or other parameter for a single request. `contextWindow` and * `provider` are intentionally excluded from the written fields — they are * server-side routing/overflow concerns, not provider request parameters, * and forwarding them would leak unknown fields into provider request bodies * (strict-schema clients like Anthropic reject the request). * * Whether or not `callSite` is set, this function applies per-provider * stripping (`thinking`/`effort`/`speed`/`verbosity`) based on the wrapped * provider's name — agent-loop callers that pre-resolve provider/model still * need this stripping so they don't accidentally send Anthropic-only knobs to * OpenAI etc. */ function normalizeSendMessageOptions( providerName: string, options?: SendMessageOptions, normalizeOptions: { forwardUsageAttributionHeaders?: boolean } = {}, ): SendMessageOptions | undefined { const config = options?.config; if (!config) { return options; } const nextConfig: Record = { ...config }; // Internal metadata must be derived here, not accepted from callers, and it // must never leak into provider JSON request bodies. delete nextConfig.usageAttributionHeaders; delete nextConfig.usageTracking; // Preserve the per-conversation prompt-cache key before `selectionSeed` is // stripped below. Gated to providers whose Responses transport consumes it // as `prompt_cache_key` (direct OpenAI, and OpenRouter's `openai/*` // Responses delegate); creating it elsewhere would leak a non-wire field // through clients that spread config into request bodies. The Anthropic // client strips `promptCacheKey` from its wire config, which also covers // OpenRouter's `anthropic/*` delegation path. An explicit caller-set value // wins. if ( PROMPT_CACHE_KEY_PROVIDERS.has(providerName) && nextConfig.promptCacheKey === undefined && typeof config.selectionSeed === "string" && config.selectionSeed.length > 0 ) { nextConfig.promptCacheKey = config.selectionSeed; } // `overrideProfile`, `forceOverrideProfile`, `selectionSeed`, and // `conversationId` are routing/resolution-time concerns (consumed by the // resolver below, `CallSiteRoutingProvider`'s provider selection, and // `UsageTrackingProvider`'s ledger attribution); none is a wire-format // field. Strip unconditionally (after the `openai` promptCacheKey copy // above) so they never leak into provider request bodies even when callers // set them without a `callSite`. delete nextConfig.overrideProfile; delete nextConfig.forceOverrideProfile; delete nextConfig.selectionSeed; delete nextConfig.conversationId; if (config.callSite !== undefined) { const resolved = resolveCallSiteConfig(config.callSite, getConfig().llm, { overrideProfile: config.overrideProfile, forceOverrideProfile: config.forceOverrideProfile, selectionSeed: config.selectionSeed, }); const attribution = resolveUsageAttribution({ callSite: config.callSite, overrideProfile: config.overrideProfile, forceOverrideProfile: config.forceOverrideProfile, selectionSeed: config.selectionSeed, }); const explicitModel = typeof config.model === "string" && config.model.trim().length > 0 ? config.model.trim() : undefined; // Routing key is consumed by the resolver above and must not leak // downstream as a wire-format field. delete nextConfig.callSite; if (normalizeOptions.forwardUsageAttributionHeaders === true) { // Read from the conversation row rather than the live SubagentManager: // the row is durable and the lookup is a memoized primary-key read that // can never throw, so billing attribution cannot destabilize dispatch. const subagent = resolveSubagentAttribution(config.conversationId); const usageAttributionHeaders = buildUsageAttributionHeaders({ callSite: attribution.callSite, appliedProfile: attribution.appliedProfile, profileSource: attribution.profileSource, resolvedProvider: attribution.resolvedProvider, resolvedModel: attribution.resolvedModel, resolvedMixArm: attribution.resolvedMixArm, subagentRole: subagent.subagentRole, subagentSpawnMode: subagent.subagentSpawnMode, }); if (Object.keys(usageAttributionHeaders).length > 0) { nextConfig.usageAttributionHeaders = usageAttributionHeaders; } } // Apply resolved values, letting per-call explicit fields win where set. nextConfig.model = explicitModel ?? resolved.model; if (nextConfig.max_tokens === undefined) { nextConfig.max_tokens = resolved.maxTokens; } if (nextConfig.effort === undefined) { nextConfig.effort = resolved.effort; } if (nextConfig.speed === undefined) { nextConfig.speed = resolved.speed; } if (nextConfig.verbosity === undefined) { nextConfig.verbosity = resolved.verbosity; } // `temperature` defaults to `null` in the LLM schema (meaning "no opinion // — let the provider pick its own default"). Only forward when the // resolved value is an actual number; passing `temperature: null` to // provider clients would either be a wire error or silently override // sensible provider defaults. Mirrors the legacy non-callSite path which // never set `temperature` on `providerConfig`. if ( nextConfig.temperature === undefined && resolved.temperature !== null && resolved.temperature !== undefined ) { nextConfig.temperature = resolved.temperature; } // `topP` (schema, camelCase) maps to the provider wire field `top_p`. // Defaults to `null` ("no opinion"); only forward an actual number so we // never send `top_p: null`, mirroring the `temperature` handling above. if ( nextConfig.top_p === undefined && resolved.topP !== null && resolved.topP !== undefined ) { nextConfig.top_p = resolved.topP; } if (nextConfig.thinking === undefined && resolved.thinking !== undefined) { nextConfig.thinking = resolved.thinking; } // Not a wire field: consumed (and stripped) by provider clients that // implement prompt caching, like `cacheTtl` / `disableTurnStartCache`. if ( nextConfig.disableCache === undefined && resolved.disableCache !== undefined ) { nextConfig.disableCache = resolved.disableCache; } // Forward OpenRouter-only routing preferences so `OpenRouterProvider` can // translate `openrouter.only` into the wire-format `provider: { only: [...] }` // body field on both the OpenAI-compat and Anthropic-compat endpoints. if ( providerName === "openrouter" && nextConfig.openrouter === undefined && Array.isArray(resolved.openrouter?.only) && resolved.openrouter.only.length > 0 ) { nextConfig.openrouter = { only: resolved.openrouter.only }; } // Forward a profile's opted-in `logit_bias` preset only on the Fireworks // (OpenAI-compatible) path. `resolved.logitBias` is set by the resolver from // the single winning profile (not the deep-merge), so it can't leak from a // lower-precedence profile into one that didn't opt in. // `resolveLogitBiasPreset` additionally gates on the resolved model's // tokenizer. Strict-schema clients (Anthropic) reject unknown body fields, // hence the provider gate. if ( providerName === "fireworks" && nextConfig.logit_bias === undefined && resolved.logitBias !== undefined && typeof nextConfig.model === "string" ) { const biasMap = resolveLogitBiasPreset( resolved.logitBias, nextConfig.model, ); if (biasMap !== undefined) { nextConfig.logit_bias = biasMap; } } // `contextWindow` and `provider` are server-side concerns, not provider // request parameters: effective context is resolved per call site/profile // by the agent/conversation path, while `provider` selection is handled by // `CallSiteRoutingProvider` upstream. Forwarding them as per-call config // leaks unknown fields into provider request bodies — Anthropic (and other // strict-schema clients) reject the request with // "Extra inputs are not permitted". } // Convert schema-shape `{ enabled, streamThinking }` into Anthropic's // discriminated wire-format (`{ type: "adaptive" | "disabled" }`). // `AnthropicProvider`'s SDK requires a `type` discriminator, and downstream // forced-tool/temperature conflict checks compare against the wire shape. // Applies to both the resolver path above and pass-through callers (e.g. // `host.providers.llm.complete`) that supply `thinking` directly without a // `callSite`. if (nextConfig.thinking !== undefined) { const normalized = normalizeThinkingConfigForWire(nextConfig.thinking); if (normalized === undefined) { delete nextConfig.thinking; } else { nextConfig.thinking = normalized; } } if ( isThinkingConfigDisabled(nextConfig.thinking) && disabledThinkingForcesEffortNone(providerName, nextConfig.model) ) { nextConfig.effort = "none"; } // Claude Fable always reasons with adaptive thinking and rejects an explicit // `thinking: { type: "disabled" }` (Anthropic 400s the request). Drop a // disabled thinking config for these models so they fall back to their // always-on adaptive thinking; effort and other params are unaffected. if ( typeof nextConfig.model === "string" && isAdaptiveThinkingOnlyModel(nextConfig.model) && isThinkingConfigDisabled(nextConfig.thinking) ) { delete nextConfig.thinking; } // Pre-adaptive Claude models (Haiku 4.5, Opus 4.5, Sonnet 4.5) reject // `thinking: { type: "adaptive" }` (Anthropic 400s the request), and Vellum // never sends the legacy budget_tokens form. Drop an adaptive thinking // config for these models so the request goes out without thinking instead // of failing. A pass-through `{ type: "enabled", budget_tokens }` config is // left intact: these models do support that shape. if ( typeof nextConfig.model === "string" && isAdaptiveThinkingUnsupportedModel(nextConfig.model) && isThinkingConfigAdaptive(nextConfig.thinking) && targetsAnthropicWire(providerName, nextConfig.model) ) { delete nextConfig.thinking; } // thinking is Anthropic-specific on the wire; OpenRouter reads it as a // signal for its unified reasoning parameter; Gemini reads `level` from it. // Strip it for other providers. if ( !THINKING_AWARE_PROVIDERS.has(providerName) && nextConfig.thinking !== undefined ) { delete nextConfig.thinking; } // Strip Gemini-only extras (`level`, `streamThinking`) from the wire // `thinking` object for providers that don't read them. Anthropic in // particular rejects unknown keys inside `thinking` with "Extra inputs are // not permitted"; the OpenRouter Anthropic-compat path hits the same SDK. if ( nextConfig.thinking !== undefined && !THINKING_EXTRA_FIELDS_AWARE_PROVIDERS.has(providerName) && typeof nextConfig.thinking === "object" && nextConfig.thinking !== null ) { const wire = nextConfig.thinking as Record; if (wire.level !== undefined || wire.streamThinking !== undefined) { const scrubbed: Record = {}; for (const [key, value] of Object.entries(wire)) { if (key === "level" || key === "streamThinking") { continue; } scrubbed[key] = value; } nextConfig.thinking = scrubbed; } } // Anthropic (and the gateways fronting Anthropic) rejects requests that // combine extended thinking with forced tool use (`tool_choice.type` of // `"tool"` or `"any"`). Strip thinking when both are present so the // request doesn't fail with a 400 "Thinking may not be enabled when // tool_choice forces tool use." `tool_choice: { type: "auto" }` is // compatible with thinking and left untouched. // // For OpenRouter and the Vercel AI Gateway, only strip when routing to an // `anthropic/*` model — non-Anthropic reasoning models don't share this // wire constraint (e.g. OpenRouter translates `thinking` into its // `reasoning` parameter via `buildExtraCreateParams` and may support // reasoning with forced tool_choice). const isThinkingForcedToolConflict = (() => { if (nextConfig.thinking == null) { return false; } if (isThinkingConfigDisabled(nextConfig.thinking)) { return false; } const tc = nextConfig.tool_choice as Record | undefined; if (tc == null || (tc.type !== "tool" && tc.type !== "any")) { return false; } const model = typeof nextConfig.model === "string" ? nextConfig.model : ""; return targetsAnthropicWire(providerName, model); })(); if (isThinkingForcedToolConflict) { delete nextConfig.thinking; } // Anthropic (and the gateways fronting Anthropic) rejects requests that // combine extended thinking with `temperature` ≠ 1. From the API: // "`temperature` may only be set to 1 when thinking is enabled or in // adaptive mode." // // Defense-in-depth: callers that hardcode a non-default temperature in // their per-call config are easy to miss when reviewing — we already had // this bug ship in three places (reply suggestions, recall agent // round, recall fallback finalize). Drop the offending temperature with // a warn log so the request goes through with Anthropic's default // (which is 1 in thinking mode anyway). We keep `thinking` rather than // `temperature` because thinking is the more deliberate, profile-level // choice — silently downgrading reasoning capacity for an unrelated // per-call hint would be the worse failure mode. // // Scope: // - Anthropic: always. // - OpenRouter / Vercel AI Gateway fronting `anthropic/*`: same wire // constraint applies. // - Other providers: not our problem here (e.g. OpenAI reasoning models // strip `temperature` upstream; non-Anthropic gateway reasoning // models don't have this exact constraint). // // Anthropic applies the same constraint family to `top_p` (see the `top_p` // guard below), so the "thinking is enabled on the Anthropic wire" predicate // is shared between the two guards. const isThinkingEnabledOnAnthropicWire = (() => { const model = typeof nextConfig.model === "string" ? nextConfig.model : ""; // Claude Fable always reasons in adaptive mode, so the constraint applies // even when no explicit `thinking` config is present (a disabled config was // already dropped above). For every other model the constraint only applies // when thinking is actually enabled. if (!isAdaptiveThinkingOnlyModel(model)) { if (nextConfig.thinking == null) { return false; } if (isThinkingConfigDisabled(nextConfig.thinking)) { return false; } } return targetsAnthropicWire(providerName, model); })(); const isThinkingTemperatureConflict = (() => { if (!isThinkingEnabledOnAnthropicWire) { return false; } const temp = nextConfig.temperature; if (typeof temp !== "number") { return false; } // Unlike `top_p`, `temperature: 1` is explicitly accepted alongside // thinking, so it's the one value that doesn't conflict. return temp !== 1; })(); if (isThinkingTemperatureConflict) { log.warn( { providerName, callSite: config.callSite, droppedTemperature: nextConfig.temperature, }, "Dropping `temperature` because thinking is enabled — Anthropic only " + "accepts `temperature: 1` (or unset) when thinking/adaptive mode is " + "on. Set `thinking: { type: 'disabled' }` on the call site if you " + "need a specific temperature.", ); delete nextConfig.temperature; } // Anthropic (and the gateways fronting Anthropic) also rejects requests that // combine extended thinking with *any* `top_p` modification. Unlike // `temperature` there is no "=== 1 is fine" exception — when thinking is // enabled the request must not set `top_p` at all. Drop it with a warn log // so the request goes through with Anthropic's default, keeping `thinking` // (the more deliberate, profile-level choice) for the same reasons as the // temperature guard above. if (isThinkingEnabledOnAnthropicWire && nextConfig.top_p !== undefined) { log.warn( { providerName, callSite: config.callSite, droppedTopP: nextConfig.top_p, }, "Dropping `top_p` because thinking is enabled — Anthropic does not " + "accept `top_p` modifications when thinking/adaptive mode is on. Set " + "`thinking: { type: 'disabled' }` on the call site if you need a " + "specific top_p.", ); delete nextConfig.top_p; } // effort is supported by Anthropic, OpenAI, and OpenAI-compatible providers; strip for others if ( !EFFORT_SUPPORTED_PROVIDERS.has(providerName) && nextConfig.effort !== undefined ) { delete nextConfig.effort; } // speed (fast mode) is Anthropic-specific; strip for other providers if (providerName !== "anthropic" && nextConfig.speed !== undefined) { delete nextConfig.speed; } // verbosity maps to OpenAI's `text.verbosity` (Responses API); strip for // providers that don't accept it to avoid leaking unknown fields on the wire. if ( !VERBOSITY_SUPPORTED_PROVIDERS.has(providerName) && nextConfig.verbosity !== undefined ) { delete nextConfig.verbosity; } // `openrouter.only` is OpenRouter-specific routing; strip for other // providers so strict-schema clients don't see an unknown field. if (providerName !== "openrouter" && nextConfig.openrouter !== undefined) { delete nextConfig.openrouter; } return { ...options, config: nextConfig, }; } function buildUsageAttributionHeaders(input: { callSite: string | null; appliedProfile: string | null; profileSource: string; resolvedProvider: string; resolvedModel: string; resolvedMixArm: string | null; subagentRole: string | null; subagentSpawnMode: string | null; }): Record { const headers: Record = {}; addSanitizedHeader( headers, USAGE_ATTRIBUTION_HEADER_NAMES.callSite, input.callSite, ); addSanitizedHeader( headers, USAGE_ATTRIBUTION_HEADER_NAMES.inferenceProfile, input.appliedProfile, ); if (input.appliedProfile) { addSanitizedHeader( headers, USAGE_ATTRIBUTION_HEADER_NAMES.inferenceProfileSource, input.profileSource, ); } addSanitizedHeader( headers, USAGE_ATTRIBUTION_HEADER_NAMES.resolvedProvider, input.resolvedProvider, ); addSanitizedHeader( headers, USAGE_ATTRIBUTION_HEADER_NAMES.resolvedModel, input.resolvedModel, ); addSanitizedHeader( headers, USAGE_ATTRIBUTION_HEADER_NAMES.resolvedMixArm, input.resolvedMixArm, ); addSanitizedHeader( headers, USAGE_ATTRIBUTION_HEADER_NAMES.subagentRole, input.subagentRole, ); addSanitizedHeader( headers, USAGE_ATTRIBUTION_HEADER_NAMES.subagentSpawnMode, input.subagentSpawnMode, ); return headers; } function addSanitizedHeader( headers: Record, name: string, value: unknown, ): void { const sanitized = sanitizeUsageMetadataValue(value); if (sanitized != null) { headers[name] = sanitized; } } /** * `RetryProvider` sets `retriesExhausted = true` on the final thrown error * when the retry loop burned through all attempts against a retryable error * (transient network, 5xx, provider-overloaded, mid-stream corruption). * Consumers can read it via `(err as { retriesExhausted?: boolean })` to * suppress Sentry captures for user-network-flap noise — the retry loop * already did its job, and no engineering action would change the outcome. */ export class RetryProvider implements Provider { public readonly name: string; private inner: Provider; get tokenEstimationProvider(): string | undefined { return this.inner.tokenEstimationProvider; } get supportsNativeWebSearch(): boolean | undefined { return this.inner.supportsNativeWebSearch; } supportsNativeWebSearchFor(options?: SendMessageOptions): boolean { return this.inner.supportsNativeWebSearchFor ? this.inner.supportsNativeWebSearchFor(options) : this.inner.supportsNativeWebSearch === true; } // Forward the optional token-counting endpoint so the capability survives // the wrapper chain (callers gate on its presence). Bound straight to the // inner provider — count_tokens is a cheap separate endpoint and its caller // already falls back on error, so it needs no retry wrapping. // Deliberately not re-bound when a credential refresh swaps `inner`: every // outer wrapper snapshots this the same way at construction, so a re-bind // here would never reach callers. count_tokens on the pre-refresh credential // fails soft — its caller falls back to estimation. public readonly countInputTokens?: NonNullable; constructor( inner: Provider, private readonly options: { forwardUsageAttributionHeaders?: boolean; credentialSource?: ProviderCredentialSource; connectionName?: string; refreshCredentialProvider?: () => Promise; } = {}, ) { this.inner = inner; this.name = inner.name; if (inner.countInputTokens) { this.countInputTokens = inner.countInputTokens.bind(inner); } } private shouldRefreshManagedCredential(error: unknown): boolean { return ( this.options.credentialSource === "vellum-managed" && this.options.refreshCredentialProvider !== undefined && error instanceof ProviderError && (error.statusCode === 401 || error.statusCode === 403) && (error.reason === undefined || error.reason === "unknown" || error.reason === "invalid_credentials") ); } private attributeCredential(error: unknown): void { const { credentialSource, connectionName } = this.options; if ( !(error instanceof ProviderError) || (!credentialSource && !connectionName) ) { return; } // Merges under whatever a closer layer already stamped, so a route // resolved at dispatch keeps precedence over this adapter's own view. error.attachRouteAttribution({ ...(credentialSource ? { credentialSource } : {}), ...(connectionName ? { connectionName } : {}), }); } async sendMessage( messages: Message[], options?: SendMessageOptions, ): Promise { let didRetry = false; let retryAttempt = 0; let credentialRefreshAttempted = false; let messagesForAttempt = messages; const normalizedOptions = normalizeSendMessageOptions(this.name, options, { forwardUsageAttributionHeaders: this.options.forwardUsageAttributionHeaders === true, }); while (true) { try { const result = await this.inner.sendMessage( messagesForAttempt, normalizedOptions, ); return result; } catch (error) { if ( !credentialRefreshAttempted && this.shouldRefreshManagedCredential(error) ) { credentialRefreshAttempted = true; try { const refreshed = await this.options.refreshCredentialProvider?.(); if (refreshed) { this.inner = refreshed; log.info( { provider: this.name, connectionName: this.options.connectionName, }, "Retrying managed inference with refreshed assistant credentials", ); continue; } } catch (refreshError) { log.warn( { provider: this.name, connectionName: this.options.connectionName, refreshError, }, "Failed to reload managed assistant credentials", ); } } if (retryAttempt < DEFAULT_MAX_RETRIES && isRetryableError(error)) { // Malformed tool-argument JSON is conditioned on the request, so // resend with the corrective note. Built from the original // `messages` each time — the note appears exactly once no matter // how many attempts fail this way. if (isUnparseableToolArgsError(error)) { messagesForAttempt = withUnparseableToolArgsHint(messages); } // Prefer server-provided Retry-After; fall back to exponential backoff. const retryAfter = error instanceof ProviderError ? error.retryAfterMs : undefined; const MAX_RETRY_DELAY_MS = 60_000; // Cap server-suggested delays at 60s const delay = Math.min( retryAfter ?? computeRetryDelay(retryAttempt, DEFAULT_BASE_DELAY_MS), MAX_RETRY_DELAY_MS, ); const errorType = error instanceof ProviderError && error.statusCode === 429 ? "rate_limit" : error instanceof ProviderError && error.statusCode !== undefined && error.statusCode >= 500 ? `server_error_${error.statusCode}` : isRetryableProviderMessage(error) ? "provider_overloaded" : isRetryableStreamError(error) ? "stream_corruption" : isRetryableTransportAbort(error) ? "transport_abort" : "network_error"; log.warn( { attempt: retryAttempt + 1, maxRetries: DEFAULT_MAX_RETRIES, delay, retryAfterHeader: retryAfter !== undefined, errorType, correctiveHint: messagesForAttempt !== messages, provider: this.name, message: error instanceof Error ? error.message : String(error), }, "Retrying after transient error", ); didRetry = true; retryAttempt++; await sleep(delay); continue; } // If we exhausted retries on a retryable error, tag the error so // downstream consumers (Sentry capture, etc.) can recognize that the // retry loop already tried its best. The catch-site logic above only // stops retrying when either (a) retries are exhausted, or (b) the // error isn't retryable — so we check the retryable predicate here to // distinguish the two cases. if (didRetry && isRetryableError(error) && error instanceof Error) { (error as Error & { retriesExhausted?: boolean }).retriesExhausted = true; } this.attributeCredential(error); throw error; } } } }