Skip to content

Commit fcd9e0c

Browse files
authored
feat(vibe): read provider cache-hit tokens from session stats (#1309)
Mistral Vibe v2.23.2 (mistralai/mistral-vibe#969) started recording provider cache-hit (cached) tokens in the session stats written to `meta.json`. This teaches the Vibe parser to read them. `stats.session_cached_tokens` is the provider cache-read count, reported as a subset of `session_prompt_tokens` (the OpenAI/Mistral `prompt_tokens` / `prompt_tokens_details.cached_tokens` wire shape). The parser now splits it out into the usage event's cache-read field and subtracts it from input tokens, so the cached prefix is priced at the discounted cache-read rate rather than the full input rate and is not double-counted. This mirrors the convention already used by the workbuddy and grok parsers. Vibe exposes a single cached counter with no cache-creation breakdown, so `CacheCreationInputTokens` stays zero. Reviewers should look at `vibeUsageEvents` in `internal/parser/vibe.go`. The format provenance entry in `docs/internal/session-format-sources.md` is updated in the same change. Co-authored-by: Grégoire <verdie-g@users.noreply.github.com>
1 parent 7449a01 commit fcd9e0c

4 files changed

Lines changed: 69 additions & 7 deletions

File tree

internal/db/db.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,12 @@ const projectIdentityRemoteScrubCompletedKey = "project_identity_remote_scrub_v1
342342
// (76: Copilot CLI tool execution boundaries. Re-parsing persists
343343
// tool.execution_start and tool.execution_complete timestamps as result events
344344
// so Session Analysis excludes resumed-session idle time from completed calls.)
345-
const dataVersion = 76
345+
// (77: Vibe usage reparse. The Vibe parser now reads
346+
// stats.session_cached_tokens, splitting the provider cache-hit count out of
347+
// input tokens into the usage event's cache-read field. Existing rows need
348+
// re-parsing so the cached prefix is priced at the discounted cache-read rate
349+
// instead of the full input rate.)
350+
const dataVersion = 77
346351

347352
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
348353

internal/db/db_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,9 +1008,9 @@ func TestMigration_ToolResultEventsTable(t *testing.T) {
10081008
"expected tool_result_events table after reopen")
10091009
}
10101010

1011-
func TestCurrentDataVersionCopilotToolTiming(t *testing.T) {
1012-
assert.Equal(t, 76, CurrentDataVersion(),
1013-
"Copilot tool execution timing requires a data version bump")
1011+
func TestCurrentDataVersionVibeCachedTokensReparse(t *testing.T) {
1012+
assert.Equal(t, 77, CurrentDataVersion(),
1013+
"reading Vibe cache-hit tokens requires a data version bump")
10141014
}
10151015

10161016
func TestInsertMessages_PreservesToolResultEvents(t *testing.T) {

internal/parser/vibe.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,11 @@ type VibeStats struct {
6060
Steps int `json:"steps"`
6161
SessionPromptTokens int `json:"session_prompt_tokens"`
6262
SessionCompletionTokens int `json:"session_completion_tokens"`
63+
SessionCachedTokens int `json:"session_cached_tokens"`
6364
ContextTokens int `json:"context_tokens"`
6465
LastTurnPromptTokens int `json:"last_turn_prompt_tokens"`
6566
LastTurnCompletionTokens int `json:"last_turn_completion_tokens"`
67+
LastTurnCachedTokens int `json:"last_turn_cached_tokens"`
6668
SessionTotalLLMTokens int64 `json:"session_total_llm_tokens"`
6769
LastTurnTotalTokens int `json:"last_turn_total_tokens"`
6870
}
@@ -441,7 +443,13 @@ func vibeUsageEvents(
441443
// Use SessionPromptTokens for input tokens and SessionCompletionTokens for output tokens.
442444
// SessionTotalLLMTokens appears to be the sum of input + output tokens,
443445
// so we don't use it as a direct source but it can serve as validation.
444-
inputTokens := stats.SessionPromptTokens
446+
//
447+
// SessionCachedTokens is the provider cache-hit (read) count, reported as a
448+
// subset of SessionPromptTokens (OpenAI/Mistral wire shape). Split it out
449+
// so cache reads are priced at the discounted cache-read rate and not
450+
// double-counted against the full input rate.
451+
cacheReadTokens := max(stats.SessionCachedTokens, 0)
452+
inputTokens := max(stats.SessionPromptTokens-cacheReadTokens, 0)
445453
outputTokens := stats.SessionCompletionTokens
446454

447455
return []ParsedUsageEvent{{
@@ -450,9 +458,9 @@ func vibeUsageEvents(
450458
Model: model,
451459
InputTokens: inputTokens,
452460
OutputTokens: outputTokens,
453-
// Vibe doesn't currently expose cache token breakdown in meta.json
461+
// Vibe doesn't currently expose cache-creation breakdown
454462
CacheCreationInputTokens: 0,
455-
CacheReadInputTokens: 0,
463+
CacheReadInputTokens: cacheReadTokens,
456464
ReasoningTokens: 0,
457465
// Vibe doesn't currently expose cost information
458466
Cost: nil,

internal/parser/vibe_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,55 @@ func TestParseVibeSessionModelFromConfig(t *testing.T) {
520520
assert.Equal(t, 40, usageEvent.OutputTokens)
521521
}
522522

523+
// TestParseVibeSessionCachedTokens verifies that the provider cache-hit count
524+
// recorded under stats.session_cached_tokens is split out into the cache-read
525+
// field and subtracted from input tokens, since Vibe reports it as a subset of
526+
// session_prompt_tokens (OpenAI/Mistral wire shape). Counting it in both places
527+
// would double-bill the cached prefix.
528+
func TestParseVibeSessionCachedTokens(t *testing.T) {
529+
tmpDir := t.TempDir()
530+
531+
content := `{"role": "user", "content": "test message", "message_id": "1"}
532+
{"role": "assistant", "content": "test response", "message_id": "2"}
533+
`
534+
metaContent := `{
535+
"session_id": "test-session-cached",
536+
"start_time": "2026-06-13T10:00:00Z",
537+
"end_time": "2026-06-13T10:05:00Z",
538+
"title": "Test session with cached tokens",
539+
"config": {"active_model": "mistral-medium-3.5"},
540+
"stats": {
541+
"session_prompt_tokens": 100,
542+
"session_completion_tokens": 40,
543+
"session_cached_tokens": 30,
544+
"context_tokens": 140,
545+
"last_turn_cached_tokens": 10,
546+
"session_total_llm_tokens": 140
547+
}
548+
}
549+
`
550+
files := map[string]string{
551+
"session_test/messages.jsonl": content,
552+
"session_test/meta.json": metaContent,
553+
}
554+
setupFileSystem(t, tmpDir, files)
555+
556+
path := filepath.Join(tmpDir, "session_test", "messages.jsonl")
557+
fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()}
558+
559+
result, err := parseVibeTestSession(t, path, fileInfo)
560+
require.NoError(t, err)
561+
562+
require.Len(t, result.UsageEvents, 1)
563+
usageEvent := result.UsageEvents[0]
564+
assert.Equal(t, "mistral-medium-3.5", usageEvent.Model)
565+
// Input is the fresh (non-cached) prefix: 100 - 30.
566+
assert.Equal(t, 70, usageEvent.InputTokens)
567+
assert.Equal(t, 40, usageEvent.OutputTokens)
568+
assert.Equal(t, 30, usageEvent.CacheReadInputTokens)
569+
assert.Equal(t, 0, usageEvent.CacheCreationInputTokens)
570+
}
571+
523572
// TestParseVibeSessionInjectedUserExcluded verifies that an injected user
524573
// record (system context) is marked system and excluded from both the first
525574
// message and the user-message count, so it cannot masquerade as the user's

0 commit comments

Comments
 (0)