import { IconDownload, IconLoader2, IconPlugConnected, } from "@tabler/icons-react" import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { type ModelProviderOption, addModel, getCatalogs, setDefaultModel, } from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { AdvancedSection, Field, KeyInput, SwitchCardField, } from "@/components/shared-form" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from "@/components/ui/sheet" import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" import { FetchModelsDialog } from "./fetch-models-dialog" 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 { TestModelDialog } from "./test-model-dialog" interface AddForm { modelName: string provider: string model: string apiBase: string apiKey: string proxy: string authMethod: string connectMode: string workspace: string rpm: string maxTokensField: string requestTimeout: string thinkingLevel: string toolSchemaTransform: string extraBody: string customHeaders: string } const EMPTY_ADD_FORM: AddForm = { modelName: "", provider: "", model: "", apiBase: "", apiKey: "", proxy: "", authMethod: "", connectMode: "", workspace: "", rpm: "", maxTokensField: "", requestTimeout: "", thinkingLevel: "", toolSchemaTransform: "", extraBody: "", 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 onSaved: () => void existingModelNames: string[] providerOptions?: ModelProviderOption[] } export function AddModelSheet({ open, onClose, onSaved, existingModelNames, providerOptions, }: AddModelSheetProps) { const { t } = useTranslation() const [form, setForm] = useState(EMPTY_ADD_FORM) const [saving, setSaving] = useState(false) const [setAsDefault, setSetAsDefault] = useState(false) const [fieldErrors, setFieldErrors] = useState< Partial> >({}) const [serverError, setServerError] = useState("") const [modelValidation, setModelValidation] = useState(null) const [fetchOpen, setFetchOpen] = useState(false) const [testOpen, setTestOpen] = useState(false) 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"), ) const isDirty = JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault useEffect(() => { if (open) { setForm(EMPTY_ADD_FORM) setSetAsDefault(false) setFieldErrors({}) setServerError("") setModelValidation(null) setFetchedModels([]) setCatalogModels([]) } }, [open]) // Load catalog models when provider or apiBase changes useEffect(() => { const providerKey = getProviderKey(form.provider || undefined) const apiBase = form.apiBase.trim().replace(/\/+$/, "") if (!form.provider.trim()) { setCatalogModels([]) return } let cancelled = false getCatalogs() .then((res) => { if (cancelled) return const matched = (res.entries || []).filter((e) => { const ep = getProviderKey(e.provider || undefined) const eb = (e.api_base ?? "").trim().replace(/\/+$/, "") return ep === providerKey && eb === apiBase }) const ids = matched.flatMap((e) => e.models.map((m) => m.id)) const unique = [...new Set(ids)] setCatalogModels(unique) }) .catch(() => {}) return () => { cancelled = true } }, [form.provider, form.apiBase]) const validate = (): boolean => { const errors: Partial> = {} const modelName = form.modelName.trim() if (!modelName) { errors.modelName = t("models.add.errorRequired") } else if (existingModelNames.some((name) => name.trim() === modelName)) { errors.modelName = t("models.add.errorDuplicateModelName") } if (!form.model.trim()) errors.model = t("models.add.errorRequired") if (modelValidation?.level === "error") { errors.model = t( modelValidation.messageKey, modelValidation.messageParams, ) } setFieldErrors(errors) return Object.keys(errors).length === 0 } const setField = (key: keyof AddForm) => (e: React.ChangeEvent) => { setForm((f) => ({ ...f, [key]: e.target.value })) if (fieldErrors[key]) { setFieldErrors((prev) => ({ ...prev, [key]: undefined })) } } const debouncedValidateModel = useCallback( (value: string, provider: string) => { if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => { const result = validateModelField(value, provider || undefined) setModelValidation(result) }, 300) }, [], ) const handleModelChange = (e: React.ChangeEvent) => { const value = e.target.value setForm((f) => ({ ...f, model: value })) if (fieldErrors.model) { setFieldErrors((prev) => ({ ...prev, model: undefined })) } debouncedValidateModel(value, form.provider) } const handleProviderChange = (provider: string) => { setForm((f) => { return { ...f, provider, apiBase: getNextApiBaseForProviderChange( f.apiBase, f.provider, provider, ), } }) // Re-validate model with new provider context if (form.model) { debouncedValidateModel(form.model, provider) } // Clear setAsDefault if the new provider doesn't support being default const allowed = providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? false if (!allowed) { setSetAsDefault(false) } } const applyFix = () => { if (modelValidation?.fix) { setForm((f) => ({ ...f, model: modelValidation.fix! })) setModelValidation(null) } } const handleCommonModel = (modelId: string) => { setForm((f) => ({ ...f, model: modelId })) setModelValidation(null) if (fieldErrors.model) { setFieldErrors((prev) => ({ ...prev, model: undefined })) } } const handleFetchFill = (models: string[]) => { setFetchedModels(models) if (models.length >= 1) { setForm((f) => ({ ...f, model: models[0] })) setModelValidation(null) if (fieldErrors.model) { setFieldErrors((prev) => ({ ...prev, model: undefined })) } } } const providerDef = PROVIDER_MAP.get(form.provider) const commonModels = providerDef?.commonModels || [] const defaultModelAllowed = form.provider ? (providerOptions?.find((o) => o.id === form.provider)?.default_model_allowed ?? false) : false const handleSave = async () => { if (!validate()) return let extraBody: Record | undefined let customHeaders: Record | undefined try { if (form.extraBody.trim()) { extraBody = JSON.parse(form.extraBody.trim()) } else { extraBody = {} } } catch { setServerError( t("models.field.extraBody") + ": " + t("models.field.invalidJson"), ) return } try { if (form.customHeaders.trim()) { customHeaders = JSON.parse(form.customHeaders.trim()) } else { customHeaders = {} } } catch { setServerError( t("models.field.customHeaders") + ": " + t("models.field.invalidJson"), ) return } setSaving(true) setServerError("") try { const modelName = form.modelName.trim() const provider = form.provider.trim() const modelId = form.model.trim() await addModel({ model_name: modelName, provider: provider || undefined, model: modelId, api_base: form.apiBase.trim() || undefined, api_key: form.apiKey.trim() || undefined, proxy: form.proxy.trim() || undefined, auth_method: 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.trim() || undefined, request_timeout: form.requestTimeout ? Number(form.requestTimeout) : undefined, thinking_level: form.thinkingLevel.trim() || undefined, tool_schema_transform: form.toolSchemaTransform.trim() || undefined, extra_body: extraBody, custom_headers: customHeaders, }) if (setAsDefault) { await setDefaultModel(modelName) } const gateway = await refreshGatewayState({ force: true }) showSaveSuccessOrRestartToast( t, t("models.add.saveSuccess"), modelName, gateway?.restartRequired === true, ) onSaved() onClose() } catch (e) { setServerError(e instanceof Error ? e.message : t("models.add.saveError")) } finally { setSaving(false) } } return ( <> !v && onClose()}> {t("models.add.title")} {t("models.add.description")}
{fieldErrors.modelName && (

{fieldErrors.modelName}

)}
{modelValidation && modelValidation.messageKey && (
{t( modelValidation.messageKey, modelValidation.messageParams, )} {modelValidation.fix && ( )}
)} {fieldErrors.model && !modelValidation && (

{fieldErrors.model}

)} {commonModels.length > 0 && (
{commonModels.map((m) => ( handleCommonModel(m)} > {m} ))}
)} {catalogModels.length > 0 && (
{catalogModels.map((m) => ( handleCommonModel(m)} > {m} ))}
)} {fetchedModels.length > 0 && (
{fetchedModels.map((m) => ( handleCommonModel(m)} > {m} ))}
)}
{form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && ( )} {!form.provider && ( {t("models.field.selectProviderFirst")} )}
setForm((f) => ({ ...f, apiKey: v }))} placeholder={apiKeyPlaceholder} />