feat(byok): fetch live models dynamically from provider api with user api key on input
This commit is contained in:
45
memento-note/app/api/user/api-keys/live-models/route.ts
Normal file
45
memento-note/app/api/user/api-keys/live-models/route.ts
Normal file
@@ -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=<provider>&key=<api_key>&baseUrl=<optional_custom_url>
|
||||
*
|
||||
* 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 });
|
||||
}
|
||||
@@ -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<string[]>([])
|
||||
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 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 pt-2 border-t border-border/40">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-model-select" className="text-[10px] font-bold uppercase tracking-widest text-concrete">Modèle de l'IA (Optionnel)</Label>
|
||||
{providerModels[provider] && providerModels[provider].length > 0 ? (
|
||||
<Label htmlFor="byok-model-select" className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
Modèle de l'IA (Optionnel)
|
||||
</Label>
|
||||
{isFetchingLiveModels ? (
|
||||
<div className="flex items-center gap-2 text-xs text-concrete py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-brand-accent" />
|
||||
Récupération de vos modèles disponibles...
|
||||
</div>
|
||||
) : liveModels && liveModels.length > 0 ? (
|
||||
<Select
|
||||
value={isCustomModel ? 'custom' : model}
|
||||
onValueChange={(val) => {
|
||||
@@ -248,7 +293,7 @@ export function ByokSettingsPanel() {
|
||||
<SelectValue placeholder="Choisir un modèle..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerModels[provider].map((m) => (
|
||||
{liveModels.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
@@ -258,12 +303,12 @@ export function ByokSettingsPanel() {
|
||||
</Select>
|
||||
) : (
|
||||
<div className="text-xs text-concrete py-2 italic">
|
||||
Spécifiez le modèle ci-contre si besoin.
|
||||
Entrez votre clé API ci-dessous pour charger vos modèles.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isCustomModel || !(providerModels[provider] && providerModels[provider].length > 0)) && (
|
||||
{(isCustomModel || (!isFetchingLiveModels && !(liveModels && liveModels.length > 0))) && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-model-custom" className="text-[10px] font-bold uppercase tracking-widest text-concrete">Saisir le nom du modèle</Label>
|
||||
<Input
|
||||
@@ -290,7 +335,11 @@ export function ByokSettingsPanel() {
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setApiKey(val)
|
||||
fetchLiveModels(provider, val)
|
||||
}}
|
||||
placeholder={t('byokSettings.apiKeyPlaceholder')}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
import { type AiGatewayProvider } from '@/lib/ai/router';
|
||||
|
||||
// Base URLs mapping for providers
|
||||
const PROVIDER_URLS: Record<string, string> = {
|
||||
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<string, string[]> = {
|
||||
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<string, string[]> = {
|
||||
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<string[]> {
|
||||
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<string, string> = { '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] ?? [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user