diff --git a/app/api/provider-models/route.ts b/app/api/provider-models/route.ts index 6b362e73..25dcd0f8 100644 --- a/app/api/provider-models/route.ts +++ b/app/api/provider-models/route.ts @@ -6,22 +6,20 @@ import { listProviderModels, ModelListError, } from "@/lib/provider-models" +import { PROVIDER_NAMES, PROVIDERS, type ProviderName } from "@/lib/providers" import { allowPrivateUrls, isPrivateUrl, RedirectRefusedError, redirectGuardedFetch, } from "@/lib/ssrf-protection" -import type { ProviderName } from "@/lib/types/model-config" export const runtime = "nodejs" // Public lists need no key -const NO_KEY_NEEDED = new Set([ - "ollama", - "openrouter", - "aihubmix", -]) +const NO_KEY_NEEDED = new Set( + PROVIDER_NAMES.filter((p) => PROVIDERS[p].listNeedsNoKey), +) /** * The models a provider offers, for the "Fetch models" button in model diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index c436901c..d3aefd35 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -22,13 +22,9 @@ import { loadAdminProviders, } from "@/lib/admin/providers" import { getEnvFallback } from "@/lib/admin/settings" +import { PROVIDER_NAMES, PROVIDERS, type ProviderName } from "@/lib/providers" import { isPrivateUrl, redirectGuardedFetch } from "@/lib/ssrf-protection" -import { - normalizeBaseUrl, - ollamaApiUrl, - PROVIDER_INFO, - type ProviderName, -} from "@/lib/types/model-config" +import { normalizeBaseUrl, ollamaApiUrl } from "@/lib/types/model-config" const AIHUBMIX_APP_CODE = "MSBS9675" @@ -40,15 +36,9 @@ interface ModelConfig { } // Providers that only support a single system message -export const SINGLE_SYSTEM_PROVIDERS = new Set([ - "minimax", - "glm", - "qwen", - "kimi", - "qiniu", - "novita", - "mimo", -]) +export const SINGLE_SYSTEM_PROVIDERS = new Set( + PROVIDER_NAMES.filter((p) => PROVIDERS[p].singleSystem), +) /** * Normalize MiniMax base URL for AI SDK compatibility. @@ -448,33 +438,16 @@ function buildProviderOptions( return Object.keys(options).length > 0 ? options : undefined } -// Map of provider to required environment variable -const PROVIDER_ENV_VARS: Record = { - bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally - openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - google: "GOOGLE_GENERATIVE_AI_API_KEY", - vertexai: "GOOGLE_VERTEX_API_KEY", - azure: "AZURE_API_KEY", - ollama: null, // No credentials needed for local Ollama - openrouter: "OPENROUTER_API_KEY", - aihubmix: "AIHUBMIX_API_KEY", - deepseek: "DEEPSEEK_API_KEY", - siliconflow: "SILICONFLOW_API_KEY", - sglang: "SGLANG_API_KEY", - gateway: "AI_GATEWAY_API_KEY", - edgeone: null, // No credentials needed - uses EdgeOne Edge AI - doubao: "DOUBAO_API_KEY", - modelscope: "MODELSCOPE_API_KEY", - glm: "GLM_API_KEY", - qwen: "QWEN_API_KEY", - qiniu: "QINIU_API_KEY", - kimi: "KIMI_API_KEY", - minimax: "MINIMAX_API_KEY", - novita: "NOVITA_API_KEY", - mimo: "MIMO_API_KEY", - atlascloud: "ATLASCLOUD_API_KEY", -} +// 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 required = credential === "apiKey" || credential === "vertexKey" + return [p, required ? (keyEnv ?? `${envPrefix}_API_KEY`) : null] + }), +) as Record /** * Auto-detect provider based on available API keys @@ -593,17 +566,12 @@ function bedrockRuntimeUrl(region: string): string { /** * Providers whose SDK has the official endpoint built in. The others are - * OpenAI-compatible APIs (or Anthropic) that are called at - * PROVIDER_INFO.defaultBaseUrl unless a base URL is configured. + * 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([ - "openai", - "google", - "azure", - "openrouter", - "gateway", - "deepseek", -]) +const SDK_KNOWS_ENDPOINT = new Set( + PROVIDER_NAMES.filter((p) => PROVIDERS[p].sdkKnowsEndpoint), +) /** Where and how to call a provider, once credentials are resolved */ interface Endpoint { @@ -798,9 +766,9 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { let provider: ProviderName if (overrides?.provider) { // Validate client-provided provider - if (!Object.hasOwn(PROVIDER_INFO, overrides.provider)) { + if (!Object.hasOwn(PROVIDERS, overrides.provider)) { throw new Error( - `Invalid provider: ${overrides.provider}. Allowed providers: ${Object.keys(PROVIDER_INFO).join(", ")}`, + `Invalid provider: ${overrides.provider}. Allowed providers: ${Object.keys(PROVIDERS).join(", ")}`, ) } provider = overrides.provider as ProviderName @@ -834,9 +802,9 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { ) } } - if (!Object.hasOwn(PROVIDER_INFO, provider)) { + if (!Object.hasOwn(PROVIDERS, provider)) { throw new Error( - `Unknown AI provider: ${provider}. Supported providers: ${Object.keys(PROVIDER_INFO).join(", ")}`, + `Unknown AI provider: ${provider}. Supported providers: ${Object.keys(PROVIDERS).join(", ")}`, ) } @@ -988,7 +956,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { const baseURL = overrides?.baseUrl || (overrides?.apiKey - ? PROVIDER_INFO.ollama.defaultBaseUrl + ? PROVIDERS.ollama.defaultBaseUrl : resolveBaseUrlEnv(overrides, "OLLAMA_BASE_URL")) model = createOllama({ ...(baseURL && { baseURL: ollamaApiUrl(baseURL) }), @@ -1027,7 +995,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { // 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 = PROVIDER_INFO[provider].defaultBaseUrl + const defaultUrl = PROVIDERS[provider].defaultBaseUrl const publicDefault = defaultUrl?.startsWith("https://") ? defaultUrl : undefined @@ -1050,7 +1018,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { (provider === "azure" && overrides?.apiKey)) ) { throw new Error( - `${PROVIDER_INFO[provider].label} needs a base URL. Add it in the model settings.`, + `${PROVIDERS[provider].label} needs a base URL. Add it in the model settings.`, ) } model = createModel(provider, modelId, { diff --git a/lib/provider-setup.ts b/lib/provider-setup.ts index b77929eb..c99592ac 100644 --- a/lib/provider-setup.ts +++ b/lib/provider-setup.ts @@ -1,50 +1,29 @@ // What the model settings show about providers: the groups of the provider // picker, and how far a provider's setup has come +import { + PROVIDER_NAMES, + PROVIDERS, + type ProviderGroup as ProviderGroupId, +} from "@/lib/providers" import { PROVIDER_INFO, type ProviderConfig, type ProviderName, } from "@/lib/types/model-config" -export type ProviderGroupId = "vendors" | "aggregators" | "cloud" | "selfHosted" +export type { ProviderGroupId } -/** The provider picker's groups, each provider in exactly one */ +/** The provider picker's groups, each provider in exactly one, in record order */ export const PROVIDER_GROUPS: { id: ProviderGroupId providers: ProviderName[] -}[] = [ - { - id: "vendors", - providers: [ - "openai", - "anthropic", - "google", - "deepseek", - "qwen", - "doubao", - "glm", - "kimi", - "minimax", - "mimo", - ], - }, - { - id: "aggregators", - providers: [ - "openrouter", - "aihubmix", - "siliconflow", - "modelscope", - "novita", - "atlascloud", - "qiniu", - "gateway", - ], - }, - { id: "cloud", providers: ["bedrock", "vertexai", "azure", "edgeone"] }, - { id: "selfHosted", providers: ["ollama", "sglang"] }, -] +}[] = (["vendors", "aggregators", "cloud", "selfHosted"] as const).map( + (id) => ({ + id, + providers: PROVIDER_NAMES.filter((p) => PROVIDERS[p].group === id), + }), +) /** Shown when the user has no provider yet */ export const POPULAR_PROVIDERS: ProviderName[] = [ @@ -71,13 +50,9 @@ export const PROVIDER_NOTE_KEYS: Partial> = { * an example; Ollama's is its cloud, SGLang's a local server) or comes with * a hint (MiniMax, MiMo): their folded options start open */ -export const OPTIONS_OPEN: ProviderName[] = [ - "azure", - "ollama", - "sglang", - "minimax", - "mimo", -] +export const OPTIONS_OPEN: ProviderName[] = PROVIDER_NAMES.filter( + (p) => PROVIDERS[p].optionsOpen, +) /** The groups with only the providers whose name matches the search */ export function filterProviderGroups(query: string) { diff --git a/lib/providers.ts b/lib/providers.ts new file mode 100644 index 00000000..d9398249 --- /dev/null +++ b/lib/providers.ts @@ -0,0 +1,532 @@ +// Every fact about a provider in one record: which AI SDK factory runs it, +// what it signs in with, its environment variable names, its official URL +// and what the settings UI shows for it. Pure data with no runtime imports, +// so client components, the Edge runtime and Node can all read it. +// +// Adding an OpenAI-compatible provider means adding one record here, plus +// its two lines in env.example and a paragraph in docs/*/ai-providers.md. + +import type { Locale } from "@/lib/i18n/config" + +/** Which AI SDK factory createModel() calls */ +export type Sdk = + | "openai" // @ai-sdk/openai: Responses API, Chat Completions behind a configured URL + | "openai-compatible" // @ai-sdk/openai-compatible plus tag extraction + | "deepseek" // @ai-sdk/deepseek: reasoning_content round trip (deepseek, kimi, mimo) + | "doubao" // deepseek for DeepSeek and Kimi model ids, openai-compatible otherwise + | "anthropic" + | "minimax" // anthropic or openai-compatible, chosen from the URL path + | "google" + | "vertex" + | "azure" + | "bedrock" + | "openrouter" + | "gateway" + | "aihubmix" // @aihubmix/ai-sdk-provider on the official URL, openai-compatible elsewhere + | "ollama" + +/** What the settings form asks for and what a request signs in with */ +export type Credential = + | "apiKey" // one key, required with a custom base URL + | "optionalKey" // Ollama: the key may be empty + | "none" // EdgeOne: the site's own function, cookies authenticate + | "aws" // Bedrock: a Bedrock API key, or an access key pair, plus a region + | "vertexKey" // Vertex AI Express Mode key + +/** How a provider lists its models (see lib/provider-models.ts) */ +export type ModelListStyle = + | "openai" + | "anthropic" + | "google" + | "ollama" + | "openrouter" + | "aihubmix" + | "gateway" + +export type ProviderGroup = "vendors" | "aggregators" | "cloud" | "selfHosted" + +/** One sentence in every UI language */ +export type Copy = Record + +export interface ProviderDef { + label: string + sdk: Sdk + credential: Credential + /** The picker's group; the order of the records is the order inside a group */ + group: ProviderGroup + /** + * The server reads _API_KEY and _BASE_URL. Only the + * spelling of the names: whether the key is required (auto-detection, + * validation) follows `credential`, and whether the admin panel writes + * ADMIN__API_KEY[_n] follows `fixedCredentials`. + * null: no key variables (Bedrock, EdgeOne) + */ + envPrefix: string | null + /** The key variable when it does not follow the prefix (GOOGLE_GENERATIVE_AI_API_KEY) */ + keyEnv?: string + /** A second variable accepted as the server key (ANTHROPIC_AUTH_TOKEN, sent as Bearer) */ + altKeyEnv?: string + /** Azure: the SDK builds the URL from this variable when there is no base URL */ + resourceNameEnv?: string + /** + * The official endpoint, also the form's placeholder. A non-https value + * (SGLang's 127.0.0.1) only fills the form: the server never calls it. + * Missing for a credential "apiKey" provider: the user must enter one + */ + defaultBaseUrl?: string + /** The SDK has the official endpoint built in: pass no baseURL unless one is configured */ + sdkKnowsEndpoint?: true + /** Where users create a key */ + apiKeyUrl?: string + /** How to list models; missing when a key alone cannot (Bedrock, Vertex, Azure, EdgeOne, Doubao, MiniMax) */ + modelList?: ModelListStyle + /** The list is public or local: no key needed to list (Ollama, OpenRouter, AIHubMix) */ + listNeedsNoKey?: true + /** A short hand-written list for providers without modelList; the first one is added with the provider */ + models?: string[] + /** The SDK builds the request path itself, so chatRequestUrl shows nothing */ + ownRequestPath?: true + /** Only one system message per request */ + singleSystem?: true + /** Server credentials live in fixed env vars; the admin panel allows one entry */ + fixedCredentials?: true + /** models.dev logo name when it differs from the key */ + logo?: string + /** A Lucide icon for providers without a logo on models.dev */ + icon?: "cloud" | "server" | "sparkles" + /** The note shown under the name in the picker (Bedrock: "AWS credentials") */ + note?: Copy + /** A hint under the base URL field (MiniMax: which path selects which protocol) */ + baseUrlHint?: Copy + /** The folded options start open: the base URL usually needs changing */ + optionsOpen?: true +} + +// Written in the order the provider picker shows them, group by group +const defs = { + // Model vendors + openai: { + label: "OpenAI", + sdk: "openai", + credential: "apiKey", + group: "vendors", + envPrefix: "OPENAI", + defaultBaseUrl: "https://api.openai.com/v1", + sdkKnowsEndpoint: true, + apiKeyUrl: "https://platform.openai.com/api-keys", + modelList: "openai", + }, + anthropic: { + label: "Anthropic", + sdk: "anthropic", + credential: "apiKey", + group: "vendors", + envPrefix: "ANTHROPIC", + altKeyEnv: "ANTHROPIC_AUTH_TOKEN", + defaultBaseUrl: "https://api.anthropic.com/v1", + apiKeyUrl: "https://platform.claude.com/settings/keys", + modelList: "anthropic", + }, + google: { + label: "Google", + sdk: "google", + credential: "apiKey", + group: "vendors", + envPrefix: "GOOGLE", + keyEnv: "GOOGLE_GENERATIVE_AI_API_KEY", + defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta", + sdkKnowsEndpoint: true, + apiKeyUrl: "https://aistudio.google.com/apikey", + modelList: "google", + ownRequestPath: true, + }, + deepseek: { + label: "DeepSeek", + sdk: "deepseek", + credential: "apiKey", + group: "vendors", + envPrefix: "DEEPSEEK", + defaultBaseUrl: "https://api.deepseek.com/v1", + sdkKnowsEndpoint: true, + apiKeyUrl: "https://platform.deepseek.com/api_keys", + modelList: "openai", + }, + qwen: { + label: "Qwen (Alibaba)", + sdk: "openai-compatible", + credential: "apiKey", + group: "vendors", + envPrefix: "QWEN", + defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key", + modelList: "openai", + singleSystem: true, + }, + doubao: { + label: "Doubao (ByteDance)", + sdk: "doubao", + credential: "apiKey", + group: "vendors", + envPrefix: "DOUBAO", + defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", + apiKeyUrl: + "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", + logo: "bytedance", + icon: "sparkles", + // Volcengine Ark ids use the dash form + models: [ + "doubao-seed-2-0-pro-260215", + "doubao-seed-2-0-lite-260428", + "doubao-seed-2-0-mini-260428", + "doubao-seed-1-8-251228", + "doubao-seed-1-6-251015", + "doubao-seed-1-6-flash-250828", + "doubao-seed-1-6-vision-250815", + "doubao-1-5-pro-32k-250115", + "doubao-1-5-lite-32k-250115", + ], + }, + glm: { + label: "GLM (Zhipu)", + sdk: "openai-compatible", + credential: "apiKey", + group: "vendors", + envPrefix: "GLM", + defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4", + apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys", + modelList: "openai", + singleSystem: true, + }, + kimi: { + label: "Kimi (Moonshot)", + sdk: "deepseek", + credential: "apiKey", + group: "vendors", + envPrefix: "KIMI", + defaultBaseUrl: "https://api.moonshot.cn/v1", + apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", + modelList: "openai", + singleSystem: true, + }, + minimax: { + label: "MiniMax", + sdk: "minimax", + credential: "apiKey", + group: "vendors", + envPrefix: "MINIMAX", + defaultBaseUrl: "https://api.minimaxi.com/anthropic", + apiKeyUrl: + "https://platform.minimaxi.com/user-center/basic-information/interface-key", + ownRequestPath: true, + singleSystem: true, + optionsOpen: true, + baseUrlHint: { + en: "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API", + zh: "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API", + ja: "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用", + "zh-Hant": + "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API", + }, + // The Anthropic-compatible API + models: [ + "MiniMax-M3", + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + "MiniMax-M2.5", + ], + }, + mimo: { + label: "MiMo (Xiaomi)", + sdk: "deepseek", + credential: "apiKey", + group: "vendors", + envPrefix: "MIMO", + defaultBaseUrl: "https://api.xiaomimimo.com/v1", + apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys", + modelList: "openai", + singleSystem: true, + logo: "xiaomi", + optionsOpen: true, + baseUrlHint: { + en: "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1", + zh: "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1", + ja: "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください", + "zh-Hant": + "預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1", + }, + }, + + // Aggregators and gateways + openrouter: { + label: "OpenRouter", + sdk: "openrouter", + credential: "apiKey", + group: "aggregators", + envPrefix: "OPENROUTER", + defaultBaseUrl: "https://openrouter.ai/api/v1", + sdkKnowsEndpoint: true, + apiKeyUrl: "https://openrouter.ai/keys", + modelList: "openrouter", + listNeedsNoKey: true, + }, + aihubmix: { + label: "AIHubMix", + sdk: "aihubmix", + credential: "apiKey", + group: "aggregators", + envPrefix: "AIHUBMIX", + defaultBaseUrl: "https://aihubmix.com/v1", + apiKeyUrl: "https://aihubmix.com/token", + modelList: "aihubmix", + listNeedsNoKey: true, + }, + siliconflow: { + label: "SiliconFlow", + sdk: "openai-compatible", + credential: "apiKey", + group: "aggregators", + envPrefix: "SILICONFLOW", + defaultBaseUrl: "https://api.siliconflow.cn/v1", + apiKeyUrl: "https://cloud.siliconflow.cn/account/ak", + modelList: "openai", + }, + modelscope: { + label: "ModelScope", + sdk: "openai-compatible", + credential: "apiKey", + group: "aggregators", + envPrefix: "MODELSCOPE", + defaultBaseUrl: "https://api-inference.modelscope.cn/v1", + apiKeyUrl: "https://modelscope.cn/my/myaccesstoken", + modelList: "openai", + }, + novita: { + label: "Novita AI", + sdk: "openai-compatible", + credential: "apiKey", + group: "aggregators", + envPrefix: "NOVITA", + defaultBaseUrl: "https://api.novita.ai/openai", + apiKeyUrl: "https://novita.ai/dashboard/key", + modelList: "openai", + singleSystem: true, + }, + atlascloud: { + label: "Atlas Cloud", + sdk: "openai-compatible", + credential: "apiKey", + group: "aggregators", + envPrefix: "ATLASCLOUD", + defaultBaseUrl: "https://api.atlascloud.ai/v1", + apiKeyUrl: "https://www.atlascloud.ai/console/api-keys", + modelList: "openai", + logo: "openai", + }, + qiniu: { + label: "Qiniu", + sdk: "openai-compatible", + credential: "apiKey", + group: "aggregators", + envPrefix: "QINIU", + defaultBaseUrl: "https://api.qnaigc.com/v1", + apiKeyUrl: "https://www.qiniu.com/ai/models", + modelList: "openai", + singleSystem: true, + }, + gateway: { + label: "AI Gateway", + sdk: "gateway", + credential: "apiKey", + group: "aggregators", + envPrefix: "AI_GATEWAY", + defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai", + sdkKnowsEndpoint: true, + apiKeyUrl: "https://vercel.com/ai-gateway", + modelList: "gateway", + ownRequestPath: true, + logo: "vercel", + }, + + // Cloud platforms + bedrock: { + label: "Amazon Bedrock", + sdk: "bedrock", + credential: "aws", + group: "cloud", + envPrefix: null, + fixedCredentials: true, + ownRequestPath: true, + logo: "amazon-bedrock", + icon: "cloud", + note: { + en: "AWS credentials", + zh: "AWS 凭证", + ja: "AWS 認証情報", + "zh-Hant": "AWS 憑證", + }, + // Newer models only answer through an inference profile id (the + // region prefix). Each id here was called once on 2026-10-04. + models: [ + // Anthropic Claude ("global." works from any region) + "global.anthropic.claude-opus-5-5", + "global.anthropic.claude-sonnet-5-5", + "global.anthropic.claude-fable-5-1", + "global.anthropic.claude-opus-5", + "global.anthropic.claude-sonnet-5", + "global.anthropic.claude-opus-4-8", + "global.anthropic.claude-opus-4-7", + "global.anthropic.claude-sonnet-4-6", + "global.anthropic.claude-opus-4-6-v1", + "global.anthropic.claude-opus-4-5-20251101-v1:0", + "global.anthropic.claude-sonnet-4-5-20250929-v1:0", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "global.anthropic.claude-sonnet-4-20250514-v1:0", + // Amazon Nova + "us.amazon.nova-2-lite-v1:0", + "amazon.nova-pro-v1:0", + "amazon.nova-lite-v1:0", + "amazon.nova-micro-v1:0", + // Meta Llama + "us.meta.llama4-maverick-17b-instruct-v1:0", + "us.meta.llama4-scout-17b-instruct-v1:0", + "us.meta.llama3-3-70b-instruct-v1:0", + // Mistral + "mistral.mistral-large-3-675b-instruct", + "us.mistral.pixtral-large-2502-v1:0", + ], + }, + vertexai: { + label: "Google Vertex AI", + sdk: "vertex", + credential: "vertexKey", + group: "cloud", + envPrefix: "GOOGLE_VERTEX", + fixedCredentials: true, + ownRequestPath: true, + logo: "google", + note: { + en: "Express mode key", + zh: "Express 模式 Key", + ja: "Express モードのキー", + "zh-Hant": "Express 模式 Key", + }, + models: [ + // Gemini 3 series + "gemini-3.1-pro-preview", + "gemini-3.5-flash", + "gemini-3-flash-preview", + "gemini-3.1-flash-lite", + // Gemini 2.5 series + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ], + }, + azure: { + label: "Azure OpenAI", + sdk: "azure", + credential: "apiKey", + group: "cloud", + envPrefix: "AZURE", + resourceNameEnv: "AZURE_RESOURCE_NAME", + // A placeholder, not a real resource; the form treats it as unset + defaultBaseUrl: "https://your-resource.openai.azure.com/openai", + sdkKnowsEndpoint: true, + ownRequestPath: true, + optionsOpen: true, + note: { + en: "Key and resource URL", + zh: "Key 和资源地址", + ja: "キーとリソース URL", + "zh-Hant": "Key 和資源位址", + }, + // Deployment names in the user's resource + models: [ + "gpt-5.5", + "gpt-5.4", + "gpt-5.1", + "gpt-5", + "gpt-5-mini", + "gpt-4.1", + "gpt-4o", + "gpt-4o-mini", + "o3", + "o4-mini", + ], + }, + edgeone: { + label: "EdgeOne Pages", + sdk: "openai-compatible", + credential: "none", + group: "cloud", + envPrefix: null, + ownRequestPath: true, + logo: "tencent-cloud", + note: { + en: "No key needed", + zh: "不用 Key", + ja: "キー不要", + "zh-Hant": "不用 Key", + }, + models: ["@tx/deepseek-ai/deepseek-v32"], + }, + + // Self-hosted + ollama: { + label: "Ollama", + sdk: "ollama", + credential: "optionalKey", + group: "selfHosted", + envPrefix: "OLLAMA", + fixedCredentials: true, + defaultBaseUrl: "https://ollama.com/api", + apiKeyUrl: "https://ollama.com/settings/keys", + modelList: "ollama", + listNeedsNoKey: true, + optionsOpen: true, + note: { + en: "Key optional", + zh: "Key 可不填", + ja: "キーは任意", + "zh-Hant": "Key 可不填", + }, + }, + sglang: { + label: "SGLang", + sdk: "openai-compatible", + credential: "apiKey", + group: "selfHosted", + envPrefix: "SGLANG", + // A local example: the server never calls it, see publicDefaultUrl + defaultBaseUrl: "http://127.0.0.1:8000/v1", + modelList: "openai", + logo: "openai", + icon: "server", + optionsOpen: true, + note: { + en: "Your server's URL", + zh: "填服务地址", + ja: "サーバーの URL", + "zh-Hant": "填服務位址", + }, + }, +} satisfies Record + +export type ProviderName = keyof typeof defs + +// Widened to ProviderDef so optional fields can be read on any provider: +// `satisfies` keeps each record's own literal type, on whose union only the +// fields every record has would exist +export const PROVIDERS: Record = defs + +export const PROVIDER_NAMES = Object.keys(PROVIDERS) as ProviderName[] + +/** + * The https default the server may call on its own. A local default + * (SGLang's 127.0.0.1) only fills the settings form: the server must not + * call its own machine for it. + */ +export function publicDefaultUrl(provider: ProviderName): string | undefined { + const url = PROVIDERS[provider].defaultBaseUrl + return url?.startsWith("https://") ? url : undefined +} diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index ca10bb2c..0cee71c2 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -1,30 +1,13 @@ // Types for multi-provider model configuration -export type ProviderName = - | "openai" - | "anthropic" - | "google" - | "vertexai" - | "azure" - | "bedrock" - | "ollama" - | "openrouter" - | "aihubmix" - | "deepseek" - | "siliconflow" - | "sglang" - | "gateway" - | "edgeone" - | "doubao" - | "modelscope" - | "glm" - | "qwen" - | "qiniu" - | "kimi" - | "minimax" - | "novita" - | "mimo" - | "atlascloud" +import { PROVIDER_NAMES, PROVIDERS, type ProviderName } from "@/lib/providers" + +// The provider record, under the name the rest of the app imports it by +export { + type ModelListStyle, + PROVIDERS as PROVIDER_INFO, + type ProviderName, +} from "@/lib/providers" // Individual model configuration export interface ModelConfig { @@ -86,185 +69,18 @@ export interface FlattenedModel { // Providers whose server credentials live in fixed env vars // (AWS_ACCESS_KEY_ID, GOOGLE_VERTEX_API_KEY, OLLAMA_API_KEY) with no -// apiKeyEnv redirection support — their credentials are global -export const FIXED_CRED_PROVIDERS: ProviderName[] = [ - "bedrock", - "vertexai", - "ollama", -] +// apiKeyEnv redirection support: their credentials are global +export const FIXED_CRED_PROVIDERS: ProviderName[] = PROVIDER_NAMES.filter( + (p) => PROVIDERS[p].fixedCredentials, +) -// Map provider names to models.dev logo names -export const PROVIDER_LOGO_MAP: Record = { - openai: "openai", - anthropic: "anthropic", - google: "google", - azure: "azure", - bedrock: "amazon-bedrock", - openrouter: "openrouter", - aihubmix: "aihubmix", - deepseek: "deepseek", - siliconflow: "siliconflow", - sglang: "openai", // SGLang is OpenAI-compatible - gateway: "vercel", - edgeone: "tencent-cloud", - vertexai: "google", - doubao: "bytedance", - modelscope: "modelscope", - minimax: "minimax", - novita: "novita", - mimo: "xiaomi", - atlascloud: "openai", -} - -/** How a provider lists its models (see lib/provider-models.ts) */ -export type ModelListStyle = - | "openai" - | "anthropic" - | "google" - | "ollama" - | "openrouter" - | "aihubmix" - | "gateway" - -// Provider metadata. apiKeyUrl is the page where users create a key. -// modelList is missing where a key alone cannot list the models (Bedrock, -// Vertex, Azure) or the list is not reliable (Doubao, MiniMax). -export const PROVIDER_INFO: Record< - ProviderName, - { - label: string - defaultBaseUrl?: string - apiKeyUrl?: string - modelList?: ModelListStyle - } -> = { - openai: { - label: "OpenAI", - defaultBaseUrl: "https://api.openai.com/v1", - apiKeyUrl: "https://platform.openai.com/api-keys", - modelList: "openai", - }, - anthropic: { - label: "Anthropic", - defaultBaseUrl: "https://api.anthropic.com/v1", - apiKeyUrl: "https://platform.claude.com/settings/keys", - modelList: "anthropic", - }, - google: { - label: "Google", - defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta", - apiKeyUrl: "https://aistudio.google.com/apikey", - modelList: "google", - }, - vertexai: { label: "Google Vertex AI" }, - azure: { - label: "Azure OpenAI", - defaultBaseUrl: "https://your-resource.openai.azure.com/openai", - }, - bedrock: { label: "Amazon Bedrock" }, - ollama: { - label: "Ollama", - defaultBaseUrl: "https://ollama.com/api", - apiKeyUrl: "https://ollama.com/settings/keys", - modelList: "ollama", - }, - openrouter: { - label: "OpenRouter", - defaultBaseUrl: "https://openrouter.ai/api/v1", - apiKeyUrl: "https://openrouter.ai/keys", - modelList: "openrouter", - }, - aihubmix: { - label: "AIHubMix", - defaultBaseUrl: "https://aihubmix.com/v1", - apiKeyUrl: "https://aihubmix.com/token", - modelList: "aihubmix", - }, - deepseek: { - label: "DeepSeek", - defaultBaseUrl: "https://api.deepseek.com/v1", - apiKeyUrl: "https://platform.deepseek.com/api_keys", - modelList: "openai", - }, - siliconflow: { - label: "SiliconFlow", - defaultBaseUrl: "https://api.siliconflow.cn/v1", - apiKeyUrl: "https://cloud.siliconflow.cn/account/ak", - modelList: "openai", - }, - sglang: { - label: "SGLang", - defaultBaseUrl: "http://127.0.0.1:8000/v1", - modelList: "openai", - }, - gateway: { - label: "AI Gateway", - defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai", - apiKeyUrl: "https://vercel.com/ai-gateway", - modelList: "gateway", - }, - edgeone: { label: "EdgeOne Pages" }, - doubao: { - label: "Doubao (ByteDance)", - defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", - apiKeyUrl: - "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", - }, - modelscope: { - label: "ModelScope", - defaultBaseUrl: "https://api-inference.modelscope.cn/v1", - apiKeyUrl: "https://modelscope.cn/my/myaccesstoken", - modelList: "openai", - }, - glm: { - label: "GLM (Zhipu)", - defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4", - apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys", - modelList: "openai", - }, - qwen: { - label: "Qwen (Alibaba)", - defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", - apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key", - modelList: "openai", - }, - qiniu: { - label: "Qiniu", - defaultBaseUrl: "https://api.qnaigc.com/v1", - apiKeyUrl: "https://www.qiniu.com/ai/models", - modelList: "openai", - }, - kimi: { - label: "Kimi (Moonshot)", - defaultBaseUrl: "https://api.moonshot.cn/v1", - apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", - modelList: "openai", - }, - minimax: { - label: "MiniMax", - defaultBaseUrl: "https://api.minimaxi.com/anthropic", - apiKeyUrl: - "https://platform.minimaxi.com/user-center/basic-information/interface-key", - }, - novita: { - label: "Novita AI", - defaultBaseUrl: "https://api.novita.ai/openai", - apiKeyUrl: "https://novita.ai/dashboard/key", - modelList: "openai", - }, - mimo: { - label: "MiMo (Xiaomi)", - defaultBaseUrl: "https://api.xiaomimimo.com/v1", - apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys", - modelList: "openai", - }, - atlascloud: { - label: "Atlas Cloud", - defaultBaseUrl: "https://api.atlascloud.ai/v1", - apiKeyUrl: "https://www.atlascloud.ai/console/api-keys", - modelList: "openai", - }, -} +// models.dev logo names that differ from the provider name +export const PROVIDER_LOGO_MAP: Record = Object.fromEntries( + PROVIDER_NAMES.flatMap((p) => { + const logo = PROVIDERS[p].logo + return logo ? [[p, logo]] : [] + }), +) // Suggested models per provider for quick add export const SUGGESTED_MODELS: Partial> = { @@ -547,7 +363,7 @@ export function createProviderConfig(provider: ProviderName): ProviderConfig { id: generateId(), provider, apiKey: "", - baseUrl: PROVIDER_INFO[provider].defaultBaseUrl, + baseUrl: PROVIDERS[provider].defaultBaseUrl, models: [], } } @@ -567,7 +383,7 @@ export function flattenModels(config: MultiModelConfig): FlattenedModel[] { for (const provider of config.providers) { // Use custom name if provided, otherwise use default provider label const providerLabel = - provider.name || PROVIDER_INFO[provider.provider].label + provider.name || PROVIDERS[provider.provider].label for (const model of provider.models) { models.push({ @@ -635,14 +451,5 @@ export function chatRequestUrl( if (provider === "ollama") return `${ollamaApiUrl(url)}/chat` // These SDKs build their own paths (or, for MiniMax, pick the protocol // from the URL) - const ownPaths: ProviderName[] = [ - "google", - "vertexai", - "azure", - "bedrock", - "gateway", - "minimax", - "edgeone", - ] - return ownPaths.includes(provider) ? null : `${url}/chat/completions` + return PROVIDERS[provider].ownRequestPath ? null : `${url}/chat/completions` } diff --git a/tests/unit/__snapshots__/provider-matrix.jsonl b/tests/unit/__snapshots__/provider-matrix.jsonl index fbb8d811..3f5e27ec 100644 --- a/tests/unit/__snapshots__/provider-matrix.jsonl +++ b/tests/unit/__snapshots__/provider-matrix.jsonl @@ -3260,8 +3260,8 @@ {"noProvider":true,"env":"only MIMO_API_KEY","serverProvider":"mimo","getAIModel":{"provider":"mimo","modelId":"test-model","model":{"factory":"deepseek","method":"default","modelId":"test-model","options":{"apiKey":"server-key","baseURL":"https://api.xiaomimimo.com/v1"}}}} {"noProvider":true,"env":"only ATLASCLOUD_API_KEY","serverProvider":"atlascloud","getAIModel":{"provider":"atlascloud","modelId":"test-model","model":{"wrapped":{"factory":"openai-compatible","method":"default","modelId":"test-model","options":{"name":"atlascloud","apiKey":"server-key","baseURL":"https://api.atlascloud.ai/v1","includeUsage":true}},"middleware":{"middleware":"extractReasoning","tagName":"think"}}}} {"noProvider":true,"env":"vertex and openai","serverProvider":null,"getAIModel":{"error":"Multiple AI providers configured (openai, vertexai). Please set AI_PROVIDER to specify which one to use."}} -{"noProvider":true,"env":"only OLLAMA_API_KEY","serverProvider":null,"getAIModel":{"error":"No AI provider configured. Please set one of the following API keys in your .env.local file:\n- OPENAI_API_KEY for openai\n- ANTHROPIC_API_KEY for anthropic\n- GOOGLE_GENERATIVE_AI_API_KEY for google\n- GOOGLE_VERTEX_API_KEY for vertexai\n- AZURE_API_KEY for azure\n- OPENROUTER_API_KEY for openrouter\n- AIHUBMIX_API_KEY for aihubmix\n- DEEPSEEK_API_KEY for deepseek\n- SILICONFLOW_API_KEY for siliconflow\n- SGLANG_API_KEY for sglang\n- AI_GATEWAY_API_KEY for gateway\n- DOUBAO_API_KEY for doubao\n- MODELSCOPE_API_KEY for modelscope\n- GLM_API_KEY for glm\n- QWEN_API_KEY for qwen\n- QINIU_API_KEY for qiniu\n- KIMI_API_KEY for kimi\n- MINIMAX_API_KEY for minimax\n- NOVITA_API_KEY for novita\n- MIMO_API_KEY for mimo\n- ATLASCLOUD_API_KEY for atlascloud\n- AWS_ACCESS_KEY_ID for bedrock\nOr set AI_PROVIDER=ollama for local Ollama."}} -{"noProvider":true,"env":"no key at all","serverProvider":null,"getAIModel":{"error":"No AI provider configured. Please set one of the following API keys in your .env.local file:\n- OPENAI_API_KEY for openai\n- ANTHROPIC_API_KEY for anthropic\n- GOOGLE_GENERATIVE_AI_API_KEY for google\n- GOOGLE_VERTEX_API_KEY for vertexai\n- AZURE_API_KEY for azure\n- OPENROUTER_API_KEY for openrouter\n- AIHUBMIX_API_KEY for aihubmix\n- DEEPSEEK_API_KEY for deepseek\n- SILICONFLOW_API_KEY for siliconflow\n- SGLANG_API_KEY for sglang\n- AI_GATEWAY_API_KEY for gateway\n- DOUBAO_API_KEY for doubao\n- MODELSCOPE_API_KEY for modelscope\n- GLM_API_KEY for glm\n- QWEN_API_KEY for qwen\n- QINIU_API_KEY for qiniu\n- KIMI_API_KEY for kimi\n- MINIMAX_API_KEY for minimax\n- NOVITA_API_KEY for novita\n- MIMO_API_KEY for mimo\n- ATLASCLOUD_API_KEY for atlascloud\n- AWS_ACCESS_KEY_ID for bedrock\nOr set AI_PROVIDER=ollama for local Ollama."}} +{"noProvider":true,"env":"only OLLAMA_API_KEY","serverProvider":null,"getAIModel":{"error":"No AI provider configured. Please set one of the following API keys in your .env.local file:\n- OPENAI_API_KEY for openai\n- ANTHROPIC_API_KEY for anthropic\n- GOOGLE_GENERATIVE_AI_API_KEY for google\n- DEEPSEEK_API_KEY for deepseek\n- QWEN_API_KEY for qwen\n- DOUBAO_API_KEY for doubao\n- GLM_API_KEY for glm\n- KIMI_API_KEY for kimi\n- MINIMAX_API_KEY for minimax\n- MIMO_API_KEY for mimo\n- OPENROUTER_API_KEY for openrouter\n- AIHUBMIX_API_KEY for aihubmix\n- SILICONFLOW_API_KEY for siliconflow\n- MODELSCOPE_API_KEY for modelscope\n- NOVITA_API_KEY for novita\n- ATLASCLOUD_API_KEY for atlascloud\n- QINIU_API_KEY for qiniu\n- AI_GATEWAY_API_KEY for gateway\n- GOOGLE_VERTEX_API_KEY for vertexai\n- AZURE_API_KEY for azure\n- SGLANG_API_KEY for sglang\n- AWS_ACCESS_KEY_ID for bedrock\nOr set AI_PROVIDER=ollama for local Ollama."}} +{"noProvider":true,"env":"no key at all","serverProvider":null,"getAIModel":{"error":"No AI provider configured. Please set one of the following API keys in your .env.local file:\n- OPENAI_API_KEY for openai\n- ANTHROPIC_API_KEY for anthropic\n- GOOGLE_GENERATIVE_AI_API_KEY for google\n- DEEPSEEK_API_KEY for deepseek\n- QWEN_API_KEY for qwen\n- DOUBAO_API_KEY for doubao\n- GLM_API_KEY for glm\n- KIMI_API_KEY for kimi\n- MINIMAX_API_KEY for minimax\n- MIMO_API_KEY for mimo\n- OPENROUTER_API_KEY for openrouter\n- AIHUBMIX_API_KEY for aihubmix\n- SILICONFLOW_API_KEY for siliconflow\n- MODELSCOPE_API_KEY for modelscope\n- NOVITA_API_KEY for novita\n- ATLASCLOUD_API_KEY for atlascloud\n- QINIU_API_KEY for qiniu\n- AI_GATEWAY_API_KEY for gateway\n- GOOGLE_VERTEX_API_KEY for vertexai\n- AZURE_API_KEY for azure\n- SGLANG_API_KEY for sglang\n- AWS_ACCESS_KEY_ID for bedrock\nOr set AI_PROVIDER=ollama for local Ollama."}} {"noProvider":true,"env":"AI_PROVIDER=openai","getAIModel":{"error":"OPENAI_API_KEY environment variable is required for openai provider. Please set it in your .env.local file."}} {"noProvider":true,"env":"AI_PROVIDER=anthropic","getAIModel":{"error":"Either ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable is required for anthropic provider. Please set one in your .env.local file."}} {"noProvider":true,"env":"AI_PROVIDER=google","getAIModel":{"error":"GOOGLE_GENERATIVE_AI_API_KEY environment variable is required for google provider. Please set it in your .env.local file."}} diff --git a/tests/unit/providers.test.ts b/tests/unit/providers.test.ts new file mode 100644 index 00000000..ee5bba62 --- /dev/null +++ b/tests/unit/providers.test.ts @@ -0,0 +1,510 @@ +/** + * The provider record (lib/providers.ts) replaces thirteen tables keyed by + * provider name. This file freezes those tables' values as they were written + * before the refactor and checks that the record derives the same values. + * The fixture goes away once the derived tables have no readers left; the + * last two tests stay. + */ +import { describe, expect, it, vi } from "vitest" +import { SINGLE_SYSTEM_PROVIDERS } from "@/lib/ai-providers" +import { i18n } from "@/lib/i18n/config" +import enDict from "@/lib/i18n/dictionaries/en.json" +import jaDict from "@/lib/i18n/dictionaries/ja.json" +import zhDict from "@/lib/i18n/dictionaries/zh.json" +import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json" +import { + OPTIONS_OPEN, + POPULAR_PROVIDERS, + PROVIDER_GROUPS, +} from "@/lib/provider-setup" +import { PROVIDER_NAMES, PROVIDERS, publicDefaultUrl } from "@/lib/providers" +import { + chatRequestUrl, + FIXED_CRED_PROVIDERS, + PROVIDER_INFO, + PROVIDER_LOGO_MAP, + SUGGESTED_MODELS, +} from "@/lib/types/model-config" + +vi.mock("@/lib/admin/settings", () => ({ + loadSettings: () => ({}), + getEnvFallback: (key: string) => process.env[key] ?? null, +})) + +// The tables as they were before lib/providers.ts existed +const fixture = { + providerNames: [ + "openai", + "anthropic", + "google", + "vertexai", + "azure", + "bedrock", + "ollama", + "openrouter", + "aihubmix", + "deepseek", + "siliconflow", + "sglang", + "gateway", + "edgeone", + "doubao", + "modelscope", + "glm", + "qwen", + "qiniu", + "kimi", + "minimax", + "novita", + "mimo", + "atlascloud", + ], + providerInfo: { + openai: { + label: "OpenAI", + defaultBaseUrl: "https://api.openai.com/v1", + apiKeyUrl: "https://platform.openai.com/api-keys", + modelList: "openai", + }, + anthropic: { + label: "Anthropic", + defaultBaseUrl: "https://api.anthropic.com/v1", + apiKeyUrl: "https://platform.claude.com/settings/keys", + modelList: "anthropic", + }, + google: { + label: "Google", + defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta", + apiKeyUrl: "https://aistudio.google.com/apikey", + modelList: "google", + }, + vertexai: { label: "Google Vertex AI" }, + azure: { + label: "Azure OpenAI", + defaultBaseUrl: "https://your-resource.openai.azure.com/openai", + }, + bedrock: { label: "Amazon Bedrock" }, + ollama: { + label: "Ollama", + defaultBaseUrl: "https://ollama.com/api", + apiKeyUrl: "https://ollama.com/settings/keys", + modelList: "ollama", + }, + openrouter: { + label: "OpenRouter", + defaultBaseUrl: "https://openrouter.ai/api/v1", + apiKeyUrl: "https://openrouter.ai/keys", + modelList: "openrouter", + }, + aihubmix: { + label: "AIHubMix", + defaultBaseUrl: "https://aihubmix.com/v1", + apiKeyUrl: "https://aihubmix.com/token", + modelList: "aihubmix", + }, + deepseek: { + label: "DeepSeek", + defaultBaseUrl: "https://api.deepseek.com/v1", + apiKeyUrl: "https://platform.deepseek.com/api_keys", + modelList: "openai", + }, + siliconflow: { + label: "SiliconFlow", + defaultBaseUrl: "https://api.siliconflow.cn/v1", + apiKeyUrl: "https://cloud.siliconflow.cn/account/ak", + modelList: "openai", + }, + sglang: { + label: "SGLang", + defaultBaseUrl: "http://127.0.0.1:8000/v1", + modelList: "openai", + }, + gateway: { + label: "AI Gateway", + defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai", + apiKeyUrl: "https://vercel.com/ai-gateway", + modelList: "gateway", + }, + edgeone: { label: "EdgeOne Pages" }, + doubao: { + label: "Doubao (ByteDance)", + defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", + apiKeyUrl: + "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", + }, + modelscope: { + label: "ModelScope", + defaultBaseUrl: "https://api-inference.modelscope.cn/v1", + apiKeyUrl: "https://modelscope.cn/my/myaccesstoken", + modelList: "openai", + }, + glm: { + label: "GLM (Zhipu)", + defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4", + apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys", + modelList: "openai", + }, + qwen: { + label: "Qwen (Alibaba)", + defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key", + modelList: "openai", + }, + qiniu: { + label: "Qiniu", + defaultBaseUrl: "https://api.qnaigc.com/v1", + apiKeyUrl: "https://www.qiniu.com/ai/models", + modelList: "openai", + }, + kimi: { + label: "Kimi (Moonshot)", + defaultBaseUrl: "https://api.moonshot.cn/v1", + apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", + modelList: "openai", + }, + minimax: { + label: "MiniMax", + defaultBaseUrl: "https://api.minimaxi.com/anthropic", + apiKeyUrl: + "https://platform.minimaxi.com/user-center/basic-information/interface-key", + }, + novita: { + label: "Novita AI", + defaultBaseUrl: "https://api.novita.ai/openai", + apiKeyUrl: "https://novita.ai/dashboard/key", + modelList: "openai", + }, + mimo: { + label: "MiMo (Xiaomi)", + defaultBaseUrl: "https://api.xiaomimimo.com/v1", + apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys", + modelList: "openai", + }, + atlascloud: { + label: "Atlas Cloud", + defaultBaseUrl: "https://api.atlascloud.ai/v1", + apiKeyUrl: "https://www.atlascloud.ai/console/api-keys", + modelList: "openai", + }, + } as Record< + string, + { + label: string + defaultBaseUrl?: string + apiKeyUrl?: string + modelList?: string + } + >, + fixedCredProviders: ["bedrock", "vertexai", "ollama"], + providerLogoMap: { + openai: "openai", + anthropic: "anthropic", + google: "google", + azure: "azure", + bedrock: "amazon-bedrock", + openrouter: "openrouter", + aihubmix: "aihubmix", + deepseek: "deepseek", + siliconflow: "siliconflow", + sglang: "openai", + gateway: "vercel", + edgeone: "tencent-cloud", + vertexai: "google", + doubao: "bytedance", + modelscope: "modelscope", + minimax: "minimax", + novita: "novita", + mimo: "xiaomi", + atlascloud: "openai", + } as Record, + ownPaths: [ + "google", + "vertexai", + "azure", + "bedrock", + "gateway", + "minimax", + "edgeone", + ], + singleSystemProviders: [ + "minimax", + "glm", + "qwen", + "kimi", + "qiniu", + "novita", + "mimo", + ], + providerEnvVars: { + bedrock: null, + openai: "OPENAI_API_KEY", + anthropic: "ANTHROPIC_API_KEY", + google: "GOOGLE_GENERATIVE_AI_API_KEY", + vertexai: "GOOGLE_VERTEX_API_KEY", + azure: "AZURE_API_KEY", + ollama: null, + openrouter: "OPENROUTER_API_KEY", + aihubmix: "AIHUBMIX_API_KEY", + deepseek: "DEEPSEEK_API_KEY", + siliconflow: "SILICONFLOW_API_KEY", + sglang: "SGLANG_API_KEY", + gateway: "AI_GATEWAY_API_KEY", + edgeone: null, + doubao: "DOUBAO_API_KEY", + modelscope: "MODELSCOPE_API_KEY", + glm: "GLM_API_KEY", + qwen: "QWEN_API_KEY", + qiniu: "QINIU_API_KEY", + kimi: "KIMI_API_KEY", + minimax: "MINIMAX_API_KEY", + novita: "NOVITA_API_KEY", + mimo: "MIMO_API_KEY", + atlascloud: "ATLASCLOUD_API_KEY", + } as Record, + sdkKnowsEndpoint: [ + "openai", + "google", + "azure", + "openrouter", + "gateway", + "deepseek", + ], + providerGroups: [ + { + id: "vendors", + providers: [ + "openai", + "anthropic", + "google", + "deepseek", + "qwen", + "doubao", + "glm", + "kimi", + "minimax", + "mimo", + ], + }, + { + id: "aggregators", + providers: [ + "openrouter", + "aihubmix", + "siliconflow", + "modelscope", + "novita", + "atlascloud", + "qiniu", + "gateway", + ], + }, + { id: "cloud", providers: ["bedrock", "vertexai", "azure", "edgeone"] }, + { id: "selfHosted", providers: ["ollama", "sglang"] }, + ], + popularProviders: [ + "openai", + "anthropic", + "google", + "deepseek", + "openrouter", + "qwen", + ], + providerNoteKeys: { + bedrock: "noteBedrock", + vertexai: "noteVertex", + azure: "noteAzure", + edgeone: "noteEdgeone", + ollama: "noteOllama", + sglang: "noteSglang", + } as Record, + baseUrlHintKeys: { + minimax: "minimaxBaseUrlHint", + mimo: "mimoBaseUrlHint", + } as Record, + optionsOpen: ["azure", "ollama", "sglang", "minimax", "mimo"], + noKeyNeeded: ["ollama", "openrouter", "aihubmix"], +} + +const sorted = (list: Iterable) => [...list].sort() + +describe("the provider record derives the old tables", () => { + it("names the same providers", () => { + expect(sorted(PROVIDER_NAMES)).toEqual(sorted(fixture.providerNames)) + expect(PROVIDER_INFO).toBe(PROVIDERS) + }) + + it("PROVIDER_INFO: label, defaultBaseUrl, apiKeyUrl and modelList", () => { + for (const p of PROVIDER_NAMES) { + const { label, defaultBaseUrl, apiKeyUrl, modelList } = PROVIDERS[p] + expect([ + p, + { label, defaultBaseUrl, apiKeyUrl, modelList }, + ]).toEqual([p, { ...fixture.providerInfo[p] }]) + } + }) + + it("FIXED_CRED_PROVIDERS", () => { + expect(FIXED_CRED_PROVIDERS).toEqual(fixture.fixedCredProviders) + }) + + it("PROVIDER_LOGO_MAP, as its reader resolves it", () => { + for (const p of PROVIDER_NAMES) { + expect([p, PROVIDER_LOGO_MAP[p] || p]).toEqual([ + p, + fixture.providerLogoMap[p] || p, + ]) + expect([p, PROVIDERS[p].logo ?? p]).toEqual([ + p, + fixture.providerLogoMap[p] || p, + ]) + } + }) + + it("chatRequestUrl's ownPaths", () => { + const own = PROVIDER_NAMES.filter( + (p) => chatRequestUrl(p, "https://x.example/v1") === null, + ) + expect(sorted(own)).toEqual(sorted(fixture.ownPaths)) + }) + + it("SINGLE_SYSTEM_PROVIDERS", () => { + expect(sorted(SINGLE_SYSTEM_PROVIDERS)).toEqual( + sorted(fixture.singleSystemProviders), + ) + }) + + it("PROVIDER_ENV_VARS: the key variable each provider must have", () => { + for (const p of PROVIDER_NAMES) { + const { credential, envPrefix, keyEnv } = PROVIDERS[p] + const required = + credential === "apiKey" || credential === "vertexKey" + const derived = required ? (keyEnv ?? `${envPrefix}_API_KEY`) : null + expect([p, derived]).toEqual([p, fixture.providerEnvVars[p]]) + } + }) + + it("SDK_KNOWS_ENDPOINT", () => { + const derived = PROVIDER_NAMES.filter( + (p) => PROVIDERS[p].sdkKnowsEndpoint, + ) + expect(sorted(derived)).toEqual(sorted(fixture.sdkKnowsEndpoint)) + }) + + it("PROVIDER_GROUPS, in the picker's order", () => { + expect(PROVIDER_GROUPS).toEqual(fixture.providerGroups) + }) + + it("POPULAR_PROVIDERS", () => { + expect(POPULAR_PROVIDERS).toEqual(fixture.popularProviders) + }) + + it("OPTIONS_OPEN", () => { + expect(sorted(OPTIONS_OPEN)).toEqual(sorted(fixture.optionsOpen)) + }) + + it("NO_KEY_NEEDED", () => { + const derived = PROVIDER_NAMES.filter( + (p) => PROVIDERS[p].listNeedsNoKey, + ) + expect(sorted(derived)).toEqual(sorted(fixture.noKeyNeeded)) + }) + + it("the picker notes and base URL hints, word for word in every language", () => { + const dicts: Record> = { + en: enDict.modelConfig, + zh: zhDict.modelConfig, + ja: jaDict.modelConfig, + "zh-Hant": zhHantDict.modelConfig, + } + const noted = PROVIDER_NAMES.filter((p) => PROVIDERS[p].note) + expect(sorted(noted)).toEqual( + sorted(Object.keys(fixture.providerNoteKeys)), + ) + for (const p of noted) { + for (const [lang, dict] of Object.entries(dicts)) { + expect([p, lang, PROVIDERS[p].note?.[lang as never]]).toEqual([ + p, + lang, + dict[fixture.providerNoteKeys[p]], + ]) + } + } + const hinted = PROVIDER_NAMES.filter((p) => PROVIDERS[p].baseUrlHint) + expect(sorted(hinted)).toEqual( + sorted(Object.keys(fixture.baseUrlHintKeys)), + ) + for (const p of hinted) { + for (const [lang, dict] of Object.entries(dicts)) { + expect([ + p, + lang, + PROVIDERS[p].baseUrlHint?.[lang as never], + ]).toEqual([p, lang, dict[fixture.baseUrlHintKeys[p]]]) + } + } + }) + + it("the model lists of the providers that cannot list models", () => { + for (const p of PROVIDER_NAMES) { + const { modelList, models } = PROVIDERS[p] + if (modelList) { + expect([p, models]).toEqual([p, undefined]) + } else { + expect([p, models]).toEqual([p, SUGGESTED_MODELS[p]]) + } + } + }) +}) + +describe("the provider record's own rules", () => { + it("has 21 providers taking part in server key auto-detection", () => { + // credential apiKey or vertexKey: the providers whose

