diff --git a/bin/opik-logger-darwin-amd64 b/bin/opik-logger-darwin-amd64 index a403c7e..c97e52f 100755 Binary files a/bin/opik-logger-darwin-amd64 and b/bin/opik-logger-darwin-amd64 differ diff --git a/bin/opik-logger-darwin-arm64 b/bin/opik-logger-darwin-arm64 index c31f5db..68df2a0 100755 Binary files a/bin/opik-logger-darwin-arm64 and b/bin/opik-logger-darwin-arm64 differ diff --git a/bin/opik-logger-linux-amd64 b/bin/opik-logger-linux-amd64 index 3fa55fa..980a006 100755 Binary files a/bin/opik-logger-linux-amd64 and b/bin/opik-logger-linux-amd64 differ diff --git a/bin/opik-logger-windows-amd64.exe b/bin/opik-logger-windows-amd64.exe index a5ff9fa..337c0a1 100755 Binary files a/bin/opik-logger-windows-amd64.exe and b/bin/opik-logger-windows-amd64.exe differ diff --git a/src/billing.go b/src/billing.go index be3dfb5..7a440a6 100644 --- a/src/billing.go +++ b/src/billing.go @@ -120,6 +120,12 @@ type billingCall struct { // with usage and the message's contiguous entry span within fullEntries. // The transcript repeats the same usage on every entry of a multi-block // message, so usage is taken once from the first entry seen. +// +// All-zero usage means the API never billed the call. Claude Code writes +// such entries locally (`model:""`, isApiErrorMessage) when a +// request errors or is interrupted; treating one as a real call reconciles +// the full history layout against a zero-token prompt and dumps the +// usage-derived pieces into the fresh-input tier as phantom tokens. func llmCallsInTurn(fullEntries, turnEntries []TranscriptEntry) []billingCall { offset := len(fullEntries) - len(turnEntries) var calls []billingCall @@ -136,10 +142,10 @@ func llmCallsInTurn(fullEntries, turnEntries []TranscriptEntry) []billingCall { calls[pos].entryEnd = offset + i + 1 continue } - if e.Message.Usage == nil { + u := e.Message.Usage + if u == nil || u.InputTokens+u.CacheReadInputTokens+u.CacheCreationInputTokens+u.OutputTokens == 0 { continue } - u := e.Message.Usage index[id] = len(calls) calls = append(calls, billingCall{ entryIdx: offset + i, diff --git a/src/billing_test.go b/src/billing_test.go index eb8feb5..91a7b67 100644 --- a/src/billing_test.go +++ b/src/billing_test.go @@ -369,3 +369,54 @@ func TestBillingExactOvershootIsClamped(t *testing.T) { read, write, fresh, output, wantRead, wantFresh, wantOut, rows) } } + +// Claude Code writes locally fabricated assistant entries (model +// "", isApiErrorMessage) with an all-zero usage object when an +// API call errors. They were never billed: treating one as a real call +// reconciles the whole history against a zero-token prompt and dumps the +// usage-derived pieces into the fresh-input tier. +func TestBillingSkipsSyntheticZeroUsageCalls(t *testing.T) { + u1 := &Usage{InputTokens: 100, CacheCreationInputTokens: 8_000, OutputTokens: 60_000} + entries := []TranscriptEntry{userPromptEntry("do the thing")} + entries = append(entries, assistantCall(t, "m1", u1, + Content{Type: "thinking", Thinking: "redacted"}, + Content{Type: "text", Text: "working on it"}, + )...) + + // The synthetic error entry: zero usage, full history would be "its + // request" — must be ignored entirely. + entries = append(entries, assistantCall(t, "synthetic-1", &Usage{}, + Content{Type: "text", Text: "API error: request interrupted"}, + )...) + + u2 := &Usage{InputTokens: 50, CacheReadInputTokens: 70_000, + CacheCreationInputTokens: 2_000, OutputTokens: 40} + entries = append(entries, assistantCall(t, "m2", u2, Content{Type: "text", Text: "done"})...) + + snap := computeBillingSnapshot(entries, entries) + if snap == nil { + t.Fatal("expected billing snapshot") + } + if got := snap["llm_calls"].(int); got != 2 { + t.Fatalf("llm_calls = %d, want 2 (synthetic call must be skipped)", got) + } + + wantRead := u1.CacheReadInputTokens + u2.CacheReadInputTokens + wantWrite := u1.CacheCreationInputTokens + u2.CacheCreationInputTokens + wantFresh := u1.InputTokens + u2.InputTokens + wantOut := u1.OutputTokens + u2.OutputTokens + + read, write, fresh, output, rows := billingColumnSums(snap) + closeEnough := func(got, want int) bool { + d := got - want + if d < 0 { + d = -d + } + return d <= rows + } + if !closeEnough(read, wantRead) || !closeEnough(write, wantWrite) || + !closeEnough(fresh, wantFresh) || !closeEnough(output, wantOut) { + t.Errorf("Σ lanes = read %d / write %d / fresh %d / output %d, want %d/%d/%d/%d (±%d)", + read, write, fresh, output, wantRead, wantWrite, wantFresh, wantOut, rows) + } +}