import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; import { buildClaudeCodeCompatibleHeaders, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH, joinClaudeCodeCompatibleUrl, } from "../services/claudeCodeCompatible.ts"; import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; import { mergeClientAnthropicBeta, normalizeAnthropicHeaderVariants, } from "../config/anthropicHeaders.ts"; import { isOfficialAnthropicBaseUrl } from "../utils/anthropicHost.ts"; import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { stripUnsupportedParams } from "../translator/paramSupport.ts"; import { injectReasoningContentForThinkingModel, isThinkingMessageModel, } from "../utils/reasoningContentInjector.ts"; import { detectFormat, getOpenAICompatibleType, getTargetFormat, isClaudeCodeCompatible, } from "../services/provider.ts"; import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; import { getSapResourceGroup } from "../config/sap.ts"; import { normalizeBailianMessagesUrl, normalizeDataRobotChatUrl, normalizeAzureAiChatUrl, normalizeWatsonxChatUrl, normalizeOciChatUrl, normalizeSapChatUrl, normalizeXiaomiMimoChatUrl, normalizeOpenAIChatUrl, getOpenRouterConnectionPreset, } from "./default/urlNormalizers.ts"; import { buildMaritalkChatUrl } from "../config/maritalk.ts"; import { LOCAL_PROVIDERS } from "@/shared/constants/providers"; import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; import { buildClineHeaders } from "@/shared/utils/clineAuth"; import { normalizeHerokuChatUrl, normalizeDatabricksChatUrl, normalizeSnowflakeChatUrl, normalizeGigachatChatUrl, } from "@/lib/providers/validation/urlHelpers"; import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; import type { PoolConfig } from "../services/sessionPool/types.ts"; /** * Apply operator-configured per-provider custom headers onto an outgoing header * map. Defense-in-depth on top of the Zod `customHeadersSchema`: * - skip hop-by-hop/framing AND auth header names (canonical denylist, so a row * written before the schema tightening still can't override credential auth); * - skip control-char (CR/LF/NUL) names/values before they reach undici; * - assign case-insensitively, replacing any existing same-named header (e.g. * the executor's own Content-Type/Accept) instead of emitting a duplicate. * Used for every *-compatible node, INCLUDING anthropic-compatible-cc-* (whose * header builder returns early, so custom headers must be merged in explicitly). */ function applyCustomHeaders(headers: Record, rawCustomHeaders: unknown): void { let customHeaders: Record | null = null; if ( rawCustomHeaders && typeof rawCustomHeaders === "object" && !Array.isArray(rawCustomHeaders) ) { customHeaders = rawCustomHeaders as Record; } else if (typeof rawCustomHeaders === "string") { try { const parsed = JSON.parse(rawCustomHeaders); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { customHeaders = parsed as Record; } } catch { /* ignore invalid JSON */ } } if (!customHeaders) return; for (const [k, v] of Object.entries(customHeaders)) { if (typeof k !== "string" || typeof v !== "string") continue; if (isForbiddenCustomHeaderName(k)) continue; if (/[\r\n\0]/.test(k) || /[\r\n]/.test(v)) continue; const lower = k.toLowerCase(); for (const existing of Object.keys(headers)) { if (existing.toLowerCase() === lower) delete headers[existing]; } headers[k] = v; } } export class DefaultExecutor extends BaseExecutor { constructor(provider) { super(provider, PROVIDERS[provider] || PROVIDERS.openai); const registryEntry = getRegistryEntry(provider); if (registryEntry?.poolConfig) { this.poolConfig = registryEntry.poolConfig as PoolConfig; } } buildUrl(model, stream, urlIndex = 0, credentials = null) { void model; void stream; void urlIndex; if (this.provider?.startsWith?.("openai-compatible-")) { const psd = credentials?.providerSpecificData; const baseUrl = psd?.baseUrl || "https://api.openai.com/v1"; const normalized = baseUrl.replace(/\/$/, ""); const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; if (customPath) return `${normalized}${customPath}`; const forceResponses = psd?._omnirouteForceResponsesUpstream === true; const path = forceResponses || getOpenAICompatibleType(this.provider, psd) === "responses" ? "/responses" : "/chat/completions"; return `${normalized}${path}`; } if (this.provider?.startsWith?.("anthropic-compatible-")) { const psd = credentials?.providerSpecificData; const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1"; const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; if (isClaudeCodeCompatible(this.provider)) { return joinClaudeCodeCompatibleUrl( baseUrl, customPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH ); } const normalized = baseUrl.replace(/\/$/, ""); return `${normalized}${customPath || "/messages"}`; } switch (this.provider) { case "openai": { // #5842: responses-only models (o1-pro / gpt-5.x-pro) 404 on // /v1/chat/completions ("only supported in v1/responses"). Route them to // the native /responses endpoint — the per-model targetFormat (registry tag // + the -pro heuristic in getModelTargetFormat) is the single source of // truth, keeping the URL in lockstep with the chatCore body translation. // Mirrors the gh executor's targetFormat-driven routing (9router#102). const customBaseUrl = typeof credentials?.providerSpecificData?.baseUrl === "string" && credentials.providerSpecificData.baseUrl.trim() ? (credentials.providerSpecificData.baseUrl as string) : null; const chatUrl = customBaseUrl ? normalizeOpenAIChatUrl(customBaseUrl) : this.config.baseUrl; if (getModelTargetFormat("openai", model) === "openai-responses") { return chatUrl.replace(/\/chat\/completions\/?$/, "/responses"); } return chatUrl; } case "bailian-coding-plan": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeBailianMessagesUrl(baseUrl); } case "heroku": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeHerokuChatUrl(baseUrl); } case "databricks": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeDatabricksChatUrl(baseUrl); } case "datarobot": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeDataRobotChatUrl(baseUrl); } case "azure-ai": { const forceResponses = credentials?.providerSpecificData?._omnirouteForceResponsesUpstream === true; const apiType = forceResponses || credentials?.providerSpecificData?.apiType === "responses" ? "responses" : "chat"; const baseUrl = this.resolveBaseUrl(credentials); return normalizeAzureAiChatUrl(baseUrl, apiType); } case "watsonx": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeWatsonxChatUrl(baseUrl); } case "oci": { const forceResponses = credentials?.providerSpecificData?._omnirouteForceResponsesUpstream === true; const apiType = forceResponses || credentials?.providerSpecificData?.apiType === "responses" ? "responses" : "chat"; const baseUrl = this.resolveBaseUrl(credentials); return normalizeOciChatUrl(baseUrl, apiType); } case "sap": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeSapChatUrl(baseUrl); } case "xiaomi-mimo": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeXiaomiMimoChatUrl(baseUrl); } case "snowflake": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeSnowflakeChatUrl(baseUrl); } case "gigachat": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeGigachatChatUrl(baseUrl); } case "maritalk": { const baseUrl = this.resolveBaseUrl(credentials); return buildMaritalkChatUrl(baseUrl); } case "siliconflow": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeOpenAIChatUrl(baseUrl); } case "ollama-local": case "llama-cpp": case "lm-studio": case "modal": case "reka": case "vllm": case "lemonade": case "llamafile": case "triton": case "docker-model-runner": case "xinference": case "oobabooga": { // #3197 (residual of #3136): for self-hosted/local providers, prefer the // catalog's localDefault when no explicit baseUrl is set. `this.config` // falls back to PROVIDERS.openai for providers not in the open-sse // registry (llama-cpp, etc.), so without this guard an empty baseUrl // silently hits OpenAI's API. Fall back to localDefault BEFORE config. const localDefault = LOCAL_PROVIDERS[this.provider]?.localDefault; const baseUrl = credentials?.providerSpecificData?.baseUrl || localDefault || this.config.baseUrl; return normalizeOpenAIChatUrl(baseUrl); } case "zai": case "glm-coding-apikey": { const zaiBaseUrl = this.resolveBaseUrl(credentials); return `${zaiBaseUrl}?beta=true`; } case "claude": case "glm": case "glmt": case "kimi-coding": case "minimax": case "minimax-cn": return `${this.config.baseUrl}?beta=true`; case "gemini": return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`; case "qwen": { const resourceUrl = credentials?.providerSpecificData?.resourceUrl; return `https://${resourceUrl || "portal.qwen.ai"}/v1/chat/completions`; } default: { // Honor a user-supplied custom base URL (providerSpecificData.baseUrl) for // OpenAI-format providers (e.g. the built-in "openai" provider pointed at a // proxy/gateway). Without this, a configured custom base URL was silently // ignored and requests always hit the hardcoded this.config.baseUrl // (https://api.openai.com/v1/...). Scoped to openai-format providers so // non-OpenAI default-branch providers keep their existing behavior. const customBaseUrl = typeof credentials?.providerSpecificData?.baseUrl === "string" && credentials.providerSpecificData.baseUrl.trim() ? (credentials.providerSpecificData.baseUrl as string) : null; const isOpenAIFormat = !this.config.format || this.config.format === "openai"; if (customBaseUrl && isOpenAIFormat) { return normalizeOpenAIChatUrl(customBaseUrl); } const url = this.config.baseUrl; const entry = getRegistryEntry(this.provider); return entry?.urlSuffix ? `${url}${entry.urlSuffix}` : url; } } } buildHeaders(credentials, stream = true, clientHeaders?: Record | null) { const { headers, effectiveKey } = this.buildHeadersPreamble(credentials, stream); switch (this.provider) { case "gemini": effectiveKey ? (headers["x-goog-api-key"] = effectiveKey) : (headers["Authorization"] = `Bearer ${credentials.accessToken}`); break; case "snowflake": { const rawToken = effectiveKey || credentials.accessToken || ""; const usesProgrammaticAccessToken = rawToken.startsWith("pat/"); headers["Authorization"] = `Bearer ${usesProgrammaticAccessToken ? rawToken.slice(4) : rawToken}`; headers["X-Snowflake-Authorization-Token-Type"] = usesProgrammaticAccessToken ? "PROGRAMMATIC_ACCESS_TOKEN" : "KEYPAIR_JWT"; break; } case "gigachat": headers["Authorization"] = `Bearer ${credentials.accessToken || effectiveKey}`; break; case "clarifai": { const clarifaiToken = effectiveKey || credentials.accessToken; if (clarifaiToken) { headers["Authorization"] = `Key ${clarifaiToken}`; } break; } case "azure-ai": if (effectiveKey || credentials.accessToken) { headers["api-key"] = effectiveKey || credentials.accessToken; } delete headers["Authorization"]; break; case "oci": { const bearerToken = effectiveKey || credentials.accessToken; if (bearerToken) { headers["Authorization"] = `Bearer ${bearerToken}`; } const projectId = credentials.projectId || credentials?.providerSpecificData?.projectId || credentials?.providerSpecificData?.project; if (projectId) { headers["OpenAI-Project"] = projectId; } break; } case "sap": { const bearerToken = effectiveKey || credentials.accessToken; if (bearerToken) { headers["Authorization"] = `Bearer ${bearerToken}`; } headers["AI-Resource-Group"] = getSapResourceGroup(credentials?.providerSpecificData); break; } case "reka": { const bearerToken = effectiveKey || credentials.accessToken; if (bearerToken) { headers["Authorization"] = `Bearer ${bearerToken}`; headers["X-Api-Key"] = bearerToken; } break; } case "maritalk": { const token = effectiveKey || credentials.accessToken; if (token) { headers["Authorization"] = `Key ${token}`; } break; } case "claude": case "anthropic": effectiveKey ? (headers["x-api-key"] = effectiveKey) : (headers["Authorization"] = `Bearer ${credentials.accessToken}`); break; case "glm": case "glmt": case "kimi-coding": case "bailian-coding-plan": case "kimi-coding-apikey": case "zai": case "glm-coding-apikey": headers["x-api-key"] = effectiveKey || credentials.accessToken; break; case "cline": // Cline's API requires the bearer token prefixed with `workos:` plus a // set of Cline client-identification headers; plain `Bearer ` // is rejected upstream. buildClineHeaders() emits both. Object.assign(headers, buildClineHeaders(effectiveKey || credentials.accessToken)); break; default: if (isClaudeCodeCompatible(this.provider)) { const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults( credentials?.providerSpecificData ); const ccHeaders = buildClaudeCodeCompatibleHeaders( effectiveKey || credentials.accessToken || "", stream, credentials?.providerSpecificData?.ccSessionId, { redactThinking: ccRequestDefaults.redactThinking === true } ); // CC nodes are also anthropic-compatible-*, so honor operator custom // headers here (the early return skips the shared block below). applyCustomHeaders(ccHeaders, credentials.providerSpecificData?.customHeaders); return ccHeaders; } if (this.provider?.startsWith?.("anthropic-compatible-")) { if (effectiveKey) { headers["x-api-key"] = effectiveKey; } else if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; } // Port of decolua/9router commit b977bf74: // Third-party Anthropic-compatible gateways frequently require // Authorization: Bearer ALONGSIDE x-api-key — without it they // return 401 missing_api_key on every forward. Only emit the // Bearer fallback for non-official upstreams; api.anthropic.com // (and the empty/default baseUrl that targets it) must keep the // x-api-key-only behavior to avoid regressing the official path. if (effectiveKey && !headers["Authorization"]) { const baseUrl = credentials?.providerSpecificData?.baseUrl || ""; const isOfficialAnthropic = isOfficialAnthropicBaseUrl(baseUrl); if (!isOfficialAnthropic) { headers["Authorization"] = `Bearer ${effectiveKey}`; } } // Default the anthropic-version header only when the caller/operator // has not already supplied one. The lookup is case-insensitive so a // pre-set "Anthropic-Version" (e.g. from this.config.headers or a // custom header) is not clobbered with a duplicate lowercase entry. const hasAnthropicVersion = Object.keys(headers).some( (key) => key.toLowerCase() === "anthropic-version" ); if (!hasAnthropicVersion) { headers["anthropic-version"] = "2023-06-01"; } } else { // Use registry authHeader if available, otherwise default to bearer const entry = getRegistryEntry(this.provider); const authHeader = entry?.authHeader || "bearer"; const token = effectiveKey || credentials.accessToken || entry?.anonymousApiKey; if (token) { if (authHeader === "x-api-key") { headers["x-api-key"] = token; } else if (authHeader === "x-goog-api-key") { headers["x-goog-api-key"] = token; } else { headers["Authorization"] = `Bearer ${token}`; } } } } headers["Accept"] = stream ? "text/event-stream" : "application/json"; // Qwen header cleanup: Remove X-Dashscope-* headers if using an API key (DashScope compatible mode). // If using OAuth (Qwen Code), we MUST keep them for portal.qwen.ai to accept the request. if (this.provider === "qwen" && effectiveKey) { for (const key of Object.keys(headers)) { if (key.toLowerCase().startsWith("x-dashscope-")) { delete headers[key]; } } } const isCompatibleProvider = this.provider?.startsWith?.("openai-compatible-") || this.provider?.startsWith?.("anthropic-compatible-"); if (isCompatibleProvider) { applyCustomHeaders(headers, credentials.providerSpecificData?.customHeaders); } // Forward client request metadata headers (from OpenCode or similar clients) // Allowlist-based: only specific x-opencode-* headers and User-Agent are forwarded if (clientHeaders) { forwardOpencodeClientHeaders(headers, clientHeaders); // #3974: merge the client's negotiated anthropic-beta (allowlisted) into the // outbound set. The registry's static ANTHROPIC_BETA_CLAUDE_OAUTH lacks // tool-search-tool-2025-10-19, so deferred-tool requests were rejected with // 400 "Tool reference not found". Allowlist-merge preserves it without // forwarding betas the backend rejects. const clientBeta = clientHeaders["anthropic-beta"] ?? clientHeaders["Anthropic-Beta"] ?? null; const betaKey = Object.keys(headers).find((key) => key.toLowerCase() === "anthropic-beta"); if (betaKey && clientBeta) { headers[betaKey] = mergeClientAnthropicBeta(headers[betaKey], clientBeta); } } normalizeAnthropicHeaderVariants(headers); return headers; } /** * Downgrade `response_format: { type: "json_schema" }` to `json_object` for * `openai-compatible-*` providers, injecting the JSON schema into the system * prompt instead. DeepSeek / Ollama / local OpenAI-compatible models often * lack native Structured Output and return empty or malformed content when a * `json_schema` response_format is forwarded as-is. Gated on the * `openai-compatible-` provider family so providers with native Structured * Output support keep the native `json_schema` path. */ applyJsonSchemaFallback(body: T): T { if (!this.provider?.startsWith?.("openai-compatible-")) return body; if (!body || typeof body !== "object" || Array.isArray(body)) return body; const record = body as Record; const rf = record.response_format as { type?: string; json_schema?: { schema?: unknown } } | undefined; if (rf?.type !== "json_schema" || !rf.json_schema?.schema) return body; const schemaJson = JSON.stringify(rf.json_schema.schema, null, 2); const prompt = `You must respond with valid JSON that strictly follows this JSON schema:\n\`\`\`json\n${schemaJson}\n\`\`\`\nRespond ONLY with the JSON object, no other text.`; const messages: Array> = Array.isArray(record.messages) ? (record.messages as Array>).map((m) => ({ ...m })) : []; const sys = messages.find((m) => m.role === "system"); if (sys) { if (typeof sys.content === "string") { sys.content = `${sys.content}\n\n${prompt}`; } else if (Array.isArray(sys.content)) { sys.content.push({ type: "text", text: `\n\n${prompt}` }); } } else { messages.unshift({ role: "system", content: prompt }); } return { ...record, messages, response_format: { type: "json_object" } } as T; } // Some Responses-compatible upstreams (e.g. LM Studio) reject a request whose // `text` is an object missing `text.format` with a 400 missing_required_parameter. // The Responses API default for that field is { type: "text" }, so default it // for openai-compatible "responses" providers before forwarding upstream. defaultResponsesTextFormat(body: T): T { if (!this.provider?.startsWith?.("openai-compatible-")) return body; if (!this.provider.includes("responses")) return body; if (!body || typeof body !== "object" || Array.isArray(body)) return body; const record = body as Record; const text = record.text; if (!text || typeof text !== "object" || Array.isArray(text)) return body; const textRecord = text as Record; if (textRecord.format !== undefined) return body; return { ...record, text: { ...textRecord, format: { type: "text" } } } as T; } /** * For compatible providers, the model name is already clean by the time * it reaches the executor (chatCore sets body.model = modelInfo.model, * which is the parsed model ID without internal routing prefixes). * * Models may legitimately contain "/" as part of their ID (e.g. "zai-org/GLM-5-FP8", * "org/model-name") — we must NOT strip path segments. (Fix #493) */ transformRequest(model, body, stream, credentials) { const cleanedBody = super.transformRequest(model, body, stream, credentials); let withDefaults = applyProviderRequestDefaults(cleanedBody, this.config.requestDefaults); withDefaults = this.applyJsonSchemaFallback(withDefaults); withDefaults = this.defaultResponsesTextFormat(withDefaults); // Port of decolua/9router commit d652300e: // Cerebras returns 400 (wrong_api_format), Mistral returns 422 // (extra_forbidden), and NVIDIA's OpenAI-compatible wrapper returns 400 // (Unsupported parameter) when the forwarded body carries `client_metadata` // (an OpenAI Codex / Claude CLI passthrough field with no equivalent on // these upstreams). Strip it before sending downstream. Other providers // (notably `openai` / `codex`) intentionally keep it. if ( withDefaults && typeof withDefaults === "object" && !Array.isArray(withDefaults) && (this.provider === "cerebras" || this.provider === "mistral" || this.provider === "nvidia") && Object.prototype.hasOwnProperty.call(withDefaults, "client_metadata") ) { const withoutClientMetadata = { ...(withDefaults as Record) }; delete withoutClientMetadata.client_metadata; withDefaults = withoutClientMetadata; } // 9router#1649: Mistral's API returns 422 (extra_forbidden) when an // assistant message carries a `reasoning_content` field (replayed thinking // from a prior turn, e.g. via the Codex /responses path). The field is // nested per-message, so the generic top-level 400/field-downgrade retry // doesn't cover it. Strip it from every message before sending — scoped to // Mistral so DeepSeek (which *requires* replayed reasoning_content) is // unaffected. if ( this.provider === "mistral" && withDefaults && typeof withDefaults === "object" && !Array.isArray(withDefaults) && Array.isArray((withDefaults as Record).messages) ) { const record = withDefaults as Record; const messages = record.messages as unknown[]; let mutated = false; const cleaned = messages.map((msg) => { if ( msg && typeof msg === "object" && !Array.isArray(msg) && Object.prototype.hasOwnProperty.call(msg, "reasoning_content") ) { mutated = true; const { reasoning_content: _dropped, ...rest } = msg as Record; return rest; } return msg; }); if (mutated) { withDefaults = { ...record, messages: cleaned }; } } const targetFormat = getTargetFormat(this.provider, credentials?.providerSpecificData); const requestFormat = withDefaults && typeof withDefaults === "object" && !Array.isArray(withDefaults) ? detectFormat(withDefaults as Record) : "openai"; if (typeof withDefaults === "object" && withDefaults !== null && !Array.isArray(withDefaults)) { if (this.provider?.startsWith?.("anthropic-compatible-")) { if (Object.prototype.hasOwnProperty.call(withDefaults, "stream_options")) { const withoutStreamOptions = { ...withDefaults } as Record; delete withoutStreamOptions.stream_options; withDefaults = withoutStreamOptions; } } else if (stream && targetFormat === "openai" && requestFormat !== "openai-responses") { // Port of decolua/9router#663 (closes upstream #557): Qwen rejects with // 400 "'stream_options' only set this when you set stream: true" when the // outgoing body carries `stream: false` (Claude Code / Claude-Code- // compatible callers force the executor-level stream flag on via // `upstreamStream = stream || isClaudeCodeCompatible`, but the body keeps // the caller's original `stream: false`). Same upstream also rejects the // injection when `thinking` / `enable_thinking` is set. Skip injection in // those cases instead of unconditionally adding `stream_options`. const defaultsRecord = withDefaults as Record; const bodyDisablesStreamOptions = defaultsRecord.stream !== undefined && defaultsRecord.stream !== true; const qwenBlocksStreamOptions = this.provider === "qwen" && (Boolean(defaultsRecord.thinking) || Boolean(defaultsRecord.enable_thinking)); if (bodyDisablesStreamOptions || qwenBlocksStreamOptions) { if (Object.prototype.hasOwnProperty.call(defaultsRecord, "stream_options")) { const withoutStreamOptions = { ...defaultsRecord }; delete withoutStreamOptions.stream_options; withDefaults = withoutStreamOptions; } } else if (!credentials?.providerSpecificData?.disableStreamOptions) { withDefaults = { ...withDefaults, stream: true, stream_options: { ...((defaultsRecord.stream_options as object) || {}), include_usage: true, }, }; } else if (Object.prototype.hasOwnProperty.call(withDefaults, "stream_options")) { const withoutStreamOptions = { ...withDefaults } as Record; delete withoutStreamOptions.stream_options; withDefaults = withoutStreamOptions; } } else if (!stream && Object.prototype.hasOwnProperty.call(withDefaults, "stream_options")) { // #3884: stream_options is only valid on streaming requests. NVIDIA NIM // (and the OpenAI spec) reject "Stream options can only be defined when // stream=True" on non-streaming calls. Strip any client-sent // stream_options when the outbound request is not streaming. const withoutStreamOptions = { ...withDefaults } as Record; delete withoutStreamOptions.stream_options; withDefaults = withoutStreamOptions; } else if ( (targetFormat === "openai-responses" || requestFormat === "openai-responses") && Object.prototype.hasOwnProperty.call(withDefaults, "stream_options") ) { const withoutStreamOptions = { ...withDefaults } as Record; delete withoutStreamOptions.stream_options; withDefaults = withoutStreamOptions; } // #1961: Map max_tokens -> max_completion_tokens for recent OpenAI models if (targetFormat === "openai") { const isRecentOpenAI = /^(o1|o3|o4|gpt-5)/i.test(model); if (isRecentOpenAI && withDefaults && typeof withDefaults === "object") { const defaultsRecord = withDefaults as Record; if ("max_tokens" in defaultsRecord) { defaultsRecord.max_completion_tokens = defaultsRecord.max_tokens; delete defaultsRecord.max_tokens; } } } if (this.provider === "openrouter") { const connectionPreset = getOpenRouterConnectionPreset(credentials?.providerSpecificData); if (connectionPreset && (withDefaults as Record).preset === undefined) { withDefaults = { ...(withDefaults as Record), preset: connectionPreset, }; } } } if (this.provider === "qwen" && typeof withDefaults === "object" && withDefaults !== null) { return sanitizeQwenThinkingToolChoice( withDefaults as Record, "QwenExecutor" ); } // Config-driven strip of params unsupported by the target provider/model // (e.g. claude-opus-4 deprecated `temperature` → Anthropic 400). Port from // 9router#7ae9fff6 (fixes upstream #1748). Rules live in // ../translator/paramSupport.ts so adding one means editing one table. if (typeof withDefaults === "object" && withDefaults !== null) { const bodyRecord = withDefaults as Record; const outboundModel = typeof bodyRecord.model === "string" ? bodyRecord.model : model; stripUnsupportedParams(this.provider, outboundModel, bodyRecord); } // Apply modelIdPrefix from RegistryEntry (e.g. "accounts/fireworks/models/") // so registry can store short model IDs while the upstream API receives the full path. if (typeof withDefaults === "object" && withDefaults !== null) { const entry = getRegistryEntry(this.provider); if (entry?.modelIdPrefix) { const body = withDefaults as Record; if (typeof body.model === "string") { // Skip prepending when the model already carries the canonical prefix OR any // other accepted fully-qualified prefix (e.g. Fireworks router IDs). #3133. const acceptedPrefixes = [entry.modelIdPrefix, ...(entry.acceptedModelIdPrefixes ?? [])]; const alreadyQualified = acceptedPrefixes.some((prefix) => (body.model as string).startsWith(prefix) ); if (!alreadyQualified) { body.model = `${entry.modelIdPrefix}${body.model}`; } } } } // ClinePass reasoning models burn all of max_tokens on the thinking phase // when the budget is too small, leaving content empty (finish_reason: // "length"). Bump max_tokens to a safe floor when reasoning is enabled and // the budget is undersized. CLINEPASS-GATED — no-op for every other provider. if (typeof withDefaults === "object" && withDefaults !== null) { this.ensureThinkingBudget(withDefaults as Record, model); } // 9router#1480: the native Moonshot `kimi` provider (executor "default") // is a thinking-mode upstream that 400s with "reasoning_content must be // passed back" when a prior assistant turn lacks it. OpencodeExecutor // already injects a placeholder for OpenCode-routed thinking models; the // direct kimi connection hit neither injection path. Scope to `kimi` so // gateway-served models that merely match the thinking-model name pattern // (and may reject an extra field) are unaffected. if (this.provider === "kimi") { const outboundModel = typeof (withDefaults as Record)?.model === "string" ? ((withDefaults as Record).model as string) : model; if (isThinkingMessageModel(outboundModel)) { withDefaults = injectReasoningContentForThinkingModel(withDefaults); } } return withDefaults; } // ClinePass / OpenRouter-style thinking models leave content empty when the // reasoning budget consumes all of max_tokens. Bump max_tokens to a safe // minimum only when reasoning is enabled and the budget is undersized. // CLINEPASS-GATED: returns early for every other provider. ensureThinkingBudget(body: Record, model: string): Record { if (!body || this.provider !== "clinepass") return body; const outboundModel = typeof body.model === "string" ? body.model : model; const entry = getRegistryEntry(this.provider); const modelEntry = entry?.models?.find((m) => m.id === outboundModel); if (!modelEntry?.supportsReasoning) return body; const extraBody = body.extra_body as Record | undefined; const thinking = extraBody?.thinking as Record | undefined; const effort = body.reasoning_effort; const reasoningEnabled = thinking?.type === "enabled" || (typeof effort === "string" && effort !== "none" && effort !== "off") || effort === true; if (!reasoningEnabled) return body; const MIN_TOKENS = 4096; const maxOutput = typeof modelEntry.maxOutputTokens === "number" && modelEntry.maxOutputTokens > 0 ? modelEntry.maxOutputTokens : MIN_TOKENS; const target = Math.min(MIN_TOKENS, maxOutput); const current = body.max_tokens ?? body.max_completion_tokens; if (typeof current !== "number" || current <= 0) { body.max_tokens = target; } else if (current < MIN_TOKENS && current < maxOutput) { body.max_tokens = MIN_TOKENS; } return body; } /** * Refresh credentials via the centralized tokenRefresh service. * Delegates to getAccessToken() which handles all providers with * race-condition protection (deduplication via refreshPromiseCache). */ async refreshCredentials(credentials, log) { if (this.provider === "gigachat") { if (!credentials.apiKey) return null; try { return await getGigachatAccessToken({ credentials: credentials.apiKey, }); } catch (error) { log?.error?.("TOKEN", `gigachat refresh error: ${error.message}`); return null; } } if (!credentials.refreshToken) return null; try { return await getAccessToken(this.provider, credentials, log); } catch (error) { log?.error?.("TOKEN", `${this.provider} refresh error: ${error.message}`); return null; } } needsRefresh(credentials) { if (this.provider === "gigachat") { if (credentials.apiKey && !credentials.accessToken) return true; if (!credentials.expiresAt) return false; } return super.needsRefresh(credentials); } async execute(input: ExecuteInput) { const pool = this.getPool(); if (!pool) return super.execute(input); const session = pool.acquire(); if (session) { input.upstreamExtraHeaders = { ...session.buildHeaders(), ...input.upstreamExtraHeaders, }; } let result; try { result = await super.execute(input); } catch (err) { if (session) { pool.reportCooldown(session); session.release(); } throw err; } if (session) { try { const status = result?.response?.status; if (status === 429) { pool.reportCooldown(session); } else if (status >= 500) { pool.reportDead(session); } else { pool.reportSuccess(session); } } finally { session.release(); } } return result; } } export default DefaultExecutor;