874 lines
37 KiB
TypeScript
874 lines
37 KiB
TypeScript
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<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;
|
|
}
|
|
}
|
|
|
|
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<string, string> | 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 <token>`
|
|
// 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<T>(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<string, unknown>;
|
|
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<Record<string, unknown>> = Array.isArray(record.messages)
|
|
? (record.messages as Array<Record<string, unknown>>).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<T>(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<string, unknown>;
|
|
const text = record.text;
|
|
if (!text || typeof text !== "object" || Array.isArray(text)) return body;
|
|
const textRecord = text as Record<string, unknown>;
|
|
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<string, unknown>) };
|
|
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<string, unknown>).messages)
|
|
) {
|
|
const record = withDefaults as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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<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") {
|
|
// 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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<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 === "openrouter") {
|
|
const connectionPreset = getOpenRouterConnectionPreset(credentials?.providerSpecificData);
|
|
if (connectionPreset && (withDefaults as Record<string, unknown>).preset === undefined) {
|
|
withDefaults = {
|
|
...(withDefaults as Record<string, unknown>),
|
|
preset: connectionPreset,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.provider === "qwen" && typeof withDefaults === "object" && withDefaults !== null) {
|
|
return sanitizeQwenThinkingToolChoice(
|
|
withDefaults as Record<string, unknown>,
|
|
"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<string, unknown>;
|
|
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<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}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<string, unknown>, 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<string, unknown>)?.model === "string"
|
|
? ((withDefaults as Record<string, unknown>).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<string, unknown>, model: string): Record<string, unknown> {
|
|
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<string, unknown> | undefined;
|
|
const thinking = extraBody?.thinking as Record<string, unknown> | 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;
|