_API_KEY the + // server must have; aws, optionalKey and none need no key + const detected = PROVIDER_NAMES.filter((p) => + ["apiKey", "vertexKey"].includes(PROVIDERS[p].credential), + ) + expect(detected).toHaveLength(21) + expect(sorted(detected)).toEqual( + sorted( + Object.entries(fixture.providerEnvVars) + .filter(([, v]) => v) + .map(([p]) => p), + ), + ) + }) + + it("writes every note and hint in every UI language", () => { + for (const p of PROVIDER_NAMES) { + for (const copy of [PROVIDERS[p].note, PROVIDERS[p].baseUrlHint]) { + if (!copy) continue + expect(sorted(Object.keys(copy))).toEqual(sorted(i18n.locales)) + for (const text of Object.values(copy)) { + expect(text.trim()).not.toBe("") + } + } + } + }) + + it("only calls https defaults on its own", () => { + expect(publicDefaultUrl("sglang")).toBeUndefined() + expect(publicDefaultUrl("openai")).toBe("https://api.openai.com/v1") + expect(publicDefaultUrl("bedrock")).toBeUndefined() + }) + + it("gives the no-key and fixed-credential providers no key variable to detect", () => { + for (const p of PROVIDER_NAMES) { + const { credential, envPrefix, fixedCredentials } = PROVIDERS[p] + if (credential === "none") expect([p, envPrefix]).toEqual([p, null]) + if (credential === "aws") expect([p, envPrefix]).toEqual([p, null]) + if (fixedCredentials) { + expect(["aws", "vertexKey", "optionalKey"]).toContain( + credential, + ) + } + } + const fixed = PROVIDER_NAMES.filter( + (p) => PROVIDERS[p].fixedCredentials, + ) + expect(fixed).toEqual(["bedrock", "vertexai", "ollama"]) + }) +})