import React, { useEffect, useRef, useState } from 'react'; import { Input } from '../ui/input'; import { Button } from '../ui/button'; import { Loader2, Plus, X, ChevronDown, Eye, EyeOff, Check, ExternalLink, Info, } from 'lucide-react'; import { cn } from '../../lib/utils'; import { validateBaseURL } from './types'; import { useValidateOpenRouterModel } from '../hooks/useOpenRouter'; import { useProviderApiKey, type LLMProvider } from '../hooks/useLLM'; import { useValidateLocalFile } from '../hooks/useModels'; import { useDextoAuth } from '../hooks/useDextoAuth'; const BEDROCK_DOCS_URL = 'https://docs.dexto.ai/docs/guides/supported-llm-providers#amazon-bedrock'; // 'vertex' is TODO - see comment in PROVIDER_OPTIONS. export type CustomModelProvider = | 'openai-compatible' | 'openrouter' | 'litellm' | 'glama' | 'bedrock' | 'ollama' | 'local' | 'dexto'; export interface CustomModelFormData { provider: CustomModelProvider; name: string; baseURL: string; displayName: string; maxInputTokens: string; maxOutputTokens: string; apiKey: string; filePath: string; } interface CustomModelFormProps { formData: CustomModelFormData; onChange: (updates: Partial) => void; onSubmit: () => void; onCancel: () => void; isSubmitting?: boolean; error?: string | null; isEditing?: boolean; } const PROVIDER_OPTIONS: { value: CustomModelProvider; label: string; description: string }[] = [ { value: 'openai-compatible', label: 'OpenAI-Compatible', description: 'Local or self-hosted models (Ollama, vLLM, etc.)', }, { value: 'openrouter', label: 'OpenRouter', description: 'Access 100+ models via OpenRouter API', }, { value: 'litellm', label: 'LiteLLM', description: 'Unified proxy for 100+ LLM providers', }, { value: 'glama', label: 'Glama', description: 'OpenAI-compatible gateway for multiple providers', }, { value: 'bedrock', label: 'AWS Bedrock', description: 'Custom Bedrock model IDs (uses AWS credentials)', }, { value: 'ollama', label: 'Ollama', description: 'Local Ollama server models', }, { value: 'local', label: 'Local (GGUF)', description: 'Custom GGUF files via node-llama-cpp', }, // TODO: Add 'vertex' provider for custom Vertex AI model IDs (uses ADC auth like Bedrock) // Would allow users to add model IDs not yet in registry (e.g., new Gemini previews) ]; // Dexto option is feature-flagged - shown separately when enabled const DEXTO_PROVIDER_OPTION = { value: 'dexto' as const, label: 'Dexto', description: 'Access 100+ models with Dexto credits (login required)', }; // ============================================================================ // Provider-specific field components // ============================================================================ interface ProviderFieldsProps { formData: CustomModelFormData; onChange: (updates: Partial) => void; setLocalError: (error: string | null) => void; providerKeyData?: { hasKey: boolean; envVar: string }; } /** * Bedrock fields - just model ID, display name, max tokens * No API key (uses AWS credentials) */ function BedrockFields({ formData, onChange, setLocalError }: ProviderFieldsProps) { return ( <> {/* Setup Guide Banner */}

Bedrock uses AWS credentials from your environment.

Make sure{' '} AWS_REGION {' '} and either{' '} AWS_BEARER_TOKEN_BEDROCK {' '} or IAM credentials are set.

View setup guide
{/* Model ID */}
{ onChange({ name: e.target.value }); setLocalError(null); }} placeholder="e.g., anthropic.claude-3-haiku-20240307-v1:0" className="h-9 text-sm" />

Find model IDs in the{' '} AWS Bedrock documentation

