Skip to content

Commit 8e95a7b

Browse files
committed
feat: cache statistics in terminal and WebUI
Add cache metrics (Anthropic cache_creation_input_tokens, cache_read_input_tokens and OpenAI prompt_tokens_details.cached_tokens) to the agent runtime with display in both terminal and WebUI. - llm/client.go: Parse cache metrics from API response (both formats) - loop/loop.go: Accumulate cache metrics across iterations - odek.go: Expose TotalCacheCreationTokens/ReadTokens/CachedTokens - render/render.go: Summary() method after final answer - serve.go: Send cache metrics in done WebSocket message - ui/index.html: Show cache stats in per-message and session stats
1 parent 0cdc636 commit 8e95a7b

4 files changed

Lines changed: 64 additions & 1 deletion

File tree

cmd/odek/serve.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,9 @@ func handlePrompt(
498498

499499
contextTokens := agent.TotalInputTokens()
500500
outputTokens := agent.TotalOutputTokens()
501+
cacheCreate := agent.TotalCacheCreationTokens()
502+
cacheRead := agent.TotalCacheReadTokens()
503+
cached := agent.TotalCachedTokens()
501504
*sessionInputTokens += contextTokens
502505
*sessionOutputTokens += outputTokens
503506

@@ -506,6 +509,9 @@ func handlePrompt(
506509
"latency": latency.Seconds(),
507510
"contextTokens": contextTokens,
508511
"outputTokens": outputTokens,
512+
"cacheCreationTokens": cacheCreate,
513+
"cacheReadTokens": cacheRead,
514+
"cachedTokens": cached,
509515
"sessionContextTokens": *sessionInputTokens,
510516
"sessionOutputTokens": *sessionOutputTokens,
511517
})

cmd/odek/ui/index.html

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1399,14 +1399,33 @@ <h3>Delete session?</h3>
13991399
parts.push('⚡ ' + (event.latency < 1 ? (event.latency * 1000).toFixed(0) + 'ms' : event.latency.toFixed(1) + 's'));
14001400
if (event.contextTokens != null) parts.push('⌂ ' + formatNum(event.contextTokens));
14011401
if (event.outputTokens != null) parts.push('⎇ ' + formatNum(event.outputTokens));
1402+
// Cache metrics — show only when non-zero
1403+
if (event.cacheCreationTokens > 0) parts.push('⊕ ' + formatNum(event.cacheCreationTokens));
1404+
if (event.cacheReadTokens > 0) parts.push('⊙ ' + formatNum(event.cacheReadTokens));
1405+
if (event.cachedTokens > 0) parts.push('⊡ ' + formatNum(event.cachedTokens));
14021406
stats.textContent = parts.join(' · ');
14031407
lastAssistant.appendChild(stats);
14041408
}
14051409
}
14061410
// Update session-level token stats in top bar
14071411
const sessionStatsEl = document.getElementById('session-stats');
14081412
if (event.sessionContextTokens != null && event.sessionOutputTokens != null) {
1409-
sessionStatsEl.textContent = '∑ ⌂ ' + formatNum(event.sessionContextTokens) + ' · ⎇ ' + formatNum(event.sessionOutputTokens);
1413+
const sessParts = ['∑ ⌂ ' + formatNum(event.sessionContextTokens) + ' · ⎇ ' + formatNum(event.sessionOutputTokens)];
1414+
if (event.cacheReadTokens > 0 || event.cacheCreationTokens > 0 || event.cachedTokens > 0) {
1415+
// Count total session cache stats (accumulated across the session)
1416+
// We store cache totals on the session-stats element's dataset
1417+
const el = document.getElementById('session-stats');
1418+
const cc = (parseInt(el.dataset.cacheCreate || '0') + (event.cacheCreationTokens || 0));
1419+
const cr = (parseInt(el.dataset.cacheRead || '0') + (event.cacheReadTokens || 0));
1420+
const cd = (parseInt(el.dataset.cached || '0') + (event.cachedTokens || 0));
1421+
el.dataset.cacheCreate = cc;
1422+
el.dataset.cacheRead = cr;
1423+
el.dataset.cached = cd;
1424+
if (cr > 0) sessParts.push('⊙ ' + formatNum(cr));
1425+
if (cc > 0) sessParts.push('⊕ ' + formatNum(cc));
1426+
if (cd > 0) sessParts.push('⊡ ' + formatNum(cd));
1427+
}
1428+
sessionStatsEl.textContent = sessParts.join(' · ');
14101429
sessionStatsEl.classList.add('visible');
14111430
}
14121431
if (sessionId) loadSessions();

internal/loop/loop.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ func (e *Engine) RunWithMessages(ctx context.Context, messages []llm.Message) (s
207207
// Reset token accounting for this run
208208
e.TotalInputTokens = 0
209209
e.TotalOutputTokens = 0
210+
e.TotalCacheCreationTokens = 0
211+
e.TotalCacheReadTokens = 0
212+
e.TotalCachedTokens = 0
210213
return e.runLoop(ctx, messages)
211214
}
212215

@@ -281,6 +284,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
281284
e.TotalOutputTokens += result.OutputTokens
282285

283286
// Accumulate cache metrics
287+
// Accumulate cache metrics across iterations
284288
e.TotalCacheCreationTokens += result.CacheCreationTokens
285289
e.TotalCacheReadTokens += result.CacheReadTokens
286290
e.TotalCachedTokens += result.CachedTokens
@@ -289,6 +293,13 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
289293
if len(result.ToolCalls) == 0 {
290294
if e.renderer != nil {
291295
e.renderer.FinalAnswer(result.Content)
296+
e.renderer.Summary(
297+
e.TotalInputTokens,
298+
e.TotalOutputTokens,
299+
e.TotalCacheCreationTokens,
300+
e.TotalCacheReadTokens,
301+
e.TotalCachedTokens,
302+
)
292303
}
293304
// Append final assistant message so callers (e.g. WebUI) get
294305
// the final text in the messages slice and can stream it.

internal/render/render.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,33 @@ func (r *Renderer) FinalAnswer(text string) {
204204
fmt.Fprintln(r.w)
205205
}
206206

207+
// Summary prints a run summary line with total token and cache statistics.
208+
// Emitted after the final answer when at least one stat is non-zero.
209+
// Shows: total input/output tokens, cache creation/read/cached tokens.
210+
func (r *Renderer) Summary(inTokens, outTokens, cacheCreate, cacheRead, cached int) {
211+
if r.disable() {
212+
return
213+
}
214+
if inTokens == 0 && outTokens == 0 {
215+
return
216+
}
217+
parts := []string{
218+
fmt.Sprintf("⌂ %d in", inTokens),
219+
fmt.Sprintf("⎇ %d out", outTokens),
220+
}
221+
if cacheCreate > 0 {
222+
parts = append(parts, fmt.Sprintf("⊕ %d created", cacheCreate))
223+
}
224+
if cacheRead > 0 {
225+
parts = append(parts, fmt.Sprintf("⊙ %d read", cacheRead))
226+
}
227+
if cached > 0 {
228+
parts = append(parts, fmt.Sprintf("⊡ %d cached", cached))
229+
}
230+
fmt.Fprintln(r.w, r.style(gray, "── "+strings.Join(parts, " · ")))
231+
fmt.Fprintln(r.w)
232+
}
233+
207234
// Error prints a non-fatal loop error with a cross emoji.
208235
func (r *Renderer) Error(err error) {
209236
if r.disable() || err == nil {

0 commit comments

Comments
 (0)