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.
This commit is contained in:
dayuan.jiang
2026-10-12 21:17:51 +09:00
parent f811242568
commit e1c083dae0
7 changed files with 170 additions and 510 deletions
+3 -7
View File
@@ -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<ProviderName>(
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 },
+23
View File
@@ -431,3 +431,26 @@ TEMPERATURE=0 # 输出更具确定性(推荐用于图表)
- **经济实惠**:DeepSeek 提供具有竞争力的价格
- **隐私保护**:使用 Ollama 进行完全本地、离线的操作(需要强大的硬件支持)
- **灵活性**:OpenRouter 通过单一 API 提供对众多模型的访问
## 新增一个服务商
一个服务商的全部事实都在 `lib/providers.ts` 的一条记录里:用哪个 AI SDK(`sdk`)、用什么凭据登录(`credential`)、环境变量 `<PREFIX>_API_KEY` / `<PREFIX>_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` 的本文档里各加一节说明。
+23
View File
@@ -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 `<PREFIX>_API_KEY` / `<PREFIX>_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`.
+23
View File
@@ -431,3 +431,26 @@ TEMPERATURE=0 # より決定論的な出力(ダイアグラムに推奨)
- **低コスト**: DeepSeek は競争力のある価格を提供しています
- **プライバシー**: 完全にローカルなオフライン操作には Ollama を使用してください(強力なハードウェアが必要です)
- **柔軟性**: OpenRouter は単一の API で多数のモデルへのアクセスを提供します
## プロバイダーの追加
プロバイダーに関する事実はすべて `lib/providers.ts` の 1 つのレコードにあります。使用する AI SDK(`sdk`)、サインインに使う資格情報(`credential`)、環境変数 `<PREFIX>_API_KEY` / `<PREFIX>_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` のこのドキュメントにセクションを追加します。
-9
View File
@@ -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()
+1 -16
View File
@@ -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<string, string> = 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<Record<ProviderName, string[]>> = {
openai: [
+97 -478
View File
@@ -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<string, string>,
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<string, string | null>,
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<string, Record<string, string>>,
// 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<string, Record<string, string>>,
optionsOpen: ["azure", "ollama", "sglang", "minimax", "mimo"],
noKeyNeeded: ["ollama", "openrouter", "aihubmix"],
}
const sorted = (list: Iterable<string>) => [...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 <P>_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",
])
})
})