{/* Display Name */}
onChange({ displayName: e.target.value })} placeholder="Friendly name for the model" className="h-9 text-sm" />
{/* Max Input Tokens */}
onChange({ maxInputTokens: e.target.value })} placeholder="e.g., 200000 (leave blank for default)" type="number" className="h-9 text-sm" />
); } /** * OpenRouter fields - model ID with live validation, API key */ function OpenRouterFields({ formData, onChange, setLocalError, providerKeyData, }: ProviderFieldsProps) { const { mutateAsync: validateOpenRouterModel } = useValidateOpenRouterModel(); const validationTimerRef = useRef | null>(null); const [showApiKey, setShowApiKey] = useState(false); const [validation, setValidation] = useState<{ status: 'idle' | 'validating' | 'valid' | 'invalid'; error?: string; }>({ status: 'idle' }); // Debounced validation useEffect(() => { const modelId = formData.name.trim(); if (!modelId) { setValidation({ status: 'idle' }); return; } if (!modelId.includes('/')) { setValidation({ status: 'invalid', error: 'Format: provider/model (e.g., anthropic/claude-3.5-sonnet)', }); return; } if (validationTimerRef.current) clearTimeout(validationTimerRef.current); setValidation({ status: 'validating' }); validationTimerRef.current = setTimeout(async () => { try { const result = await validateOpenRouterModel(modelId); setValidation( result.valid ? { status: 'valid' } : { status: 'invalid', error: result.error || `Model '${modelId}' not found`, } ); } catch { setValidation({ status: 'invalid', error: 'Validation failed - check model ID' }); } }, 500); return () => { if (validationTimerRef.current) clearTimeout(validationTimerRef.current); }; }, [formData.name, validateOpenRouterModel]); const isValid = validation.status === 'valid'; const isInvalid = validation.status === 'invalid'; const isValidating = validation.status === 'validating'; return ( <> {/* Model ID */}
{ onChange({ name: e.target.value }); setLocalError(null); }} placeholder="e.g., anthropic/claude-3.5-sonnet" className={cn( 'h-9 text-sm pr-8', isValid && 'border-green-500 focus-visible:ring-green-500', isInvalid && 'border-red-500 focus-visible:ring-red-500' )} /> {formData.name.trim() && (
{isValidating && ( )} {isValid && } {isInvalid && }
)}
{isInvalid && validation.error && (

{validation.error}

)}

Find model IDs at{' '} openrouter.ai/models

{/* Display Name */}
onChange({ displayName: e.target.value })} placeholder="Friendly name for the model" className="h-9 text-sm" />
{/* API Key */} ); } /** * Glama fields - model ID (provider/model format), API key */ function GlamaFields({ formData, onChange, setLocalError, providerKeyData }: ProviderFieldsProps) { const [showApiKey, setShowApiKey] = useState(false); return ( <> {/* Model ID */}
{ onChange({ name: e.target.value }); setLocalError(null); }} placeholder="e.g., openai/gpt-4o, anthropic/claude-3-sonnet" className="h-9 text-sm" />

Format: provider/model. See{' '} glama.ai {' '} for supported providers.

{/* Display Name */}
onChange({ displayName: e.target.value })} placeholder="Friendly name for the model" className="h-9 text-sm" />
{/* API Key */} ); } /** * OpenAI-Compatible fields - model name, baseURL (required), API key, token limits */ function OpenAICompatibleFields({ formData, onChange, setLocalError, providerKeyData, }: ProviderFieldsProps) { const [showApiKey, setShowApiKey] = useState(false); return ( <> {/* Model Name */}
{ onChange({ name: e.target.value }); setLocalError(null); }} placeholder="e.g., llama3.2:latest" className="h-9 text-sm" />
{/* Base URL */}
{ onChange({ baseURL: e.target.value }); setLocalError(null); }} placeholder="e.g., http://localhost:11434/v1" className="h-9 text-sm" />

The API endpoint URL (must include /v1 for OpenAI-compatible APIs)

{/* Display Name */}
onChange({ displayName: e.target.value })} placeholder="Friendly name for the model" className="h-9 text-sm" />
{/* API Key */} {/* Token limits */}
onChange({ maxInputTokens: e.target.value })} placeholder="128000" type="number" className="h-9 text-sm" />
onChange({ maxOutputTokens: e.target.value })} placeholder="Optional" type="number" className="h-9 text-sm" />
); } /** * LiteLLM fields - model name, baseURL (required), API key, token limits */ function LiteLLMFields({ formData, onChange, setLocalError, providerKeyData, }: ProviderFieldsProps) { const [showApiKey, setShowApiKey] = useState(false); return ( <> {/* Model Name */}
{ onChange({ name: e.target.value }); setLocalError(null); }} placeholder="e.g., gpt-4, claude-3-sonnet" className="h-9 text-sm" />
{/* Base URL */}
{ onChange({ baseURL: e.target.value }); setLocalError(null); }} placeholder="e.g., http://localhost:4000" className="h-9 text-sm" />

