import { BaseExecutor } from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; import { getRotatingApiKey } from "../services/apiKeyRotator.ts"; import { buildClaudeCodeCompatibleHeaders, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH, joinClaudeCodeCompatibleUrl, } from "../services/claudeCodeCompatible.ts"; import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts"; import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.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}?beta=true`; } 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 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`; } 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 "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 "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: return this.config.baseUrl; } } buildHeaders(credentials, stream = true) { const headers = { "Content-Type": "application/json", ...this.config.headers }; // T07: resolve extra keys round-robin locally since DefaultExecutor overrides BaseExecutor buildHeaders const extraKeys = (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; const effectiveKey = extraKeys.length > 0 && credentials.connectionId && credentials.apiKey ? getRotatingApiKey(credentials.connectionId, credentials.apiKey, extraKeys) : credentials.apiKey; 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 "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": 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 { headers["Authorization"] = `Bearer ${effectiveKey || credentials.accessToken}`; } } 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]; } } } 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) { void model; void stream; void credentials; const withDefaults = applyProviderRequestDefaults(body, this.config.requestDefaults); if (this.provider === "qwen" && typeof body === "object" && body !== null) { return sanitizeQwenThinkingToolChoice(withDefaults, "QwenExecutor"); } 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;