model catalog

This commit is contained in:
2026-02-14 21:00:30 -08:00
parent 9bfd0ec1e7
commit cd449a30fa
5 changed files with 327 additions and 13 deletions

View File

@@ -5,6 +5,7 @@ import swaggerUI from "@fastify/swagger-ui";
import sensible from "@fastify/sensible"; import sensible from "@fastify/sensible";
import { env } from "./env.js"; import { env } from "./env.js";
import { ensureDatabaseReady } from "./db-init.js"; import { ensureDatabaseReady } from "./db-init.js";
import { warmModelCatalog } from "./llm/model-catalog.js";
import { registerRoutes } from "./routes.js"; import { registerRoutes } from "./routes.js";
const app = Fastify({ const app = Fastify({
@@ -18,6 +19,7 @@ const app = Fastify({
}); });
await ensureDatabaseReady(app.log); await ensureDatabaseReady(app.log);
await warmModelCatalog(app.log);
await app.register(cors, { await app.register(cors, {
origin: true, origin: true,

View File

@@ -0,0 +1,99 @@
import type { FastifyBaseLogger } from "fastify";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
import type { Provider } from "./types.js";
export type ProviderModelSnapshot = {
models: string[];
loadedAt: string | null;
error: string | null;
};
export type ModelCatalogSnapshot = Record<Provider, ProviderModelSnapshot>;
const providers: Provider[] = ["openai", "anthropic", "xai"];
const MODEL_FETCH_TIMEOUT_MS = 15000;
const modelCatalog: ModelCatalogSnapshot = {
openai: { models: [], loadedAt: null, error: null },
anthropic: { models: [], loadedAt: null, error: null },
xai: { models: [], loadedAt: null, error: null },
};
function uniqSorted(models: string[]) {
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
}
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();
return uniqSorted(page.data.map((model) => model.id));
}
if (provider === "anthropic") {
const page = await anthropicClient().models.list({ limit: 200 });
return uniqSorted(page.data.map((model) => model.id));
}
const page = await xaiClient().models.list();
return uniqSorted(page.data.map((model) => model.id));
}
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);
modelCatalog[provider] = {
models: [],
loadedAt: new Date().toISOString(),
error: message,
};
logger?.warn({ provider, err: message }, "failed to load provider model catalog");
}
}
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
await Promise.all(providers.map((provider) => refreshProviderModels(provider, logger)));
}
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
return {
openai: {
models: [...modelCatalog.openai.models],
loadedAt: modelCatalog.openai.loadedAt,
error: modelCatalog.openai.error,
},
anthropic: {
models: [...modelCatalog.anthropic.models],
loadedAt: modelCatalog.anthropic.loadedAt,
error: modelCatalog.anthropic.error,
},
xai: {
models: [...modelCatalog.xai.models],
loadedAt: modelCatalog.xai.loadedAt,
error: modelCatalog.xai.error,
},
};
}

View File

