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 [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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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] ?? [];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user