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>
206 lines
6.1 KiB
TypeScript
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
|
|
}
|
|
}
|