Skip to content

Commit 09a8ba8

Browse files
committed
Merge remote-tracking branch 'origin/main' into jacques/OPIK-6873-reconciliation-flag
# Conflicts: # bin/opik-logger-darwin-amd64 # bin/opik-logger-darwin-arm64 # bin/opik-logger-linux-amd64 # bin/opik-logger-windows-amd64.exe
2 parents c66bbd0 + 1fe842e commit 09a8ba8

6 files changed

Lines changed: 59 additions & 2 deletions

File tree

bin/opik-logger-darwin-amd64

0 Bytes
Binary file not shown.

bin/opik-logger-darwin-arm64

0 Bytes
Binary file not shown.

bin/opik-logger-linux-amd64

0 Bytes
Binary file not shown.

bin/opik-logger-windows-amd64.exe

0 Bytes
Binary file not shown.

src/billing.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ type billingCall struct {
134134
// with usage and the message's contiguous entry span within fullEntries.
135135
// The transcript repeats the same usage on every entry of a multi-block
136136
// message, so usage is taken once from the first entry seen.
137+
//
138+
// All-zero usage means the API never billed the call. Claude Code writes
139+
// such entries locally (`model:"<synthetic>"`, isApiErrorMessage) when a
140+
// request errors or is interrupted; treating one as a real call reconciles
141+
// the full history layout against a zero-token prompt and dumps the
142+
// usage-derived pieces into the fresh-input tier as phantom tokens.
137143
func llmCallsInTurn(fullEntries, turnEntries []TranscriptEntry) []billingCall {
138144
offset := len(fullEntries) - len(turnEntries)
139145
var calls []billingCall
@@ -150,10 +156,10 @@ func llmCallsInTurn(fullEntries, turnEntries []TranscriptEntry) []billingCall {
150156
calls[pos].entryEnd = offset + i + 1
151157
continue
152158
}
153-
if e.Message.Usage == nil {
159+
u := e.Message.Usage
160+
if u == nil || u.InputTokens+u.CacheReadInputTokens+u.CacheCreationInputTokens+u.OutputTokens == 0 {
154161
continue
155162
}
156-
u := e.Message.Usage
157163
index[id] = len(calls)
158164
calls = append(calls, billingCall{
159165
entryIdx: offset + i,

src/billing_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,54 @@ func TestBillingReconciliationFlag(t *testing.T) {
367367
t.Errorf("overshoot must surface as positive input_delta, got %v", recon)
368368
}
369369
}
370+
371+
// Claude Code writes locally fabricated assistant entries (model
372+
// "<synthetic>", isApiErrorMessage) with an all-zero usage object when an
373+
// API call errors. They were never billed: treating one as a real call
374+
// reconciles the whole history against a zero-token prompt and dumps the
375+
// usage-derived pieces into the fresh-input tier.
376+
func TestBillingSkipsSyntheticZeroUsageCalls(t *testing.T) {
377+
u1 := &Usage{InputTokens: 100, CacheCreationInputTokens: 8_000, OutputTokens: 60_000}
378+
entries := []TranscriptEntry{userPromptEntry("do the thing")}
379+
entries = append(entries, assistantCall(t, "m1", u1,
380+
Content{Type: "thinking", Thinking: "redacted"},
381+
Content{Type: "text", Text: "working on it"},
382+
)...)
383+
384+
// The synthetic error entry: zero usage, full history would be "its
385+
// request" — must be ignored entirely.
386+
entries = append(entries, assistantCall(t, "synthetic-1", &Usage{},
387+
Content{Type: "text", Text: "API error: request interrupted"},
388+
)...)
389+
390+
u2 := &Usage{InputTokens: 50, CacheReadInputTokens: 70_000,
391+
CacheCreationInputTokens: 2_000, OutputTokens: 40}
392+
entries = append(entries, assistantCall(t, "m2", u2, Content{Type: "text", Text: "done"})...)
393+
394+
snap := computeBillingSnapshot(entries, entries)
395+
if snap == nil {
396+
t.Fatal("expected billing snapshot")
397+
}
398+
if got := snap["llm_calls"].(int); got != 2 {
399+
t.Fatalf("llm_calls = %d, want 2 (synthetic call must be skipped)", got)
400+
}
401+
402+
wantRead := u1.CacheReadInputTokens + u2.CacheReadInputTokens
403+
wantWrite := u1.CacheCreationInputTokens + u2.CacheCreationInputTokens
404+
wantFresh := u1.InputTokens + u2.InputTokens
405+
wantOut := u1.OutputTokens + u2.OutputTokens
406+
407+
read, write, fresh, output, rows := billingColumnSums(snap)
408+
closeEnough := func(got, want int) bool {
409+
d := got - want
410+
if d < 0 {
411+
d = -d
412+
}
413+
return d <= rows
414+
}
415+
if !closeEnough(read, wantRead) || !closeEnough(write, wantWrite) ||
416+
!closeEnough(fresh, wantFresh) || !closeEnough(output, wantOut) {
417+
t.Errorf("Σ lanes = read %d / write %d / fresh %d / output %d, want %d/%d/%d/%d (±%d)",
418+
read, write, fresh, output, wantRead, wantWrite, wantFresh, wantOut, rows)
419+
}
420+
}

0 commit comments

Comments
 (0)