Skip to content

Commit 01232ca

Browse files
authored
Merge pull request #19 from comet-ml/jacques/output-tokens-trace-level
feat: add cc.output_tokens trace-level aggregate for Sankey visualization
2 parents 4de656f + e388eb7 commit 01232ca

4 files changed

Lines changed: 142 additions & 1 deletion

File tree

src/dryrun_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ func TestDryRunOnTestThread(t *testing.T) {
1818
t.Fatal(err)
1919
}
2020
snaps := domainSnapshotsFromEntries(entries, entries)
21-
for _, domain := range []string{"tools", "skills", "user_prompts", "tool_results", "thinking", "memory", "agents", "cc_builtin", "assistant_text", "prior_assistant", "file_attachments"} {
21+
for _, domain := range []string{"tools", "skills", "user_prompts", "tool_results", "thinking", "memory", "agents", "cc_builtin", "assistant_text", "prior_assistant", "file_attachments", "output_tokens"} {
2222
fmt.Printf("--- %s ---\n", domain)
2323
if snaps[domain] == nil {
2424
fmt.Println("(nil)")

src/extractors.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,3 +705,68 @@ func extractAssistantTextSnapshot(entries []TranscriptEntry) map[string]interfac
705705
},
706706
}
707707
}
708+
709+
// extractOutputTokensSnapshot aggregates attributed output tokens by category
710+
// at the trace level. This lets the Sankey visualization use
711+
// sum(metadata.cc.output_tokens.by_category.*) directly without span
712+
// aggregation. `cc.output_tokens.{summary, by_category}`.
713+
//
714+
// Categories:
715+
// - thinking — extended thinking blocks
716+
// - assistant_text — visible text responses
717+
// - builtin_tool_use — CC built-in tools (Bash, Read, Edit, …)
718+
// - mcp_tool_use — MCP tool calls (name prefix "mcp__")
719+
// - skill_invocations — Skill tool invocations
720+
//
721+
// `parsed` should be the dedup-applied output of ParseAssistantMessages +
722+
// DeduplicateUsage. Pass nil to reparse from entries.
723+
func extractOutputTokensSnapshot(entries []TranscriptEntry, parsed []ParsedEntry) map[string]interface{} {
724+
if parsed == nil {
725+
parsed = ParseAssistantMessages(entries)
726+
DeduplicateUsage(parsed)
727+
}
728+
729+
var (
730+
thinking int
731+
assistantText int
732+
builtinToolUse int
733+
mcpToolUse int
734+
skillInvocations int
735+
)
736+
737+
for _, p := range parsed {
738+
tok := p.AttributedOutputTokens
739+
switch p.ContentType {
740+
case "thinking":
741+
thinking += tok
742+
case "text":
743+
assistantText += tok
744+
case "tool_use":
745+
switch {
746+
case strings.HasPrefix(p.Content.Name, "mcp__"):
747+
mcpToolUse += tok
748+
case p.Content.Name == "Skill":
749+
skillInvocations += tok
750+
default:
751+
builtinToolUse += tok
752+
}
753+
}
754+
}
755+
756+
total := thinking + assistantText + builtinToolUse + mcpToolUse + skillInvocations
757+
if total == 0 {
758+
return nil
759+
}
760+
return map[string]interface{}{
761+
"summary": map[string]interface{}{
762+
"total_tokens": total,
763+
},
764+
"by_category": map[string]interface{}{
765+
"thinking": thinking,
766+
"assistant_text": assistantText,
767+
"builtin_tool_use": builtinToolUse,
768+
"mcp_tool_use": mcpToolUse,
769+
"skill_invocations": skillInvocations,
770+
},
771+
}
772+
}

src/extractors_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,81 @@ import (
66
"testing"
77
)
88

