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 -12
View File
@@ -33,14 +33,9 @@ import {
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
import { STORAGE_KEYS } from "@/lib/storage"
import {
FIXED_CRED_PROVIDERS,
generateId,
PROVIDER_INFO,
type ProviderName,
SUGGESTED_MODELS,
} from "@/lib/types/model-config"
import { generateId, SUGGESTED_MODELS } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
@@ -72,7 +67,7 @@ function ProviderDetail({
Record<string, { ok: boolean; message: string }>
>({})
const info = PROVIDER_INFO[provider.provider]
const info = PROVIDERS[provider.provider]
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
(m) => !provider.models.includes(m),
)
@@ -500,7 +495,7 @@ export function ModelsSection({
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name || PROVIDER_INFO[p.provider].label}
{p.name || PROVIDERS[p.provider].label}
</span>
{p.isDefault && (
<Star
@@ -526,13 +521,13 @@ export function ModelsSection({
{dict.modelConfig.addProvider}
</SelectTrigger>
<SelectContent className="max-h-72">
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
{(Object.keys(PROVIDERS) as ProviderName[]).map(
(p) => {
// Global-credential providers already in
// the env config can't be added here —
// panel credentials would override theirs
const envBlocked =
FIXED_CRED_PROVIDERS.includes(p) &&
!!PROVIDERS[p].fixedCredentials &&
envProviders.some(
(e) => e.provider === p,
)
@@ -544,7 +539,7 @@ export function ModelsSection({
>
<div className="flex items-center gap-2">
<ProviderLogo provider={p} />
{PROVIDER_INFO[p].label}
{PROVIDERS[p].label}
{envBlocked && (
<span className="text-xs text-muted-foreground">
{
+18 -13
View File
@@ -47,6 +47,7 @@ import {
resolveMaxOutputTokens,
withOutputTokenLimitFallback,
} from "@/lib/output-token-limit"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
import {
type FlattenedServerModel,
findServerModelById,
@@ -173,14 +174,17 @@ async function handleChatRequest(req: Request): Promise<Response> {
// A server model's provider comes from its config: for one set up in
// the admin panel the header holds the provider name's slug. Without
// either, the server's own AI_PROVIDER.
const isEdgeOne =
(serverModelConfig.provider || provider || getServerProvider()) ===
"edgeone"
// EdgeOne is this deployment's own function, whatever URL the request
// names: another host would get the user's EdgeOne cookies, and the
// quota counts it. Absolute, as the SDK needs.
if (isEdgeOne) baseUrl = edgeOneEndpoint(req)
const requestedProvider =
serverModelConfig.provider || provider || getServerProvider()
// A provider without credentials (EdgeOne) is this deployment's own
// function, whatever URL the request names: another host would get the
// user's EdgeOne cookies, and the quota counts it. Absolute, as the SDK
// needs.
const onSiteFunction =
!!requestedProvider &&
Object.hasOwn(PROVIDERS, requestedProvider) &&
PROVIDERS[requestedProvider as ProviderName].credential === "none"
if (onSiteFunction) baseUrl = edgeOneEndpoint(req)
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
// not point the server at a private or internal address
@@ -195,12 +199,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
const cookieHeader = req.headers.get("cookie")
const clientOverrides = {
// Server model provider takes precedence over client header; EdgeOne
// named only in AI_PROVIDER is named here, for its own base URL
// Server model provider takes precedence over client header; the
// site's function named only in AI_PROVIDER is named here, for its
// own base URL
provider:
serverModelConfig.provider ||
provider ||
(isEdgeOne ? "edgeone" : null),
(onSiteFunction ? requestedProvider : null),
baseUrl,
apiKey: req.headers.get("x-ai-api-key"),
// A server model runs the model it was configured with, whatever the header says
@@ -216,7 +221,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
vertexApiKey: req.headers.get("x-vertex-api-key"),
// Pass cookies for EdgeOne Pages authentication, and the access code,
// which the EdgeOne function checks too
...(isEdgeOne && {
...(onSiteFunction && {
headers: {
...(cookieHeader && { cookie: cookieHeader }),
"x-access-code": req.headers.get("x-access-code") || "",
@@ -420,7 +425,7 @@ ${userInputText}
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
const isCustomOpenAIEndpoint =
resolvedProvider === "openai" &&
PROVIDERS[resolvedProvider].sdk === "openai" &&
!!(
baseUrl ||
process.env.OPENAI_BASE_URL ||
+13 -10
View File
@@ -55,13 +55,14 @@ import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils"
import type { ListedModel } from "@/lib/provider-models"
import { hasCredentials } from "@/lib/provider-setup"
import { PROVIDERS } from "@/lib/providers"
import { STORAGE_KEYS } from "@/lib/storage"
import type {
ModelConfig,
ProviderConfig,
ProviderName,
} from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
@@ -300,7 +301,7 @@ export function ModelConfigDialog({
const errorHints = (p: ProviderConfig): Record<string, string> => ({
...(dict.errors.llm as Record<string, string>),
invalid_api_key:
p.provider === "bedrock" && !p.apiKey
PROVIDERS[p.provider].credential === "aws" && !p.apiKey
? t.awsKeysRejected
: t.keyRejected,
model_not_found: t.modelNotFound,
@@ -530,9 +531,9 @@ export function ModelConfigDialog({
const run = (latestTestRun.get(selectedProviderId) ?? 0) + 1
latestTestRun.set(selectedProviderId, run)
// For EdgeOne, construct baseUrl from current origin
// The site's own function (EdgeOne): its URL is this origin's
const baseUrl =
selectedProvider.provider === "edgeone"
PROVIDERS[selectedProvider.provider].credential === "none"
? `${window.location.origin}/api/edgeai`
: selectedProvider.baseUrl
const hints = errorHints(selectedProvider)
@@ -741,7 +742,7 @@ export function ModelConfigDialog({
}
const providerName = (p: ProviderConfig) =>
p.name || PROVIDER_INFO[p.provider].label
p.name || PROVIDERS[p.provider].label
const renderModelRow = (model: ModelConfig) => {
if (!selectedProvider) return null
@@ -922,11 +923,13 @@ export function ModelConfigDialog({
: undefined
const canTest = credentialsDone && models.length > 0
const testing = validationStatus === "validating"
const def = PROVIDERS[provider.provider]
// A key-only provider without a default URL (Azure) needs its URL too
const testBlocker = !credentialsDone
? provider.provider === "bedrock"
? def.credential === "aws"
? t.needAws
: provider.provider === "azure"
? t.needAzure
: def.credential === "apiKey" && !def.defaultBaseUrl
? t.needUrl
: t.needKey
: models.length === 0
? t.needModel
@@ -984,7 +987,7 @@ export function ModelConfigDialog({
done={credentialsDone && !keyRejected}
title={t.stepConnect}
>
{provider.provider === "edgeone" ? (
{def.credential === "none" ? (
<p className="text-xs text-muted-foreground">
{t.edgeoneNoKey}
</p>
@@ -1027,7 +1030,7 @@ export function ModelConfigDialog({
done={models.length > 0}
title={t.stepModels}
action={
PROVIDER_INFO[provider.provider].modelList && (
def.modelList && (
<button
type="button"
onClick={handleFetchModels}
+19 -26
View File
@@ -2,6 +2,7 @@
import { ChevronRight, Key, Link2, Tag } from "lucide-react"
import { type ReactNode, useState } from "react"
import { useLocale } from "@/components/canvas/locale-context"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
@@ -13,13 +14,8 @@ import {
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { OPTIONS_OPEN } from "@/lib/provider-setup"
import {
chatRequestUrl,
normalizeBaseUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
import { chatRequestUrl, normalizeBaseUrl } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
// Logical secret field. The caller owns the actual input — plaintext for the
@@ -94,7 +90,8 @@ export function ProviderCredentialsFields({
sessionTokenInput,
}: ProviderCredentialsFieldsProps) {
const dict = useDictionary()
const info = PROVIDER_INFO[provider]
const locale = useLocale()
const info = PROVIDERS[provider]
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
example: info.defaultBaseUrl || "https://api.example.com/v1",
})
@@ -105,7 +102,7 @@ export function ProviderCredentialsFields({
() =>
!!name ||
(!!baseUrl && baseUrl !== info.defaultBaseUrl) ||
OPTIONS_OPEN.includes(provider),
!!info.optionsOpen,
)
// Bedrock: the way that is filled in (an API key is used first when both
// are, as the server does), else the one shown last, so emptying the
@@ -199,7 +196,8 @@ export function ProviderCredentialsFields({
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" && ` ${dict.modelConfig.optional}`}
{info.credential === "optionalKey" &&
` ${dict.modelConfig.optional}`}
</Label>
{info.apiKeyUrl && (
<a
@@ -248,26 +246,21 @@ export function ProviderCredentialsFields({
})}
</p>
)}
{provider === "minimax" && (
{info.baseUrlHint && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint}
</p>
)}
{provider === "mimo" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.mimoBaseUrlHint}
{info.baseUrlHint[locale]}
</p>
)}
</div>
)
// EdgeOne needs no credentials
if (provider === "edgeone") return null
// A provider without credentials (EdgeOne) has no fields
if (info.credential === "none") return null
if (settingsLayout) {
return (
<div className="space-y-4">
{provider === "bedrock" ? (
{info.credential === "aws" ? (
<>
{/* Buttons, not radios: arrow keys would empty
secrets without a deliberate press */}
@@ -393,7 +386,7 @@ export function ProviderCredentialsFields({
)}
{regionField}
</>
) : provider === "vertexai" ? (
) : info.credential === "vertexKey" ? (
<div className="space-y-2">
<Label
htmlFor="vertex-api-key"
@@ -423,14 +416,14 @@ export function ProviderCredentialsFields({
optionsOpen && "rotate-90",
)}
/>
{provider === "bedrock"
{info.credential === "aws"
? dict.modelConfig.moreOptionsName
: dict.modelConfig.moreOptions}
</button>
{optionsOpen && (
<>
{provider !== "bedrock" &&
(provider === "vertexai"
{info.credential !== "aws" &&
(info.credential === "vertexKey"
? vertexBaseUrlField
: baseUrlField)}
{nameField}
@@ -445,7 +438,7 @@ export function ProviderCredentialsFields({
<div className="space-y-5">
{nameField}
{provider === "bedrock" ? (
{info.credential === "aws" ? (
<>
{/* AWS Access Key ID */}
<div className="space-y-2">
@@ -479,7 +472,7 @@ export function ProviderCredentialsFields({
{regionField}
</>
) : provider === "vertexai" ? (
) : info.credential === "vertexKey" ? (
<>
{/* Vertex AI API Key (Express Mode) */}
<div className="space-y-2">
+9 -13
View File
@@ -1,8 +1,10 @@
import { Cloud, Server, Sparkles } from "lucide-react"
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
import { cn } from "@/lib/utils"
// Provider logo from models.dev, with Lucide fallbacks for providers
const ICONS = { cloud: Cloud, server: Server, sparkles: Sparkles }
// Provider logo from models.dev, or the record's Lucide icon for providers
// that have no logo there
export function ProviderLogo({
provider,
@@ -11,17 +13,11 @@ export function ProviderLogo({
provider: ProviderName
className?: string
}) {
if (provider === "bedrock") {
return <Cloud className={cn("size-4", className)} />
const { icon, logo } = PROVIDERS[provider]
if (icon) {
const Icon = ICONS[icon]
return <Icon className={cn("size-4", className)} />
}
if (provider === "sglang") {
return <Server className={cn("size-4", className)} />
}
if (provider === "doubao") {
return <Sparkles className={cn("size-4", className)} />
}
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
@@ -29,7 +25,7 @@ export function ProviderLogo({
aria-hidden="true"
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${logoName}.svg`}
src={`https://models.dev/logos/${logo ?? provider}.svg`}
width={16}
/>
)
+7 -11
View File
@@ -1,11 +1,12 @@
import { ChevronRight, Search } from "lucide-react"
import { useState } from "react"
import { useLocale } from "@/components/canvas/locale-context"
import { ProviderLogo } from "@/components/provider-logo"
import { SettingsHeader } from "@/components/settings/settings-header"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { filterProviderGroups, PROVIDER_NOTE_KEYS } from "@/lib/provider-setup"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
import { filterProviderGroups } from "@/lib/provider-setup"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
/** Every provider in groups, with a search; picking one adds it */
export function ProviderPicker({
@@ -20,6 +21,7 @@ export function ProviderPicker({
}) {
const dict = useDictionary()
const t = dict.modelConfig
const locale = useLocale()
const [query, setQuery] = useState("")
const groups = filterProviderGroups(query)
const groupLabels: Record<string, string> = {
@@ -28,8 +30,6 @@ export function ProviderPicker({
cloud: t.groupCloud,
selfHosted: t.groupSelfHosted,
}
const notes = t as Record<string, string>
return (
<div className="flex min-h-0 flex-1 flex-col">
<SettingsHeader>
@@ -93,9 +93,8 @@ export function ProviderPicker({
</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-2">
{group.providers.map((provider) => {
const noteKey = PROVIDER_NOTE_KEYS[provider]
const note = [
noteKey && notes[noteKey],
PROVIDERS[provider].note?.[locale],
added.includes(provider) && t.added,
]
.filter(Boolean)
@@ -116,14 +115,11 @@ export function ProviderPicker({
<span
className="block truncate text-[12.5px] font-medium"
title={
PROVIDER_INFO[provider]
PROVIDERS[provider]
.label
}
>
{
PROVIDER_INFO[provider]
.label
}
{PROVIDERS[provider].label}
</span>
{note && (
<span className="block truncate text-[11px] text-muted-foreground">
+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`
}
+2 -4
View File
@@ -552,6 +552,7 @@ test("a local server's base URL is shown from the start", async ({ page }) => {
test("Azure needs its resource URL before it counts as connected", async ({
page,
}) => {
// Azure has no default URL: a new entry starts without one
await openApp(page, {
config: {
version: 1,
@@ -560,7 +561,6 @@ test("Azure needs its resource URL before it counts as connected", async ({
id: "z1",
provider: "azure",
apiKey: "k",
baseUrl: "https://your-resource.openai.azure.com/openai",
models: [{ id: "m1", modelId: "gpt-6-astra" }],
},
],
@@ -575,9 +575,7 @@ test("Azure needs its resource URL before it counts as connected", async ({
await expect(dialog.getByTestId("step-1-todo")).toBeVisible()
await expect(dialog.getByTestId("test-models")).toBeDisabled()
await expect(
dialog.getByText(
"Fill in the API key and your Azure resource's base URL first.",
),
dialog.getByText("Fill in the API key and the base URL first."),
).toBeVisible()
await dialog
.locator("#base-url")
+30 -14
View File
@@ -1,16 +1,12 @@
import { describe, expect, it } from "vitest"
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 {
filterProviderGroups,
hasCredentials,
POPULAR_PROVIDERS,
PROVIDER_GROUPS,
PROVIDER_NOTE_KEYS,
providerStatus,
} from "@/lib/provider-setup"
import { PROVIDER_NAMES, PROVIDERS } from "@/lib/providers"
import { PROVIDER_INFO, type ProviderConfig } from "@/lib/types/model-config"
import { useUiStore } from "@/stores/ui-store"
@@ -33,15 +29,6 @@ describe("provider groups", () => {
for (const p of POPULAR_PROVIDERS) expect(PROVIDER_INFO[p]).toBeTruthy()
})
it("have their notes in every language", () => {
for (const dict of [enDict, zhDict, jaDict, zhHantDict]) {
const t = dict.modelConfig as Record<string, string>
for (const key of Object.values(PROVIDER_NOTE_KEYS)) {
expect(t[key as string]).toBeTruthy()
}
}
})
it("are searched by label or name, ignoring case and spaces", () => {
expect(filterProviderGroups(" ")).toBe(PROVIDER_GROUPS)
const kimi = filterProviderGroups("MOONSHOT")
@@ -63,6 +50,18 @@ describe("hasCredentials", () => {
[{ provider: "edgeone" }, true],
[{ provider: "vertexai", apiKey: "k" }, false],
[{ provider: "vertexai", vertexApiKey: "k" }, true],
// Azure has no default URL: the user's own is part of signing in
[{ provider: "azure", apiKey: "k" }, false],
[
{
provider: "azure",
apiKey: "k",
baseUrl: "https://contoso.openai.azure.com/openai",
},
true,
],
// SGLang's local default fills the form, so a key is enough here
[{ provider: "sglang", apiKey: "k" }, true],
[{ provider: "bedrock", apiKey: "k" }, false],
[
{ provider: "bedrock", apiKey: "k", awsRegion: "us-east-1" },
@@ -90,6 +89,23 @@ describe("hasCredentials", () => {
expect([p, hasCredentials(provider(p))]).toEqual([p, expected])
}
})
it("asks every provider for a key, an AWS pair, a Vertex key or nothing", () => {
// With only a key filled in, by the record's credential kind
for (const p of PROVIDER_NAMES) {
const { credential, defaultBaseUrl } = PROVIDERS[p]
const expected =
credential === "none" || credential === "optionalKey"
? true
: credential === "apiKey"
? !!defaultBaseUrl
: false
expect([
p,
hasCredentials(provider({ provider: p, apiKey: "k" })),
]).toEqual([p, expected])
}
})
})
describe("providerStatus", () => {
+72 -42
View File
@@ -8,10 +8,6 @@
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,
@@ -309,18 +305,63 @@ const fixture = {
"openrouter",
"qwen",
],
providerNoteKeys: {
bedrock: "noteBedrock",
vertexai: "noteVertex",
azure: "noteAzure",
edgeone: "noteEdgeone",
ollama: "noteOllama",
sglang: "noteSglang",
} as Record<string, string>,
baseUrlHintKeys: {
minimax: "minimaxBaseUrlHint",
mimo: "mimoBaseUrlHint",
} as Record<string, string>,
// 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"],
}
@@ -336,10 +377,14 @@ describe("the provider record derives the old tables", () => {
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, { ...fixture.providerInfo[p] }])
]).toEqual([p, expected])
}
})
@@ -410,37 +455,22 @@ describe("the provider record derives the old tables", () => {
})
it("the picker notes and base URL hints, word for word in every language", () => {
const dicts: Record<string, Record<string, string>> = {
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)),
)
expect(sorted(noted)).toEqual(sorted(Object.keys(fixture.notes)))
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]],
])
}
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.baseUrlHintKeys)),
sorted([...Object.keys(fixture.baseUrlHints), "azure"]),
)
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]]])
}
for (const p of Object.keys(fixture.baseUrlHints) as typeof hinted) {
expect([p, PROVIDERS[p].baseUrlHint]).toEqual([
p,
fixture.baseUrlHints[p],
])
}
})
+25
View File
@@ -382,3 +382,28 @@ describe("the Test on the deployment's own endpoints", () => {
expect(quota.checks).toBe(0)
})
})
describe("POST /api/validate-model with an unknown provider", () => {
const send = (body: object) =>
validateModel(
new Request("http://localhost/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modelId: "m", ...body }),
}),
)
it("refuses a name that is not a provider, with or without a key", async () => {
for (const body of [
{ provider: "nope", apiKey: "k" },
{ provider: "nope" },
// A prototype property is not a provider either
{ provider: "toString", apiKey: "k" },
]) {
const res = await send(body)
expect(res.status).toBe(400)
const { error } = await res.json()
expect(error).toMatch(/^Invalid provider: /)
}
})
})