Repository Public
1/**2 * `GET /models`: live listing per provider (when its API key is configured) merged over the3 * static catalogue, cached 10 min. A provider whose listing fails, times out or has no key is4 * answered from the catalogue with `available: false` and a `stale` note — never a guess.5 */6import type { FastifyInstance } from 'fastify';7import { z } from 'zod';8import { Provider } from '@aos/shared';9import { ModelInfoEx } from '../../dto.js';10import { authOf, type Ctx } from '../../http.js';1112const PROVIDERS = Provider.options;13/** A failed listing is retried sooner than a good one expires. */14const FAILURE_CACHE_MS = 60_000;1516export class ModelCatalogue {17 private cache: { at: number; ttl: number; models: ModelInfoEx[] } | null = null;18 private inflight: Promise<ModelInfoEx[]> | null = null;19 constructor(private readonly ctx: Ctx) {}2021 /** Catalogue only (no network): used to validate a mission's model id. */22 known(): ModelInfoEx[] {23 return this.ctx.providers?.catalogue() ?? [];24 }2526 async list(): Promise<ModelInfoEx[]> {27 const now = this.ctx.now();28 if (this.cache && now - this.cache.at < this.cache.ttl) return this.cache.models;29 this.inflight ??= this.load().finally(() => (this.inflight = null));30 return this.inflight;31 }3233 private async load(): Promise<ModelInfoEx[]> {34 const { providers, config, log } = this.ctx;35 if (!providers) return [];36 const catalogue = providers.catalogue();37 let failed = false;38 const parts = await Promise.all(39 PROVIDERS.map(async (p): Promise<ModelInfoEx[]> => {40 const fallback = (note: string): ModelInfoEx[] => catalogue.filter((m) => m.provider === p).map((m) => ({ ...m, available: false, stale: true, note }));41 if (!config.providerKeys[p]) return fallback('API key not configured');42 let timer: NodeJS.Timeout | undefined;43 try {44 const live = await Promise.race([45 providers.listModels(p),46 new Promise<never>((_, reject) => {47 timer = setTimeout(() => reject(new Error('timeout')), config.modelsTimeoutMs);48 }),49 ]);50 return live.filter((m) => m.provider === p);51 } catch (err) {52 failed = true;53 // The message of a provider error may quote request details: log the class only.54 log.warn({ provider: p, error: (err as Error).name }, 'model listing failed, serving the static catalogue');55 return fallback('live model listing unavailable');56 } finally {57 clearTimeout(timer);58 }59 }),60 );61 const models = parts.flat();62 this.cache = { at: this.ctx.now(), ttl: failed ? Math.min(FAILURE_CACHE_MS, config.modelsCacheMs) : config.modelsCacheMs, models };63 return models;64 }65}6667export default async function modelsPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {68 const catalogue = new ModelCatalogue(ctx);69 app.get('/models', { config: { doc: { summary: 'Models of every provider with prices and availability (cached 10 min)', response: z.array(ModelInfoEx), responseName: 'ModelInfoList' } } }, async (req) => {70 authOf(req);71 return catalogue.list();72 });73}74