diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index eadef390..b8f27dd1 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -1203,10 +1203,11 @@ export function getServerProvider(): ProviderName | null { /** * Whether a call made with the caller's own settings runs on an endpoint of - * the deployment: its EdgeOne function, the server's keyless Ollama, or an + * the deployment: the site's own function (EdgeOne, credential "none"), the + * server's keyless local provider (Ollama, credential "optionalKey"), or an * address on the server's network (which ignores a dummy key header). - * Bedrock and EdgeOne never use a client base URL. Never in the desktop - * app, where every endpoint is the user's. clientBaseUrl: normalized. + * Bedrock never uses a client base URL. Never in the desktop app, where + * every endpoint is the user's. clientBaseUrl: normalized. */ export async function usesServerEndpoint( provider: ProviderName | null | undefined, @@ -1214,10 +1215,13 @@ export async function usesServerEndpoint( apiKey: string | null | undefined, ): Promise { if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false - if (provider === "edgeone") return true - if (provider === "ollama" && !clientBaseUrl && !apiKey) return true + const def = provider ? providerDef(provider) : undefined + if (def?.credential === "none") return true + if (def?.credential === "optionalKey" && !clientBaseUrl && !apiKey) { + return true + } return ( - provider !== "bedrock" && + def?.sdk !== "bedrock" && !!clientBaseUrl && (await isPrivateUrl(clientBaseUrl)) ) @@ -1225,8 +1229,8 @@ export async function usesServerEndpoint( /** * Whether the call is paid for by the server's own credentials (env keys or - * IAM role) rather than credentials sent with the request. Mirrors which key - * each branch of getAIModel ends up using. + * IAM role) rather than credentials sent with the request: the key + * resolveEndpoint picks for getAIModel is the server's. */ export function usesServerCredentials( provider: ProviderName, @@ -1236,28 +1240,10 @@ export function usesServerCredentials( if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false // Cleaned like getAIModel does: "/" means no base URL const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "") - switch (provider) { - case "bedrock": - return !( - overrides?.apiKey || - (overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey) - ) - case "vertexai": - return !overrides?.vertexApiKey - case "edgeone": - // The platform's own endpoint, no key involved - return false - case "ollama": - // Only a server key costs money; a keyless local server or the - // client's own server does not - return ( - !baseUrl && - !overrides?.apiKey && - !!(overrides?.apiKeyEnv || process.env.OLLAMA_API_KEY) - ) - default: - return !overrides?.apiKey - } + return ( + resolveEndpoint(provider, { ...overrides, baseUrl }).keySource === + "server" + ) } /** diff --git a/lib/provider-models.ts b/lib/provider-models.ts index 224592a2..7ad51800 100644 --- a/lib/provider-models.ts +++ b/lib/provider-models.ts @@ -1,12 +1,8 @@ import { createGateway } from "ai" import { getModelInfo } from "@/lib/model-catalog" +import { PROVIDERS, type ProviderName, publicDefaultUrl } from "@/lib/providers" import { readLimitedBody } from "@/lib/read-limited-body" -import { - normalizeBaseUrl, - ollamaApiUrl, - PROVIDER_INFO, - type ProviderName, -} from "@/lib/types/model-config" +import { normalizeBaseUrl, ollamaApiUrl } from "@/lib/types/model-config" /** A model a provider offers. tools is false when it cannot call tools. */ export interface ListedModel { @@ -17,10 +13,7 @@ export interface ListedModel { const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models" export function canListModels(provider: ProviderName): boolean { - return ( - Object.hasOwn(PROVIDER_INFO, provider) && - !!PROVIDER_INFO[provider].modelList - ) + return Object.hasOwn(PROVIDERS, provider) && !!PROVIDERS[provider].modelList } // Models in OpenAI-style lists that are not for chat @@ -131,16 +124,15 @@ async function getJson( /** * Where to list from without the user's base URL: where chat goes then. For - * Ollama without a key that is the server's Ollama, else the SDK's local - * default; a local default in PROVIDER_INFO (SGLang's) only fills the - * settings form. + * Ollama (an optional key) without a key that is the server's Ollama, else + * the SDK's local default; a local default in the record (SGLang's) only + * fills the settings form. */ function listFallbackUrl(provider: ProviderName, apiKey?: string): string { - if (provider === "ollama" && !apiKey) { + if (PROVIDERS[provider].credential === "optionalKey" && !apiKey) { return process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434/api" } - const url = PROVIDER_INFO[provider].defaultBaseUrl - return url?.startsWith("https://") ? url : "" + return publicDefaultUrl(provider) ?? "" } /** @@ -167,7 +159,7 @@ export async function listProviderModels( baseUrl && !/^https:\/\/aihubmix\.com(\/v1)?$/.test(base) ? "openai" - : PROVIDER_INFO[provider].modelList + : PROVIDERS[provider].modelList switch (style) { case "anthropic": { @@ -242,7 +234,7 @@ export async function listProviderModels( default: { if (!base) { throw new ModelListError( - `${PROVIDER_INFO[provider].label} needs a base URL to list its models.`, + `${PROVIDERS[provider].label} needs a base URL to list its models.`, ) } const data = await getJson(`${base}/models`, bearer, fetchFn) diff --git a/tests/unit/provider-legacy.test.ts b/tests/unit/provider-legacy.test.ts deleted file mode 100644 index 1d749225..00000000 --- a/tests/unit/provider-legacy.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Differential tests for the provider refactor. The two quota functions' - * bodies as they were before the refactor are copied here; every equivalence - * class of their inputs is enumerated and the live functions must agree. - * These copies go away once the functions read the provider record (step 4c); - * the snapshot in provider-matrix.test.ts keeps their answers after that. - * - * The last block checks that every provider-keyed table names only known - * providers, so a typo in one table cannot hide behind a Partial type. - */ -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest" -import { - type ClientOverrides, - resolveEndpoint, - SINGLE_SYSTEM_PROVIDERS, - usesServerCredentials, - usesServerEndpoint, -} from "@/lib/ai-providers" -import { - OPTIONS_OPEN, - POPULAR_PROVIDERS, - PROVIDER_GROUPS, - PROVIDER_NOTE_KEYS, -} from "@/lib/provider-setup" -import { - FIXED_CRED_PROVIDERS, - normalizeBaseUrl, - PROVIDER_INFO, - PROVIDER_LOGO_MAP, - type ProviderName, - SUGGESTED_MODELS, -} from "@/lib/types/model-config" - -vi.mock("@/lib/admin/settings", () => ({ - loadSettings: () => ({}), - getEnvFallback: (key: string) => process.env[key] ?? null, -})) - -const PRIVATE_HOSTS = new Set(["10.0.0.5", "localhost"]) -const isPrivate = (url: string) => PRIVATE_HOSTS.has(new URL(url).hostname) - -vi.mock("@/lib/ssrf-protection", async (importOriginal) => { - const actual = - await importOriginal() - return { - ...actual, - isPrivateUrl: async (url: string) => - new Set(["10.0.0.5", "localhost"]).has(new URL(url).hostname), - } -}) - -const PROVIDERS = Object.keys(PROVIDER_INFO) as ProviderName[] - -// lib/ai-providers.ts usesServerCredentials, copied before the refactor -function legacyUsesServerCredentials( - provider: ProviderName, - overrides?: ClientOverrides, -): boolean { - // The desktop app's local server only has the user's own keys (its .env) - if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false - // Cleaned like getAIModel does: "/" means no base URL - const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "") - switch (provider) { - case "bedrock": - return !( - overrides?.apiKey || - (overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey) - ) - case "vertexai": - return !overrides?.vertexApiKey - case "edgeone": - // The platform's own endpoint, no key involved - return false - case "ollama": - // Only a server key costs money; a keyless local server or the - // client's own server does not - return ( - !baseUrl && - !overrides?.apiKey && - !!(overrides?.apiKeyEnv || process.env.OLLAMA_API_KEY) - ) - default: - return !overrides?.apiKey - } -} - -// lib/ai-providers.ts usesServerEndpoint, copied before the refactor, with -// isPrivateUrl replaced by a synchronous host name check -function legacyUsesServerEndpoint( - provider: ProviderName | null | undefined, - clientBaseUrl: string, - apiKey: string | null | undefined, -): boolean { - if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false - if (provider === "edgeone") return true - if (provider === "ollama" && !clientBaseUrl && !apiKey) return true - return provider !== "bedrock" && !!clientBaseUrl && isPrivate(clientBaseUrl) -} - -const saved: Record = {} -const VARS = [ - "NEXT_AI_DRAWIO_DESKTOP", - "OLLAMA_API_KEY", - "SET_VAR", - "EMPTY_VAR", -] - -beforeAll(() => { - for (const name of VARS) { - saved[name] = process.env[name] - delete process.env[name] - } - process.env.SET_VAR = "a-value" -}) - -afterAll(() => { - for (const name of VARS) { - if (saved[name] === undefined) delete process.env[name] - else process.env[name] = saved[name] - } -}) - -function setFlag(name: string, on: boolean) { - if (on) process.env[name] = "1" - else delete process.env[name] -} - -describe("usesServerCredentials against its pre-refactor body", () => { - it("agrees on all 30720 input classes", () => { - const apiKeys = [undefined, "k"] - const baseUrls = [ - undefined, - "/", - "https://u.example", - "https://u.example/v1/chat/completions/messages", - ] - const awsIds = [undefined, "id"] - const awsSecrets = [undefined, "s"] - const vertexKeys = [undefined, "v"] - const apiKeyEnvs: (string | string[] | undefined)[] = [ - undefined, - "SET_VAR", - "EMPTY_VAR", - ["EMPTY_VAR", "SET_VAR"], - ["EMPTY_VAR", "EMPTY_VAR_2"], - ] - let count = 0 - for (const desktop of [false, true]) { - setFlag("NEXT_AI_DRAWIO_DESKTOP", desktop) - for (const ollamaKey of [false, true]) { - if (ollamaKey) process.env.OLLAMA_API_KEY = "server-ollama-key" - else delete process.env.OLLAMA_API_KEY - for (const provider of PROVIDERS) - for (const apiKey of apiKeys) - for (const baseUrl of baseUrls) - for (const awsAccessKeyId of awsIds) - for (const awsSecretAccessKey of awsSecrets) - for (const vertexApiKey of vertexKeys) - for (const apiKeyEnv of apiKeyEnvs) { - const overrides: ClientOverrides = { - apiKey, - baseUrl, - awsAccessKeyId, - awsSecretAccessKey, - vertexApiKey, - apiKeyEnv, - } - const expected = - legacyUsesServerCredentials( - provider, - overrides, - ) - const actual = - usesServerCredentials( - provider, - overrides, - ) - if (actual !== expected) { - expect.fail( - `${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++ - } - } - } - expect(count).toBe(30720) - }) -}) - -describe("usesServerEndpoint against its pre-refactor body", () => { - it("agrees on all 288 input classes", async () => { - let count = 0 - for (const desktop of [false, true]) { - setFlag("NEXT_AI_DRAWIO_DESKTOP", desktop) - for (const provider of PROVIDERS) { - for (const clientBaseUrl of [ - "", - "https://u.example", - "http://10.0.0.5:8000", - ]) { - for (const apiKey of [undefined, "k"]) { - const expected = legacyUsesServerEndpoint( - provider, - clientBaseUrl, - apiKey, - ) - const actual = await usesServerEndpoint( - provider, - clientBaseUrl, - apiKey, - ) - if (actual !== expected) { - expect.fail( - `${provider} url=${clientBaseUrl} key=${apiKey} desktop=${desktop}: ${actual} instead of ${expected}`, - ) - } - count++ - } - } - } - } - expect(count).toBe(288) - }) -}) - -describe("provider-keyed tables", () => { - const names = new Set(PROVIDERS) - const onlyKnown = (list: Iterable, table: string) => { - const items = [...list] - for (const p of items) { - if (!names.has(p)) expect.fail(`${table} names unknown "${p}"`) - } - expect(new Set(items).size, `${table} has duplicates`).toBe( - items.length, - ) - } - - it("name only known providers, each once", () => { - onlyKnown(SINGLE_SYSTEM_PROVIDERS, "SINGLE_SYSTEM_PROVIDERS") - onlyKnown(FIXED_CRED_PROVIDERS, "FIXED_CRED_PROVIDERS") - onlyKnown(Object.keys(PROVIDER_LOGO_MAP), "PROVIDER_LOGO_MAP") - onlyKnown(Object.keys(SUGGESTED_MODELS), "SUGGESTED_MODELS") - onlyKnown(POPULAR_PROVIDERS, "POPULAR_PROVIDERS") - onlyKnown(Object.keys(PROVIDER_NOTE_KEYS), "PROVIDER_NOTE_KEYS") - onlyKnown(OPTIONS_OPEN, "OPTIONS_OPEN") - const grouped = PROVIDER_GROUPS.flatMap((g) => g.providers) - onlyKnown(grouped, "PROVIDER_GROUPS") - expect(grouped.length).toBe(PROVIDERS.length) - }) -})