fix(providers): apply the review of PR #988

Five reviewers (Fable 5.1, Opus 5.5, Sonnet 5.5, Codex GPT-6 Astra, Codex
GPT-6.1 Sol) found no behavior or security regression; this commit applies
what they asked for.

Settings dialog: the automatic model list fetch only fills the list, which
waits behind 'Browse models'; it no longer opens the picker (the modal picker
took the focus from whatever the user was typing) and shows no error of its
own. One list request per provider at a time, so a click during the automatic
fetch sends nothing twice; the baseline that detects freshly entered
credentials is taken on every visit of a provider page; SGLang's prefilled
local example URL triggers no fetch. Entries saved with Azure's old
placeholder URL still count as having no URL. The base URL field of Azure and
of the custom preset is labelled required (new dictionary key baseUrlRequired).

Smaller code: the PROVIDER_INFO alias is gone (three files and the tests
import PROVIDERS), as are three logo values no code read, two unused type
re-exports, three unused exports and SINGLE_SYSTEM_PROVIDERS (the chat route
reads the record). Renamed for clarity: REQUIRED_KEY_VARS, requiredKeyEnv,
configuredUrl, LocalizedText. Seven stale comments rewritten; the custom
preset's note shortened.

Tests: the matrix gains six override shapes (a Vertex key alone, AWS keys with
a session token, headers with a URL, a trusted URL with a key, a key with a
server model's URL variable, '/' alone), 5843 rows; the new rows were
regenerated on the pre-refactor code and match it. The matrix and the server
credential tests clear every variable they read; the admin variable table
covers every provider; a test that derived its expectation from the function
under test is gone; the singleSystem, listNeedsNoKey, sdkKnowsEndpoint and
auto-detection sets are spelled out.

Docs: the READMEs say 25 providers and name the OpenAI-compatible preset; the
AI_PROVIDER lists are complete; the Azure example URL has its /openai suffix;
'Adding a Provider' names the tests to update.
This commit is contained in:
dayuan.jiang
2026-10-12 21:17:51 +09:00
parent 65625ba212
commit 75ffc32a49
29 changed files with 1880 additions and 161 deletions
+21 -27
View File
@@ -41,11 +41,6 @@ interface ModelConfig {
provider: ProviderName
}
// Providers that only support a single system message
export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>(
PROVIDER_NAMES.filter((p) => PROVIDERS[p].singleSystem),
)
/**
* Normalize MiniMax base URL for AI SDK compatibility.
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
@@ -203,10 +198,7 @@ function resolveApiKey(
}
}
/**
* Resolve base URL from custom env var name or default env var.
* Supports multiple base URLs per provider via ai-models.json baseUrlEnv config.
*/
/** The server's base URL: a server model's own variable (baseUrlEnv) or the default one */
function resolveBaseUrlEnv(
overrides: ClientOverrides | undefined,
defaultEnvVar: string,
@@ -484,10 +476,10 @@ export function envVarsOf(provider: ProviderName): {
}
}
// 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
// The key variable the server must have for each provider; null where it
// needs none 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(
const REQUIRED_KEY_VARS = Object.fromEntries(
PROVIDER_NAMES.map((p) => {
const { credential } = PROVIDERS[p]
const required = credential === "apiKey" || credential === "vertexKey"
@@ -504,7 +496,8 @@ const PROVIDER_ENV_VARS = Object.fromEntries(
* either variable; a custom variable without a value fails, with no fall
* back to the default one; a provider with a resourceNameEnv (Azure) then
* needs its base URL variable, the server model's own URL variable or the
* resource name. Providers with no required key variable pass.
* resource name. Providers with no required key variable pass unless a
* custom variable was given.
*/
function serverCredentialError(
provider: ProviderName,
@@ -523,20 +516,20 @@ function serverCredentialError(
}
const { altKeyEnv, resourceNameEnv } = PROVIDERS[provider]
const keyEnv = PROVIDER_ENV_VARS[provider]
const requiredKeyEnv = REQUIRED_KEY_VARS[provider]
if (altKeyEnv && !customApiKeyEnv) {
const hasCredential = !!(
(keyEnv && process.env[keyEnv]) ||
(requiredKeyEnv && process.env[requiredKeyEnv]) ||
process.env[altKeyEnv]
)
if (!hasCredential) {
return (
`Either ${keyEnv} or ${altKeyEnv} environment variable is required for ${provider} provider. ` +
`Either ${requiredKeyEnv} or ${altKeyEnv} environment variable is required for ${provider} provider. ` +
`Please set one in your .env.local file.`
)
}
} else {
const requiredVar = customApiKeyEnv || keyEnv
const requiredVar = customApiKeyEnv || requiredKeyEnv
if (requiredVar && !process.env[requiredVar]) {
return (
`${requiredVar} environment variable is required for ${provider} provider. ` +
@@ -545,7 +538,8 @@ function serverCredentialError(
}
}
// The SDK builds the URL from the resource name when there is no URL
// The SDK builds the URL from the resource name when there is no URL.
// Only Azure has one, and the text stays as it was (tests freeze it)
if (resourceNameEnv) {
const baseUrlEnv = envVarsOf(provider).baseUrl as string
const hasBaseUrl =
@@ -569,7 +563,7 @@ function serverCredentialError(
function detectProvider(): ProviderName | null {
const configuredProviders = PROVIDER_NAMES.filter(
(p) =>
PROVIDER_ENV_VARS[p] !== null && serverCredentialError(p) === null,
REQUIRED_KEY_VARS[p] !== null && serverCredentialError(p) === null,
)
return configuredProviders.length === 1 ? configuredProviders[0] : null
}
@@ -606,10 +600,10 @@ function bedrockRuntimeUrl(region: string): string {
return `https://bedrock-runtime.${region}.${suffix}`
}
export type KeySource = "client" | "server" | "none"
type KeySource = "client" | "server" | "none"
/** Where and how to call a provider, once credentials are resolved */
export interface Endpoint {
interface Endpoint {
apiKey?: string
/** Anthropic Bearer auth (ANTHROPIC_AUTH_TOKEN), server only */
authToken?: string
@@ -639,7 +633,7 @@ export interface Endpoint {
* drops one pasted endpoint path per call, so it runs once, in the caller).
* Bedrock gets only keySource: bedrockModel() builds its SDK parameters.
*/
export function resolveEndpoint(
function resolveEndpoint(
provider: ProviderName,
overrides?: ClientOverrides,
): Endpoint {
@@ -735,7 +729,7 @@ export function resolveEndpoint(
// An API key and a base URL from <PREFIX>_API_KEY /
// <PREFIX>_BASE_URL, or a server model's apiKeyEnv / baseUrlEnv
const key = resolveApiKey(overrides, vars.apiKey)
const configuredBaseURL = resolveBaseURL(
const configuredUrl = resolveBaseURL(
overrides?.apiKey,
clientUrl,
resolveBaseUrlEnv(overrides, vars.baseUrl as string),
@@ -746,7 +740,7 @@ export function resolveEndpoint(
// The others get the record's https default (a local default,
// SGLang's 127.0.0.1, only fills the settings form).
const baseURL =
configuredBaseURL ||
configuredUrl ||
(def.sdkKnowsEndpoint &&
!(def.sdk === "openai" && overrides?.apiKey)
? undefined
@@ -754,7 +748,7 @@ export function resolveEndpoint(
return {
apiKey: key.value,
baseURL,
configuredBaseURL: !!configuredBaseURL,
configuredBaseURL: !!configuredUrl,
guarded,
keySource: key.source,
selectedKeyEnv: key.selectedKeyEnv,
@@ -1069,12 +1063,12 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
console.log(`[AI Provider] Auto-detected provider: ${provider}`)
} else {
// List configured providers for better error message
const configured = Object.entries(PROVIDER_ENV_VARS)
const configured = Object.entries(REQUIRED_KEY_VARS)
.filter(([, envVar]) => envVar && process.env[envVar as string])
.map(([p]) => p)
if (configured.length === 0) {
const keys = Object.entries(PROVIDER_ENV_VARS)
const keys = Object.entries(REQUIRED_KEY_VARS)
.filter(([, envVar]) => envVar)
.map(([p, envVar]) => `- ${envVar} for ${p}`)
throw new Error(
+1
View File
@@ -298,6 +298,7 @@
"mayNotDraw": "models.dev lists no tool call support for this model, so it may not be able to draw.",
"requestUrl": "Requests go to {url}",
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
"baseUrlRequired": "Base URL (required)",
"customEndpoint": "Custom endpoint URL",
"models": "Models",
"customModelId": "Custom model ID...",
+1
View File
@@ -253,6 +253,7 @@
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
"requestUrl": "リクエスト先: {url}",
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
"baseUrlRequired": "ベース URL(必須)",
"customEndpoint": "カスタムエンドポイント URL",
"models": "モデル",
"customModelId": "カスタムモデル ID...",
+1
View File
@@ -298,6 +298,7 @@
"mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。",
"requestUrl": "請求將傳送至 {url}",
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
"baseUrlRequired": "基礎 URL(必填)",
"customEndpoint": "自訂端點 URL",
"models": "模型",
"customModelId": "自訂模型 ID...",
+1
View File
@@ -298,6 +298,7 @@
"mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。",
"requestUrl": "请求将发往 {url}",
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
"baseUrlRequired": "基础 URL(必填)",
"customEndpoint": "自定义端点 URL",
"models": "模型",
"customModelId": "自定义模型 ID...",
+11 -6
View File
@@ -8,8 +8,6 @@ import {
} from "@/lib/providers"
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
export type { ProviderGroupId }
/** The provider picker's groups, each provider in exactly one, in record order */
export const PROVIDER_GROUPS: {
id: ProviderGroupId
@@ -44,6 +42,8 @@ export function filterProviderGroups(query: string) {
})).filter((group) => group.providers.length > 0)
}
const LEGACY_AZURE_PLACEHOLDER = "https://your-resource.openai.azure.com/openai"
/** The credentials a test or a chat request needs are filled in */
export function hasCredentials(p: ProviderConfig): boolean {
const def = PROVIDERS[p.provider]
@@ -59,10 +59,15 @@ export function hasCredentials(p: ProviderConfig): boolean {
case "none":
case "optionalKey":
return true
case "apiKey":
// Without a default URL in the record (Azure) the user's own is
// part of the credentials
return !!p.apiKey && (!!def.defaultBaseUrl || !!p.baseUrl)
case "apiKey": {
// Without a default URL in the record (Azure, custom) the user's
// own is part of the credentials. Entries saved while Azure still
// had a placeholder default hold that placeholder; it names no
// real resource
const baseUrl =
p.baseUrl === LEGACY_AZURE_PLACEHOLDER ? "" : p.baseUrl
return !!p.apiKey && (!!def.defaultBaseUrl || !!baseUrl)
}
}
}
+13 -14
View File
@@ -46,7 +46,7 @@ export type ModelListStyle =
export type ProviderGroup = "vendors" | "aggregators" | "cloud" | "selfHosted"
/** One sentence in every UI language */
export type Copy = Record<Locale, string>
export type LocalizedText = Record<Locale, string>
export interface ProviderDef {
label: string
@@ -70,8 +70,9 @@ export interface ProviderDef {
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
* (SGLang's 127.0.0.1) only fills the form; the server never falls back
* to it. A credential "apiKey" provider without one (Azure, custom)
* needs the user's URL
*/
defaultBaseUrl?: string
/** The SDK has the official endpoint built in: pass no baseURL unless one is configured */
@@ -92,12 +93,12 @@ export interface ProviderDef {
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 */
/** A Lucide icon shown instead of a models.dev logo */
icon?: "cloud" | "server" | "sparkles"
/** The note shown under the name in the picker (Bedrock: "AWS credentials") */
note?: Copy
note?: LocalizedText
/** A hint under the base URL field (MiniMax: which path selects which protocol) */
baseUrlHint?: Copy
baseUrlHint?: LocalizedText
/** The folded options start open: the base URL usually needs changing */
optionsOpen?: true
}
@@ -171,7 +172,6 @@ const defs = {
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: [
@@ -356,7 +356,6 @@ const defs = {
envPrefix: null,
fixedCredentials: true,
ownRequestPath: true,
logo: "amazon-bedrock",
icon: "cloud",
note: {
en: "AWS credentials",
@@ -503,10 +502,10 @@ const defs = {
credential: "apiKey",
group: "selfHosted",
envPrefix: "SGLANG",
// A local example: the server never calls it, see publicDefaultUrl
// A local example that only fills the form; the server never falls
// back to it (publicDefaultUrl)
defaultBaseUrl: "http://127.0.0.1:8000/v1",
modelList: "openai",
logo: "openai",
icon: "server",
optionsOpen: true,
note: {
@@ -531,10 +530,10 @@ const defs = {
// Open-source chat templates reject several system messages
singleSystem: true,
note: {
en: "Any OpenAI-compatible URL and key",
zh: "任何 OpenAI 兼容地址和 key",
ja: "OpenAI 互換の URL とキー",
"zh-Hant": "任何 OpenAI 相容位址和 key",
en: "Your URL and key",
zh: "填地址和 Key",
ja: "URL とキー",
"zh-Hant": "填位址和 Key",
},
},
} satisfies Record<string, ProviderDef>
+5 -6
View File
@@ -1,12 +1,11 @@
import fs from "fs/promises"
import path from "path"
import { z } from "zod"
import type { ProviderName } from "@/lib/types/model-config"
import { PROVIDER_INFO } from "@/lib/types/model-config"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
export const ProviderNameSchema: z.ZodType<ProviderName> = z
.string()
.refine((val): val is ProviderName => Object.hasOwn(PROVIDER_INFO, val), {
.refine((val): val is ProviderName => Object.hasOwn(PROVIDERS, val), {
message: "Invalid provider name",
})
@@ -81,7 +80,7 @@ function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
)
return null
}
if (!Object.hasOwn(PROVIDER_INFO, aiProvider)) {
if (!Object.hasOwn(PROVIDERS, aiProvider)) {
console.warn(
`[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`,
)
@@ -102,7 +101,7 @@ function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
return {
providers: [
{
name: PROVIDER_INFO[providerName]?.label || providerName,
name: PROVIDERS[providerName]?.label || providerName,
provider: providerName,
models,
default: true,
@@ -195,7 +194,7 @@ export async function loadFlattenedServerModels(): Promise<
for (const p of cfg.providers) {
const providerLabel =
p.name || PROVIDER_INFO[p.provider]?.label || p.provider
p.name || PROVIDERS[p.provider]?.label || p.provider
// Use slugified name for unique ID (supports multiple API keys per provider)
const nameSlug = slugify(p.name)
+1 -6
View File
@@ -2,12 +2,7 @@
import { 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"
export type { ProviderName } from "@/lib/providers"
// Individual model configuration
export interface ModelConfig {