From e1c083dae0445aa1046013225e1f94e625941568 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Mon, 12 Oct 2026 14:51:55 +0900 Subject: [PATCH] refactor(providers): drop the transitional tables, document how to add a provider Step 6 of the provider refactor plan. FIXED_CRED_PROVIDERS, PROVIDER_LOGO_MAP, OPTIONS_OPEN and NO_KEY_NEEDED had no readers left and are gone; the record's fields are read where they were used. The fixture that compared the derived tables with their old literal values is gone with them; tests/unit/providers.test.ts keeps the record's own rules (21 providers in auto-detection, every note in every UI language, https defaults only, fixed-credential providers, the picker's order) and a new check that env.example names every record's variables. docs/{en,cn,ja}/ai-providers.md end with a section on adding a provider: one record, two env.example lines, one docs paragraph. --- app/api/provider-models/route.ts | 10 +- docs/cn/ai-providers.md | 23 ++ docs/en/ai-providers.md | 23 ++ docs/ja/ai-providers.md | 23 ++ lib/provider-setup.ts | 9 - lib/types/model-config.ts | 17 +- tests/unit/providers.test.ts | 575 ++++++------------------------- 7 files changed, 170 insertions(+), 510 deletions(-) diff --git a/app/api/provider-models/route.ts b/app/api/provider-models/route.ts index 25dcd0f8..678ab107 100644 --- a/app/api/provider-models/route.ts +++ b/app/api/provider-models/route.ts @@ -6,7 +6,7 @@ import { listProviderModels, ModelListError, } from "@/lib/provider-models" -import { PROVIDER_NAMES, PROVIDERS, type ProviderName } from "@/lib/providers" +import { PROVIDERS, type ProviderName } from "@/lib/providers" import { allowPrivateUrls, isPrivateUrl, @@ -16,11 +16,6 @@ import { export const runtime = "nodejs" -// Public lists need no key -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 * settings. Answers { models: null } for providers that cannot list them, @@ -45,7 +40,8 @@ export async function POST(req: Request) { if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) { return NextResponse.json({ error: "Invalid base URL" }, { status: 400 }) } - if (!apiKey && !NO_KEY_NEEDED.has(provider)) { + // Public and local lists need no key + if (!apiKey && !PROVIDERS[provider].listNeedsNoKey) { return NextResponse.json( { error: "API key is required" }, { status: 400 }, diff --git a/docs/cn/ai-providers.md b/docs/cn/ai-providers.md index 07f8080d..c9970c07 100644 --- a/docs/cn/ai-providers.md +++ b/docs/cn/ai-providers.md @@ -431,3 +431,26 @@ TEMPERATURE=0 # 输出更具确定性(推荐用于图表) - **经济实惠**:DeepSeek 提供具有竞争力的价格 - **隐私保护**:使用 Ollama 进行完全本地、离线的操作(需要强大的硬件支持) - **灵活性**:OpenRouter 通过单一 API 提供对众多模型的访问 + +## 新增一个服务商 + +一个服务商的全部事实都在 `lib/providers.ts` 的一条记录里:用哪个 AI SDK(`sdk`)、用什么凭据登录(`credential`)、环境变量 `_API_KEY` / `_BASE_URL` 的前缀(`envPrefix`)、官方地址、选择器里的分组,以及可选的提示文案。设置界面、连接测试、聊天路由和管理面板都读这条记录,所以新增一个 OpenAI 兼容服务商只需: + +1. 在 `lib/providers.ts` 里加一条记录,例如: + + ```ts + acme: { + label: "Acme AI", + sdk: "openai-compatible", + credential: "apiKey", + group: "aggregators", + envPrefix: "ACME", + defaultBaseUrl: "https://api.acme.example/v1", + apiKeyUrl: "https://acme.example/keys", + modelList: "openai", + }, + ``` + + 需要在地址输入框下方显示提示,或在选择器里显示说明的服务商,在记录里加 `baseUrlHint` 或 `note` 对象,四种界面语言(`en`、`zh`、`ja`、`zh-Hant`)都要写。不能在线列出模型的服务商不写 `modelList`,改写一个短的 `models` 列表。 +2. 在 `env.example` 里加上 `ACME_API_KEY` 和 `ACME_BASE_URL`(有一个单元测试检查每条记录的变量名都出现在里面)。 +3. 在 `docs/en`、`docs/cn`、`docs/ja` 的本文档里各加一节说明。 diff --git a/docs/en/ai-providers.md b/docs/en/ai-providers.md index 98c8fd38..00a93749 100644 --- a/docs/en/ai-providers.md +++ b/docs/en/ai-providers.md @@ -446,3 +446,26 @@ When unset, the model uses its default behavior. - **Budget-friendly**: DeepSeek offers competitive pricing - **Privacy**: Use Ollama for fully local, offline operation (requires powerful hardware) - **Flexibility**: OpenRouter provides access to many models through a single API + +## Adding a Provider + +Every fact about a provider lives in one record in `lib/providers.ts`: which AI SDK runs it (`sdk`), what it signs in with (`credential`), the prefix of its `_API_KEY` / `_BASE_URL` variables (`envPrefix`), its official URL, the picker group, and optional notes. The settings UI, the connection test, the chat route and the admin panel all read that record, so adding an OpenAI-compatible provider is: + +1. Add a record to `lib/providers.ts`, for example: + + ```ts + acme: { + label: "Acme AI", + sdk: "openai-compatible", + credential: "apiKey", + group: "aggregators", + envPrefix: "ACME", + defaultBaseUrl: "https://api.acme.example/v1", + apiKeyUrl: "https://acme.example/keys", + modelList: "openai", + }, + ``` + + A provider that needs a hint under its base URL field, or a note in the picker, gets a `baseUrlHint` or `note` object with all four UI languages (`en`, `zh`, `ja`, `zh-Hant`). Providers that cannot list their models online keep a short `models` list instead of `modelList`. +2. Add `ACME_API_KEY` and `ACME_BASE_URL` to `env.example` (a unit test checks that every record's variables appear there). +3. Add a section for it to this document in `docs/en`, `docs/cn` and `docs/ja`. diff --git a/docs/ja/ai-providers.md b/docs/ja/ai-providers.md index 6f1d6551..d65326c9 100644 --- a/docs/ja/ai-providers.md +++ b/docs/ja/ai-providers.md @@ -431,3 +431,26 @@ TEMPERATURE=0 # より決定論的な出力(ダイアグラムに推奨) - **低コスト**: DeepSeek は競争力のある価格を提供しています - **プライバシー**: 完全にローカルなオフライン操作には Ollama を使用してください(強力なハードウェアが必要です) - **柔軟性**: OpenRouter は単一の API で多数のモデルへのアクセスを提供します + +## プロバイダーの追加 + +プロバイダーに関する事実はすべて `lib/providers.ts` の 1 つのレコードにあります。使用する AI SDK(`sdk`)、サインインに使う資格情報(`credential`)、環境変数 `_API_KEY` / `_BASE_URL` のプレフィックス(`envPrefix`)、公式 URL、ピッカーのグループ、任意の注記です。設定画面、接続テスト、チャットのルート、管理パネルはすべてこのレコードを読むため、OpenAI 互換プロバイダーの追加は次の手順だけです。 + +1. `lib/providers.ts` にレコードを追加します。例: + + ```ts + acme: { + label: "Acme AI", + sdk: "openai-compatible", + credential: "apiKey", + group: "aggregators", + envPrefix: "ACME", + defaultBaseUrl: "https://api.acme.example/v1", + apiKeyUrl: "https://acme.example/keys", + modelList: "openai", + }, + ``` + + ベース URL 欄の下にヒントを表示したい、またはピッカーに注記を表示したいプロバイダーには、4 つの UI 言語(`en`、`zh`、`ja`、`zh-Hant`)をすべて含む `baseUrlHint` または `note` オブジェクトを追加します。モデル一覧をオンラインで取得できないプロバイダーは、`modelList` の代わりに短い `models` リストを持ちます。 +2. `env.example` に `ACME_API_KEY` と `ACME_BASE_URL` を追加します(各レコードの変数名がそこに現れることをユニットテストが確認します)。 +3. `docs/en`、`docs/cn`、`docs/ja` のこのドキュメントにセクションを追加します。 diff --git a/lib/provider-setup.ts b/lib/provider-setup.ts index 63f957c0..59ca6161 100644 --- a/lib/provider-setup.ts +++ b/lib/provider-setup.ts @@ -31,15 +31,6 @@ export const POPULAR_PROVIDERS: ProviderName[] = [ "qwen", ] -/** - * Providers whose base URL usually needs changing (Azure has no default; - * 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[] = PROVIDER_NAMES.filter( - (p) => PROVIDERS[p].optionsOpen, -) - /** The groups with only the providers whose name matches the search */ export function filterProviderGroups(query: string) { const q = query.trim().toLowerCase() diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index ebafea53..0ebe44c1 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -1,6 +1,6 @@ // Types for multi-provider model configuration -import { PROVIDER_NAMES, PROVIDERS, type ProviderName } from "@/lib/providers" +import { PROVIDERS, type ProviderName } from "@/lib/providers" // The provider record, under the name the rest of the app imports it by export { @@ -67,21 +67,6 @@ export interface FlattenedModel { isDefault?: boolean } -// 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[] = PROVIDER_NAMES.filter( - (p) => PROVIDERS[p].fixedCredentials, -) - -// 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> = { openai: [ diff --git a/tests/unit/providers.test.ts b/tests/unit/providers.test.ts index 2df88a9a..4fe6a689 100644 --- a/tests/unit/providers.test.ts +++ b/tests/unit/providers.test.ts @@ -1,492 +1,32 @@ /** - * 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. + * The rules the provider record (lib/providers.ts) must keep, and the parts + * of the UI that follow its order. The record replaced thirteen tables keyed + * by provider name; while they were being replaced, a fixture of their old + * values checked the derived ones, and the behavior snapshot + * (provider-matrix.test.ts) still holds every scenario's answer. */ +import { readFileSync } from "node:fs" +import path from "node:path" import { describe, expect, it, vi } from "vitest" -import { SINGLE_SYSTEM_PROVIDERS } from "@/lib/ai-providers" +import { envVarsOf } from "@/lib/ai-providers" import { i18n } from "@/lib/i18n/config" -import { - OPTIONS_OPEN, - POPULAR_PROVIDERS, - PROVIDER_GROUPS, -} from "@/lib/provider-setup" +import { 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" +import { PROVIDER_INFO, 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", - ], - // The dictionaries' noteBedrock, noteVertex, noteAzure, noteEdgeone, - // noteOllama and noteSglang entries - notes: { - bedrock: { - en: "AWS credentials", - zh: "AWS 凭证", - ja: "AWS 認証情報", - "zh-Hant": "AWS 憑證", - }, - vertexai: { - en: "Express mode key", - zh: "Express 模式 Key", - ja: "Express モードのキー", - "zh-Hant": "Express 模式 Key", - }, - azure: { - en: "Key and resource URL", - zh: "Key 和资源地址", - ja: "キーとリソース URL", - "zh-Hant": "Key 和資源位址", - }, - edgeone: { - en: "No key needed", - zh: "不用 Key", - ja: "キー不要", - "zh-Hant": "不用 Key", - }, - ollama: { - en: "Key optional", - zh: "Key 可不填", - ja: "キーは任意", - "zh-Hant": "Key 可不填", - }, - sglang: { - en: "Your server's URL", - zh: "填服务地址", - ja: "サーバーの URL", - "zh-Hant": "填服務位址", - }, - } as Record>, - // The dictionaries' minimaxBaseUrlHint and mimoBaseUrlHint entries - baseUrlHints: { - minimax: { - 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", - }, - mimo: { - 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", - }, - } 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)) +describe("the provider record", () => { + it("is what the rest of the app imports as PROVIDER_INFO", () => { expect(PROVIDER_INFO).toBe(PROVIDERS) + expect(PROVIDER_NAMES).toHaveLength(24) }) - it("PROVIDER_INFO: label, defaultBaseUrl, apiKeyUrl and modelList", () => { - for (const p of PROVIDER_NAMES) { - const { label, defaultBaseUrl, apiKeyUrl, modelList } = PROVIDERS[p] - const expected = { ...fixture.providerInfo[p] } - // The one intended difference: Azure's placeholder URL named no - // real resource and is gone; the form treats no URL as unset - if (p === "azure") delete expected.defaultBaseUrl - expect([ - p, - { label, defaultBaseUrl, apiKeyUrl, modelList }, - ]).toEqual([p, expected]) - } - }) - - 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 noted = PROVIDER_NAMES.filter((p) => PROVIDERS[p].note) - expect(sorted(noted)).toEqual(sorted(Object.keys(fixture.notes))) - for (const p of noted) { - expect([p, PROVIDERS[p].note]).toEqual([p, fixture.notes[p]]) - } - // Azure's hint is new (it replaces the placeholder URL); the other - // two came from the dictionaries - const hinted = PROVIDER_NAMES.filter((p) => PROVIDERS[p].baseUrlHint) - expect(sorted(hinted)).toEqual( - sorted([...Object.keys(fixture.baseUrlHints), "azure"]), - ) - for (const p of Object.keys(fixture.baseUrlHints) as typeof hinted) { - expect([p, PROVIDERS[p].baseUrlHint]).toEqual([ - p, - fixture.baseUrlHints[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 @@ -494,12 +34,10 @@ describe("the provider record's own rules", () => { ["apiKey", "vertexKey"].includes(PROVIDERS[p].credential), ) expect(detected).toHaveLength(21) + // Ollama has a key variable too, an optional one + const withKeyVar = PROVIDER_NAMES.filter((p) => !!envVarsOf(p).apiKey) expect(sorted(detected)).toEqual( - sorted( - Object.entries(fixture.providerEnvVars) - .filter(([, v]) => v) - .map(([p]) => p), - ), + sorted(withKeyVar.filter((p) => p !== "ollama")), ) }) @@ -537,4 +75,85 @@ describe("the provider record's own rules", () => { ) expect(fixed).toEqual(["bedrock", "vertexai", "ollama"]) }) + + it("keeps a short model list exactly for 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]]) + } + } + }) + + it("names every server variable in env.example", () => { + // env.example is written by hand; a new provider adds its two lines + const example = readFileSync( + path.join(process.cwd(), "env.example"), + "utf8", + ) + for (const p of PROVIDER_NAMES) { + const { apiKey, baseUrl } = envVarsOf(p) + for (const name of [apiKey, baseUrl]) { + if (!name) continue + expect([p, name, example.includes(name)]).toEqual([ + p, + name, + true, + ]) + } + } + }) +}) + +describe("the picker's order", () => { + it("groups every provider once, in record order", () => { + expect(PROVIDER_GROUPS).toEqual([ + { + 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"] }, + ]) + }) + + it("offers the same popular providers", () => { + expect(POPULAR_PROVIDERS).toEqual([ + "openai", + "anthropic", + "google", + "deepseek", + "openrouter", + "qwen", + ]) + }) })