import { BaseExecutor, setUserAgentHeader } from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; import { getRotatingApiKey, getValidApiKey, resolveKeyForRequest, } from "../services/apiKeyRotator.ts"; import type { KeyHealth } from "../services/apiKeyRotator.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 { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { detectFormat, getOpenAICompatibleType, getTargetFormat, isClaudeCodeCompatible, } from "../services/provider.ts"; import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; import { buildDataRobotChatUrl } from "../config/datarobot.ts"; import { buildAzureAiChatUrl } from "../config/azureAi.ts"; import { buildWatsonxChatUrl } from "../config/watsonx.ts"; import { buildOciChatUrl } from "../config/oci.ts"; import { buildSapChatUrl, getSapResourceGroup } from "../config/sap.ts"; import { buildMaritalkChatUrl } from "../config/maritalk.ts"; function normalizeBaseUrl(baseUrl) { return (baseUrl || "").trim().replace(/\/$/, ""); } function normalizeBailianMessagesUrl(baseUrl) { const normalized = normalizeBaseUrl(baseUrl).replace(/\?beta=true$/, ""); const messagesUrl = normalized.endsWith("/messages") ? normalized : `${normalized}/messages`; return messagesUrl; } function normalizeHerokuChatUrl(baseUrl) { const normalized = normalizeBaseUrl(baseUrl); if (normalized.endsWith("/v1/chat/completions")) return normalized; return `${normalized}/v1/chat/completions`; } function normalizeDatabricksChatUrl(baseUrl) { const normalized = normalizeBaseUrl(baseUrl); if (normalized.endsWith("/chat/completions")) return normalized; return `${normalized}/chat/completions`; } function normalizeDataRobotChatUrl(baseUrl) { return buildDataRobotChatUrl(baseUrl); } function normalizeAzureAiChatUrl(baseUrl: string, apiType: "chat" | "responses" = "chat") { return buildAzureAiChatUrl(baseUrl, apiType); } function normalizeWatsonxChatUrl(baseUrl: string) { return buildWatsonxChatUrl(baseUrl); } function normalizeOciChatUrl(baseUrl: string, apiType: "chat" | "responses" = "chat") { return buildOciChatUrl(baseUrl, apiType); } function normalizeSapChatUrl(baseUrl) { return buildSapChatUrl(baseUrl); } function normalizeXiaomiMimoChatUrl(baseUrl) { const normalized = normalizeBaseUrl(baseUrl).replace(/\/chat\/completions$/, ""); return `${normalized}/chat/completions`; } function normalizeSnowflakeChatUrl(baseUrl) { const normalized = normalizeBaseUrl(baseUrl) .replace(/\/cortex\/inference:complete$/, "") .replace(/\/api\/v2$/, ""); return `${normalized}/api/v2/cortex/inference:complete`; } function normalizeGigachatChatUrl(baseUrl) { const normalized = normalizeBaseUrl(baseUrl).replace(/\/chat\/completions$/, ""); return `${normalized}/chat/completions`; } function normalizeOpenAIChatUrl(baseUrl) { const normalized = normalizeBaseUrl(baseUrl); if ( normalized.endsWith("/chat/completions") || normalized.endsWith("/responses") || normalized.endsWith("/chat") ) { return normalized; } return normalized.endsWith("/v1") ? `${normalized}/chat/completions` : normalized; } export class DefaultExecutor extends BaseExecutor { constructor(provider) { super(provider, PROVIDERS[provider] || PROVIDERS.openai); } 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 path = 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 "bailian-coding-plan": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeBailianMessagesUrl(baseUrl); } case "heroku": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeHerokuChatUrl(baseUrl); } case "databricks": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeDatabricksChatUrl(baseUrl); } case "datarobot": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeDataRobotChatUrl(baseUrl); } case "azure-ai": { const forceResponses = credentials?.providerSpecificData?._omnirouteForceResponsesUpstream === true; const apiType = forceResponses || credentials?.providerSpecificData?.apiType === "responses" ? "responses" : "chat"; const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeAzureAiChatUrl(baseUrl, apiType); } case "watsonx": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeWatsonxChatUrl(baseUrl); } case "oci": { const forceResponses = credentials?.providerSpecificData?._omnirouteForceResponsesUpstream === true; const apiType = forceResponses || credentials?.providerSpecificData?.apiType === "responses" ? "responses" : "chat"; const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeOciChatUrl(baseUrl, apiType); } case "sap": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeSapChatUrl(baseUrl); } case "xiaomi-mimo": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeXiaomiMimoChatUrl(baseUrl); } case "snowflake": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeSnowflakeChatUrl(baseUrl); } case "gigachat": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeGigachatChatUrl(baseUrl); } case "maritalk": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return buildMaritalkChatUrl(baseUrl); } case "lm-studio": case "modal": case "reka": case "vllm": case "lemonade": case "llamafile": case "triton": case "docker-model-runner": case "xinference": case "oobabooga": { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeOpenAIChatUrl(baseUrl); } case "zai": case "glm-coding-apikey": { const zaiBaseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; 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: { 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 = { "Content-Type": "application/json", ...this.config.headers }; // Allow per-provider User-Agent override via environment variable. 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) { headers["User-Agent"] = envUA; if ("user-agent" in headers) { headers["user-agent"] = envUA; } } } // T07: resolve extra keys round-robin locally since DefaultExecutor overrides BaseExecutor buildHeaders const extraKeys = (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; const selectedKeyId = (credentials.providerSpecificData as Record | undefined) ?.selectedKeyId as string | undefined; let effectiveKey = credentials.apiKey; if (extraKeys.length > 0 && credentials.connectionId && credentials.apiKey) { const resolved = resolveKeyForRequest( credentials.connectionId, credentials.apiKey, extraKeys, selectedKeyId ?? null ); effectiveKey = resolved?.key ?? credentials.apiKey; if (resolved && credentials.providerSpecificData) { (credentials.providerSpecificData as Record).selectedKeyId = resolved.keyId; } } 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; default: if (isClaudeCodeCompatible(this.provider)) { return buildClaudeCodeCompatibleHeaders( effectiveKey || credentials.accessToken || "", stream, credentials?.providerSpecificData?.ccSessionId ); } if (this.provider?.startsWith?.("anthropic-compatible-")) { if (effectiveKey) { headers["x-api-key"] = effectiveKey; } else if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; } if (!headers["anthropic-version"]) { 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; 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]; } } } // Forward client request metadata headers (from OpenCode or similar clients) // Allowlist-based: only specific x-opencode-* headers and User-Agent are forwarded if (clientHeaders) { const clientUA = clientHeaders["User-Agent"] || clientHeaders["user-agent"]; if (clientUA) { setUserAgentHeader(headers, clientUA); } const opencodeHeaderKeys = [ "x-opencode-session", "x-opencode-request", "x-opencode-project", "x-opencode-client", ]; for (const headerName of opencodeHeaderKeys) { const value = Object.entries(clientHeaders).find( ([key]) => key.toLowerCase() === headerName.toLowerCase() )?.[1]; if (value) { headers[headerName] = value; } } } return headers; } /** * 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); 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") { if (!credentials?.providerSpecificData?.disableStreamOptions) { withDefaults = { ...withDefaults, stream_options: { ...(((withDefaults as Record).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 ( (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 === "qwen" && typeof withDefaults === "object" && withDefaults !== null) { return sanitizeQwenThinkingToolChoice( withDefaults as Record, "QwenExecutor" ); } // 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" && !body.model.startsWith(entry.modelIdPrefix)) { body.model = `${entry.modelIdPrefix}${body.model}`; } } } return withDefaults; } /** * 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); } } export default DefaultExecutor;