import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { createPortal } from "react-dom";
import {
Brain,
ChevronDown,
Cpu,
DollarSign,
Eye,
RefreshCw,
Settings2,
Star,
Wrench,
X,
Zap,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
AuxiliaryModelsResponse,
AuxiliaryTaskAssignment,
MoaConfigResponse,
MoaModelSlot,
ModelsAnalyticsModelEntry,
ModelsAnalyticsResponse,
} from "@/lib/api";
import { timeAgo, cn, themedBody } from "@/lib/utils";
import {
DASHBOARD_MODAL_BACKDROP,
DASHBOARD_MODAL_PANEL,
shouldCloseOuterModalOnEscape,
} from "@/lib/dashboard-modal-shell";
import { formatTokenCount } from "@/lib/format";
import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
import { ModelReloadConfirm } from "@/components/ModelReloadConfirm";
const PERIODS = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
] as const;
// Must match _AUX_TASK_SLOTS in hermes_cli/web_server.py.
const AUX_TASKS: readonly { key: string; label: string; hint: string }[] = [
{ key: "vision", label: "Vision", hint: "Image analysis" },
{ key: "compression", label: "Compression", hint: "Context compaction" },
{ key: "skills_hub", label: "Skills Hub", hint: "Skill search" },
{ key: "approval", label: "Approval", hint: "Smart auto-approve" },
{ key: "mcp", label: "MCP", hint: "MCP tool routing" },
{ key: "title_generation", label: "Title Gen", hint: "Session titles" },
{ key: "review", label: "Review", hint: "/review subagent" },
{ key: "triage_specifier", label: "Triage Specifier", hint: "Kanban spec fleshing" },
{ key: "kanban_decomposer", label: "Kanban Decomposer", hint: "Task decomposition" },
{ key: "profile_describer", label: "Profile Describer", hint: "Auto profile descriptions" },
{ key: "curator", label: "Curator", hint: "Skill-usage review" },
] as const;
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function formatCost(n: number): string {
if (n >= 1) return `$${n.toFixed(2)}`;
if (n >= 0.01) return `$${n.toFixed(3)}`;
if (n > 0) return `$${n.toFixed(4)}`;
return "$0";
}
/** Short model name: strip vendor prefix like "openrouter/" or "anthropic/". */
function shortModelName(model: string): string {
const slashIdx = model.indexOf("/");
if (slashIdx > 0) return model.slice(slashIdx + 1);
return model;
}
/** Extract vendor prefix from a model string like "anthropic/claude-opus-4.7" → "anthropic". */
function modelVendor(model: string, fallback?: string): string {
const slashIdx = model.indexOf("/");
if (slashIdx > 0) return model.slice(0, slashIdx);
return fallback || "";
}
function TokenBar({
input,
output,
cacheRead,
reasoning,
}: {
input: number;
output: number;
cacheRead: number;
reasoning: number;
}) {
const total = input + output + cacheRead + reasoning;
if (total === 0) return null;
// Segments carry a CSS color value (hex or `var(--token)`) rather than
// a Tailwind class so the input/output series can pick up the active
// theme's `--series-*-token` vars — see `themes/types.ts`
// `ThemeSeriesColors`. The /60–/70 fade on the bar is applied via
// color-mix on the same value so themes don't need to ship two
// separate hex literals.
const segments: Array<{ color: string; label: string; value: number }> = [
{ value: cacheRead, color: "#60a5fa", label: "Cache Read" }, // tailwind blue-400
{ value: reasoning, color: "#c084fc", label: "Reasoning" }, // tailwind purple-400
{ value: input, color: "var(--series-input-token)", label: "Input" },
{ value: output, color: "var(--series-output-token)", label: "Output" },
].filter((s) => s.value > 0);
return (
{/* Stacked bar — segments fill proportionally to their share of total */}
{segments.map((s, i) => (
{/* Stepped fill pattern overlay */}
))}
{/* Legend */}
{segments.map((s, i) => (
{s.label} {formatTokens(s.value)}
))}
);
}
function CapabilityBadges({
capabilities,
}: {
capabilities: ModelsAnalyticsModelEntry["capabilities"];
}) {
const hasAny =
capabilities.supports_tools ||
capabilities.supports_vision ||
capabilities.supports_reasoning ||
capabilities.model_family;
if (!hasAny) return null;
return (
{capabilities.supports_tools && (
Tools
)}
{capabilities.supports_vision && (
Vision
)}
{capabilities.supports_reasoning && (
Reasoning
)}
{capabilities.model_family && (
{capabilities.model_family}
)}
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* Per-card "Use as" menu */
/* ──────────────────────────────────────────────────────────────────── */
function UseAsMenu({
provider,
model,
isMain,
mainAuxTask,
onAssigned,
}: {
provider: string;
model: string;
/** True when this card's model+provider match config.yaml's main slot. */
isMain: boolean;
/** If this model is assigned to a specific aux task, that task's key. */
mainAuxTask: string | null;
onAssigned(): void;
}) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [pendingConfirm, setPendingConfirm] = useState<{
message: string;
scope: "main" | "auxiliary";
task: string;
} | null>(null);
const assign = async (
scope: "main" | "auxiliary",
task: string,
confirmExpensiveModel = false,
) => {
if (!provider || !model) {
setError("Missing provider/model");
return;
}
setBusy(true);
setError(null);
try {
const result = await api.setModelAssignment({
confirm_expensive_model: confirmExpensiveModel,
scope,
provider,
model,
task,
});
if (result.confirm_required) {
setPendingConfirm({
scope,
task,
message:
result.confirm_message ||
"This model has unusually high known pricing.",
});
return;
}
onAssigned();
setOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
// Close on outside click.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target && !target.closest?.("[data-use-as-menu]")) setOpen(false);
};
window.addEventListener("mousedown", onDown);
return () => window.removeEventListener("mousedown", onDown);
}, [open]);
return (
setOpen((v) => !v)}
disabled={busy}
className="h-6 px-2 text-xs uppercase"
prefix={busy ? : null}
>
Use as
{open && (
assign("main", "")}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-2 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
>
Main model
{isMain && (
current
)}
Auxiliary task
assign("auxiliary", "")}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
>
All auxiliary tasks
{AUX_TASKS.map((t) => (
assign("auxiliary", t.key)}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
>
{t.label}
{mainAuxTask === t.key && (
current
)}
))}
{error && (
{error}
)}
)}
setPendingConfirm(null)}
onConfirm={() => {
const pending = pendingConfirm;
if (!pending) return;
setPendingConfirm(null);
void assign(pending.scope, pending.task, true);
}}
/>
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* ModelCard */
/* ──────────────────────────────────────────────────────────────────── */
function ModelCard({
entry,
rank,
main,
aux,
onAssigned,
showTokens,
}: {
entry: ModelsAnalyticsModelEntry;
rank: number;
main: { provider: string; model: string } | null;
aux: AuxiliaryTaskAssignment[];
onAssigned(): void;
showTokens: boolean;
}) {
const { t } = useI18n();
const provider = entry.provider || modelVendor(entry.model);
const totalTokens = entry.input_tokens + entry.output_tokens;
const caps = entry.capabilities;
const isMain =
!!main &&
main.provider === provider &&
main.model === entry.model;
// First aux task currently using this model (if any).
const mainAuxTask =
aux.find(
(a) => a.provider === provider && a.model === entry.model,
)?.task ?? null;
return (
#{rank}
{shortModelName(entry.model)}
{isMain && (
main
)}
{mainAuxTask && (
aux · {mainAuxTask}
)}
{provider && (
{provider}
)}
{caps.context_window && caps.context_window > 0 && (
{formatTokenCount(caps.context_window)} ctx
)}
{caps.max_output_tokens && caps.max_output_tokens > 0 && (
{formatTokenCount(caps.max_output_tokens)} out
)}
{showTokens ? (
{formatTokens(totalTokens)}
{t.models.tokens}
) : (
entry.sessions > 0 && (
{entry.sessions}
{t.models.sessions}
)
)}
{showTokens && (
<>
{entry.sessions}
{t.models.sessions}
{formatTokens(entry.avg_tokens_per_session)}
{t.models.avgPerSession}
{entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"}
{t.models.apiCalls}
>
)}
{showTokens && entry.estimated_cost > 0 && (
{formatCost(entry.estimated_cost)}
)}
{showTokens && entry.tool_calls > 0 && (
{entry.tool_calls} {t.models.toolCalls}
)}
{entry.last_used_at > 0 && (
{timeAgo(entry.last_used_at)}
)}
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* Model Settings panel (top of page) */
/* ──────────────────────────────────────────────────────────────────── */
type PickerTarget =
| { kind: "main" }
| { kind: "aux"; task: string };
type MoaPickerTarget =
| { kind: "reference"; index: number }
| { kind: "aggregator" };
function AuxiliaryTasksModal({
aux,
refreshKey,
onSaved,
onClose,
}: {
aux: AuxiliaryModelsResponse | null;
refreshKey: number;
onSaved(): void;
onClose(): void;
}) {
const [picker, setPicker] = useState(null);
const [resetBusy, setResetBusy] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const modalRef = useModalBehavior({ open: true, onClose });
const resetAllAux = async () => {
setConfirmReset(false);
setResetBusy(true);
try {
await api.setModelAssignment({
scope: "auxiliary",
task: "__reset__",
provider: "",
model: "",
});
onSaved();
} finally {
setResetBusy(false);
}
};
return (
e.target === e.currentTarget && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="aux-modal-title"
>
Auxiliary Tasks
setConfirmReset(true)}
disabled={resetBusy}
className="h-6 text-xs uppercase"
prefix={resetBusy ? : null}
>
Reset all to auto
Auxiliary tasks handle side-jobs like vision, session search, and
compression. auto means
"use the main model". Override per-task when you want a
cheap/fast model for a specific job.
{AUX_TASKS.map((t) => {
const cur = aux?.tasks.find((a) => a.task === t.key);
const isAuto =
!cur || cur.provider === "auto" || !cur.provider;
return (
{t.label}
{t.hint}
{isAuto
? "auto (use main model)"
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
setPicker({ kind: "aux", task: t.key })}
className="h-6 text-xs uppercase"
>
Change
);
})}
{picker && picker.kind === "aux" && (
t.key === picker.task)?.label ??
picker.task
}`}
onApply={async ({ provider, model, confirmExpensiveModel }) => {
const result = await api.setModelAssignment({
confirm_expensive_model: confirmExpensiveModel,
scope: "auxiliary",
task: picker.task,
provider,
model,
});
if (!result.confirm_required) onSaved();
return result;
}}
onClose={() => setPicker(null)}
/>
)}
setConfirmReset(false)}
onConfirm={() => void resetAllAux()}
title="Reset auxiliary models"
description="Reset every auxiliary task to 'auto'? This overrides any per-task overrides you've set."
destructive
confirmLabel="Reset all"
loading={resetBusy}
/>
);
}
function MoaModelsModal({
config,
refreshKey,
onClose,
onSaved,
}: {
config: MoaConfigResponse;
refreshKey: number;
onClose(): void;
onSaved(next: MoaConfigResponse): void;
}) {
const [draft, setDraft] = useState(config);
const [selected, setSelected] = useState(config.default_preset || Object.keys(config.presets)[0] || "default");
const [newName, setNewName] = useState("");
const [picker, setPicker] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// Nested ModelPickerDialog owns Escape while open — don't dismiss MoA too.
const closeMoaUnlessPickerOpen = useCallback(() => {
if (!shouldCloseOuterModalOnEscape(picker !== null)) return;
onClose();
}, [picker, onClose]);
const modalRef = useModalBehavior({
open: true,
onClose: closeMoaUnlessPickerOpen,
});
const presetNames = Object.keys(draft.presets || {});
const preset = draft.presets[selected] || draft.presets[presetNames[0]];
const slotLabel = (slot: MoaModelSlot) => `${slot.provider || "(provider)"} · ${slot.model || "(model)"}`;
const updateSelectedPreset = (updater: (preset: MoaConfigResponse["presets"][string]) => MoaConfigResponse["presets"][string]) => {
setDraft((prev) => ({
...prev,
presets: {
...prev.presets,
[selected]: updater(prev.presets[selected]),
},
}));
};
const save = async () => {
setBusy(true);
setError(null);
try {
const saved = await api.saveMoaModels(draft);
onSaved(saved);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const addPreset = () => {
const name = newName.trim();
if (!name || draft.presets[name]) return;
const seed = preset || {
reference_models: draft.reference_models,
aggregator: draft.aggregator,
reference_temperature: draft.reference_temperature,
aggregator_temperature: draft.aggregator_temperature,
reference_timeout: draft.reference_timeout,
degraded_reference_policy: draft.degraded_reference_policy,
max_tokens: draft.max_tokens,
enabled: draft.enabled,
};
setDraft((prev) => ({
...prev,
default_preset: prev.default_preset || name,
presets: { ...prev.presets, [name]: { ...seed, reference_models: [...seed.reference_models] } },
}));
setSelected(name);
setNewName("");
};
const deletePreset = () => {
if (presetNames.length <= 1) return;
const remaining = presetNames.filter((name) => name !== selected);
const nextSelected = remaining[0];
setDraft((prev) => {
const next = { ...prev.presets };
delete next[selected];
return {
...prev,
presets: next,
default_preset: prev.default_preset === selected ? nextSelected : prev.default_preset,
active_preset: prev.active_preset === selected ? "" : prev.active_preset,
};
});
setSelected(nextSelected);
};
if (!preset) return null;
// Portal to document.body: the main dashboard column is `relative z-2`,
// which traps fixed descendants below the sidebar (same as ModelPickerDialog).
return createPortal(
{
if (e.target === e.currentTarget) closeMoaUnlessPickerOpen();
}}
role="dialog"
aria-modal="true"
aria-labelledby="moa-modal-title"
>
{/* Opaque panel — do not use
here; Card defaults to bg-background-base/80. */}
Configure Mixture of Agents presets
Presets appear as models under the Mixture of Agents provider. References produce perspectives; the aggregator is the acting model that answers and calls tools.
setSelected(event.target.value)}
>
{presetNames.map((name) => {name} )}
setDraft((prev) => ({ ...prev, default_preset: selected }))}>Set default
Delete
setNewName(event.target.value)}
/>
Add preset
Default: {draft.default_preset}
Reference models
{preset.reference_models.map((slot, index) => (
updateSelectedPreset((prev) => ({
...prev,
reference_models: prev.reference_models.map((s, i) =>
i === index ? { ...s, enabled: checked === true } : s
),
}))
}
/>
{slotLabel(slot)}
setPicker({ kind: "reference", index })}>Change
updateSelectedPreset((prev) => ({ ...prev, reference_models: prev.reference_models.filter((_, i) => i !== index) }))}>Remove
))}
updateSelectedPreset((prev) => ({ ...prev, reference_models: [...prev.reference_models, { ...prev.aggregator, enabled: true }] }))}>Add reference model
Aggregator
{slotLabel(preset.aggregator)}
setPicker({ kind: "aggregator" })}>Change
{error &&
{error}
}
Cancel
void save()} disabled={busy}>{busy ? "Saving…" : "Save"}
{picker && (
{
if ((provider || "").toLowerCase() === "moa") {
setError("MoA presets can't reference or aggregate the Mixture of Agents provider (no recursive MoA).");
return;
}
setError(null);
updateSelectedPreset((prev) => {
if (picker.kind === "aggregator") return { ...prev, aggregator: { provider, model } };
return {
...prev,
reference_models: prev.reference_models.map((slot, i) => i === picker.index ? { ...slot, provider, model } : slot),
};
});
}}
onClose={() => setPicker(null)}
/>
)}
,
document.body,
);
}
function ModelSettingsPanel({
aux,
refreshKey,
onSaved,
}: {
aux: AuxiliaryModelsResponse | null;
refreshKey: number;
onSaved(): void;
}) {
const [auxModalOpen, setAuxModalOpen] = useState(false);
const [moaModalOpen, setMoaModalOpen] = useState(false);
const [moa, setMoa] = useState(null);
const [picker, setPicker] = useState(null);
const [pendingReloadModel, setPendingReloadModel] = useState(
null,
);
const mainProv = aux?.main.provider ?? "";
const mainModel = aux?.main.model ?? "";
useEffect(() => {
api.getMoaModels().then(setMoa).catch(() => setMoa(null));
}, [refreshKey]);
const applyAssignment = async ({
scope,
task,
provider,
model,
confirmExpensiveModel,
}: {
confirmExpensiveModel?: boolean;
scope: "main" | "auxiliary";
task: string;
provider: string;
model: string;
}) => {
const result = await api.setModelAssignment({
confirm_expensive_model: confirmExpensiveModel,
scope,
task,
provider,
model,
});
if (!result.confirm_required) onSaved();
return result;
};
// Count how many aux tasks have overrides
const auxOverrideCount = aux?.tasks.filter(
(a) => a.provider && a.provider !== "auto",
).length ?? 0;
return (
Model Settings
applies to new sessions
{/* Main row */}
Main model
{mainProv || "(unset)"}
{mainProv && mainModel && " · "}
{mainModel || "(unset)"}
setPicker({ kind: "main" })}
className="shrink-0 self-start text-xs uppercase sm:self-center"
>
Change
{/* Auxiliary tasks summary + open modal */}
Auxiliary tasks
{auxOverrideCount > 0
? `${auxOverrideCount} override${auxOverrideCount > 1 ? "s" : ""} · ${AUX_TASKS.length - auxOverrideCount} auto`
: `${AUX_TASKS.length} tasks · all auto`}
setAuxModalOpen(true)}
className="shrink-0 self-start text-xs uppercase sm:self-center"
>
Configure
Mixture of Agents
{moa
? `${moa.reference_models.length} reference${moa.reference_models.length === 1 ? "" : "s"} · ${moa.aggregator.provider}/${shortModelName(moa.aggregator.model)}`
: "not loaded"}
setMoaModalOpen(true)}
disabled={!moa}
className="shrink-0 self-start text-xs uppercase sm:self-center"
>
Configure
{picker && (
{
const result = await applyAssignment({
confirmExpensiveModel,
scope: "main",
task: "",
provider,
model,
});
if (!result.confirm_required) {
setPendingReloadModel(model.split("/").slice(-1)[0]);
}
return result;
}}
onClose={() => setPicker(null)}
/>
)}
{auxModalOpen && (
setAuxModalOpen(false)}
/>
)}
setPendingReloadModel(null)}
/>
{moaModalOpen && moa && (
{
setMoa(next);
onSaved();
}}
onClose={() => setMoaModalOpen(false)}
/>
)}
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* Page */
/* ──────────────────────────────────────────────────────────────────── */
export default function ModelsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState(null);
const [aux, setAux] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [saveKey, setSaveKey] = useState(0);
// Gate the token/cost UI on `dashboard.show_token_analytics`. See
// hermes_cli/config.py for the rationale: the numbers exclude auxiliary
// calls and retries, so they're misleading next to provider billing.
const [showTokens, setShowTokens] = useState(false);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
useEffect(() => {
api
.getConfig()
.then((cfg) => {
const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown };
setShowTokens(dash.show_token_analytics === true);
})
.catch(() => {
// Default to hidden on any failure — safer than showing wrong numbers.
setShowTokens(false);
});
}, []);
const load = useCallback(() => {
setLoading(true);
setError(null);
Promise.all([
api.getModelsAnalytics(days),
api.getAuxiliaryModels().catch(() => null),
])
.then(([models, auxData]) => {
setData(models);
setAux(auxData);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [days]);
const refreshAux = useCallback(() => {
api
.getAuxiliaryModels()
.then(setAux)
.catch(() => {});
}, []);
const onAssigned = useCallback(() => {
// Reload aux state after any assignment change.
refreshAux();
setSaveKey((k) => k + 1);
}, [refreshAux]);
useLayoutEffect(() => {
// Period selector + refresh both live in afterTitle so the controls
// sit immediately next to the page title instead of being pinned to
// the far-right `end` slot. The active period is conveyed by the
// filled (non-outlined) button — no redundant period badge.
setAfterTitle(
{PERIODS.map((p) => (
setDays(p.days)}
className="uppercase"
>
{p.label}
))}
{loading ? : }
,
);
setEnd(null);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]);
useEffect(() => {
load();
}, [load]);
// Model assignments can change outside this page (config editor, chat
// /model --global, CLI), so refetch them when the page regains focus.
useEffect(() => {
let last = 0;
const onFocus = () => {
if (document.visibilityState !== "visible") return;
if (Date.now() - last < 1000) return;
last = Date.now();
refreshAux();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onFocus);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onFocus);
};
}, [refreshAux]);
return (
{data && (
{!showTokens && (
Token & cost analytics are hidden because the local counts
exclude auxiliary calls (compression, vision, web extract,
…) and provider retries, so they diverge from your provider
bill. Enable{" "}
dashboard.show_token_analytics {" "}
in Config to
show the local debug estimate anyway.
)}
)}
{loading && !data && (
)}
{error && (
{error}
)}
{data && (
<>
{data.models.length > 0 ? (
{data.models.map((m, i) => (
))}
) : (
{t.models.noModelsData}
{t.models.startSession}
)}
>
)}
);
}