Files
Momento/memento-note/lib/ai/live-public-catalog.ts
Antigravity afbb0dfc2d
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m57s
CI / Deploy production (on server) (push) Successful in 24s
fix(ui): thème, textes et lisibilité restants de la revue
Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:13:07 +00:00

206 lines
6.1 KiB
TypeScript

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<string, string> = {
openai: 'openai',
anthropic: 'anthropic',
google: 'google',
deepseek: 'deepseek',
minimax: 'minimax',
mistralai: 'mistral',
'z-ai': 'glm',
}
let memoryCache: { at: number; models: Record<string, string[]> } | 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<string, OpenRouterModel[]>()
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<string, string[]> {
const byVendor = new Map<string, OpenRouterModel[]>()
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<string, string[]> = {}
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<OpenRouterModel[]> {
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<string, string[]>): Record<string, string[]> {
const merged: Record<string, string[]> = { ...PROVIDER_MODEL_SUGGESTIONS }
for (const [id, models] of Object.entries(live)) {
if (models.length > 0) merged[id] = models
}
return merged
}
async function readRedis(): Promise<Record<string, string[]> | null> {
try {
const raw = await redis.get(REDIS_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Record<string, string[]>
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
async function writeRedis(models: Record<string, string[]>): Promise<void> {
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<Record<string, string[]>> {
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
}
}