refactor(providers): the UI, the connection test and the routes read the record's fields

Step 5 of the provider refactor plan. Provider names gave way to the
record's credential and sdk fields in: hasCredentials (one rule per
credential kind), the connection test (an Object.hasOwn check first, then a
credential switch; EdgeOne's site endpoint and cookie forwarding follow
credential none), the chat route (the on-site function and the single-system
rule for the OpenAI SDK), the credentials form (which fields to show, the
optional-key label, the base URL hint from the record), the picker (the note
from the record), the logo (icon and logo fields), the settings dialog (test
blocker, error hints, EdgeOne origin), the admin panel (fixedCredentials),
chatRequestUrl (sdk), the model list (sdk aihubmix) and the zod schema
(Object.hasOwn instead of 'in'). The eight provider-specific dictionary
entries moved into the record as four-language objects; needAzure became a
generic needUrl.

Two intended behavior changes: the connection test answers 400 'Invalid
provider' for an unknown name whether or not a key is sent (before: 400
'API key is required' without a key, 200 with an error text with one); and
Azure has no placeholder default URL any more, so a new Azure entry starts
with an empty URL and the generic 'needs a URL' rule applies. The e2e
Azure test seeds no URL and expects the new wording. Out-of-layer
provider-name checks: 44 before, 6 after (the admin panel's fixed-variable
branches for Bedrock, Vertex AI and Ollama, kept on purpose). Behavior
snapshot unchanged.
This commit is contained in:
dayuan.jiang
2026-10-12 21:17:51 +09:00
parent e0a196dded
commit f811242568
22 changed files with 303 additions and 266 deletions
+7 -6
View File
@@ -1,11 +1,10 @@
import { z } from "zod"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
import { PROVIDER_NAMES, PROVIDERS, type ProviderName } from "@/lib/providers"
import {
ProviderNameSchema,
type ServerModelsConfig,
slugify,
} from "@/lib/server-model-config"
import { FIXED_CRED_PROVIDERS, PROVIDER_INFO } from "@/lib/types/model-config"
import { type MaskedSecret, maskSecret } from "./auth"
import { loadSettings } from "./settings"
@@ -158,7 +157,7 @@ export function mergeSecrets(
}
function displayName(p: StoredAdminProvider): string {
return p.name?.trim() || PROVIDER_INFO[p.provider].label
return p.name?.trim() || PROVIDERS[p.provider].label
}
export function validateAdminProviders(
@@ -166,9 +165,11 @@ export function validateAdminProviders(
envConfig: ServerModelsConfig | null = null,
): string | null {
const envProviders = envConfig?.providers ?? []
for (const single of FIXED_CRED_PROVIDERS) {
// Providers whose credentials live in fixed variables can have one entry
const fixed = PROVIDER_NAMES.filter((p) => PROVIDERS[p].fixedCredentials)
for (const single of fixed) {
if (list.filter((p) => p.provider === single).length > 1) {
return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).`
return `Only one ${PROVIDERS[single].label} provider is supported (its credentials use fixed environment variables).`
}
// Its credentials are global; a panel instance would silently
// override the credentials env-configured models rely on
@@ -176,7 +177,7 @@ export function validateAdminProviders(
list.some((p) => p.provider === single) &&
envProviders.some((p) => p.provider === single)
) {
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
return `${PROVIDERS[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
}
}
// Server model ids are built from the slugified name, so names must
+1 -1
View File
@@ -1031,7 +1031,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
const isClientOverride = !!(
overrides?.provider &&
(overrides?.apiKey ||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
(requested?.credential === "vertexKey" && overrides?.vertexApiKey))
)
// Use client override if provided, otherwise fall back to env vars.
+1 -9
View File
@@ -299,8 +299,6 @@
"requestUrl": "Requests go to {url}",
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
"customEndpoint": "Custom endpoint URL",
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
"mimoBaseUrlHint": "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1",
"models": "Models",
"customModelId": "Custom model ID...",
"allAdded": "All added",
@@ -343,12 +341,6 @@
"groupAggregators": "Aggregators",
"groupCloud": "Cloud platforms",
"groupSelfHosted": "Self-hosted",
"noteBedrock": "AWS credentials",
"noteVertex": "Express mode key",
"noteAzure": "Key and resource URL",
"noteEdgeone": "No key needed",
"noteOllama": "Key optional",
"noteSglang": "Your server's URL",
"added": "Added",
"awsCredentials": "AWS credentials",
"bedrockAuthApiKey": "Bedrock API key",
@@ -362,6 +354,7 @@
"modelFailed": "Failed",
"modelUntested": "Untested",
"needKey": "Fill in the API key first.",
"needUrl": "Fill in the API key and the base URL first.",
"needAws": "Fill in the AWS credentials and region first.",
"needModel": "Add a model first.",
"stepConnect": "Connection",
@@ -384,7 +377,6 @@
"deleteProviderOnly": "Delete {name}",
"addProviderEntry": "Add a provider…",
"awsKeysRejected": "AWS rejected these access keys. Check that both were copied in full.",
"needAzure": "Fill in the API key and your Azure resource's base URL first.",
"validationFailedCountOne": "{count} model failed the test",
"validationFailedCountOther": "{count} models failed the test",
"deleteProviderNamedOne": "Delete {name} and its model",
+1 -9
View File
@@ -254,8 +254,6 @@
"requestUrl": "リクエスト先: {url}",
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
"customEndpoint": "カスタムエンドポイント URL",
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
"mimoBaseUrlHint": "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
"models": "モデル",
"customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み",
@@ -298,12 +296,6 @@
"groupAggregators": "アグリゲーター",
"groupCloud": "クラウド",
"groupSelfHosted": "セルフホスト",
"noteBedrock": "AWS 認証情報",
"noteVertex": "Express モードのキー",
"noteAzure": "キーとリソース URL",
"noteEdgeone": "キー不要",
"noteOllama": "キーは任意",
"noteSglang": "サーバーの URL",
"added": "追加済み",
"awsCredentials": "AWS 認証情報",
"bedrockAuthApiKey": "Bedrock API キー",
@@ -317,6 +309,7 @@
"modelFailed": "エラー",
"modelUntested": "未テスト",
"needKey": "先に API キーを入力してください。",
"needUrl": "先に API キーとベース URL を入力してください。",
"needAws": "先に AWS 認証情報とリージョンを入力してください。",
"needModel": "先にモデルを追加してください。",
"stepConnect": "接続",
@@ -339,7 +332,6 @@
"deleteProviderOnly": "{name} を削除",
"addProviderEntry": "プロバイダーを追加…",
"awsKeysRejected": "AWS がこのアクセスキーを拒否しました。2 つとも最後までコピーされているか確認してください。",
"needAzure": "先に API キーと Azure リソースのベース URL を入力してください。",
"validationFailedCountOne": "{count} 個のモデルがテストに失敗しました",
"validationFailedCountOther": "{count} 個のモデルがテストに失敗しました",
"deleteProviderNamedOne": "{name} とそのモデルを削除",
+1 -9
View File
@@ -299,8 +299,6 @@
"requestUrl": "請求將傳送至 {url}",
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
"customEndpoint": "自訂端點 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
"models": "模型",
"customModelId": "自訂模型 ID...",
"allAdded": "已全部新增",
@@ -343,12 +341,6 @@
"groupAggregators": "聚合平台",
"groupCloud": "雲端平台",
"groupSelfHosted": "自行部署",
"noteBedrock": "AWS 憑證",
"noteVertex": "Express 模式 Key",
"noteAzure": "Key 和資源位址",
"noteEdgeone": "不用 Key",
"noteOllama": "Key 可不填",
"noteSglang": "填服務位址",
"added": "已新增",
"awsCredentials": "AWS 憑證",
"bedrockAuthApiKey": "Bedrock API Key",
@@ -362,6 +354,7 @@
"modelFailed": "出錯",
"modelUntested": "未測試",
"needKey": "先填好 API Key。",
"needUrl": "先填好 API Key 和位址。",
"needAws": "先填好 AWS 憑證和區域。",
"needModel": "先新增一個模型。",
"stepConnect": "連線",
@@ -384,7 +377,6 @@
"deleteProviderOnly": "刪除 {name}",
"addProviderEntry": "新增服務商…",
"awsKeysRejected": "AWS 拒絕了這組存取金鑰。檢查兩個金鑰是否都複製完整。",
"needAzure": "先填好 API Key 和 Azure 資源的 Base URL。",
"validationFailedCountOne": "{count} 個模型沒通過測試",
"validationFailedCountOther": "{count} 個模型沒通過測試",
"deleteProviderNamedOne": "刪除 {name} 和它的 {count} 個模型",
+1 -9
View File
@@ -299,8 +299,6 @@
"requestUrl": "请求将发往 {url}",
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
"customEndpoint": "自定义端点 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
"mimoBaseUrlHint": "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
"models": "模型",
"customModelId": "自定义模型 ID...",
"allAdded": "已全部添加",
@@ -343,12 +341,6 @@
"groupAggregators": "聚合平台",
"groupCloud": "云平台",
"groupSelfHosted": "自己部署",
"noteBedrock": "AWS 凭证",
"noteVertex": "Express 模式 Key",
"noteAzure": "Key 和资源地址",
"noteEdgeone": "不用 Key",
"noteOllama": "Key 可不填",
"noteSglang": "填服务地址",
"added": "已添加",
"awsCredentials": "AWS 凭证",
"bedrockAuthApiKey": "Bedrock API Key",
@@ -362,6 +354,7 @@
"modelFailed": "出错",
"modelUntested": "未测试",
"needKey": "先填好 API Key。",
"needUrl": "先填好 API Key 和地址。",
"needAws": "先填好 AWS 凭证和区域。",
"needModel": "先添加一个模型。",
"stepConnect": "连接",
@@ -384,7 +377,6 @@
"deleteProviderOnly": "删除 {name}",
"addProviderEntry": "添加服务商…",
"awsKeysRejected": "AWS 拒绝了这组访问密钥。检查两个密钥是否都复制完整。",
"needAzure": "先填好 API Key 和 Azure 资源的 Base URL。",
"validationFailedCountOne": "{count} 个模型没通过测试",
"validationFailedCountOther": "{count} 个模型没通过测试",
"deleteProviderNamedOne": "删除 {name} 和它的 {count} 个模型",
+1 -1
View File
@@ -155,7 +155,7 @@ export async function listProviderModels(
// AIHubMix has a public list, unless the user points to another
// endpoint, which is OpenAI-compatible
const style =
provider === "aihubmix" &&
PROVIDERS[provider].sdk === "aihubmix" &&
baseUrl &&
!/^https:\/\/aihubmix\.com(\/v1)?$/.test(base)
? "openai"
+15 -34
View File
@@ -6,11 +6,7 @@ import {
PROVIDERS,
type ProviderGroup as ProviderGroupId,
} from "@/lib/providers"
import {
PROVIDER_INFO,
type ProviderConfig,
type ProviderName,
} from "@/lib/types/model-config"
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
export type { ProviderGroupId }
@@ -35,20 +31,10 @@ export const POPULAR_PROVIDERS: ProviderName[] = [
"qwen",
]
/** Providers that need something other than a plain API key */
export const PROVIDER_NOTE_KEYS: Partial<Record<ProviderName, string>> = {
bedrock: "noteBedrock",
vertexai: "noteVertex",
azure: "noteAzure",
edgeone: "noteEdgeone",
ollama: "noteOllama",
sglang: "noteSglang",
}
/**
* Providers whose base URL usually needs changing (Azure's default is only
* an example; Ollama's is its cloud, SGLang's a local server) or comes with
* a hint (MiniMax, MiMo): their folded options start open
* 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,
@@ -62,35 +48,30 @@ export function filterProviderGroups(query: string) {
...group,
providers: group.providers.filter(
(p) =>
PROVIDER_INFO[p].label.toLowerCase().includes(q) ||
p.includes(q),
PROVIDERS[p].label.toLowerCase().includes(q) || p.includes(q),
),
})).filter((group) => group.providers.length > 0)
}
/** The credentials a test or a chat request needs are filled in */
export function hasCredentials(p: ProviderConfig): boolean {
switch (p.provider) {
case "bedrock":
const def = PROVIDERS[p.provider]
switch (def.credential) {
case "aws":
return (
(!!p.apiKey ||
(!!p.awsAccessKeyId && !!p.awsSecretAccessKey)) &&
!!p.awsRegion
)
case "vertexai":
case "vertexKey":
return !!p.vertexApiKey
// Its default base URL names no real resource
case "azure":
return (
!!p.apiKey &&
!!p.baseUrl &&
p.baseUrl !== PROVIDER_INFO.azure.defaultBaseUrl
)
case "edgeone":
case "ollama":
case "none":
case "optionalKey":
return true
default:
return !!p.apiKey
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)
}
}
+8 -2
View File
@@ -429,8 +429,7 @@ const defs = {
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",
// No default: the URL names the user's own resource
sdkKnowsEndpoint: true,
ownRequestPath: true,
optionsOpen: true,
@@ -440,6 +439,13 @@ const defs = {
ja: "キーとリソース URL",
"zh-Hant": "Key 和資源位址",
},
baseUrlHint: {
en: "Your Azure OpenAI resource's URL, https://<resource>.openai.azure.com/openai",
zh: "Azure OpenAI 资源的地址,形如 https://<资源名>.openai.azure.com/openai",
ja: "Azure OpenAI リソースの URL。例: https://<リソース名>.openai.azure.com/openai",
"zh-Hant":
"Azure OpenAI 資源的位址,形如 https://<資源名稱>.openai.azure.com/openai",
},
// Deployment names in the user's resource
models: [
"gpt-5.5",
+2 -2
View File
@@ -6,7 +6,7 @@ import { PROVIDER_INFO } from "@/lib/types/model-config"
export const ProviderNameSchema: z.ZodType<ProviderName> = z
.string()
.refine((val): val is ProviderName => val in PROVIDER_INFO, {
.refine((val): val is ProviderName => Object.hasOwn(PROVIDER_INFO, val), {
message: "Invalid provider name",
})
@@ -81,7 +81,7 @@ function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
)
return null
}
if (!(aiProvider in PROVIDER_INFO)) {
if (!Object.hasOwn(PROVIDER_INFO, aiProvider)) {
console.warn(
`[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`,
)
+59 -36
View File
@@ -12,8 +12,9 @@ import {
isQuotaEnabled,
} from "@/lib/dynamo-quota-manager"
import { classifyLLMError } from "@/lib/llm-errors"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
import { normalizeBaseUrl } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id"
export interface TestModelRequest {
@@ -74,10 +75,22 @@ export async function testModelConnection(
{ status: 400 },
)
}
// EdgeOne is this site's own function, as in the chat; the admin
// panel's Test sends no URL, and a relative one cannot be fetched
const baseUrl =
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
// The body is untrusted: an unknown name has no record to read
if (!Object.hasOwn(PROVIDERS, provider)) {
return NextResponse.json(
{
valid: false,
error: `Invalid provider: ${provider}. Allowed providers: ${Object.keys(PROVIDERS).join(", ")}`,
},
{ status: 400 },
)
}
const { credential } = PROVIDERS[provider]
// The site's own function (EdgeOne) is this site's, as in the chat;
// the admin panel's Test sends no URL, and a relative one cannot be
// fetched
const onSiteFunction = credential === "none"
const baseUrl = onSiteFunction ? edgeOneEndpoint(req) : body.baseUrl
// SECURITY: Block SSRF attacks via custom baseUrl
if (
@@ -92,35 +105,44 @@ export async function testModelConnection(
)
}
// Validate credentials based on provider
if (provider === "bedrock") {
if (
!(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
!awsRegion
) {
return NextResponse.json(
{
valid: false,
error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
},
{ status: 400 },
)
}
} else if (provider === "vertexai") {
if (!vertexApiKey) {
return NextResponse.json(
{
valid: false,
error: "Vertex AI API key is required for Express Mode",
},
{ status: 400 },
)
}
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
// The credentials the provider's kind asks for
switch (credential) {
case "aws":
if (
!(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
!awsRegion
) {
return NextResponse.json(
{
valid: false,
error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
},
{ status: 400 },
)
}
break
case "vertexKey":
if (!vertexApiKey) {
return NextResponse.json(
{
valid: false,
error: "Vertex AI API key is required for Express Mode",
},
{ status: 400 },
)
}
break
case "apiKey":
if (!apiKey) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
}
break
case "optionalKey":
case "none":
break
}
// The Test button checks the user's own provider. On the server's
// keys (Ollama Cloud without a key or URL) anyone could run any model.
@@ -180,8 +202,9 @@ export async function testModelConnection(
// Temporary AWS credentials need it, as in the chat
awsSessionToken,
vertexApiKey,
// EdgeOne checks the Pages cookies and the access code
...(provider === "edgeone" && {
// The site's function (EdgeOne) checks the Pages cookies and
// the access code
...(onSiteFunction && {
headers: {
cookie: req.headers.get("cookie") || "",
"x-access-code": req.headers.get("x-access-code") || "",
+4 -3
View File
@@ -447,9 +447,10 @@ export function chatRequestUrl(
): string | null {
const url = normalizeBaseUrl(baseUrl)
if (!url) return null
if (provider === "anthropic") return `${url}/messages`
if (provider === "ollama") return `${ollamaApiUrl(url)}/chat`
const { sdk, ownRequestPath } = PROVIDERS[provider]
if (sdk === "anthropic") return `${url}/messages`
if (sdk === "ollama") return `${ollamaApiUrl(url)}/chat`
// These SDKs build their own paths (or, for MiniMax, pick the protocol
// from the URL)
return PROVIDERS[provider].ownRequestPath ? null : `${url}/chat/completions`
return ownRequestPath ? null : `${url}/chat/completions`
}