diff --git a/memento-note/app/api/user/api-keys/live-models/route.ts b/memento-note/app/api/user/api-keys/live-models/route.ts new file mode 100644 index 0000000..3ba8de8 --- /dev/null +++ b/memento-note/app/api/user/api-keys/live-models/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { getEffectiveTier } from '@/lib/entitlements'; +import { getAllowedByokProviders, isByokProviderAllowed } from '@/lib/byok'; +import { fetchLiveModelsForProvider } from '@/lib/ai/models-list'; +import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router'; + +/** + * GET /api/user/api-keys/live-models?provider=&key=&baseUrl= + * + * Dynamically queries the third-party provider's API with the user's key to fetch + * actual available models dynamically. + */ +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const tier = await getEffectiveTier(session.user.id); + if (tier === 'BASIC') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { searchParams } = request.nextUrl; + const provider = searchParams.get('provider') as AiGatewayProvider; + const apiKey = searchParams.get('key'); + const baseUrl = searchParams.get('baseUrl') ?? undefined; + + if (!provider || !apiKey) { + return NextResponse.json({ error: 'Missing parameters' }, { status: 400 }); + } + + if (!VALID_PROVIDERS.has(provider)) { + return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); + } + + if (!isByokProviderAllowed(tier, provider)) { + return NextResponse.json({ error: 'Tier restricted' }, { status: 403 }); + } + + const models = await fetchLiveModelsForProvider(provider, apiKey, baseUrl); + + return NextResponse.json({ success: true, models }); +} diff --git a/memento-note/components/ai/byok-settings-panel.tsx b/memento-note/components/ai/byok-settings-panel.tsx index d073f2c..1c60114 100644 --- a/memento-note/components/ai/byok-settings-panel.tsx +++ b/memento-note/components/ai/byok-settings-panel.tsx @@ -53,6 +53,10 @@ export function ByokSettingsPanel() { const [customModel, setCustomModel] = useState('') const [isCustomModel, setIsCustomModel] = useState(false) + // Dynamic models fetched directly via user's API Key + const [liveModels, setLiveModels] = useState([]) + const [isFetchingLiveModels, setIsFetchingLiveModels] = useState(false) + const { data, isLoading, error } = useQuery({ queryKey: ['user', 'api-keys'], queryFn: fetchByokKeys, @@ -62,13 +66,46 @@ export function ByokSettingsPanel() { const handleProviderChange = (p: string) => { setProvider(p) - const sug = providerModels[p] || [] - if (sug.length > 0) { - setModel(sug[0]) + setApiKey('') + setLiveModels([]) + setIsCustomModel(false) + setModel('') + setCustomModel('') + } + + // Triggered dynamically to fetch models when user enters/pastes their API key + const fetchLiveModels = async (p: string, key: string) => { + if (!p || !key || key.length < 8) return; + setIsFetchingLiveModels(true) + try { + const query = new URLSearchParams({ provider: p, key }) + const res = await fetch(`/api/user/api-keys/live-models?${query.toString()}`) + if (res.ok) { + const body = await res.json() + if (body.success && Array.isArray(body.models)) { + setLiveModels(body.models) + if (body.models.length > 0) { + setModel(body.models[0]) + setIsCustomModel(false) + } else { + setIsCustomModel(true) + } + return; + } + } + } catch (err) { + console.error('[fetchLiveModels] Failed:', err) + } finally { + setIsFetchingLiveModels(false) + } + + // Fallback if request fails + const fallbackList = providerModels[p] || [] + setLiveModels(fallbackList) + if (fallbackList.length > 0) { + setModel(fallbackList[0]) setIsCustomModel(false) } else { - setModel('') - setCustomModel('') setIsCustomModel(true) } } @@ -104,6 +141,7 @@ export function ByokSettingsPanel() { setProvider('') setModel('') setCustomModel('') + setLiveModels([]) setIsCustomModel(false) invalidate() }, @@ -230,8 +268,15 @@ export function ByokSettingsPanel() { {provider && (
- - {providerModels[provider] && providerModels[provider].length > 0 ? ( + + {isFetchingLiveModels ? ( +
+ + Récupération de vos modèles disponibles... +
+ ) : liveModels && liveModels.length > 0 ? ( ) : (
- Spécifiez le modèle ci-contre si besoin. + Entrez votre clé API ci-dessous pour charger vos modèles.
)}
- {(isCustomModel || !(providerModels[provider] && providerModels[provider].length > 0)) && ( + {(isCustomModel || (!isFetchingLiveModels && !(liveModels && liveModels.length > 0))) && (
setApiKey(e.target.value)} + onChange={(e) => { + const val = e.target.value + setApiKey(val) + fetchLiveModels(provider, val) + }} placeholder={t('byokSettings.apiKeyPlaceholder')} disabled={saveMutation.isPending} /> diff --git a/memento-note/lib/ai/models-list.ts b/memento-note/lib/ai/models-list.ts index 83c7272..714e64d 100644 --- a/memento-note/lib/ai/models-list.ts +++ b/memento-note/lib/ai/models-list.ts @@ -1,3 +1,17 @@ +import { type AiGatewayProvider } from '@/lib/ai/router'; + +// Base URLs mapping for providers +const PROVIDER_URLS: Record = { + openai: 'https://api.openai.com/v1', + deepseek: 'https://api.deepseek.com/v1', + openrouter: 'https://openrouter.ai/api/v1', + mistral: 'https://api.mistral.ai/v1', + zai: 'https://api.zukijourney.com/v1', + minimax: 'https://api.minimax.io/v1', + glm: 'https://open.bigmodel.ai/api/paas/v4', +}; + +// Fallback popular models when live fetching fails or for providers without /models endpoint (e.g. Anthropic, Google) export const PROVIDER_MODEL_SUGGESTIONS: Record = { openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'], anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'], @@ -9,3 +23,61 @@ export const PROVIDER_MODEL_SUGGESTIONS: Record = { openrouter: ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-chat'], custom: [], }; + +/** + * Dynamically queries the provider's /models endpoint using the user's API Key + * to fetch their actual available models list instead of relying on hardcoded choices. + */ +export async function fetchLiveModelsForProvider( + provider: AiGatewayProvider, + apiKey: string, + customBaseUrl?: string +): Promise { + try { + // Anthropic and Google do not expose a public list via a simple key GET /models (or need specific formats) + // We fall back to the popular defaults for those. + if (provider === 'anthropic' || provider === 'anthropic_custom' || provider === 'google') { + const standardProvider = provider === 'anthropic_custom' ? 'anthropic' : provider; + return PROVIDER_MODEL_SUGGESTIONS[standardProvider] ?? []; + } + + const baseUrl = provider === 'custom' + ? customBaseUrl?.replace(/\/$/, '') + : PROVIDER_URLS[provider]; + + if (!baseUrl) { + return PROVIDER_MODEL_SUGGESTIONS[provider] ?? []; + } + + const headers: Record = { 'Content-Type': 'application/json' }; + headers['Authorization'] = `Bearer ${apiKey}`; + + if (provider === 'openrouter') { + headers['HTTP-Referer'] = 'https://localhost:3000'; + headers['X-Title'] = 'Memento AI'; + } + + const response = await fetch(`${baseUrl}/models`, { + headers, + signal: AbortSignal.timeout(6000), + }); + + if (!response.ok) { + return PROVIDER_MODEL_SUGGESTIONS[provider] ?? []; + } + + const data = await response.json(); + const fetched: string[] = (data.data ?? []) + .map((m: any) => m.id || m.name) + .filter(Boolean) + .sort(); + + if (fetched.length > 0) { + return fetched; + } + } catch (err) { + console.warn(`[fetchLiveModelsForProvider] Failed to fetch live models for ${provider}, using fallbacks:`, err); + } + + return PROVIDER_MODEL_SUGGESTIONS[provider] ?? []; +}