Initial commit: Agentic OS troubleshooting platform
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>
This commit is contained in:
368
frontend/app/models/page.tsx
Normal file
368
frontend/app/models/page.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user