Skip to content

Commit 6e15fe0

Browse files
authored
Fix cache token pricing and dashboard display (#5)
Count cache creation separately from fresh input, support alternate OTel cache token attributes, and display bundled pricing fields correctly in the dashboard. Includes regression coverage for cache token normalization.
1 parent 4f7ccfd commit 6e15fe0

3 files changed

Lines changed: 24 additions & 8 deletions

File tree

dashboard-ui/app.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ async function renderPricing() {
238238
<button id="refresh-pricing" class="btn" type="button">Refresh pricing</button>
239239
</div>
240240
<div class="overflow-x-auto"><table class="table"><thead><tr><th>Model</th><th>Input</th><th>Output</th><th>Cache read</th><th>Cache write</th><th>Premium</th></tr></thead><tbody>
241-
${models.map(([model, row]) => `<tr><td class="font-medium">${escapeHtml(model)}</td><td>${pricingValue(row, ["input", "input_per_mtok", "input_cost_per_1m"])}</td><td>${pricingValue(row, ["output", "output_per_mtok", "output_cost_per_1m"])}</td><td>${pricingValue(row, ["cache_read", "cache_read_per_mtok"])}</td><td>${pricingValue(row, ["cache_write", "cache_write_per_mtok"])}</td><td>${pricingValue(row, ["premium_request", "premium_requests", "request"])} </td></tr>`).join("") || `<tr><td colspan="6">${emptyInline("Pricing data is not available yet.")}</td></tr>`}
241+
${models.map(([model, row]) => `<tr><td class="font-medium">${escapeHtml(model)}</td><td>${pricingValue(row, ["input"])}</td><td>${pricingValue(row, ["output"])}</td><td>${pricingValue(row, ["cached_input"])}</td><td>${pricingValue(row, ["cache_write"])}</td><td></td></tr>`).join("") || `<tr><td colspan="6">${emptyInline("Pricing data is not available yet.")}</td></tr>`}
242242
</tbody></table></div>
243243
</section>`;
244244
document.querySelector("#refresh-pricing").addEventListener("click", async (event) => {
@@ -359,8 +359,8 @@ function drawerContent(session, details) {
359359
<div class="mt-3 grid gap-3 sm:grid-cols-4">
360360
${miniStat("Started", formatDate(session.started_at))}${miniStat("Last seen", formatDate(session.last_seen_at))}${miniStat("Requests", tok(session.premium_requests || calls.length))}${miniStat("Cache tokens", tok(totalCache))}
361361
</div>
362-
<section class="mt-6"><h3 class="text-sm font-semibold">LLM calls</h3><div class="mt-3 overflow-x-auto"><table class="table"><thead><tr><th>Time</th><th>Model</th><th>Cost</th><th>Input</th><th>Output</th><th>Duration</th></tr></thead><tbody>
363-
${calls.map((call) => `<tr><td class="whitespace-nowrap text-muted-foreground">${formatDate(call.ts || call.started_at || call.timestamp || call.time)}</td><td>${escapeHtml(call.model || session.model || "unknown")}</td><td>${money(call.usd_cost)}</td><td>${tok(call.input_tokens)}</td><td>${tok(call.output_tokens)}</td><td>${tok(call.duration_ms || call.api_duration_ms || 0)} ms</td></tr>`).join("") || `<tr><td colspan="6">${emptyInline("No per-call records were returned for this session.")}</td></tr>`}
362+
<section class="mt-6"><h3 class="text-sm font-semibold">LLM calls</h3><div class="mt-3 overflow-x-auto"><table class="table"><thead><tr><th>Time</th><th>Model</th><th>Cost</th><th>Fresh Input</th><th>Cache Read</th><th>Cache Write</th><th>Output</th><th>Duration</th></tr></thead><tbody>
363+
${calls.map((call) => `<tr><td class="whitespace-nowrap text-muted-foreground">${formatDate(call.ts || call.started_at || call.timestamp || call.time)}</td><td>${escapeHtml(call.model || session.model || "unknown")}</td><td>${money(call.usd_cost)}</td><td>${tok(call.input_tokens)}</td><td>${tok(call.cache_read || 0)}</td><td>${tok(call.cache_creation || 0)}</td><td>${tok(call.output_tokens)}</td><td>${tok(call.duration_ms || call.api_duration_ms || 0)} ms</td></tr>`).join("") || `<tr><td colspan="8">${emptyInline("No per-call records were returned for this session.")}</td></tr>`}
364364
</tbody></table></div></section>`;
365365
}
366366

src/otel/parser.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,12 @@ export function normalizeSpan(record: unknown): NormalizedCall | null {
115115
if (!model) return null;
116116

117117
const rawInput = num(a["gen_ai.usage.input_tokens"], a.input_tokens, record.input_tokens);
118-
const cacheRead = num(a["gen_ai.usage.cache_read.input_tokens"], a.cache_read, record.cache_read);
119-
const cacheCreation = num(a["gen_ai.usage.cache_creation.input_tokens"], a["gen_ai.usage.cache_write.input_tokens"], a.cache_creation, a.cache_write, record.cache_creation, record.cache_write);
118+
const cacheRead = num(a["gen_ai.usage.cache_read_input_tokens"], a["gen_ai.usage.cache_read.input_tokens"], a["gen_ai.usage.cache_read"], a.cache_read_tokens, a["cache_read_input_tokens"], a.cache_read, record.cache_read_tokens, record.cache_read);
119+
const cacheCreation = num(a["gen_ai.usage.cache_creation_input_tokens"], a["gen_ai.usage.cache_creation.input_tokens"], a["gen_ai.usage.cache_creation"], a["gen_ai.usage.cache_write_input_tokens"], a["gen_ai.usage.cache_write.input_tokens"], a["gen_ai.usage.cache_write"], a.cache_creation_tokens, a["cache_write_input_tokens"], a.cache_creation, a.cache_write, record.cache_creation_tokens, record.cache_creation, record.cache_write);
120120
const output = num(a["gen_ai.usage.output_tokens"], a.output_tokens, record.output_tokens);
121121
const reasoning = num(a["gen_ai.usage.reasoning.output_tokens"], a["gen_ai.usage.reasoning_tokens"], a.reasoning, record.reasoning);
122-
// OTel gen_ai.usage.input_tokens includes cache reads; keep only fresh input for downstream aggregations.
123-
const freshInput = Math.max(rawInput - cacheRead, 0);
122+
// Total input includes both cache categories; downstream aggregations store only fresh input here.
123+
const freshInput = Math.max(rawInput - cacheRead - cacheCreation, 0);
124124

125125
const start = timeFromTuple(record.startTime) ?? timeFromNano(record.startTimeUnixNano) ?? isoTime(record.ts) ?? isoTime(a.ts) ?? timeFromTuple(record.hrTime);
126126
const end = timeFromTuple(record.endTime) ?? timeFromNano(record.endTimeUnixNano);

tests-ts/otel-parser.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe("OTel parser", () => {
1313

1414
it("normalizes a Format-A-ish span fixture with fresh input", () => {
1515
const call = normalizeSpan(fixture);
16-
expect(call).toMatchObject({ session_id: "sess-test-123", model: "claude-opus-4.7", input_tokens: 800, output_tokens: 100, cache_read: 200, cache_creation: 100, reasoning: 20, duration_ms: 321, source: "cli-span" });
16+
expect(call).toMatchObject({ session_id: "sess-test-123", model: "claude-opus-4.7", input_tokens: 700, output_tokens: 100, cache_read: 200, cache_creation: 100, reasoning: 20, duration_ms: 321, source: "cli-span" });
1717
expect(call?.dedup_key).toBe("span-1");
1818
expect(call?.ts).toBe("2026-05-13T12:00:00.000Z");
1919
});
@@ -30,6 +30,22 @@ describe("OTel parser", () => {
3030
expect(call).toMatchObject({ dedup_key: "resp-1", session_id: "sess-b", model: "gpt-5-mini", input_tokens: 30, output_tokens: 7, cache_read: 20, duration_ms: 0, source: "chat-logrecord" });
3131
});
3232

33+
it("normalizes alternate cache token attribute names", () => {
34+
const call = normalizeSpan({
35+
traceId: "t2",
36+
spanId: "s2",
37+
attributes: {
38+
"gen_ai.operation.name": "chat",
39+
"gen_ai.request.model": "claude-sonnet-4.6",
40+
"gen_ai.usage.input_tokens": 1_000,
41+
"gen_ai.usage.cache_read_input_tokens": 250,
42+
"gen_ai.usage.cache_write_input_tokens": 150,
43+
"gen_ai.usage.output_tokens": 50,
44+
},
45+
});
46+
expect(call).toMatchObject({ input_tokens: 600, cache_read: 250, cache_creation: 150, output_tokens: 50 });
47+
});
48+
3349
it("returns null for metric records", () => {
3450
expect(normalizeSpan({ scopeMetrics: [], attributes: { "gen_ai.operation.name": "chat" } })).toBeNull();
3551
});

0 commit comments

Comments
 (0)