Files
Sybil-2/server/src/llm/model-catalog.ts

142 lines
4.8 KiB
TypeScript
Raw Normal View History

2026-02-14 21:00:30 -08:00
import type { FastifyBaseLogger } from "fastify";
2026-05-04 21:52:39 -07:00
import { env } from "../env.js";
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
2026-02-14 21:00:30 -08:00
import type { Provider } from "./types.js";
export type ProviderModelSnapshot = {
models: string[];
loadedAt: string | null;
error: string | null;
};
2026-05-04 21:52:39 -07:00
export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
2026-02-14 21:00:30 -08:00
2026-05-04 21:52:39 -07:00
const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
2026-02-14 21:00:30 -08:00
const MODEL_FETCH_TIMEOUT_MS = 15000;
2026-05-20 22:08:45 -07:00
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
2026-02-14 21:00:30 -08:00
const modelCatalog: ModelCatalogSnapshot = {
openai: { models: [], loadedAt: null, error: null },
anthropic: { models: [], loadedAt: null, error: null },
xai: { models: [], loadedAt: null, error: null },
};
2026-05-20 22:08:45 -07:00
let catalogRefreshPromise: Promise<void> | null = null;
2026-05-04 21:52:39 -07:00
function getCatalogProviders(): Provider[] {
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
}
2026-02-14 21:00:30 -08:00
function uniqSorted(models: string[]) {
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
}
2026-05-02 21:44:32 -07:00
function isLikelyOpenAIResponsesModel(model: string) {
2026-05-02 21:19:52 -07:00
const id = model.toLowerCase();
if (id.includes("embedding") || id.includes("moderation")) return false;
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
if (id.includes("search") || id.includes("computer-use")) return false;
return /^(gpt-|o\d|chatgpt-)/.test(id);
}
2026-02-14 21:00:30 -08:00
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string) {
let timeoutId: NodeJS.Timeout | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
}),
]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
async function fetchProviderModels(provider: Provider) {
if (provider === "openai") {
const page = await openaiClient().models.list();
2026-05-02 21:44:32 -07:00
return uniqSorted(page.data.map((model) => model.id).filter(isLikelyOpenAIResponsesModel));
2026-02-14 21:00:30 -08:00
}
if (provider === "anthropic") {
const page = await anthropicClient().models.list({ limit: 200 });
return uniqSorted(page.data.map((model) => model.id));
}
2026-05-04 21:52:39 -07:00
if (provider === "xai") {
const page = await xaiClient().models.list();
return uniqSorted(page.data.map((model) => model.id));
}
const page = await hermesAgentClient().models.list();
const models = page.data.map((model) => model.id);
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
return uniqSorted(models);
2026-02-14 21:00:30 -08:00
}
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
try {
const models = await withTimeout(fetchProviderModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
modelCatalog[provider] = {
models,
loadedAt: new Date().toISOString(),
error: null,
};
logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
} catch (err: any) {
const message = err?.message ?? String(err);
2026-05-20 22:08:45 -07:00
const previous = modelCatalog[provider];
const fallbackModels = provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
2026-02-14 21:00:30 -08:00
modelCatalog[provider] = {
2026-05-20 22:08:45 -07:00
models: previous?.models.length ? previous.models : fallbackModels,
loadedAt: previous?.loadedAt ?? null,
2026-02-14 21:00:30 -08:00
error: message,
};
logger?.warn({ provider, err: message }, "failed to load provider model catalog");
}
}
2026-05-20 22:08:45 -07:00
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
if (catalogRefreshPromise) return catalogRefreshPromise;
catalogRefreshPromise = Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
.then(() => undefined)
.finally(() => {
catalogRefreshPromise = null;
});
return catalogRefreshPromise;
}
2026-02-14 21:00:30 -08:00
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
2026-05-20 22:08:45 -07:00
await refreshModelCatalog(logger);
}
export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
const timer = setInterval(() => {
void refreshModelCatalog(logger);
}, MODEL_CATALOG_REFRESH_INTERVAL_MS);
timer.unref?.();
return () => {
clearInterval(timer);
};
2026-02-14 21:00:30 -08:00
}
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
2026-05-04 21:52:39 -07:00
const snapshot: ModelCatalogSnapshot = {};
for (const provider of getCatalogProviders()) {
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
snapshot[provider] = {
models: [...entry.models],
loadedAt: entry.loadedAt,
error: entry.error,
};
}
return snapshot;
2026-02-14 21:00:30 -08:00
}