refactor(models): unify provider metadata around backend catalog (#2896)

* feat(models): unify provider metadata around backend catalog

- Move shared provider metadata and alias normalization into backend-owned provider catalog
- Expose display, fetch, auth, and default model metadata through /api/models provider_options
- Replace frontend static provider registry with catalog-driven selection, validation, grouping, and fallback rendering
- Treat provider default api_base as placeholder and effective fetch/test base while keep submitted api_base separate from derived defaults
- Add model page retry handling, touched locale updates, and provider metadata assertions in backend tests

* fix(models): canonicalize backend provider aliases and common models

* fix(models): restore deepseek common model recommendations
This commit is contained in:
LC
2026-05-20 11:50:34 +08:00
committed by GitHub
parent 639b32703a
commit 548dc15acd
28 changed files with 1441 additions and 1084 deletions
@@ -36,10 +36,22 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
import { FetchModelsDialog } from "./fetch-models-dialog"
import {
getEffectiveAPIBase,
getSubmittedAPIBase,
normalizeApiBase,
} from "./model-provider-form-shared"
import { type FieldValidation, validateModelField } from "./model-validation"
import { ProviderCombobox } from "./provider-combobox"
import { getProviderKey } from "./provider-label"
import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry"
import {
getCanonicalProviderKey,
getProviderCatalogEntry,
getProviderCatalogMap,
getProviderDefaultAPIBase,
getProviderDefaultAuthMethod,
isProviderAuthMethodLocked,
providerSupportsFetch,
} from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog"
interface AddForm {
@@ -82,37 +94,6 @@ const EMPTY_ADD_FORM: AddForm = {
customHeaders: "",
}
function normalizeApiBase(value: string): string {
return value.trim().replace(/\/+$/, "")
}
function getNextApiBaseForProviderChange(
currentApiBase: string,
currentProvider: string,
nextProvider: string,
): string {
const normalizedCurrentApiBase = normalizeApiBase(currentApiBase)
const currentDefaultApiBase = normalizeApiBase(
PROVIDER_MAP.get(currentProvider)?.defaultApiBase ?? "",
)
const nextDefaultApiBase =
PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? ""
if (!normalizedCurrentApiBase) {
return nextDefaultApiBase
}
if (
normalizedCurrentApiBase &&
currentDefaultApiBase &&
normalizedCurrentApiBase === currentDefaultApiBase
) {
return nextDefaultApiBase
}
return currentApiBase
}
interface AddModelSheetProps {
open: boolean
onClose: () => void
@@ -144,6 +125,7 @@ export function AddModelSheet({
const [catalogModels, setCatalogModels] = useState<string[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const providerMap = getProviderCatalogMap(providerOptions)
const apiKeyPlaceholder = maskedSecretPlaceholder(
form.apiKey,
@@ -166,8 +148,12 @@ export function AddModelSheet({
// Load catalog models when provider or apiBase changes
useEffect(() => {
const providerKey = getProviderKey(form.provider || undefined)
const apiBase = form.apiBase.trim().replace(/\/+$/, "")
const providerKey = getCanonicalProviderKey(form.provider, providerOptions)
const apiBase = getEffectiveAPIBase(
form.provider,
form.apiBase,
providerOptions,
)
if (!form.provider.trim()) {
setCatalogModels([])
return
@@ -177,7 +163,7 @@ export function AddModelSheet({
.then((res) => {
if (cancelled) return
const matched = (res.entries || []).filter((e) => {
const ep = getProviderKey(e.provider || undefined)
const ep = getCanonicalProviderKey(e.provider, providerOptions)
const eb = (e.api_base ?? "").trim().replace(/\/+$/, "")
return ep === providerKey && eb === apiBase
})
@@ -189,7 +175,7 @@ export function AddModelSheet({
return () => {
cancelled = true
}
}, [form.provider, form.apiBase])
}, [form.provider, form.apiBase, providerOptions])
const validate = (): boolean => {
const errors: Partial<Record<keyof AddForm, string>> = {}
@@ -199,6 +185,9 @@ export function AddModelSheet({
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
errors.modelName = t("models.add.errorDuplicateModelName")
}
if (!providerDef) {
errors.provider = t("models.field.providerInvalid")
}
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
if (modelValidation?.level === "error") {
errors.model = t(
@@ -223,11 +212,15 @@ export function AddModelSheet({
(value: string, provider: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
const result = validateModelField(value, provider || undefined)
const result = validateModelField(
value,
provider || undefined,
providerOptions,
)
setModelValidation(result)
}, 300)
},
[],
[providerOptions],
)
const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -241,14 +234,41 @@ export function AddModelSheet({
const handleProviderChange = (provider: string) => {
setForm((f) => {
const previousOption = getProviderCatalogEntry(
f.provider,
providerOptions,
)
const nextOption = getProviderCatalogEntry(provider, providerOptions)
const previousDefaultBase = normalizeApiBase(
getProviderDefaultAPIBase(f.provider, providerOptions),
)
const nextDefaultBase = normalizeApiBase(
getProviderDefaultAPIBase(provider, providerOptions),
)
const currentApiBase = normalizeApiBase(f.apiBase)
let authMethod = f.authMethod
let apiBase = f.apiBase
if (nextOption?.authMethodLocked) {
authMethod = nextOption.defaultAuthMethod ?? ""
} else if (
previousOption?.authMethodLocked &&
f.authMethod === (previousOption.defaultAuthMethod ?? "")
) {
authMethod = ""
}
if (
currentApiBase &&
previousDefaultBase &&
currentApiBase === previousDefaultBase &&
currentApiBase !== nextDefaultBase
) {
apiBase = ""
}
return {
...f,
provider,
apiBase: getNextApiBaseForProviderChange(
f.apiBase,
f.provider,
provider,
),
provider: getCanonicalProviderKey(provider, providerOptions),
apiBase,
authMethod,
}
})
// Re-validate model with new provider context
@@ -257,11 +277,14 @@ export function AddModelSheet({
}
// Clear setAsDefault if the new provider doesn't support being default
const allowed =
providerOptions?.find((o) => o.id === provider)?.default_model_allowed ??
getProviderCatalogEntry(provider, providerOptions)?.defaultModelAllowed ??
false
if (!allowed) {
setSetAsDefault(false)
}
if (fieldErrors.provider) {
setFieldErrors((prev) => ({ ...prev, provider: undefined }))
}
}
const applyFix = () => {
@@ -290,12 +313,38 @@ export function AddModelSheet({
}
}
const providerDef = PROVIDER_MAP.get(form.provider)
const canonicalProvider = getCanonicalProviderKey(
form.provider,
providerOptions,
)
const providerDef = canonicalProvider
? providerMap.get(canonicalProvider)
: undefined
const commonModels = providerDef?.commonModels || []
const defaultModelAllowed = form.provider
? (providerOptions?.find((o) => o.id === form.provider)
?.default_model_allowed ?? false)
: false
const authMethodLocked = isProviderAuthMethodLocked(
form.provider,
providerOptions,
)
const defaultAuthMethod = getProviderDefaultAuthMethod(
form.provider,
providerOptions,
)
const effectiveAuthMethod = (
authMethodLocked ? defaultAuthMethod : form.authMethod
)
.trim()
.toLowerCase()
const isOAuth = effectiveAuthMethod === "oauth"
const defaultModelAllowed = providerDef?.defaultModelAllowed === true
const apiBasePlaceholder =
getProviderDefaultAPIBase(form.provider, providerOptions) ||
"https://api.example.com/v1"
const effectiveApiBase = getEffectiveAPIBase(
form.provider,
form.apiBase,
providerOptions,
)
const submittedApiBase = getSubmittedAPIBase(form.apiBase)
const handleSave = async () => {
if (!validate()) return
@@ -331,16 +380,18 @@ export function AddModelSheet({
setServerError("")
try {
const modelName = form.modelName.trim()
const provider = form.provider.trim()
const provider = canonicalProvider
const modelId = form.model.trim()
await addModel({
model_name: modelName,
provider: provider || undefined,
model: modelId,
api_base: form.apiBase.trim() || undefined,
api_base: submittedApiBase,
api_key: form.apiKey.trim() || undefined,
proxy: form.proxy.trim() || undefined,
auth_method: form.authMethod.trim() || undefined,
auth_method: authMethodLocked
? defaultAuthMethod || undefined
: form.authMethod.trim() || undefined,
connect_mode: form.connectMode.trim() || undefined,
workspace: form.workspace.trim() || undefined,
rpm: form.rpm ? Number(form.rpm) : undefined,
@@ -414,6 +465,8 @@ export function AddModelSheet({
<Field
label={t("models.field.provider")}
hint={t("models.field.providerHint")}
error={fieldErrors.provider}
required
>
<ProviderCombobox
value={form.provider}
@@ -517,18 +570,17 @@ export function AddModelSheet({
</div>
)}
<div className="flex items-center gap-2">
{form.provider &&
FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
)}
{providerSupportsFetch(form.provider, providerOptions) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
)}
{!form.provider && (
<span className="text-muted-foreground text-xs">
{t("models.field.selectProviderFirst")}
@@ -537,19 +589,25 @@ export function AddModelSheet({
</div>
</Field>
<Field label={t("models.field.apiKey")}>
<KeyInput
value={form.apiKey}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
placeholder={apiKeyPlaceholder}
/>
</Field>
{!isOAuth && (
<Field label={t("models.field.apiKey")}>
<KeyInput
value={form.apiKey}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
placeholder={apiKeyPlaceholder}
/>
</Field>
)}
<Field label={t("models.field.apiBase")}>
<Field
label={t("models.field.apiBase")}
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
>
<Input
value={form.apiBase}
onChange={setField("apiBase")}
placeholder="https://api.example.com/v1"
placeholder={apiBasePlaceholder}
disabled={isOAuth}
/>
</Field>
@@ -591,12 +649,19 @@ export function AddModelSheet({
<Field
label={t("models.field.authMethod")}
hint={t("models.field.authMethodHint")}
hint={
authMethodLocked
? t("models.field.authMethodManagedHint")
: t("models.field.authMethodHint")
}
>
<Input
value={form.authMethod}
value={
authMethodLocked ? defaultAuthMethod : form.authMethod
}
onChange={setField("authMethod")}
placeholder="oauth"
disabled={authMethodLocked}
/>
</Field>
@@ -751,9 +816,10 @@ export function AddModelSheet({
open={fetchOpen}
onClose={() => setFetchOpen(false)}
onFill={handleFetchFill}
provider={form.provider}
provider={canonicalProvider}
apiKey={form.apiKey}
apiBase={form.apiBase}
apiBase={effectiveApiBase}
backendOptions={providerOptions}
/>
<TestModelDialog
@@ -761,11 +827,11 @@ export function AddModelSheet({
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
provider: canonicalProvider,
model: form.model,
apiBase: form.apiBase,
apiBase: effectiveApiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
authMethod: effectiveAuthMethod,
}}
/>
</Sheet>
@@ -11,6 +11,7 @@ import { toast } from "sonner"
import {
type CatalogEntry,
type CatalogModel,
type ModelProviderOption,
addModel,
deleteCatalog,
getCatalogs,
@@ -27,21 +28,26 @@ import {
import { Input } from "@/components/ui/input"
import { refreshGatewayState } from "@/store/gateway"
import { getProviderLabel } from "./provider-label"
import { PROVIDER_MAP } from "./provider-registry"
import {
getCanonicalProviderKey,
getProviderCatalogMap,
} from "./provider-registry"
interface CatalogDialogProps {
open: boolean
onClose: () => void
onModelAdded: () => void
providerOptions?: ModelProviderOption[]
}
export function CatalogDialog({
open,
onClose,
onModelAdded,
providerOptions,
}: CatalogDialogProps) {
const { t } = useTranslation()
const providerMap = getProviderCatalogMap(providerOptions)
const [loading, setLoading] = useState(false)
const [entries, setEntries] = useState<CatalogEntry[]>([])
const [expandedId, setExpandedId] = useState<string | null>(null)
@@ -188,6 +194,11 @@ export function CatalogDialog({
const isExpanded = expandedId === entry.id
const entrySelected = selected.get(entry.id) || new Set()
const filteredModels = getFilteredModels(entry.models)
const providerKey = getCanonicalProviderKey(
entry.provider,
providerOptions,
)
const providerDef = providerMap.get(providerKey)
return (
<div
@@ -206,7 +217,7 @@ export function CatalogDialog({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{getProviderLabel(entry.provider)}
{providerDef?.label || providerKey}
</span>
<span className="text-muted-foreground font-mono text-xs">
{entry.api_key_mask}
@@ -290,7 +301,7 @@ export function CatalogDialog({
</div>
{entrySelected.size > 0 && (
<div className="mt-2 space-y-2">
{PROVIDER_MAP.get(entry.provider)?.requiresApiKey !==
{providerDef?.requiresApiKey !==
false && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-2 text-xs text-yellow-700 dark:text-yellow-400">
{t("models.catalog.needApiKey")}
@@ -37,13 +37,21 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
import { FetchModelsDialog } from "./fetch-models-dialog"
import {
getEffectiveAPIBase,
getSubmittedAPIBase,
normalizeApiBase,
} from "./model-provider-form-shared"
import { type FieldValidation, validateModelField } from "./model-validation"
import { ProviderCombobox } from "./provider-combobox"
import { getProviderKey } from "./provider-label"
import {
FETCHABLE_PROVIDER_KEYS,
PROVIDER_API_BASES,
PROVIDER_MAP,
getCanonicalProviderKey,
getProviderCatalogEntry,
getProviderCatalogMap,
getProviderDefaultAPIBase,
getProviderDefaultAuthMethod,
isProviderAuthMethodLocked,
providerSupportsFetch,
} from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog"
@@ -74,39 +82,9 @@ interface EditModelSheetProps {
providerOptions?: ModelProviderOption[]
}
function normalizeApiBase(value: string): string {
return value.trim().replace(/\/+$/, "")
}
function getNextApiBaseForProviderChange(
currentApiBase: string,
currentProvider: string,
nextProvider: string,
): string {
const normalizedCurrentApiBase = normalizeApiBase(currentApiBase)
const currentDefaultApiBase = normalizeApiBase(
PROVIDER_API_BASES[currentProvider] || "",
)
const nextDefaultApiBase = PROVIDER_API_BASES[nextProvider] || ""
if (!normalizedCurrentApiBase) {
return nextDefaultApiBase
}
if (
normalizedCurrentApiBase &&
currentDefaultApiBase &&
normalizedCurrentApiBase === currentDefaultApiBase
) {
return nextDefaultApiBase
}
return currentApiBase
}
function buildInitialEditForm(model: ModelInfo): EditForm {
return {
provider: model.provider ?? "",
provider: getCanonicalProviderKey(model.provider),
modelId: model.model,
apiKey: "",
apiBase: model.api_base ?? "",
@@ -166,6 +144,7 @@ export function EditModelSheet({
const [catalogModels, setCatalogModels] = useState<string[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const providerMap = getProviderCatalogMap(providerOptions)
const initialForm = model ? buildInitialEditForm(model) : null
const isDirty =
@@ -182,12 +161,19 @@ export function EditModelSheet({
setFetchedModels([])
setCatalogModels([])
// Load matching catalog models
const providerKey = getProviderKey(model.provider || undefined)
const apiBase = (model.api_base ?? "").trim().replace(/\/+$/, "")
const providerKey = getCanonicalProviderKey(
model.provider,
providerOptions,
)
const apiBase = getEffectiveAPIBase(
model.provider ?? "",
model.api_base ?? "",
providerOptions,
)
getCatalogs()
.then((res) => {
const matched = (res.entries || []).filter((e) => {
const ep = getProviderKey(e.provider || undefined)
const ep = getCanonicalProviderKey(e.provider, providerOptions)
const eb = (e.api_base ?? "").trim().replace(/\/+$/, "")
return ep === providerKey && eb === apiBase
})
@@ -197,22 +183,28 @@ export function EditModelSheet({
})
.catch(() => {})
}
}, [model])
}, [model, providerOptions])
const setField =
(key: keyof EditForm) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (error) setError("")
setForm((f) => ({ ...f, [key]: e.target.value }))
}
const debouncedValidateModel = useCallback(
(value: string, provider: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
const result = validateModelField(value, provider || undefined)
const result = validateModelField(
value,
provider || undefined,
providerOptions,
)
setModelValidation(result)
}, 300)
},
[],
[providerOptions],
)
const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -222,16 +214,50 @@ export function EditModelSheet({
}
const handleProviderChange = (provider: string) => {
setForm((f) => ({
...f,
provider,
apiBase: getNextApiBaseForProviderChange(f.apiBase, f.provider, provider),
}))
if (error) setError("")
setForm((f) => {
const previousOption = getProviderCatalogEntry(
f.provider,
providerOptions,
)
const nextOption = getProviderCatalogEntry(provider, providerOptions)
const previousDefaultBase = normalizeApiBase(
getProviderDefaultAPIBase(f.provider, providerOptions),
)
const nextDefaultBase = normalizeApiBase(
getProviderDefaultAPIBase(provider, providerOptions),
)
const currentApiBase = normalizeApiBase(f.apiBase)
let authMethod = f.authMethod
let apiBase = f.apiBase
if (nextOption?.authMethodLocked) {
authMethod = nextOption.defaultAuthMethod ?? ""
} else if (
previousOption?.authMethodLocked &&
f.authMethod === (previousOption.defaultAuthMethod ?? "")
) {
authMethod = ""
}
if (
currentApiBase &&
previousDefaultBase &&
currentApiBase === previousDefaultBase &&
currentApiBase !== nextDefaultBase
) {
apiBase = ""
}
return {
...f,
provider: getCanonicalProviderKey(provider, providerOptions),
apiBase,
authMethod,
}
})
if (form.modelId) {
debouncedValidateModel(form.modelId, provider)
}
const allowed =
providerOptions?.find((o) => o.id === provider)?.default_model_allowed ??
getProviderCatalogEntry(provider, providerOptions)?.defaultModelAllowed ??
false
if (!allowed) {
setSetAsDefault(false)
@@ -258,15 +284,45 @@ export function EditModelSheet({
}
}
const providerDef = PROVIDER_MAP.get(form.provider)
const canonicalProvider = getCanonicalProviderKey(
form.provider,
providerOptions,
)
const providerDef = canonicalProvider
? providerMap.get(canonicalProvider)
: undefined
const commonModels = providerDef?.commonModels || []
const defaultModelAllowed = form.provider
? (providerOptions?.find((o) => o.id === form.provider)
?.default_model_allowed ?? false)
: false
const authMethodLocked = isProviderAuthMethodLocked(
form.provider,
providerOptions,
)
const defaultAuthMethod = getProviderDefaultAuthMethod(
form.provider,
providerOptions,
)
const effectiveAuthMethod = (
authMethodLocked ? defaultAuthMethod : form.authMethod
)
.trim()
.toLowerCase()
const isOAuth = effectiveAuthMethod === "oauth"
const defaultModelAllowed = providerDef?.defaultModelAllowed === true
const apiBasePlaceholder =
getProviderDefaultAPIBase(form.provider, providerOptions) ||
"https://api.example.com/v1"
const effectiveApiBase = getEffectiveAPIBase(
form.provider,
form.apiBase,
providerOptions,
)
const submittedApiBase = getSubmittedAPIBase(form.apiBase)
const handleSave = async () => {
if (!model) return
if (!providerDef) {
setError(t("models.field.providerInvalid"))
return
}
if (!form.modelId.trim()) {
setError(t("models.add.errorRequired"))
return
@@ -304,7 +360,7 @@ export function EditModelSheet({
setError("")
try {
const modelId = form.modelId.trim()
const provider = form.provider.trim()
const provider = canonicalProvider
const streaming =
model.streaming?.enabled === true || form.streamingEnabled
? { enabled: form.streamingEnabled }
@@ -313,18 +369,20 @@ export function EditModelSheet({
model_name: model.model_name,
provider: provider,
model: modelId,
api_base: form.apiBase || undefined,
api_key: form.apiKey || undefined,
proxy: form.proxy || undefined,
auth_method: form.authMethod || undefined,
connect_mode: form.connectMode || undefined,
workspace: form.workspace || undefined,
api_base: submittedApiBase,
api_key: form.apiKey.trim() || undefined,
proxy: form.proxy.trim() || undefined,
auth_method: authMethodLocked
? defaultAuthMethod || undefined
: form.authMethod.trim() || undefined,
connect_mode: form.connectMode.trim() || undefined,
workspace: form.workspace.trim() || undefined,
rpm: form.rpm ? Number(form.rpm) : undefined,
max_tokens_field: form.maxTokensField || undefined,
max_tokens_field: form.maxTokensField.trim() || undefined,
request_timeout: form.requestTimeout
? Number(form.requestTimeout)
: undefined,
thinking_level: form.thinkingLevel || undefined,
thinking_level: form.thinkingLevel.trim() || undefined,
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
streaming,
extra_body: extraBody,
@@ -349,7 +407,6 @@ export function EditModelSheet({
}
}
const isOAuth = model?.auth_method === "oauth"
const hasSavedAPIKey = Boolean(model?.api_key)
const apiKeyPlaceholder = hasSavedAPIKey
? maskedSecretPlaceholder(
@@ -382,6 +439,12 @@ export function EditModelSheet({
<Field
label={t("models.field.provider")}
hint={t("models.field.providerHint")}
error={
!providerDef && form.provider
? t("models.field.providerInvalid")
: undefined
}
required
>
<ProviderCombobox
value={form.provider}
@@ -477,18 +540,17 @@ export function EditModelSheet({
</div>
)}
<div className="flex items-center gap-2">
{form.provider &&
FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
)}
{providerSupportsFetch(form.provider, providerOptions) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
)}
</div>
</Field>
@@ -514,7 +576,7 @@ export function EditModelSheet({
<Input
value={form.apiBase}
onChange={setField("apiBase")}
placeholder="https://api.example.com/v1"
placeholder={apiBasePlaceholder}
disabled={isOAuth}
/>
</Field>
@@ -557,12 +619,19 @@ export function EditModelSheet({
<Field
label={t("models.field.authMethod")}
hint={t("models.field.authMethodHint")}
hint={
authMethodLocked
? t("models.field.authMethodManagedHint")
: t("models.field.authMethodHint")
}
>
<Input
value={form.authMethod}
value={
authMethodLocked ? defaultAuthMethod : form.authMethod
}
onChange={setField("authMethod")}
placeholder="oauth"
disabled={authMethodLocked}
/>
</Field>
@@ -719,11 +788,11 @@ export function EditModelSheet({
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
provider: canonicalProvider,
model: form.modelId,
apiBase: form.apiBase,
apiBase: effectiveApiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
authMethod: effectiveAuthMethod,
modelIndex: model?.index,
}}
/>
@@ -732,9 +801,10 @@ export function EditModelSheet({
open={fetchOpen}
onClose={() => setFetchOpen(false)}
onFill={handleFetchFill}
provider={form.provider}
provider={canonicalProvider}
apiKey={form.apiKey}
apiBase={form.apiBase}
apiBase={effectiveApiBase}
backendOptions={providerOptions}
/>
</>
)
@@ -2,7 +2,11 @@ import { IconDownload, IconLoader2 } from "@tabler/icons-react"
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { type UpstreamModel, fetchUpstreamModels } from "@/api/models"
import {
type ModelProviderOption,
type UpstreamModel,
fetchUpstreamModels,
} from "@/api/models"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -14,7 +18,10 @@ import {
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { PROVIDER_MAP } from "./provider-registry"
import {
getCanonicalProviderKey,
getProviderCatalogMap,
} from "./provider-registry"
interface FetchModelsDialogProps {
open: boolean
@@ -23,6 +30,7 @@ interface FetchModelsDialogProps {
provider: string
apiKey: string
apiBase: string
backendOptions?: ModelProviderOption[]
}
export function FetchModelsDialog({
@@ -32,6 +40,7 @@ export function FetchModelsDialog({
provider,
apiKey,
apiBase,
backendOptions,
}: FetchModelsDialogProps) {
const { t } = useTranslation()
const [fetching, setFetching] = useState(false)
@@ -40,7 +49,8 @@ export function FetchModelsDialog({
const [error, setError] = useState("")
const [filter, setFilter] = useState("")
const providerDef = PROVIDER_MAP.get(provider)
const canonicalProvider = getCanonicalProviderKey(provider, backendOptions)
const providerDef = getProviderCatalogMap(backendOptions).get(canonicalProvider)
const needsKey = providerDef?.requiresApiKey !== false
const handleFetch = useCallback(async () => {
@@ -50,7 +60,7 @@ export function FetchModelsDialog({
setSelected(new Set())
try {
const res = await fetchUpstreamModels({
provider,
provider: canonicalProvider,
api_key: apiKey,
api_base: apiBase,
})
@@ -62,7 +72,7 @@ export function FetchModelsDialog({
} finally {
setFetching(false)
}
}, [provider, apiKey, apiBase, t])
}, [canonicalProvider, apiKey, apiBase, t])
// Auto-fetch when dialog opens (skip if provider requires API key but none is set)
useEffect(() => {
@@ -122,7 +132,7 @@ export function FetchModelsDialog({
{t("models.fetch.description")}
{provider && (
<span className="mt-1 block font-mono text-xs">
{t("models.fetch.providerLabel")} {provider}
{t("models.fetch.providerLabel")} {canonicalProvider}
{apiBase && ` | ${apiBase}`}
</span>
)}
@@ -0,0 +1,21 @@
import type { ModelProviderOption } from "@/api/models"
import { getProviderDefaultAPIBase } from "./provider-registry"
export function normalizeApiBase(value: string): string {
return value.trim().replace(/\/+$/, "")
}
export function getEffectiveAPIBase(
provider: string,
apiBase: string,
providerOptions?: ModelProviderOption[],
): string {
return normalizeApiBase(
apiBase || getProviderDefaultAPIBase(provider, providerOptions),
)
}
export function getSubmittedAPIBase(apiBase: string): string | undefined {
return normalizeApiBase(apiBase) || undefined
}
@@ -5,10 +5,12 @@
* Messages use i18n keys with interpolation params — callers must
* translate them via t(key, params).
*/
import type { ModelProviderOption } from "@/api/models"
import {
KNOWN_PROVIDER_KEYS,
PROVIDER_ALIASES,
findClosestProvider,
getCanonicalProviderKey,
getKnownProviderKeys,
} from "./provider-registry"
export type ValidationLevel = "error" | "warning" | "success"
@@ -27,9 +29,11 @@ export interface FieldValidation {
export function validateModelField(
input: string,
selectedProvider?: string,
backendOptions?: ModelProviderOption[],
): FieldValidation {
const trimmed = input.trim()
if (!trimmed) return { level: "success", messageKey: "" }
const knownProviderKeys = getKnownProviderKeys(backendOptions)
// Hard errors
if (/\s/.test(trimmed)) {
@@ -78,10 +82,10 @@ export function validateModelField(
return { level: "error", messageKey: "models.validation.emptyModel" }
}
if (!KNOWN_PROVIDER_KEYS.has(provider)) {
if (!knownProviderKeys.has(provider)) {
// Check aliases
const alias = PROVIDER_ALIASES[provider]
if (alias) {
const alias = getCanonicalProviderKey(provider, backendOptions)
if (alias && alias !== provider) {
return {
level: "warning",
messageKey: "models.validation.shouldUse",
@@ -90,7 +94,7 @@ export function validateModelField(
}
}
// Typo check
const closest = findClosestProvider(provider)
const closest = findClosestProvider(provider, backendOptions)
if (closest) {
return {
level: "warning",
@@ -23,13 +23,16 @@ import { AddModelSheet } from "./add-model-sheet"
import { CatalogDialog } from "./catalog-dialog"
import { DeleteModelDialog } from "./delete-model-dialog"
import { EditModelSheet } from "./edit-model-sheet"
import { getProviderKey, getProviderLabel } from "./provider-label"
import { PROVIDER_PRIORITY } from "./provider-registry"
import {
getCanonicalProviderKey,
getProviderCatalogMap,
} from "./provider-registry"
import { ProviderSection } from "./provider-section"
import type { ProviderCatalogEntry } from "./provider-registry"
interface ProviderGroup {
key: string
label: string
provider: Pick<ProviderCatalogEntry, "key" | "label" | "iconSlug" | "domain">
models: ModelInfo[]
hasDefault: boolean
availableCount: number
@@ -51,8 +54,10 @@ export function ModelsPage() {
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
null,
)
const providerMap = getProviderCatalogMap(providerOptions)
const fetchModels = useCallback(async () => {
setLoading(true)
try {
const data = await getModels()
const sorted = [...data.models].sort((a, b) => {
@@ -97,12 +102,21 @@ export function ModelsPage() {
}
}
const grouped: Record<string, { label: string; models: ModelInfo[] }> = {}
const grouped: Record<
string,
{ provider: Pick<ProviderCatalogEntry, "key" | "label" | "iconSlug" | "domain">; models: ModelInfo[] }
> = {}
for (const model of models) {
const providerKey = getProviderKey(model.provider)
const providerKey = getCanonicalProviderKey(model.provider, providerOptions)
const providerDef = providerKey ? providerMap.get(providerKey) : undefined
if (!grouped[providerKey]) {
grouped[providerKey] = {
label: getProviderLabel(model.provider),
provider: {
key: providerKey,
label: providerDef?.label || providerKey,
iconSlug: providerDef?.iconSlug,
domain: providerDef?.domain,
},
models: [],
}
}
@@ -116,7 +130,7 @@ export function ModelsPage() {
).length
return {
key,
label: group.label,
provider: group.provider,
models: group.models,
hasDefault: group.models.some((model) => model.is_default),
availableCount,
@@ -130,13 +144,13 @@ export function ModelsPage() {
return b.availableCount - a.availableCount
}
const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER
const bPriority = PROVIDER_PRIORITY[b.key] ?? Number.MAX_SAFE_INTEGER
const aPriority = -(providerMap.get(a.key)?.priority ?? 0)
const bPriority = -(providerMap.get(b.key)?.priority ?? 0)
if (aPriority !== bPriority) {
return aPriority - bPriority
}
return a.label.localeCompare(b.label)
return a.provider.label.localeCompare(b.provider.label)
})
const defaultModel = models.find((model) => model.is_default)
@@ -149,11 +163,17 @@ export function ModelsPage() {
size="sm"
variant="outline"
onClick={() => setCatalogOpen(true)}
disabled={providerOptions.length === 0}
>
<IconDatabase className="size-4" />
{t("models.catalog.button")}
</Button>
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
<Button
size="sm"
variant="outline"
onClick={() => setAddOpen(true)}
disabled={providerOptions.length === 0}
>
<IconPlus className="size-4" />
{t("models.add.button")}
</Button>
@@ -172,6 +192,11 @@ export function ModelsPage() {
<p className="text-muted-foreground mt-1 text-sm">
{t("models.description")}
</p>
{!loading && providerOptions.length === 0 && (
<p className="text-muted-foreground mt-1 text-sm">
{t("models.providerCatalogUnavailable")}
</p>
)}
</div>
{loading && (
@@ -181,8 +206,19 @@ export function ModelsPage() {
)}
{fetchError && (
<div className="text-destructive bg-destructive/10 rounded-lg px-4 py-3 text-sm">
{fetchError}
<div className="bg-destructive/10 rounded-lg px-4 py-3 text-sm">
<p className="text-destructive">{fetchError}</p>
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
void fetchModels()
}}
>
{t("models.retry")}
</Button>
</div>
</div>
)}
@@ -191,8 +227,7 @@ export function ModelsPage() {
{providerGroups.map((providerGroup) => (
<ProviderSection
key={providerGroup.key}
provider={providerGroup.label}
providerKey={providerGroup.key}
provider={providerGroup.provider}
models={providerGroup.models}
onEdit={setEditingModel}
onSetDefault={handleSetDefault}
@@ -230,6 +265,7 @@ export function ModelsPage() {
open={catalogOpen}
onClose={() => setCatalogOpen(false)}
onModelAdded={fetchModels}
providerOptions={providerOptions}
/>
</div>
)
@@ -11,7 +11,6 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
@@ -21,9 +20,9 @@ import { cn } from "@/lib/utils"
import { ProviderIcon } from "./provider-icon"
import {
type MergedProvider,
PROVIDERS,
mergeWithBackendOptions,
getCanonicalProviderKey,
type ProviderCatalogEntry,
getProviderCatalog,
} from "./provider-registry"
import type { ModelProviderOption } from "@/api/models"
@@ -48,47 +47,23 @@ export function ProviderCombobox({
}: ProviderComboboxProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [customMode, setCustomMode] = useState(false)
const [customValue, setCustomValue] = useState("")
const [containerEl, setContainerEl] = useState<HTMLElement | null>(null)
useEffect(() => {
setContainerEl(containerRef?.current ?? null)
}, [containerRef])
const allProviders: MergedProvider[] = backendOptions
? mergeWithBackendOptions(backendOptions)
: [...PROVIDERS]
.sort((a, b) => b.priority - a.priority)
.map((p) => ({
...p,
createAllowed: true,
defaultModelAllowed: false,
}))
const canonicalValue = getCanonicalProviderKey(value, backendOptions)
const allProviders: ProviderCatalogEntry[] = getProviderCatalog(backendOptions)
const visible = filterCreateAllowed
? allProviders.filter((p) => p.createAllowed)
? allProviders.filter((p) => p.createAllowed || p.key === canonicalValue)
: allProviders
const allKeys = new Set(allProviders.map((p) => p.key))
const selected = allProviders.find((p) => p.key === value)
const isCustom = value && !allKeys.has(value)
const selected = allProviders.find((p) => p.key === canonicalValue)
const showUnknownValue = value && !allKeys.has(canonicalValue)
const handleSelect = (currentValue: string) => {
if (currentValue === "__custom__") {
setCustomMode(true)
setCustomValue(isCustom ? value : "")
return
}
onChange(currentValue === value ? "" : currentValue)
setCustomMode(false)
setOpen(false)
}
const handleCustomConfirm = () => {
const trimmed = customValue.trim()
if (trimmed) {
onChange(trimmed)
}
setCustomMode(false)
onChange(currentValue === canonicalValue ? "" : currentValue)
setOpen(false)
}
@@ -97,7 +72,6 @@ export function ProviderCombobox({
open={open}
onOpenChange={(isOpen: boolean) => {
setOpen(isOpen)
if (!isOpen) setCustomMode(false)
}}
>
<PopoverTrigger asChild>
@@ -110,12 +84,11 @@ export function ProviderCombobox({
{selected ? (
<span className="flex items-center gap-2">
<ProviderIcon
providerKey={selected.key}
providerLabel={selected.label}
provider={selected}
/>
{selected.labelZh || selected.label}
{selected.label}
</span>
) : isCustom ? (
) : showUnknownValue ? (
<span className="flex items-center gap-2 font-mono text-sm">
{value}
</span>
@@ -128,97 +101,52 @@ export function ProviderCombobox({
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" container={containerEl}>
{customMode ? (
<div className="flex flex-col gap-2 p-2">
<Input
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={t("models.combobox.customPlaceholder")}
className="h-8 font-mono text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCustomConfirm()
if (e.key === "Escape") {
setCustomMode(false)
setOpen(false)
}
}}
/>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 flex-1 text-xs"
onClick={() => {
setCustomMode(false)
setOpen(false)
}}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
className="h-7 flex-1 text-xs"
onClick={handleCustomConfirm}
disabled={!customValue.trim()}
>
{t("common.confirm")}
</Button>
</div>
</div>
) : (
<Command>
<CommandInput placeholder={t("models.combobox.searchProvider")} />
<CommandList>
<CommandEmpty>{t("models.combobox.noProvider")}</CommandEmpty>
<CommandGroup>
{visible.map((provider) => (
<Command>
<CommandInput placeholder={t("models.combobox.searchProvider")} />
<CommandList>
<CommandEmpty>
{backendOptions && backendOptions.length > 0
? t("models.combobox.noProvider")
: t("models.combobox.noCatalog")}
</CommandEmpty>
<CommandGroup>
{visible.map((provider) => {
const disabled = !provider.createAllowed && provider.key !== value
return (
<CommandItem
key={provider.key}
value={provider.key}
keywords={[
provider.label,
provider.labelZh || "",
...(provider.aliases || []),
...provider.aliases,
]}
onSelect={handleSelect}
disabled={disabled}
>
<span className="flex items-center gap-2">
<ProviderIcon
providerKey={provider.key}
providerLabel={provider.label}
provider={provider}
/>
<span>{provider.labelZh || provider.label}</span>
<span>{provider.label}</span>
{provider.isLocal && (
<span className="text-muted-foreground text-xs">
{t("models.combobox.local")}
</span>
<span className="text-muted-foreground text-xs">
{t("models.combobox.local")}
</span>
)}
</span>
<IconCheck
className={cn(
"ml-auto size-4",
value === provider.key ? "opacity-100" : "opacity-0",
canonicalValue === provider.key ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
<CommandItem
value="__custom__"
keywords={["custom", "自定义"]}
onSelect={handleSelect}
>
<span className="text-muted-foreground italic">
{t("models.combobox.custom")}
</span>
{isCustom && (
<IconCheck className="ml-auto size-4 opacity-100" />
)}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
)}
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
@@ -1,22 +1,18 @@
import { useMemo, useState } from "react"
import { PROVIDER_DOMAINS, PROVIDER_ICON_SLUGS } from "./provider-registry"
import type { ProviderCatalogEntry } from "./provider-registry"
interface ProviderIconProps {
providerKey: string
providerLabel: string
provider: Pick<ProviderCatalogEntry, "key" | "label" | "iconSlug" | "domain">
}
export function ProviderIcon({
providerKey,
providerLabel,
}: ProviderIconProps) {
export function ProviderIcon({ provider }: ProviderIconProps) {
const [sourceIndex, setSourceIndex] = useState(0)
const [loadFailed, setLoadFailed] = useState(false)
const initial = providerLabel.trim().charAt(0).toUpperCase() || "?"
const initial = provider.label.trim().charAt(0).toUpperCase() || "?"
const iconUrls = useMemo(() => {
const slug = PROVIDER_ICON_SLUGS[providerKey]
const domain = PROVIDER_DOMAINS[providerKey]
const slug = provider.iconSlug
const domain = provider.domain
const urls: string[] = []
if (slug) {
urls.push(`https://cdn.simpleicons.org/${slug}`)
@@ -25,7 +21,7 @@ export function ProviderIcon({
urls.push(`https://www.google.com/s2/favicons?domain=${domain}&sz=64`)
}
return urls
}, [providerKey])
}, [provider.domain, provider.iconSlug])
const iconUrl = iconUrls[sourceIndex]
@@ -41,7 +37,7 @@ export function ProviderIcon({
<span className="inline-flex size-4 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-black/10 bg-white p-0.5 dark:border-white/20">
<img
src={iconUrl}
alt={`${providerLabel} logo`}
alt={`${provider.label} logo`}
className="size-full object-contain"
loading="lazy"
referrerPolicy="no-referrer"
@@ -1,14 +0,0 @@
import { PROVIDER_ALIASES, PROVIDER_LABELS } from "./provider-registry"
export function getProviderKey(provider?: string): string {
const normalized = provider?.trim().toLowerCase()
if (!normalized) return "openai"
return PROVIDER_ALIASES[normalized] ?? normalized
}
export function getProviderLabel(provider?: string): string {
const prefix = getProviderKey(provider)
return PROVIDER_LABELS[prefix] ?? prefix
}
export { PROVIDER_LABELS, PROVIDER_ALIASES }
@@ -1,459 +1,175 @@
/**
* Unified provider registry — single source of truth for all provider metadata.
* All consumer files (provider-label, provider-icon, models-page, add/edit sheets)
* should derive their data from this registry.
*/
import type { ModelProviderOption } from "@/api/models"
export interface ProviderDefinition {
export interface ProviderCatalogEntry {
key: string
label: string
labelZh?: string
iconSlug?: string
domain?: string
priority: number
isLocal: boolean
defaultApiBase?: string
requiresApiKey: boolean
isLocal: boolean
priority: number
commonModels?: string[]
aliases?: string[]
/** Whether this provider supports the OpenAI-compatible /models listing endpoint. */
supportsFetch?: boolean
createAllowed: boolean
defaultModelAllowed: boolean
supportsFetch: boolean
defaultAuthMethod?: string
authMethodLocked?: boolean
emptyApiKeyAllowed?: boolean
commonModels: string[]
aliases: string[]
}
export const PROVIDERS: ProviderDefinition[] = [
{
key: "openai",
label: "OpenAI",
iconSlug: "openai",
domain: "openai.com",
defaultApiBase: "https://api.openai.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 100,
commonModels: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1", "o3-mini"],
aliases: ["gpt"],
supportsFetch: true,
},
{
key: "anthropic",
label: "Anthropic",
iconSlug: "anthropic",
domain: "anthropic.com",
defaultApiBase: "https://api.anthropic.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 95,
commonModels: [
"claude-sonnet-4-20250514",
"claude-haiku-4-20250414",
"claude-3-5-sonnet-20241022",
],
aliases: ["claude"],
},
{
key: "gemini",
label: "Google Gemini",
iconSlug: "googlegemini",
domain: "gemini.google.com",
defaultApiBase: "https://generativelanguage.googleapis.com/v1beta",
requiresApiKey: true,
isLocal: false,
priority: 90,
commonModels: ["gemini-2.0-flash", "gemini-2.5-pro", "gemini-1.5-flash"],
aliases: ["google"],
},
{
key: "deepseek",
label: "DeepSeek",
iconSlug: "deepseek",
domain: "deepseek.com",
defaultApiBase: "https://api.deepseek.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 85,
commonModels: ["deepseek-chat", "deepseek-reasoner"],
supportsFetch: true,
},
{
key: "openrouter",
label: "OpenRouter",
iconSlug: "openrouter",
domain: "openrouter.ai",
defaultApiBase: "https://openrouter.ai/api/v1",
requiresApiKey: true,
isLocal: false,
priority: 80,
commonModels: [
"openai/gpt-4o",
"anthropic/claude-sonnet-4",
"google/gemini-2.0-flash",
],
supportsFetch: true,
},
{
key: "qwen-portal",
label: "Qwen",
labelZh: "Qwen (阿里云)",
iconSlug: "alibabacloud",
domain: "qwenlm.ai",
defaultApiBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
requiresApiKey: true,
isLocal: false,
priority: 75,
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
aliases: ["qwen"],
supportsFetch: true,
},
{
key: "qwen-intl",
label: "Qwen International",
iconSlug: "alibabacloud",
domain: "alibabacloud.com",
defaultApiBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
requiresApiKey: true,
isLocal: false,
priority: 74,
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
aliases: ["qwen-international", "dashscope-intl"],
supportsFetch: true,
},
{
key: "moonshot",
label: "Moonshot",
labelZh: "Moonshot (月之暗面)",
domain: "moonshot.ai",
defaultApiBase: "https://api.moonshot.cn/v1",
requiresApiKey: true,
isLocal: false,
priority: 70,
commonModels: ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"],
supportsFetch: true,
},
{
key: "volcengine",
label: "Volcengine",
labelZh: "Volcengine (火山引擎)",
iconSlug: "bytedance",
domain: "volcengine.com",
defaultApiBase: "https://ark.cn-beijing.volces.com/api/v3",
requiresApiKey: true,
isLocal: false,
priority: 69,
commonModels: ["doubao-1.5-pro", "doubao-1.5-lite"],
supportsFetch: true,
},
{
key: "zhipu",
label: "Zhipu AI",
labelZh: "Zhipu AI (智谱)",
iconSlug: "zhipu",
domain: "zhipuai.cn",
defaultApiBase: "https://open.bigmodel.cn/api/paas/v4",
requiresApiKey: true,
isLocal: false,
priority: 68,
commonModels: ["glm-4-plus", "glm-4-flash"],
supportsFetch: true,
},
{
key: "groq",
label: "Groq",
iconSlug: "groq",
domain: "groq.com",
defaultApiBase: "https://api.groq.com/openai/v1",
requiresApiKey: true,
isLocal: false,
priority: 65,
commonModels: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"],
supportsFetch: true,
},
{
key: "mistral",
label: "Mistral AI",
iconSlug: "mistralai",
domain: "mistral.ai",
defaultApiBase: "https://api.mistral.ai/v1",
requiresApiKey: true,
isLocal: false,
priority: 64,
commonModels: ["mistral-large-latest", "mistral-small-latest"],
supportsFetch: true,
},
{
key: "nvidia",
label: "NVIDIA",
iconSlug: "nvidia",
domain: "nvidia.com",
defaultApiBase: "https://integrate.api.nvidia.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 63,
commonModels: ["meta/llama-3.1-405b-instruct"],
supportsFetch: true,
},
{
key: "cerebras",
label: "Cerebras",
iconSlug: "cerebras",
domain: "cerebras.ai",
defaultApiBase: "https://api.cerebras.ai/v1",
requiresApiKey: true,
isLocal: false,
priority: 62,
commonModels: ["llama3.1-8b", "llama3.1-70b"],
supportsFetch: true,
},
{
key: "azure",
label: "Azure OpenAI",
iconSlug: "microsoftazure",
domain: "azure.com",
requiresApiKey: true,
isLocal: false,
priority: 61,
commonModels: ["gpt-4o", "gpt-4o-mini"],
},
{
key: "github-copilot",
label: "GitHub Copilot",
iconSlug: "githubcopilot",
domain: "github.com",
requiresApiKey: false,
isLocal: true,
priority: 55,
},
{
key: "antigravity",
label: "Google Code Assist",
domain: "antigravity.google",
requiresApiKey: false,
isLocal: false,
priority: 54,
},
{
key: "ollama",
label: "Ollama",
labelZh: "Ollama (本地)",
iconSlug: "ollama",
domain: "ollama.com",
defaultApiBase: "http://localhost:11434/v1",
requiresApiKey: false,
isLocal: true,
priority: 50,
commonModels: ["llama3", "mistral", "codellama", "qwen2.5"],
supportsFetch: true,
},
{
key: "vllm",
label: "VLLM",
labelZh: "VLLM (本地)",
domain: "vllm.ai",
defaultApiBase: "http://localhost:8000/v1",
requiresApiKey: false,
isLocal: true,
priority: 49,
supportsFetch: true,
},
{
key: "lmstudio",
label: "LM Studio",
labelZh: "LM Studio (本地)",
domain: "lmstudio.ai",
defaultApiBase: "http://localhost:1234/v1",
requiresApiKey: false,
isLocal: true,
priority: 48,
supportsFetch: true,
},
{
key: "venice",
label: "Venice AI",
iconSlug: "venice",
domain: "venice.ai",
defaultApiBase: "https://api.venice.ai/api/v1",
requiresApiKey: true,
isLocal: false,
priority: 45,
supportsFetch: true,
},
{
key: "shengsuanyun",
label: "ShengsuanYun",
labelZh: "ShengsuanYun (神算云)",
domain: "shengsuanyun.com",
defaultApiBase: "https://router.shengsuanyun.com/api/v1",
requiresApiKey: true,
isLocal: false,
priority: 44,
supportsFetch: true,
},
{
key: "siliconflow",
label: "SiliconFlow",
labelZh: "硅基流动",
domain: "siliconflow.cn",
defaultApiBase: "https://api.siliconflow.cn/v1",
requiresApiKey: true,
isLocal: false,
priority: 43.5,
supportsFetch: true,
},
{
key: "vivgrid",
label: "Vivgrid",
domain: "vivgrid.com",
defaultApiBase: "https://api.vivgrid.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 43,
supportsFetch: true,
},
{
key: "minimax",
label: "MiniMax",
domain: "minimaxi.com",
defaultApiBase: "https://api.minimaxi.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 42,
supportsFetch: true,
},
{
key: "longcat",
label: "LongCat",
domain: "longcat.chat",
defaultApiBase: "https://api.longcat.chat/openai",
requiresApiKey: true,
isLocal: false,
priority: 41,
supportsFetch: true,
},
{
key: "modelscope",
label: "ModelScope",
labelZh: "ModelScope (魔搭社区)",
domain: "modelscope.cn",
defaultApiBase: "https://api-inference.modelscope.cn/v1",
requiresApiKey: true,
isLocal: false,
priority: 40,
supportsFetch: true,
},
{
key: "mimo",
label: "Xiaomi MiMo",
iconSlug: "xiaomi",
domain: "xiaomi.com",
defaultApiBase: "https://api.xiaomimimo.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 39,
supportsFetch: true,
},
{
key: "avian",
label: "Avian",
domain: "avian.io",
defaultApiBase: "https://api.avian.io/v1",
requiresApiKey: true,
isLocal: false,
priority: 38,
supportsFetch: true,
},
{
key: "zai",
label: "Z.ai",
domain: "z.ai",
defaultApiBase: "https://api.z.ai/api/coding/paas/v4",
requiresApiKey: true,
isLocal: false,
priority: 37,
aliases: ["z.ai", "z-ai"],
supportsFetch: true,
},
{
key: "novita",
label: "Novita AI",
domain: "novita.ai",
defaultApiBase: "https://api.novita.ai/openai",
requiresApiKey: true,
isLocal: false,
priority: 36,
supportsFetch: true,
},
{
key: "litellm",
label: "LiteLLM",
domain: "litellm.ai",
defaultApiBase: "http://localhost:4000/v1",
requiresApiKey: true,
isLocal: false,
priority: 35,
supportsFetch: true,
},
]
// Frontend still needs the same trim/lower normalization as the backend
// NormalizeProvider before it can look up canonical IDs in provider_options.
// This helper does not define provider semantics; aliases and canonical IDs
// still come entirely from the backend payload.
function normalizeProvider(provider?: string): string {
return provider?.trim().toLowerCase() || ""
}
// ── Derived data for consumers ───────────────────────────────────────────────
function toCatalogEntry(option: ModelProviderOption): ProviderCatalogEntry {
const defaultApiBase = option.default_api_base || undefined
return {
key: option.id,
label: option.display_name || option.id,
iconSlug: option.icon_slug || undefined,
domain: option.domain || undefined,
priority: option.priority ?? 0,
isLocal: option.local === true,
defaultApiBase,
requiresApiKey: !option.empty_api_key_allowed,
createAllowed: option.create_allowed,
defaultModelAllowed: option.default_model_allowed,
supportsFetch: option.supports_fetch === true,
defaultAuthMethod: option.default_auth_method || undefined,
authMethodLocked: option.auth_method_locked,
emptyApiKeyAllowed: option.empty_api_key_allowed,
commonModels: option.common_models || [],
aliases: option.aliases || [],
}
}
export const PROVIDER_MAP = new Map(PROVIDERS.map((p) => [p.key, p]))
function buildAliasMap(
backendOptions?: ModelProviderOption[],
): Record<string, string> {
const aliases: Record<string, string> = {}
for (const option of backendOptions || []) {
const key = normalizeProvider(option.id)
if (!key) continue
aliases[key] = option.id
for (const alias of option.aliases || []) {
const normalized = normalizeProvider(alias)
if (normalized) {
aliases[normalized] = option.id
}
}
}
return aliases
}
export const PROVIDER_LABELS: Record<string, string> = Object.fromEntries(
PROVIDERS.map((p) => [p.key, p.labelZh || p.label]),
)
export function getProviderAliasMap(
backendOptions?: ModelProviderOption[],
): Record<string, string> {
return buildAliasMap(backendOptions)
}
export const PROVIDER_ALIASES: Record<string, string> = Object.fromEntries(
PROVIDERS.flatMap((p) => (p.aliases || []).map((a) => [a, p.key])),
)
export function getCanonicalProviderKey(
provider?: string,
backendOptions?: ModelProviderOption[],
): string {
const normalized = normalizeProvider(provider)
if (!normalized) return ""
return getProviderAliasMap(backendOptions)[normalized] ?? normalized
}
export const KNOWN_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key))
export function getKnownProviderKeys(
backendOptions?: ModelProviderOption[],
): Set<string> {
return new Set(getProviderCatalog(backendOptions).map((p) => p.key))
}
export const FETCHABLE_PROVIDER_KEYS = new Set(
PROVIDERS.filter((p) => p.supportsFetch).map((p) => p.key),
)
export function getProviderCatalog(
backendOptions?: ModelProviderOption[],
): ProviderCatalogEntry[] {
if (!backendOptions || backendOptions.length === 0) {
return []
}
export const PROVIDER_ICON_SLUGS: Record<string, string> = Object.fromEntries(
PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]),
)
return [...backendOptions]
.map(toCatalogEntry)
.sort((a, b) => b.priority - a.priority)
}
export const PROVIDER_DOMAINS: Record<string, string> = Object.fromEntries(
PROVIDERS.filter((p) => p.domain).map((p) => [p.key, p.domain!]),
)
export function getProviderCatalogMap(
backendOptions?: ModelProviderOption[],
): Map<string, ProviderCatalogEntry> {
return new Map(getProviderCatalog(backendOptions).map((p) => [p.key, p]))
}
export const PROVIDER_PRIORITY: Record<string, number> = Object.fromEntries(
PROVIDERS.map((p) => [p.key, p.priority]),
)
export function getProviderCatalogEntry(
provider: string | undefined,
backendOptions?: ModelProviderOption[],
): ProviderCatalogEntry | undefined {
const key = getCanonicalProviderKey(provider, backendOptions)
if (!key) return undefined
return getProviderCatalogMap(backendOptions).get(key)
}
export const PROVIDER_API_BASES: Record<string, string> = Object.fromEntries(
PROVIDERS.filter((p) => p.defaultApiBase).map((p) => [
p.key,
p.defaultApiBase!,
]),
)
export function getProviderDefaultAPIBase(
provider: string | undefined,
backendOptions?: ModelProviderOption[],
): string {
return getProviderCatalogEntry(provider, backendOptions)?.defaultApiBase ?? ""
}
export function getProviderDefaultAuthMethod(
provider: string | undefined,
backendOptions?: ModelProviderOption[],
): string {
return getProviderCatalogEntry(provider, backendOptions)?.defaultAuthMethod ?? ""
}
export function isProviderAuthMethodLocked(
provider: string | undefined,
backendOptions?: ModelProviderOption[],
): boolean {
return getProviderCatalogEntry(provider, backendOptions)?.authMethodLocked === true
}
export function providerSupportsFetch(
provider: string | undefined,
backendOptions?: ModelProviderOption[],
): boolean {
const key = getCanonicalProviderKey(provider, backendOptions)
if (!key) return false
return getProviderCatalogMap(backendOptions).get(key)?.supportsFetch === true
}
/**
* Find the closest known provider key by edit distance.
* Returns the key if distance <= 2, otherwise undefined.
*/
export function findClosestProvider(input: string): string | undefined {
export function findClosestProvider(
input: string,
backendOptions?: ModelProviderOption[],
): string | undefined {
const lower = input.toLowerCase()
let best: string | undefined
let bestDist = 3 // only accept distance <= 2
let bestDist = 3
for (const key of KNOWN_PROVIDER_KEYS) {
for (const key of getKnownProviderKeys(backendOptions)) {
const dist = editDistance(lower, key)
if (dist < bestDist) {
bestDist = dist
best = key
}
}
// Also check aliases
for (const alias of Object.keys(PROVIDER_ALIASES)) {
for (const alias of Object.keys(getProviderAliasMap(backendOptions))) {
const dist = editDistance(lower, alias)
if (dist < bestDist) {
bestDist = dist
best = PROVIDER_ALIASES[alias]
best = getProviderAliasMap(backendOptions)[alias]
}
}
return best
@@ -477,55 +193,3 @@ function editDistance(a: string, b: string): number {
}
return dp[m][n]
}
// ── Backend options merge ────────────────────────────────────────────────────
export interface MergedProvider extends ProviderDefinition {
createAllowed: boolean
defaultModelAllowed: boolean
defaultAuthMethod?: string
authMethodLocked?: boolean
}
/**
* Merge the frontend PROVIDERS registry with backend provider_options.
* Frontend provides presentation data (labels, icons, priority, etc.).
* Backend provides authoritative availability and policy fields.
*/
export function mergeWithBackendOptions(
backendOptions: ModelProviderOption[],
): MergedProvider[] {
const backendMap = new Map(backendOptions.map((o) => [o.id, o]))
const merged: MergedProvider[] = []
// Start with frontend providers, enriched with backend policy
for (const p of PROVIDERS) {
const backend = backendMap.get(p.key)
merged.push({
...p,
createAllowed: backend?.create_allowed ?? false,
defaultModelAllowed: backend?.default_model_allowed ?? false,
defaultAuthMethod: backend?.default_auth_method,
authMethodLocked: backend?.auth_method_locked,
})
if (backend) backendMap.delete(p.key)
}
// Add providers only known to the backend
for (const [key, backend] of backendMap) {
merged.push({
key,
label: key,
requiresApiKey: !backend.empty_api_key_allowed,
isLocal: backend.empty_api_key_allowed,
priority: 0,
createAllowed: backend.create_allowed,
defaultModelAllowed: backend.default_model_allowed,
defaultAuthMethod: backend.default_auth_method,
authMethodLocked: backend.auth_method_locked,
defaultApiBase: backend.default_api_base || undefined,
})
}
return merged.sort((a, b) => b.priority - a.priority)
}
@@ -5,10 +5,10 @@ import type { ModelInfo } from "@/api/models"
import { ModelCard } from "./model-card"
import { ProviderIcon } from "./provider-icon"
import type { ProviderCatalogEntry } from "./provider-registry"
interface ProviderSectionProps {
provider: string
providerKey: string
provider: Pick<ProviderCatalogEntry, "key" | "label" | "iconSlug" | "domain">
models: ModelInfo[]
onEdit: (model: ModelInfo) => void
onSetDefault: (model: ModelInfo) => void
@@ -18,7 +18,6 @@ interface ProviderSectionProps {
export function ProviderSection({
provider,
providerKey,
models,
onEdit,
onSetDefault,
@@ -38,8 +37,8 @@ export function ProviderSection({
<div className="border-border/40 border-t" />
<span className="text-foreground/80 text-center text-xs font-semibold tracking-wide uppercase">
<span className="bg-background inline-flex items-center gap-1.5 px-2">
<ProviderIcon providerKey={providerKey} providerLabel={provider} />
{provider}
<ProviderIcon provider={provider} />
{provider.label}
</span>
</span>
<div className="border-border/40 border-t" />