token-lens 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +23 -0
- data/bin/token-lens +7 -0
- data/lib/token_lens/cli.rb +22 -0
- data/lib/token_lens/commands/record.rb +41 -0
- data/lib/token_lens/commands/render.rb +28 -0
- data/lib/token_lens/parser.rb +171 -0
- data/lib/token_lens/pricing.rb +51 -0
- data/lib/token_lens/renderer/annotator.rb +19 -0
- data/lib/token_lens/renderer/html.rb +667 -0
- data/lib/token_lens/renderer/layout.rb +56 -0
- data/lib/token_lens/renderer/reshaper.rb +88 -0
- data/lib/token_lens/session.rb +45 -0
- data/lib/token_lens/sources/jsonl.rb +35 -0
- data/lib/token_lens/tokens/jsonl.rb +99 -0
- data/lib/token_lens/version.rb +5 -0
- data/lib/token_lens.rb +9 -0
- metadata +71 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fc58978bdc3f4759309ded99d65da22be12a01287ea79acdb43ab873e7cb8771
|
|
4
|
+
data.tar.gz: 34c54363d13e72ed669e3f27bbf1fc8af2d3665e958d977baabfaac9ab80f849
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3f3c35dedc0cd0efd6e12b8d136aab7f4328ba4619f912f07c94727e3bd6c4c2bb4184d07a9e3bbb117dd0eaeec28edd27c5f7100f140a79b12043773a066881
|
|
7
|
+
data.tar.gz: d0767d26c77b4ba6dfec6f3a40b70c7d1dde3b3a58094fa8707b4bf57bc860612ed1af91c161b31fc3f3faa78f3db25ed6f5ddc3ae2f9422ca351084e3d5ec41
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brickell Research
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Token Lens
|
|
2
|
+
|
|
3
|
+
Basically a combination of [perf](https://perfwiki.github.io/main/) plus [flame-graphs](https://www.brendangregg.com/flamegraphs.html) for local [claude-code](https://code.claude.com/docs/en/overview) usage.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Record Data --> Interpret Data --> Render Data
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Data Sources
|
|
12
|
+
|
|
13
|
+
1. Tap OTEL console output
|
|
14
|
+
2. Tail session JSONL
|
|
15
|
+
3. Local API proxy
|
|
16
|
+
|
|
17
|
+
## Contributing
|
|
18
|
+
|
|
19
|
+
Checkout [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
20
|
+
|
|
21
|
+
## License
|
|
22
|
+
|
|
23
|
+
[MIT](./LICENSE)
|
data/bin/token-lens
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "token_lens/commands/record"
|
|
5
|
+
require "token_lens/commands/render"
|
|
6
|
+
|
|
7
|
+
module TokenLens
|
|
8
|
+
class CLI < Thor
|
|
9
|
+
desc "record", "Tail the active session and capture events to stdout"
|
|
10
|
+
option :duration_in_seconds, type: :numeric, default: 30, desc: "Seconds to record"
|
|
11
|
+
def record
|
|
12
|
+
Commands::Record.new(duration_in_seconds: options[:duration_in_seconds]).run
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc "render", "Render a captured session as a flame graph"
|
|
16
|
+
option :file_path, type: :string, required: true, desc: "Path to the captured JSON file"
|
|
17
|
+
option :output, type: :string, default: "flame.html", desc: "Output HTML path"
|
|
18
|
+
def render
|
|
19
|
+
Commands::Render.new(file_path: options[:file_path], output: options[:output]).run
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "token_lens/sources/jsonl"
|
|
5
|
+
|
|
6
|
+
module TokenLens
|
|
7
|
+
module Commands
|
|
8
|
+
class Record
|
|
9
|
+
def initialize(duration_in_seconds:)
|
|
10
|
+
@duration_in_seconds = duration_in_seconds
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
warn "Recording for #{@duration_in_seconds}s... (Ctrl+C to stop early)"
|
|
15
|
+
|
|
16
|
+
queue = Queue.new
|
|
17
|
+
events = []
|
|
18
|
+
|
|
19
|
+
thread = Thread.new { Sources::Jsonl.new(queue).start }
|
|
20
|
+
drain_thread = Thread.new { loop { events << queue.pop } }
|
|
21
|
+
|
|
22
|
+
trap("INT") { finish(thread, drain_thread, queue, events) }
|
|
23
|
+
trap("TERM") { finish(thread, drain_thread, queue, events) }
|
|
24
|
+
|
|
25
|
+
sleep @duration_in_seconds
|
|
26
|
+
finish(thread, drain_thread, queue, events)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def finish(thread, drain_thread, queue, events)
|
|
32
|
+
thread.kill
|
|
33
|
+
drain_thread.kill
|
|
34
|
+
events << queue.pop until queue.empty?
|
|
35
|
+
warn "\nCaptured #{events.size} events"
|
|
36
|
+
$stdout.puts JSON.generate(events)
|
|
37
|
+
exit 0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "token_lens/parser"
|
|
4
|
+
require "token_lens/renderer/reshaper"
|
|
5
|
+
require "token_lens/renderer/annotator"
|
|
6
|
+
require "token_lens/renderer/layout"
|
|
7
|
+
require "token_lens/renderer/html"
|
|
8
|
+
|
|
9
|
+
module TokenLens
|
|
10
|
+
module Commands
|
|
11
|
+
class Render
|
|
12
|
+
def initialize(file_path:, output:)
|
|
13
|
+
@file_path = file_path
|
|
14
|
+
@output = output
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
tree = Parser.new(file_path: @file_path).parse
|
|
19
|
+
tree = Renderer::Reshaper.new.reshape(tree)
|
|
20
|
+
Renderer::Annotator.new.annotate(tree)
|
|
21
|
+
Renderer::Layout.new.layout(tree)
|
|
22
|
+
html = Renderer::Html.new.render(tree)
|
|
23
|
+
File.write(@output, html)
|
|
24
|
+
warn "Wrote #{@output}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "token_lens/tokens/jsonl"
|
|
5
|
+
|
|
6
|
+
module TokenLens
|
|
7
|
+
class ParseError < StandardError; end
|
|
8
|
+
|
|
9
|
+
class Parser
|
|
10
|
+
def initialize(file_path:)
|
|
11
|
+
@file_path = file_path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse
|
|
15
|
+
raw_events = JSON.parse(read_file).map { |e| e["event"] }
|
|
16
|
+
tokens = raw_events
|
|
17
|
+
.map { |e| Tokens::Jsonl.from_raw(e) }
|
|
18
|
+
.select { |t| t.type == "user" || t.type == "assistant" }
|
|
19
|
+
tree = build_tree(tokens, raw_events)
|
|
20
|
+
attach_subagent_turns(tree, raw_events)
|
|
21
|
+
attach_task_notifications(tree)
|
|
22
|
+
tree
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def build_tree(tokens, raw_events = [])
|
|
28
|
+
index = tokens.each_with_object({}) { |t, h| h[t.uuid] = {token: t, children: []} }
|
|
29
|
+
|
|
30
|
+
# Build a parent map covering ALL raw events (including filtered progress/tool-result
|
|
31
|
+
# events) so we can walk through gaps when a token's direct parent was filtered out.
|
|
32
|
+
raw_parent = {}
|
|
33
|
+
raw_events.each { |e| raw_parent[e["uuid"]] = e["parentUuid"] if e["uuid"] }
|
|
34
|
+
|
|
35
|
+
roots = []
|
|
36
|
+
index.each_value do |node|
|
|
37
|
+
parent_uuid = node[:token].parent_uuid
|
|
38
|
+
# Walk up through filtered events to find the nearest indexed ancestor.
|
|
39
|
+
hops = 0
|
|
40
|
+
while parent_uuid && !index[parent_uuid] && hops < 20
|
|
41
|
+
parent_uuid = raw_parent[parent_uuid]
|
|
42
|
+
hops += 1
|
|
43
|
+
end
|
|
44
|
+
parent = parent_uuid && index[parent_uuid]
|
|
45
|
+
parent ? parent[:children] << node : roots << node
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
roots
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Extract subagent turns from agent_progress events and attach them as
|
|
52
|
+
# is_sidechain children of the assistant turn that invoked the Agent tool.
|
|
53
|
+
def attach_subagent_turns(tree, raw_events)
|
|
54
|
+
progress_by_tool_use = Hash.new { |h, k| h[k] = [] }
|
|
55
|
+
raw_events.each do |evt|
|
|
56
|
+
next unless evt["type"] == "progress"
|
|
57
|
+
next unless evt.dig("data", "type") == "agent_progress"
|
|
58
|
+
next unless evt.dig("data", "message", "type") == "assistant"
|
|
59
|
+
tool_use_id = evt["parentToolUseID"]
|
|
60
|
+
next unless tool_use_id
|
|
61
|
+
progress_by_tool_use[tool_use_id] << evt
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return if progress_by_tool_use.empty?
|
|
65
|
+
|
|
66
|
+
# Index all nodes by their tool_use content IDs
|
|
67
|
+
tool_use_node = {}
|
|
68
|
+
flatten_nodes(tree).each do |node|
|
|
69
|
+
node[:token].tool_uses.each { |tu| tool_use_node[tu["id"]] = node }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
progress_by_tool_use.each do |tool_use_id, evts|
|
|
73
|
+
parent = tool_use_node[tool_use_id]
|
|
74
|
+
next unless parent
|
|
75
|
+
parent[:children] += build_subagent_nodes(evts)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Collapse streaming chains (same requestId = one API call) and build tokens.
|
|
80
|
+
def build_subagent_nodes(events)
|
|
81
|
+
by_request = Hash.new { |h, k| h[k] = [] }
|
|
82
|
+
events.each do |evt|
|
|
83
|
+
req_id = evt.dig("data", "message", "requestId") || evt["uuid"]
|
|
84
|
+
by_request[req_id] << evt
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
by_request.values
|
|
88
|
+
.sort_by { |g| g.first["timestamp"] || "" }
|
|
89
|
+
.map { |group| subagent_token(group) }
|
|
90
|
+
.map { |t| {token: t, children: []} }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def subagent_token(group)
|
|
94
|
+
representative = group.first
|
|
95
|
+
msg_data = representative.dig("data", "message")
|
|
96
|
+
inner = msg_data["message"] || {}
|
|
97
|
+
usage = inner["usage"] || {}
|
|
98
|
+
|
|
99
|
+
# Combine tool_uses across streaming events in this API call (parallel tools)
|
|
100
|
+
combined_tool_uses = group.flat_map { |evt|
|
|
101
|
+
Array(evt.dig("data", "message", "message", "content"))
|
|
102
|
+
.select { |b| b.is_a?(Hash) && b["type"] == "tool_use" }
|
|
103
|
+
}.uniq { |tu| tu["id"] }
|
|
104
|
+
|
|
105
|
+
content = combined_tool_uses.empty? ? Array(inner["content"]) : combined_tool_uses
|
|
106
|
+
|
|
107
|
+
Tokens::Jsonl.new(
|
|
108
|
+
uuid: msg_data["uuid"] || representative["uuid"],
|
|
109
|
+
parent_uuid: nil,
|
|
110
|
+
request_id: msg_data["requestId"],
|
|
111
|
+
type: "assistant",
|
|
112
|
+
role: "assistant",
|
|
113
|
+
model: inner["model"],
|
|
114
|
+
is_sidechain: true,
|
|
115
|
+
agent_id: representative.dig("data", "agentId"),
|
|
116
|
+
content: content,
|
|
117
|
+
input_tokens: usage["input_tokens"].to_i,
|
|
118
|
+
output_tokens: usage["output_tokens"].to_i,
|
|
119
|
+
cache_read_tokens: usage["cache_read_input_tokens"].to_i,
|
|
120
|
+
cache_creation_tokens: usage["cache_creation_input_tokens"].to_i,
|
|
121
|
+
marginal_input_tokens: 0,
|
|
122
|
+
timestamp: representative["timestamp"],
|
|
123
|
+
is_compaction: false
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Wire task-notification user turns back to the Agent call that spawned them.
|
|
128
|
+
# Each <task-notification> contains a <tool-use-id> that matches an Agent
|
|
129
|
+
# tool call in the main thread. We detach the notification from its current
|
|
130
|
+
# tree position, mark it is_sidechain, and attach it under the Agent call node.
|
|
131
|
+
def attach_task_notifications(tree)
|
|
132
|
+
all = flatten_nodes(tree)
|
|
133
|
+
|
|
134
|
+
agent_node_by_tool_use = {}
|
|
135
|
+
all.each do |node|
|
|
136
|
+
node[:token].tool_uses.each do |tu|
|
|
137
|
+
next unless tu["name"] == "Agent"
|
|
138
|
+
agent_node_by_tool_use[tu["id"]] = node
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
return if agent_node_by_tool_use.empty?
|
|
142
|
+
|
|
143
|
+
all.select { |n| n[:token].is_task_notification? }.each do |node|
|
|
144
|
+
tool_use_id = node[:token].human_text
|
|
145
|
+
.match(/<tool-use-id>\s*(.*?)\s*<\/tool-use-id>/m)&.[](1)
|
|
146
|
+
next unless tool_use_id
|
|
147
|
+
agent_node = agent_node_by_tool_use[tool_use_id]
|
|
148
|
+
next unless agent_node
|
|
149
|
+
next unless remove_node(tree, node)
|
|
150
|
+
|
|
151
|
+
node[:token] = node[:token].with(is_sidechain: true)
|
|
152
|
+
agent_node[:children] << node
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def remove_node(nodes, target)
|
|
157
|
+
return true if nodes.delete(target)
|
|
158
|
+
nodes.any? { |node| remove_node(node[:children], target) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def flatten_nodes(nodes)
|
|
162
|
+
nodes.flat_map { |n| [n, *flatten_nodes(n[:children])] }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def read_file
|
|
166
|
+
File.read(@file_path)
|
|
167
|
+
rescue => e
|
|
168
|
+
raise TokenLens::ParseError, "Failed to read file: #{e.message}"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenLens
|
|
4
|
+
module Pricing
|
|
5
|
+
# Prices in USD per million tokens. Source: platform.claude.com/docs/en/about-claude/pricing
|
|
6
|
+
# Last verified: 2026-03-23
|
|
7
|
+
#
|
|
8
|
+
# cache_creation = 5-minute cache write (1.25x input). The API reports this as
|
|
9
|
+
# cache_creation_input_tokens in the usage object.
|
|
10
|
+
# cache_read = cache hit (0.1x input).
|
|
11
|
+
#
|
|
12
|
+
# Entries are matched via String#start_with? in order — put more specific prefixes first.
|
|
13
|
+
TABLE = {
|
|
14
|
+
# --- Opus 4.5 / 4.6 (new pricing tier: $5/$25) ---
|
|
15
|
+
"claude-opus-4-6" => {input: 5.0, cache_read: 0.50, cache_creation: 6.25, output: 25.0},
|
|
16
|
+
"claude-opus-4-5" => {input: 5.0, cache_read: 0.50, cache_creation: 6.25, output: 25.0},
|
|
17
|
+
|
|
18
|
+
# --- Opus 4.0 / 4.1 (original tier: $15/$75) ---
|
|
19
|
+
"claude-opus-4" => {input: 15.0, cache_read: 1.50, cache_creation: 18.75, output: 75.0},
|
|
20
|
+
|
|
21
|
+
# --- Sonnet 4.x (all variants same price) ---
|
|
22
|
+
"claude-sonnet-4" => {input: 3.0, cache_read: 0.30, cache_creation: 3.75, output: 15.0},
|
|
23
|
+
|
|
24
|
+
# --- Haiku 4.5 ---
|
|
25
|
+
"claude-haiku-4-5" => {input: 1.0, cache_read: 0.10, cache_creation: 1.25, output: 5.0},
|
|
26
|
+
|
|
27
|
+
# --- Haiku 4.x fallback ---
|
|
28
|
+
"claude-haiku-4" => {input: 1.0, cache_read: 0.10, cache_creation: 1.25, output: 5.0},
|
|
29
|
+
|
|
30
|
+
# --- Claude 3.x (legacy, new-style IDs like claude-sonnet-3-7) ---
|
|
31
|
+
"claude-sonnet-3" => {input: 3.0, cache_read: 0.30, cache_creation: 3.75, output: 15.0},
|
|
32
|
+
"claude-haiku-3-5" => {input: 0.80, cache_read: 0.08, cache_creation: 1.00, output: 4.0},
|
|
33
|
+
|
|
34
|
+
# --- Claude 3.x (old-style IDs like claude-3-opus-20240229) ---
|
|
35
|
+
"claude-3-opus" => {input: 15.0, cache_read: 1.50, cache_creation: 18.75, output: 75.0},
|
|
36
|
+
"claude-3-5-sonnet" => {input: 3.0, cache_read: 0.30, cache_creation: 3.75, output: 15.0},
|
|
37
|
+
"claude-3-sonnet" => {input: 3.0, cache_read: 0.30, cache_creation: 3.75, output: 15.0},
|
|
38
|
+
"claude-3-5-haiku" => {input: 0.80, cache_read: 0.08, cache_creation: 1.00, output: 4.0},
|
|
39
|
+
"claude-3-haiku" => {input: 0.25, cache_read: 0.03, cache_creation: 0.30, output: 1.25}
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Fallback when model string is nil or unrecognised — use Sonnet 4 rates
|
|
43
|
+
FALLBACK = {input: 3.0, cache_read: 0.30, cache_creation: 3.75, output: 15.0}.freeze
|
|
44
|
+
|
|
45
|
+
def self.for_model(model)
|
|
46
|
+
return FALLBACK unless model
|
|
47
|
+
_prefix, rates = TABLE.find { |prefix, _| model.start_with?(prefix) }
|
|
48
|
+
rates || FALLBACK
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenLens
|
|
4
|
+
module Renderer
|
|
5
|
+
class Annotator
|
|
6
|
+
def annotate(nodes, depth = 0)
|
|
7
|
+
nodes.each do |node|
|
|
8
|
+
node[:depth] = depth
|
|
9
|
+
annotate(node[:children], depth + 1)
|
|
10
|
+
child_tokens = node[:children].sum { |c| c[:subtree_tokens] }
|
|
11
|
+
child_cost = node[:children].sum { |c| c[:subtree_cost] }
|
|
12
|
+
node[:subtree_tokens] = [node[:token].display_width, 1].max + child_tokens
|
|
13
|
+
node[:subtree_cost] = node[:token].cost_usd + child_cost
|
|
14
|
+
end
|
|
15
|
+
nodes
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|