feat(api): prompt-cache health summary endpoint and analytics tab (#8827)

Adds GET /api/usage/cache-health and a Cache Health tab under
/dashboard/analytics, both backed by a pure summary over the cache columns
already present in call_logs.

Motivated by a production diagnosis where the aggregate ratio was actively
misleading. The window read 24.8M cached tokens and wrote 9.5M — a
write/read of 0.385, which reads as merely mediocre. The actual shape was
very different: the median call wrote 848 tokens while 18% of the calls
carried 94% of every written token, and two models in the same window sat
at 0.13 (Sonnet) and 0.51 (Opus). Averaging hid all three facts.

So the summary reports what the average cannot: the distribution
(p50/p90/p99), the concentration (how few calls carry how much of the
write), and the per-model split. The heavy-write threshold is relative to
the window (10x the median, floored at 1024) because a cutoff tuned for
130k-token conversations reports nothing at all on 2k-token ones; 1024 is
the minimum Anthropic bills for cache creation, below which a write carries
no signal.

Calls that neither read nor wrote are counted separately from thrash — a
route that does not cache is an absence of caching, not a sick cache — and
only successful calls are summarized, since a 4xx/5xx never reached the
provider cache and would dilute the ratio.

Tests cover the summary (8) and the route (6, against a real SQLite so the
WHERE clause itself is exercised), including that an internal failure
answers 500 without leaking the stack trace, the SQL text or a table name.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-28 03:53:28 -03:00
committed by GitHub
parent c8f1d62de5
commit aa85fa02bb
6 changed files with 886 additions and 2 deletions

View File

@@ -0,0 +1,271 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import Badge from "@/shared/components/Badge";
import { Skeleton } from "@/shared/components/Loading";
import TimeRangeSelector from "@/shared/components/analytics/TimeRangeSelector";
import type { UtilizationTimeRange } from "@/shared/types/utilization";
import { cn } from "@/shared/utils/cn";
type CacheHealthVerdict = "healthy" | "degraded" | "thrash" | "no-data";
type CacheHealthModel = {
model: string;
calls: number;
cacheReadTotal: number;
cacheWriteTotal: number;
writeReadRatio: number;
heavyWriteCalls: number;
};
type CacheHealthResponse = {
totalCalls: number;
cacheReadTotal: number;
cacheWriteTotal: number;
writeReadRatio: number;
warmCalls: number;
coldCalls: number;
rewriteCalls: number;
uncachedCalls: number;
writeP50: number;
writeP90: number;
writeP99: number;
writeMax: number;
heavyWriteCalls: number;
heavyWriteCallShare: number;
heavyWriteTokenShare: number;
heavyWriteThreshold: number;
verdict: CacheHealthVerdict;
byModel: CacheHealthModel[];
timeRange: UtilizationTimeRange;
since: string;
truncated: boolean;
};
type Translator = ((key: string, values?: Record<string, unknown>) => string) & {
has?: (key: string) => boolean;
};
function text(t: Translator, key: string, fallback: string) {
return typeof t.has === "function" && t.has(key) ? t(key) : fallback;
}
const compact = (n: number) => n.toLocaleString(undefined, { maximumFractionDigits: 0 });
const pct = (n: number) => `${(n * 100).toFixed(1)}%`;
function verdictVariant(v: CacheHealthVerdict) {
if (v === "thrash") return "error" as const;
if (v === "degraded") return "warning" as const;
if (v === "healthy") return "success" as const;
return "default" as const;
}
function Metric({ label, value, hint }: { label: string; value: string; hint?: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-text-muted">{label}</span>
<span className="text-2xl font-semibold text-text-main">{value}</span>
{hint ? <span className="text-xs text-text-muted">{hint}</span> : null}
</div>
);
}
export default function CacheHealthTab() {
const t = useTranslations("analytics") as Translator;
const [range, setRange] = useState<UtilizationTimeRange>("24h");
const [data, setData] = useState<CacheHealthResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (r: UtilizationTimeRange) => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/usage/cache-health?range=${r}`);
if (!res.ok) throw new Error(String(res.status));
setData((await res.json()) as CacheHealthResponse);
} catch {
setError("load-failed");
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(range);
}, [load, range]);
if (loading) return <Skeleton className="h-64 w-full" />;
if (error || !data) {
return (
<Card>
<p className="text-sm text-text-muted">
{text(t, "cacheHealthLoadError", "Could not load the prompt-cache summary.")}
</p>
</Card>
);
}
const hasData = data.verdict !== "no-data";
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-text-main">
{text(t, "cacheHealthTitle", "Prompt cache health")}
</h2>
<Badge variant={verdictVariant(data.verdict)}>
{text(t, `cacheHealthVerdict.${data.verdict}`, data.verdict)}
</Badge>
</div>
<TimeRangeSelector value={range} onChange={setRange} />
</div>
<p className="text-sm text-text-muted">
{text(
t,
"cacheHealthIntro",
"A warm loop writes the prefix once and then only pays the delta. When a few calls " +
"carry most of the writes, the prefix is being rebuilt — usually because the client " +
"rewrote its history or alternates between two request shapes."
)}
</p>
{!hasData ? (
<Card>
<p className="text-sm text-text-muted">
{text(t, "cacheHealthEmpty", "No calls with cache accounting in this window.")}
</p>
</Card>
) : (
<>
<Card>
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
<Metric
label={text(t, "cacheHealthRatio", "Write / read")}
value={data.writeReadRatio.toFixed(2)}
hint={text(t, "cacheHealthRatioHint", "below 0.20 is a warm loop")}
/>
<Metric
label={text(t, "cacheHealthRead", "Tokens read")}
value={compact(data.cacheReadTotal)}
/>
<Metric
label={text(t, "cacheHealthWrite", "Tokens written")}
value={compact(data.cacheWriteTotal)}
/>
<Metric
label={text(t, "cacheHealthCalls", "Calls")}
value={compact(data.totalCalls)}
hint={
data.truncated
? text(t, "cacheHealthTruncated", "newest 5000 only")
: `${compact(data.warmCalls)} warm · ${compact(data.coldCalls)} cold`
}
/>
</div>
</Card>
{/* A concentração é o achado que a média esconde — por isso ganha destaque próprio. */}
<Card>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-text-main">
{text(t, "cacheHealthConcentration", "Where the writes are concentrated")}
</h3>
<span className="text-xs text-text-muted">
{text(t, "cacheHealthThreshold", "outlier above")} {compact(data.heavyWriteThreshold)}{" "}
{text(t, "cacheHealthTokens", "tokens")}
</span>
</div>
<p className="text-sm text-text-main">
<strong>{pct(data.heavyWriteCallShare)}</strong>{" "}
{text(t, "cacheHealthOfCalls", "of the calls carry")}{" "}
<strong>{pct(data.heavyWriteTokenShare)}</strong>{" "}
{text(t, "cacheHealthOfWrites", "of all written tokens")} (
{compact(data.heavyWriteCalls)} {text(t, "cacheHealthCallsLower", "calls")}).
</p>
<div className="h-2 w-full overflow-hidden rounded-full bg-bg-subtle">
<div
className={cn(
"h-full rounded-full",
data.heavyWriteTokenShare > 0.75 ? "bg-red-500" : "bg-amber-500"
)}
style={{ width: `${Math.min(100, data.heavyWriteTokenShare * 100)}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-4 pt-1 md:grid-cols-4">
<Metric label="p50" value={compact(data.writeP50)} />
<Metric label="p90" value={compact(data.writeP90)} />
<Metric label="p99" value={compact(data.writeP99)} />
<Metric label="max" value={compact(data.writeMax)} />
</div>
</div>
</Card>
<Card>
<h3 className="mb-3 text-sm font-semibold text-text-main">
{text(t, "cacheHealthByModel", "By model (worst ratio first)")}
</h3>
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase text-text-muted">
<th className="pb-2 pr-4 font-medium">{text(t, "cacheHealthModel", "Model")}</th>
<th className="pb-2 pr-4 text-right font-medium">
{text(t, "cacheHealthCalls", "Calls")}
</th>
<th className="pb-2 pr-4 text-right font-medium">
{text(t, "cacheHealthRead", "Read")}
</th>
<th className="pb-2 pr-4 text-right font-medium">
{text(t, "cacheHealthWrite", "Written")}
</th>
<th className="pb-2 pr-4 text-right font-medium">W/R</th>
<th className="pb-2 text-right font-medium">
{text(t, "cacheHealthHeavy", "Heavy")}
</th>
</tr>
</thead>
<tbody>
{data.byModel.map((m) => (
<tr key={m.model} className="border-b border-border/50 last:border-0">
<td className="py-2 pr-4 font-mono text-xs text-text-main">{m.model}</td>
<td className="py-2 pr-4 text-right text-text-muted">{compact(m.calls)}</td>
<td className="py-2 pr-4 text-right text-text-muted">
{compact(m.cacheReadTotal)}
</td>
<td className="py-2 pr-4 text-right text-text-muted">
{compact(m.cacheWriteTotal)}
</td>
<td
className={cn(
"py-2 pr-4 text-right font-medium",
m.writeReadRatio > 1
? "text-red-500"
: m.writeReadRatio > 0.2
? "text-amber-500"
: "text-text-main"
)}
>
{m.writeReadRatio.toFixed(2)}
</td>
<td className="py-2 text-right text-text-muted">
{compact(m.heavyWriteCalls)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import { UsageAnalytics, CardSkeleton } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
import EvalsTab from "../usage/components/EvalsTab";
import CacheHealthTab from "./CacheHealthTab";
import ComboHealthTab from "./ComboHealthTab";
import ProviderUtilizationTab from "./ProviderUtilizationTab";
import RouteExplainabilityTab from "./RouteExplainabilityTab";
@@ -13,7 +14,13 @@ import SearchAnalyticsTab from "./SearchAnalyticsTab";
import DiversityScoreCard from "./components/DiversityScoreCard";
type AnalyticsTab =
"overview" | "evals" | "search" | "utilization" | "combo-health" | "route-trace";
| "overview"
| "evals"
| "search"
| "utilization"
| "combo-health"
| "cache-health"
| "route-trace";
const ANALYTICS_TABS: Array<{
id: AnalyticsTab;
@@ -31,6 +38,12 @@ const ANALYTICS_TABS: Array<{
label: "Combo Health",
icon: "health_and_safety",
},
{
id: "cache-health",
labelKey: "cacheHealth",
label: "Cache Health",
icon: "database",
},
{ id: "route-trace", labelKey: "routeTrace", label: "Route Trace", icon: "alt_route" },
];
@@ -44,7 +57,13 @@ function analyticsText(t: AnalyticsTranslator, key: string, fallback: string) {
function normalizeTab(tab: string | null): AnalyticsTab {
if (tab === "route-trace" || tab === "route-explain") return "route-trace";
if (tab === "evals" || tab === "search" || tab === "utilization" || tab === "combo-health") {
if (
tab === "evals" ||
tab === "search" ||
tab === "utilization" ||
tab === "combo-health" ||
tab === "cache-health"
) {
return tab;
}
return "overview";
@@ -118,6 +137,7 @@ function AnalyticsPageContent() {
{activeTab === "search" ? <SearchAnalyticsTab /> : null}
{activeTab === "utilization" ? <ProviderUtilizationTab /> : null}
{activeTab === "combo-health" ? <ComboHealthTab /> : null}
{activeTab === "cache-health" ? <CacheHealthTab /> : null}
{activeTab === "route-trace" ? (
<RouteExplainabilityTab initialRequestId={initialRequestId} />
) : null}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { buildCacheHealthResponse } from "@/lib/usage/cacheHealth";
const querySchema = z.object({
range: z.enum(["1h", "24h", "7d", "30d"]).default("24h"),
model: z.string().min(1).max(200).optional(),
});
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const parsed = querySchema.safeParse({
range: searchParams.get("range") ?? undefined,
model: searchParams.get("model") || undefined,
});
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid query parameters" },
{ status: 400 }
);
}
return NextResponse.json(buildCacheHealthResponse(parsed.data));
} catch (error) {
// Never echo the error: the message can carry the DATA_DIR path and the
// SQL text. Details go to the server log, the caller gets a fixed string.
console.error("Error building cache health summary:", error);
return NextResponse.json({ error: "Failed to build cache health summary" }, { status: 500 });
}
}

View File

@@ -0,0 +1,290 @@
/**
* Prompt-cache health — turns the raw cache columns of `call_logs` into a
* summary an operator can act on.
*
* Why this exists: a production diagnosis on 2026-07-27 showed the aggregate
* ratio is close to useless on its own. The window read 24.8M cached tokens and
* wrote 9.5M (`write/read = 0.385`) — mediocre-looking but unremarkable. The
* real story was the CONCENTRATION: 18% of the calls carried 94% of the write,
* while the median call wrote 848 tokens. Two models in the same window sat at
* 0.13 (Sonnet) and 0.51 (Opus).
*
* So the summary reports three things the average hides:
* - the distribution (p50/p90/p99), because the median being cheap is exactly
* what makes the tail invisible;
* - the concentration (how few calls carry how much of the write), which is
* what tells you whether to chase a pattern or accept the cost;
* - the per-model split, because a healthy model averages away a sick one.
*/
import { getDbInstance } from "@/lib/db/core";
import type { UtilizationTimeRange } from "@/shared/types/utilization";
/** One `call_logs` row, already narrowed to the cache columns. Both counters are nullable in the schema. */
export interface CacheHealthRow {
model: string;
cacheRead: number | null;
cacheCreation: number | null;
timestamp: string;
}
export interface CacheHealthModelSummary {
model: string;
calls: number;
cacheReadTotal: number;
cacheWriteTotal: number;
writeReadRatio: number;
heavyWriteCalls: number;
}
export type CacheHealthVerdict = "healthy" | "degraded" | "thrash" | "no-data";
export interface CacheHealthSummary {
totalCalls: number;
cacheReadTotal: number;
cacheWriteTotal: number;
/** `write / max(read, 1)`. Below ~0.2 is a warm loop; above 1 the prefix is being repaid. */
writeReadRatio: number;
/** Read something and read at least as much as it wrote — the loop is working. */
warmCalls: number;
/** Wrote without reading: a genuinely new prefix (first turn, or the cache expired). */
coldCalls: number;
/** Read, but rewrote more than it read — the prefix moved under it. */
rewriteCalls: number;
/** Neither read nor wrote: this route simply does not cache. Not a fault. */
uncachedCalls: number;
writeP50: number;
writeP90: number;
writeP99: number;
writeMax: number;
/** Calls whose write is an outlier for THIS window (see `heavyWriteThreshold`). */
heavyWriteCalls: number;
heavyWriteCallShare: number;
/** Share of all written tokens those few calls account for. This is the number that matters. */
heavyWriteTokenShare: number;
heavyWriteThreshold: number;
verdict: CacheHealthVerdict;
byModel: CacheHealthModelSummary[];
}
/**
* Anthropic pads cache-creation up to a 1024-token minimum (see the note on
* issue #2215 in `claude-to-openai.ts`), so a write below that carries no
* signal — it is the floor, not a decision. Used as the lower bound of the
* outlier threshold so a window of tiny conversations cannot make everything
* look "heavy".
*/
const MIN_MEANINGFUL_WRITE = 1024;
/**
* How many times the median a write must be to count as an outlier. Relative on
* purpose: a fixed cutoff tuned for 130k-token conversations reports nothing at
* all on 2k-token ones, and the whole point is to find the tail of whatever
* window the operator is looking at.
*/
const HEAVY_WRITE_MEDIAN_FACTOR = 10;
const num = (v: number | null | undefined): number =>
typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
/** Nearest-rank percentile over an ascending array. */
function percentile(sortedAsc: number[], p: number): number {
if (sortedAsc.length === 0) return 0;
const idx = Math.min(sortedAsc.length - 1, Math.floor(sortedAsc.length * p));
return sortedAsc[idx];
}
function ratio(write: number, read: number): number {
// max(read, 1) keeps this JSON-safe: a window that only ever wrote would divide
// by zero, and Infinity does not survive JSON.stringify.
return write / Math.max(read, 1);
}
export function summarizeCacheHealth(rows: CacheHealthRow[]): CacheHealthSummary {
const empty: CacheHealthSummary = {
totalCalls: 0,
cacheReadTotal: 0,
cacheWriteTotal: 0,
writeReadRatio: 0,
warmCalls: 0,
coldCalls: 0,
rewriteCalls: 0,
uncachedCalls: 0,
writeP50: 0,
writeP90: 0,
writeP99: 0,
writeMax: 0,
heavyWriteCalls: 0,
heavyWriteCallShare: 0,
heavyWriteTokenShare: 0,
heavyWriteThreshold: 0,
verdict: "no-data",
byModel: [],
};
if (rows.length === 0) return empty;
let readTotal = 0;
let writeTotal = 0;
let warm = 0;
let cold = 0;
let rewrite = 0;
let uncached = 0;
const writes: number[] = [];
const perModel = new Map<string, { calls: number; read: number; write: number; heavy: number }>();
for (const r of rows) {
const read = num(r.cacheRead);
const write = num(r.cacheCreation);
readTotal += read;
writeTotal += write;
writes.push(write);
if (read === 0 && write === 0) uncached++;
else if (read === 0) cold++;
else if (read >= write) warm++;
else rewrite++;
const key = r.model || "unknown";
const m = perModel.get(key) || { calls: 0, read: 0, write: 0, heavy: 0 };
m.calls++;
m.read += read;
m.write += write;
perModel.set(key, m);
}
const sorted = [...writes].sort((a, b) => a - b);
const median = percentile(sorted, 0.5);
const heavyWriteThreshold = Math.max(median * HEAVY_WRITE_MEDIAN_FACTOR, MIN_MEANINGFUL_WRITE);
let heavyCalls = 0;
let heavyTokens = 0;
for (const r of rows) {
const write = num(r.cacheCreation);
if (write <= heavyWriteThreshold) continue;
heavyCalls++;
heavyTokens += write;
const m = perModel.get(r.model || "unknown");
if (m) m.heavy++;
}
// Only calls that touched the cache at all get a say in the verdict — a route
// that never caches is an absence of caching, not a sick cache.
const cacheTouching = warm + cold + rewrite;
const warmShare = cacheTouching > 0 ? warm / cacheTouching : 1;
const verdict: CacheHealthVerdict =
warmShare >= 0.6 ? "healthy" : warmShare >= 0.3 ? "degraded" : "thrash";
const byModel: CacheHealthModelSummary[] = [...perModel.entries()]
.map(([model, m]) => ({
model,
calls: m.calls,
cacheReadTotal: m.read,
cacheWriteTotal: m.write,
writeReadRatio: ratio(m.write, m.read),
heavyWriteCalls: m.heavy,
}))
.sort((a, b) => b.writeReadRatio - a.writeReadRatio || b.cacheWriteTotal - a.cacheWriteTotal);
return {
totalCalls: rows.length,
cacheReadTotal: readTotal,
cacheWriteTotal: writeTotal,
writeReadRatio: ratio(writeTotal, readTotal),
warmCalls: warm,
coldCalls: cold,
rewriteCalls: rewrite,
uncachedCalls: uncached,
writeP50: median,
writeP90: percentile(sorted, 0.9),
writeP99: percentile(sorted, 0.99),
writeMax: sorted[sorted.length - 1] ?? 0,
heavyWriteCalls: heavyCalls,
heavyWriteCallShare: heavyCalls / rows.length,
heavyWriteTokenShare: writeTotal > 0 ? heavyTokens / writeTotal : 0,
heavyWriteThreshold,
verdict,
byModel,
};
}
const RANGE_MS: Record<UtilizationTimeRange, number> = {
"1h": 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
/**
* Cap on rows pulled into memory for one summary. A busy box logs thousands of
* calls a day; the summary is statistical, so the newest N is representative
* and bounded. `truncated` in the response tells the caller when this bit.
*/
const MAX_ROWS = 5000;
type CacheHealthDbRow = {
model: string | null;
requested_model: string | null;
tokens_cache_read: number | null;
tokens_cache_creation: number | null;
timestamp: string | null;
};
export interface CacheHealthResponse extends CacheHealthSummary {
timeRange: UtilizationTimeRange;
since: string;
/** True when the window held more calls than `MAX_ROWS` and only the newest were summarized. */
truncated: boolean;
}
/**
* Reads the cache columns of `call_logs` for the window and summarizes them.
*
* Only successful calls count: a 4xx/5xx never reached the provider cache, so
* including them would dilute the ratio with requests that never had a chance
* to hit. Rows where both counters are NULL are excluded at the SQL level —
* those pre-date cache accounting and would show up as fake "uncached" calls.
*/
export function buildCacheHealthResponse(opts: {
range: UtilizationTimeRange;
model?: string;
now?: number;
}): CacheHealthResponse {
const since = new Date((opts.now ?? Date.now()) - RANGE_MS[opts.range]).toISOString();
const db = getDbInstance();
const params: (string | number)[] = [since];
let modelFilter = "";
if (opts.model) {
modelFilter = " AND (model = ? OR requested_model = ?)";
params.push(opts.model, opts.model);
}
params.push(MAX_ROWS + 1);
const rows = db
.prepare(
`SELECT model, requested_model, tokens_cache_read, tokens_cache_creation, timestamp
FROM call_logs
WHERE timestamp >= ?
AND status = 200
AND (tokens_cache_read IS NOT NULL OR tokens_cache_creation IS NOT NULL)
${modelFilter}
ORDER BY timestamp DESC
LIMIT ?`
)
.all(...params) as CacheHealthDbRow[];
const truncated = rows.length > MAX_ROWS;
const summary = summarizeCacheHealth(
rows.slice(0, MAX_ROWS).map((r) => ({
// requested_model carries the alias the caller actually asked for
// (e.g. a quota-share `qtSd/...` id); model is what the upstream saw.
// The alias is the useful grouping key for an operator.
model: r.requested_model || r.model || "unknown",
cacheRead: r.tokens_cache_read,
cacheCreation: r.tokens_cache_creation,
timestamp: r.timestamp || "",
}))
);
return { ...summary, timeRange: opts.range, since, truncated };
}

View File

@@ -0,0 +1,123 @@
/**
* Route contract for GET /api/usage/cache-health.
*
* Uses a real on-disk SQLite so the SQL itself is exercised — a summary that
* silently returns zeros because the WHERE clause is wrong is worse than no
* endpoint at all, and only a real query catches that.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cache-health-"));
process.env.DATA_DIR = tmpDir;
// Cleanup on exit, not in test.after(): a root after-hook fires as soon as the
// first top-level test settles and would delete the directory out from under
// the later ones.
process.on("exit", () => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
/* best effort */
}
});
const { getDbInstance, resetDbInstance } = await import("@/lib/db/core");
const { buildCacheHealthResponse } = await import("@/lib/usage/cacheHealth");
const { GET } = await import("@/app/api/usage/cache-health/route");
const NOW = Date.parse("2026-07-28T03:00:00.000Z");
function seed() {
const db = getDbInstance();
db.prepare("DELETE FROM call_logs").run();
const insert = db.prepare(
`INSERT INTO call_logs (id, timestamp, status, model, requested_model,
tokens_cache_read, tokens_cache_creation)
VALUES (?, ?, ?, ?, ?, ?, ?)`
);
const iso = (minsAgo: number) => new Date(NOW - minsAgo * 60_000).toISOString();
// dentro da janela de 1h: um write inicial + dois reads (padrão saudável)
insert.run("a1", iso(10), 200, "claude-sonnet-5", "claude-sonnet-5", 0, 11660);
insert.run("a2", iso(9), 200, "claude-sonnet-5", "claude-sonnet-5", 11660, 14);
insert.run("a3", iso(8), 200, "claude-sonnet-5", "claude-sonnet-5", 11674, 15);
// uma chamada cara do mesmo período, modelo diferente
insert.run("a4", iso(7), 200, "claude-opus-4-8", "claude-opus-4-8", 37509, 91878);
// ruído que NÃO pode entrar: erro, fora da janela, e linha sem contabilidade de cache
insert.run("b1", iso(5), 500, "claude-sonnet-5", "claude-sonnet-5", 999999, 999999);
insert.run("b2", iso(600), 200, "claude-sonnet-5", "claude-sonnet-5", 888888, 888888);
insert.run("b3", iso(4), 200, "claude-sonnet-5", "claude-sonnet-5", null, null);
}
test("resume a janela ignorando erro, fora-da-janela e linhas sem contabilidade", () => {
seed();
const r = buildCacheHealthResponse({ range: "1h", now: NOW });
assert.equal(r.totalCalls, 4, "só as 4 chamadas 200 dentro de 1h com colunas de cache");
assert.equal(r.cacheReadTotal, 0 + 11660 + 11674 + 37509);
assert.equal(r.cacheWriteTotal, 11660 + 14 + 15 + 91878);
assert.equal(r.timeRange, "1h");
assert.equal(r.truncated, false);
assert.ok(
!JSON.stringify(r).includes("999999") && !JSON.stringify(r).includes("888888"),
"o 500 e a linha antiga não podem vazar para o resumo"
);
});
test("filtra por modelo quando pedido", () => {
seed();
const r = buildCacheHealthResponse({ range: "1h", model: "claude-opus-4-8", now: NOW });
assert.equal(r.totalCalls, 1);
assert.equal(r.byModel.length, 1);
assert.equal(r.byModel[0].model, "claude-opus-4-8");
});
test("GET responde 200 com o resumo", async () => {
seed();
const res = await GET(new Request("http://localhost/api/usage/cache-health?range=1h"));
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.timeRange, "1h");
assert.equal(typeof body.writeReadRatio, "number");
assert.ok(Array.isArray(body.byModel));
assert.ok(["healthy", "degraded", "thrash", "no-data"].includes(body.verdict));
});
test("GET rejeita range inválido com 400", async () => {
const res = await GET(new Request("http://localhost/api/usage/cache-health?range=99y"));
assert.equal(res.status, 400);
});
test("GET assume 24h quando o range é omitido", async () => {
seed();
const res = await GET(new Request("http://localhost/api/usage/cache-health"));
assert.equal(res.status, 200);
assert.equal((await res.json()).timeRange, "24h");
});
test("erro interno responde 500 sem vazar stack trace nem SQL", async () => {
// Gatilho determinístico: sem a tabela, o prepare() lança SqliteError. Um
// DATA_DIR inválido NÃO serve — o driver fica preso tentando criar o arquivo
// em vez de falhar, e o teste passaria sem exercitar o catch.
seed();
const db = getDbInstance();
db.prepare("DROP TABLE call_logs").run();
try {
const res = await GET(new Request("http://localhost/api/usage/cache-health?range=1h"));
assert.equal(res.status, 500, "o caminho de erro precisa ser realmente exercitado");
const text = JSON.stringify(await res.json());
assert.ok(!text.includes("at /"), "sem stack trace no corpo");
assert.ok(!/\.ts:\d+/.test(text), "sem caminho de arquivo no corpo");
assert.ok(!/SELECT|call_logs/i.test(text), "sem o texto do SQL nem nome de tabela no corpo");
} finally {
resetDbInstance();
}
});

View File

@@ -0,0 +1,147 @@
/**
* Prompt-cache health summary.
*
* Context: production diagnosis on 2026-07-27 found that 18% of the Opus calls
* carried 94% of all cache_creation tokens, while the median call wrote only
* 848 tokens. A plain average hid this completely — `write/read = 0.385` looks
* merely mediocre, and the aggregate says nothing about WHERE the waste is.
* The summary therefore has to expose the concentration (heavy-write share),
* not just the ratio, and split per model, because Sonnet sat at 0.13 while
* Opus sat at 0.51 in the very same window.
*
* The numbers in these fixtures are the real shape observed on the box.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { summarizeCacheHealth, type CacheHealthRow } from "@/lib/usage/cacheHealth";
function row(
model: string,
cacheRead: number,
cacheCreation: number,
timestamp = "2026-07-28T02:00:00.000Z"
): CacheHealthRow {
return { model, cacheRead, cacheCreation, timestamp };
}
test("uma conversa saudável: escreve o prefixo uma vez e depois só o delta", () => {
const rows = [
row("claude-sonnet-5", 0, 11660),
row("claude-sonnet-5", 11660, 14),
row("claude-sonnet-5", 11674, 15),
row("claude-sonnet-5", 11689, 12),
];
const s = summarizeCacheHealth(rows);
assert.equal(s.totalCalls, 4);
assert.equal(s.cacheReadTotal, 35023);
assert.equal(s.cacheWriteTotal, 11701);
// O write inicial é inevitável; o que importa é que os turnos seguintes leem.
assert.equal(s.warmCalls, 3);
assert.equal(s.coldCalls, 1);
assert.equal(s.verdict, "healthy");
});
test("thrash: repaga o prefixo a cada turno em vez de ler", () => {
const rows = [
row("claude-opus-4-8", 0, 200000),
row("claude-opus-4-8", 0, 205000),
row("claude-opus-4-8", 0, 210000),
];
const s = summarizeCacheHealth(rows);
assert.equal(s.verdict, "thrash");
assert.ok(s.writeReadRatio > 1, "razão write/read acima de 1 é a assinatura do thrash");
assert.equal(s.warmCalls, 0);
});
test("expõe a CONCENTRAÇÃO do write, que a média esconde", () => {
// 20 chamadas: 18 baratas + 2 caras. A média mente, a concentração não.
const rows = [
...Array.from({ length: 18 }, () => row("claude-opus-4-8", 127000, 800)),
row("claude-opus-4-8", 37509, 91878),
row("claude-opus-4-8", 37509, 87949),
];
const s = summarizeCacheHealth(rows);
assert.equal(s.heavyWriteCalls, 2);
assert.equal(s.heavyWriteCallShare, 0.1, "2 de 20 chamadas");
assert.ok(
s.heavyWriteTokenShare > 0.9,
`essas 2 chamadas concentram >90% do write, não 10% (obtido: ${s.heavyWriteTokenShare})`
);
// A mediana continua barata — é exatamente por isso que o p50 sozinho engana.
assert.equal(s.writeP50, 800);
assert.ok(s.writeP90 > 50000, "o p90 é onde a dor aparece");
});
test("quebra por modelo — Opus e Sonnet na mesma janela têm saúde diferente", () => {
const rows = [
...Array.from({ length: 8 }, () => row("claude-sonnet-5", 100000, 1000)),
...Array.from({ length: 6 }, () => row("claude-opus-4-8", 100000, 1000)),
...Array.from({ length: 2 }, () => row("claude-opus-4-8", 37509, 90000)),
];
const s = summarizeCacheHealth(rows);
const byModel = Object.fromEntries(s.byModel.map((m) => [m.model, m]));
assert.equal(s.byModel.length, 2);
assert.ok(
byModel["claude-opus-4-8"].writeReadRatio > byModel["claude-sonnet-5"].writeReadRatio,
"o modelo problemático tem de se destacar no relatório"
);
// Ordenado pelo pior primeiro: é o que o operador precisa ver de cara.
assert.equal(s.byModel[0].model, "claude-opus-4-8");
});
test("chamadas sem cache nenhum são contadas à parte, não como thrash", () => {
// Um modelo/rota que não cacheia não é um cache doente — é ausência de cache.
const rows = [row("gpt-4o", 0, 0), row("gpt-4o", 0, 0), row("claude-sonnet-5", 9000, 20)];
const s = summarizeCacheHealth(rows);
assert.equal(s.uncachedCalls, 2);
assert.equal(s.warmCalls, 1);
assert.equal(s.verdict, "healthy", "ausência de cache não pode ser reportada como thrash");
});
test("janela vazia não divide por zero nem inventa veredicto", () => {
const s = summarizeCacheHealth([]);
assert.equal(s.totalCalls, 0);
assert.equal(s.writeReadRatio, 0);
assert.equal(s.heavyWriteTokenShare, 0);
assert.equal(s.verdict, "no-data");
assert.deepEqual(s.byModel, []);
});
test("tolera linhas com null vindas do banco (colunas são nullable)", () => {
const rows = [
{ model: "claude-sonnet-5", cacheRead: null, cacheCreation: 5000, timestamp: "x" },
{ model: "claude-sonnet-5", cacheRead: 5000, cacheCreation: null, timestamp: "x" },
] as unknown as CacheHealthRow[];
const s = summarizeCacheHealth(rows);
assert.equal(s.cacheReadTotal, 5000);
assert.equal(s.cacheWriteTotal, 5000);
assert.equal(s.totalCalls, 2);
});
test("o limiar de 'heavy' acompanha a janela em vez de ser um número mágico fixo", () => {
// Conversas pequenas (cache de ~2k) não podem ser julgadas pelo mesmo corte
// absoluto de uma conversa de 130k, senão nada é 'heavy' e o relatório cala.
const pequenas = [
...Array.from({ length: 9 }, () => row("m", 2000, 20)),
row("m", 500, 1800),
];
const s = summarizeCacheHealth(pequenas);
assert.equal(s.heavyWriteCalls, 1, "a chamada cara relativa à janela tem de ser detectada");
});