fabro/apps/fabro-web/app/routes/settings-models.tsx
2026-07-23 10:12:25 -04:00

631 lines
19 KiB
TypeScript

import { useCallback, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import {
CheckCircleIcon,
ChevronDownIcon,
XCircleIcon,
} from "@heroicons/react/16/solid";
import type { Model, Provider } from "@qltysh/fabro-api-client";
import { apiData, modelsApi } from "../lib/api-client";
import { useModels, useProviders } from "../lib/queries";
import { Spinner } from "../components/state";
import {
Dot,
Panel,
PanelSkeleton,
Row,
SettingsPageIntro,
} from "../components/settings-panel";
import { plural } from "../lib/plural";
import {
FilterButton,
type FilterOption,
} from "../components/runs-list/filter-button";
import {
SortHeader,
type SortDirection,
} from "../components/runs-list/sort-header";
import { Tooltip } from "../components/ui";
import { formatContextWindow, formatTokensPerSecond } from "../lib/format";
import {
modelOfferingKey,
modelOfferingTestArgs,
} from "../lib/model-offerings";
import { useDebouncedValue } from "../hooks/effects";
export function meta() {
return [{ title: "Models — Fabro" }];
}
export default function SettingsModels() {
const query = useProviders();
return (
<div className="space-y-8">
<SettingsPageIntro description="LLM providers configured on this Fabro server." />
{query.data ? (
<>
<ProvidersPanel providers={query.data.data} />
<ModelsSection providers={query.data.data} />
</>
) : (
<PanelSkeleton />
)}
</div>
);
}
function ProvidersPanel({ providers }: { providers: Provider[] }) {
const { configured, unconfigured, priorityProviderId } = useMemo(() => {
const configured: Provider[] = [];
const unconfigured: Provider[] = [];
for (const provider of providers) {
if (provider.configured) {
configured.push(provider);
} else {
unconfigured.push(provider);
}
}
const top = configured.reduce<Provider | null>(
(best, p) => (!best || p.priority > best.priority ? p : best),
null,
);
return { configured, unconfigured, priorityProviderId: top?.id ?? null };
}, [providers]);
const [showUnconfigured, setShowUnconfigured] = useState(false);
const showUnconfiguredRows = configured.length === 0 || showUnconfigured;
if (providers.length === 0) {
return (
<Panel title="Providers">
<div className="px-4 py-6 text-sm text-fg-muted">
No LLM providers in the catalog.
</div>
</Panel>
);
}
return (
<Panel title="Providers">
{configured.map((provider) => (
<ProviderRow
key={provider.id}
provider={provider}
isPriority={provider.id === priorityProviderId}
/>
))}
{showUnconfiguredRows
? unconfigured.map((provider) => (
<ProviderRow key={provider.id} provider={provider} />
))
: null}
{configured.length > 0 && unconfigured.length > 0 ? (
<button
type="button"
onClick={() => setShowUnconfigured((v) => !v)}
aria-expanded={showUnconfigured}
className="flex w-full items-center gap-1.5 px-4 py-3 text-left text-xs font-medium text-fg-muted hover:text-fg-3"
>
<ChevronDownIcon
className={`size-4 h-lh shrink-0 transition-transform ${
showUnconfigured ? "rotate-180" : ""
}`}
/>
{showUnconfigured ? "Hide" : "Show"} {unconfigured.length}{" "}
unconfigured {plural(unconfigured.length, "provider", "providers")}
</button>
) : null}
</Panel>
);
}
function ProviderRow({
provider,
isPriority = false,
}: {
provider: Provider;
isPriority?: boolean;
}) {
const name = provider.display_name || provider.id;
const modelCount = `${provider.model_count} ${plural(provider.model_count, "model", "models")}`;
return (
<Row
title={
<span className="flex items-center gap-4">
<ProviderLogo provider={provider} />
<span className="flex min-w-0 flex-col">
<span className="flex items-center gap-2 text-sm text-fg-2">
{name}
{isPriority ? <Label>Priority</Label> : null}
</span>
<span className="text-xs/5 text-fg-3">{modelCount}</span>
</span>
</span>
}
>
<ProviderStatus provider={provider} />
</Row>
);
}
function Label({ children }: { children: ReactNode }) {
return (
<span className="inline-flex items-center rounded-md bg-overlay-strong px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-fg-3">
{children}
</span>
);
}
function ProviderLogo({ provider }: { provider: Provider }) {
const [failed, setFailed] = useState(false);
const name = provider.display_name || provider.id;
const chip =
"grid size-10 shrink-0 place-items-center rounded-md bg-ice-50 ring-1 ring-line-strong";
const dim = provider.configured ? "" : "opacity-60";
if (failed) {
const initial = name.charAt(0).toUpperCase() || "?";
return (
<span className={`${chip} text-base font-medium text-page ${dim}`}>
{initial}
</span>
);
}
return (
<span className={`${chip} ${dim}`}>
<img
alt=""
src={`/images/providers/${provider.id}.svg`}
onError={() => setFailed(true)}
className="size-7"
/>
</span>
);
}
function ProviderStatus({ provider }: { provider: Provider }) {
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex items-center gap-2">
<Dot on={provider.configured} />
<span className={provider.configured ? "text-fg" : "text-fg-muted"}>
{provider.configured ? "Configured" : "Not configured"}
</span>
</span>
{!provider.configured && provider.api_key_url ? (
<a
href={provider.api_key_url}
target="_blank"
rel="noreferrer"
className="text-xs text-teal-500 hover:underline"
>
Get API key
</a>
) : null}
{!provider.configured && provider.expected_secret_name ? (
<Link
to={`/settings/secrets/new?name=${encodeURIComponent(provider.expected_secret_name)}`}
className="text-xs text-teal-500 hover:underline"
>
Add secret
</Link>
) : null}
</span>
);
}
type ModelSortKey = "provider" | "model" | "context" | "speed";
type RowState =
| { phase: "queued" }
| { phase: "running" }
| { phase: "ok" }
| { phase: "error"; message: string };
type Sweep = {
done: number;
total: number;
ok: number;
failed: number;
};
const TEST_CONCURRENCY = 4;
function ModelsSection({ providers }: { providers: Provider[] }) {
const [providerFilter, setProviderFilter] = useState<string>("");
const [searchInput, setSearchInput] = useState("");
const debouncedSearch = useDebouncedValue(searchInput, 250);
const [sortKey, setSortKey] = useState<ModelSortKey>("provider");
const [direction, setDirection] = useState<SortDirection>("asc");
const [results, setResults] = useState<Map<string, RowState>>(new Map());
const [sweep, setSweep] = useState<Sweep | null>(null);
const { data, isLoading } = useModels(providerFilter, debouncedSearch);
const providerNameById = useMemo(() => {
const map = new Map<string, string>();
for (const p of providers) map.set(p.id, p.display_name || p.id);
return map;
}, [providers]);
const rows = useMemo(() => {
const all = (data?.data ?? []).filter((m) => m.configured);
return sortModels(all, sortKey, direction, providerNameById);
}, [data, sortKey, direction, providerNameById]);
const providerOptions: FilterOption<string>[] = useMemo(() => {
const options: FilterOption<string>[] = [{ value: "", label: "All providers" }];
for (const provider of providers) {
if (provider.configured) {
options.push({ value: provider.id, label: provider.display_name || provider.id });
}
}
return options;
}, [providers]);
const onSort = useCallback(
(key: ModelSortKey) => {
if (sortKey === key) {
setDirection((dir) => (dir === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setDirection("asc");
}
},
[sortKey],
);
const running = sweep !== null && sweep.done < sweep.total;
const runSweep = useCallback(async () => {
if (running) return;
const offerings = rows.map((model) => ({
id: model.id,
provider: model.provider,
key: modelOfferingKey(model),
}));
if (offerings.length === 0) return;
const seed = new Map<string, RowState>();
for (const offering of offerings) {
seed.set(offering.key, { phase: "queued" });
}
setResults(seed);
setSweep({ done: 0, total: offerings.length, ok: 0, failed: 0 });
let cursor = 0;
const worker = async () => {
while (cursor < offerings.length) {
const i = cursor;
cursor += 1;
const offering = offerings[i];
setResults((prev) => {
const next = new Map(prev);
next.set(offering.key, { phase: "running" });
return next;
});
let outcome: RowState;
try {
const result = await apiData(() =>
modelsApi.testModel(...modelOfferingTestArgs(offering)),
);
if (
result.provider !== offering.provider ||
result.model_id !== offering.id
) {
outcome = {
phase: "error",
message: `Server tested unexpected offering ${result.provider}/${result.model_id}`,
};
} else if (result.status === "ok") {
outcome = { phase: "ok" };
} else if (result.status === "error") {
outcome = {
phase: "error",
message: result.error_message ?? "Failed",
};
} else {
outcome = { phase: "error", message: "provider not configured" };
}
} catch (err) {
outcome = {
phase: "error",
message: err instanceof Error ? err.message : String(err),
};
}
setResults((prev) => {
const next = new Map(prev);
next.set(offering.key, outcome);
return next;
});
setSweep((prev) =>
prev
? {
done: prev.done + 1,
total: prev.total,
ok: prev.ok + (outcome.phase === "ok" ? 1 : 0),
failed: prev.failed + (outcome.phase === "error" ? 1 : 0),
}
: prev,
);
}
};
await Promise.all(
Array.from({ length: Math.min(TEST_CONCURRENCY, offerings.length) }, () =>
worker(),
),
);
}, [rows, running]);
const showEmpty = !isLoading && rows.length === 0;
return (
<section className="space-y-3">
<header className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-medium text-fg-2">Models</h2>
<div className="flex flex-wrap items-center gap-2">
<FilterButton
label="Provider"
value={providerFilter}
allValue=""
options={providerOptions}
onChange={setProviderFilter}
/>
<input
type="search"
name="model-search"
aria-label="Search models"
placeholder="Search models…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-44 rounded-md border border-line bg-panel/80 px-3 py-2 text-xs text-fg-2 placeholder:text-fg-muted focus:border-line-strong focus:outline-none"
/>
{sweep && !running ? (
<span
className={`text-xs tabular-nums ${sweep.failed === 0 ? "text-mint" : "text-coral"}`}
>
{sweep.ok} ok · {sweep.failed} failed
</span>
) : null}
<button
type="button"
onClick={() => void runSweep()}
disabled={running || rows.length === 0}
className="inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-medium text-fg-2 ring-1 ring-line hover:bg-overlay/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{running ? (
<>
<Spinner className="size-3.5 h-lh shrink-0 text-teal-500" />
Testing {sweep!.done}/{sweep!.total}
</>
) : (
"Test models"
)}
</button>
</div>
</header>
<div className="-mx-4 -my-2 overflow-x-auto whitespace-nowrap sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full px-4 py-2 align-middle sm:px-6 lg:px-8">
<table className="w-full text-sm [&_td:first-child]:pl-0 [&_td:last-child]:pr-0 [&_th:first-child]:pl-0 [&_th:last-child]:pr-0">
<thead>
<tr className="border-b border-line text-xs font-medium text-fg-3">
<SortHeader<ModelSortKey>
label="Provider"
sortKey="provider"
activeSort={sortKey}
direction={direction}
onClick={onSort}
/>
<SortHeader<ModelSortKey>
label="Model"
sortKey="model"
activeSort={sortKey}
direction={direction}
onClick={onSort}
/>
<SortHeader<ModelSortKey>
label="Context"
sortKey="context"
activeSort={sortKey}
direction={direction}
align="right"
onClick={onSort}
/>
<SortHeader<ModelSortKey>
label="Speed"
sortKey="speed"
activeSort={sortKey}
direction={direction}
align="right"
onClick={onSort}
/>
<th
scope="col"
className="whitespace-nowrap px-3 py-2.5 text-right text-xs font-medium text-fg-3"
>
Test
</th>
</tr>
</thead>
<tbody>
{rows.map((model) => (
<ModelTableRow
key={modelOfferingKey(model)}
model={model}
providerLabel={
providerNameById.get(model.provider) ?? model.provider
}
state={results.get(modelOfferingKey(model))}
/>
))}
</tbody>
</table>
</div>
</div>
{showEmpty && (
<div className="py-6 text-sm text-fg-muted">
{debouncedSearch || providerFilter
? "No matching models from configured providers."
: "No configured providers yet — add a provider above to enable models."}
</div>
)}
</section>
);
}
function ModelTableRow({
model,
providerLabel,
state,
}: {
model: Model;
providerLabel: string;
state: RowState | undefined;
}) {
return (
<tr className="border-b border-line last:border-b-0">
<td className="whitespace-nowrap px-3 py-2.5 text-fg-3">
{providerLabel}
</td>
<td className="whitespace-nowrap px-3 py-2.5">
<ModelNameCell model={model} />
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-right font-mono text-xs text-fg-muted tabular-nums">
{formatContextWindow(model.limits.context_window)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-right font-mono text-xs text-fg-muted tabular-nums">
{formatTokensPerSecond(model.estimated_output_tps)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-right">
<TestStatusCell state={state} />
</td>
</tr>
);
}
function TestStatusCell({ state }: { state: RowState | undefined }) {
if (!state) {
return <span className="text-xs text-fg-muted"></span>;
}
if (state.phase === "queued") {
return (
<span className="inline-flex items-center" aria-label="Queued">
<Spinner className="size-3.5 h-lh shrink-0 opacity-40" />
</span>
);
}
if (state.phase === "running") {
return (
<span className="inline-flex items-center text-fg-2" aria-label="Testing">
<Spinner className="size-3.5 h-lh shrink-0 text-teal-500" />
</span>
);
}
if (state.phase === "ok") {
return (
<span className="inline-flex items-center text-mint" aria-label="Ok">
<CheckCircleIcon className="size-4 h-lh shrink-0" />
</span>
);
}
return (
<Tooltip
label={
<span className="block w-[32rem] max-w-[80vw] whitespace-pre-line break-words text-left font-mono leading-relaxed">
{state.message}
</span>
}
>
<span
aria-label={`Failed: ${state.message}`}
className="inline-flex cursor-default items-center text-coral focus:outline-none"
tabIndex={0}
>
<XCircleIcon className="size-4 h-lh shrink-0" />
</span>
</Tooltip>
);
}
function ModelNameCell({ model }: { model: Model }) {
const hasAliases = model.aliases.length > 0;
const nameNode = hasAliases ? (
<Tooltip
label={
<span className="block min-w-[10rem]">
<span className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-fg-muted">
Aliases
</span>
{model.aliases.map((alias) => (
<span key={alias} className="block font-mono text-fg-2">
{alias}
</span>
))}
</span>
}
>
<button
type="button"
className="cursor-default font-mono text-xs text-fg-2 underline decoration-dotted decoration-fg-muted underline-offset-4 hover:decoration-fg-3 focus:outline-none focus-visible:text-fg"
>
{model.id}
</button>
</Tooltip>
) : (
<span className="font-mono text-xs text-fg-2">{model.id}</span>
);
if (!model.default && !model.small_default) {
return nameNode;
}
return (
<span className="inline-flex items-center gap-2">
{nameNode}
{model.default ? <Label>Default</Label> : null}
{model.small_default ? <Label>Small</Label> : null}
</span>
);
}
function sortModels(
models: Model[],
key: ModelSortKey,
direction: SortDirection,
providerNameById: Map<string, string>,
): Model[] {
const sign = direction === "asc" ? 1 : -1;
const providerLabel = (m: Model) =>
providerNameById.get(m.provider) ?? m.provider;
const sorted = Array.from(models);
sorted.sort((a, b) => {
let cmp = 0;
switch (key) {
case "provider":
cmp = providerLabel(a).localeCompare(providerLabel(b));
if (cmp === 0) cmp = a.id.localeCompare(b.id);
break;
case "model":
cmp = a.id.localeCompare(b.id);
break;
case "context":
cmp = a.limits.context_window - b.limits.context_window;
if (cmp === 0) cmp = a.id.localeCompare(b.id);
break;
case "speed": {
const ta = a.estimated_output_tps ?? -Infinity;
const tb = b.estimated_output_tps ?? -Infinity;
cmp = ta - tb;
if (cmp === 0) cmp = a.id.localeCompare(b.id);
break;
}
}
return cmp * sign;
});
return sorted;
}