feat(byok): fetch live models dynamically from provider api with user api key on input
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled

This commit is contained in:
Antigravity
2026-05-28 21:49:32 +00:00
parent 6703e75bf3
commit 7cc2a9ea3b
3 changed files with 177 additions and 11 deletions

View 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 });
}

View File

@@ -53,6 +53,10 @@ export function ByokSettingsPanel() {
const [customModel, setCustomModel] = useState('') const [customModel, setCustomModel] = useState('')
const [isCustomModel, setIsCustomModel] = useState(false) 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({ const { data, isLoading, error } = useQuery({
queryKey: ['user', 'api-keys'], queryKey: ['user', 'api-keys'],
queryFn: fetchByokKeys, queryFn: fetchByokKeys,
@@ -62,13 +66,46 @@ export function ByokSettingsPanel() {
const handleProviderChange = (p: string) => { const handleProviderChange = (p: string) => {
setProvider(p) setProvider(p)
const sug = providerModels[p] || [] setApiKey('')
if (sug.length > 0) { setLiveModels([])
setModel(sug[0])
setIsCustomModel(false) setIsCustomModel(false)
} else {
setModel('') setModel('')
setCustomModel('') 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 {
setIsCustomModel(true) setIsCustomModel(true)
} }
} }
@@ -104,6 +141,7 @@ export function ByokSettingsPanel() {
setProvider('') setProvider('')
setModel('') setModel('')
setCustomModel('') setCustomModel('')
setLiveModels([])
setIsCustomModel(false) setIsCustomModel(false)
invalidate() invalidate()
}, },
@@ -230,8 +268,15 @@ export function ByokSettingsPanel() {
{provider && ( {provider && (
<div className="grid gap-4 sm:grid-cols-2 pt-2 border-t border-border/40"> <div className="grid gap-4 sm:grid-cols-2 pt-2 border-t border-border/40">
<div className="space-y-2"> <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> <Label htmlFor="byok-model-select" className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{providerModels[provider] && providerModels[provider].length > 0 ? ( 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 <Select
value={isCustomModel ? 'custom' : model} value={isCustomModel ? 'custom' : model}
onValueChange={(val) => { onValueChange={(val) => {
@@ -248,7 +293,7 @@ export function ByokSettingsPanel() {
<SelectValue placeholder="Choisir un modèle..." /> <SelectValue placeholder="Choisir un modèle..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{providerModels[provider].map((m) => ( {liveModels.map((m) => (
<SelectItem key={m} value={m}> <SelectItem key={m} value={m}>
{m} {m}
</SelectItem> </SelectItem>
@@ -258,12 +303,12 @@ export function ByokSettingsPanel() {
</Select> </Select>
) : ( ) : (
<div className="text-xs text-concrete py-2 italic"> <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>
)} )}
</div> </div>
{(isCustomModel || !(providerModels[provider] && providerModels[provider].length > 0)) && ( {(isCustomModel || (!isFetchingLiveModels && !(liveModels && liveModels.length > 0))) && (
<div className="space-y-2"> <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> <Label htmlFor="byok-model-custom" className="text-[10px] font-bold uppercase tracking-widest text-concrete">Saisir le nom du modèle</Label>
<Input <Input
@@ -290,7 +335,11 @@ export function ByokSettingsPanel() {
type="password" type="password"
autoComplete="off" autoComplete="off"
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => {
const val = e.target.value
setApiKey(val)
fetchLiveModels(provider, val)
}}
placeholder={t('byokSettings.apiKeyPlaceholder')} placeholder={t('byokSettings.apiKeyPlaceholder')}
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
/> />

View File

@@ -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[]> = { export const PROVIDER_MODEL_SUGGESTIONS: Record<string, string[]> = {
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'], 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'], 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'], openrouter: ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-chat'],
custom: [], 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] ?? [];
}