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
+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">