Your LiteLLM proxy URL

{/* Display Name */}
onChange({ displayName: e.target.value })} placeholder="Friendly name for the model" className="h-9 text-sm" />
{/* API Key */} {/* Token limits */}
onChange({ maxInputTokens: e.target.value })} placeholder="128000" type="number" className="h-9 text-sm" />
onChange({ maxOutputTokens: e.target.value })} placeholder="Optional" type="number" className="h-9 text-sm" />
); } /** * Ollama fields - model name, optional baseURL * No API key required */ function OllamaFields({ formData, onChange, setLocalError }: ProviderFieldsProps) { return ( <> {/* Setup Guide Banner */}

Ollama must be installed and running.

Get Ollama
{/* Model Name */}
{ onChange({ name: e.target.value }); setLocalError(null); }} placeholder="e.g., llama3.2:latest, mistral:7b" className="h-9 text-sm" />

Run{' '} ollama list to see available models

{/* Base URL (optional) */}
onChange({ baseURL: e.target.value })} placeholder="http://localhost:11434 (default)" className="h-9 text-sm" />
{/* Display Name */}
onChange({ displayName: e.target.value })} placeholder="Friendly name for the model" className="h-9 text-sm" />
); } /** * Local GGUF fields - model ID, file path with validation * No API key required */ function LocalFields({ formData, onChange, setLocalError }: ProviderFieldsProps) { const { mutateAsync: validateFile } = useValidateLocalFile(); const validationTimerRef = useRef | null>(null); const [validation, setValidation] = useState<{ status: 'idle' | 'validating' | 'valid' | 'invalid'; sizeBytes?: number; error?: string; }>({ status: 'idle' }); // Debounced file validation useEffect(() => { const filePath = formData.filePath?.trim(); if (!filePath) { setValidation({ status: 'idle' }); return; } if (!filePath.startsWith('/')) { setValidation({ status: 'invalid', error: 'Path must be absolute (start with /)' }); return; } if (!filePath.endsWith('.gguf')) { setValidation({ status: 'invalid', error: 'File must have .gguf extension' }); return; } if (validationTimerRef.current) clearTimeout(validationTimerRef.current); setValidation({ status: 'validating' }); validationTimerRef.current = setTimeout(async () => { try { const result = await validateFile(filePath); setValidation( result.valid ? { status: 'valid', sizeBytes: result.sizeBytes } : { status: 'invalid', error: result.error || 'File not found' } ); } catch { setValidation({ status: 'invalid', error: 'Validation failed' }); } }, 500); return () => { if (validationTimerRef.current) clearTimeout(validationTimerRef.current); }; }, [formData.filePath, validateFile]); const isValid = validation.status === 'valid'; const isInvalid = validation.status === 'invalid'; const isValidating = validation.status === 'validating'; // Helper to format file size const formatSize = (bytes?: number) => { if (!bytes) return ''; const gb = bytes / (1024 * 1024 * 1024); if (gb >= 1) return `${gb.toFixed(1)} GB`; const mb = bytes / (1024 * 1024); return `${mb.toFixed(0)} MB`; }; return ( <> {/* Setup Guide Banner */}

Requires node-llama-cpp to be installed.

Run{' '} dexto setup {' '} and select "local" to install dependencies.

{/* Model ID */}
{ onChange({ name: e.target.value }); setLocalError(null); }} placeholder="e.g., my-custom-llama" className="h-9 text-sm" />

A unique identifier for this model

{/* File Path */}
{ onChange({ filePath: e.target.value }); setLocalError(null); }} placeholder="/path/to/model.gguf" className={cn( 'h-9 text-sm pr-8', isValid && 'border-green-500 focus-visible:ring-green-500', isInvalid && 'border-red-500 focus-visible:ring-red-500' )} /> {formData.filePath?.trim() && (
{isValidating && ( )} {isValid && } {isInvalid && }
)}
{isValid && validation.sizeBytes && (

Found: {formatSize(validation.sizeBytes)}

)} {isInvalid && validation.error && (

{validation.error}

)}
{/* Display Name */}
onChange({ displayName: e.target.value })} placeholder="Friendly name for the model" className="h-9 text-sm" />
{/* Note: Context length is auto-detected by node-llama-cpp from GGUF metadata */}

