model catalog
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
99
server/src/llm/model-catalog.ts
Normal file
99
server/src/llm/model-catalog.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
215
web/src/App.tsx
215
web/src/App.tsx
@@ -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">
|
||||||
|
|||||||
@@ -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[];
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user