@@ -6,6 +6,7 @@ import { requireAdmin } from "./auth.js";
import { env } from "./env.js"; import { env } from "./env.js";
import { runMultiplex } from "./llm/multiplexer.js"; import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream } from "./llm/streaming.js"; import { runMultiplexStream } from "./llm/streaming.js";
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
import { exaClient } from "./search/exa.js"; import { exaClient } from "./search/exa.js";
type IncomingChatMessage = { type IncomingChatMessage = {
@@ -135,6 +136,11 @@ export async function registerRoutes(app: FastifyInstance) {
return { authenticated: true, mode: env.ADMIN_TOKEN ? "token" : "open" }; return { authenticated: true, mode: env.ADMIN_TOKEN ? "token" : "open" };
}); });
app.get("/v1/models", async (req) => {
requireAdmin(req);
return { providers: getModelCatalogSnapshot() };
});
app.get("/v1/chats", async (req) => { app.get("/v1/chats", async (req) => {
requireAdmin(req); requireAdmin(req);
const chats = await prisma.chat.findMany({ const chats = await prisma.chat.findMany({

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact"; import { Check, ChevronDown, Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { AuthScreen } from "@/components/auth/auth-screen"; import { AuthScreen } from "@/components/auth/auth-screen";
@@ -13,11 +12,14 @@ import {
deleteChat, deleteChat,
deleteSearch, deleteSearch,
getChat, getChat,
listModels,
getSearch, getSearch,
listChats, listChats,
listSearches, listSearches,
runCompletion, runCompletion,
runSearchStream, runSearchStream,
type ModelCatalogResponse,
type Provider,
type ChatDetail, type ChatDetail,
type ChatSummary, type ChatSummary,
type CompletionRequestMessage, type CompletionRequestMessage,
@@ -28,7 +30,6 @@ import {
import { useSessionAuth } from "@/hooks/use-session-auth"; import { useSessionAuth } from "@/hooks/use-session-auth";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Provider = "openai" | "anthropic" | "xai";
type SidebarSelection = { kind: "chat" | "search"; id: string }; type SidebarSelection = { kind: "chat" | "search"; id: string };
type DraftSelectionKind = "chat" | "search"; type DraftSelectionKind = "chat" | "search";
type SidebarItem = SidebarSelection & { type SidebarItem = SidebarSelection & {
@@ -42,12 +43,153 @@ type ContextMenuState = {
y: number; y: number;
}; };
const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = { const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: "gpt-4.1-mini", openai: ["gpt-4.1-mini"],
anthropic: "claude-3-5-sonnet-latest", anthropic: ["claude-3-5-sonnet-latest"],
xai: "grok-3-mini", xai: ["grok-3-mini"],
}; };
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
openai: { models: [], loadedAt: null, error: null },
anthropic: { models: [], loadedAt: null, error: null },
xai: { models: [], loadedAt: null, error: null },
};
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
type ProviderModelPreferences = Record<Provider, string | null>;
const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
openai: null,
anthropic: null,
xai: null,
};
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
const providerModels = catalog[provider]?.models ?? [];
if (providerModels.length) return providerModels;
return PROVIDER_FALLBACK_MODELS[provider];
}
function loadStoredModelPreferences() {
if (typeof window === "undefined") return EMPTY_MODEL_PREFERENCES;
try {
const raw = window.localStorage.getItem(MODEL_PREFERENCES_STORAGE_KEY);
if (!raw) return EMPTY_MODEL_PREFERENCES;
const parsed = JSON.parse(raw) as Partial<Record<Provider, string>>;
return {
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
};
} catch {
return EMPTY_MODEL_PREFERENCES;
}
}
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
if (preferred && options.includes(preferred)) return preferred;
if (fallback && options.includes(fallback)) return fallback;
return options[0] ?? "";
}
type ModelComboboxProps = {
options: string[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
};
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filteredOptions = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return options;
return options.filter((option) => option.toLowerCase().includes(needle));
}, [options, query]);
useEffect(() => {
if (!open) return;
inputRef.current?.focus();
}, [open]);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (rootRef.current?.contains(event.target as Node)) return;
setOpen(false);
setQuery("");
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setOpen(false);
setQuery("");
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div className="relative" ref={rootRef}>
<button
type="button"
className="flex h-9 min-w-56 items-center justify-between rounded-md border border-input bg-background px-2 text-sm"
onClick={() => {
if (disabled) return;
setOpen((current) => !current);
}}
disabled={disabled}
>
<span className="truncate text-left">{value || "Select model"}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
</button>
{open ? (
<div className="absolute right-0 z-50 mt-1 w-full rounded-md border border-border bg-background p-1 shadow-md">
<input
ref={inputRef}
value={query}
onInput={(event) => setQuery(event.currentTarget.value)}
className="mb-1 h-8 w-full rounded-sm border border-input bg-background px-2 text-sm outline-none"
placeholder="Filter models"
/>
<div className="max-h-64 overflow-y-auto">
{filteredOptions.length ? (
filteredOptions.map((option) => (
<button
key={option}
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
onClick={() => {
onChange(option);
setOpen(false);
setQuery("");
}}
>
<Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} />
<span className="truncate">{option}</span>
</button>
))
) : (
<p className="px-2 py-2 text-sm text-muted-foreground">No models found</p>
)}
</div>
</div>
) : null}
</div>
);
}
function getChatTitle(chat: Pick<ChatSummary, "title">, messages?: ChatDetail["messages"]) { function getChatTitle(chat: Pick<ChatSummary, "title">, messages?: ChatDetail["messages"]) {
if (chat.title?.trim()) return chat.title.trim(); if (chat.title?.trim()) return chat.title.trim();
const firstUserMessage = messages?.find((m) => m.role === "user")?.content.trim(); const firstUserMessage = messages?.find((m) => m.role === "user")?.content.trim();
@@ -117,7 +259,12 @@ export default function App() {
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null); const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
const [composer, setComposer] = useState(""); const [composer, setComposer] = useState("");
const [provider, setProvider] = useState<Provider>("openai"); const [provider, setProvider] = useState<Provider>("openai");
const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.openai); const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
const [model, setModel] = useState(() => {
const stored = loadStoredModelPreferences();
return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0];
});
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null); const transcriptEndRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null); const contextMenuRef = useRef<HTMLDivElement>(null);
@@ -179,6 +326,20 @@ export default function App() {
} }
}; };
const refreshModels = async () => {
try {
const data = await listModels();
setModelCatalog(data.providers);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
}
};
const refreshChat = async (chatId: string) => { const refreshChat = async (chatId: string) => {
setIsLoadingSelection(true); setIsLoadingSelection(true);
try { try {
@@ -217,9 +378,22 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
void refreshCollections(); void Promise.all([refreshCollections(), refreshModels()]);
}, [isAuthenticated]); }, [isAuthenticated]);
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
useEffect(() => {
setModel((current) => {
return pickProviderModel(providerModelOptions, providerModelPreferences[provider], current);
});
}, [provider, providerModelOptions, providerModelPreferences]);
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences));
}, [providerModelPreferences]);
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null; const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
useEffect(() => { useEffect(() => {
@@ -419,10 +593,15 @@ export default function App() {
}, },
]; ];
const selectedModel = model.trim();
if (!selectedModel) {
throw new Error("No model available for selected provider");
}
await runCompletion({ await runCompletion({
chatId, chatId,
provider, provider,
model: model.trim(), model: selectedModel,
messages: requestMessages, messages: requestMessages,
}); });
@@ -690,7 +869,8 @@ export default function App() {
onChange={(event) => { onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider; const nextProvider = event.currentTarget.value as Provider;
setProvider(nextProvider); setProvider(nextProvider);
setModel(PROVIDER_DEFAULT_MODELS[nextProvider]); const options = getModelOptions(modelCatalog, nextProvider);
setModel((current) => pickProviderModel(options, providerModelPreferences[nextProvider], current));
}} }}
disabled={isSending} disabled={isSending}
> >
@@ -698,7 +878,18 @@ export default function App() {
<option value="anthropic">Anthropic</option> <option value="anthropic">Anthropic</option>
<option value="xai">xAI</option> <option value="xai">xAI</option>
</select> </select>
<Input value={model} onInput={(event) => setModel(event.currentTarget.value)} placeholder="Model" disabled={isSending} /> <ModelCombobox
options={providerModelOptions}
value={model}
disabled={isSending || providerModelOptions.length === 0}
onChange={(nextModel) => {
setModel(nextModel);
setProviderModelPreferences((current) => ({
...current,
[provider]: nextModel,
}));
}}
/>
</> </>
) : ( ) : (
<div className="flex h-9 items-center rounded-md border border-input px-3 text-sm text-muted-foreground"> <div className="flex h-9 items-center rounded-md border border-input px-3 text-sm text-muted-foreground">

View File

@@ -83,6 +83,18 @@ export type CompletionRequestMessage = {
name?: string; name?: string;
}; };
export type Provider = "openai" | "anthropic" | "xai";
export type ProviderModelInfo = {
models: string[];
loadedAt: string | null;
error: string | null;
};
export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>;
};
type CompletionResponse = { type CompletionResponse = {
chatId: string | null; chatId: string | null;
message: { message: {
@@ -142,6 +154,10 @@ export async function verifySession() {
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session"); return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
} }
export async function listModels() {
return api<ModelCatalogResponse>("/v1/models");
}
export async function createChat(title?: string) { export async function createChat(title?: string) {
const data = await api<{ chat: ChatSummary }>("/v1/chats", { const data = await api<{ chat: ChatSummary }>("/v1/chats", {
method: "POST", method: "POST",
@@ -296,7 +312,7 @@ export async function runSearchStream(
export async function runCompletion(body: { export async function runCompletion(body: {
chatId: string; chatId: string;
provider: "openai" | "anthropic" | "xai"; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
}) { }) {