Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import useSWR from "swr";
|
|
import AppShell from "@/components/AppShell";
|
|
import { PageHeader } from "@/components/ui";
|
|
import { api, fetcher } from "@/lib/api";
|
|
import { useAuth } from "@/lib/auth";
|
|
|
|
type CatalogEntry = {
|
|
id: string;
|
|
backend: string;
|
|
model: string;
|
|
tier: string;
|
|
label: string;
|
|
display: string;
|
|
input_per_m: number;
|
|
output_per_m: number;
|
|
};
|
|
|
|
type RoutingResponse = {
|
|
config: {
|
|
local_model_id: string;
|
|
economy_gemini_id: string;
|
|
economy_deepseek_id: string;
|
|
economy_openrouter_id: string;
|
|
premium_gemini_id: string;
|
|
premium_deepseek_id: string;
|
|
premium_openrouter_id: string;
|
|
cloud_provider_order: string[];
|
|
auto_escalate: boolean;
|
|
max_tier: string;
|
|
};
|
|
catalog: Record<string, Record<string, CatalogEntry[]>>;
|
|
available_backends: Record<string, boolean>;
|
|
strategy: string;
|
|
};
|
|
|
|
type LiveModel = { id: string; label: string; [key: string]: unknown };
|
|
|
|
type AvailableModelsResponse = {
|
|
providers: Record<
|
|
string,
|
|
{ available: boolean; error?: string; models: LiveModel[]; note?: string }
|
|
>;
|
|
routing_order: string;
|
|
};
|
|
|
|
const TIER_LABEL: Record<string, string> = {
|
|
local: "Local (always tried first)",
|
|
economy: "Economy cloud (capable, low cost)",
|
|
premium: "Premium cloud (strongest, higher cost)",
|
|
};
|
|
|
|
const PROVIDER_ORDER_OPTIONS = [
|
|
{ value: "gemini,deepseek,openrouter", label: "Gemini → DeepSeek → OpenRouter" },
|
|
{ value: "gemini,openrouter,deepseek", label: "Gemini → OpenRouter → DeepSeek" },
|
|
{ value: "deepseek,gemini,openrouter", label: "DeepSeek → Gemini → OpenRouter" },
|
|
{ value: "deepseek,openrouter,gemini", label: "DeepSeek → OpenRouter → Gemini" },
|
|
{ value: "openrouter,gemini,deepseek", label: "OpenRouter → Gemini → DeepSeek" },
|
|
{ value: "openrouter,deepseek,gemini", label: "OpenRouter → DeepSeek → Gemini" },
|
|
];
|
|
|
|
function priceLabel(m: CatalogEntry) {
|
|
if (m.input_per_m === 0 && m.output_per_m === 0) return "free tier eligible";
|
|
return `$${m.input_per_m}/${m.output_per_m} per 1M`;
|
|
}
|
|
|
|
export default function ModelsPage() {
|
|
const { user } = useAuth();
|
|
const { data, mutate } = useSWR<RoutingResponse>("/api/llm/routing", fetcher);
|
|
const { data: liveModels } = useSWR<AvailableModelsResponse>(
|
|
user?.role === "admin" ? "/api/llm/models/available" : null,
|
|
fetcher,
|
|
);
|
|
const [form, setForm] = useState<RoutingResponse["config"] | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [showLive, setShowLive] = useState(false);
|
|
|
|
const cfg = form ?? data?.config;
|
|
|
|
const economyOptions = useMemo(() => {
|
|
if (!data) return { gemini: [], deepseek: [], openrouter: [] };
|
|
return {
|
|
gemini: data.catalog.economy?.gemini || [],
|
|
deepseek: data.catalog.economy?.deepseek || [],
|
|
openrouter: data.catalog.economy?.openrouter || [],
|
|
};
|
|
}, [data]);
|
|
|
|
const premiumOptions = useMemo(() => {
|
|
if (!data) return { gemini: [], deepseek: [], openrouter: [] };
|
|
return {
|
|
gemini: data.catalog.premium?.gemini || [],
|
|
deepseek: data.catalog.premium?.deepseek || [],
|
|
openrouter: data.catalog.premium?.openrouter || [],
|
|
};
|
|
}, [data]);
|
|
|
|
const localOptions = data?.catalog.local?.ollama || [];
|
|
|
|
const save = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!cfg) return;
|
|
setSaving(true);
|
|
setMsg(null);
|
|
try {
|
|
await api("/api/llm/routing", { method: "PUT", body: JSON.stringify(cfg) });
|
|
setMsg("Saved — new tasks will use this routing.");
|
|
mutate();
|
|
} catch (err: any) {
|
|
setMsg(err.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (user?.role !== "admin") {
|
|
return (
|
|
<AppShell>
|
|
<PageHeader title="Model routing" subtitle="Admin access required" />
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppShell>
|
|
<PageHeader
|
|
title="Model routing"
|
|
subtitle="Local first, then Gemini → DeepSeek → OpenRouter on escalation"
|
|
/>
|
|
|
|
{data?.strategy && (
|
|
<div className="card mb-6 text-sm text-muted">{data.strategy}</div>
|
|
)}
|
|
|
|
<div className="mb-6 flex flex-wrap gap-3">
|
|
{Object.entries(data?.available_backends || {}).map(([k, ok]) => (
|
|
<span
|
|
key={k}
|
|
className={`badge ${ok ? "bg-good/20 text-good" : "bg-panel2 text-muted"}`}
|
|
>
|
|
{k}: {ok ? "available" : "no API key"}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{!cfg ? (
|
|
<div className="card text-muted">Loading…</div>
|
|
) : (
|
|
<form onSubmit={save} className="space-y-6">
|
|
<div className="card">
|
|
<h2 className="mb-1 font-semibold">{TIER_LABEL.local}</h2>
|
|
<p className="mb-4 text-xs text-muted">Used for triage and first reasoning attempt on every task.</p>
|
|
<select
|
|
className="input"
|
|
value={cfg.local_model_id}
|
|
onChange={(e) => setForm({ ...cfg, local_model_id: e.target.value })}
|
|
>
|
|
{localOptions.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.label} ({m.model}) — free
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h2 className="mb-1 font-semibold">{TIER_LABEL.economy}</h2>
|
|
<p className="mb-4 text-xs text-muted">
|
|
Escalation target for medium complexity, partial diagnostic failures, or follow-up runs.
|
|
</p>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div>
|
|
<label className="label">Gemini economy</label>
|
|
<select
|
|
className="input"
|
|
value={cfg.economy_gemini_id}
|
|
disabled={!data?.available_backends.gemini}
|
|
onChange={(e) => setForm({ ...cfg, economy_gemini_id: e.target.value })}
|
|
>
|
|
{economyOptions.gemini.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.label} — {priceLabel(m)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">DeepSeek economy</label>
|
|
<select
|
|
className="input"
|
|
value={cfg.economy_deepseek_id}
|
|
disabled={!data?.available_backends.deepseek}
|
|
onChange={(e) => setForm({ ...cfg, economy_deepseek_id: e.target.value })}
|
|
>
|
|
{economyOptions.deepseek.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.label} — {priceLabel(m)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">OpenRouter economy</label>
|
|
<select
|
|
className="input"
|
|
value={cfg.economy_openrouter_id}
|
|
disabled={!data?.available_backends.openrouter}
|
|
onChange={(e) => setForm({ ...cfg, economy_openrouter_id: e.target.value })}
|
|
>
|
|
{economyOptions.openrouter.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.label} — {priceLabel(m)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h2 className="mb-1 font-semibold">{TIER_LABEL.premium}</h2>
|
|
<p className="mb-4 text-xs text-muted">
|
|
Used for critical severity, many diagnostic failures, or when economy/local models request escalation.
|
|
</p>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div>
|
|
<label className="label">Gemini premium</label>
|
|
<select
|
|
className="input"
|
|
value={cfg.premium_gemini_id}
|
|
disabled={!data?.available_backends.gemini}
|
|
onChange={(e) => setForm({ ...cfg, premium_gemini_id: e.target.value })}
|
|
>
|
|
{premiumOptions.gemini.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.label} — {priceLabel(m)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">DeepSeek premium</label>
|
|
<select
|
|
className="input"
|
|
value={cfg.premium_deepseek_id}
|
|
disabled={!data?.available_backends.deepseek}
|
|
onChange={(e) => setForm({ ...cfg, premium_deepseek_id: e.target.value })}
|
|
>
|
|
{premiumOptions.deepseek.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.label} — {priceLabel(m)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">OpenRouter premium</label>
|
|
<select
|
|
className="input"
|
|
value={cfg.premium_openrouter_id}
|
|
disabled={!data?.available_backends.openrouter}
|
|
onChange={(e) => setForm({ ...cfg, premium_openrouter_id: e.target.value })}
|
|
>
|
|
{premiumOptions.openrouter.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.label} — {priceLabel(m)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card grid gap-4 md:grid-cols-3">
|
|
<div>
|
|
<label className="label">Cloud provider priority</label>
|
|
<select
|
|
className="input"
|
|
value={cfg.cloud_provider_order.join(",")}
|
|
onChange={(e) =>
|
|
setForm({ ...cfg, cloud_provider_order: e.target.value.split(",") })
|
|
}
|
|
>
|
|
{PROVIDER_ORDER_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">Max tier (cost cap)</label>
|
|
<select
|
|
className="input"
|
|
value={cfg.max_tier}
|
|
onChange={(e) => setForm({ ...cfg, max_tier: e.target.value })}
|
|
>
|
|
<option value="local">Local only</option>
|
|
<option value="economy">Up to economy</option>
|
|
<option value="premium">Up to premium</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={cfg.auto_escalate}
|
|
onChange={(e) => setForm({ ...cfg, auto_escalate: e.target.checked })}
|
|
className="rounded"
|
|
/>
|
|
Auto-escalate when local/economy is uncertain
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{msg && <div className="text-sm text-muted">{msg}</div>}
|
|
<button className="btn-primary" disabled={saving}>
|
|
{saving ? "Saving…" : "Save routing config"}
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{liveModels && (
|
|
<div className="card mt-8">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="font-semibold">Live models from each API</h2>
|
|
<button type="button" className="btn-secondary text-sm" onClick={() => setShowLive(!showLive)}>
|
|
{showLive ? "Hide" : "Show all"}
|
|
</button>
|
|
</div>
|
|
{!showLive ? (
|
|
<p className="text-sm text-muted">
|
|
Ollama: {liveModels.providers.ollama?.models?.length ?? 0} · Gemini:{" "}
|
|
{liveModels.providers.gemini?.models?.length ?? 0} · DeepSeek:{" "}
|
|
{liveModels.providers.deepseek?.models?.length ?? 0} · OpenRouter:{" "}
|
|
{liveModels.providers.openrouter?.models?.length ?? 0}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{Object.entries(liveModels.providers).map(([name, info]) => (
|
|
<div key={name}>
|
|
<h3 className="mb-2 text-sm font-medium capitalize">
|
|
{name}{" "}
|
|
<span className={info.available ? "text-good" : "text-muted"}>
|
|
({info.available ? `${info.models.length} models` : info.error || "unavailable"})
|
|
</span>
|
|
</h3>
|
|
{info.note && <p className="mb-2 text-xs text-muted">{info.note}</p>}
|
|
<ul className="max-h-48 overflow-y-auto text-xs text-muted">
|
|
{info.models.map((m) => (
|
|
<li key={m.id} className="font-mono">
|
|
{m.id}
|
|
{m.label !== m.id ? ` — ${m.label}` : ""}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</AppShell>
|
|
);
|
|
}
|