Files
SuperCharged-Claude-Code-Up…/dexto/packages/webui/components/ModelPicker/CustomModelForms.tsx
admin b52318eeae feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 00:27:56 +04:00

1337 lines
52 KiB
TypeScript

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<CustomModelFormData>) => 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<CustomModelFormData>) => 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 */}
<div className="p-3 rounded-md bg-blue-500/10 border border-blue-500/30">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs text-blue-700 dark:text-blue-300">
Bedrock uses AWS credentials from your environment.
</p>
<p className="text-xs text-muted-foreground">
Make sure{' '}
<code className="px-1 py-0.5 rounded bg-muted text-[10px]">
AWS_REGION
</code>{' '}
and either{' '}
<code className="px-1 py-0.5 rounded bg-muted text-[10px]">
AWS_BEARER_TOKEN_BEDROCK
</code>{' '}
or IAM credentials are set.
</p>
<a
href={BEDROCK_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1"
>
View setup guide
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
{/* Model ID */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Model ID *</label>
<Input
value={formData.name}
onChange={(e) => {
onChange({ name: e.target.value });
setLocalError(null);
}}
placeholder="e.g., anthropic.claude-3-haiku-20240307-v1:0"
className="h-9 text-sm"
/>
<p className="text-[10px] text-muted-foreground">
Find model IDs in the{' '}
<a
href="https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
AWS Bedrock documentation
</a>
</p>
</div>
{/* Display Name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Display Name <span className="text-muted-foreground/60">(optional)</span>
</label>
<Input
value={formData.displayName}
onChange={(e) => onChange({ displayName: e.target.value })}
placeholder="Friendly name for the model"
className="h-9 text-sm"
/>
</div>
{/* Max Input Tokens */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Max Input Tokens <span className="text-muted-foreground/60">(optional)</span>
</label>
<Input
value={formData.maxInputTokens}
onChange={(e) => onChange({ maxInputTokens: e.target.value })}
placeholder="e.g., 200000 (leave blank for default)"
type="number"
className="h-9 text-sm"
/>
</div>
</>
);
}
/**
* OpenRouter fields - model ID with live validation, API key
*/
function OpenRouterFields({
formData,
onChange,
setLocalError,
providerKeyData,
}: ProviderFieldsProps) {
const { mutateAsync: validateOpenRouterModel } = useValidateOpenRouterModel();
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | 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 */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Model ID *</label>
<div className="relative">
<Input
value={formData.name}
onChange={(e) => {
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() && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
{isValidating && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{isValid && <Check className="h-4 w-4 text-green-500" />}
{isInvalid && <X className="h-4 w-4 text-red-500" />}
</div>
)}
</div>
{isInvalid && validation.error && (
<p className="text-[10px] text-red-500">{validation.error}</p>
)}
<p className="text-[10px] text-muted-foreground">
Find model IDs at{' '}
<a
href="https://openrouter.ai/models"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
openrouter.ai/models
</a>
</p>
</div>
{/* Display Name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Display Name <span className="text-muted-foreground/60">(optional)</span>
</label>
<Input
value={formData.displayName}
onChange={(e) => onChange({ displayName: e.target.value })}
placeholder="Friendly name for the model"
className="h-9 text-sm"
/>
</div>
{/* API Key */}
<ApiKeyField
formData={formData}
onChange={onChange}
providerKeyData={providerKeyData}
showApiKey={showApiKey}
setShowApiKey={setShowApiKey}
/>
</>
);
}
/**
* Glama fields - model ID (provider/model format), API key
*/
function GlamaFields({ formData, onChange, setLocalError, providerKeyData }: ProviderFieldsProps) {
const [showApiKey, setShowApiKey] = useState(false);
return (
<>
{/* Model ID */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Model ID *</label>
<Input
value={formData.name}
onChange={(e) => {
onChange({ name: e.target.value });
setLocalError(null);
}}
placeholder="e.g., openai/gpt-4o, anthropic/claude-3-sonnet"
className="h-9 text-sm"
/>
<p className="text-[10px] text-muted-foreground">
Format: provider/model. See{' '}
<a
href="https://glama.ai/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
glama.ai
</a>{' '}
for supported providers.
</p>
</div>
{/* Display Name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Display Name <span className="text-muted-foreground/60">(optional)</span>
</label>
<Input
value={formData.displayName}
onChange={(e) => onChange({ displayName: e.target.value })}
placeholder="Friendly name for the model"
className="h-9 text-sm"
/>
</div>
{/* API Key */}
<ApiKeyField
formData={formData}
onChange={onChange}
providerKeyData={providerKeyData}
showApiKey={showApiKey}
setShowApiKey={setShowApiKey}
/>
</>
);
}
/**
* 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 */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Model Name *</label>
<Input
value={formData.name}
onChange={(e) => {
onChange({ name: e.target.value });
setLocalError(null);
}}
placeholder="e.g., llama3.2:latest"
className="h-9 text-sm"
/>
</div>
{/* Base URL */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Base URL *</label>
<Input
value={formData.baseURL}
onChange={(e) => {
onChange({ baseURL: e.target.value });
setLocalError(null);
}}
placeholder="e.g., http://localhost:11434/v1"
className="h-9 text-sm"
/>
<p className="text-[10px] text-muted-foreground">
The API endpoint URL (must include /v1 for OpenAI-compatible APIs)
</p>
</div>
{/* Display Name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Display Name <span className="text-muted-foreground/60">(optional)</span>
</label>
<Input
value={formData.displayName}
onChange={(e) => onChange({ displayName: e.target.value })}
placeholder="Friendly name for the model"
className="h-9 text-sm"
/>
</div>
{/* API Key */}
<ApiKeyField
formData={formData}
onChange={onChange}
providerKeyData={providerKeyData}
showApiKey={showApiKey}
setShowApiKey={setShowApiKey}
placeholder="Required if your endpoint needs authentication"
/>
{/* Token limits */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Max Input Tokens
</label>
<Input
value={formData.maxInputTokens}
onChange={(e) => onChange({ maxInputTokens: e.target.value })}
placeholder="128000"
type="number"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Max Output Tokens
</label>
<Input
value={formData.maxOutputTokens}
onChange={(e) => onChange({ maxOutputTokens: e.target.value })}
placeholder="Optional"
type="number"
className="h-9 text-sm"
/>
</div>
</div>
</>
);
}
/**
* 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 */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Model Name *</label>
<Input
value={formData.name}
onChange={(e) => {
onChange({ name: e.target.value });
setLocalError(null);
}}
placeholder="e.g., gpt-4, claude-3-sonnet"
className="h-9 text-sm"
/>
</div>
{/* Base URL */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
LiteLLM Proxy URL *
</label>
<Input
value={formData.baseURL}
onChange={(e) => {
onChange({ baseURL: e.target.value });
setLocalError(null);
}}
placeholder="e.g., http://localhost:4000"
className="h-9 text-sm"
/>
<p className="text-[10px] text-muted-foreground">Your LiteLLM proxy URL</p>
</div>
{/* Display Name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Display Name <span className="text-muted-foreground/60">(optional)</span>
</label>
<Input
value={formData.displayName}
onChange={(e) => onChange({ displayName: e.target.value })}
placeholder="Friendly name for the model"
className="h-9 text-sm"
/>
</div>
{/* API Key */}
<ApiKeyField
formData={formData}
onChange={onChange}
providerKeyData={providerKeyData}
showApiKey={showApiKey}
setShowApiKey={setShowApiKey}
/>
{/* Token limits */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Max Input Tokens
</label>
<Input
value={formData.maxInputTokens}
onChange={(e) => onChange({ maxInputTokens: e.target.value })}
placeholder="128000"
type="number"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Max Output Tokens
</label>
<Input
value={formData.maxOutputTokens}
onChange={(e) => onChange({ maxOutputTokens: e.target.value })}
placeholder="Optional"
type="number"
className="h-9 text-sm"
/>
</div>
</div>
</>
);
}
/**
* Ollama fields - model name, optional baseURL
* No API key required
*/
function OllamaFields({ formData, onChange, setLocalError }: ProviderFieldsProps) {
return (
<>
{/* Setup Guide Banner */}
<div className="p-3 rounded-md bg-blue-500/10 border border-blue-500/30">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs text-blue-700 dark:text-blue-300">
Ollama must be installed and running.
</p>
<a
href="https://ollama.ai"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1"
>
Get Ollama
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
{/* Model Name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Model Name *</label>
<Input
value={formData.name}
onChange={(e) => {
onChange({ name: e.target.value });
setLocalError(null);
}}
placeholder="e.g., llama3.2:latest, mistral:7b"
className="h-9 text-sm"
/>
<p className="text-[10px] text-muted-foreground">
Run{' '}
<code className="px-1 py-0.5 rounded bg-muted text-[10px]">ollama list</code> to
see available models
</p>
</div>
{/* Base URL (optional) */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Ollama URL <span className="text-muted-foreground/60">(optional)</span>
</label>
<Input
value={formData.baseURL}
onChange={(e) => onChange({ baseURL: e.target.value })}
placeholder="http://localhost:11434 (default)"
className="h-9 text-sm"
/>
</div>
{/* Display Name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Display Name <span className="text-muted-foreground/60">(optional)</span>
</label>
<Input
value={formData.displayName}
onChange={(e) => onChange({ displayName: e.target.value })}
placeholder="Friendly name for the model"
className="h-9 text-sm"
/>
</div>
</>
);
}
/**
* 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<ReturnType<typeof setTimeout> | 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 */}
<div className="p-3 rounded-md bg-blue-500/10 border border-blue-500/30">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs text-blue-700 dark:text-blue-300">
Requires node-llama-cpp to be installed.
</p>
<p className="text-xs text-muted-foreground">
Run{' '}
<code className="px-1 py-0.5 rounded bg-muted text-[10px]">
dexto setup
</code>{' '}
and select &quot;local&quot; to install dependencies.
</p>
</div>
</div>
</div>
{/* Model ID */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Model ID *</label>
<Input
value={formData.name}
onChange={(e) => {
onChange({ name: e.target.value });
setLocalError(null);
}}
placeholder="e.g., my-custom-llama"
className="h-9 text-sm"
/>
<p className="text-[10px] text-muted-foreground">
A unique identifier for this model
</p>
</div>
{/* File Path */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
GGUF File Path *
</label>
<div className="relative">
<Input
value={formData.filePath}
onChange={(e) => {
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() && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
{isValidating && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{isValid && <Check className="h-4 w-4 text-green-500" />}
{isInvalid && <X className="h-4 w-4 text-red-500" />}
</div>
)}
</div>
{isValid && validation.sizeBytes && (
<p className="text-[10px] text-green-600">
Found: {formatSize(validation.sizeBytes)}
</p>
)}
{isInvalid && validation.error && (
<p className="text-[10px] text-red-500">{validation.error}</p>
)}
</div>
{/* Display Name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Display Name <span className="text-muted-foreground/60">(optional)</span>
</label>
<Input
value={formData.displayName}
onChange={(e) => onChange({ displayName: e.target.value })}
placeholder="Friendly name for the model"
className="h-9 text-sm"
/>
</div>
{/* Note: Context length is auto-detected by node-llama-cpp from GGUF metadata */}
<p className="text-[10px] text-muted-foreground/60">
Context length is automatically detected from the GGUF file.
</p>
</>
);
}
/**
* 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<ReturnType<typeof setTimeout> | 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 */}
<div className="p-3 rounded-md bg-purple-500/10 border border-purple-500/30">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs text-purple-700 dark:text-purple-300">
Uses your Dexto credits with OpenRouter model IDs.
</p>
<p className="text-xs text-muted-foreground">
Requires login: run{' '}
<code className="px-1 py-0.5 rounded bg-muted text-[10px]">
dexto login
</code>{' '}
from the CLI first.
</p>
</div>
</div>
</div>
{/* Model ID */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Model ID *</label>
<div className="relative">
<Input
value={formData.name}
onChange={(e) => {
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() && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
{isValidating && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{isValid && <Check className="h-4 w-4 text-green-500" />}
{isInvalid && <X className="h-4 w-4 text-red-500" />}
</div>
)}
</div>
{isInvalid && validation.error && (
<p className="text-[10px] text-red-500">{validation.error}</p>
)}
<p className="text-[10px] text-muted-foreground">
Find model IDs at{' '}
<a
href="https://openrouter.ai/models"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
openrouter.ai/models
</a>
</p>
</div>
{/* Display Name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Display Name <span className="text-muted-foreground/60">(optional)</span>
</label>
<Input
value={formData.displayName}
onChange={(e) => onChange({ displayName: e.target.value })}
placeholder="Friendly name for the model"
className="h-9 text-sm"
/>
</div>
{/* 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<CustomModelFormData>) => void;
providerKeyData?: { hasKey: boolean; envVar: string };
showApiKey: boolean;
setShowApiKey: (show: boolean) => void;
placeholder?: string;
}) {
const hasExistingKey = providerKeyData?.hasKey ?? false;
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-muted-foreground">
API Key <span className="text-muted-foreground/60">(optional)</span>
</label>
{hasExistingKey && !formData.apiKey && (
<span className="inline-flex items-center gap-1 text-[10px] text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
Configured
</span>
)}
</div>
<div className="relative">
<Input
value={formData.apiKey}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
>
{showApiKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
<p className="text-[10px] text-muted-foreground">
{hasExistingKey
? formData.apiKey
? `Will override ${providerKeyData?.envVar} for this model`
: `Using ${providerKeyData?.envVar}`
: `Will be saved as ${providerKeyData?.envVar || 'provider env var'}`}
</p>
</div>
);
}
// ============================================================================
// 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<string | null>(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 <BedrockFields {...props} />;
case 'openrouter':
return <OpenRouterFields {...props} />;
case 'glama':
return <GlamaFields {...props} />;
case 'litellm':
return <LiteLLMFields {...props} />;
case 'ollama':
return <OllamaFields {...props} />;
case 'local':
return <LocalFields {...props} />;
case 'dexto':
return <DextoFields {...props} />;
case 'openai-compatible':
default:
return <OpenAICompatibleFields {...props} />;
}
};
return (
<div className="flex flex-col flex-1 min-h-0 max-h-full">
{/* Header */}
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 border-b border-border/30">
<h3 className="text-sm font-semibold text-foreground">
{isEditing ? 'Edit Custom Model' : 'Add Custom Model'}
</h3>
<button
onClick={onCancel}
className="p-1.5 rounded-md hover:bg-muted transition-colors"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</div>
{/* Scrollable Form Content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
{/* Error Display */}
{displayError && (
<div className="p-2 rounded-md bg-destructive/10 border border-destructive/30">
<p className="text-xs text-destructive">{displayError}</p>
</div>
)}
{/* Provider Dropdown */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Provider</label>
<div className="relative">
<button
type="button"
onClick={() => setDropdownOpen(!dropdownOpen)}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-md',
'bg-muted/50 border border-border/50 text-sm',
'hover:bg-muted transition-colors',
dropdownOpen && 'ring-2 ring-ring'
)}
>
<span className="text-foreground">{selectedProvider?.label}</span>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
dropdownOpen && 'rotate-180'
)}
/>
</button>
{dropdownOpen && (
<div className="absolute z-50 w-full mt-1 rounded-md border border-border bg-popover shadow-lg">
{providerOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onChange({
provider: option.value,
name: '',
baseURL: '',
displayName: '',
apiKey: '',
maxInputTokens: '',
maxOutputTokens: '',
filePath: '',
});
setDropdownOpen(false);
}}
className={cn(
'w-full px-3 py-2 text-left hover:bg-accent transition-colors',
'first:rounded-t-md last:rounded-b-md',
formData.provider === option.value && 'bg-accent/50'
)}
>
<div className="text-sm font-medium">{option.label}</div>
<div className="text-xs text-muted-foreground">
{option.description}
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Provider-specific fields */}
{renderProviderFields()}
</div>
{/* Footer */}
<div className="flex-shrink-0 px-4 py-3 border-t border-border/30 flex gap-2">
<Button variant="outline" onClick={onCancel} className="flex-1 h-9 text-sm">
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !canSubmit}
className="flex-1 h-9 text-sm"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : isEditing ? (
<Check className="h-4 w-4 mr-2" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{isEditing ? 'Save & Select Model' : 'Add & Select Model'}
</Button>
</div>
</div>
);
}