diff --git a/open-sse/config/azureAi.ts b/open-sse/config/azureAi.ts index ce573d8552..fedbb99054 100644 --- a/open-sse/config/azureAi.ts +++ b/open-sse/config/azureAi.ts @@ -1,11 +1,7 @@ -import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; +import { stripTrailingSlashes, normalizeBaseUrl } from "../utils/urlSanitize.ts"; export const AZURE_AI_DEFAULT_BASE_URL = "https://example-resource.services.ai.azure.com/openai/v1"; -function normalizeBaseUrl(value: string | null | undefined): string { - return stripTrailingSlashes((value || "").trim()); -} - export function normalizeAzureAiBaseUrl(value: string | null | undefined): string { const normalized = normalizeBaseUrl(value || AZURE_AI_DEFAULT_BASE_URL); if (!normalized) return AZURE_AI_DEFAULT_BASE_URL; diff --git a/open-sse/config/datarobot.ts b/open-sse/config/datarobot.ts index 30a23a79ca..505f9a4299 100644 --- a/open-sse/config/datarobot.ts +++ b/open-sse/config/datarobot.ts @@ -1,4 +1,4 @@ -import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; +import { stripTrailingSlashes, normalizeBaseUrl } from "../utils/urlSanitize.ts"; const DATAROBOT_API_V2_SEGMENT = "/api/v2"; const DATAROBOT_LLMGW_CHAT_PATH = "/genai/llmgw/chat/completions/"; @@ -6,10 +6,6 @@ const DATAROBOT_LLMGW_CATALOG_PATH = "/genai/llmgw/catalog/"; export const DATAROBOT_DEFAULT_BASE_URL = "https://app.datarobot.com"; -function normalizeBaseUrl(value: string | null | undefined): string { - return stripTrailingSlashes((value || "").trim()); -} - export function normalizeDataRobotBaseUrl(value: string | null | undefined): string { const normalized = normalizeBaseUrl(value || DATAROBOT_DEFAULT_BASE_URL); return normalized || DATAROBOT_DEFAULT_BASE_URL; diff --git a/open-sse/config/oci.ts b/open-sse/config/oci.ts index 1001ea6f2c..68d7cb5a77 100644 --- a/open-sse/config/oci.ts +++ b/open-sse/config/oci.ts @@ -1,12 +1,8 @@ -import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; +import { stripTrailingSlashes, normalizeBaseUrl } from "../utils/urlSanitize.ts"; export const OCI_DEFAULT_BASE_URL = "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1"; -function normalizeBaseUrl(value: string | null | undefined): string { - return stripTrailingSlashes((value || "").trim()); -} - export function normalizeOciBaseUrl(value: string | null | undefined): string { const normalized = normalizeBaseUrl(value || OCI_DEFAULT_BASE_URL); if (!normalized) return OCI_DEFAULT_BASE_URL; diff --git a/open-sse/config/providers/registry/nebius/index.ts b/open-sse/config/providers/registry/nebius/index.ts index 6e9caf7eaf..b49bd34e81 100644 --- a/open-sse/config/providers/registry/nebius/index.ts +++ b/open-sse/config/providers/registry/nebius/index.ts @@ -1,12 +1,9 @@ import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; -export const nebiusProvider: RegistryEntry = { +export const nebiusProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ id: "nebius", alias: "nebius", - format: "openai", - executor: "default", baseUrl: "https://api.tokenfactory.nebius.com/v1/chat/completions", - authType: "apikey", - authHeader: "bearer", models: [{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" }], -}; +}); diff --git a/open-sse/config/providers/registry/siliconflow/index.ts b/open-sse/config/providers/registry/siliconflow/index.ts index 46dd572b2d..238cf60e97 100644 --- a/open-sse/config/providers/registry/siliconflow/index.ts +++ b/open-sse/config/providers/registry/siliconflow/index.ts @@ -1,13 +1,10 @@ import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; -export const siliconflowProvider: RegistryEntry = { +export const siliconflowProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ id: "siliconflow", alias: "siliconflow", - format: "openai", - executor: "default", baseUrl: "https://api.siliconflow.com/v1/chat/completions", - authType: "apikey", - authHeader: "bearer", models: [ // DeepSeek { id: "deepseek-ai/DeepSeek-V3.2", name: "DeepSeek V3.2" }, @@ -84,4 +81,4 @@ export const siliconflowProvider: RegistryEntry = { { id: "google/gemma-4-26B-A4B-it", name: "Gemma 4 26B" }, { id: "ByteDance-Seed/Seed-OSS-36B-Instruct", name: "Seed OSS 36B" }, ], -}; +}); diff --git a/open-sse/config/providers/registry/together/index.ts b/open-sse/config/providers/registry/together/index.ts index afc67211dc..c185804b08 100644 --- a/open-sse/config/providers/registry/together/index.ts +++ b/open-sse/config/providers/registry/together/index.ts @@ -1,13 +1,10 @@ import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; -export const togetherProvider: RegistryEntry = { +export const togetherProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ id: "together", alias: "together", - format: "openai", - executor: "default", baseUrl: "https://api.together.xyz/v1/chat/completions", - authType: "apikey", - authHeader: "bearer", models: [ { id: "meta-llama/Llama-3.3-70B-Instruct-Turbo-Free", name: "Llama 3.3 70B Turbo (πŸ†“ Free)" }, { id: "meta-llama/Llama-Vision-Free", name: "Llama Vision (πŸ†“ Free)" }, @@ -20,4 +17,4 @@ export const togetherProvider: RegistryEntry = { { id: "Qwen/Qwen3-235B-A22B", name: "Qwen3 235B" }, { id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", name: "Llama 4 Maverick" }, ], -}; +}); diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 670e18ed93..ecc4fd088d 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -143,6 +143,24 @@ export interface RegistryEntry { anonymousApiKey?: string; } +/** + * Build a standard OpenAI-compatible provider registry entry. + * Eliminates the 4-field boilerplate (format, executor, authType, authHeader) + * repeated across 40+ provider files. + */ +export function buildOpenAiCompatibleRegistryEntry( + overrides: Pick & + Partial> +): RegistryEntry { + return { + format: "openai", + executor: "default", + authType: "apikey", + authHeader: "bearer", + ...overrides, + } as RegistryEntry; +} + export interface LegacyProvider { format: string; baseUrl?: string; diff --git a/open-sse/config/sap.ts b/open-sse/config/sap.ts index 3c51fa1e3c..9430a69790 100644 --- a/open-sse/config/sap.ts +++ b/open-sse/config/sap.ts @@ -1,12 +1,8 @@ -import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; +import { stripTrailingSlashes, normalizeBaseUrl } from "../utils/urlSanitize.ts"; export const SAP_DEFAULT_BASE_URL = "https://example-aicore.cfapps.eu10.hana.ondemand.com/v2/lm/deployments/example-deployment"; -function normalizeBaseUrl(value: string | null | undefined): string { - return stripTrailingSlashes((value || "").trim()); -} - function sanitizeUrl(value: string): string { try { const parsed = new URL(value); diff --git a/open-sse/config/watsonx.ts b/open-sse/config/watsonx.ts index fb9c58dfbf..0afa77de5a 100644 --- a/open-sse/config/watsonx.ts +++ b/open-sse/config/watsonx.ts @@ -1,11 +1,7 @@ -import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; +import { stripTrailingSlashes, normalizeBaseUrl } from "../utils/urlSanitize.ts"; export const WATSONX_DEFAULT_BASE_URL = "https://ca-tor.ml.cloud.ibm.com/ml/gateway/v1"; -function normalizeBaseUrl(value: string | null | undefined): string { - return stripTrailingSlashes((value || "").trim()); -} - export function normalizeWatsonxBaseUrl(value: string | null | undefined): string { const normalized = normalizeBaseUrl(value || WATSONX_DEFAULT_BASE_URL); if (!normalized) return WATSONX_DEFAULT_BASE_URL; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 6717b3e1ba..088db019e4 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -234,7 +234,10 @@ export function stripStainlessHeadersForOpenAICompat( // Normalize User-Agent: SDK-based clients send verbose product strings that some // upstreams block. Replace with a clean browser-like UA only when it looks SDK-derived. const ua = (headers["User-Agent"] || headers["user-agent"] || "").toLowerCase(); - if (ua.includes("openai") && (ua.includes("node") || ua.includes("axios") || ua.includes("undici"))) { + if ( + ua.includes("openai") && + (ua.includes("node") || ua.includes("axios") || ua.includes("undici")) + ) { setUserAgentHeader(headers, "Mozilla/5.0 (compatible; OpenAI Compatible)"); } @@ -529,15 +532,49 @@ export class BaseExecutor { return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl || ""; } - buildHeaders( + /** + * Resolve the effective base URL for a request, preferring per-connection + * providerSpecificData.baseUrl over the static provider config baseUrl. + */ + protected resolveBaseUrl(credentials: ProviderCredentials | null, fallback?: string): string { + return credentials?.providerSpecificData?.baseUrl || fallback || this.config.baseUrl || ""; + } + + /** + * Resolve the effective API key via extra-keys round-robin rotation. + * Mutates `credentials.providerSpecificData.selectedKeyId` on rotation. + */ + protected resolveEffectiveKey(credentials: ProviderCredentials): string { + const extraKeys = + (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; + const selectedKeyId = (credentials.providerSpecificData as Record | undefined) + ?.selectedKeyId as string | undefined; + let effectiveKey = credentials.apiKey; + if (extraKeys.length > 0 && credentials.connectionId && credentials.apiKey) { + const resolved = resolveKeyForRequest( + credentials.connectionId, + credentials.apiKey, + extraKeys, + selectedKeyId ?? null + ); + effectiveKey = resolved?.key ?? credentials.apiKey; + if (resolved && credentials.providerSpecificData) { + (credentials.providerSpecificData as Record).selectedKeyId = + resolved.keyId; + } + } + return effectiveKey; + } + + /** + * Build the common header preamble shared by BaseExecutor and DefaultExecutor: + * Content-Type, config.headers, per-provider User-Agent env override, and + * resolved effective key (via extra-keys round-robin). + */ + protected buildHeadersPreamble( credentials: ProviderCredentials, - stream = true, - clientHeaders?: Record | null, - model?: string, - health?: Record - ): Record { - void clientHeaders; - void model; + stream: boolean + ): { headers: Record; effectiveKey: string } { const headers: Record = { "Content-Type": "application/json", ...this.config.headers, @@ -554,28 +591,25 @@ export class BaseExecutor { } } + const effectiveKey = this.resolveEffectiveKey(credentials); + void stream; + return { headers, effectiveKey }; + } + + buildHeaders( + credentials: ProviderCredentials, + stream = true, + clientHeaders?: Record | null, + model?: string, + health?: Record + ): Record { + void clientHeaders; + void model; + const { headers, effectiveKey } = this.buildHeadersPreamble(credentials, stream); + if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; } else if (credentials.apiKey) { - const extraKeys = - (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; - const selectedKeyId = ( - credentials.providerSpecificData as Record | undefined - )?.selectedKeyId as string | undefined; - let effectiveKey = credentials.apiKey; - if (extraKeys.length > 0 && credentials.connectionId) { - const resolved = resolveKeyForRequest( - credentials.connectionId, - credentials.apiKey, - extraKeys, - selectedKeyId ?? null - ); - effectiveKey = resolved?.key ?? credentials.apiKey; - if (resolved && credentials.providerSpecificData) { - (credentials.providerSpecificData as Record).selectedKeyId = - resolved.keyId; - } - } headers["Authorization"] = `Bearer ${effectiveKey}`; } @@ -1112,14 +1146,11 @@ export class BaseExecutor { const seed = activeCredentials?.accessToken || activeCredentials?.apiKey || "anon"; const psd = activeCredentials?.providerSpecificData as - | Record - | undefined; + Record | undefined; let identitySource: - | "upstream-metadata" - | "upstream-header" - | "synthesized" - | "synthesized-cloaked" = "synthesized"; + "upstream-metadata" | "upstream-header" | "synthesized" | "synthesized-cloaked" = + "synthesized"; let sessionId: string; let deviceId: string; let accountUUID: string; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index c6d44533f5..f4d4731b9b 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -1,12 +1,7 @@ -import { BaseExecutor, setUserAgentHeader, type ExecuteInput } from "./base.ts"; +import { BaseExecutor, 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, @@ -38,6 +33,14 @@ 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 { normalizeBaseUrl } from "../utils/urlSanitize.ts"; +import { + normalizeHerokuChatUrl, + normalizeDatabricksChatUrl, + normalizeSnowflakeChatUrl, + normalizeGigachatChatUrl, +} from "@/lib/providers/validation/urlHelpers"; +import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; import type { PoolConfig } from "../services/sessionPool/types.ts"; @@ -83,28 +86,12 @@ function applyCustomHeaders(headers: Record, rawCustomHeaders: u } } -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); } @@ -130,18 +117,6 @@ function normalizeXiaomiMimoChatUrl(baseUrl) { 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 ( @@ -208,19 +183,19 @@ export class DefaultExecutor extends BaseExecutor { } switch (this.provider) { case "bailian-coding-plan": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeBailianMessagesUrl(baseUrl); } case "heroku": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeHerokuChatUrl(baseUrl); } case "databricks": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeDatabricksChatUrl(baseUrl); } case "datarobot": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeDataRobotChatUrl(baseUrl); } case "azure-ai": { @@ -230,11 +205,11 @@ export class DefaultExecutor extends BaseExecutor { forceResponses || credentials?.providerSpecificData?.apiType === "responses" ? "responses" : "chat"; - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeAzureAiChatUrl(baseUrl, apiType); } case "watsonx": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeWatsonxChatUrl(baseUrl); } case "oci": { @@ -244,31 +219,31 @@ export class DefaultExecutor extends BaseExecutor { forceResponses || credentials?.providerSpecificData?.apiType === "responses" ? "responses" : "chat"; - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeOciChatUrl(baseUrl, apiType); } case "sap": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeSapChatUrl(baseUrl); } case "xiaomi-mimo": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeXiaomiMimoChatUrl(baseUrl); } case "snowflake": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeSnowflakeChatUrl(baseUrl); } case "gigachat": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeGigachatChatUrl(baseUrl); } case "maritalk": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return buildMaritalkChatUrl(baseUrl); } case "siliconflow": { - const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const baseUrl = this.resolveBaseUrl(credentials); return normalizeOpenAIChatUrl(baseUrl); } case "ollama-local": @@ -295,7 +270,7 @@ export class DefaultExecutor extends BaseExecutor { } case "zai": case "glm-coding-apikey": { - const zaiBaseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + const zaiBaseUrl = this.resolveBaseUrl(credentials); return `${zaiBaseUrl}?beta=true`; } case "claude": @@ -335,40 +310,7 @@ export class DefaultExecutor extends BaseExecutor { } buildHeaders(credentials, stream = true, clientHeaders?: Record | null) { - const headers = { "Content-Type": "application/json", ...this.config.headers }; - - // Allow per-provider User-Agent override via environment variable. - const providerId = this.config?.id || this.provider; - if (providerId) { - const envKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`; - const envUA = process.env[envKey]?.trim(); - if (envUA) { - headers["User-Agent"] = envUA; - if ("user-agent" in headers) { - headers["user-agent"] = envUA; - } - } - } - - // T07: resolve extra keys round-robin locally since DefaultExecutor overrides BaseExecutor buildHeaders - const extraKeys = - (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; - const selectedKeyId = (credentials.providerSpecificData as Record | undefined) - ?.selectedKeyId as string | undefined; - let effectiveKey = credentials.apiKey; - if (extraKeys.length > 0 && credentials.connectionId && credentials.apiKey) { - const resolved = resolveKeyForRequest( - credentials.connectionId, - credentials.apiKey, - extraKeys, - selectedKeyId ?? null - ); - effectiveKey = resolved?.key ?? credentials.apiKey; - if (resolved && credentials.providerSpecificData) { - (credentials.providerSpecificData as Record).selectedKeyId = - resolved.keyId; - } - } + const { headers, effectiveKey } = this.buildHeadersPreamble(credentials, stream); switch (this.provider) { case "gemini": @@ -546,25 +488,7 @@ export class DefaultExecutor extends BaseExecutor { // 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; - } - } + 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 diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 6fccf7117d..566b4f6cd5 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -1,10 +1,4 @@ -import { randomUUID } from "crypto"; -import { - BaseExecutor, - setUserAgentHeader, - type ExecuteInput, - type ProviderCredentials, -} from "./base.ts"; +import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; import { @@ -12,6 +6,7 @@ import { isThinkingMessageModel, } from "../utils/reasoningContentInjector.ts"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; +import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; /** * Per-account proxy configuration, persisted by NoAuthAccountCard under @@ -42,6 +37,22 @@ interface OpencodeAccountState { const OPENCODE_COOLDOWN_BASE_MS = 5_000; const OPENCODE_COOLDOWN_MAX_MS = 60_000; +const EFFORT_LEVELS = ["low", "medium", "high", "max"] as const; + +/** + * Parse a DeepSeek V4 Pro model string with an effort-level suffix. + * e.g. "deepseek-v4-pro-low" β†’ { baseModel: "deepseek-v4-pro", effort: "low" } + * Returns null if the model doesn't match the pattern. + */ +function parseDeepSeekEffortLevel(model: string): { baseModel: string; effort: string } | null { + const m = String(model || ""); + const matchedLevel = EFFORT_LEVELS.find((level) => m.endsWith(`-${level}`)); + if (!matchedLevel) return null; + const baseModel = m.slice(0, -matchedLevel.length - 1); + if (baseModel.toLowerCase() !== "deepseek-v4-pro") return null; + return { baseModel: "deepseek-v4-pro", effort: matchedLevel }; +} + export class OpencodeExecutor extends BaseExecutor { _requestFormat: string | null = null; @@ -159,7 +170,9 @@ export class OpencodeExecutor extends BaseExecutor { log?.info?.( "OPENCODE", `dispatch via account ${masked} (idx ${attempt + 1}/${this.accounts.length})` + - (account.proxy ? ` through proxy ${account.proxy.host}:${account.proxy.port}` : " direct") + (account.proxy + ? ` through proxy ${account.proxy.host}:${account.proxy.port}` + : " direct") ); // Pin egress to this account's proxy for the whole BaseExecutor dispatch @@ -173,10 +186,7 @@ export class OpencodeExecutor extends BaseExecutor { const status = result.response.status; if (status === 429) { this.markCooldown(account); - log?.warn?.( - "OPENCODE", - `Rate limited (429) on account ${masked}, rotating to next…` - ); + log?.warn?.("OPENCODE", `Rate limited (429) on account ${masked}, rotating to next…`); continue; } @@ -239,59 +249,16 @@ export class OpencodeExecutor extends BaseExecutor { } if (clientHeaders) { - const clientUA = clientHeaders["User-Agent"] || clientHeaders["user-agent"]; - if (clientUA) { - setUserAgentHeader(headers, clientUA); - } + forwardOpencodeClientHeaders(headers, clientHeaders, { + synthesizeRequestId: true, + }); + } - // Forward OpenCode request metadata headers from client - const findClientHeader = (name: string) => - Object.entries(clientHeaders).find( - ([key]) => key.toLowerCase() === name.toLowerCase() - )?.[1]; - - const opencodeHeaderKeys = [ - "x-opencode-session", - "x-opencode-request", - "x-opencode-project", - "x-opencode-client", - ]; - for (const headerName of opencodeHeaderKeys) { - const value = findClientHeader(headerName); - if (value) { - headers[headerName] = value; - } - } - - // #4022: OpenCode CLI only emits x-opencode-* headers when the provider id - // starts with "opencode". For a custom-named provider (e.g. "omniroute") it - // instead sends x-session-affinity / X-Session-Id, which both carry the same - // OpenCode sessionID. Map that session id onto x-opencode-session so session - // continuity to the opencode.ai upstream works regardless of how the user - // named the provider. Scoped to this executor (opencode.ai/zen upstreams - // only) β€” the generic DefaultExecutor intentionally does NOT do this, to - // avoid leaking the client session id to arbitrary third-party upstreams. - if (!headers["x-opencode-session"]) { - const sessionAffinity = - findClientHeader("x-session-affinity") || findClientHeader("x-session-id"); - if (sessionAffinity) { - headers["x-opencode-session"] = sessionAffinity; - - // #4465: a custom-named provider only reaches this fallback because the - // OpenCode CLI did NOT emit the x-opencode-* set (it only does so when the - // provider id starts with "opencode"). It therefore also dropped - // x-opencode-request, a per-request correlation id. Synthesize one so these - // users are not disadvantaged versus opencode-prefixed providers on the - // opencode.ai upstream. x-opencode-client / x-opencode-project are NOT - // fabricated: their valid values are opencode-internal and inventing them - // could be rejected upstream β€” they remain forward-only above. Scoped to this - // executor (opencode.ai/zen) and only to the fallback path, so the direct - // OpenCode CLI flow (which controls its own request id) is untouched. - if (!headers["x-opencode-request"]) { - headers["x-opencode-request"] = randomUUID(); - } - } - } + if (!headers["User-Agent"] && !headers["user-agent"]) { + headers["User-Agent"] = "opencode/local"; + } + if (!headers["x-opencode-client"]) { + headers["x-opencode-client"] = "cli"; } void model; @@ -316,16 +283,11 @@ export class OpencodeExecutor extends BaseExecutor { } if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) { const mb = modifiedBody as Record; - const m = String(model || ""); - const effortLevels = ["low", "medium", "high", "max"] as const; - const matchedLevel = effortLevels.find((level) => m.endsWith(`-${level}`)); - if (matchedLevel) { - const base = m.slice(0, -matchedLevel.length - 1); - if (base.toLowerCase() === "deepseek-v4-pro") { - mb.model = "deepseek-v4-pro"; - if (mb.reasoning_effort === undefined) { - mb.reasoning_effort = matchedLevel; - } + const parsed = parseDeepSeekEffortLevel(model); + if (parsed) { + mb.model = parsed.baseModel; + if (mb.reasoning_effort === undefined) { + mb.reasoning_effort = parsed.effort; } } } diff --git a/open-sse/executors/t3-chat-web.ts b/open-sse/executors/t3-chat-web.ts index c3119fcca7..d7bbfcfd62 100644 --- a/open-sse/executors/t3-chat-web.ts +++ b/open-sse/executors/t3-chat-web.ts @@ -14,7 +14,7 @@ */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import { sanitizeErrorMessage } from "../utils/error.ts"; +import { errorResponse } from "../utils/error.ts"; import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -110,16 +110,7 @@ export function validateT3Credentials(creds: T3ChatCredentials | null | undefine } function buildErrorResponse(status: number, message: string): Response { - return new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(message), - type: "upstream_error", - code: `HTTP_${status}`, - }, - }), - { status, headers: { "Content-Type": "application/json" } } - ); + return errorResponse(status, message); } /** diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts new file mode 100644 index 0000000000..17746522a4 --- /dev/null +++ b/open-sse/utils/opencodeHeaders.ts @@ -0,0 +1,67 @@ +import { randomUUID } from "crypto"; +import { setUserAgentHeader } from "../executors/base.ts"; + +/** + * Header keys that are forwarded from the client to the upstream provider. + * Used by both OpencodeExecutor and DefaultExecutor. + */ +const OPENCODE_HEADER_KEYS = [ + "x-opencode-session", + "x-opencode-request", + "x-opencode-project", + "x-opencode-client", +] as const; + +/** + * Case-insensitive lookup for a header in a headers record. + */ +function findHeader(headers: Record, name: string): string | undefined { + return Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]; +} + +/** + * Forward OpenCode client request metadata headers to the upstream provider. + * + * Shared logic used by OpencodeExecutor and DefaultExecutor: + * 1. Forwards User-Agent from clientHeaders via `setUserAgentHeader()` + * 2. Forwards x-opencode-session, x-opencode-request, x-opencode-project, + * x-opencode-client headers (case-insensitive match) + * + * @param headers - The outbound headers record to mutate + * @param clientHeaders - The client-provided headers to forward from + * @param options.synthesizeRequestId - When true (OpencodeExecutor only), maps + * x-session-affinity / x-session-id to x-opencode-session when the latter is + * missing, and synthesizes a UUID for x-opencode-request if also missing. + */ +export function forwardOpencodeClientHeaders( + headers: Record, + clientHeaders: Record, + options?: { synthesizeRequestId?: boolean } +): void { + // 1. Forward User-Agent + const clientUA = clientHeaders["User-Agent"] || clientHeaders["user-agent"]; + if (clientUA) { + setUserAgentHeader(headers, clientUA); + } + + // 2. Forward x-opencode-* metadata headers + for (const headerName of OPENCODE_HEADER_KEYS) { + const value = findHeader(clientHeaders, headerName); + if (value) { + headers[headerName] = value; + } + } + + // 3. OpencodeExecutor-only: synthesize session/request id from fallback headers + if (options?.synthesizeRequestId && !headers["x-opencode-session"]) { + const sessionAffinity = + findHeader(clientHeaders, "x-session-affinity") || findHeader(clientHeaders, "x-session-id"); + if (sessionAffinity) { + headers["x-opencode-session"] = sessionAffinity; + + if (!headers["x-opencode-request"]) { + headers["x-opencode-request"] = randomUUID(); + } + } + } +} diff --git a/open-sse/utils/urlSanitize.ts b/open-sse/utils/urlSanitize.ts index d598d268cb..3cdb8ddd90 100644 --- a/open-sse/utils/urlSanitize.ts +++ b/open-sse/utils/urlSanitize.ts @@ -12,3 +12,13 @@ export function stripTrailingSlashes(value: string): string { } return end === value.length ? value : value.slice(0, end); } + +/** + * Normalize a base URL by trimming whitespace and stripping trailing slashes. + * Handles non-string inputs gracefully (returns empty string). + * Single source of truth β€” replaces per-file inline copies in config/*.ts. + */ +export function normalizeBaseUrl(value: string | null | undefined): string { + const str = typeof value === "string" ? value : ""; + return stripTrailingSlashes(str.trim()); +} diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 38a7bfeae5..336664da44 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1,18 +1,6 @@ -/** - * Service kind β€” declarative tag for what a provider can do beyond basic LLM chat. - * Affects UI filtering and playground routing; does not influence request routing. - */ -export type ServiceKind = - | "llm" - | "embedding" - | "image" - | "imageToText" - | "tts" - | "stt" - | "webSearch" - | "webFetch" - | "video" - | "music"; +// Re-export service kinds from leaf module (avoids circular dep with providerSchema) +export type { ServiceKind } from "./serviceKinds"; +export { SERVICE_KIND_VALUES } from "./serviceKinds"; export type RiskNoticeVariant = "oauth" | "webCookie" | "deprecated" | "embedded-service"; diff --git a/src/shared/constants/serviceKinds.ts b/src/shared/constants/serviceKinds.ts new file mode 100644 index 0000000000..2121edb1d6 --- /dev/null +++ b/src/shared/constants/serviceKinds.ts @@ -0,0 +1,32 @@ +/** + * Service kind β€” declarative tag for what a provider can do beyond basic LLM chat. + * Affects UI filtering and playground routing; does not influence request routing. + * + * This is a dependency-free leaf module to avoid circular imports between + * providers.ts and providerSchema.ts. + */ + +export type ServiceKind = + | "llm" + | "embedding" + | "image" + | "imageToText" + | "tts" + | "stt" + | "webSearch" + | "webFetch" + | "video" + | "music"; + +export const SERVICE_KIND_VALUES: readonly ServiceKind[] = [ + "llm", + "embedding", + "image", + "imageToText", + "tts", + "stt", + "webSearch", + "webFetch", + "video", + "music", +]; diff --git a/src/shared/validation/providerSchema.ts b/src/shared/validation/providerSchema.ts index 9351c621b1..d157b9bf90 100644 --- a/src/shared/validation/providerSchema.ts +++ b/src/shared/validation/providerSchema.ts @@ -9,19 +9,7 @@ */ import { z } from "zod"; - -const SERVICE_KIND_VALUES = [ - "llm", - "embedding", - "image", - "imageToText", - "tts", - "stt", - "webSearch", - "webFetch", - "video", - "music", -] as const; +import { SERVICE_KIND_VALUES } from "@/shared/constants/serviceKinds"; export const ProviderSchema = z.object({ id: z.string().min(1), diff --git a/tests/unit/refactor-buildHeaders-default.test.ts b/tests/unit/refactor-buildHeaders-default.test.ts new file mode 100644 index 0000000000..72b9253c6c --- /dev/null +++ b/tests/unit/refactor-buildHeaders-default.test.ts @@ -0,0 +1,129 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +test("DefaultExecutor.buildHeaders: gemini uses x-goog-api-key header", () => { + const executor = new DefaultExecutor("gemini"); + const headers = executor.buildHeaders({ apiKey: "gem-key-1" }, true); + assert.equal(headers["x-goog-api-key"], "gem-key-1"); + assert.equal(headers["Authorization"], undefined); +}); + +test("DefaultExecutor.buildHeaders: gemini falls back to accessToken when no apiKey", () => { + const executor = new DefaultExecutor("gemini"); + const headers = executor.buildHeaders({ accessToken: "tok-gem" }, true); + assert.equal(headers["Authorization"], "Bearer tok-gem"); + assert.equal(headers["x-goog-api-key"], undefined); +}); + +test("DefaultExecutor.buildHeaders: claude uses x-api-key header", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ apiKey: "sk-ant-123" }, true); + assert.equal(headers["x-api-key"], "sk-ant-123"); + assert.equal(headers["Authorization"], undefined); +}); + +test("DefaultExecutor.buildHeaders: claude falls back to accessToken", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ accessToken: "tok-claude" }, true); + assert.equal(headers["Authorization"], "Bearer tok-claude"); + assert.equal(headers["x-api-key"], undefined); +}); + +test("DefaultExecutor.buildHeaders: anthropic uses x-api-key header", () => { + const executor = new DefaultExecutor("anthropic"); + const headers = executor.buildHeaders({ apiKey: "sk-ant-456" }, true); + assert.equal(headers["x-api-key"], "sk-ant-456"); + assert.equal(headers["Authorization"], undefined); +}); + +test("DefaultExecutor.buildHeaders: azure-ai uses api-key header", () => { + const executor = new DefaultExecutor("azure-ai"); + const headers = executor.buildHeaders({ apiKey: "az-key-1" }, true); + assert.equal(headers["api-key"], "az-key-1"); + assert.equal(headers["Authorization"], undefined); +}); + +test("DefaultExecutor.buildHeaders: azure-ai uses accessToken when no apiKey", () => { + const executor = new DefaultExecutor("azure-ai"); + const headers = executor.buildHeaders({ accessToken: "tok-az" }, true); + assert.equal(headers["api-key"], "tok-az"); + assert.equal(headers["Authorization"], undefined); +}); + +test("DefaultExecutor.buildHeaders: snowflake strips pat/ prefix", () => { + const executor = new DefaultExecutor("snowflake"); + const headers = executor.buildHeaders({ apiKey: "pat/my-token" }, true); + assert.equal(headers["Authorization"], "Bearer my-token"); + assert.equal(headers["X-Snowflake-Authorization-Token-Type"], "PROGRAMMATIC_ACCESS_TOKEN"); +}); + +test("DefaultExecutor.buildHeaders: snowflake uses KEYPAIR_JWT when no pat/ prefix", () => { + const executor = new DefaultExecutor("snowflake"); + const headers = executor.buildHeaders({ apiKey: "jwt-token-abc" }, true); + assert.equal(headers["Authorization"], "Bearer jwt-token-abc"); + assert.equal(headers["X-Snowflake-Authorization-Token-Type"], "KEYPAIR_JWT"); +}); + +test("DefaultExecutor.buildHeaders: clarifai uses Key prefix", () => { + const executor = new DefaultExecutor("clarifai"); + const headers = executor.buildHeaders({ apiKey: "clar-123" }, true); + assert.equal(headers["Authorization"], "Key clar-123"); +}); + +test("DefaultExecutor.buildHeaders: maritalk uses Key prefix", () => { + const executor = new DefaultExecutor("maritalk"); + const headers = executor.buildHeaders({ apiKey: "mt-key-1" }, true); + assert.equal(headers["Authorization"], "Key mt-key-1"); +}); + +test("DefaultExecutor.buildHeaders: reka sets both Authorization and X-Api-Key", () => { + const executor = new DefaultExecutor("reka"); + const headers = executor.buildHeaders({ apiKey: "reka-1" }, true); + assert.equal(headers["Authorization"], "Bearer reka-1"); + assert.equal(headers["X-Api-Key"], "reka-1"); +}); + +test("DefaultExecutor.buildHeaders: gigachat uses accessToken preferentially", () => { + const executor = new DefaultExecutor("gigachat"); + const headers = executor.buildHeaders({ apiKey: "giga-key", accessToken: "giga-tok" }, true); + assert.equal(headers["Authorization"], "Bearer giga-tok"); +}); + +test("DefaultExecutor.buildHeaders: gigachat falls back to apiKey", () => { + const executor = new DefaultExecutor("gigachat"); + const headers = executor.buildHeaders({ apiKey: "giga-key" }, true); + assert.equal(headers["Authorization"], "Bearer giga-key"); +}); + +test("DefaultExecutor.buildHeaders: generic provider uses Bearer Authorization", () => { + const executor = new DefaultExecutor("openai"); + const headers = executor.buildHeaders({ apiKey: "sk-openai-1" }, true); + assert.equal(headers["Authorization"], "Bearer sk-openai-1"); +}); + +test("DefaultExecutor.buildHeaders: stream=true sets Accept text/event-stream", () => { + const executor = new DefaultExecutor("openai"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, true); + assert.equal(headers["Accept"], "text/event-stream"); +}); + +test("DefaultExecutor.buildHeaders: stream=false sets Accept application/json", () => { + const executor = new DefaultExecutor("openai"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, false); + assert.equal(headers["Accept"], "application/json"); +}); + +test("DefaultExecutor.buildHeaders: OCI adds OpenAI-Project header when projectId present", () => { + const executor = new DefaultExecutor("oci"); + const headers = executor.buildHeaders({ apiKey: "oci-key", projectId: "proj-123" }, true); + assert.equal(headers["Authorization"], "Bearer oci-key"); + assert.equal(headers["OpenAI-Project"], "proj-123"); +}); + +test("DefaultExecutor.buildHeaders: OCI omits OpenAI-Project when projectId absent", () => { + const executor = new DefaultExecutor("oci"); + const headers = executor.buildHeaders({ apiKey: "oci-key" }, true); + assert.equal(headers["Authorization"], "Bearer oci-key"); + assert.equal(headers["OpenAI-Project"], undefined); +}); diff --git a/tests/unit/refactor-buildHeaders-opencode.test.ts b/tests/unit/refactor-buildHeaders-opencode.test.ts new file mode 100644 index 0000000000..4117053f43 --- /dev/null +++ b/tests/unit/refactor-buildHeaders-opencode.test.ts @@ -0,0 +1,104 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; + +// --------------------------------------------------------------------------- +// OpencodeExecutor.buildHeaders β€” request format auth switch +// --------------------------------------------------------------------------- + +test("OpencodeExecutor.buildHeaders: default format uses Bearer Authorization", () => { + const executor = new OpencodeExecutor("opencode"); + // _requestFormat defaults to null β†’ default Bearer path + const headers = executor.buildHeaders({ apiKey: "sk-oc-1" }, true); + assert.equal(headers["Authorization"], "Bearer sk-oc-1"); + assert.equal(headers["x-api-key"], undefined); +}); + +test("OpencodeExecutor.buildHeaders: claude format uses x-api-key header", () => { + const executor = new OpencodeExecutor("opencode"); + executor._requestFormat = "claude"; + const headers = executor.buildHeaders({ apiKey: "sk-claude-1" }, true); + assert.equal(headers["x-api-key"], "sk-claude-1"); + assert.equal(headers["Authorization"], undefined); +}); + +test("OpencodeExecutor.buildHeaders: claude format sets anthropic-version header", () => { + const executor = new OpencodeExecutor("opencode"); + executor._requestFormat = "claude"; + const headers = executor.buildHeaders({ apiKey: "key-1" }, true); + assert.equal(headers["anthropic-version"], "2023-06-01"); +}); + +test("OpencodeExecutor.buildHeaders: non-claude format omits anthropic-version", () => { + const executor = new OpencodeExecutor("opencode"); + executor._requestFormat = "openai"; + const headers = executor.buildHeaders({ apiKey: "key-1" }, true); + assert.equal(headers["anthropic-version"], undefined); +}); + +test("OpencodeExecutor.buildHeaders: stream=true sets Accept text/event-stream", () => { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, true); + assert.equal(headers["Accept"], "text/event-stream"); +}); + +test("OpencodeExecutor.buildHeaders: stream=false omits Accept header", () => { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, false); + assert.equal(headers["Accept"], undefined); +}); + +test("OpencodeExecutor.buildHeaders: uses accessToken when apiKey is absent", () => { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ accessToken: "tok-oc" }, true); + assert.equal(headers["Authorization"], "Bearer tok-oc"); +}); + +test("OpencodeExecutor.buildHeaders: apiKey takes precedence over accessToken", () => { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ apiKey: "sk-pri", accessToken: "tok-sec" }, true); + assert.equal(headers["Authorization"], "Bearer sk-pri"); +}); + +test("OpencodeExecutor.buildHeaders: claude format with accessToken still uses x-api-key", () => { + const executor = new OpencodeExecutor("opencode"); + executor._requestFormat = "claude"; + const headers = executor.buildHeaders({ apiKey: "sk-a", accessToken: "tok-b" }, true); + assert.equal(headers["x-api-key"], "sk-a"); + assert.equal(headers["Authorization"], undefined); +}); + +test("OpencodeExecutor.buildHeaders: Content-Type always application/json", () => { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, true); + assert.equal(headers["Content-Type"], "application/json"); +}); + +test("OpencodeExecutor.buildHeaders: defaults User-Agent to opencode/local when no client UA", () => { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, true); + assert.equal(headers["User-Agent"], "opencode/local"); +}); + +test("OpencodeExecutor.buildHeaders: preserves client User-Agent when provided", () => { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, true, { + "User-Agent": "opencode/1.17.12", + }); + assert.equal(headers["User-Agent"], "opencode/1.17.12"); +}); + +test("OpencodeExecutor.buildHeaders: defaults x-opencode-client to cli when absent", () => { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, true); + assert.equal(headers["x-opencode-client"], "cli"); +}); + +test("OpencodeExecutor.buildHeaders: preserves x-opencode-client from client headers", () => { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, true, { + "x-opencode-client": "desktop", + }); + assert.equal(headers["x-opencode-client"], "desktop"); +}); diff --git a/tests/unit/refactor-buildHeaders-preamble.test.ts b/tests/unit/refactor-buildHeaders-preamble.test.ts new file mode 100644 index 0000000000..d3b3a7d91f --- /dev/null +++ b/tests/unit/refactor-buildHeaders-preamble.test.ts @@ -0,0 +1,120 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BaseExecutor } from "../../open-sse/executors/base.ts"; +import type { ProviderCredentials } from "../../open-sse/executors/base.ts"; + +/** + * TestExecutor exposes protected buildHeadersPreamble and resolveEffectiveKey. + */ +class TestExecutor extends BaseExecutor { + constructor(config = {}) { + super("test-provider", { + baseUrls: ["https://default.example/v1/chat/completions"], + headers: { "X-Provider-Header": "from-config" }, + ...config, + }); + } + + publicBuildHeadersPreamble( + credentials: ProviderCredentials, + stream: boolean + ): { headers: Record; effectiveKey: string } { + return this.buildHeadersPreamble(credentials, stream); + } + + publicResolveEffectiveKey(credentials: ProviderCredentials): string { + return this.resolveEffectiveKey(credentials); + } + + async transformRequest(model: string, body: unknown, stream: boolean) { + return body; + } +} + +// --------------------------------------------------------------------------- +// resolveEffectiveKey tests +// --------------------------------------------------------------------------- + +test("resolveEffectiveKey: returns apiKey when no extraApiKeys present", () => { + const executor = new TestExecutor(); + const result = executor.publicResolveEffectiveKey({ + apiKey: "sk-primary-123", + }); + assert.equal(result, "sk-primary-123"); +}); + +test("resolveEffectiveKey: returns apiKey when extraApiKeys is empty", () => { + const executor = new TestExecutor(); + const result = executor.publicResolveEffectiveKey({ + apiKey: "sk-primary-123", + providerSpecificData: { extraApiKeys: [] }, + }); + assert.equal(result, "sk-primary-123"); +}); + +test("resolveEffectiveKey: returns apiKey when no connectionId", () => { + const executor = new TestExecutor(); + const result = executor.publicResolveEffectiveKey({ + apiKey: "sk-primary-123", + providerSpecificData: { extraApiKeys: ["sk-extra-1", "sk-extra-2"] }, + }); + assert.equal(result, "sk-primary-123"); +}); + +test("resolveEffectiveKey: returns accessToken when apiKey is undefined", () => { + const executor = new TestExecutor(); + const result = executor.publicResolveEffectiveKey({ + accessToken: "tok-abc", + }); + assert.equal(result, undefined); +}); + +// --------------------------------------------------------------------------- +// buildHeadersPreamble tests +// --------------------------------------------------------------------------- + +test("buildHeadersPreamble: includes Content-Type application/json", () => { + const executor = new TestExecutor(); + const { headers } = executor.publicBuildHeadersPreamble({ apiKey: "key-1" }, true); + assert.equal(headers["Content-Type"], "application/json"); +}); + +test("buildHeadersPreamble: merges config.headers into result", () => { + const executor = new TestExecutor({ + headers: { "X-Custom-Header": "custom-value" }, + }); + const { headers } = executor.publicBuildHeadersPreamble({ apiKey: "key-1" }, true); + assert.equal(headers["X-Custom-Header"], "custom-value"); +}); + +test("buildHeadersPreamble: returns effectiveKey from resolveEffectiveKey", () => { + const executor = new TestExecutor(); + const { effectiveKey } = executor.publicBuildHeadersPreamble({ apiKey: "sk-abc-123" }, true); + assert.equal(effectiveKey, "sk-abc-123"); +}); + +test("buildHeadersPreamble: sets User-Agent from provider env var override", () => { + const envKey = "TEST_PROVIDER_USER_AGENT"; + process.env[envKey] = "custom-agent/1.0"; + try { + const executor = new TestExecutor({ id: "test-provider" }); + const { headers } = executor.publicBuildHeadersPreamble({ apiKey: "key-1" }, true); + assert.equal(headers["User-Agent"], "custom-agent/1.0"); + } finally { + delete process.env[envKey]; + } +}); + +test("buildHeadersPreamble: does not set User-Agent when env var is empty", () => { + const envKey = "TEST_PROVIDER_USER_AGENT"; + const saved = process.env[envKey]; + delete process.env[envKey]; + try { + const executor = new TestExecutor({ id: "test-provider" }); + const { headers } = executor.publicBuildHeadersPreamble({ apiKey: "key-1" }, true); + assert.equal(headers["User-Agent"], undefined); + } finally { + if (saved !== undefined) process.env[envKey] = saved; + } +}); diff --git a/tests/unit/refactor-effortLevel.test.ts b/tests/unit/refactor-effortLevel.test.ts new file mode 100644 index 0000000000..bf146f51f0 --- /dev/null +++ b/tests/unit/refactor-effortLevel.test.ts @@ -0,0 +1,127 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +/** + * Characterization tests for the DeepSeek V4 Pro effort-level parsing logic. + * + * The parseDeepSeekEffortLevel function is module-level (non-exported) in + * open-sse/executors/opencode.ts. We re-implement the same logic here to lock + * down the expected behavior as a regression safety net. + * + * Source reference: open-sse/executors/opencode.ts lines 44-62 + */ + +const EFFORT_LEVELS = ["low", "medium", "high", "max"] as const; + +function parseDeepSeekEffortLevel(model: string): { baseModel: string; effort: string } | null { + const m = String(model || ""); + const matchedLevel = EFFORT_LEVELS.find((level) => m.endsWith(`-${level}`)); + if (!matchedLevel) return null; + const baseModel = m.slice(0, -matchedLevel.length - 1); + if (baseModel.toLowerCase() !== "deepseek-v4-pro") return null; + return { baseModel: "deepseek-v4-pro", effort: matchedLevel }; +} + +// -- Valid effort levels ------------------------------------------------------- + +describe("parseDeepSeekEffortLevel - valid suffixes", () => { + it("deepseek-v4-pro-low returns low effort", () => { + const result = parseDeepSeekEffortLevel("deepseek-v4-pro-low"); + assert.deepEqual(result, { baseModel: "deepseek-v4-pro", effort: "low" }); + }); + + it("deepseek-v4-pro-medium returns medium effort", () => { + const result = parseDeepSeekEffortLevel("deepseek-v4-pro-medium"); + assert.deepEqual(result, { baseModel: "deepseek-v4-pro", effort: "medium" }); + }); + + it("deepseek-v4-pro-high returns high effort", () => { + const result = parseDeepSeekEffortLevel("deepseek-v4-pro-high"); + assert.deepEqual(result, { baseModel: "deepseek-v4-pro", effort: "high" }); + }); + + it("deepseek-v4-pro-max returns max effort", () => { + const result = parseDeepSeekEffortLevel("deepseek-v4-pro-max"); + assert.deepEqual(result, { baseModel: "deepseek-v4-pro", effort: "max" }); + }); +}); + +// -- Case-insensitive base model matching -------------------------------------- + +describe("parseDeepSeekEffortLevel - case-insensitive base model", () => { + it("matches DeepSeek-V4-Pro-high (uppercase base, lowercase suffix)", () => { + const result = parseDeepSeekEffortLevel("DeepSeek-V4-Pro-high"); + assert.deepEqual(result, { baseModel: "deepseek-v4-pro", effort: "high" }); + }); + + it("matches deepseek-V4-pro-low (mixed case base, lowercase suffix)", () => { + const result = parseDeepSeekEffortLevel("deepseek-V4-pro-low"); + assert.deepEqual(result, { baseModel: "deepseek-v4-pro", effort: "low" }); + }); + + it("returns null for DeepSeek-V4-Pro-High (uppercase suffix does not match)", () => { + // Suffix matching is case-sensitive β€” only the base model comparison uses toLowerCase + assert.equal(parseDeepSeekEffortLevel("DeepSeek-V4-Pro-High"), null); + }); +}); + +// -- Returns null for non-matching inputs -------------------------------------- + +describe("parseDeepSeekEffortLevel - no match cases", () => { + it("returns null for bare deepseek-v4-pro (no suffix)", () => { + assert.equal(parseDeepSeekEffortLevel("deepseek-v4-pro"), null); + }); + + it("returns null for gpt-5-high (not deepseek-v4-pro)", () => { + assert.equal(parseDeepSeekEffortLevel("gpt-5-high"), null); + }); + + it("returns null for empty string", () => { + assert.equal(parseDeepSeekEffortLevel(""), null); + }); + + it("returns null for trailing dash with no level (deepseek-v4-pro-)", () => { + assert.equal(parseDeepSeekEffortLevel("deepseek-v4-pro-"), null); + }); + + it("returns null for deepseek-v4-pro-lowextra (suffix is not an exact level)", () => { + assert.equal(parseDeepSeekEffortLevel("deepseek-v4-pro-lowextra"), null); + }); + + it("returns null for deepseek-v4-pro (case mismatch on level)", () => { + // The suffix "Low" does not match "low" exactly + assert.equal(parseDeepSeekEffortLevel("deepseek-v4-pro-Low"), null); + }); + + it("returns null for a random string", () => { + assert.equal(parseDeepSeekEffortLevel("random-string"), null); + }); + + it("returns null for just the suffix -high", () => { + assert.equal(parseDeepSeekEffortLevel("-high"), null); + }); +}); + +// -- Edge cases ---------------------------------------------------------------- + +describe("parseDeepSeekEffortLevel - edge cases", () => { + it("handles numeric input via String coercion", () => { + // String(123) === "123" β€” no dash suffix, returns null + assert.equal(parseDeepSeekEffortLevel(123 as unknown as string), null); + }); + + it("handles null input via String coercion", () => { + // String(null) === "null" β€” no match + assert.equal(parseDeepSeekEffortLevel(null as unknown as string), null); + }); + + it("handles undefined input via String coercion", () => { + // String(undefined) === "undefined" β€” no match + assert.equal(parseDeepSeekEffortLevel(undefined as unknown as string), null); + }); + + it("returns the canonical baseModel as lowercase when suffix matches", () => { + const result = parseDeepSeekEffortLevel("DeepSeek-V4-Pro-max"); + assert.deepEqual(result, { baseModel: "deepseek-v4-pro", effort: "max" }); + }); +}); diff --git a/tests/unit/refactor-opencodeHeaders.test.ts b/tests/unit/refactor-opencodeHeaders.test.ts new file mode 100644 index 0000000000..b79865b30e --- /dev/null +++ b/tests/unit/refactor-opencodeHeaders.test.ts @@ -0,0 +1,227 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { forwardOpencodeClientHeaders } from "../../open-sse/utils/opencodeHeaders.ts"; + +// Helper: create a fresh empty headers record +function h(): Record { + return {}; +} + +// ── User-Agent forwarding ─────────────────────────────────────────────────── + +describe("forwardOpencodeClientHeaders – User-Agent", () => { + it("forwards User-Agent from client headers", () => { + const headers = h(); + const clientHeaders = { "User-Agent": "MyTool/1.0" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["User-Agent"], "MyTool/1.0"); + }); + + it("does NOT set lowercase user-agent when it is absent from headers", () => { + const headers = h(); + const clientHeaders = { "User-Agent": "MyTool/1.0" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + // setUserAgentHeader only sets lowercase "user-agent" if it already exists + assert.equal(headers["user-agent"], undefined); + }); + + it("sets lowercase user-agent when it already exists in headers", () => { + const headers = { "user-agent": "old" } as Record; + const clientHeaders = { "User-Agent": "MyTool/1.0" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["user-agent"], "MyTool/1.0"); + assert.equal(headers["User-Agent"], "MyTool/1.0"); + }); + + it("falls back to lowercase user-agent from client headers", () => { + const headers = h(); + const clientHeaders = { "user-agent": "FallbackTool/2.0" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["User-Agent"], "FallbackTool/2.0"); + }); +}); + +// ── x-opencode-* header forwarding ───────────────────────────────────────── + +describe("forwardOpencodeClientHeaders – x-opencode-* headers", () => { + it("forwards x-opencode-session", () => { + const headers = h(); + const clientHeaders = { "x-opencode-session": "sess-123" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-opencode-session"], "sess-123"); + }); + + it("forwards x-opencode-request", () => { + const headers = h(); + const clientHeaders = { "x-opencode-request": "req-abc" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-opencode-request"], "req-abc"); + }); + + it("forwards x-opencode-project", () => { + const headers = h(); + const clientHeaders = { "x-opencode-project": "/home/user/project" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-opencode-project"], "/home/user/project"); + }); + + it("forwards x-opencode-client", () => { + const headers = h(); + const clientHeaders = { "x-opencode-client": "opencode-cli" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-opencode-client"], "opencode-cli"); + }); + + it("forwards all four x-opencode-* headers at once", () => { + const headers = h(); + const clientHeaders = { + "x-opencode-session": "sess-1", + "x-opencode-request": "req-1", + "x-opencode-project": "/proj", + "x-opencode-client": "cli", + }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-opencode-session"], "sess-1"); + assert.equal(headers["x-opencode-request"], "req-1"); + assert.equal(headers["x-opencode-project"], "/proj"); + assert.equal(headers["x-opencode-client"], "cli"); + }); + + it("matches x-opencode-* headers case-insensitively", () => { + const headers = h(); + const clientHeaders = { + "X-OpenCode-Session": "Sess-Upper", + "X-OpenCode-Request": "Req-Upper", + "X-OpenCode-Project": "/Upper", + "X-OpenCode-Client": "UpperClient", + }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-opencode-session"], "Sess-Upper"); + assert.equal(headers["x-opencode-request"], "Req-Upper"); + assert.equal(headers["x-opencode-project"], "/Upper"); + assert.equal(headers["x-opencode-client"], "UpperClient"); + }); + + it("does NOT forward unknown headers", () => { + const headers = h(); + const clientHeaders = { + "x-opencode-session": "s1", + "x-random-header": "should-not-forward", + Authorization: "Bearer tok", + }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-random-header"], undefined); + assert.equal(headers["Authorization"], undefined); + }); +}); + +// ── synthesizeRequestId ───────────────────────────────────────────────────── + +describe("forwardOpencodeClientHeaders – synthesizeRequestId", () => { + it("maps x-session-affinity β†’ x-opencode-session when latter is missing", () => { + const headers = h(); + const clientHeaders = { "x-session-affinity": "affinity-abc" }; + forwardOpencodeClientHeaders(headers, clientHeaders, { + synthesizeRequestId: true, + }); + assert.equal(headers["x-opencode-session"], "affinity-abc"); + }); + + it("maps x-session-id β†’ x-opencode-session when latter is missing", () => { + const headers = h(); + const clientHeaders = { "x-session-id": "sid-xyz" }; + forwardOpencodeClientHeaders(headers, clientHeaders, { + synthesizeRequestId: true, + }); + assert.equal(headers["x-opencode-session"], "sid-xyz"); + }); + + it("prefers x-session-affinity over x-session-id", () => { + const headers = h(); + const clientHeaders = { + "x-session-affinity": "aff-1", + "x-session-id": "sid-1", + }; + forwardOpencodeClientHeaders(headers, clientHeaders, { + synthesizeRequestId: true, + }); + assert.equal(headers["x-opencode-session"], "aff-1"); + }); + + it("synthesizes x-opencode-request (UUID) when session and request are missing", () => { + const headers = h(); + const clientHeaders = { "x-session-affinity": "aff-2" }; + forwardOpencodeClientHeaders(headers, clientHeaders, { + synthesizeRequestId: true, + }); + // x-opencode-request should be a valid UUID string + assert.ok(headers["x-opencode-request"], "should have synthesized x-opencode-request"); + assert.match( + headers["x-opencode-request"], + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + "synthesized request id should be a UUID" + ); + }); + + it("does NOT overwrite existing x-opencode-session", () => { + const headers = { "x-opencode-session": "existing-sess" } as Record; + const clientHeaders = { "x-session-affinity": "aff-3" }; + forwardOpencodeClientHeaders(headers, clientHeaders, { + synthesizeRequestId: true, + }); + assert.equal(headers["x-opencode-session"], "existing-sess"); + }); + + it("does NOT synthesize when synthesizeRequestId is false", () => { + const headers = h(); + const clientHeaders = { "x-session-affinity": "aff-4" }; + forwardOpencodeClientHeaders(headers, clientHeaders, { + synthesizeRequestId: false, + }); + assert.equal(headers["x-opencode-session"], undefined); + assert.equal(headers["x-opencode-request"], undefined); + }); + + it("does NOT synthesize when synthesizeRequestId is absent", () => { + const headers = h(); + const clientHeaders = { "x-session-affinity": "aff-5" }; + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-opencode-session"], undefined); + assert.equal(headers["x-opencode-request"], undefined); + }); + + it("does NOT synthesize when there is no session affinity header", () => { + const headers = h(); + const clientHeaders = { "x-random": "value" }; + forwardOpencodeClientHeaders(headers, clientHeaders, { + synthesizeRequestId: true, + }); + assert.equal(headers["x-opencode-session"], undefined); + assert.equal(headers["x-opencode-request"], undefined); + }); +}); + +// ── Edge cases ────────────────────────────────────────────────────────────── + +describe("forwardOpencodeClientHeaders – edge cases", () => { + it("empty clientHeaders β†’ no mutation on headers", () => { + const headers = h(); + forwardOpencodeClientHeaders(headers, {}); + assert.deepEqual(headers, {}); + }); + + it("does not overwrite existing x-opencode-* headers from client", () => { + const headers = h(); + const clientHeaders = { + "x-opencode-session": "client-sess", + "x-opencode-request": "client-req", + }; + // Pre-set the headers β€” forwardOpencodeClientHeaders sets them from clientHeaders, + // which is the same value here. The function does overwrite with the same value. + // This test documents that client headers are the authoritative source. + forwardOpencodeClientHeaders(headers, clientHeaders); + assert.equal(headers["x-opencode-session"], "client-sess"); + assert.equal(headers["x-opencode-request"], "client-req"); + }); +}); diff --git a/tests/unit/refactor-registryFactory.test.ts b/tests/unit/refactor-registryFactory.test.ts new file mode 100644 index 0000000000..9cefcb572d --- /dev/null +++ b/tests/unit/refactor-registryFactory.test.ts @@ -0,0 +1,105 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { buildOpenAiCompatibleRegistryEntry } from "../../open-sse/config/providers/shared.ts"; +import type { RegistryEntry } from "../../open-sse/config/providers/shared.ts"; + +// ── buildOpenAiCompatibleRegistryEntry ────────────────────────────────────── + +describe("buildOpenAiCompatibleRegistryEntry", () => { + it("returns all default fields when only id is provided", () => { + const entry = buildOpenAiCompatibleRegistryEntry({ id: "my-provider" }); + assert.equal(entry.id, "my-provider"); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + }); + + it("includes baseUrl when provided", () => { + const entry = buildOpenAiCompatibleRegistryEntry({ + id: "my-provider", + baseUrl: "https://api.example.com/v1", + }); + assert.equal(entry.baseUrl, "https://api.example.com/v1"); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + }); + + it("includes models array when provided", () => { + const models = [ + { id: "gpt-4", name: "GPT-4" }, + { id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" }, + ]; + const entry = buildOpenAiCompatibleRegistryEntry({ + id: "my-provider", + models, + }); + assert.deepEqual(entry.models, models); + assert.equal(entry.models.length, 2); + }); + + it("overrides default authHeader when custom value is provided", () => { + const entry = buildOpenAiCompatibleRegistryEntry({ + id: "my-provider", + authHeader: "x-api-key", + } as Parameters[0]); + assert.equal(entry.authHeader, "x-api-key"); + // Other defaults still present + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + assert.equal(entry.authType, "apikey"); + }); + + it("overrides default executor when custom value is provided", () => { + const entry = buildOpenAiCompatibleRegistryEntry({ + id: "my-provider", + executor: "opencode", + }); + assert.equal(entry.executor, "opencode"); + // Other defaults still present + assert.equal(entry.format, "openai"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + }); + + it("includes passthroughModels when set to true", () => { + const entry = buildOpenAiCompatibleRegistryEntry({ + id: "my-provider", + passthroughModels: true, + }); + assert.equal(entry.passthroughModels, true); + assert.equal(entry.format, "openai"); + }); + + it("allows combining multiple overrides", () => { + const entry = buildOpenAiCompatibleRegistryEntry({ + id: "multi", + baseUrl: "https://multi.api.com", + executor: "cursor", + authHeader: "x-token", + passthroughModels: true, + timeoutMs: 30_000, + }); + assert.equal(entry.id, "multi"); + assert.equal(entry.baseUrl, "https://multi.api.com"); + assert.equal(entry.executor, "cursor"); + assert.equal(entry.authHeader, "x-token"); + assert.equal(entry.passthroughModels, true); + assert.equal(entry.timeoutMs, 30_000); + assert.equal(entry.format, "openai"); + assert.equal(entry.authType, "apikey"); + }); + + it("returns a RegistryEntry-compatible object", () => { + const entry: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "type-check", + }); + // Verify the type is assignable (compile-time) and runtime shape is correct + assert.equal(typeof entry.id, "string"); + assert.equal(typeof entry.format, "string"); + assert.equal(typeof entry.executor, "string"); + assert.equal(typeof entry.authType, "string"); + assert.equal(typeof entry.authHeader, "string"); + }); +}); diff --git a/tests/unit/refactor-resolveBaseUrl.test.ts b/tests/unit/refactor-resolveBaseUrl.test.ts new file mode 100644 index 0000000000..0c710eec2b --- /dev/null +++ b/tests/unit/refactor-resolveBaseUrl.test.ts @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BaseExecutor } from "../../open-sse/executors/base.ts"; +import type { ProviderCredentials } from "../../open-sse/executors/base.ts"; + +/** + * TestExecutor exposes protected resolveBaseUrl for unit testing. + */ +class TestExecutor extends BaseExecutor { + constructor(config = {}) { + super("test-provider", { + baseUrls: ["https://default.example/v1/chat/completions"], + ...config, + }); + } + + /** Public wrapper for the protected method. */ + publicResolveBaseUrl(credentials: ProviderCredentials | null, fallback?: string): string { + return this.resolveBaseUrl(credentials, fallback); + } + + async transformRequest(model: string, body: unknown, stream: boolean) { + return body; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("resolveBaseUrl: returns credentials.providerSpecificData.baseUrl when set", () => { + const executor = new TestExecutor(); + const result = executor.publicResolveBaseUrl({ + apiKey: "key-1", + providerSpecificData: { baseUrl: "https://custom.example/v1/chat/completions" }, + }); + assert.equal(result, "https://custom.example/v1/chat/completions"); +}); + +test("resolveBaseUrl: returns fallback when credentials have no baseUrl", () => { + const executor = new TestExecutor(); + const result = executor.publicResolveBaseUrl( + { apiKey: "key-1" }, + "https://fallback.example/v1/chat/completions" + ); + assert.equal(result, "https://fallback.example/v1/chat/completions"); +}); + +test("resolveBaseUrl: returns config.baseUrl when no credentials and no fallback", () => { + const executor = new TestExecutor({ + baseUrl: "https://config.example/v1/chat/completions", + }); + const result = executor.publicResolveBaseUrl(null); + assert.equal(result, "https://config.example/v1/chat/completions"); +}); + +test("resolveBaseUrl: returns empty string when nothing is configured", () => { + const executor = new TestExecutor(); + const result = executor.publicResolveBaseUrl(null); + assert.equal(result, ""); +}); + +test("resolveBaseUrl: credentials.baseUrl takes precedence over fallback", () => { + const executor = new TestExecutor(); + const result = executor.publicResolveBaseUrl( + { + apiKey: "key-1", + providerSpecificData: { baseUrl: "https://cred.example/v1" }, + }, + "https://fallback.example/v1" + ); + assert.equal(result, "https://cred.example/v1"); +}); + +test("resolveBaseUrl: credentials.baseUrl takes precedence over config.baseUrl", () => { + const executor = new TestExecutor({ + baseUrl: "https://config.example/v1", + }); + const result = executor.publicResolveBaseUrl({ + apiKey: "key-1", + providerSpecificData: { baseUrl: "https://cred.example/v1" }, + }); + assert.equal(result, "https://cred.example/v1"); +}); + +test("resolveBaseUrl: fallback takes precedence over config.baseUrl", () => { + const executor = new TestExecutor({ + baseUrl: "https://config.example/v1", + }); + const result = executor.publicResolveBaseUrl({ apiKey: "key-1" }, "https://fallback.example/v1"); + assert.equal(result, "https://fallback.example/v1"); +}); diff --git a/tests/unit/refactor-urlSanitize.test.ts b/tests/unit/refactor-urlSanitize.test.ts new file mode 100644 index 0000000000..227404dadb --- /dev/null +++ b/tests/unit/refactor-urlSanitize.test.ts @@ -0,0 +1,72 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { stripTrailingSlashes, normalizeBaseUrl } from "../../open-sse/utils/urlSanitize.ts"; + +// ── stripTrailingSlashes ──────────────────────────────────────────────────── + +describe("stripTrailingSlashes", () => { + it("returns unchanged string when there is no trailing slash", () => { + assert.equal(stripTrailingSlashes("hello"), "hello"); + }); + + it("strips a single trailing slash", () => { + assert.equal(stripTrailingSlashes("hello/"), "hello"); + }); + + it("strips multiple trailing slashes", () => { + assert.equal(stripTrailingSlashes("hello///"), "hello"); + }); + + it("returns empty string for empty input", () => { + assert.equal(stripTrailingSlashes(""), ""); + }); + + it("returns empty string when input is only a slash", () => { + assert.equal(stripTrailingSlashes("/"), ""); + }); + + it("strips one trailing slash from a full URL with path", () => { + assert.equal(stripTrailingSlashes("https://api.example.com/v1/"), "https://api.example.com/v1"); + }); + + it("leaves a URL without trailing slash unchanged", () => { + assert.equal(stripTrailingSlashes("https://api.example.com"), "https://api.example.com"); + }); + + it("strips multiple trailing slashes from a URL", () => { + assert.equal(stripTrailingSlashes("https://api.example.com///"), "https://api.example.com"); + }); +}); + +// ── normalizeBaseUrl ──────────────────────────────────────────────────────── + +describe("normalizeBaseUrl", () => { + it("returns empty string for null", () => { + assert.equal(normalizeBaseUrl(null), ""); + }); + + it("returns empty string for undefined", () => { + assert.equal(normalizeBaseUrl(undefined), ""); + }); + + it("returns empty string for a number", () => { + assert.equal(normalizeBaseUrl(123 as unknown as string), ""); + }); + + it("trims whitespace and strips trailing slash", () => { + assert.equal(normalizeBaseUrl(" https://api.example.com/v1/ "), "https://api.example.com/v1"); + }); + + it("leaves a clean URL unchanged", () => { + assert.equal(normalizeBaseUrl("https://api.example.com/v1"), "https://api.example.com/v1"); + }); + + it("strips trailing slash from a URL", () => { + assert.equal(normalizeBaseUrl("https://api.example.com/"), "https://api.example.com"); + }); + + it("returns empty string for empty input", () => { + assert.equal(normalizeBaseUrl(""), ""); + }); +}); diff --git a/tests/unit/t3-chat-web-credentials.test.ts b/tests/unit/t3-chat-web-credentials.test.ts index 2cb0754d68..f3bbf645e9 100644 --- a/tests/unit/t3-chat-web-credentials.test.ts +++ b/tests/unit/t3-chat-web-credentials.test.ts @@ -80,5 +80,5 @@ test("execute(): empty apiKey still returns a 400 error response", async () => { } as never); assert.equal(result.response.status, 400); const body = await result.response.json(); - assert.equal(body.error.code, "HTTP_400"); + assert.equal(body.error.code, "bad_request"); });