9+
func TestExtractOutputTokensSnapshot(t *testing.T) {
10+
// One LLM call with thinking + text + builtin tool + MCP tool + Skill.
11+
// All blocks share the same message.id so DeduplicateUsage can attribute.
12+
const msgID = "msg_abc123"
13+
entries := []TranscriptEntry{
14+
{
15+
Type: "assistant",
16+
UUID: "u1",
17+
Message: &Message{
18+
ID: msgID,
19+
Model: "claude-opus-4-8",
20+
Usage: &Usage{OutputTokens: 1000},
21+
Content: ContentSlice{
22+
{Type: "thinking", Thinking: "..."},
23+
{Type: "text", Text: "hello world"},
24+
{Type: "tool_use", ID: "t1", Name: "Bash", Input: map[string]interface{}{"command": "ls"}},
25+
{Type: "tool_use", ID: "t2", Name: "mcp__slack__send", Input: map[string]interface{}{}},
26+
{Type: "tool_use", ID: "t3", Name: "Skill", Input: map[string]interface{}{}},
27+
},
28+
},
29+
},
30+
}
31+
32+
parsed := ParseAssistantMessages(entries)
33+
DeduplicateUsage(parsed)
34+
35+
snap := extractOutputTokensSnapshot(entries, parsed)
36+
if snap == nil {
37+
t.Fatal("expected non-nil snapshot")
38+
}
39+
40+
summary, _ := snap["summary"].(map[string]interface{})
41+
if summary == nil {
42+
t.Fatal("missing summary")
43+
}
44+
total, _ := summary["total_tokens"].(int)
45+
if total != 1000 {
46+
t.Errorf("total_tokens = %d, want 1000", total)
47+
}
48+
49+
cat, _ := snap["by_category"].(map[string]interface{})
50+
if cat == nil {
51+
t.Fatal("missing by_category")
52+
}
53+
54+
// Sum of all categories must equal total.
55+
catSum := 0
56+
for _, key := range []string{"thinking", "assistant_text", "builtin_tool_use", "mcp_tool_use", "skill_invocations"} {
57+
v, _ := cat[key].(int)
58+
catSum += v
59+
}
60+
if catSum != total {
61+
t.Errorf("sum(by_category) = %d, want %d (total_tokens)", catSum, total)
62+
}
63+
64+
// thinking must be > 0 (leftover after non-thinking blocks).
65+
if thinking, _ := cat["thinking"].(int); thinking == 0 {
66+
t.Error("thinking should be > 0")
67+
}
68+
69+
// Each non-thinking category must have been assigned something.
70+
for _, key := range []string{"assistant_text", "builtin_tool_use", "mcp_tool_use", "skill_invocations"} {
71+
if v, _ := cat[key].(int); v == 0 {
72+
t.Errorf("by_category[%s] = 0, expected > 0", key)
73+
}
74+
}
75+
}
76+
77+
func TestExtractOutputTokensSnapshotNilOnEmpty(t *testing.T) {
78+
snap := extractOutputTokensSnapshot(nil, nil)
79+
if snap != nil {
80+
t.Errorf("expected nil on empty entries, got %v", snap)
81+
}
82+
}
83+
984
func TestExtractAgentsSnapshotPrefersFrontmatterName(t *testing.T) {
1085
home := t.TempDir()
1186
cwd := t.TempDir()

src/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,7 @@ func domainSnapshotsFromEntries(fullEntries, turnEntries []TranscriptEntry) map[
767767
"file_attachments": extractFileAttachmentsSnapshot(turnEntries),
768768
"prior_assistant": extractPriorAssistantSnapshot(fullEntries, turnEntries),
769769
"assistant_text": extractAssistantTextSnapshot(turnEntries),
770+
"output_tokens": extractOutputTokensSnapshot(turnEntries, parsedTurn),
770771
// cc_builtin covers the bundled system-prompt + tool-catalog cost
771772
// /context reports under "System prompt" / "System tools" /
772773
// "System tools (deferred)". These never appear in the transcript

0 commit comments

Comments
 (0)