import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list' import { redis } from '@/lib/redis' const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models' const REDIS_KEY = 'memento:public-byok-models:v2' const REDIS_TTL_SEC = 12 * 60 * 60 const MEMORY_TTL_MS = 30 * 60 * 1000 const DISPLAY_PER_PROVIDER = 3 type OpenRouterModel = { id?: string created?: number architecture?: { modality?: string output_modalities?: string[] } } const VENDOR_TO_PROVIDER: Record = { openai: 'openai', anthropic: 'anthropic', google: 'google', deepseek: 'deepseek', minimax: 'minimax', mistralai: 'mistral', 'z-ai': 'glm', } let memoryCache: { at: number; models: Record } | null = null function slugOf(id: string): string { return (id.includes('/') ? id.split('/')[1] : id).replace(/:.*$/, '') } function isNoisyId(id: string): boolean { const slug = slugOf(id) if (/:(batch|free|nitro)/i.test(id)) return true if (/embed/i.test(slug)) return true if (/-fast$/i.test(slug)) return true if (/-vision|-image|-audio|-preview|-exp|codex|oss|safeguard/i.test(slug)) return true if (/gemma|lyria|voxtral|ministral|devstral|codestral/i.test(slug)) return true if (/chat-latest/i.test(slug)) return true if (/-\d{4,8}$/.test(slug)) return true return false } function cleanliness(id: string): number { const slug = slugOf(id) let score = 0 if (/-pro$/i.test(slug)) score += 20 if (/-lite$/i.test(slug)) score += 5 return score } function isChatModel(model: OpenRouterModel): boolean { const id = model.id ?? '' if (!id || isNoisyId(id)) return false const outputs = model.architecture?.output_modalities if (outputs && !outputs.includes('text')) return false return true } function familyKey(id: string): string { return slugOf(id) .replace(/-pro$/i, '') .replace(/-latest$/i, '') } function nativeName(openRouterId: string): string { const slug = openRouterId.includes('/') ? openRouterId.split('/')[1] : openRouterId if (/^minimax-/i.test(slug)) { return slug.replace(/^minimax-/i, 'MiniMax-').replace(/-m(\d)/i, '-M$1') } return slug } function pickLatestIds(models: OpenRouterModel[], keepPrefix: boolean): string[] { const byFamily = new Map() const order: string[] = [] const sorted = [...models].sort((a, b) => (b.created ?? 0) - (a.created ?? 0)) for (const model of sorted) { const id = model.id if (!id) continue const key = familyKey(id) if (!byFamily.has(key)) { byFamily.set(key, []) order.push(key) } byFamily.get(key)!.push(model) } const picked: string[] = [] for (const key of order) { const best = [...(byFamily.get(key) ?? [])].sort( (a, b) => cleanliness(a.id ?? '') - cleanliness(b.id ?? ''), )[0] const id = best?.id if (!id) continue picked.push(keepPrefix ? id.replace(/:.*$/, '') : nativeName(id)) if (picked.length >= DISPLAY_PER_PROVIDER) break } return picked } function buildFromOpenRouter(models: OpenRouterModel[]): Record { const byVendor = new Map() for (const model of models) { if (!isChatModel(model) || !model.id?.includes('/')) continue const vendor = model.id.split('/')[0] const list = byVendor.get(vendor) ?? [] list.push(model) byVendor.set(vendor, list) } const result: Record = {} for (const [vendor, provider] of Object.entries(VENDOR_TO_PROVIDER)) { result[provider] = pickLatestIds(byVendor.get(vendor) ?? [], false) } const showcaseVendors = ['openai', 'anthropic', 'google'] result.openrouter = showcaseVendors .map((vendor) => { const latest = pickLatestIds(byVendor.get(vendor) ?? [], true)[0] return latest }) .filter(Boolean) result.zai = [ result.openai?.[0], result.anthropic?.[0], result.google?.[0], ].filter(Boolean) as string[] return result } async function fetchOpenRouterModels(): Promise { const response = await fetch(OPENROUTER_MODELS_URL, { headers: { Accept: 'application/json', 'HTTP-Referer': process.env.NEXTAUTH_URL || 'https://memento-note.com', 'X-Title': 'Memento', }, signal: AbortSignal.timeout(8000), cache: 'no-store', }) if (!response.ok) { throw new Error(`catalog ${response.status}`) } const data = (await response.json()) as { data?: OpenRouterModel[] } return Array.isArray(data.data) ? data.data : [] } function mergeWithFallback(live: Record): Record { const merged: Record = { ...PROVIDER_MODEL_SUGGESTIONS } for (const [id, models] of Object.entries(live)) { if (models.length > 0) merged[id] = models } return merged } async function readRedis(): Promise | null> { try { const raw = await redis.get(REDIS_KEY) if (!raw) return null const parsed = JSON.parse(raw) as Record return parsed && typeof parsed === 'object' ? parsed : null } catch { return null } } async function writeRedis(models: Record): Promise { try { await redis.set(REDIS_KEY, JSON.stringify(models), 'EX', REDIS_TTL_SEC) } catch { /* le cache mémoire suffit */ } } /** Noms de modèles à jour, lus périodiquement — jamais une liste figée seule. */ export async function getLivePublicModels(): Promise> { if (memoryCache && Date.now() - memoryCache.at < MEMORY_TTL_MS) { return memoryCache.models } const cached = await readRedis() if (cached) { memoryCache = { at: Date.now(), models: cached } return cached } try { const remote = await fetchOpenRouterModels() const merged = mergeWithFallback(buildFromOpenRouter(remote)) memoryCache = { at: Date.now(), models: merged } await writeRedis(merged) return merged } catch (error) { console.warn('[live-public-catalog] lecture distante impossible, liste de secours', error) const fallback = { ...PROVIDER_MODEL_SUGGESTIONS } memoryCache = { at: Date.now(), models: fallback } return fallback } }