From 6afffbcbf2440ea9a238d8263dc2c60a1db42a3b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:48:54 +0000 Subject: [PATCH] Profiling page: per-turn phase timings, live in the web dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine already tracks where each turn's wall time goes (expert-disk service, I/O wait, expert matmul, attention, lm_head) — it just only spoke at exit or under PROF=1. Stream it instead: - glm.c: mux serve emits a per-turn "PROF" protocol line next to TIERS/HITS (window deltas per request, same convention as the STAT hit%); the phase window base is now always captured (a few loads per request). - openai_server.py: parses PROF into a 120-turn rolling window and serves it at /profile (read-only, same trust level as /health). - web: new Profiling tab — stat tiles (tok/s, wall, tokens/forward, disk service), wall-time composition bars for the last turn and the window, per-turn throughput and stacked phase columns with hover readouts, and a table of recent turns. Disk service is shown apart from the stack: it overlaps with compute, so only the I/O wait the compute thread felt counts inside wall time. Phase colours are a CVD-validated set with gaps + legend + table so identity never rides on colour alone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WhTmF8yvEBgSkUKSVfZF7P --- README.md | 5 +- c/glm.c | 15 +++- c/openai_server.py | 23 +++++ c/tests/test_openai_server.py | 33 +++++++ web/README.md | 7 +- web/src/App.tsx | 7 +- web/src/Profiling.tsx | 165 ++++++++++++++++++++++++++++++++++ web/src/index.css | 36 ++++++++ web/src/lib/api.test.ts | 15 +++- web/src/lib/api.ts | 23 +++++ 10 files changed, 321 insertions(+), 8 deletions(-) create mode 100644 web/src/Profiling.tsx diff --git a/README.md b/README.md index 2e22048..3561d81 100644 --- a/README.md +++ b/README.md @@ -540,9 +540,10 @@ What you get: - **Chat** with live metrics: a flashing token counter while generating, then tok/s, time-to-first-token, prompt→completion counts and queue wait; - **Runtime panel**: your hardware (CPU, GPUs + VRAM, RAM, cores), the scheduler, and the live expert-tier bar — how many of the 19,456 experts sit in VRAM / RAM / disk right now; -- **Brain**: the whole model as a 76×256 cortex, one cell per expert. Colour = tier, brightness = routing heat, and the experts routed in each turn flash white and decay — you watch the model think. Hover any cell for its tier, heat and [measured topic affinity](https://github.com/JustVugg/colibri/issues/175) (specialists for code, Chinese, math, law… live in layers 11–22). +- **Brain**: the whole model as a 76×256 cortex, one cell per expert. Colour = tier, brightness = routing heat, and the experts routed in each turn flash white and decay — you watch the model think. Hover any cell for its tier, heat and [measured topic affinity](https://github.com/JustVugg/colibri/issues/175) (specialists for code, Chinese, math, law… live in layers 11–22); +- **Profiling**: where each turn's wall time went — I/O wait vs expert matmul vs attention vs LM head — as stacked per-turn bars, plus throughput history, tokens-per-forward batching, and a table of the recent turns. The same phase timers behind the `PROFILE` line, streamed live. -The dashboard talks to the engine over two tiny protocol lines (`TIERS`, `EMAP`/`HITS`) and plain JSON endpoints — nothing heavier than the engine itself. +The dashboard talks to the engine over a few tiny protocol lines (`TIERS`, `EMAP`/`HITS`, `PROF`) and plain JSON endpoints — nothing heavier than the engine itself. ## Got a better machine? Try it — here's what to expect diff --git a/c/glm.c b/c/glm.c index 872057e..5412cce 100644 --- a/c/glm.c +++ b/c/glm.c @@ -4874,7 +4874,8 @@ typedef struct { float temp, top_p; double started; uint64_t hits0, miss0; - ProfBase pb; /* PROF=1: window start (same convention as hits0) */ + ProfBase pb; /* phase-time window start (same convention as hits0): + feeds the PROF protocol line and the PROF=1 report */ } ServeReq; static void mux_data(Tok *T, unsigned long long id, int token){ @@ -4891,6 +4892,16 @@ static void mux_done(Model *m, ServeCtx *sc, ServeReq *r){ tiers_emit(m); emap_emit(m); hits_emit(m); + /* PROF: per-turn phase timings for the dashboard profiling page — + * "PROF ". + * With KV_SLOTS>1 concurrent slots share the batched forwards, so the shares + * describe the whole engine over the window, not the single request (same + * convention as the STAT hit% below). */ + printf("PROF %.3f %d %d %.3f %.3f %.3f %.3f %.3f %llu\n",dt, + r->prompt_tokens,r->emitted, + m->t_edisk-r->pb.edisk,m->t_ewait-r->pb.ewait,m->t_emm-r->pb.emm, + m->t_attn-r->pb.attn,m->t_head-r->pb.head, + (unsigned long long)(m->n_fw-r->pb.n_fw)); printf("DONE %llu STAT %d %.2f %.1f %.2f %d %d\n",r->id,r->emitted, r->emitted/dt,(dh+dm)>0?100.0*dh/(dh+dm):0.0,rss_gb(), r->prompt_tokens,r->length_limited); @@ -4961,7 +4972,7 @@ static int mux_submit(Model *m, Tok *T, ServeCtx *ctx, ServeReq *req, int nctx, ServeReq *r=&req[sub.slot]; memset(r,0,sizeof(*r)); r->id=sub.id; r->maximum=sub.max_tokens; r->temp=sub.temperature; r->top_p=sub.top_p; r->prompt_tokens=nt; r->started=now_s(); r->hits0=m->hits; r->miss0=m->miss; - if(g_prof) prof_base(m,&r->pb); + prof_base(m,&r->pb); /* a few loads: cheap enough to always track */ int room=maxctx-sc->len-1; if(r->maximum>room){r->maximum=room; r->length_limited=1;} g_temp=r->temp; g_nuc=r->top_p; int next=pick_tok(logit,m->c.vocab,-1); free(logit); diff --git a/c/openai_server.py b/c/openai_server.py index ba19e43..d55fbc8 100644 --- a/c/openai_server.py +++ b/c/openai_server.py @@ -27,6 +27,7 @@ HERE = Path(__file__).resolve().parent END = b"\x01\x01END\x01\x01\n" READY = b"\x01\x01READY\x01\x01\n" MAX_BODY = 4 << 20 +PROFILE_TURNS = 120 # rolling window of per-turn PROF snapshots kept for /profile DEFAULT_CORS_ORIGINS = ( "http://127.0.0.1:8000", "http://localhost:8000", @@ -473,6 +474,8 @@ class Engine: self.emap = None self.hits = None self.hits_seq = 0 # latest "TIERS" snapshot from the engine + self.profile = collections.deque(maxlen=PROFILE_TURNS) # per-turn phase timings + self.profile_seq = 0 read_engine_turn(self.process.stdout, READY, lambda _: None) self.dispatcher = threading.Thread(target=self._dispatch_stdout, name="colibri-stdout", daemon=True) @@ -550,6 +553,20 @@ class Engine: elif kind == "HITS" and len(fields) == 4: self.hits = fields[3] self.hits_seq += 1 + elif kind == "PROF" and len(fields) >= 10: + # per-turn phase timings: where the engine spent this turn's wall time + self.profile.append({ + "wall_s": float(fields[1]), + "prompt_tokens": int(fields[2]), + "completion_tokens": int(fields[3]), + "expert_disk_s": float(fields[4]), + "expert_wait_s": float(fields[5]), + "expert_matmul_s": float(fields[6]), + "attention_s": float(fields[7]), + "lm_head_s": float(fields[8]), + "forwards": int(fields[9]), + }) + self.profile_seq += 1 elif kind == "TIERS" and len(fields) >= 6: self.tiers = {"vram": int(fields[1]), "ram": int(fields[2]), "disk": int(fields[3]), "vram_gb": float(fields[4]), @@ -782,6 +799,12 @@ class APIHandler(BaseHTTPRequestHandler): payload["seq"] = eng.hits_seq self.send_json(200, payload, request_id) return + if path == "/profile": + eng = self.server.engine + payload = {"seq": getattr(eng, "profile_seq", 0) if eng else 0, + "turns": list(getattr(eng, "profile", ()) or ()) if eng else []} + self.send_json(200, payload, request_id) + return if self.serve_static(path): return self.require_auth() diff --git a/c/tests/test_openai_server.py b/c/tests/test_openai_server.py index f8aec86..4cda9ef 100644 --- a/c/tests/test_openai_server.py +++ b/c/tests/test_openai_server.py @@ -380,6 +380,25 @@ class DispatcherTest(unittest.TestCase): engine.close() self.assertEqual(chunks, ["é"]) + def test_records_profile_snapshots_from_prof_lines(self): + def respond(process, frame): + request_id = frame.split()[1] + process.stdout.feed(b"DATA " + request_id + b" 2\nok\n") + process.stdout.feed(b"PROF 2.500 7 12 0.400 0.100 0.900 0.600 0.200 15\n") + process.stdout.feed(b"DONE " + request_id + b" STAT 12 4.8 0 1.0 7 0\n") + + process = FakeProcess(respond) + with patch("openai_server.subprocess.Popen", return_value=process): + engine = Engine("glm", "model") + engine.generate("hello", 16, 0.7, 0.9, lambda _: None) + engine.close() + self.assertEqual(engine.profile_seq, 1) + self.assertEqual(list(engine.profile), [{ + "wall_s": 2.5, "prompt_tokens": 7, "completion_tokens": 12, + "expert_disk_s": 0.4, "expert_wait_s": 0.1, "expert_matmul_s": 0.9, + "attention_s": 0.6, "lm_head_s": 0.2, "forwards": 15, + }]) + def test_cancels_generation_after_consumer_disconnects(self): request_id = None @@ -443,6 +462,20 @@ class HTTPTest(unittest.TestCase): self.assertIn("queued", scheduler) self.assertEqual(health["kv_slots"], 2) + def test_profile_reports_recent_turns_without_auth(self): + with urlopen(self.base + "/profile", timeout=2) as response: + self.assertEqual(json.load(response), {"seq": 0, "turns": []}) + turn = {"wall_s": 2.5, "prompt_tokens": 7, "completion_tokens": 12, + "expert_disk_s": 0.4, "expert_wait_s": 0.1, "expert_matmul_s": 0.9, + "attention_s": 0.6, "lm_head_s": 0.2, "forwards": 15} + self.engine.profile = [turn] + self.engine.profile_seq = 1 + try: + with urlopen(self.base + "/profile", timeout=2) as response: + self.assertEqual(json.load(response), {"seq": 1, "turns": [turn]}) + finally: + del self.engine.profile, self.engine.profile_seq + def test_browser_preflight(self): request = Request(self.base + "/v1/chat/completions", method="OPTIONS", headers={ "Origin": "http://localhost:5173", diff --git a/web/README.md b/web/README.md index c867d01..fe502ae 100644 --- a/web/README.md +++ b/web/README.md @@ -17,9 +17,14 @@ npm test npm run build ``` +Besides Chat and Brain, the **Profiling** tab charts where the engine spent +each turn's wall time (I/O wait, expert matmul, attention, LM head) from the +server's `/profile` endpoint — a rolling window of per-turn `PROF` snapshots +emitted by the engine. + The test suite stays browser-light: API requests use a mocked `fetch`, while runtime capability and storage behavior are covered through pure helpers. It -checks that `/health` is resolved next to (not below) the OpenAI `/v1` prefix, +checks that `/health` and `/profile` are resolved next to (not below) the OpenAI `/v1` prefix, supports both boolean and numeric `scheduler.active` responses, and sends the colibrì-specific `cache_slot` field only when KV-slot support was advertised. diff --git a/web/src/App.tsx b/web/src/App.tsx index d4e0e41..a33cc88 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -31,6 +31,7 @@ import { Textarea } from "@/components/ui/textarea" import { getHealth, listModels, streamChat, type ChatMessage, type HealthResponse, type StreamChatResult } from "@/lib/api" import { activeRequests, supportsCacheSlots } from "@/lib/runtime" import { Brain } from "./Brain" +import { Profiling } from "./Profiling" import { persistPublicSettings, stored } from "@/lib/storage" import { cn } from "@/lib/utils" @@ -73,7 +74,7 @@ export default function App() { const [totalTokens, setTotalTokens] = useState({ prompt: 0, completion: 0 }) const [connecting, setConnecting] = useState(false) const [connected, setConnected] = useState(false) - const [view, setView] = useState<"chat" | "brain">("chat") + const [view, setView] = useState<"chat" | "brain" | "profiling">("chat") const [error, setError] = useState("") const autoConnected = useRef(false) const abortRef = useRef(null) @@ -314,6 +315,7 @@ export default function App() {
+
{loading && tokenCount > 0 ? {tokenCount} tokens : null} @@ -326,7 +328,8 @@ export default function App() {
- {view === "brain" ? : <> + {view === "brain" ? + : view === "profiling" ? : <>
{!messages.length ? ( diff --git a/web/src/Profiling.tsx b/web/src/Profiling.tsx new file mode 100644 index 0000000..9fdaebb --- /dev/null +++ b/web/src/Profiling.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from "react" +import { Activity, Gauge, HardDrive, Timer } from "lucide-react" + +import { getProfile, type ProfileTurn } from "@/lib/api" + +/* Wall-time phases stacked per turn. The order is the palette's CVD-safe slot + * order (validated as a set on this surface) — identity never leans on colour + * alone: segments keep 2px gaps, the legend is always shown and the table + * carries the exact numbers. Disk *service* time is reported separately: it + * runs on I/O threads overlapped with compute, so only the stall the compute + * thread actually felt (I/O wait) belongs inside the wall-time stack. */ +const PHASES = [ + { key: "expert_wait_s", name: "I/O wait", color: "#3987e5" }, + { key: "expert_matmul_s", name: "Expert matmul", color: "#199e70" }, + { key: "attention_s", name: "Attention", color: "#c98500" }, + { key: "lm_head_s", name: "LM head", color: "#008300" }, + { key: "other_s", name: "Other", color: "#9085e9" }, +] as const + +interface Turn extends ProfileTurn { other_s: number; toks: number } + +const derive = (turn: ProfileTurn): Turn => ({ + ...turn, + other_s: Math.max(0, turn.wall_s - turn.expert_wait_s - turn.expert_matmul_s - turn.attention_s - turn.lm_head_s), + toks: turn.wall_s > 0 ? turn.completion_tokens / turn.wall_s : 0, +}) + +const seconds = (value: number) => (value >= 10 ? value.toFixed(1) : value.toFixed(2)) + "s" + +function ShareBar({ label, turns }: { label: string; turns: Turn[] }) { + const total = turns.reduce((sum, turn) => sum + turn.wall_s, 0) + const parts = PHASES.map((phase) => ({ ...phase, value: turns.reduce((sum, turn) => sum + turn[phase.key], 0) })) + return ( +
+
{label}{seconds(total)}
+
`${part.name} ${seconds(part.value)}`).join(", ")}> + {parts.map((part) => { + const share = total > 0 ? part.value / total : 0 + return share > 0.001 ? ( + + {share >= 0.09 ? `${Math.round(100 * share)}%` : ""} + + ) : null + })} +
+
+ ) +} + +/* Column chart over the recent turns; oldest on the left. Stacked mode draws the + * wall-time composition, plain mode a single series (no legend — the title names it). */ +function TurnColumns({ turns, stacked, height, format }: { turns: Turn[]; stacked: boolean; height: number; format: (turn: Turn) => string }) { + const [hover, setHover] = useState(null) + const peak = Math.max(...turns.map((turn) => (stacked ? turn.wall_s : turn.toks)), 1e-9) + const gap = 2 + const width = Math.max(1, (100 - gap * (turns.length - 1)) / turns.length) + return ( +
setHover(null)}> + +
+ {turns.length > 1 ? `${turns.length} turns · oldest → newest` : "1 turn"} + {hover !== null && turns[hover] ? format(turns[hover]) : `peak ${stacked ? seconds(peak) : peak.toFixed(1) + " tok/s"}`} +
+
+ ) +} + +export function Profiling({ baseUrl, apiKey, connected }: { baseUrl: string; apiKey: string; connected: boolean }) { + const [turns, setTurns] = useState([]) + + useEffect(() => { + if (!connected) return + let disposed = false + const poll = async () => { + if (document.visibilityState === "hidden") return + try { + const result = await getProfile(baseUrl, apiKey) + if (!disposed) setTurns(result.turns.map(derive)) + } catch { /* engine busy or restarting — keep the last snapshot */ } + } + void poll() + const timer = window.setInterval(() => void poll(), 2000) + return () => { disposed = true; window.clearInterval(timer) } + }, [baseUrl, apiKey, connected]) + + const latest = turns[turns.length - 1] + const recent = turns.slice(-40) + const diskService = turns.reduce((sum, turn) => sum + turn.expert_disk_s, 0) + + return ( +
+
+
Profiling — where the engine spends each turn
+
+ {PHASES.map((phase) => {phase.name})} +
+
+ + {!latest ? ( +

{connected ? "No profiled turns yet — send a chat message and the breakdown appears here." : "Connect to the engine to collect per-turn timings."}

+ ) : ( + <> +
+
Last turn{latest.toks.toFixed(1)}tok/s
+
Wall time{seconds(latest.wall_s)}{latest.prompt_tokens} → {latest.completion_tokens} tokens
+
Batching{latest.forwards > 0 ? (latest.completion_tokens / latest.forwards).toFixed(2) : "—"}tokens / forward
+
Disk service{seconds(latest.expert_disk_s)}overlapped with compute
+
+ +
+ + {turns.length > 1 ? : null} +
+ +
+
+
Throughput per turn (tok/s)
+ `${turn.toks.toFixed(1)} tok/s · ${turn.completion_tokens} tokens`} /> +
+
+
Turn wall time by phase (s)
+ `${seconds(turn.wall_s)} · ${PHASES.map((phase) => `${phase.name} ${seconds(turn[phase.key])}`).join(" · ")}`} /> +
+
+ +
+ + {PHASES.map((phase) => )} + + {recent.slice().reverse().map((turn, index) => ( + + + + + + {PHASES.map((phase) => )} + + + ))} + +
TurnTokenstok/sWall{phase.name}Disk service
{turns.length - index}{turn.prompt_tokens} → {turn.completion_tokens}{turn.toks.toFixed(1)}{seconds(turn.wall_s)}{seconds(turn[phase.key])}{seconds(turn.expert_disk_s)}
+ {diskService > 0 ?

Disk service is time spent reading experts on I/O threads; it overlaps with compute, so only the I/O wait the compute thread felt counts inside the wall-time stack. With multiple KV sessions the shares describe the whole engine over the turn's window.

: null} +
+ + )} +
+ ) +} diff --git a/web/src/index.css b/web/src/index.css index b428805..8d777f4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -151,3 +151,39 @@ button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus- .brain-tip-spec { color: var(--primary); font-weight: 700; } .brain-tip-spec small, .brain-tip-aff { color: #8b9aa3; font-weight: 400; } .brain-tip-aff { font-size: 10px; } + +/* ---- Profiling page ---- */ +.prof-page { flex: 1; display: flex; flex-direction: column; gap: 16px; padding: 18px 22px; overflow: auto; } +.prof-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; } +.prof-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: #a5b0b4; align-items: center; } +.prof-legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; margin-right: 5px; vertical-align: -1px; } +.prof-tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } +.prof-tiles > div { display: grid; gap: 4px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 11px; background: var(--card); } +.prof-tiles span { display: flex; align-items: center; gap: 5px; color: #7f8d92; font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.prof-tiles strong { font: 600 22px ui-monospace, SFMono-Regular, monospace; font-variant-numeric: tabular-nums; } +.prof-tiles small { color: var(--muted-foreground); font-size: 10px; } +.prof-shares { display: grid; gap: 10px; padding: 14px; border: 1px solid var(--border); border-radius: 11px; background: var(--card); } +.prof-share { display: grid; gap: 6px; } +.prof-share-head { display: flex; justify-content: space-between; color: #a5b0b4; font-size: 11px; font-weight: 600; } +.prof-share-head code { color: var(--foreground); font-size: 11px; font-variant-numeric: tabular-nums; } +.prof-share-bar { display: flex; gap: 2px; height: 22px; border-radius: 6px; overflow: hidden; } +.prof-share-bar span { display: grid; place-items: center; min-width: 0; overflow: hidden; color: #06090b; font-size: 10px; font-weight: 700; font-variant-numeric: tabular-nums; transition: width .5s ease; } +.prof-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.prof-chart { display: grid; gap: 8px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 11px; background: var(--card); } +.prof-chart-title { color: #a5b0b4; font-size: 11px; font-weight: 600; } +.prof-plot svg { display: block; width: 100%; height: 120px; } +.prof-grid { stroke: var(--border); stroke-width: .3; } +.prof-plot-foot { display: flex; justify-content: space-between; gap: 8px; margin-top: 6px; color: #7f8d92; font-size: 10px; } +.prof-plot-foot code { color: var(--foreground); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.prof-table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 11px; background: var(--card); } +.prof-table { width: 100%; border-collapse: collapse; font-size: 11px; font-variant-numeric: tabular-nums; } +.prof-table th { padding: 9px 12px; border-bottom: 1px solid var(--border); color: #7f8d92; font-size: 9px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; text-align: left; white-space: nowrap; } +.prof-table th i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; margin-right: 5px; vertical-align: -1px; } +.prof-table td { padding: 7px 12px; border-bottom: 1px solid var(--border); color: #c3ccd0; white-space: nowrap; } +.prof-table tbody tr:last-child td { border-bottom: 0; } +.prof-note { margin: 0; padding: 10px 12px; color: var(--muted-foreground); font-size: 10px; line-height: 1.5; border-top: 1px solid var(--border); } +@media (max-width: 900px) { + .prof-page { padding: 10px; } + .prof-tiles { grid-template-columns: 1fr 1fr; } + .prof-charts { grid-template-columns: 1fr; } +} diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 29e7ede..9239828 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { extractSSE, getHealth, serverEndpoint, streamChat } from "./api" +import { extractSSE, getHealth, getProfile, serverEndpoint, streamChat } from "./api" afterEach(() => vi.unstubAllGlobals()) @@ -36,6 +36,19 @@ describe("runtime API", () => { headers: expect.objectContaining({ Authorization: "Bearer secret" }), })) }) + + it("requests the profiling history next to the OpenAI v1 prefix", async () => { + const turn = { + wall_s: 2.5, prompt_tokens: 7, completion_tokens: 12, + expert_disk_s: 0.4, expert_wait_s: 0.1, expert_matmul_s: 0.9, + attention_s: 0.6, lm_head_s: 0.2, forwards: 15, + } + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ seq: 1, turns: [turn] }))) + vi.stubGlobal("fetch", fetchMock) + + await expect(getProfile("http://localhost:8000/v1/")).resolves.toEqual({ seq: 1, turns: [turn] }) + expect(fetchMock).toHaveBeenCalledWith("http://localhost:8000/profile", expect.anything()) + }) }) describe("chat request extensions", () => { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 195f138..11bb9c5 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -49,6 +49,23 @@ export interface HealthResponse { hwinfo?: HwinfoHealth } +export interface ProfileTurn { + wall_s: number + prompt_tokens: number + completion_tokens: number + expert_disk_s: number + expert_wait_s: number + expert_matmul_s: number + attention_s: number + lm_head_s: number + forwards: number +} + +export interface ProfileResponse { + seq: number + turns: ProfileTurn[] +} + export interface TokenUsage { prompt_tokens: number completion_tokens: number @@ -100,6 +117,12 @@ export async function getHealth(baseUrl: string, apiKey = "", signal?: AbortSign return (await response.json()) as HealthResponse } +export async function getProfile(baseUrl: string, apiKey = "", signal?: AbortSignal): Promise { + const response = await fetch(serverEndpoint(baseUrl, "profile"), { headers: headers(apiKey), signal }) + if (!response.ok) throw new Error(await responseError(response)) + return (await response.json()) as ProfileResponse +} + export function extractSSE(buffer: string) { const frames = buffer.split(/\r?\n\r?\n/) const rest = frames.pop() || ""