diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index cab4725a..eadef390 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -22,7 +22,13 @@ import { loadAdminProviders, } from "@/lib/admin/providers" import { getEnvFallback } from "@/lib/admin/settings" -import { PROVIDER_NAMES, PROVIDERS, type ProviderName } from "@/lib/providers" +import { + PROVIDER_NAMES, + PROVIDERS, + type ProviderDef, + type ProviderName, + publicDefaultUrl, +} from "@/lib/providers" import { isPrivateUrl, redirectGuardedFetch } from "@/lib/ssrf-protection" import { normalizeBaseUrl, ollamaApiUrl } from "@/lib/types/model-config" @@ -132,6 +138,24 @@ export function resolveBaseURL( return userBaseUrl || serverBaseUrl || undefined } +/** The provider record of a name that may not be one */ +function providerDef(name: string): ProviderDef | undefined { + return Object.hasOwn(PROVIDERS, name) + ? PROVIDERS[name as ProviderName] + : undefined +} + +/** + * A key and whose it is: the client's own, or the server's. "server" also + * when the variable turns out empty, which the credential check reports. + */ +interface ResolvedKey { + value?: string + source: "client" | "server" + /** The apiKeyEnv entry drawn, when there were several */ + selectedKeyEnv?: string +} + /** * Resolve API key from custom env var name or default env var. * Supports multiple API keys per provider via ai-models.json apiKeyEnv config. @@ -145,9 +169,9 @@ export function resolveBaseURL( */ function resolveApiKey( overrides: ClientOverrides | undefined, - defaultEnvVar: string, -): string | undefined { - if (overrides?.apiKey) return overrides.apiKey + defaultEnvVar: string | undefined, +): ResolvedKey { + if (overrides?.apiKey) return { value: overrides.apiKey, source: "client" } if (overrides?.apiKeyEnv) { // Handle array of env var names - randomly select one @@ -162,17 +186,21 @@ function resolveApiKey( validEnvVars[ Math.floor(Math.random() * validEnvVars.length) ] - console.log( - `[API Key Routing] Selected ${selectedEnvVar} from ${validEnvVars.length} available keys`, - ) - return process.env[selectedEnvVar] + return { + value: process.env[selectedEnvVar], + source: "server", + selectedKeyEnv: selectedEnvVar, + } } } else { - return process.env[overrides.apiKeyEnv] + return { value: process.env[overrides.apiKeyEnv], source: "server" } } } - return process.env[defaultEnvVar] + return { + value: defaultEnvVar ? process.env[defaultEnvVar] : undefined, + source: "server", + } } /** @@ -578,25 +606,250 @@ function bedrockRuntimeUrl(region: string): string { return `https://bedrock-runtime.${region}.${suffix}` } -/** - * Providers whose SDK has the official endpoint built in. The others are - * OpenAI-compatible APIs (or Anthropic) that are called at the record's - * defaultBaseUrl unless a base URL is configured. - */ -const SDK_KNOWS_ENDPOINT = new Set( - PROVIDER_NAMES.filter((p) => PROVIDERS[p].sdkKnowsEndpoint), -) +export type KeySource = "client" | "server" | "none" /** Where and how to call a provider, once credentials are resolved */ -interface Endpoint { +export interface Endpoint { apiKey?: string + /** Anthropic Bearer auth (ANTHROPIC_AUTH_TOKEN), server only */ + authToken?: string + /** Azure, the server's resource only */ + resourceName?: string + /** undefined: the SDK's built-in endpoint */ baseURL?: string + /** The URL came from the client or a server variable, not the record */ + configuredBaseURL: boolean + /** A client-chosen, untrusted URL: wrap fetch in redirectGuardedFetch */ + guarded: boolean + /** "server" is what usesServerCredentials answers true for */ + keySource: KeySource + /** The apiKeyEnv entry drawn (array case); getAIModel logs it */ + selectedKeyEnv?: string headers?: Record + /** Set by getAIModel from guarded; resolveEndpoint never fills it */ fetch?: typeof fetch - authToken?: string // Anthropic Bearer auth - resourceName?: string // Azure - // baseURL comes from the settings or env, not the provider's default - configuredBaseURL?: boolean +} + +/** + * Whose key a call uses and where it goes, by the provider's credential + * kind. Reads its arguments and process.env, draws one key with Math.random + * when apiKeyEnv is an array, and never throws, fetches or logs: getAIModel + * decides what a missing key or URL means, usesServerCredentials reads + * keySource. overrides.baseUrl must already be normalized (normalizeBaseUrl + * drops one pasted endpoint path per call, so it runs once, in the caller). + * Bedrock gets only keySource: bedrockModel() builds its SDK parameters. + */ +export function resolveEndpoint( + provider: ProviderName, + overrides?: ClientOverrides, +): Endpoint { + const def = PROVIDERS[provider] + const vars = envVarsOf(provider) + const clientUrl = overrides?.baseUrl || undefined + // Requests to a base URL the client chose must not follow redirects. A + // trusted URL is the server's own (the admin Test of an entry without + // one), not a user's + const guarded = !!clientUrl && !overrides?.trustedBaseUrl + + switch (def.credential) { + case "aws": { + // The client's own Bedrock API key or access key pair, else the + // server's (the admin panel's ADMIN_AWS_* keys or an IAM role) + const clientKeys = !!( + overrides?.apiKey || + (overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey) + ) + return { + configuredBaseURL: false, + guarded, + keySource: clientKeys ? "client" : "server", + } + } + case "vertexKey": { + // SECURITY: a client base URL only ever gets the client's key, so + // the server's key is never sent to a client-chosen host. A client + // key only goes to the client's URL or the official one. + const apiKey = clientUrl + ? overrides?.vertexApiKey || undefined + : overrides?.vertexApiKey || process.env[vars.apiKey as string] + const baseURL = resolveBaseURL( + overrides?.vertexApiKey, + clientUrl, + process.env[vars.baseUrl as string], + ) + return { + apiKey, + baseURL, + configuredBaseURL: !!baseURL, + guarded, + keySource: overrides?.vertexApiKey ? "client" : "server", + } + } + case "none": + // The site's own function (EdgeOne), an OpenAI-compatible API + // without a key. The route passes the site's absolute URL and the + // cookies and access code that authenticate the call. + return { + apiKey: "edgeone", + baseURL: clientUrl || "/api/edgeai", + headers: overrides?.headers, + configuredBaseURL: !!clientUrl, + guarded, + keySource: "none", + } + case "optionalKey": { + // SECURITY: with a client base URL only the client's key is used, + // never the server's (Ollama). A user's key never goes to the + // server's URL: without a URL of their own it goes to the + // provider's cloud. The server's key goes to the server's URL + // variable (or a server model's own), else to the SDK's local + // default. + const key: ResolvedKey = clientUrl + ? { value: overrides?.apiKey || undefined, source: "client" } + : resolveApiKey(overrides, vars.apiKey) + const baseURL = + clientUrl || + (overrides?.apiKey + ? def.defaultBaseUrl + : resolveBaseUrlEnv(overrides, vars.baseUrl as string)) + // Only a server key costs money; a keyless local server or the + // client's own server does not. Spelled out because resolveApiKey + // says "server" for an empty variable too. + const serverKey = + overrides?.apiKeyEnv || process.env[vars.apiKey as string] + const keySource: KeySource = overrides?.apiKey + ? "client" + : !clientUrl && serverKey + ? "server" + : "none" + return { + apiKey: key.value, + baseURL, + configuredBaseURL: !!baseURL, + guarded, + keySource, + selectedKeyEnv: key.selectedKeyEnv, + } + } + case "apiKey": { + // An API key and a base URL from _API_KEY / + // _BASE_URL, or a server model's apiKeyEnv / baseUrlEnv + const key = resolveApiKey(overrides, vars.apiKey) + const configuredBaseURL = resolveBaseURL( + overrides?.apiKey, + clientUrl, + resolveBaseUrlEnv(overrides, vars.baseUrl as string), + ) + // Without a configured URL an SDK that knows its endpoint gets + // none, except OpenAI with a user's key: the SDK would read the + // server's OPENAI_BASE_URL, so the official endpoint is named. + // The others get the record's https default (a local default, + // SGLang's 127.0.0.1, only fills the settings form). + const baseURL = + configuredBaseURL || + (def.sdkKnowsEndpoint && + !(def.sdk === "openai" && overrides?.apiKey) + ? undefined + : publicDefaultUrl(provider)) + return { + apiKey: key.value, + baseURL, + configuredBaseURL: !!configuredBaseURL, + guarded, + keySource: key.source, + selectedKeyEnv: key.selectedKeyEnv, + // Bearer auth when there is no API key (Anthropic) + authToken: + def.altKeyEnv && !key.value + ? process.env[def.altKeyEnv] + : undefined, + // Only the server's own resource; a client key needs its URL + // (Azure) + resourceName: + def.resourceNameEnv && !overrides?.apiKey + ? process.env[def.resourceNameEnv] + : undefined, + } + } + } +} + +/** + * A Bedrock model from the client's credentials if it sent any (a Bedrock + * API key, or an access key pair), else the admin panel's ADMIN_AWS_* keys, + * else the default AWS credential chain (an IAM role, the AWS_* variables). + */ +function bedrockModel( + modelId: string, + overrides: ClientOverrides | undefined, +): LanguageModel { + const clientApiKey = overrides?.apiKey + const hasClientCredentials = + overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey + // Keys from the admin panel. The ADMIN_ names keep them out of the + // default AWS credential chain, which other clients such as the + // DynamoDB quota manager use with their own credentials. + const adminAccessKeyId = process.env.ADMIN_AWS_ACCESS_KEY_ID + const adminSecretAccessKey = process.env.ADMIN_AWS_SECRET_ACCESS_KEY + // The region becomes part of the endpoint's host name, so a + // request's region must be a region name, or it could send the + // server's credentials to another host + if ( + overrides?.awsRegion && + !/^[a-z]{2,4}(-[a-z]+)+-\d{1,2}$/.test(overrides.awsRegion) + ) { + throw Object.assign( + new Error(`Invalid AWS region "${overrides.awsRegion}"`), + { statusCode: 400 }, + ) + } + const bedrockRegion = + overrides?.awsRegion || + process.env.ADMIN_AWS_REGION || + process.env.AWS_REGION || + "us-west-2" + + const bedrockProvider = clientApiKey + ? createAmazonBedrock({ + region: bedrockRegion, + apiKey: clientApiKey, + // The SDK falls back to signing with AWS keys when it + // drops a key (one of spaces): never the server's + credentialProvider: async () => { + throw new Error("The Bedrock API key is empty") + }, + // Without a baseURL it reads the server's + // AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL + baseURL: bedrockRuntimeUrl(bedrockRegion), + }) + : hasClientCredentials + ? createAmazonBedrock({ + region: bedrockRegion, + accessKeyId: overrides.awsAccessKeyId as string, + secretAccessKey: overrides.awsSecretAccessKey as string, + ...(overrides?.awsSessionToken && { + sessionToken: overrides.awsSessionToken, + }), + // Without an apiKey the SDK reads the server's + // AWS_BEARER_TOKEN_BEDROCK, which wins over the keys + apiKey: "", + // Without a baseURL it reads the server's + // AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL + baseURL: bedrockRuntimeUrl(bedrockRegion), + }) + : adminAccessKeyId && adminSecretAccessKey + ? createAmazonBedrock({ + region: bedrockRegion, + accessKeyId: adminAccessKeyId, + secretAccessKey: adminSecretAccessKey, + // The keys the admin panel's Test button checked + apiKey: "", + }) + : createAmazonBedrock({ + region: bedrockRegion, + credentialProvider: fromNodeProviderChain(), + }) + return bedrockProvider(modelId) } /** @@ -623,7 +876,10 @@ function compatibleModel( }) } -/** Create the model for a provider. Credentials are already resolved. */ +/** + * Create the model for a provider with its endpoint resolved, by the SDK + * the record names. Bedrock never comes here: getAIModel calls bedrockModel. + */ function createModel( provider: ProviderName, modelId: string, @@ -634,7 +890,7 @@ function createModel( ...(e.baseURL && { baseURL: e.baseURL }), ...(e.fetch && { fetch: e.fetch }), } - switch (provider) { + switch (PROVIDERS[provider].sdk) { case "openai": { const openaiProvider = createOpenAI(opts) // A configured base URL is usually a proxy that only has Chat @@ -679,8 +935,6 @@ function createModel( // Without a key or URL the SDK uses Vercel's endpoint and OIDC return createGateway(opts)(modelId) case "deepseek": - case "kimi": - case "mimo": // Kimi and MiMo return reasoning_content like DeepSeek and need it // passed back in multi-turn tool calls (MiMo answers 400 otherwise) return createDeepSeek(opts)(modelId) @@ -710,10 +964,21 @@ function createModel( })(modelId) : compatibleModel(provider, modelId, { ...e, baseURL }) } - default: - // siliconflow, sglang, modelscope, glm, qwen, qiniu, novita, - // atlascloud, edgeone + case "vertex": + // Express Mode: an API key; the URL only when one is configured + return createVertex(opts)(modelId) + case "ollama": + return createOllama({ + ...(e.baseURL && { baseURL: ollamaApiUrl(e.baseURL) }), + ...(e.apiKey && { + headers: { Authorization: `Bearer ${e.apiKey}` }, + }), + ...(e.fetch && { fetch: e.fetch }), + })(modelId) + case "openai-compatible": return compatibleModel(provider, modelId, e) + case "bedrock": + throw new Error("Bedrock models come from bedrockModel()") } } @@ -734,18 +999,27 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // If a custom baseUrl is provided, an API key MUST also be provided. // This prevents attackers from redirecting server API keys to malicious endpoints. - // Exception: EdgeOne doesn't require API keys. - // Ollama is exempt only when no server OLLAMA_API_KEY is configured; - // when it IS configured, the outer guard also enforces client apiKey for custom baseUrls. + // Exceptions, by the provider's credential kind: a provider without keys + // (EdgeOne); a Vertex key; an optional key (Ollama) only when the server + // has no key of that provider, otherwise the client must send its own. // A trusted URL is the server's own (the admin Test of an entry without - // one), not a user's + // one), not a user's. An unknown provider name has no kind and is + // refused here like any other, and named below. + const requested = overrides?.provider + ? providerDef(overrides.provider) + : undefined if ( overrides?.baseUrl && !overrides?.trustedBaseUrl && !overrides?.apiKey && - !(overrides?.provider === "vertexai" && overrides?.vertexApiKey) && - overrides?.provider !== "edgeone" && - !(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY) + !(requested?.credential === "vertexKey" && overrides?.vertexApiKey) && + requested?.credential !== "none" && + !( + requested?.credential === "optionalKey" && + !process.env[ + envVarsOf(overrides.provider as ProviderName).apiKey as string + ] + ) ) { throw new Error( `API key is required when using a custom base URL. ` + @@ -833,222 +1107,51 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`) - // Requests to a base URL the client chose must not follow redirects - const guardedFetch = - overrides?.baseUrl && !overrides.trustedBaseUrl - ? redirectGuardedFetch() - : undefined // Build provider-specific options from environment variables let providerOptions = buildProviderOptions(provider, modelId) let model: LanguageModel + const def = PROVIDERS[provider] - switch (provider) { - case "bedrock": { - // Use client-provided credentials if available (a Bedrock API - // key, or an access key pair), otherwise fall back to IAM/env vars - const clientApiKey = overrides?.apiKey - const hasClientCredentials = - overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey - // Keys from the admin panel. The ADMIN_ names keep them out of the - // default AWS credential chain, which other clients such as the - // DynamoDB quota manager use with their own credentials. - const adminAccessKeyId = process.env.ADMIN_AWS_ACCESS_KEY_ID - const adminSecretAccessKey = process.env.ADMIN_AWS_SECRET_ACCESS_KEY - // The region becomes part of the endpoint's host name, so a - // request's region must be a region name, or it could send the - // server's credentials to another host - if ( - overrides?.awsRegion && - !/^[a-z]{2,4}(-[a-z]+)+-\d{1,2}$/.test(overrides.awsRegion) - ) { - throw Object.assign( - new Error(`Invalid AWS region "${overrides.awsRegion}"`), - { statusCode: 400 }, - ) + if (def.sdk === "bedrock") { + model = bedrockModel(modelId, overrides) + // Add Anthropic beta options if using Claude models via Bedrock + if (modelId.includes("anthropic.claude")) { + // Deep merge to preserve both anthropicBeta and reasoningConfig + providerOptions = { + bedrock: { + ...BEDROCK_ANTHROPIC_BETA.bedrock, + ...(providerOptions?.bedrock || {}), + }, } - const bedrockRegion = - overrides?.awsRegion || - process.env.ADMIN_AWS_REGION || - process.env.AWS_REGION || - "us-west-2" - - const bedrockProvider = clientApiKey - ? createAmazonBedrock({ - region: bedrockRegion, - apiKey: clientApiKey, - // The SDK falls back to signing with AWS keys when it - // drops a key (one of spaces): never the server's - credentialProvider: async () => { - throw new Error("The Bedrock API key is empty") - }, - // Without a baseURL it reads the server's - // AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL - baseURL: bedrockRuntimeUrl(bedrockRegion), - }) - : hasClientCredentials - ? createAmazonBedrock({ - region: bedrockRegion, - accessKeyId: overrides.awsAccessKeyId as string, - secretAccessKey: overrides.awsSecretAccessKey as string, - ...(overrides?.awsSessionToken && { - sessionToken: overrides.awsSessionToken, - }), - // Without an apiKey the SDK reads the server's - // AWS_BEARER_TOKEN_BEDROCK, which wins over the keys - apiKey: "", - // Without a baseURL it reads the server's - // AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL - baseURL: bedrockRuntimeUrl(bedrockRegion), - }) - : adminAccessKeyId && adminSecretAccessKey - ? createAmazonBedrock({ - region: bedrockRegion, - accessKeyId: adminAccessKeyId, - secretAccessKey: adminSecretAccessKey, - // The keys the admin panel's Test button checked - apiKey: "", - }) - : createAmazonBedrock({ - region: bedrockRegion, - credentialProvider: fromNodeProviderChain(), - }) - model = bedrockProvider(modelId) - // Add Anthropic beta options if using Claude models via Bedrock - if (modelId.includes("anthropic.claude")) { - // Deep merge to preserve both anthropicBeta and reasoningConfig - providerOptions = { - bedrock: { - ...BEDROCK_ANTHROPIC_BETA.bedrock, - ...(providerOptions?.bedrock || {}), - }, - } - } - break } - - case "vertexai": { - // Express Mode: Use API key for authentication - // SECURITY: a client base URL only ever gets the client's key, so the - // server's GOOGLE_VERTEX_API_KEY is never sent to a client-chosen host - const vertexApiKey = overrides?.baseUrl - ? overrides.vertexApiKey - : overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY - - if (!vertexApiKey) { - throw new Error( - "Vertex AI requires an API key for Express Mode. " + - "Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.", - ) - } - - // Support custom base URL from env or client override. - // A client key only goes to the client's URL or the official one. - const baseURL = resolveBaseURL( - overrides?.vertexApiKey, - overrides?.baseUrl, - process.env.GOOGLE_VERTEX_BASE_URL, + } else { + const e = resolveEndpoint(provider, overrides) + if (e.selectedKeyEnv) { + console.log(`[API Key Routing] Selected ${e.selectedKeyEnv}`) + } + if (def.credential === "vertexKey" && !e.apiKey) { + throw new Error( + "Vertex AI requires an API key for Express Mode. " + + "Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.", ) - model = createVertex({ - apiKey: vertexApiKey, - ...(baseURL && { baseURL }), - ...(guardedFetch && { fetch: guardedFetch }), - })(modelId) - break } - - case "ollama": { - // SECURITY: When client provides a custom base URL, only use - // client-provided API key. Never fall back to server OLLAMA_API_KEY - // to prevent leaking server credentials to user-controlled endpoints. - const apiKey = overrides?.baseUrl - ? overrides?.apiKey || undefined - : resolveApiKey(overrides, "OLLAMA_API_KEY") - // Like other providers, a user's key never goes to the server's - // base URL: without a URL of their own it goes to Ollama Cloud. - // The server's key goes to OLLAMA_BASE_URL (or a server model's - // own variable), else to the SDK's local default. - const baseURL = - overrides?.baseUrl || - (overrides?.apiKey - ? PROVIDERS.ollama.defaultBaseUrl - : resolveBaseUrlEnv(overrides, "OLLAMA_BASE_URL")) - model = createOllama({ - ...(baseURL && { baseURL: ollamaApiUrl(baseURL) }), - ...(apiKey && { - headers: { Authorization: `Bearer ${apiKey}` }, - }), - ...(guardedFetch && { fetch: guardedFetch }), - })(modelId) - break - } - - case "edgeone": - // EdgeOne Pages Edge AI, an OpenAI-compatible API without a key. - // The SDK appends /chat/completions to the base URL. Cookies - // (eo_token, eo_time) and the access code authenticate the call. - model = compatibleModel(provider, modelId, { - apiKey: "edgeone", - baseURL: overrides?.baseUrl || "/api/edgeai", - headers: overrides?.headers, - fetch: guardedFetch, - }) - break - - default: { - // Every other provider takes an API key and a base URL from - // _API_KEY / _BASE_URL (or a server model's apiKeyEnv) - const apiKey = resolveApiKey( - overrides, - PROVIDER_ENV_VARS[provider] as string, + // A key-only provider needs a URL unless its SDK knows the endpoint; + // with a user's key an SDK that builds the URL from the server's + // resource name (Azure) would read AZURE_RESOURCE_NAME, so it needs one too + if ( + def.credential === "apiKey" && + !e.baseURL && + (!def.sdkKnowsEndpoint || + (def.resourceNameEnv && overrides?.apiKey)) + ) { + throw new Error( + `${def.label} needs a base URL. Add it in the model settings.`, ) - const baseUrlEnv = envVarsOf(provider).baseUrl as string - // A local default (SGLang's 127.0.0.1) only fills the settings - // form; the server must not call its own machine for it. With a - // user's key the OpenAI SDK would read the server's - // OPENAI_BASE_URL, so name the official endpoint. - const defaultUrl = PROVIDERS[provider].defaultBaseUrl - const publicDefault = defaultUrl?.startsWith("https://") - ? defaultUrl - : undefined - const configuredBaseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - resolveBaseUrlEnv(overrides, baseUrlEnv), - ) - const baseURL = - configuredBaseURL || - (SDK_KNOWS_ENDPOINT.has(provider) && - !(provider === "openai" && overrides?.apiKey) - ? undefined - : publicDefault) - // With a user's Azure key the SDK would read the server's - // AZURE_RESOURCE_NAME - if ( - !baseURL && - (!SDK_KNOWS_ENDPOINT.has(provider) || - (provider === "azure" && overrides?.apiKey)) - ) { - throw new Error( - `${PROVIDERS[provider].label} needs a base URL. Add it in the model settings.`, - ) - } - model = createModel(provider, modelId, { - apiKey, - baseURL, - configuredBaseURL: !!configuredBaseURL, - fetch: guardedFetch, - // Bearer auth for Anthropic when there is no API key - authToken: - provider === "anthropic" && !apiKey - ? process.env.ANTHROPIC_AUTH_TOKEN - : undefined, - // Only the server's own resource; a client key needs its URL - resourceName: - provider === "azure" && !overrides?.apiKey - ? process.env.AZURE_RESOURCE_NAME - : undefined, - }) } + model = createModel(provider, modelId, { + ...e, + fetch: e.guarded ? redirectGuardedFetch() : undefined, + }) } return { model, providerOptions, modelId, provider } diff --git a/tests/unit/provider-legacy.test.ts b/tests/unit/provider-legacy.test.ts index 63c96d00..1d749225 100644 --- a/tests/unit/provider-legacy.test.ts +++ b/tests/unit/provider-legacy.test.ts @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest" import { type ClientOverrides, + resolveEndpoint, SINGLE_SYSTEM_PROVIDERS, usesServerCredentials, usesServerEndpoint, @@ -179,6 +180,22 @@ describe("usesServerCredentials against its pre-refactor body", () => { `${provider} ${JSON.stringify(overrides)} desktop=${desktop} ollamaKey=${ollamaKey}: ${actual} instead of ${expected}`, ) } + // resolveEndpoint's keySource is + // what the function will read; + // the desktop rule stays outside + const fromEndpoint = + !desktop && + resolveEndpoint(provider, { + ...overrides, + baseUrl: normalizeBaseUrl( + baseUrl ?? "", + ), + }).keySource === "server" + if (fromEndpoint !== expected) { + expect.fail( + `resolveEndpoint ${provider} ${JSON.stringify(overrides)} ollamaKey=${ollamaKey}: ${fromEndpoint} instead of ${expected}`, + ) + } count++ } }