mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
* chore(release): open v3.8.14 development cycle Version bump 3.8.13 -> 3.8.14 (root + electron + open-sse + openapi + lockfiles). Seed the v3.8.14 changelog with the four post-tag hotfixes that shipped to Docker/Electron in v3.8.13 but missed the immutable npm 3.8.13 (#3336 SSRF / CodeQL #323, #3334/#3335/#3339 Electron packaging). i18n CHANGELOG mirrors get the in-progress placeholder section. * feat: add per-provider custom headers support for OpenAI/Anthropic-compatible nodes (#3338) Integrated into release/v3.8.14 * fix: Kiro Builder ID token import fails with Bad credentials (#3333) Integrated into release/v3.8.14 — adds Builder ID cached-creds + OIDC refresh path for Kiro token import, with regression tests (#3333). * Improve code quality: auto-pr/docstrings-1780792063 (#3337) Integrated into release/v3.8.14 — docstring for context analytics route re-export. * fix(catalog): remove minimaxai/minimax-m3 from NVIDIA NIM tier (404 upstream) (#3329) (#3341) NVIDIA NIM does not host minimaxai/minimax-m3 — every request returns 404 page not found, while sibling minimaxai/minimax-m2.7 on the same provider works. Advertising a model that 404s is a catalog bug; remove it from the nvidia tier (it remains on the tiers that actually serve MiniMax M3). Re-add only once NVIDIA serves it. Co-authored-by: mikmaneggahommie <mikmaneggahommie@users.noreply.github.com> * fix(cli): write OpenCode config to ~/.config on all platforms incl. Windows (#3330) (#3343) resolveOpencodeConfigDir used %APPDATA% on Windows, but OpenCode reads its config from XDG ~/.config/opencode/ on every platform (on Windows: %USERPROFILE%\.config\opencode\, NOT %APPDATA%). So a Windows user who configured OpenCode via the dashboard had the file written where OpenCode never looks — it silently had no effect. Use the XDG path (XDG_CONFIG_HOME || ~/.config) unconditionally. Update the UI note + route JSDoc, and flip the three tests that encoded the old %APPDATA% behavior (t40 per-platform + card-note, cli-runtime-extended getCliConfigPaths). Co-authored-by: abdulkadirozyurt <abdulkadirozyurt@users.noreply.github.com> * fix(proxy): make auto-selection fallback opt-in (#3332) (#3344) selectWorkingProxyFallback (Step 11 of resolveProxyForConnection) listed ALL registry proxies, ignoring assignments and per-connection proxy_enabled, and returned the first working one with level:'autoSelect'. So a single proxy added to the registry silently became a global fallback for every connection's traffic. Gate it behind a new PROXY_AUTO_SELECT_ENABLED feature flag (default off): the fallback now no-ops unless the operator opts in. No registry proxy becomes a silent global default anymore. Co-authored-by: hertznsk <hertznsk@users.noreply.github.com> * fix(sse): treat MiniMax M3 as multimodal so vision isn't stripped (#3328) (#3342) MiniMax M3 via the opencode provider (oc/minimax-m3-free) appeared blind: image inputs didn't reach the model, while the same model in Cline could see them. Verified empirically that MiniMax M3 on the opencode upstream IS multimodal -- a base64 image is described correctly (it returns 403 only for remote image URLs, which it doesn't accept). Root cause: OmniRoute treated MiniMax M3 as a non-vision model in two places, so when compression was active the image was replaced with a text placeholder before dispatch: - compression's modelSupportsVision() heuristic (lite.ts) only matched gpt-4/4o/claude-3/gemini/vision -- minimax was absent -> replaceImageUrls stripped the image. - the opencode minimax-m3-free catalog entry lacked supportsVision, so the combo vision-capability gate could also exclude/mishandle it. Add 'minimax-m3' to the vision heuristic and supportsVision: true to the opencode minimax-m3-free entry. TDD: a failing-then-passing test in compression/lite.test.ts proves replaceImageUrls now keeps images for minimax-m3 ids, plus a registry assertion mirroring the #2822 qwen test. Reported-by: @mikmaneggahommie * docs(i18n): translate 25 core documentation files to Indonesian (#3348) Integrated into release/v3.8.14 — Indonesian i18n docs. * fix(review): resolve /review-reviews battery findings (LEDGER-1..11) on v3.8.14 (#3350) Integrated into release/v3.8.14 — /review-reviews battery hardening (LEDGER-1..11) for #3338 custom-headers + #3333 kiro, plus cycle-test drift fixes (#3329/#3330/#3332). * fix(provider-proxy): honor per-account proxy toggles (#3349) Integrated into release/v3.8.14 — honor per-account proxy toggles + auto-fallback opt-in via PROXY_AUTO_SELECT_ENABLED. * fix(dashboard): remove duplicate Distribute Proxies button on provider page (#3352) * fix(providers): reduce proxy label noise (#3346) Integrated into release/v3.8.14 — reduce proxy label noise + a11y (aria-label/sr-only). * fix(duckduckgo): restore bare Response contract and rebase onto release/v3.8.14 (#3323) Integrated into release/v3.8.14 — browser-backed cookie providers (duckduckgo/claude-web) with restored executor contract + unit tests. * fix(noauth): expose only usable model aliases (#3345) Integrated into release/v3.8.14 — noauth usable-alias filtering + registry alias plumbing (veo-free). * fix(dashboard): stop infinite config-load loop on Hermes Agent detail page (#3353) * fix(electron): tree-kill the server on exit/update to release the omniroute.exe lock (#3347) (#3354) * chore(release): finalize v3.8.14 changelog + clear release-gate drift - CHANGELOG: finalize the v3.8.14 section (date, full New Features/Bug Fixes/ Maintenance coverage of all 16 cycle commits, Contributors hall of 12). - docs: document OMNIROUTE_BROWSER_POOL + WEB_COOKIE_USE_BROWSER (#3323) in .env.example + ENVIRONMENT.md; regenerate the id/llm.txt strict mirror (#3348 had translated it; llm.txt mirrors must match root). - test(proxy-fetch): #3323 made tlsClient.available a computed getter — stub it via Object.defineProperty instead of assignment (5 tests were red on the base). * fix(translator): coerce Gemini functionDeclaration parameters to an OBJECT schema (#3357) (#3360) * fix(gemini): resolve truncation/suppression of false positive textual tool call markers in backticks (#3358) Integrated into release/v3.8.14 — Gemini/Antigravity textual tool-call marker normalization (no false-positive suppression + split-chunk buffering). * docs(changelog): add #3358 Gemini textual tool-call normalization to v3.8.14 * fix(dashboard): surface real analytics error instead of generic placeholder (#3356) (#3361) The Analytics page discarded the server's error body on a non-OK response and rendered a generic "An error occurred", so users (and maintainers) could not see why /api/usage/analytics 500'd after an upgrade. Now the route returns the real reason via buildErrorBody (sanitized, Hard Rule #12) and the page surfaces it via a new readFetchErrorMessage helper that handles both the OpenAI-style and legacy error shapes. Reported-by: @superti4r --------- Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com> Co-authored-by: Someres <168349709+quanturbo@users.noreply.github.com> Co-authored-by: Dong Mengzhe <154944819+Lang-Qiu@users.noreply.github.com> Co-authored-by: mikmaneggahommie <mikmaneggahommie@users.noreply.github.com> Co-authored-by: abdulkadirozyurt <abdulkadirozyurt@users.noreply.github.com> Co-authored-by: hertznsk <hertznsk@users.noreply.github.com> Co-authored-by: Krisna Santosa <54174372+KrisnaSantosa15@users.noreply.github.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Wilson <pedbookmed@gmail.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Ardem2025 <ardemb22@gmail.com>
671 lines
25 KiB
TypeScript
671 lines
25 KiB
TypeScript
import { BaseExecutor, setUserAgentHeader, type ExecuteInput } 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";
|
|
import { LOCAL_PROVIDERS } from "@/shared/constants/providers";
|
|
import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders";
|
|
|
|
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<string, string>, rawCustomHeaders: unknown): void {
|
|
let customHeaders: Record<string, unknown> | null = null;
|
|
if (
|
|
rawCustomHeaders &&
|
|
typeof rawCustomHeaders === "object" &&
|
|
!Array.isArray(rawCustomHeaders)
|
|
) {
|
|
customHeaders = rawCustomHeaders as Record<string, unknown>;
|
|
} else if (typeof rawCustomHeaders === "string") {
|
|
try {
|
|
const parsed = JSON.parse(rawCustomHeaders);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
customHeaders = parsed as Record<string, unknown>;
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
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 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 "siliconflow": {
|
|
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
|
return normalizeOpenAIChatUrl(baseUrl);
|
|
}
|
|
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 = 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<string, string> | 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<string, unknown> | 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<string, unknown>).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)) {
|
|
const ccHeaders = buildClaudeCodeCompatibleHeaders(
|
|
effectiveKey || credentials.accessToken || "",
|
|
stream,
|
|
credentials?.providerSpecificData?.ccSessionId
|
|
);
|
|
// 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}`;
|
|
}
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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<string, unknown>)
|
|
: "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<string, unknown>;
|
|
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<string, unknown>).stream_options as object) || {}),
|
|
include_usage: true,
|
|
},
|
|
};
|
|
} else if (Object.prototype.hasOwnProperty.call(withDefaults, "stream_options")) {
|
|
const withoutStreamOptions = { ...withDefaults } as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>,
|
|
"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<string, unknown>;
|
|
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}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|