diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index dbd41b534..1deaf9a06 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -6,7 +6,12 @@ import { import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { addModel, getCatalogs, setDefaultModel } from "@/api/models" +import { + type ModelProviderOption, + addModel, + getCatalogs, + setDefaultModel, +} from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { @@ -111,6 +116,7 @@ interface AddModelSheetProps { onClose: () => void onSaved: () => void existingModelNames: string[] + providerOptions?: ModelProviderOption[] } export function AddModelSheet({ @@ -118,6 +124,7 @@ export function AddModelSheet({ onClose, onSaved, existingModelNames, + providerOptions, }: AddModelSheetProps) { const { t } = useTranslation() const [form, setForm] = useState(EMPTY_ADD_FORM) @@ -134,6 +141,7 @@ export function AddModelSheet({ const [fetchedModels, setFetchedModels] = useState([]) const [catalogModels, setCatalogModels] = useState([]) const debounceRef = useRef>(undefined) + const scrollContainerRef = useRef(null) const apiKeyPlaceholder = maskedSecretPlaceholder( form.apiKey, t("models.field.apiKeyPlaceholder"), @@ -283,6 +291,8 @@ export function AddModelSheet({ try { if (form.extraBody.trim()) { extraBody = JSON.parse(form.extraBody.trim()) + } else { + extraBody = {} } } catch { setServerError( @@ -293,6 +303,8 @@ export function AddModelSheet({ try { if (form.customHeaders.trim()) { customHeaders = JSON.parse(form.customHeaders.trim()) + } else { + customHeaders = {} } } catch { setServerError( @@ -362,7 +374,7 @@ export function AddModelSheet({ -
+
diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index d76bfc9fe..b05d61a65 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next" import { type ModelInfo, + type ModelProviderOption, getCatalogs, setDefaultModel, updateModel, @@ -65,6 +66,7 @@ interface EditModelSheetProps { open: boolean onClose: () => void onSaved: () => void + providerOptions?: ModelProviderOption[] } function normalizeApiBase(value: string): string { @@ -126,6 +128,7 @@ export function EditModelSheet({ open, onClose, onSaved, + providerOptions, }: EditModelSheetProps) { const { t } = useTranslation() const [form, setForm] = useState({ @@ -155,6 +158,7 @@ export function EditModelSheet({ const [fetchedModels, setFetchedModels] = useState([]) const [catalogModels, setCatalogModels] = useState([]) const debounceRef = useRef>(undefined) + const scrollContainerRef = useRef(null) const initialForm = model ? buildInitialEditForm(model) : null const isDirty = model != null && @@ -256,6 +260,8 @@ export function EditModelSheet({ try { if (form.extraBody.trim()) { extraBody = JSON.parse(form.extraBody.trim()) + } else { + extraBody = {} } } catch { setError( @@ -266,6 +272,8 @@ export function EditModelSheet({ try { if (form.customHeaders.trim()) { customHeaders = JSON.parse(form.customHeaders.trim()) + } else { + customHeaders = {} } } catch { setError( @@ -343,7 +351,7 @@ export function EditModelSheet({ -
+
diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index f98377b2f..9c0c400db 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -8,7 +8,12 @@ import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" -import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" +import { + type ModelInfo, + type ModelProviderOption, + getModels, + setDefaultModel, +} from "@/api/models" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" @@ -33,6 +38,9 @@ interface ProviderGroup { export function ModelsPage() { const { t } = useTranslation() const [models, setModels] = useState([]) + const [providerOptions, setProviderOptions] = useState< + ModelProviderOption[] + >([]) const [loading, setLoading] = useState(true) const [fetchError, setFetchError] = useState("") @@ -55,6 +63,7 @@ export function ModelsPage() { return a.model_name.localeCompare(b.model_name) }) setModels(sorted) + setProviderOptions(data.provider_options || []) setFetchError("") } catch (e) { setFetchError(e instanceof Error ? e.message : t("models.loadError")) @@ -200,6 +209,7 @@ export function ModelsPage() { open={editingModel !== null} onClose={() => setEditingModel(null)} onSaved={fetchModels} + providerOptions={providerOptions} /> setAddOpen(false)} onSaved={fetchModels} existingModelNames={models.map((model) => model.model_name)} + providerOptions={providerOptions} /> void placeholder?: string + backendOptions?: ModelProviderOption[] + /** When true, only show providers with create_allowed from the backend. */ + filterCreateAllowed?: boolean + /** Container element for the popover portal. Use to avoid scroll conflicts inside dialogs/sheets. */ + containerRef?: React.RefObject } export function ProviderCombobox({ value, onChange, placeholder, + backendOptions, + filterCreateAllowed, + containerRef, }: ProviderComboboxProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [customMode, setCustomMode] = useState(false) const [customValue, setCustomValue] = useState("") - const sorted = [...PROVIDERS].sort((a, b) => b.priority - a.priority) - const selected = sorted.find((p) => p.key === value) - const isCustom = value && !KNOWN_PROVIDER_KEYS.has(value) + const allProviders: MergedProvider[] = backendOptions + ? mergeWithBackendOptions(backendOptions) + : [...PROVIDERS] + .sort((a, b) => b.priority - a.priority) + .map((p) => ({ + ...p, + createAllowed: true, + defaultModelAllowed: false, + })) + const visible = filterCreateAllowed + ? allProviders.filter((p) => p.createAllowed) + : 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 handleSelect = (currentValue: string) => { if (currentValue === "__custom__") { @@ -97,7 +122,7 @@ export function ProviderCombobox({ - + {customMode ? (
{t("models.combobox.noProvider")} - {sorted.map((provider) => ( + {visible.map((provider) => ( [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) +} diff --git a/web/frontend/src/components/ui/popover.tsx b/web/frontend/src/components/ui/popover.tsx index bba758093..c67bc5c91 100644 --- a/web/frontend/src/components/ui/popover.tsx +++ b/web/frontend/src/components/ui/popover.tsx @@ -9,9 +9,11 @@ const PopoverTrigger = PopoverPrimitive.Trigger const PopoverContent = React.forwardRef< React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - + React.ComponentPropsWithoutRef & { + container?: HTMLElement | null + } +>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => ( +