diff --git a/lib/admin/providers.ts b/lib/admin/providers.ts index f1e87273..c9fbae93 100644 --- a/lib/admin/providers.ts +++ b/lib/admin/providers.ts @@ -1,14 +1,11 @@ import { z } from "zod" +import { PROVIDERS, type ProviderName } from "@/lib/providers" import { ProviderNameSchema, type ServerModelsConfig, slugify, } from "@/lib/server-model-config" -import { - FIXED_CRED_PROVIDERS, - PROVIDER_INFO, - type ProviderName, -} from "@/lib/types/model-config" +import { FIXED_CRED_PROVIDERS, PROVIDER_INFO } from "@/lib/types/model-config" import { type MaskedSecret, maskSecret } from "./auth" import { loadSettings } from "./settings" @@ -80,20 +77,19 @@ const SECRET_FIELDS = [ "vertexApiKey", ] as const -// ADMIN_-prefixed env var names for instance `index` (0-based) of a provider -function credEnvNames( +// ADMIN_-prefixed env var names for instance `index` (0-based) of a +// provider. None for providers whose credentials live in fixed variables +// (Bedrock, Vertex AI, Ollama) or that have none (EdgeOne). +export function credEnvNames( provider: ProviderName, index: number, ): { key?: string; url?: string } { - if (FIXED_CRED_PROVIDERS.includes(provider) || provider === "edgeone") { - return {} - } - const prefix = - provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase() + const { fixedCredentials, envPrefix } = PROVIDERS[provider] + if (fixedCredentials || envPrefix === null) return {} const suffix = index === 0 ? "" : `_${index + 1}` return { - key: `ADMIN_${prefix}_API_KEY${suffix}`, - url: `ADMIN_${prefix}_BASE_URL${suffix}`, + key: `ADMIN_${envPrefix}_API_KEY${suffix}`, + url: `ADMIN_${envPrefix}_BASE_URL${suffix}`, } } diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index d3aefd35..cab4725a 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -438,56 +438,112 @@ function buildProviderOptions( return Object.keys(options).length > 0 ? options : undefined } +/** + * The names of a provider's server key and base URL variables, from the + * record: _API_KEY (or the record's own keyEnv) and _BASE_URL. + * Empty for Bedrock and EdgeOne. Always the spelling; whether the server + * must have the key is serverCredentialError's question. + */ +export function envVarsOf(provider: ProviderName): { + apiKey?: string + baseUrl?: string +} { + const { envPrefix, keyEnv } = PROVIDERS[provider] + if (envPrefix === null) return {} + return { + apiKey: keyEnv ?? `${envPrefix}_API_KEY`, + baseUrl: `${envPrefix}_BASE_URL`, + } +} + // Map of provider to required environment variable. null where the server // needs no key of this kind: Bedrock (the AWS SDK uses an IAM role or the // AWS_* variables), local Ollama, EdgeOne (the site's own function) const PROVIDER_ENV_VARS = Object.fromEntries( PROVIDER_NAMES.map((p) => { - const { credential, envPrefix, keyEnv } = PROVIDERS[p] + const { credential } = PROVIDERS[p] const required = credential === "apiKey" || credential === "vertexKey" - return [p, required ? (keyEnv ?? `${envPrefix}_API_KEY`) : null] + return [p, required ? (envVarsOf(p).apiKey ?? null) : null] }), ) as Record +/** + * Why the server lacks this provider's credentials, or null when it has + * them. detectProvider asks for null; validateProviderCredentials throws + * the text. In order: an array apiKeyEnv passes when any of its variables + * has a value, and nothing else is checked; without a custom variable a + * provider with an altKeyEnv (Anthropic's ANTHROPIC_AUTH_TOKEN) accepts + * either variable; a custom variable without a value fails, with no fall + * back to the default one; a provider with a resourceNameEnv (Azure) then + * needs its base URL variable, the server model's own URL variable or the + * resource name. Providers with no required key variable pass. + */ +function serverCredentialError( + provider: ProviderName, + customApiKeyEnv?: string | string[], + customBaseUrlEnv?: string, +): string | null { + if (Array.isArray(customApiKeyEnv)) { + const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar]) + if (!hasAnyKey) { + return ( + `At least one of [${customApiKeyEnv.join(", ")}] environment variables is required for ${provider} provider. ` + + `Please set at least one in your .env.local file.` + ) + } + return null + } + + const { altKeyEnv, resourceNameEnv } = PROVIDERS[provider] + const keyEnv = PROVIDER_ENV_VARS[provider] + if (altKeyEnv && !customApiKeyEnv) { + const hasCredential = !!( + (keyEnv && process.env[keyEnv]) || + process.env[altKeyEnv] + ) + if (!hasCredential) { + return ( + `Either ${keyEnv} or ${altKeyEnv} environment variable is required for ${provider} provider. ` + + `Please set one in your .env.local file.` + ) + } + } else { + const requiredVar = customApiKeyEnv || keyEnv + if (requiredVar && !process.env[requiredVar]) { + return ( + `${requiredVar} environment variable is required for ${provider} provider. ` + + `Please set it in your .env.local file.` + ) + } + } + + // The SDK builds the URL from the resource name when there is no URL + if (resourceNameEnv) { + const baseUrlEnv = envVarsOf(provider).baseUrl as string + const hasBaseUrl = + !!process.env[baseUrlEnv] || + !!(customBaseUrlEnv && process.env[customBaseUrlEnv]) + const hasResourceName = !!process.env[resourceNameEnv] + if (!hasBaseUrl && !hasResourceName) { + return ( + `Azure requires either ${baseUrlEnv} or ${resourceNameEnv} to be set. ` + + `Please set one in your .env.local file.` + ) + } + } + return null +} + /** * Auto-detect provider based on available API keys * Returns the provider if exactly one is configured, otherwise null */ function detectProvider(): ProviderName | null { - const configuredProviders: ProviderName[] = [] - - for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) { - if (envVar === null) { - // Skip ollama - it doesn't require credentials - continue - } - // Anthropic accepts ANTHROPIC_AUTH_TOKEN (Bearer auth) as alternative to ANTHROPIC_API_KEY - const hasCredential = - provider === "anthropic" - ? !!( - process.env.ANTHROPIC_API_KEY || - process.env.ANTHROPIC_AUTH_TOKEN - ) - : !!process.env[envVar] - if (hasCredential) { - // Azure requires additional config (baseURL or resourceName) - if (provider === "azure") { - const hasBaseUrl = !!process.env.AZURE_BASE_URL - const hasResourceName = !!process.env.AZURE_RESOURCE_NAME - if (hasBaseUrl || hasResourceName) { - configuredProviders.push(provider as ProviderName) - } - } else { - configuredProviders.push(provider as ProviderName) - } - } - } - - if (configuredProviders.length === 1) { - return configuredProviders[0] - } - - return null + const configuredProviders = PROVIDER_NAMES.filter( + (p) => + PROVIDER_ENV_VARS[p] !== null && serverCredentialError(p) === null, + ) + return configuredProviders.length === 1 ? configuredProviders[0] : null } /** @@ -500,54 +556,12 @@ function validateProviderCredentials( customApiKeyEnv?: string | string[], customBaseUrlEnv?: string, ): void { - // Handle array of env var names - at least one must be set - if (Array.isArray(customApiKeyEnv)) { - const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar]) - if (!hasAnyKey) { - throw new Error( - `At least one of [${customApiKeyEnv.join(", ")}] environment variables is required for ${provider} provider. ` + - `Please set at least one in your .env.local file.`, - ) - } - return - } - - // Anthropic accepts ANTHROPIC_AUTH_TOKEN (Bearer auth) as alternative to ANTHROPIC_API_KEY - if (provider === "anthropic" && !customApiKeyEnv) { - const hasCredential = !!( - process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN - ) - if (!hasCredential) { - throw new Error( - `Either ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable is required for anthropic provider. ` + - `Please set one in your .env.local file.`, - ) - } - } else { - // Use custom env var name if provided, otherwise use default - const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider] - if (requiredVar && !process.env[requiredVar]) { - throw new Error( - `${requiredVar} environment variable is required for ${provider} provider. ` + - `Please set it in your .env.local file.`, - ) - } - } - - // Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition - // to API key, or a server model's own URL variable (an admin panel entry) - if (provider === "azure") { - const hasBaseUrl = - !!process.env.AZURE_BASE_URL || - !!(customBaseUrlEnv && process.env[customBaseUrlEnv]) - const hasResourceName = !!process.env.AZURE_RESOURCE_NAME - if (!hasBaseUrl && !hasResourceName) { - throw new Error( - `Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME to be set. ` + - `Please set one in your .env.local file.`, - ) - } - } + const error = serverCredentialError( + provider, + customApiKeyEnv, + customBaseUrlEnv, + ) + if (error) throw new Error(error) } /** AWS's Bedrock endpoint for a region, as the Bedrock SDK builds it */ @@ -987,10 +1001,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { overrides, PROVIDER_ENV_VARS[provider] as string, ) - const baseUrlEnv = - provider === "gateway" - ? "AI_GATEWAY_BASE_URL" - : `${provider.toUpperCase()}_BASE_URL` + 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 @@ -1078,10 +1089,7 @@ export function globalBaseUrl(provider: ProviderName): string | undefined { ) { return `https://${process.env.AZURE_RESOURCE_NAME}.openai.azure.com/openai` } - const name = - provider === "gateway" - ? "AI_GATEWAY_BASE_URL" - : `${provider.toUpperCase()}_BASE_URL` + const name = envVarsOf(provider).baseUrl as string return process.env[name] || undefined } diff --git a/tests/unit/admin-providers.test.ts b/tests/unit/admin-providers.test.ts index 9e985fd7..bfca257a 100644 --- a/tests/unit/admin-providers.test.ts +++ b/tests/unit/admin-providers.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { ADMIN_PROVIDERS_KEY, adminProvidersToConfig, + credEnvNames, deriveEnvUpdates, loadAdminProviders, maskAdminProviders, @@ -471,3 +472,55 @@ describe("loadAdminProviders", () => { expect(() => maskAdminProviders(loaded)).not.toThrow() }) }) + +describe("credEnvNames", () => { + it("names the panel's variables for every provider", () => { + // bedrock, vertexai and ollama write fixed variables; EdgeOne has none + const expected: Record = { + openai: "OPENAI", + anthropic: "ANTHROPIC", + google: "GOOGLE", + deepseek: "DEEPSEEK", + qwen: "QWEN", + doubao: "DOUBAO", + glm: "GLM", + kimi: "KIMI", + minimax: "MINIMAX", + mimo: "MIMO", + openrouter: "OPENROUTER", + aihubmix: "AIHUBMIX", + siliconflow: "SILICONFLOW", + modelscope: "MODELSCOPE", + novita: "NOVITA", + atlascloud: "ATLASCLOUD", + qiniu: "QINIU", + gateway: "AI_GATEWAY", + bedrock: null, + vertexai: null, + azure: "AZURE", + edgeone: null, + ollama: null, + sglang: "SGLANG", + } + for (const [p, prefix] of Object.entries(expected)) { + const first = credEnvNames(p as StoredAdminProvider["provider"], 0) + const third = credEnvNames(p as StoredAdminProvider["provider"], 2) + expect([p, first, third]).toEqual([ + p, + prefix + ? { + key: `ADMIN_${prefix}_API_KEY`, + url: `ADMIN_${prefix}_BASE_URL`, + } + : {}, + prefix + ? { + key: `ADMIN_${prefix}_API_KEY_3`, + url: `ADMIN_${prefix}_BASE_URL_3`, + } + : {}, + ]) + } + expect(Object.keys(expected)).toHaveLength(24) + }) +}) diff --git a/tests/unit/server-credentials.test.ts b/tests/unit/server-credentials.test.ts new file mode 100644 index 00000000..f933c39e --- /dev/null +++ b/tests/unit/server-credentials.test.ts @@ -0,0 +1,168 @@ +/** + * The server credential check getAIModel runs when the request brings no + * key of its own, and the auto-detection of the server's provider. Written + * against the pre-refactor validateProviderCredentials and detectProvider; + * the refactor merges both into serverCredentialError, which must keep every + * answer and every error text below. + */ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { getAIModel, getServerProvider } from "@/lib/ai-providers" + +vi.mock("@/lib/admin/settings", () => ({ + loadSettings: () => ({}), + getEnvFallback: (key: string) => process.env[key] ?? null, +})) + +const { factory } = vi.hoisted(() => ({ + factory: () => (modelId: string) => ({ modelId }), +})) +vi.mock("@ai-sdk/openai", () => ({ createOpenAI: factory })) +vi.mock("@ai-sdk/anthropic", () => ({ createAnthropic: factory })) +vi.mock("@ai-sdk/azure", () => ({ createAzure: factory })) +vi.mock("@ai-sdk/google-vertex", () => ({ createVertex: factory })) +vi.mock("ollama-ai-provider-v2", () => ({ createOllama: factory })) + +const VARS = [ + "AI_PROVIDER", + "AI_MODEL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "AZURE_API_KEY", + "AZURE_BASE_URL", + "AZURE_RESOURCE_NAME", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "GOOGLE_VERTEX_API_KEY", + "OLLAMA_API_KEY", + "MY_KEY", + "MY_KEY_2", + "MY_URL", +] + +beforeEach(() => { + for (const name of VARS) vi.stubEnv(name, undefined) + vi.spyOn(console, "log").mockImplementation(() => {}) +}) + +const run = (overrides: Parameters[0]) => () => + getAIModel({ modelId: "m", ...overrides }) + +describe("server credentials", () => { + it("accepts ANTHROPIC_AUTH_TOKEN alone for Anthropic", () => { + vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "token") + expect(run({ provider: "anthropic" })).not.toThrow() + vi.stubEnv("ANTHROPIC_AUTH_TOKEN", undefined) + expect(run({ provider: "anthropic" })).toThrow( + "Either ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable is required for anthropic provider. Please set one in your .env.local file.", + ) + }) + + it("holds a server model to its own variable, even for Anthropic", () => { + vi.stubEnv("ANTHROPIC_API_KEY", "k") + vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "token") + expect(run({ provider: "anthropic", apiKeyEnv: "MY_KEY" })).toThrow( + "MY_KEY environment variable is required for anthropic provider. Please set it in your .env.local file.", + ) + }) + + it("does not fall back to the default variable from an empty custom one", () => { + vi.stubEnv("OPENAI_API_KEY", "k") + expect(run({ provider: "openai", apiKeyEnv: "MY_KEY" })).toThrow( + "MY_KEY environment variable is required for openai provider. Please set it in your .env.local file.", + ) + vi.stubEnv("MY_KEY", "custom") + expect(run({ provider: "openai", apiKeyEnv: "MY_KEY" })).not.toThrow() + }) + + it("needs a URL or a resource name for Azure after the key", () => { + expect(run({ provider: "azure" })).toThrow( + "AZURE_API_KEY environment variable is required for azure provider. Please set it in your .env.local file.", + ) + vi.stubEnv("AZURE_API_KEY", "k") + expect(run({ provider: "azure" })).toThrow( + "Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME to be set. Please set one in your .env.local file.", + ) + vi.stubEnv("AZURE_RESOURCE_NAME", "res") + expect(run({ provider: "azure" })).not.toThrow() + }) + + it("lets a server model's own URL variable stand in for Azure's", () => { + vi.stubEnv("MY_KEY", "k") + vi.stubEnv("MY_URL", "https://res.openai.azure.com/openai") + expect( + run({ + provider: "azure", + apiKeyEnv: "MY_KEY", + baseUrlEnv: "MY_URL", + }), + ).not.toThrow() + vi.stubEnv("MY_URL", undefined) + expect( + run({ + provider: "azure", + apiKeyEnv: "MY_KEY", + baseUrlEnv: "MY_URL", + }), + ).toThrow(/AZURE_BASE_URL or AZURE_RESOURCE_NAME/) + }) + + it("passes an array apiKeyEnv on any value and checks nothing else", () => { + vi.stubEnv("MY_KEY_2", "k") + // No Azure URL anywhere, and the key check still passes + expect( + run({ provider: "azure", apiKeyEnv: ["MY_KEY", "MY_KEY_2"] }), + ).not.toThrow() + vi.stubEnv("MY_KEY_2", undefined) + expect( + run({ provider: "azure", apiKeyEnv: ["MY_KEY", "MY_KEY_2"] }), + ).toThrow( + "At least one of [MY_KEY, MY_KEY_2] environment variables is required for azure provider. Please set at least one in your .env.local file.", + ) + }) + + it("asks nothing of providers without a required key variable", () => { + expect(run({ provider: "ollama" })).not.toThrow() + // A custom variable is still checked + expect(run({ provider: "ollama", apiKeyEnv: "MY_KEY" })).toThrow( + "MY_KEY environment variable is required for ollama provider. Please set it in your .env.local file.", + ) + }) + + it("names Vertex AI's key variable", () => { + expect(run({ provider: "vertexai" })).toThrow( + "GOOGLE_VERTEX_API_KEY environment variable is required for vertexai provider. Please set it in your .env.local file.", + ) + vi.stubEnv("GOOGLE_VERTEX_API_KEY", "k") + expect(run({ provider: "vertexai" })).not.toThrow() + }) +}) + +describe("server provider auto-detection", () => { + it("finds the one provider with a key", () => { + vi.stubEnv("GOOGLE_VERTEX_API_KEY", "k") + expect(getServerProvider()).toBe("vertexai") + expect(run({})).not.toThrow() + }) + + it("needs AI_PROVIDER when two have keys", () => { + vi.stubEnv("GOOGLE_VERTEX_API_KEY", "k") + vi.stubEnv("OPENAI_API_KEY", "k") + expect(getServerProvider()).toBeNull() + expect(run({})).toThrow( + "Multiple AI providers configured (openai, vertexai). Please set AI_PROVIDER to specify which one to use.", + ) + }) + + it("accepts AI_PROVIDER=ollama with no key at all", () => { + vi.stubEnv("AI_PROVIDER", "ollama") + expect(getServerProvider()).toBe("ollama") + expect(run({})).not.toThrow() + }) + + it("only counts Azure with a URL or a resource name", () => { + vi.stubEnv("AZURE_API_KEY", "k") + expect(getServerProvider()).toBeNull() + vi.stubEnv("AZURE_RESOURCE_NAME", "res") + expect(getServerProvider()).toBe("azure") + }) +})