Files
OmniRoute/open-sse/executors/default.ts
Diego Rodrigues de Sa e Souza 7db430a352 Release v3.8.14 (#3340)
* 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>
2026-06-07 07:20:02 -03:00

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;