Skip to content

Commit a036b7b

Browse files
nergal-permclaude
andauthored
Normalize Gemini token usage for cost tracking (#516)
## Summary - Normalize Gemini CLI token usage into the same keys used by usage cost aggregation. - Include Gemini thoughts in output token usage and cached tokens in cache-read usage. - Add a regression test covering the normalized Gemini token usage shape. Fixes #515 --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0451837 commit a036b7b

3 files changed

Lines changed: 56 additions & 19 deletions

File tree

internal/db/db.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,16 @@ import (
2727
// trigger a non-destructive re-sync (mtime reset + skip cache
2828
// clear) so existing session data is preserved.
2929
//
30-
// Bumped to 27: Piebald parser now persists normalized per-message
30+
// Bumped to 28: Gemini parser now persists normalized
31+
// (Anthropic-style) per-message token_usage JSON instead of the raw
32+
// tokens object, and rolls thoughts tokens into OutputTokens so
33+
// per-message and session output totals match the cost JSON.
34+
// Existing Gemini rows need re-parsing so usage and cost reports
35+
// reflect the new shape and include thoughts tokens.
36+
//
37+
// (27: Piebald parser now persists normalized per-message
3138
// token_usage JSON. Existing Piebald rows need re-parsing so Usage
32-
// reports can include older Piebald sessions.
39+
// reports can include older Piebald sessions.)
3340
//
3441
// (26: Claude parser now (a) links Task / Agent tool
3542
// calls to child subagent sessions via toolUseResult.agentId
@@ -81,7 +88,7 @@ import (
8188
//
8289
// (17: Codex <skill> template filtering.)
8390
// (16: <turn_aborted> system messages.)
84-
const dataVersion = 27
91+
const dataVersion = 28
8592

8693
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
8794

internal/parser/gemini.go

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import (
1515

1616
// geminiTokens holds token usage counts from a Gemini message.
1717
type geminiTokens struct {
18-
Input int
19-
Output int
20-
Cached int
18+
Input int
19+
Output int
20+
Cached int
21+
Thoughts int
2122
}
2223

2324
// extractGeminiTokens reads the tokens object from a Gemini
@@ -28,12 +29,30 @@ func extractGeminiTokens(msg gjson.Result) geminiTokens {
2829
return geminiTokens{}
2930
}
3031
return geminiTokens{
31-
Input: int(tok.Get("input").Int()),
32-
Output: int(tok.Get("output").Int()),
33-
Cached: int(tok.Get("cached").Int()),
32+
Input: int(tok.Get("input").Int()),
33+
Output: int(tok.Get("output").Int()),
34+
Cached: int(tok.Get("cached").Int()),
35+
Thoughts: int(tok.Get("thoughts").Int()),
3436
}
3537
}
3638

39+
// normalizedGeminiTokenUsage maps Gemini's token counts onto the
40+
// Anthropic-style shape used by usage and cost queries. Thoughts
41+
// tokens are billed at the output rate, so they fold into
42+
// output_tokens here.
43+
func normalizedGeminiTokenUsage(tok geminiTokens) json.RawMessage {
44+
payload := map[string]int{
45+
"input_tokens": tok.Input,
46+
"output_tokens": tok.Output + tok.Thoughts,
47+
"cache_read_input_tokens": tok.Cached,
48+
}
49+
raw, err := json.Marshal(payload)
50+
if err != nil {
51+
return nil
52+
}
53+
return raw
54+
}
55+
3756
// ParseGeminiSession parses a Gemini CLI session JSON file.
3857
// Unlike Claude/Codex JSONL, each Gemini file is a single JSON
3958
// document containing all messages.
@@ -233,7 +252,7 @@ func parseGeminiMessage(
233252
var tokenUsage json.RawMessage
234253
tokResult := msg.Get("tokens")
235254
if tokResult.Exists() {
236-
tokenUsage = json.RawMessage(tokResult.Raw)
255+
tokenUsage = normalizedGeminiTokenUsage(tok)
237256
}
238257
return ParsedMessage{
239258
Ordinal: ordinal,
@@ -248,10 +267,11 @@ func parseGeminiMessage(
248267
Model: msg.Get("model").String(),
249268
TokenUsage: tokenUsage,
250269
ContextTokens: tok.Input + tok.Cached,
251-
OutputTokens: tok.Output,
270+
OutputTokens: tok.Output + tok.Thoughts,
252271
HasContextTokens: tokResult.Get("input").Exists() ||
253272
tokResult.Get("cached").Exists(),
254-
HasOutputTokens: tokResult.Get("output").Exists(),
273+
HasOutputTokens: tokResult.Get("output").Exists() ||
274+
tokResult.Get("thoughts").Exists(),
255275
tokenPresenceKnown: true,
256276
}, true
257277
}

internal/parser/gemini_parser_test.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/stretchr/testify/assert"
99
"github.com/stretchr/testify/require"
10+
"github.com/tidwall/gjson"
1011
"github.com/wesm/agentsview/internal/testjsonl"
1112
)
1213

@@ -279,9 +280,10 @@ func TestParseGeminiSession_TokenUsage(t *testing.T) {
279280
assert.False(t, msgs[0].HasOutputTokens)
280281
assert.Empty(t, msgs[0].TokenUsage)
281282

282-
// First assistant message (a1): input=1500, cached=100, output=200
283+
// First assistant message (a1): input=1500, cached=100,
284+
// output=200, thoughts=50
283285
assert.Equal(t, 1600, msgs[1].ContextTokens)
284-
assert.Equal(t, 200, msgs[1].OutputTokens)
286+
assert.Equal(t, 250, msgs[1].OutputTokens)
285287
assert.True(t, msgs[1].HasContextTokens)
286288
assert.True(t, msgs[1].HasOutputTokens)
287289
assert.NotEmpty(t, msgs[1].TokenUsage)
@@ -292,15 +294,16 @@ func TestParseGeminiSession_TokenUsage(t *testing.T) {
292294
assert.False(t, msgs[2].HasContextTokens)
293295
assert.False(t, msgs[2].HasOutputTokens)
294296

295-
// Second assistant message (a2): input=2000, cached=50, output=300
297+
// Second assistant message (a2): input=2000, cached=50,
298+
// output=300, thoughts=100
296299
assert.Equal(t, 2050, msgs[3].ContextTokens)
297-
assert.Equal(t, 300, msgs[3].OutputTokens)
300+
assert.Equal(t, 400, msgs[3].OutputTokens)
298301
assert.True(t, msgs[3].HasContextTokens)
299302
assert.True(t, msgs[3].HasOutputTokens)
300303
assert.NotEmpty(t, msgs[3].TokenUsage)
301304

302305
// Session totals
303-
assert.Equal(t, 500, sess.TotalOutputTokens)
306+
assert.Equal(t, 650, sess.TotalOutputTokens)
304307
assert.Equal(t, 2050, sess.PeakContextTokens)
305308
assert.True(t, sess.HasTotalOutputTokens)
306309
assert.True(t, sess.HasPeakContextTokens)
@@ -355,11 +358,18 @@ func TestParseGeminiSession_TokenUsage(t *testing.T) {
355358

356359
require.Equal(t, 2, len(msgs))
357360
assert.Equal(t, 5200, msgs[1].ContextTokens)
358-
assert.Equal(t, 800, msgs[1].OutputTokens)
361+
assert.Equal(t, 900, msgs[1].OutputTokens)
359362
assert.True(t, msgs[1].HasContextTokens)
360363
assert.True(t, msgs[1].HasOutputTokens)
361364
assert.NotEmpty(t, msgs[1].TokenUsage)
362-
assert.Equal(t, 800, sess.TotalOutputTokens)
365+
assert.Equal(t, int64(5000),
366+
gjson.GetBytes(msgs[1].TokenUsage, "input_tokens").Int())
367+
assert.Equal(t, int64(900),
368+
gjson.GetBytes(msgs[1].TokenUsage, "output_tokens").Int())
369+
assert.Equal(t, int64(200),
370+
gjson.GetBytes(msgs[1].TokenUsage,
371+
"cache_read_input_tokens").Int())
372+
assert.Equal(t, 900, sess.TotalOutputTokens)
363373
assert.Equal(t, 5200, sess.PeakContextTokens)
364374
assert.True(t, sess.HasTotalOutputTokens)
365375
assert.True(t, sess.HasPeakContextTokens)

0 commit comments

Comments
 (0)