Context length is automatically detected from the GGUF file.

); } /** * Dexto fields - model ID with OpenRouter format, no API key (uses OAuth login) */ function DextoFields({ formData, onChange, setLocalError }: ProviderFieldsProps) { const { mutateAsync: validateOpenRouterModel } = useValidateOpenRouterModel(); const validationTimerRef = useRef | null>(null); const [validation, setValidation] = useState<{ status: 'idle' | 'validating' | 'valid' | 'invalid'; error?: string; }>({ status: 'idle' }); // Debounced validation (reuses OpenRouter validation since Dexto uses OpenRouter model IDs) useEffect(() => { const modelId = formData.name.trim(); if (!modelId) { setValidation({ status: 'idle' }); return; } if (!modelId.includes('/')) { setValidation({ status: 'invalid', error: 'Format: provider/model (e.g., anthropic/claude-sonnet-4.5)', }); return; } if (validationTimerRef.current) clearTimeout(validationTimerRef.current); setValidation({ status: 'validating' }); validationTimerRef.current = setTimeout(async () => { try { const result = await validateOpenRouterModel(modelId); setValidation( result.valid ? { status: 'valid' } : { status: 'invalid', error: result.error || `Model '${modelId}' not found. Check https://openrouter.ai/models`, } ); } catch { setValidation({ status: 'invalid', error: 'Validation failed - check model ID' }); } }, 500); return () => { if (validationTimerRef.current) clearTimeout(validationTimerRef.current); }; }, [formData.name, validateOpenRouterModel]); const isValid = validation.status === 'valid'; const isInvalid = validation.status === 'invalid'; const isValidating = validation.status === 'validating'; return ( <> {/* Setup Guide Banner */}

Uses your Dexto credits with OpenRouter model IDs.

Requires login: run{' '} dexto login {' '} from the CLI first.

{/* Model ID */}
{ onChange({ name: e.target.value }); setLocalError(null); }} placeholder="e.g., anthropic/claude-sonnet-4.5, openai/gpt-5.2" className={cn( 'h-9 text-sm pr-8', isValid && 'border-green-500 focus-visible:ring-green-500', isInvalid && 'border-red-500 focus-visible:ring-red-500' )} /> {formData.name.trim() && (
{isValidating && ( )} {isValid && } {isInvalid && }
)}
{isInvalid && validation.error && (

{validation.error}

)}

Find model IDs at{' '} openrouter.ai/models

