mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
refactor(executors): deduplicate shared utilities and add comprehensive tests (#5720)
Integrated into release/v3.8.43
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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<RegistryEntry, "id"> &
|
||||
Partial<Omit<RegistryEntry, "id" | "format" | "executor" | "authType" | "authHeader">>
|
||||
): RegistryEntry {
|
||||
return {
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
...overrides,
|
||||
} as RegistryEntry;
|
||||
}
|
||||
|
||||
export interface LegacyProvider {
|
||||
format: string;
|
||||
baseUrl?: string;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown> | undefined)
|
||||
?.selectedKeyId as string | undefined;
|
||||
let effectiveKey = credentials.apiKey;
|
||||
if (extraKeys.length > 0 && credentials.connectionId && credentials.apiKey) {
|
||||
const resolved = resolveKeyForRequest(
|
||||
credentials.connectionId,
|
||||
credentials.apiKey,
|
||||
extraKeys,
|
||||
selectedKeyId ?? null
|
||||
);
|
||||
effectiveKey = resolved?.key ?? credentials.apiKey;
|
||||
if (resolved && credentials.providerSpecificData) {
|
||||
(credentials.providerSpecificData as Record<string, unknown>).selectedKeyId =
|
||||
resolved.keyId;
|
||||
}
|
||||
}
|
||||
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<string, string> | null,
|
||||
model?: string,
|
||||
health?: Record<string, KeyHealth>
|
||||
): Record<string, string> {
|
||||
void clientHeaders;
|
||||
void model;
|
||||
stream: boolean
|
||||
): { headers: Record<string, string>; effectiveKey: string } {
|
||||
const headers: Record<string, string> = {
|
||||
"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<string, string> | null,
|
||||
model?: string,
|
||||
health?: Record<string, KeyHealth>
|
||||
): Record<string, string> {
|
||||
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<string, unknown> | 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<string, unknown>).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<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | 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;
|
||||
|
||||
@@ -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<string, string>, 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<string, string> | null) {
|
||||
const headers = { "Content-Type": "application/json", ...this.config.headers };
|
||||
|
||||
// Allow per-provider User-Agent override via environment variable.
|
||||
const providerId = this.config?.id || this.provider;
|
||||
if (providerId) {
|
||||
const envKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`;
|
||||
const envUA = process.env[envKey]?.trim();
|
||||
if (envUA) {
|
||||
headers["User-Agent"] = envUA;
|
||||
if ("user-agent" in headers) {
|
||||
headers["user-agent"] = envUA;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T07: resolve extra keys round-robin locally since DefaultExecutor overrides BaseExecutor buildHeaders
|
||||
const extraKeys =
|
||||
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
|
||||
const selectedKeyId = (credentials.providerSpecificData as Record<string, unknown> | undefined)
|
||||
?.selectedKeyId as string | undefined;
|
||||
let effectiveKey = credentials.apiKey;
|
||||
if (extraKeys.length > 0 && credentials.connectionId && credentials.apiKey) {
|
||||
const resolved = resolveKeyForRequest(
|
||||
credentials.connectionId,
|
||||
credentials.apiKey,
|
||||
extraKeys,
|
||||
selectedKeyId ?? null
|
||||
);
|
||||
effectiveKey = resolved?.key ?? credentials.apiKey;
|
||||
if (resolved && credentials.providerSpecificData) {
|
||||
(credentials.providerSpecificData as Record<string, unknown>).selectedKeyId =
|
||||
resolved.keyId;
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
67
open-sse/utils/opencodeHeaders.ts
Normal file
67
open-sse/utils/opencodeHeaders.ts
Normal file
@@ -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<string, string>, 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<string, string>,
|
||||
clientHeaders: Record<string, string>,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
32
src/shared/constants/serviceKinds.ts
Normal file
32
src/shared/constants/serviceKinds.ts
Normal file
@@ -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",
|
||||
];
|
||||
@@ -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),
|
||||
|
||||
129
tests/unit/refactor-buildHeaders-default.test.ts
Normal file
129
tests/unit/refactor-buildHeaders-default.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
104
tests/unit/refactor-buildHeaders-opencode.test.ts
Normal file
104
tests/unit/refactor-buildHeaders-opencode.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
120
tests/unit/refactor-buildHeaders-preamble.test.ts
Normal file
120
tests/unit/refactor-buildHeaders-preamble.test.ts
Normal file
@@ -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<string, string>; 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;
|
||||
}
|
||||
});
|
||||
127
tests/unit/refactor-effortLevel.test.ts
Normal file
127
tests/unit/refactor-effortLevel.test.ts
Normal file
@@ -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" });
|
||||
});
|
||||
});
|
||||
227
tests/unit/refactor-opencodeHeaders.test.ts
Normal file
227
tests/unit/refactor-opencodeHeaders.test.ts
Normal file
@@ -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<string, string> {
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
105
tests/unit/refactor-registryFactory.test.ts
Normal file
105
tests/unit/refactor-registryFactory.test.ts
Normal file
@@ -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<typeof buildOpenAiCompatibleRegistryEntry>[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");
|
||||
});
|
||||
});
|
||||
93
tests/unit/refactor-resolveBaseUrl.test.ts
Normal file
93
tests/unit/refactor-resolveBaseUrl.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
72
tests/unit/refactor-urlSanitize.test.ts
Normal file
72
tests/unit/refactor-urlSanitize.test.ts
Normal file
@@ -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(""), "");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user