diff --git a/ui/app/model/page.tsx b/ui/app/model/page.tsx index 7de7c4c5..6c415827 100644 --- a/ui/app/model/page.tsx +++ b/ui/app/model/page.tsx @@ -204,8 +204,8 @@ export default function ModelsPage() { - {totalModels === 0 ? ( - + {totalModels === 0 && ( +
@@ -237,14 +237,13 @@ export default function ModelsPage() {
- ) : ( - )} + ); diff --git a/ui/app/providers/page.tsx b/ui/app/providers/page.tsx index 65f68837..f431f101 100644 --- a/ui/app/providers/page.tsx +++ b/ui/app/providers/page.tsx @@ -18,7 +18,9 @@ import { UpstreamProvider, CreateUpstreamProvider, UpdateUpstreamProvider, + AdminModel, } from '@/lib/api/services/admin'; +import { AddProviderModelDialog } from '@/components/AddProviderModelDialog'; import { Skeleton } from '@/components/ui/skeleton'; import { AlertCircle, @@ -379,6 +381,17 @@ export default function ProvidersPage() { ); const [viewingModels, setViewingModels] = useState(null); const [isCreatingAccount, setIsCreatingAccount] = useState(false); + const [modelDialogState, setModelDialogState] = useState<{ + isOpen: boolean; + providerId: number | null; + mode: 'create' | 'edit' | 'override'; + initialData?: AdminModel | null; + }>({ + isOpen: false, + providerId: null, + mode: 'create', + initialData: null, + }); const [formData, setFormData] = useState({ provider_type: 'openrouter', @@ -574,6 +587,33 @@ export default function ProvidersPage() { } }; + const handleAddModel = (providerId: number) => { + setModelDialogState({ + isOpen: true, + providerId, + mode: 'create', + initialData: null, + }); + }; + + const handleEditModel = (providerId: number, model: AdminModel) => { + setModelDialogState({ + isOpen: true, + providerId, + mode: 'edit', + initialData: model, + }); + }; + + const handleOverrideModel = (providerId: number, model: AdminModel) => { + setModelDialogState({ + isOpen: true, + providerId, + mode: 'override', + initialData: model, + }); + }; + return ( @@ -883,9 +923,14 @@ export default function ProvidersPage() { // No provided models - show custom models directly without tabs
{providerModels.db_models.length === 0 ? ( -
- No models configured. Add custom models to - use this provider. +
+
+ No models configured. Add custom models to use this provider. +
+
) : (
@@ -916,9 +961,19 @@ export default function ProvidersPage() { {model.description || model.name}
-
- {model.context_length?.toLocaleString()}{' '} - tokens +
+
+ {model.context_length?.toLocaleString()}{' '} + tokens +
+
))} @@ -969,12 +1024,17 @@ export default function ProvidersPage() { value='custom' className='mt-4 space-y-2' > - {providerModels.db_models.length > 0 && ( -
- Custom models override or extend the - provider's catalog. -
- )} +
+ {providerModels.db_models.length > 0 && ( +
+ Custom models override or extend the provider's catalog. +
+ )} + +
{providerModels.db_models.length === 0 ? (
No custom models configured @@ -1010,9 +1070,19 @@ export default function ProvidersPage() { model.name}
-
- {model.context_length?.toLocaleString()}{' '} - tokens +
+
+ {model.context_length?.toLocaleString()}{' '} + tokens +
+
) @@ -1047,9 +1117,20 @@ export default function ProvidersPage() { model.name} -
- {model.context_length?.toLocaleString()}{' '} - tokens +
+
+ {model.context_length?.toLocaleString()}{' '} + tokens +
+
) @@ -1217,6 +1298,21 @@ export default function ProvidersPage() { + + {modelDialogState.providerId && ( + setModelDialogState((prev) => ({ ...prev, isOpen: false }))} + onSuccess={() => { + queryClient.invalidateQueries({ + queryKey: ['provider-models', modelDialogState.providerId], + }); + }} + initialData={modelDialogState.initialData} + mode={modelDialogState.mode} + /> + )}
); diff --git a/ui/components/AddProviderModelDialog.tsx b/ui/components/AddProviderModelDialog.tsx new file mode 100644 index 00000000..36f2eec4 --- /dev/null +++ b/ui/components/AddProviderModelDialog.tsx @@ -0,0 +1,921 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Switch } from '@/components/ui/switch'; +import { Loader2, Plus } from 'lucide-react'; +import { toast } from 'sonner'; +import { AdminService, type AdminModel } from '@/lib/api/services/admin'; + +const listFromString = (value: string): string[] => + value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + +const listToString = (value: string[] | undefined | null): string => + value && value.length > 0 ? value.join(', ') : ''; + +const FormSchema = z.object({ + id: z.string().min(1, 'Model ID is required'), + name: z.string().min(1, 'Name is required'), + description: z.string().optional(), + context_length: z.coerce.number().min(0).default(8192), + modality: z.string().min(1, 'Modality is required'), + input_modalities_raw: z.string().optional(), + output_modalities_raw: z.string().optional(), + tokenizer: z.string().optional().default(''), + instruct_type: z.string().optional(), + canonical_slug: z.string().optional(), + alias_ids_raw: z.string().optional(), + upstream_provider_id: z.string().optional(), + input_cost: z.coerce.number().min(0).default(0), + output_cost: z.coerce.number().min(0).default(0), + request_cost: z.coerce.number().min(0).default(0), + image_cost: z.coerce.number().min(0).default(0), + web_search_cost: z.coerce.number().min(0).default(0), + internal_reasoning_cost: z.coerce.number().min(0).default(0), + max_prompt_cost: z.coerce.number().min(0).default(0), + max_completion_cost: z.coerce.number().min(0).default(0), + max_cost: z.coerce.number().min(0).default(0), + per_request_limits_raw: z.string().optional(), + top_provider_context_length: z.coerce.number().min(0).optional(), + top_provider_max_completion_tokens: z.coerce.number().min(0).optional(), + top_provider_is_moderated: z.boolean().default(false), + enabled: z.boolean().default(true), +}); + +type FormData = z.infer; + +export interface AddProviderModelDialogProps { + providerId: number; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + initialData?: AdminModel | null; + mode?: 'create' | 'edit' | 'override'; +} + +export function AddProviderModelDialog({ + providerId, + isOpen, + onClose, + onSuccess, + initialData, + mode = 'create', +}: AddProviderModelDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [isPresetOpen, setIsPresetOpen] = useState(false); + const [selectedPresetLabel, setSelectedPresetLabel] = useState('Select a preset'); + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + id: '', + name: '', + description: '', + context_length: 8192, + modality: 'text', + input_modalities_raw: 'text', + output_modalities_raw: 'text', + tokenizer: '', + instruct_type: '', + canonical_slug: '', + alias_ids_raw: '', + upstream_provider_id: '', + input_cost: 0, + output_cost: 0, + request_cost: 0, + image_cost: 0, + web_search_cost: 0, + internal_reasoning_cost: 0, + max_prompt_cost: 0, + max_completion_cost: 0, + max_cost: 0, + per_request_limits_raw: '', + top_provider_context_length: undefined, + top_provider_max_completion_tokens: undefined, + top_provider_is_moderated: false, + enabled: true, + }, + }); + + const isOverride = useMemo(() => mode === 'override', [mode]); + const isEdit = useMemo(() => mode === 'edit', [mode]); + const { data: presets = [], isLoading: isLoadingPresets } = useQuery({ + queryKey: ['openrouter-presets'], + queryFn: () => AdminService.getOpenRouterPresets(), + staleTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (initialData) { + const architecture = initialData.architecture as Record; + const pricing = initialData.pricing as Record; + const topProvider = initialData.top_provider as Record | null; + + form.reset({ + id: initialData.id, + name: initialData.name, + description: initialData.description, + context_length: initialData.context_length, + modality: + typeof architecture?.modality === 'string' + ? architecture.modality + : 'text', + input_modalities_raw: listToString( + (architecture?.input_modalities as string[]) || [] + ), + output_modalities_raw: listToString( + (architecture?.output_modalities as string[]) || [] + ), + tokenizer: + typeof architecture?.tokenizer === 'string' + ? architecture.tokenizer + : '', + instruct_type: + typeof architecture?.instruct_type === 'string' + ? architecture.instruct_type + : '', + canonical_slug: initialData.canonical_slug || '', + alias_ids_raw: listToString(initialData.alias_ids), + upstream_provider_id: + typeof initialData.upstream_provider_id === 'string' + ? initialData.upstream_provider_id + : initialData.upstream_provider_id?.toString() || '', + input_cost: pricing?.prompt ?? 0, + output_cost: pricing?.completion ?? 0, + request_cost: pricing?.request ?? 0, + image_cost: pricing?.image ?? 0, + web_search_cost: pricing?.web_search ?? 0, + internal_reasoning_cost: pricing?.internal_reasoning ?? 0, + max_prompt_cost: pricing?.max_prompt_cost ?? 0, + max_completion_cost: pricing?.max_completion_cost ?? 0, + max_cost: pricing?.max_cost ?? 0, + per_request_limits_raw: initialData.per_request_limits + ? JSON.stringify(initialData.per_request_limits, null, 2) + : '', + top_provider_context_length: + typeof topProvider?.context_length === 'number' + ? topProvider.context_length + : undefined, + top_provider_max_completion_tokens: + typeof topProvider?.max_completion_tokens === 'number' + ? topProvider.max_completion_tokens + : undefined, + top_provider_is_moderated: + typeof topProvider?.is_moderated === 'boolean' + ? topProvider.is_moderated + : false, + enabled: initialData.enabled, + }); + } else { + form.reset({ + id: '', + name: '', + description: '', + context_length: 8192, + modality: 'text', + input_modalities_raw: 'text', + output_modalities_raw: 'text', + tokenizer: '', + instruct_type: '', + canonical_slug: '', + alias_ids_raw: '', + upstream_provider_id: '', + input_cost: 0, + output_cost: 0, + request_cost: 0, + image_cost: 0, + web_search_cost: 0, + internal_reasoning_cost: 0, + max_prompt_cost: 0, + max_completion_cost: 0, + max_cost: 0, + per_request_limits_raw: '', + top_provider_context_length: undefined, + top_provider_max_completion_tokens: undefined, + top_provider_is_moderated: false, + enabled: true, + }); + } + }, [initialData, form, isOpen]); + + const applyModelToForm = (model: AdminModel) => { + setSelectedPresetLabel(`${model.id} — ${model.name}`); + const architecture = model.architecture as Record; + const pricing = model.pricing as Record; + const topProvider = model.top_provider as Record | null; + + form.setValue('id', model.id); + form.setValue('name', model.name); + form.setValue('description', model.description || ''); + form.setValue('context_length', model.context_length); + form.setValue( + 'modality', + typeof architecture?.modality === 'string' ? architecture.modality : 'text' + ); + form.setValue( + 'input_modalities_raw', + listToString((architecture?.input_modalities as string[]) || []) + ); + form.setValue( + 'output_modalities_raw', + listToString((architecture?.output_modalities as string[]) || []) + ); + form.setValue( + 'tokenizer', + typeof architecture?.tokenizer === 'string' ? architecture.tokenizer : '' + ); + form.setValue( + 'instruct_type', + typeof architecture?.instruct_type === 'string' + ? architecture.instruct_type + : '' + ); + form.setValue('canonical_slug', model.canonical_slug || ''); + form.setValue('alias_ids_raw', listToString(model.alias_ids)); + form.setValue( + 'upstream_provider_id', + typeof model.upstream_provider_id === 'string' + ? model.upstream_provider_id + : model.upstream_provider_id?.toString() || '' + ); + form.setValue('input_cost', pricing?.prompt ?? 0); + form.setValue('output_cost', pricing?.completion ?? 0); + form.setValue('request_cost', pricing?.request ?? 0); + form.setValue('image_cost', pricing?.image ?? 0); + form.setValue('web_search_cost', pricing?.web_search ?? 0); + form.setValue('internal_reasoning_cost', pricing?.internal_reasoning ?? 0); + form.setValue('max_prompt_cost', pricing?.max_prompt_cost ?? 0); + form.setValue('max_completion_cost', pricing?.max_completion_cost ?? 0); + form.setValue('max_cost', pricing?.max_cost ?? 0); + form.setValue( + 'per_request_limits_raw', + model.per_request_limits + ? JSON.stringify(model.per_request_limits, null, 2) + : '' + ); + form.setValue( + 'top_provider_context_length', + typeof topProvider?.context_length === 'number' + ? topProvider.context_length + : undefined + ); + form.setValue( + 'top_provider_max_completion_tokens', + typeof topProvider?.max_completion_tokens === 'number' + ? topProvider.max_completion_tokens + : undefined + ); + form.setValue( + 'top_provider_is_moderated', + typeof topProvider?.is_moderated === 'boolean' + ? topProvider.is_moderated + : false + ); + form.setValue('enabled', model.enabled); + }; + + const onSubmit = async (data: FormData) => { + setIsSubmitting(true); + try { + let perRequestLimits: Record | null = null; + if (data.per_request_limits_raw && data.per_request_limits_raw.trim().length) { + try { + perRequestLimits = JSON.parse(data.per_request_limits_raw); + } catch (err) { + toast.error('Per-request limits must be valid JSON'); + setIsSubmitting(false); + return; + } + } + + const adminModel: AdminModel = { + id: data.id, + name: data.name, + description: data.description || '', + created: Math.floor(Date.now() / 1000), + context_length: data.context_length, + architecture: { + modality: data.modality, + input_modalities: listFromString(data.input_modalities_raw || data.modality), + output_modalities: listFromString( + data.output_modalities_raw || data.modality + ), + tokenizer: data.tokenizer || '', + instruct_type: data.instruct_type?.trim() || null, + }, + pricing: { + prompt: data.input_cost, + completion: data.output_cost, + request: data.request_cost, + image: data.image_cost, + web_search: data.web_search_cost, + internal_reasoning: data.internal_reasoning_cost, + max_prompt_cost: data.max_prompt_cost, + max_completion_cost: data.max_completion_cost, + max_cost: data.max_cost, + }, + per_request_limits: perRequestLimits, + top_provider: + data.top_provider_context_length || + data.top_provider_max_completion_tokens || + data.top_provider_is_moderated + ? { + context_length: data.top_provider_context_length ?? null, + max_completion_tokens: + data.top_provider_max_completion_tokens ?? null, + is_moderated: data.top_provider_is_moderated, + } + : null, + upstream_provider_id: + data.upstream_provider_id?.trim().length + ? data.upstream_provider_id.trim() + : providerId, + canonical_slug: data.canonical_slug?.trim() || null, + alias_ids: listFromString(data.alias_ids_raw || ''), + enabled: data.enabled, + }; + + if (isEdit) { + await AdminService.updateProviderModel(providerId, data.id, adminModel); + toast.success('Model updated successfully'); + } else { + await AdminService.createProviderModel(providerId, adminModel); + toast.success( + isOverride ? 'Model override created' : 'Model created successfully' + ); + } + + onSuccess(); + onClose(); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : 'Unknown error saving model'; + toast.error(`Failed to save model: ${message}`); + } finally { + setIsSubmitting(false); + } + }; + + const title = isEdit + ? 'Edit Model' + : isOverride + ? 'Override Model' + : 'Add Custom Model'; + const description = isOverride + ? 'Create a custom override for this upstream model' + : 'Add a new model configuration for this provider'; + + return ( + !open && onClose()}> + + + + + {title} + + {description} + + {!isEdit && !isOverride && ( +
+
Presets
+
+ + + + + e.preventDefault()} + > + + + e.stopPropagation()} + > + {isLoadingPresets ? ( + Loading presets... + ) : presets.length === 0 ? ( + No presets available. + ) : ( + + {presets.map((preset) => ( + { + applyModelToForm(preset); + setIsPresetOpen(false); + }} + > +
+ {preset.id} + + {preset.name} + +
+
+ ))} +
+ )} +
+
+
+
+
+
+ Prefill fields from a preset model definition, then adjust as needed. +
+
+ )} +
+ +
+ ( + + Model ID * + + + + Unique identifier for the model + + + )} + /> + + ( + + Display Name * + + + + + + )} + /> +
+ + ( + + Description + +