{/* Display Name */}
onChange({ displayName: e.target.value })} placeholder="Friendly name for the model" className="h-9 text-sm" />
{/* No API Key field - Dexto uses OAuth login */} ); } /** * Reusable API Key field component */ function ApiKeyField({ formData, onChange, providerKeyData, showApiKey, setShowApiKey, placeholder, }: { formData: CustomModelFormData; onChange: (updates: Partial) => void; providerKeyData?: { hasKey: boolean; envVar: string }; showApiKey: boolean; setShowApiKey: (show: boolean) => void; placeholder?: string; }) { const hasExistingKey = providerKeyData?.hasKey ?? false; return (
{hasExistingKey && !formData.apiKey && ( Configured )}
onChange({ apiKey: e.target.value })} placeholder={ placeholder || (hasExistingKey ? 'Leave empty to use existing key' : 'Enter API key for this endpoint') } type={showApiKey ? 'text' : 'password'} className="h-9 text-sm pr-10" />

{hasExistingKey ? formData.apiKey ? `Will override ${providerKeyData?.envVar} for this model` : `Using ${providerKeyData?.envVar}` : `Will be saved as ${providerKeyData?.envVar || 'provider env var'}`}

); } // ============================================================================ // Main form component // ============================================================================ /** * Unified custom model form with provider dropdown and provider-specific fields */ export function CustomModelForm({ formData, onChange, onSubmit, onCancel, isSubmitting, error, isEditing = false, }: CustomModelFormProps) { const [dropdownOpen, setDropdownOpen] = useState(false); const [localError, setLocalError] = useState(null); // Fetch provider API key status (not the actual key - it's masked for security) const { data: providerKeyData } = useProviderApiKey(formData.provider as LLMProvider); // Fetch dexto auth status to conditionally show dexto provider option const { data: dextoAuthStatus } = useDextoAuth(); const showDextoProvider = dextoAuthStatus?.enabled ?? false; // Build provider options list - include dexto when feature is enabled const providerOptions = showDextoProvider ? [...PROVIDER_OPTIONS, DEXTO_PROVIDER_OPTION] : PROVIDER_OPTIONS; // Reset error when provider changes useEffect(() => { setLocalError(null); }, [formData.provider]); // Reset provider to default if dexto is selected but becomes unavailable useEffect(() => { if (dextoAuthStatus && !showDextoProvider && formData.provider === 'dexto') { onChange({ ...formData, provider: 'openai-compatible', }); } }, [dextoAuthStatus, showDextoProvider, formData, onChange]); const handleSubmit = () => { // Provider-specific validation switch (formData.provider) { case 'openai-compatible': case 'litellm': { if (!formData.name.trim()) { setLocalError('Model name is required'); return; } if (!formData.baseURL.trim()) { setLocalError('Base URL is required'); return; } const urlValidation = validateBaseURL(formData.baseURL); if (!urlValidation.isValid) { setLocalError(urlValidation.error || 'Invalid Base URL'); return; } break; } case 'openrouter': if (!formData.name.trim()) { setLocalError('Model ID is required'); return; } if (!formData.name.includes('/')) { setLocalError('Format: provider/model (e.g., anthropic/claude-3.5-sonnet)'); return; } break; case 'glama': if (!formData.name.trim()) { setLocalError('Model ID is required'); return; } if (!formData.name.includes('/')) { setLocalError('Glama models use format: provider/model (e.g., openai/gpt-4o)'); return; } break; case 'bedrock': if (!formData.name.trim()) { setLocalError('Model ID is required'); return; } break; case 'ollama': if (!formData.name.trim()) { setLocalError('Model name is required'); return; } // Optional baseURL validation if provided if (formData.baseURL.trim()) { const urlValidation = validateBaseURL(formData.baseURL); if (!urlValidation.isValid) { setLocalError(urlValidation.error || 'Invalid Ollama URL'); return; } } break; case 'local': if (!formData.name.trim()) { setLocalError('Model ID is required'); return; } if (!formData.filePath?.trim()) { setLocalError('GGUF file path is required'); return; } if (!formData.filePath.startsWith('/')) { setLocalError('File path must be absolute (start with /)'); return; } if (!formData.filePath.endsWith('.gguf')) { setLocalError('File must have .gguf extension'); return; } break; case 'dexto': if (!formData.name.trim()) { setLocalError('Model ID is required'); return; } if (!formData.name.includes('/')) { setLocalError('Format: provider/model (e.g., anthropic/claude-sonnet-4.5)'); return; } break; } setLocalError(null); onSubmit(); }; const canSubmit = (() => { switch (formData.provider) { case 'openai-compatible': case 'litellm': return formData.name.trim() && formData.baseURL.trim(); case 'openrouter': case 'glama': return formData.name.trim() && formData.name.includes('/'); case 'bedrock': return formData.name.trim().length > 0; case 'ollama': return formData.name.trim().length > 0; case 'local': return ( formData.name.trim().length > 0 && formData.filePath?.trim().startsWith('/') && formData.filePath?.trim().endsWith('.gguf') ); case 'dexto': return formData.name.trim() && formData.name.includes('/'); default: return false; } })(); const displayError = localError || error; const selectedProvider = providerOptions.find((p) => p.value === formData.provider); const renderProviderFields = () => { const props: ProviderFieldsProps = { formData, onChange, setLocalError, providerKeyData: providerKeyData ? { hasKey: providerKeyData.hasKey, envVar: providerKeyData.envVar, } : undefined, }; switch (formData.provider) { case 'bedrock': return ; case 'openrouter': return ; case 'glama': return ; case 'litellm': return ; case 'ollama': return ; case 'local': return ; case 'dexto': return ; case 'openai-compatible': default: return ; } }; return (
{/* Header */}

{isEditing ? 'Edit Custom Model' : 'Add Custom Model'}

{/* Scrollable Form Content */}
{/* Error Display */} {displayError && (

{displayError}

)} {/* Provider Dropdown */}
{dropdownOpen && (
{providerOptions.map((option) => ( ))}
)}
{/* Provider-specific fields */} {renderProviderFields()}
{/* Footer */}
); }