From f811242568330eec37e374dd4d14b9de2a2ee655 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Mon, 12 Oct 2026 14:48:32 +0900 Subject: [PATCH] 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. --- app/[lang]/admin/models-section.tsx | 19 ++-- app/api/chat/route.ts | 31 +++--- components/model-config-dialog.tsx | 23 +++-- components/provider-credentials-fields.tsx | 45 ++++---- components/provider-logo.tsx | 22 ++-- components/settings/provider-picker.tsx | 18 ++-- lib/admin/providers.ts | 13 +-- lib/ai-providers.ts | 2 +- lib/i18n/dictionaries/en.json | 10 +- lib/i18n/dictionaries/ja.json | 10 +- lib/i18n/dictionaries/zh-Hant.json | 10 +- lib/i18n/dictionaries/zh.json | 10 +- lib/provider-models.ts | 2 +- lib/provider-setup.ts | 49 +++------ lib/providers.ts | 10 +- lib/server-model-config.ts | 4 +- lib/test-model-connection.ts | 95 ++++++++++------- lib/types/model-config.ts | 7 +- tests/e2e/settings-providers.spec.ts | 6 +- tests/unit/provider-setup.test.ts | 44 +++++--- tests/unit/providers.test.ts | 114 +++++++++++++-------- tests/unit/validate-model-route.test.ts | 25 +++++ 22 files changed, 303 insertions(+), 266 deletions(-) diff --git a/app/[lang]/admin/models-section.tsx b/app/[lang]/admin/models-section.tsx index 242d3ab8..e15fdef2 100644 --- a/app/[lang]/admin/models-section.tsx +++ b/app/[lang]/admin/models-section.tsx @@ -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 >({}) - 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({ > - {p.name || PROVIDER_INFO[p.provider].label} + {p.name || PROVIDERS[p.provider].label} {p.isDefault && ( - {(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({ >
- {PROVIDER_INFO[p].label} + {PROVIDERS[p].label} {envBlocked && ( { diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index e63e6f63..73e36198 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -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 { // 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 { 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 { 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 || diff --git a/components/model-config-dialog.tsx b/components/model-config-dialog.tsx index 98012c02..2998ed18 100644 --- a/components/model-config-dialog.tsx +++ b/components/model-config-dialog.tsx @@ -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 => ({ ...(dict.errors.llm as Record), 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" ? (

{t.edgeoneNoKey}

@@ -1027,7 +1030,7 @@ export function ModelConfigDialog({ done={models.length > 0} title={t.stepModels} action={ - PROVIDER_INFO[provider.provider].modelList && ( + def.modelList && (
) - // 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 (
- {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" ? (