Skip to content

Commit 7436e92

Browse files
authored
Merge pull request #24 from comet-ml/jacques/OPIK-6873-reconciliation-flag
feat: flag token-count inconsistencies — cc.billing.reconciliation + feedback score
2 parents 1fe842e + 09a8ba8 commit 7436e92

7 files changed

Lines changed: 143 additions & 114 deletions

bin/opik-logger-darwin-amd64

-64 Bytes
Binary file not shown.

bin/opik-logger-darwin-arm64

16.1 KB
Binary file not shown.

bin/opik-logger-linux-amd64

4 KB
Binary file not shown.

bin/opik-logger-windows-amd64.exe

2 KB
Binary file not shown.

src/billing.go

Lines changed: 76 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,21 @@ func computeBillingSnapshot(fullEntries, turnEntries []TranscriptEntry) map[stri
6666
staticPieces := staticPrefixPieces(fullEntries)
6767
skillBodyNames := skillBodyNameBySHA(fullEntries)
6868
toolNames := toolUseNames(fullEntries)
69-
counts := countNewEvents(turnEntries, skillBodyNames, toolNames)
69+
70+
// Counts: one event per NEW conversation piece this turn, derived from
71+
// the SAME layout (keys, sizing, bucketing) billing re-bills every turn.
72+
// A separate scanner here once bucketed prompts by estimate while billing
73+
// bucketed by measured tokens, so counts landed in one bucket and tokens
74+
// in another. Assistant pieces are excluded: their input-lane keys would
75+
// double-count tool calls already counted via tool_result, and their
76+
// events are counted on the output side by attributeOutput under the
77+
// output item keys.
78+
counts := map[billingKey]int{}
79+
for _, p := range conversationPieces(turnEntries, skillBodyNames, toolNames) {
80+
if p.key.kind == kindUsage && !p.exact {
81+
counts[p.key]++
82+
}
83+
}
7084

7185
acc := map[billingKey]*billingTier{}
7286
totals := billingTier{}
@@ -83,7 +97,7 @@ func computeBillingSnapshot(fullEntries, turnEntries []TranscriptEntry) map[stri
8397
pieces = reconcileToUsage(pieces, float64(call.read+call.write+call.fresh))
8498
cutByPosition(pieces, float64(call.read), float64(call.write), acc)
8599

86-
attributeOutput(fullEntries[call.entryIdx:call.entryEnd], acc)
100+
attributeOutput(fullEntries[call.entryIdx:call.entryEnd], acc, counts)
87101

88102
totals.cacheRead += float64(call.read)
89103
totals.cacheCreation += float64(call.write)
@@ -399,13 +413,13 @@ func toolLane(name string) (string, string) {
399413
}
400414
}
401415

402-
// reconcileToUsage makes Σ pieces == total exactly. Overshoot shrinks the
403-
// estimated pieces first (usage-derived ones are normally already exact);
404-
// if the usage-derived pieces alone still exceed the measured total — the
405-
// request dropped content we can't see (compaction we failed to detect,
406-
// context editing) — they are scaled down too: the per-call exactness
407-
// contract outranks per-piece exactness. Undershoot appends the explicit
408-
// `unattributed` tail piece.
416+
// reconcileToUsage makes Σ pieces == total. Overshoot shrinks only the
417+
// estimated pieces (usage-derived ones are exact); undershoot appends the
418+
// explicit `unattributed` tail piece. Usage-derived pieces are NEVER
419+
// scaled, deliberately: if they alone exceed the measured prompt, the
420+
// request dropped content we failed to detect (a truncation mechanism we
421+
// don't parse yet), and the resulting Σ lanes > usage discrepancy is the
422+
// signal that finds that bug — smearing it away would hide it.
409423
func reconcileToUsage(pieces []billingPiece, total float64) []billingPiece {
410424
sum, estSum := 0.0, 0.0
411425
for _, p := range pieces {
@@ -414,27 +428,16 @@ func reconcileToUsage(pieces []billingPiece, total float64) []billingPiece {
414428
estSum += p.tokens
415429
}
416430
}
417-
exactSum := sum - estSum
418431
switch {
419-
case sum > total:
420-
if estSum > 0 {
421-
target := total - exactSum
422-
if target < 0 {
423-
target = 0
424-
}
425-
scale := target / estSum
426-
for i := range pieces {
427-
if !pieces[i].exact {
428-
pieces[i].tokens *= scale
429-
}
430-
}
432+
case sum > total && estSum > 0:
433+
target := total - (sum - estSum)
434+
if target < 0 {
435+
target = 0
431436
}
432-
if exactSum > total {
433-
scale := total / exactSum
434-
for i := range pieces {
435-
if pieces[i].exact {
436-
pieces[i].tokens *= scale
437-
}
437+
scale := target / estSum
438+
for i := range pieces {
439+
if !pieces[i].exact {
440+
pieces[i].tokens *= scale
438441
}
439442
}
440443
case sum < total:
@@ -466,8 +469,12 @@ func cutByPosition(pieces []billingPiece, read, write float64, acc map[billingKe
466469

467470
// attributeOutput books the call's own blocks against output. callEntries is
468471
// the contiguous span of the call's entries, so per-block attributed shares
469-
// sum to the call's usage.output_tokens by construction.
470-
func attributeOutput(callEntries []TranscriptEntry, acc map[billingKey]*billingTier) {
472+
// sum to the call's usage.output_tokens by construction. Each block also
473+
// bumps counts under the same key, so output items carry true event counts
474+
// (blocks emitted, tool calls made) keyed identically to their tokens.
475+
func attributeOutput(callEntries []TranscriptEntry, acc map[billingKey]*billingTier,
476+
counts map[billingKey]int) {
477+
471478
parsed := ParseAssistantMessages(callEntries)
472479
DeduplicateUsage(parsed)
473480
for _, p := range parsed {
@@ -484,63 +491,10 @@ func attributeOutput(callEntries []TranscriptEntry, acc map[billingKey]*billingT
484491
continue
485492
}
486493
tierFor(acc, key).output += float64(p.AttributedOutputTokens)
494+
counts[key]++
487495
}
488496
}
489497

490-
// countNewEvents returns the number of NEW events this turn per usage key:
491-
// prompts per bucket, tool calls per tool/server, files per ext, skill loads
492-
// per skill. Additive across traces (each event counted once, in its turn),
493-
// so plain SUM yields true counts — the same split rule as everywhere else.
494-
func countNewEvents(turnEntries []TranscriptEntry, skillBodyNames map[string]string,
495-
toolNames map[string]string) map[billingKey]int {
496-
497-
counts := map[billingKey]int{}
498-
bump := func(lane, entity string) {
499-
counts[billingKey{lane, entity, kindUsage}]++
500-
}
501-
502-
for _, e := range turnEntries {
503-
switch e.Type {
504-
case "user":
505-
if e.Message == nil || e.IsCompactSummary {
506-
continue
507-
}
508-
for _, c := range e.Message.Content {
509-
switch c.Type {
510-
case "text":
511-
if _, ok := skillBodyNames[sha256hex(c.Text)]; ok {
512-
continue // loads counted via buildLoadedSkillBodies below
513-
}
514-
bump("user_prompts", promptBucket(tokEstimateAs(c.Text, "user_prompt")))
515-
case "tool_result":
516-
lane, entity := toolLane(toolNames[c.ToolUseID])
517-
bump(lane, entity)
518-
}
519-
}
520-
case "attachment":
521-
if e.Attachment == nil || e.Attachment.Type != "file" {
522-
continue
523-
}
524-
var w struct {
525-
File struct {
526-
Path string `json:"path,omitempty"`
527-
} `json:"file"`
528-
}
529-
if json.Unmarshal(e.Attachment.Content, &w) == nil {
530-
ext := strings.ToLower(filepath.Ext(w.File.Path))
531-
if ext == "" {
532-
ext = "other"
533-
}
534-
bump("file_attachments", ext)
535-
}
536-
}
537-
}
538-
for _, l := range buildLoadedSkillBodies(turnEntries) {
539-
bump("skills", l.Name)
540-
}
541-
return counts
542-
}
543-
544498
func tierFor(acc map[billingKey]*billingTier, key billingKey) *billingTier {
545499
t, ok := acc[key]
546500
if !ok {
@@ -636,7 +590,12 @@ func renderBillingSnapshot(callCount int, totals billingTier,
636590
}
637591

638592
lanes := map[string]interface{}{}
593+
laneSum := billingTier{}
639594
for lane, t := range laneTiers {
595+
laneSum.cacheRead += t.cacheRead
596+
laneSum.cacheCreation += t.cacheCreation
597+
laneSum.fresh += t.fresh
598+
laneSum.output += t.output
640599
obj := tierFields(t)
641600
if items := laneItems[lane]; len(items) > 0 {
642601
sort.Slice(items, func(i, j int) bool {
@@ -660,10 +619,42 @@ func renderBillingSnapshot(callCount int, totals billingTier,
660619
"input": round(totals.fresh),
661620
"output": round(totals.output),
662621
},
663-
"lanes": lanes,
622+
"lanes": lanes,
623+
"reconciliation": reconciliation(laneSum, totals),
664624
}
665625
}
666626

627+
// reconciliation reports Σ lanes minus usage per tier column. Healthy
628+
// attribution reconciles to ~0 (the unattributed lane absorbs undershoot);
629+
// a non-zero delta means the layout disagrees with what the API billed —
630+
// usually a truncation mechanism we don't detect yet. It is emitted on
631+
// every trace so inconsistencies are monitorable instead of latent.
632+
func reconciliation(laneSum, totals billingTier) map[string]interface{} {
633+
delta := func(got, want float64) int {
634+
d := got - want
635+
if d < 0 {
636+
return -round(-d)
637+
}
638+
return round(d)
639+
}
640+
deltas := map[string]int{
641+
"input_delta": delta(laneSum.fresh, totals.fresh),
642+
"cache_read_delta": delta(laneSum.cacheRead, totals.cacheRead),
643+
"cache_creation_delta": delta(laneSum.cacheCreation, totals.cacheCreation),
644+
"output_delta": delta(laneSum.output, totals.output),
645+
}
646+
consistent := true
647+
out := map[string]interface{}{}
648+
for k, v := range deltas {
649+
out[k] = v
650+
if v != 0 {
651+
consistent = false
652+
}
653+
}
654+
out["consistent"] = consistent
655+
return out
656+
}
657+
667658
func round(f float64) int { return int(f + 0.5) }
668659

669660
func minF(a, b float64) float64 {

src/billing_test.go

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -332,41 +332,39 @@ func TestBillingCompactBoundaryTruncatesReplay(t *testing.T) {
332332
}
333333
}
334334

335-
// Safety net: when usage-derived replay pieces alone exceed the call's
336-
// measured prompt (undetected truncation), they must be scaled down so the
337-
// per-call exactness contract still holds — the overshoot must never land
338-
// in the fresh-input tier.
339-
func TestBillingExactOvershootIsClamped(t *testing.T) {
340-
entries := []TranscriptEntry{userPromptEntry("hi")}
341-
entries = append(entries, assistantCall(t, "m1",
342-
&Usage{InputTokens: 20, OutputTokens: 50_000},
343-
Content{Type: "thinking", Thinking: "redacted"},
344-
Content{Type: "text", Text: "done"},
345-
)...)
346-
u2 := &Usage{InputTokens: 40, CacheReadInputTokens: 1_000, OutputTokens: 10}
347-
entries = append(entries, assistantCall(t, "m2", u2, Content{Type: "text", Text: "ok"})...)
335+
// cc.billing.reconciliation reports Σ lanes minus usage per tier column —
336+
// the monitorable health flag. Healthy attribution reconciles to zero;
337+
// when usage-derived pieces exceed the billed prompt (a truncation we
338+
// don't detect), consistent flips false and the input delta is positive.
339+
func TestBillingReconciliationFlag(t *testing.T) {
340+
u1 := &Usage{InputTokens: 900, CacheCreationInputTokens: 30_000, OutputTokens: 250}
341+
entries := []TranscriptEntry{userPromptEntry("please do the thing")}
342+
entries = append(entries, assistantCall(t, "m1", u1,
343+
Content{Type: "text", Text: strings.Repeat("plan ", 40)})...)
348344

349345
snap := computeBillingSnapshot(entries, entries)
350-
if snap == nil {
351-
t.Fatal("expected billing snapshot")
346+
recon := snap["reconciliation"].(map[string]interface{})
347+
if !recon["consistent"].(bool) {
348+
t.Errorf("healthy turn must reconcile, got %v", recon)
352349
}
353350

354-
wantRead := u2.CacheReadInputTokens
355-
wantFresh := 20 + u2.InputTokens
356-
wantOut := 50_000 + u2.OutputTokens
351+
// Now an undetected truncation: a 50k-output call (thinking carries the
352+
// usage-derived mass) replayed against a tiny billed prompt.
353+
big := &Usage{InputTokens: 900, CacheCreationInputTokens: 30_000, OutputTokens: 50_000}
354+
entries = []TranscriptEntry{userPromptEntry("please do the thing")}
355+
entries = append(entries, assistantCall(t, "m1", big,
356+
Content{Type: "thinking", Thinking: "redacted"},
357+
Content{Type: "text", Text: "done"})...)
358+
u2 := &Usage{InputTokens: 40, CacheReadInputTokens: 1_000, OutputTokens: 10}
359+
entries = append(entries, assistantCall(t, "m2", u2, Content{Type: "text", Text: "ok"})...)
357360

358-
read, write, fresh, output, rows := billingColumnSums(snap)
359-
closeEnough := func(got, want int) bool {
360-
d := got - want
361-
if d < 0 {
362-
d = -d
363-
}
364-
return d <= rows
361+
snap = computeBillingSnapshot(entries, entries)
362+
recon = snap["reconciliation"].(map[string]interface{})
363+
if recon["consistent"].(bool) {
364+
t.Fatalf("exact overshoot must flip consistent=false, got %v", recon)
365365
}
366-
if !closeEnough(read, wantRead) || !closeEnough(write, 0) ||
367-
!closeEnough(fresh, wantFresh) || !closeEnough(output, wantOut) {
368-
t.Errorf("Σ lanes = read %d / write %d / fresh %d / output %d, want %d/0/%d/%d (±%d)",
369-
read, write, fresh, output, wantRead, wantFresh, wantOut, rows)
366+
if recon["input_delta"].(int) <= 0 {
367+
t.Errorf("overshoot must surface as positive input_delta, got %v", recon)
370368
}
371369
}
372370

src/metrics.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"fmt"
45
"os"
56
"os/exec"
67
"strconv"
@@ -236,6 +237,7 @@ func postTraceMetrics(state *State) {
236237
}
237238

238239
mergeMetadataCC(state.TraceID, metrics)
240+
postReconciliationScore(state.TraceID, metrics["billing"])
239241

240242
var files, authored, overwritten int
241243
if agg != nil {
@@ -293,3 +295,41 @@ func inferCwd() string {
293295
}
294296
return ""
295297
}
298+
299+
// postReconciliationScore mirrors cc.billing.reconciliation as a trace
300+
// feedback score so inconsistent traces are filterable in the UI:
301+
// token_count_consistent = 1 when Σ lanes == API usage on every tier
302+
// column, 0 otherwise (the per-column deltas land in the reason).
303+
func postReconciliationScore(traceID string, billing interface{}) {
304+
snap, ok := billing.(map[string]interface{})
305+
if !ok {
306+
return
307+
}
308+
recon, ok := snap["reconciliation"].(map[string]interface{})
309+
if !ok {
310+
return
311+
}
312+
313+
value := 0.0
314+
if consistent, _ := recon["consistent"].(bool); consistent {
315+
value = 1.0
316+
}
317+
score := map[string]interface{}{
318+
"id": traceID,
319+
"name": "token_count_consistent",
320+
"value": value,
321+
"source": "sdk",
322+
}
323+
if value == 0 {
324+
score["reason"] = fmt.Sprintf(
325+
"Σ lanes minus API usage: input %+d, cache_read %+d, cache_creation %+d, output %+d",
326+
recon["input_delta"], recon["cache_read_delta"],
327+
recon["cache_creation_delta"], recon["output_delta"])
328+
}
329+
330+
if err := api.Put("/traces/feedback-scores", map[string]interface{}{
331+
"scores": []interface{}{score},
332+
}); err != nil {
333+
debugLog("post reconciliation score: %v", err)
334+
}
335+
}

0 commit comments

Comments
 (0)