'use client' import { useCallback, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { KeyRound, Loader2, Shield, Trash2, CheckCircle2, XCircle, Zap, FlaskConical, Pencil, X } from 'lucide-react' import { toast } from 'sonner' import { useLanguage } from '@/lib/i18n' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { cn } from '@/lib/utils' // ─── Provider display info ──────────────────────────────────────────────────── const PROVIDER_INFO: Record = { openai: { name: 'OpenAI', hint: 'GPT-4o, GPT-4' }, anthropic: { name: 'Anthropic', hint: 'Claude 3.5 Sonnet, Haiku' }, minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' }, google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' }, deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' }, openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' }, mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' }, glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' }, zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' }, anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' }, custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' }, custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' }, custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' }, } function displayName(provider: string): string { return PROVIDER_INFO[provider]?.name ?? provider } const MANUAL_MODEL_PROVIDERS = new Set(['custom']) const NEEDS_BASE_URL = new Set(['custom', 'custom_openai', 'custom_anthropic']) function getDefaultBaseUrl(p: string): string { if (p === 'custom_openai') return 'https://api.openai.com/v1' if (p === 'custom_anthropic') return 'https://api.anthropic.com/v1' return '' } // ─── Types ──────────────────────────────────────────────────────────────────── type SavedKey = { provider: string alias: string | null model: string | null baseUrl: string | null isActive: boolean lastUsedAt: string | null } async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> { const res = await fetch('/api/user/api-keys') if (!res.ok) throw new Error('Erreur de chargement') return res.json() } async function fetchModelsFromServer(provider: string, key?: string, baseUrl?: string): Promise { const q = new URLSearchParams({ provider }) if (key) q.set('key', key) if (baseUrl) q.set('baseUrl', baseUrl) const res = await fetch(`/api/user/api-keys/live-models?${q}`) if (!res.ok) return [] const body = await res.json() return body.success && Array.isArray(body.models) ? body.models : [] } // ─── Inline edit form for a saved key ──────────────────────────────────────── function EditKeyForm({ savedKey, onDone, onInvalidate, }: { savedKey: SavedKey onDone: () => void onInvalidate: () => void }) { const [alias, setAlias] = useState(savedKey.alias ?? '') const [model, setModel] = useState(savedKey.model ?? '') const [baseUrl, setBaseUrl] = useState(savedKey.baseUrl ?? getDefaultBaseUrl(savedKey.provider)) const [newKey, setNewKey] = useState('') // empty = keep existing key const [models, setModels] = useState([]) const [loadingModels, setLoadingModels] = useState(false) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null) const needsUrl = NEEDS_BASE_URL.has(savedKey.provider) const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider) // Load models on mount useState(() => { if (manualModel) return fetchModelsFromServer(savedKey.provider, undefined, baseUrl || undefined).then((list) => { setModels(list) if (list.length > 0 && !model) setModel(list[0]) }) }) const saveMutation = useMutation({ mutationFn: async () => { const payload: Record = { model: model || undefined, alias: alias.trim() || undefined, } if (needsUrl) payload.baseUrl = baseUrl if (newKey.length >= 8) payload.apiKey = newKey const res = await fetch(`/api/user/api-keys/${encodeURIComponent(savedKey.provider)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) const body = await res.json().catch(() => ({})) if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec') }, onSuccess: () => { toast.success(`${displayName(savedKey.provider)} mis à jour ✓`) onInvalidate() onDone() }, onError: (err: Error) => toast.error(err.message), }) async function testModel() { const keyToUse = newKey.length >= 8 ? newKey : '' if (!keyToUse && !model) return if (!keyToUse) { toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"') return } setTesting(true) setTestResult(null) try { const q = new URLSearchParams({ provider: savedKey.provider, key: keyToUse, model }) if (needsUrl && baseUrl) q.append('baseUrl', baseUrl) const res = await fetch(`/api/user/api-keys/test-model?${q}`) setTestResult(await res.json()) } catch { setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) } finally { setTesting(false) } } const showModelDropdown = !manualModel && models.length > 0 const showModelInput = manualModel || (!loadingModels && models.length === 0) return (

Modifier — {displayName(savedKey.provider)}

{/* Alias */}
setAlias(e.target.value)} placeholder="ex. Ma clé pro" />
{needsUrl && (
setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
)}
{/* Model */}
{showModelDropdown ? ( ) : showModelInput ? ( setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" /> ) :
Chargement...
}
{/* Key rotation (optional) */}
{ setNewKey(e.target.value); setTestResult(null) }} placeholder="sk-... (optionnel)" />
{/* Test result */} {testResult && (
{testResult.ok ? : } {testResult.ok ? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}` : testResult.error ?? 'Échec' }
)} {/* Actions */}
) } // ─── Main Component ─────────────────────────────────────────────────────────── export function ByokSettingsPanel() { const { t } = useLanguage() const queryClient = useQueryClient() const [provider, setProvider] = useState('') const [apiKey, setApiKey] = useState('') const [alias, setAlias] = useState('') const [baseUrl, setBaseUrl] = useState('') const [model, setModel] = useState('') const [models, setModels] = useState([]) const [loadingModels, setLoadingModels] = useState(false) const [keyOk, setKeyOk] = useState(false) const [verifying, setVerifying] = useState(false) const [editingProvider, setEditingProvider] = useState(null) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null) const { data, isLoading, error } = useQuery({ queryKey: ['user', 'api-keys'], queryFn: loadKeys, }) const invalidate = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['user', 'api-keys'] }) queryClient.invalidateQueries({ queryKey: ['usage', 'current'] }) }, [queryClient]) async function onProviderChange(p: string) { setProvider(p) setApiKey('') setBaseUrl(getDefaultBaseUrl(p)) setKeyOk(false) setTestResult(null) setModel('') setModels([]) if (MANUAL_MODEL_PROVIDERS.has(p)) return setLoadingModels(true) try { const list = await fetchModelsFromServer(p) setModels(list) if (list.length > 0) setModel(list[0]) } finally { setLoadingModels(false) } } async function refreshModels(p: string, key: string, _baseUrl?: string) { if (!p || key.length < 8 || MANUAL_MODEL_PROVIDERS.has(p)) return const effectiveBaseUrl = _baseUrl ?? (NEEDS_BASE_URL.has(p) ? baseUrl : undefined) setLoadingModels(true) try { const list = await fetchModelsFromServer(p, key, effectiveBaseUrl) if (list.length > 0) { setModels(list); if (!model) setModel(list[0]) } } finally { setLoadingModels(false) } } async function verifyKey() { if (!provider || apiKey.length < 8) return if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error("Veuillez renseigner l'URL de l'API"); return } setVerifying(true) setTestResult(null) try { const q = new URLSearchParams({ provider, key: apiKey }) if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl) const res = await fetch(`/api/user/api-keys/verify?${q}`) const body = await res.json() if (res.ok && body.valid) { setKeyOk(true) if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) } toast.success('Clé API valide ✓') } else { setKeyOk(false) toast.error(body.message ?? 'Clé API invalide') } } catch { setKeyOk(false); toast.error('Erreur de vérification') } finally { setVerifying(false) } } async function testModel() { if (!provider || apiKey.length < 8 || !model) return setTesting(true) setTestResult(null) try { const q = new URLSearchParams({ provider, key: apiKey, model }) if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl) const res = await fetch(`/api/user/api-keys/test-model?${q}`) setTestResult(await res.json()) } catch { setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) } finally { setTesting(false) } } const saveMutation = useMutation({ mutationFn: async () => { const payload: Record = { provider, apiKey, model: model || undefined, alias: alias.trim() || undefined, } if (NEEDS_BASE_URL.has(provider)) payload.baseUrl = baseUrl const res = await fetch('/api/user/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) const body = await res.json().catch(() => ({})) if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec') }, onSuccess: () => { toast.success(`Clé ${displayName(provider)} enregistrée ✓`) setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('') setModel(''); setModels([]); setKeyOk(false); setTestResult(null) invalidate() }, onError: (err: Error) => toast.error(err.message), }) const toggleMutation = useMutation({ mutationFn: async ({ p, isActive }: { p: string; isActive: boolean }) => { const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isActive }), }) if (!res.ok) throw new Error('Erreur') }, onSuccess: invalidate, onError: () => toast.error(t('byokSettings.error')), }) const deleteMutation = useMutation({ mutationFn: async (p: string) => { const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' }) if (!res.ok) throw new Error('Erreur') }, onSuccess: () => { toast.success('Clé supprimée'); invalidate() }, onError: () => toast.error(t('byokSettings.error')), }) const allowed = data?.allowedProviders ?? [] const keys = data?.keys ?? [] const activeKey = keys.find((k) => k.isActive) const tierBlocked = !isLoading && allowed.length === 0 const saveDisabled = !provider || apiKey.length < 8 || saveMutation.isPending || (NEEDS_BASE_URL.has(provider) && baseUrl.length < 4) const canTest = provider && apiKey.length >= 8 && model.length > 0 const showModelDropdown = !MANUAL_MODEL_PROVIDERS.has(provider) && models.length > 0 const showModelInput = !!provider && (MANUAL_MODEL_PROVIDERS.has(provider) || (!loadingModels && models.length === 0)) if (isLoading) return
{t('byokSettings.loading')}
if (error) return

{t('byokSettings.loadError')}

return (
{/* Header */}

{t('byokSettings.title')}

{t('byokSettings.description')}

{/* BYOK Status */} {!tierBlocked && (
{activeKey ? ( <>BYOK actif · {displayName(activeKey.provider)}{activeKey.model && <> · {activeKey.model}}{activeKey.alias && <> · {activeKey.alias}} ) : ( <>Aucune clé active — utilisation des quotas Momento )}
)} {tierBlocked ? (

{t('byokSettings.tierRequired')}

) : ( <> {/* Saved keys */} {keys.length > 0 && (

Clés enregistrées

    {keys.map((key) => (
  • {displayName(key.provider)}
    {key.model && {key.model}} {key.alias && {key.alias}} {key.isActive ? '● Actif' : '○ Inactif'}
    {/* Edit button */} {/* Toggle */}
    {/* Inline edit form */} {editingProvider === key.provider && (
    setEditingProvider(null)} onInvalidate={invalidate} />
    )}
  • ))}
)} {/* Add key form */}

{keys.length > 0 ? 'Ajouter / remplacer une clé' : 'Connecter votre fournisseur IA'}

setAlias(e.target.value)} placeholder="ex. Ma clé pro" disabled={saveMutation.isPending} />
{NEEDS_BASE_URL.has(provider) && (
setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
)}
{ const val = e.target.value; setApiKey(val); setKeyOk(false); setTestResult(null); refreshModels(provider, val, baseUrl) }} placeholder="sk-..." disabled={saveMutation.isPending} className={cn(keyOk && 'border-emerald-500/60')} /> {keyOk && }
{provider && (
{loadingModels ? (
Récupération des modèles...
) : showModelDropdown ? ( ) : showModelInput ? ( { setModel(e.target.value); setTestResult(null) }} placeholder="ex. gpt-4o-mini, MiniMax-M2.7" disabled={saveMutation.isPending} /> ) : null}
)} {testResult && (
{testResult.ok ? : }
{testResult.ok ? ( <>

Modèle opérationnel ✓{testResult.latency && ({testResult.latency}ms)}

{testResult.reply &&

Réponse : {testResult.reply}

} ) :

{testResult.error ?? 'Échec du test'}

}
)}
{keys.length === 0 &&

{t('byokSettings.empty')}

} )}
) }