import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { Brain, ChevronDown, Cpu, DollarSign, Eye, RefreshCw, Settings2, Star, Wrench, X, Zap, } from "lucide-react"; import { api } from "@/lib/api"; import type { AuxiliaryModelsResponse, AuxiliaryTaskAssignment, ModelsAnalyticsModelEntry, ModelsAnalyticsResponse, } from "@/lib/api"; import { timeAgo, cn, themedBody } from "@/lib/utils"; 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 { 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"; 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: "web_extract", label: "Web Extract", hint: "Page summarization" }, { 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: "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 (
{open && (
Auxiliary task
{AUX_TASKS.map((t) => ( ))} {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 }; 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

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)"}`}
); })}
{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 ModelSettingsPanel({ aux, refreshKey, onSaved, }: { aux: AuxiliaryModelsResponse | null; refreshKey: number; onSaved(): void; }) { const [auxModalOpen, setAuxModalOpen] = useState(false); const [picker, setPicker] = useState(null); const mainProv = aux?.main.provider ?? ""; const mainModel = aux?.main.model ?? ""; 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)"}
{/* 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`}
{picker && ( applyAssignment({ confirmExpensiveModel, scope: "main", task: "", provider, model, }) } onClose={() => setPicker(null)} /> )} {auxModalOpen && ( setAuxModalOpen(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( (opts?: { silent?: boolean }) => { if (!opts?.silent) { setLoading(true); setError(null); } Promise.all([ api.getModelsAnalytics(days), api.getAuxiliaryModels().catch(() => null), ]) .then(([models, auxData]) => { setData(models); setAux(auxData); }) .catch((err) => { if (!opts?.silent) setError(String(err)); }) .finally(() => { if (!opts?.silent) setLoading(false); }); }, [days], ); const onAssigned = useCallback(() => { // Reload aux state after any assignment change. api .getAuxiliaryModels() .then(setAux) .catch(() => {}); setSaveKey((k) => k + 1); }, []); 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) => ( ))}
, ); 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) — refetch silently when the page regains focus. useEffect(() => { const refetch = () => { if (document.visibilityState === "visible") load({ silent: true }); }; window.addEventListener("focus", refetch); document.addEventListener("visibilitychange", refetch); return () => { window.removeEventListener("focus", refetch); document.removeEventListener("visibilitychange", refetch); }; }, [load]); 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}

)} )}
); }