diff options
| -rw-r--r-- | src/core/metrics/format.test.ts | 54 | ||||
| -rw-r--r-- | src/core/metrics/format.ts | 28 | ||||
| -rw-r--r-- | src/core/metrics/index.ts | 9 | ||||
| -rw-r--r-- | src/core/metrics/place.test.ts | 62 | ||||
| -rw-r--r-- | src/core/metrics/place.ts | 29 | ||||
| -rw-r--r-- | src/core/metrics/types.ts | 17 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 35 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 36 |
8 files changed, 259 insertions, 11 deletions
diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts index 9881e50..77c5204 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -1,6 +1,12 @@ import type { StepId, StepMetrics, TurnMetrics } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { computeTps, viewStepMetrics, viewTurnMetrics } from "./format"; +import { + computeCachePct, + computeTps, + viewCacheRate, + viewStepMetrics, + viewTurnMetrics, +} from "./format"; describe("computeTps", () => { it("null when elapsed missing", () => { @@ -197,3 +203,49 @@ describe("viewTurnMetrics", () => { expect(view.tps).toBe("54 tok/s"); }); }); + +describe("computeCachePct", () => { + it("is cacheReadTokens / inputTokens as a rounded percentage", () => { + expect(computeCachePct({ inputTokens: 2737, outputTokens: 10, cacheReadTokens: 2560 })).toBe( + 94, + ); + expect(computeCachePct({ inputTokens: 2669, outputTokens: 10, cacheReadTokens: 384 })).toBe(14); + }); + + it("is 0 when cacheReadTokens absent (legitimate miss, not missing data)", () => { + expect(computeCachePct({ inputTokens: 1000, outputTokens: 50 })).toBe(0); + }); + + it("is 0 when there are no input tokens (guard divide-by-zero)", () => { + expect(computeCachePct({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 5 })).toBe(0); + }); + + it("clamps to 100 if read somehow exceeds input", () => { + expect(computeCachePct({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 })).toBe(100); + }); +}); + +describe("viewCacheRate", () => { + it("success level for a high hit rate (>= 66)", () => { + const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 93 }); + expect(v.pct).toBe(93); + expect(v.level).toBe("success"); + expect(v.isHit).toBe(true); + }); + + it("warning level for a mid hit rate (33..65)", () => { + const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 54 }); + expect(v.pct).toBe(54); + expect(v.level).toBe("warning"); + }); + + it("error level for a low hit rate (< 33), including a legitimate 0%", () => { + expect(viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 14 }).level).toBe( + "error", + ); + const miss = viewCacheRate({ inputTokens: 1000, outputTokens: 50 }); + expect(miss.pct).toBe(0); + expect(miss.level).toBe("error"); + expect(miss.isHit).toBe(false); + }); +}); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index 3a4078c..cc86976 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -1,5 +1,5 @@ import type { StepMetrics, TurnMetrics, Usage } from "@dispatch/wire"; -import type { StepMetricsView, TurnMetricsView } from "./types"; +import type { CacheRateView, StepMetricsView, TurnMetricsView } from "./types"; function formatTokens(n: number): string { return n.toLocaleString("en-US"); @@ -49,6 +49,32 @@ export function viewStepMetrics(step: StepMetrics, index: number): StepMetricsVi }; } +/** + * Cache hit rate as a 0..100 integer percentage: `cacheReadTokens / inputTokens`, + * clamped to [0,1]. Absent cache field counts as 0; a 0% rate is legitimate (not + * missing data). Returns 0 when there are no input tokens. + */ +export function computeCachePct(u: Usage): number { + const read = u.cacheReadTokens ?? 0; + if (u.inputTokens <= 0) return 0; + const rate = read / u.inputTokens; + const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; + return Math.round(clamped * 100); +} + +/** Colour severity for a cache hit percentage (badge colour). */ +function cacheLevel(pct: number): "success" | "warning" | "error" { + if (pct >= 66) return "success"; + if (pct >= 33) return "warning"; + return "error"; +} + +/** Build a view of a cache hit rate (percentage + colour level + hit flag). */ +export function viewCacheRate(u: Usage): CacheRateView { + const pct = computeCachePct(u); + return { pct, level: cacheLevel(pct), isHit: (u.cacheReadTokens ?? 0) > 0 }; +} + /** Build a formatted view of a turn's aggregate metrics. */ export function viewTurnMetrics(turn: TurnMetrics): TurnMetricsView { const total = totalTokens(turn.usage); diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts index 72d825d..6997ab9 100644 --- a/src/core/metrics/index.ts +++ b/src/core/metrics/index.ts @@ -1,4 +1,10 @@ -export { computeTps, viewStepMetrics, viewTurnMetrics } from "./format"; +export { + computeCachePct, + computeTps, + viewCacheRate, + viewStepMetrics, + viewTurnMetrics, +} from "./format"; export { interleaveTurnMetrics } from "./place"; export { applyDurableMetrics, @@ -7,6 +13,7 @@ export { selectOrderedTurnMetrics, } from "./reducer"; export type { + CacheRateView, MetricsRow, MetricsState, StepMetrics, diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts index b6cb877..d94882d 100644 --- a/src/core/metrics/place.test.ts +++ b/src/core/metrics/place.test.ts @@ -2,7 +2,7 @@ import type { StepId, StepMetrics, TurnMetrics } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import type { RenderGroup } from "../chunks"; import { interleaveTurnMetrics } from "./place"; -import type { TurnMetricsEntry } from "./types"; +import type { MetricsRow, TurnMetricsEntry } from "./types"; function userGroup(seq: number, text: string): RenderGroup { return { @@ -467,3 +467,63 @@ describe("interleaveTurnMetrics", () => { expectStepMetricsAt(rows, 3, "s2", 1); }); }); + +describe("interleaveTurnMetrics — cumulative usage (cache total)", () => { + function turnMetricsRows(rows: readonly MetricsRow[]) { + return rows.filter((r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => { + return r.kind === "turn-metrics"; + }); + } + + function cacheEntry( + turnId: string, + inputTokens: number, + outputTokens: number, + cacheReadTokens: number, + ): TurnMetricsEntry { + const total: TurnMetrics = { + turnId, + usage: { inputTokens, outputTokens, cacheReadTokens }, + steps: [], + }; + return { turnId, steps: [], total }; + } + + it("turn-metrics row carries this turn's usage and the running cumulative", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1")], + [makeEntry("t1", 1000, 100)], + ); + const tm = turnMetricsRows(rows); + expect(tm).toHaveLength(1); + expect(tm[0]?.turn.turnId).toBe("t1"); + expect(tm[0]?.cumulativeUsage).toEqual({ inputTokens: 1000, outputTokens: 100 }); + }); + + it("accumulates cache read + input across turns (chat total)", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], + ); + const tm = turnMetricsRows(rows); + expect(tm).toHaveLength(2); + // turn 1: only its own usage + expect(tm[0]?.cumulativeUsage.inputTokens).toBe(2669); + expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(384); + // turn 2: sum of both (input 5406, cacheRead 2944 → matches the backend's 54% example) + expect(tm[1]?.cumulativeUsage.inputTokens).toBe(5406); + expect(tm[1]?.cumulativeUsage.cacheReadTokens).toBe(2944); + }); + + it("an in-flight (total=null) turn does not contribute to the cumulative", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 1000, 10, 500), makeProgressiveEntry("t2", [makeStep("s1", 200, 5)])], + ); + const tm = turnMetricsRows(rows); + // only the finalized turn emits a turn-metrics row; its cumulative is just itself + expect(tm).toHaveLength(1); + expect(tm[0]?.cumulativeUsage.inputTokens).toBe(1000); + expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500); + }); +}); diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts index 2481a16..fc30df0 100644 --- a/src/core/metrics/place.ts +++ b/src/core/metrics/place.ts @@ -1,3 +1,4 @@ +import type { Usage } from "@dispatch/wire"; import type { RenderGroup } from "../chunks"; import type { MetricsRow, TurnMetricsEntry } from "./types"; @@ -7,6 +8,19 @@ function groupStepId(g: RenderGroup): string | undefined { return c.type === "tool-call" || c.type === "tool-result" ? c.stepId : undefined; } +/** Element-wise sum of two token usages (cache fields included only when nonzero). */ +function addUsage(a: Usage, b: Usage): Usage { + const out: Usage = { + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + }; + const read = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); + const write = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0); + if (read > 0) (out as { cacheReadTokens?: number }).cacheReadTokens = read; + if (write > 0) (out as { cacheWriteTokens?: number }).cacheWriteTokens = write; + return out; +} + /** * Interleave turn metrics into the rendered transcript. * @@ -64,6 +78,15 @@ export function interleaveTurnMetrics( } } + // Running cumulative usage across finalized turns (conversation total at each + // entry index), for the per-turn "chat total" cache rate. + const cumulativeByEntry: Usage[] = []; + let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 }; + for (const e of entries) { + if (e.total !== null) runningUsage = addUsage(runningUsage, e.total.usage); + cumulativeByEntry.push(runningUsage); + } + const rows: MetricsRow[] = []; const firstUserIdx = segmentStarts[0] ?? 0; @@ -143,7 +166,11 @@ export function interleaveTurnMetrics( rows.push({ kind: "step-metrics", step, index: stepIndex }); } if (entry.total !== null) { - rows.push({ kind: "turn-metrics", turn: entry.total }); + rows.push({ + kind: "turn-metrics", + turn: entry.total, + cumulativeUsage: cumulativeByEntry[seg] ?? entry.total.usage, + }); } } diff --git a/src/core/metrics/types.ts b/src/core/metrics/types.ts index 2b26e8d..cf2511c 100644 --- a/src/core/metrics/types.ts +++ b/src/core/metrics/types.ts @@ -47,7 +47,22 @@ export interface TurnMetricsEntry { export type MetricsRow = | { readonly kind: "group"; readonly group: RenderGroup } | { readonly kind: "step-metrics"; readonly step: StepMetrics; readonly index: number } - | { readonly kind: "turn-metrics"; readonly turn: TurnMetrics }; + | { + readonly kind: "turn-metrics"; + readonly turn: TurnMetrics; + /** Cumulative usage across all finalized turns up to and including this one. */ + readonly cumulativeUsage: Usage; + }; + +/** Formatted cache hit-rate view: percentage + colour severity + hit flag. */ +export interface CacheRateView { + /** Cache hit rate as a 0..100 integer percentage (`cacheReadTokens / inputTokens`). */ + readonly pct: number; + /** Colour severity for a badge (maps to DaisyUI `badge-{level}`). */ + readonly level: "success" | "warning" | "error"; + /** Whether any input tokens were served from cache. */ + readonly isHit: boolean; +} /** Formatted per-step view for display. */ export interface StepMetricsView { diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 4abf717..ddec388 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -326,6 +326,41 @@ describe("ChatView", () => { expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); }); + it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [], + total: { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 }, + steps: [], + }, + }, + ]; + + const { container } = render(ChatView, { props: { chunks, turnMetrics } }); + + expect(screen.getByText("Last turn:")).toBeInTheDocument(); + expect(screen.getByText("Chat Total:")).toBeInTheDocument(); + // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge + const badges = container.querySelectorAll(".badge"); + expect(badges).toHaveLength(2); + for (const b of badges) { + expect(b.textContent).toBe("93%"); + expect(b.classList.contains("badge-success")).toBe(true); + } + }); + it("renders step-metrics inline after tool group", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false }, diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index ba6e961..3d1421d 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -1,6 +1,18 @@ <script lang="ts"> import { groupRenderedChunks, type RenderedChunk } from "../index"; - import { interleaveTurnMetrics, viewStepMetrics, viewTurnMetrics, type TurnMetricsEntry } from "../../../core/metrics"; + import { + interleaveTurnMetrics, + viewCacheRate, + viewStepMetrics, + viewTurnMetrics, + type TurnMetricsEntry, + } from "../../../core/metrics"; + + const badgeClass = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + } as const; let { chunks, @@ -132,12 +144,26 @@ </div> {:else if row.kind === "turn-metrics"} {@const turnView = viewTurnMetrics(row.turn)} + {@const lastCache = viewCacheRate(row.turn.usage)} + {@const chatCache = viewCacheRate(row.cumulativeUsage)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> - <div class="text-xs opacity-70"> - turn · {turnView.tokensLabel} ({turnView.breakdown}) - {#if turnView.tps} · {turnView.tps}{/if} - {#if turnView.duration} · {turnView.duration}{/if} + <div class="flex flex-col gap-1 text-xs"> + <div class="opacity-70"> + turn · {turnView.tokensLabel} ({turnView.breakdown}) + {#if turnView.tps} · {turnView.tps}{/if} + {#if turnView.duration} · {turnView.duration}{/if} + </div> + <div class="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span class="flex items-center gap-1"> + <span class="opacity-70">Last turn:</span> + <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span> + </span> + <span class="flex items-center gap-1"> + <span class="opacity-70">Chat Total:</span> + <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span> + </span> + </div> </div> </div> </div> |
