summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--src/core/metrics/format.test.ts54
-rw-r--r--src/core/metrics/format.ts28
-rw-r--r--src/core/metrics/index.ts9
-rw-r--r--src/core/metrics/place.test.ts62
-rw-r--r--src/core/metrics/place.ts29
-rw-r--r--src/core/metrics/types.ts17
-rw-r--r--src/features/chat/ui.test.ts35
-rw-r--r--src/features/chat/ui/ChatView.svelte36
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>