lux-hammer 0.3.16 → 0.3.20
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 +4 -4
- data/.version +1 -1
- data/AGENTS.md +16 -6
- data/README.md +83 -13
- data/lib/hammer/builder.rb +6 -1
- data/lib/hammer/input.rb +121 -0
- data/lib/hammer/option.rb +23 -9
- data/lib/hammer/parser.rb +12 -14
- data/lib/lux-hammer.rb +98 -16
- data/recipes/lib/deploy/commands.rb +1 -1
- data/recipes/lib/deploy/hammer.rb +1 -1
- data/recipes/lib/llm/usage.rb +844 -0
- data/recipes/lib/llm/wrap.rb +691 -0
- data/recipes/llm.rb +130 -4
- metadata +5 -2
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
require 'open3'
|
|
8
|
+
require 'shellwords'
|
|
9
|
+
require 'time'
|
|
10
|
+
require 'uri'
|
|
11
|
+
|
|
12
|
+
module LlmUsage
|
|
13
|
+
UsageRow = Struct.new(
|
|
14
|
+
:name,
|
|
15
|
+
:session_pct,
|
|
16
|
+
:session_reset,
|
|
17
|
+
:week_pct,
|
|
18
|
+
:week_reset,
|
|
19
|
+
:month_pct,
|
|
20
|
+
:month_reset,
|
|
21
|
+
keyword_init: true
|
|
22
|
+
)
|
|
23
|
+
TokenRow = Struct.new(
|
|
24
|
+
:provider,
|
|
25
|
+
:model,
|
|
26
|
+
:day_tokens,
|
|
27
|
+
:week_tokens,
|
|
28
|
+
:month_tokens,
|
|
29
|
+
keyword_init: true
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
CACHE_TTL ||= 180
|
|
33
|
+
CACHE_DIR ||= File.expand_path('~/.cache/llm')
|
|
34
|
+
GROK_LOG_PATH ||= File.expand_path('~/.grok/logs/unified.jsonl')
|
|
35
|
+
GROK_SESSION_GLOB ||= File.expand_path('~/.grok/sessions/**/signals.json')
|
|
36
|
+
GROK_SUMMARY_GLOB ||= File.expand_path('~/.grok/sessions/**/summary.json')
|
|
37
|
+
CLAUDE_SESSION_GLOB ||= File.expand_path('~/.claude/projects/**/*.jsonl')
|
|
38
|
+
# Written by the statusline hook (~/.claude/statusline-command.sh) from its
|
|
39
|
+
# `rate_limits` input; mtime is the observation time.
|
|
40
|
+
CLAUDE_LIMITS_PATH ||= File.expand_path('~/.cache/llm/claude-limits.json')
|
|
41
|
+
# Live fallback + the only source of `extra_usage` (the month view).
|
|
42
|
+
CLAUDE_USAGE_URL ||= 'https://api.anthropic.com/api/oauth/usage'
|
|
43
|
+
CLAUDE_OAUTH_BETA ||= 'oauth-2025-04-20'
|
|
44
|
+
CLAUDE_KEYCHAIN_SERVICE ||= 'Claude Code-credentials'
|
|
45
|
+
CLAUDE_CREDENTIALS_PATH ||= File.expand_path('~/.claude/.credentials.json')
|
|
46
|
+
CODEX_SESSION_GLOB ||= File.expand_path('~/.codex/sessions/**/rollout-*.jsonl')
|
|
47
|
+
CODEX_INIT_RPC ||= '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"llm-usage","version":"1.0"}}}'
|
|
48
|
+
CODEX_LIMITS_RPC ||= '{"jsonrpc":"2.0","id":2,"method":"account/rateLimits/read","params":{}}'
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
def collect_rows(period: nil, providers: nil, now: Time.now, cache: true)
|
|
52
|
+
wanted = normalize_providers(providers)
|
|
53
|
+
rows = []
|
|
54
|
+
notes = []
|
|
55
|
+
label = period_label(period)
|
|
56
|
+
|
|
57
|
+
if wanted.include?(:claude)
|
|
58
|
+
# The snapshot path is deliberately uncached: it's one small local file,
|
|
59
|
+
# and caching would freeze the staleness note. Only the API call caches.
|
|
60
|
+
data, note = fetch_claude_usage(period: label, cache: cache, now: now)
|
|
61
|
+
notes << note unless note.nil? || note.empty?
|
|
62
|
+
rows.concat(parse_claude(data, now: now)) if data
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if wanted.include?(:codex)
|
|
66
|
+
data, note = fetch_cached(:codex, cache) { fetch_codex_usage }
|
|
67
|
+
notes << note if note
|
|
68
|
+
row = parse_codex(data, now: now)
|
|
69
|
+
rows << row if row
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if wanted.include?(:grok)
|
|
73
|
+
data, note = fetch_cached(:grok, cache) { fetch_grok_usage }
|
|
74
|
+
notes << note if note
|
|
75
|
+
row = parse_grok(data, now: now)
|
|
76
|
+
rows << row if row
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
[rows, notes, label]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def render_table(rows, period: nil)
|
|
83
|
+
return '' if rows.empty?
|
|
84
|
+
|
|
85
|
+
headers = table_headers(period)
|
|
86
|
+
widths = column_widths(rows, headers, period: period)
|
|
87
|
+
lines = []
|
|
88
|
+
lines << format_row(headers, widths, align: headers.map { :left })
|
|
89
|
+
rows.each do |row|
|
|
90
|
+
lines << format_row(row_cells(row, period), widths)
|
|
91
|
+
end
|
|
92
|
+
lines.join("\n")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_token_table(rows)
|
|
96
|
+
return '' if rows.empty?
|
|
97
|
+
|
|
98
|
+
headers = ['Provider', 'Model', 'day (mil)', 'week (mil)', 'month (mil)']
|
|
99
|
+
cells = rows.map do |row|
|
|
100
|
+
[
|
|
101
|
+
row.provider,
|
|
102
|
+
row.model,
|
|
103
|
+
format_tokens(row.day_tokens),
|
|
104
|
+
format_tokens(row.week_tokens),
|
|
105
|
+
format_tokens(row.month_tokens)
|
|
106
|
+
]
|
|
107
|
+
end
|
|
108
|
+
totals = rows.each_with_object(day: 0, week: 0, month: 0) do |row, sum|
|
|
109
|
+
sum[:day] += row.day_tokens.to_i
|
|
110
|
+
sum[:week] += row.week_tokens.to_i
|
|
111
|
+
sum[:month] += row.month_tokens.to_i
|
|
112
|
+
end
|
|
113
|
+
total = [
|
|
114
|
+
'Total',
|
|
115
|
+
'',
|
|
116
|
+
format_tokens(totals[:day]),
|
|
117
|
+
format_tokens(totals[:week]),
|
|
118
|
+
format_tokens(totals[:month])
|
|
119
|
+
]
|
|
120
|
+
widths = cell_widths([headers] + cells + [total])
|
|
121
|
+
lines = [format_row(headers, widths, align: headers.map { :left })]
|
|
122
|
+
alignment = [:left, :left, :right, :right, :right]
|
|
123
|
+
cells.each { |line| lines << format_row(line, widths, align: alignment) }
|
|
124
|
+
lines << (' ' * (widths[0] + widths[1] + 4)) + ('-' * (widths[2..].sum + 4))
|
|
125
|
+
lines << format_row(total, widths, align: alignment)
|
|
126
|
+
lines.join("\n")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def rows_to_json(rows, period: nil, notes: [], token_rows: [])
|
|
130
|
+
{
|
|
131
|
+
period: period_label(period),
|
|
132
|
+
rows: rows.map { |row| row_to_hash(row, period: period) },
|
|
133
|
+
token_usage: token_rows.map { |row| token_row_to_hash(row) },
|
|
134
|
+
notes: notes
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def collect_token_rows(providers: nil, now: Time.now)
|
|
139
|
+
wanted = normalize_providers(providers)
|
|
140
|
+
rows = []
|
|
141
|
+
rows.concat(aggregate_claude_tokens(now: now)) if wanted.include?(:claude)
|
|
142
|
+
rows.concat(aggregate_codex_tokens(now: now)) if wanted.include?(:codex)
|
|
143
|
+
rows.concat(aggregate_grok_tokens(now: now)) if wanted.include?(:grok)
|
|
144
|
+
rows.sort_by { |row| [row.provider, row.model] }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def aggregate_codex_tokens(paths: nil, now: Time.now)
|
|
148
|
+
totals = token_totals
|
|
149
|
+
starts = calendar_period_starts(now)
|
|
150
|
+
session_paths(paths || Dir.glob(CODEX_SESSION_GLOB)).each do |path|
|
|
151
|
+
model = nil
|
|
152
|
+
File.foreach(path) do |line|
|
|
153
|
+
data = JSON.parse(line)
|
|
154
|
+
payload = data['payload'] || {}
|
|
155
|
+
model = payload['model'] if data['type'] == 'turn_context' && payload['model']
|
|
156
|
+
next unless payload['type'] == 'token_count' && model
|
|
157
|
+
|
|
158
|
+
usage = payload.dig('info', 'last_token_usage')
|
|
159
|
+
add_token_total(totals, model, data['timestamp'], usage&.dig('total_tokens'), starts, now)
|
|
160
|
+
rescue JSON::ParserError
|
|
161
|
+
next
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
build_token_rows('codex', totals)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def aggregate_claude_tokens(paths: nil, now: Time.now)
|
|
168
|
+
entries = {}
|
|
169
|
+
session_paths(paths || Dir.glob(CLAUDE_SESSION_GLOB)).each do |path|
|
|
170
|
+
File.foreach(path) do |line|
|
|
171
|
+
data = JSON.parse(line)
|
|
172
|
+
message = data['message'] || {}
|
|
173
|
+
usage = message['usage']
|
|
174
|
+
model = message['model']
|
|
175
|
+
next unless usage.is_a?(Hash) && model && model != '<synthetic>'
|
|
176
|
+
|
|
177
|
+
total = claude_token_total(usage)
|
|
178
|
+
key = message['id'] || data['uuid'] || [path, data['timestamp'], total]
|
|
179
|
+
current = entries[key]
|
|
180
|
+
if !current || total > current[:total]
|
|
181
|
+
entries[key] = { model: model, at: data['timestamp'], total: total }
|
|
182
|
+
end
|
|
183
|
+
rescue JSON::ParserError
|
|
184
|
+
next
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
totals = token_totals
|
|
189
|
+
starts = calendar_period_starts(now)
|
|
190
|
+
entries.each_value do |entry|
|
|
191
|
+
add_token_total(totals, entry[:model], entry[:at], entry[:total], starts, now)
|
|
192
|
+
end
|
|
193
|
+
build_token_rows('claude', totals)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Sum actual API tokens from unified.jsonl inference events.
|
|
197
|
+
# Session signals.json only stores current context size (snapshot), not
|
|
198
|
+
# cumulative usage — that undercounted by an order of magnitude.
|
|
199
|
+
def aggregate_grok_tokens(log_path: nil, model_map: nil, now: Time.now)
|
|
200
|
+
path = log_path || GROK_LOG_PATH
|
|
201
|
+
return [] unless File.file?(path)
|
|
202
|
+
|
|
203
|
+
totals = token_totals
|
|
204
|
+
starts = calendar_period_starts(now)
|
|
205
|
+
sid_models = model_map || grok_session_model_map
|
|
206
|
+
|
|
207
|
+
File.foreach(path) do |line|
|
|
208
|
+
data = JSON.parse(line)
|
|
209
|
+
next unless data['msg'] == 'shell.turn.inference_done'
|
|
210
|
+
|
|
211
|
+
ctx = data['ctx'] || {}
|
|
212
|
+
tokens = ctx['prompt_tokens'].to_i + ctx['completion_tokens'].to_i
|
|
213
|
+
next unless tokens.positive?
|
|
214
|
+
|
|
215
|
+
model = sid_models[data['sid']] || 'unknown'
|
|
216
|
+
add_token_total(totals, model, data['ts'], tokens, starts, now)
|
|
217
|
+
rescue JSON::ParserError
|
|
218
|
+
next
|
|
219
|
+
end
|
|
220
|
+
build_token_rows('grok', totals)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def grok_session_model_map(paths: nil)
|
|
224
|
+
map = {}
|
|
225
|
+
# Prefer signals.json; fall back to summary.json (active sessions often
|
|
226
|
+
# write summary before signals).
|
|
227
|
+
files = if paths
|
|
228
|
+
session_paths(paths)
|
|
229
|
+
else
|
|
230
|
+
session_paths(Dir.glob(GROK_SESSION_GLOB) + Dir.glob(GROK_SUMMARY_GLOB))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
files.each do |path|
|
|
234
|
+
data = read_json(path) || {}
|
|
235
|
+
model = if File.basename(path) == 'signals.json'
|
|
236
|
+
summary = read_json(File.join(File.dirname(path), 'summary.json')) || {}
|
|
237
|
+
data['primaryModelId'] || summary['current_model_id']
|
|
238
|
+
else
|
|
239
|
+
data['current_model_id'] || data['primaryModelId']
|
|
240
|
+
end
|
|
241
|
+
next unless model
|
|
242
|
+
|
|
243
|
+
sid = File.basename(File.dirname(path))
|
|
244
|
+
# signals win over summary if both exist
|
|
245
|
+
next if map.key?(sid) && File.basename(path) == 'summary.json'
|
|
246
|
+
|
|
247
|
+
map[sid] = model
|
|
248
|
+
end
|
|
249
|
+
map
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def format_pct(value)
|
|
253
|
+
return '-' if value.nil?
|
|
254
|
+
"#{value.round}%"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def format_tokens(value)
|
|
258
|
+
whole, fraction = format('%.1f', value.to_i / 1_000_000.0).split('.')
|
|
259
|
+
grouped = whole.reverse.gsub(/(\d{3})(?=\d)/, '\1_').reverse
|
|
260
|
+
"#{grouped}.#{fraction}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def format_reset_short(at, now: Time.now)
|
|
264
|
+
target = parse_time(at)
|
|
265
|
+
return '-' unless target
|
|
266
|
+
|
|
267
|
+
remaining = (target - now).to_i
|
|
268
|
+
return '-' if remaining <= 0
|
|
269
|
+
|
|
270
|
+
days, rem = remaining.divmod(86_400)
|
|
271
|
+
if days >= 1
|
|
272
|
+
hours = rem / 3600
|
|
273
|
+
parts = ["#{days}d"]
|
|
274
|
+
parts << "#{hours}h" if hours.positive?
|
|
275
|
+
return parts.join(' ')
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
hours, hour_rem = remaining.divmod(3600)
|
|
279
|
+
if hours >= 1
|
|
280
|
+
mins = hour_rem / 60
|
|
281
|
+
parts = ["#{hours}h"]
|
|
282
|
+
parts << "#{mins}m" if mins.positive?
|
|
283
|
+
return parts.join(' ')
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
mins = remaining / 60
|
|
287
|
+
return '-' if mins <= 0
|
|
288
|
+
|
|
289
|
+
"#{mins}m"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def parse_claude(data, now: Time.now)
|
|
293
|
+
return [] unless data.is_a?(Hash)
|
|
294
|
+
|
|
295
|
+
five = data['five_hour']
|
|
296
|
+
session_pct = five&.dig('utilization')
|
|
297
|
+
session_reset = five&.dig('resets_at')
|
|
298
|
+
month = claude_month_fields(data['extra_usage'])
|
|
299
|
+
|
|
300
|
+
opus = data['seven_day_opus']
|
|
301
|
+
sonnet = data['seven_day_sonnet']
|
|
302
|
+
rows = []
|
|
303
|
+
|
|
304
|
+
if opus.is_a?(Hash) || sonnet.is_a?(Hash)
|
|
305
|
+
if opus.is_a?(Hash)
|
|
306
|
+
rows << UsageRow.new(
|
|
307
|
+
name: 'Opus',
|
|
308
|
+
session_pct: format_pct(session_pct),
|
|
309
|
+
session_reset: format_reset_short(session_reset, now: now),
|
|
310
|
+
week_pct: format_pct(opus['utilization']),
|
|
311
|
+
week_reset: format_reset_short(opus['resets_at'], now: now),
|
|
312
|
+
month_pct: month[:pct],
|
|
313
|
+
month_reset: month[:reset]
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
if sonnet.is_a?(Hash)
|
|
317
|
+
rows << UsageRow.new(
|
|
318
|
+
name: 'Sonnet',
|
|
319
|
+
session_pct: '-',
|
|
320
|
+
session_reset: '-',
|
|
321
|
+
week_pct: format_pct(sonnet['utilization']),
|
|
322
|
+
week_reset: format_reset_short(sonnet['resets_at'], now: now),
|
|
323
|
+
month_pct: '-',
|
|
324
|
+
month_reset: '-'
|
|
325
|
+
)
|
|
326
|
+
end
|
|
327
|
+
return rows
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
week = data['seven_day']
|
|
331
|
+
return [] unless five.is_a?(Hash) || week.is_a?(Hash)
|
|
332
|
+
|
|
333
|
+
[
|
|
334
|
+
UsageRow.new(
|
|
335
|
+
name: 'Claude',
|
|
336
|
+
session_pct: format_pct(session_pct),
|
|
337
|
+
session_reset: format_reset_short(session_reset, now: now),
|
|
338
|
+
week_pct: format_pct(week&.dig('utilization')),
|
|
339
|
+
week_reset: format_reset_short(week&.dig('resets_at'), now: now),
|
|
340
|
+
month_pct: month[:pct],
|
|
341
|
+
month_reset: month[:reset]
|
|
342
|
+
)
|
|
343
|
+
]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def parse_codex(data, now: Time.now)
|
|
347
|
+
return nil unless data.is_a?(Hash)
|
|
348
|
+
|
|
349
|
+
limits = data['rateLimits']
|
|
350
|
+
limits ||= codex_whitelist_to_rate_limits(data) if data['rate_limit']
|
|
351
|
+
return nil unless limits
|
|
352
|
+
|
|
353
|
+
primary = limits['primary'] || limits['primary_window']
|
|
354
|
+
secondary = limits['secondary'] || limits['secondary_window']
|
|
355
|
+
session = nil
|
|
356
|
+
week = nil
|
|
357
|
+
[primary, secondary].compact.each do |window|
|
|
358
|
+
mins = window['windowDurationMins'] || (window['limit_window_seconds'].to_i / 60)
|
|
359
|
+
if mins <= 360
|
|
360
|
+
session = window
|
|
361
|
+
elsif mins >= 1440
|
|
362
|
+
week = window
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
week ||= primary if primary && !session
|
|
366
|
+
session ||= primary if primary && !week
|
|
367
|
+
|
|
368
|
+
UsageRow.new(
|
|
369
|
+
name: 'Codex',
|
|
370
|
+
session_pct: format_pct(window_pct(session)),
|
|
371
|
+
session_reset: format_reset_short(window_reset(session), now: now),
|
|
372
|
+
week_pct: format_pct(window_pct(week)),
|
|
373
|
+
week_reset: format_reset_short(window_reset(week), now: now)
|
|
374
|
+
)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def parse_grok(data, now: Time.now)
|
|
378
|
+
return nil unless data.is_a?(Hash)
|
|
379
|
+
|
|
380
|
+
config = data['config'] || data
|
|
381
|
+
week_pct = config['creditUsagePercent']
|
|
382
|
+
week_reset = config['billingPeriodEnd'] || config.dig('currentPeriod', 'end')
|
|
383
|
+
build = Array(config['productUsage']).find { |p| p['product'] == 'GrokBuild' }
|
|
384
|
+
|
|
385
|
+
UsageRow.new(
|
|
386
|
+
name: 'Grok',
|
|
387
|
+
session_pct: '-',
|
|
388
|
+
session_reset: '-',
|
|
389
|
+
week_pct: format_pct(build ? build['usagePercent'] : week_pct),
|
|
390
|
+
week_reset: format_reset_short(week_reset, now: now)
|
|
391
|
+
)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# The statusline snapshot carries five_hour/seven_day but no `extra_usage`,
|
|
395
|
+
# so the month view has to go to the OAuth API. The default view prefers the
|
|
396
|
+
# snapshot (instant, offline, no token) and only calls out when it's absent.
|
|
397
|
+
def fetch_claude_usage(period: 'default', cache: true, now: Time.now, path: CLAUDE_LIMITS_PATH)
|
|
398
|
+
return fetch_cached(:claude, cache) { fetch_claude_oauth_usage } if period == 'month'
|
|
399
|
+
|
|
400
|
+
data, note = fetch_claude_snapshot(now: now, path: path)
|
|
401
|
+
return [data, note] if data
|
|
402
|
+
|
|
403
|
+
api_data, api_note = fetch_cached(:claude, cache) { fetch_claude_oauth_usage }
|
|
404
|
+
return [api_data, api_note] if api_data
|
|
405
|
+
|
|
406
|
+
[nil, [note, api_note].compact.join('; ')]
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def fetch_claude_snapshot(now: Time.now, path: CLAUDE_LIMITS_PATH)
|
|
410
|
+
snapshot = read_json(path)
|
|
411
|
+
return [nil, 'claude: no local snapshot (statusline writer not installed)'] unless snapshot.is_a?(Hash)
|
|
412
|
+
|
|
413
|
+
data = normalize_claude_limits(snapshot, now: now)
|
|
414
|
+
return [nil, 'claude: snapshot has no live windows'] if data.empty?
|
|
415
|
+
|
|
416
|
+
age = now - File.mtime(path)
|
|
417
|
+
note = age >= 300 ? format_age_note('claude: snapshot from', age) : nil
|
|
418
|
+
[data, note]
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Same payload Claude Code's own /usage reads: five_hour, seven_day,
|
|
422
|
+
# seven_day_opus/_sonnet and extra_usage, already in parse_claude's shape.
|
|
423
|
+
def fetch_claude_oauth_usage(now: Time.now)
|
|
424
|
+
token = claude_oauth_token(now: now)
|
|
425
|
+
return [nil, 'claude: no usable OAuth token (run `claude` to sign in or refresh)'] unless token
|
|
426
|
+
|
|
427
|
+
uri = URI(CLAUDE_USAGE_URL)
|
|
428
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 3, read_timeout: 5) do |http|
|
|
429
|
+
http.get(uri.request_uri, 'Authorization' => "Bearer #{token}", 'anthropic-beta' => CLAUDE_OAUTH_BETA)
|
|
430
|
+
end
|
|
431
|
+
return [nil, "claude: usage API returned #{response.code}"] unless response.is_a?(Net::HTTPSuccess)
|
|
432
|
+
|
|
433
|
+
data = JSON.parse(response.body)
|
|
434
|
+
return [nil, 'claude: usage API returned no windows'] unless data.is_a?(Hash)
|
|
435
|
+
|
|
436
|
+
[data, nil]
|
|
437
|
+
rescue JSON::ParserError
|
|
438
|
+
[nil, 'claude: usage API returned malformed JSON']
|
|
439
|
+
rescue StandardError => e
|
|
440
|
+
[nil, "claude: usage API unreachable (#{e.message})"]
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Credentials file (Linux) first, then the macOS keychain. An expired token
|
|
444
|
+
# is treated as absent rather than refreshed here — that's Claude Code's job,
|
|
445
|
+
# and refreshing behind its back would invalidate its copy.
|
|
446
|
+
def claude_oauth_token(now: Time.now, path: CLAUDE_CREDENTIALS_PATH)
|
|
447
|
+
creds = read_json(path) || claude_keychain_credentials
|
|
448
|
+
oauth = creds.is_a?(Hash) ? creds['claudeAiOauth'] : nil
|
|
449
|
+
return nil unless oauth.is_a?(Hash)
|
|
450
|
+
|
|
451
|
+
token = oauth['accessToken']
|
|
452
|
+
return nil if token.nil? || token.empty?
|
|
453
|
+
|
|
454
|
+
expires_at = oauth['expiresAt']
|
|
455
|
+
return nil if expires_at && Time.at(expires_at.to_i / 1000) <= now
|
|
456
|
+
|
|
457
|
+
token
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def claude_keychain_credentials
|
|
461
|
+
out, status = Open3.capture2('security', 'find-generic-password', '-s', CLAUDE_KEYCHAIN_SERVICE, '-w')
|
|
462
|
+
return nil unless status.success?
|
|
463
|
+
|
|
464
|
+
JSON.parse(out)
|
|
465
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
466
|
+
nil
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Statusline `rate_limits` -> the shape parse_claude reads. Windows whose
|
|
470
|
+
# reset time has already passed are dropped: the window rolled over, so the
|
|
471
|
+
# recorded percentage no longer describes anything.
|
|
472
|
+
def normalize_claude_limits(snapshot, now: Time.now)
|
|
473
|
+
return {} unless snapshot.is_a?(Hash)
|
|
474
|
+
|
|
475
|
+
%w[five_hour seven_day seven_day_opus seven_day_sonnet].each_with_object({}) do |key, out|
|
|
476
|
+
window = snapshot[key]
|
|
477
|
+
next unless window.is_a?(Hash)
|
|
478
|
+
|
|
479
|
+
resets_at = parse_time(window['resets_at'])
|
|
480
|
+
next if resets_at.nil? || resets_at <= now
|
|
481
|
+
|
|
482
|
+
out[key] = {
|
|
483
|
+
'utilization' => window['used_percentage'] || window['utilization'],
|
|
484
|
+
'resets_at' => window['resets_at']
|
|
485
|
+
}
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def fetch_codex_usage
|
|
490
|
+
data, note = codex_app_server_rate_limits
|
|
491
|
+
return [data, note] if data
|
|
492
|
+
|
|
493
|
+
data, fallback_note = codex_session_rate_limits
|
|
494
|
+
return [data, fallback_note] if data
|
|
495
|
+
|
|
496
|
+
[nil, note || fallback_note || 'codex: no local rate limits']
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def fetch_grok_usage
|
|
500
|
+
grok_log_billing_config
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def codex_app_server_rate_limits
|
|
504
|
+
bin = find_executable('codex')
|
|
505
|
+
return [nil, 'codex: not in PATH'] unless bin
|
|
506
|
+
|
|
507
|
+
pipe = Shellwords.shelljoin([bin, 'app-server'])
|
|
508
|
+
cmd = "( printf '%s\\n' #{Shellwords.escape(CODEX_INIT_RPC)}; sleep 0.2; " \
|
|
509
|
+
"printf '%s\\n' #{Shellwords.escape(CODEX_LIMITS_RPC)}; sleep 2 ) | #{pipe}"
|
|
510
|
+
out, status = Open3.capture2('sh', '-c', cmd)
|
|
511
|
+
return [nil, 'codex: app-server rate limits unavailable'] unless status.success?
|
|
512
|
+
|
|
513
|
+
limits = nil
|
|
514
|
+
out.each_line do |line|
|
|
515
|
+
msg = JSON.parse(line)
|
|
516
|
+
next unless msg['id'] == 2 && msg['result']
|
|
517
|
+
|
|
518
|
+
limits = msg.dig('result', 'rateLimits')
|
|
519
|
+
rescue JSON::ParserError
|
|
520
|
+
next
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
return [nil, 'codex: app-server rate limits unavailable'] unless limits.is_a?(Hash)
|
|
524
|
+
|
|
525
|
+
[{ 'rateLimits' => limits }, nil]
|
|
526
|
+
rescue Errno::ENOENT
|
|
527
|
+
[nil, 'codex: not in PATH']
|
|
528
|
+
rescue StandardError => e
|
|
529
|
+
[nil, "codex: #{e.message}"]
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def codex_session_rate_limits
|
|
533
|
+
pattern = File.expand_path('~/.codex/sessions/**/rollout-*.jsonl')
|
|
534
|
+
files = Dir.glob(pattern)
|
|
535
|
+
return [nil, nil] if files.empty?
|
|
536
|
+
|
|
537
|
+
newest = files.max_by { |f| File.mtime(f) }
|
|
538
|
+
limits = nil
|
|
539
|
+
File.foreach(newest) do |line|
|
|
540
|
+
next if line.strip.empty?
|
|
541
|
+
|
|
542
|
+
data = JSON.parse(line)
|
|
543
|
+
next unless data.dig('payload', 'type') == 'token_count'
|
|
544
|
+
|
|
545
|
+
rl = data.dig('payload', 'rate_limits')
|
|
546
|
+
limits = rl if rl.is_a?(Hash)
|
|
547
|
+
rescue JSON::ParserError
|
|
548
|
+
next
|
|
549
|
+
end
|
|
550
|
+
return [nil, nil] unless limits
|
|
551
|
+
|
|
552
|
+
mapped = {
|
|
553
|
+
'rateLimits' => {
|
|
554
|
+
'primary' => map_codex_session_window(limits['primary']),
|
|
555
|
+
'secondary' => map_codex_session_window(limits['secondary'])
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
date = File.mtime(newest).strftime('%b %-d')
|
|
559
|
+
[mapped, "codex: from session log (#{date}, may be stale)"]
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def grok_log_billing_config
|
|
563
|
+
return [nil, 'grok: no billing log (run grok once)'] unless File.file?(GROK_LOG_PATH)
|
|
564
|
+
|
|
565
|
+
entry = reverse_find_jsonl(GROK_LOG_PATH) { |obj| obj['msg'] == 'billing: fetched credits config' }
|
|
566
|
+
return [nil, 'grok: no billing log (run grok once)'] unless entry
|
|
567
|
+
|
|
568
|
+
config = entry.dig('ctx', 'config')
|
|
569
|
+
return [nil, 'grok: billing log missing config'] unless config.is_a?(Hash)
|
|
570
|
+
|
|
571
|
+
note = grok_staleness_note(entry['ts'], now: Time.now)
|
|
572
|
+
[{ 'config' => config }, note]
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def reverse_find_jsonl(path)
|
|
576
|
+
File.readlines(path).reverse_each do |line|
|
|
577
|
+
next if line.strip.empty?
|
|
578
|
+
|
|
579
|
+
obj = JSON.parse(line)
|
|
580
|
+
return obj if yield(obj)
|
|
581
|
+
rescue JSON::ParserError
|
|
582
|
+
next
|
|
583
|
+
end
|
|
584
|
+
nil
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def grok_staleness_note(ts, now: Time.now)
|
|
588
|
+
return nil unless ts
|
|
589
|
+
|
|
590
|
+
logged_at = Time.parse(ts)
|
|
591
|
+
age = now - logged_at
|
|
592
|
+
return nil if age < 3600
|
|
593
|
+
|
|
594
|
+
format_age_note('grok: billing log from', age)
|
|
595
|
+
rescue ArgumentError
|
|
596
|
+
nil
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def format_age_note(prefix, seconds)
|
|
600
|
+
if seconds >= 86_400
|
|
601
|
+
days = (seconds / 86_400).to_i
|
|
602
|
+
"#{prefix} #{days}d ago"
|
|
603
|
+
elsif seconds >= 3600
|
|
604
|
+
hours = (seconds / 3600).to_i
|
|
605
|
+
"#{prefix} #{hours}h ago"
|
|
606
|
+
else
|
|
607
|
+
mins = (seconds / 60).to_i
|
|
608
|
+
"#{prefix} #{mins}m ago"
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def find_executable(cmd)
|
|
613
|
+
path, status = Open3.capture2('sh', '-c', "command -v #{cmd}")
|
|
614
|
+
path = path.strip
|
|
615
|
+
return nil unless status.success? && !path.empty? && File.executable?(path)
|
|
616
|
+
|
|
617
|
+
path
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def fetch_cached(provider, use_cache)
|
|
621
|
+
return yield unless use_cache
|
|
622
|
+
|
|
623
|
+
path = cache_path(provider)
|
|
624
|
+
if File.file?(path)
|
|
625
|
+
age = Time.now - File.mtime(path)
|
|
626
|
+
if age < CACHE_TTL
|
|
627
|
+
payload = read_json(path)
|
|
628
|
+
return [payload['data'], payload['note']] if payload && payload.key?('data')
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
data, note = yield
|
|
633
|
+
if data
|
|
634
|
+
FileUtils.mkdir_p(CACHE_DIR)
|
|
635
|
+
File.write(path, JSON.generate('fetched_at' => Time.now.iso8601, 'data' => data, 'note' => note))
|
|
636
|
+
end
|
|
637
|
+
[data, note]
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def cache_path(provider)
|
|
641
|
+
File.join(CACHE_DIR, "usage-#{provider}.json")
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def normalize_providers(providers)
|
|
645
|
+
list = Array(providers).map(&:to_s).map(&:downcase).reject(&:empty?)
|
|
646
|
+
return %i[claude codex grok] if list.empty?
|
|
647
|
+
|
|
648
|
+
valid = %w[claude codex grok]
|
|
649
|
+
bad = list - valid
|
|
650
|
+
raise Hammer::Error, "unknown provider(s): #{bad.join(', ')} (valid: #{valid.join(', ')})" unless bad.empty?
|
|
651
|
+
|
|
652
|
+
list.map(&:to_sym)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def period_label(period)
|
|
656
|
+
case period.to_s.downcase
|
|
657
|
+
when '', 'default', 'session', 'week' then 'default'
|
|
658
|
+
when 'month' then 'month'
|
|
659
|
+
else
|
|
660
|
+
raise Hammer::Error, "unknown period: #{period} (valid: week, month)"
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def table_headers(period)
|
|
665
|
+
if period_label(period) == 'month'
|
|
666
|
+
%w[Name month\ utilization month\ reset]
|
|
667
|
+
else
|
|
668
|
+
%w[Name session\ utilization session\ reset week\ utilization week\ reset]
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def row_cells(row, period)
|
|
673
|
+
if period_label(period) == 'month'
|
|
674
|
+
[row.name, row.month_pct || '-', row.month_reset || '-']
|
|
675
|
+
else
|
|
676
|
+
[row.name, row.session_pct, row.session_reset, row.week_pct, row.week_reset]
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def row_to_hash(row, period: nil)
|
|
681
|
+
if period_label(period) == 'month'
|
|
682
|
+
# Mirror row_cells: providers with no month window render '-', not null.
|
|
683
|
+
{ name: row.name, month_util: row.month_pct || '-', month_reset: row.month_reset || '-' }
|
|
684
|
+
else
|
|
685
|
+
{
|
|
686
|
+
name: row.name,
|
|
687
|
+
session_util: row.session_pct,
|
|
688
|
+
session_reset: row.session_reset,
|
|
689
|
+
week_util: row.week_pct,
|
|
690
|
+
week_reset: row.week_reset
|
|
691
|
+
}
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def column_widths(rows, headers, period: nil)
|
|
696
|
+
cell_widths([headers] + rows.map { |row| row_cells(row, period) })
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def cell_widths(cells)
|
|
700
|
+
cells.first.each_index.map { |i| cells.map { |line| line[i].to_s.length }.max }
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def format_row(cells, widths, align: nil)
|
|
704
|
+
align ||= [:left] + [:right] * (cells.length - 1)
|
|
705
|
+
cells.each_with_index.map do |cell, i|
|
|
706
|
+
text = cell.to_s
|
|
707
|
+
if align[i] == :right
|
|
708
|
+
text.rjust(widths[i])
|
|
709
|
+
else
|
|
710
|
+
text.ljust(widths[i])
|
|
711
|
+
end
|
|
712
|
+
end.join(' ')
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def parse_time(at)
|
|
716
|
+
case at
|
|
717
|
+
when nil then nil
|
|
718
|
+
when Numeric then Time.at(at.to_i)
|
|
719
|
+
when String
|
|
720
|
+
return Time.at(at.to_i) if at.match?(/\A\d+\z/)
|
|
721
|
+
Time.parse(at)
|
|
722
|
+
else
|
|
723
|
+
nil
|
|
724
|
+
end
|
|
725
|
+
rescue ArgumentError
|
|
726
|
+
nil
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def calendar_period_starts(now)
|
|
730
|
+
day_date = Date.new(now.year, now.month, now.day)
|
|
731
|
+
week_date = day_date - ((day_date.wday + 6) % 7)
|
|
732
|
+
month_date = Date.new(now.year, now.month, 1)
|
|
733
|
+
{
|
|
734
|
+
day: calendar_midnight(day_date, now),
|
|
735
|
+
week: calendar_midnight(week_date, now),
|
|
736
|
+
month: calendar_midnight(month_date, now)
|
|
737
|
+
}
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def calendar_midnight(date, now)
|
|
741
|
+
if now.utc?
|
|
742
|
+
Time.utc(date.year, date.month, date.day)
|
|
743
|
+
else
|
|
744
|
+
Time.new(date.year, date.month, date.day, 0, 0, 0, now.utc_offset)
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def session_paths(paths)
|
|
749
|
+
Array(paths).reject { |path| File.basename(path).include?('.tmp.') }
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def token_totals
|
|
753
|
+
Hash.new { |hash, model| hash[model] = { day: 0, week: 0, month: 0 } }
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def add_token_total(totals, model, at, value, starts, now)
|
|
757
|
+
timestamp = parse_time(at)
|
|
758
|
+
count = value.to_i
|
|
759
|
+
return unless timestamp && timestamp <= now && count.positive?
|
|
760
|
+
|
|
761
|
+
totals[model][:month] += count if timestamp >= starts[:month]
|
|
762
|
+
totals[model][:week] += count if timestamp >= starts[:week]
|
|
763
|
+
totals[model][:day] += count if timestamp >= starts[:day]
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def build_token_rows(provider, totals)
|
|
767
|
+
totals.map do |model, periods|
|
|
768
|
+
TokenRow.new(
|
|
769
|
+
provider: provider,
|
|
770
|
+
model: model,
|
|
771
|
+
day_tokens: periods[:day],
|
|
772
|
+
week_tokens: periods[:week],
|
|
773
|
+
month_tokens: periods[:month]
|
|
774
|
+
)
|
|
775
|
+
end.sort_by(&:model)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def claude_token_total(usage)
|
|
779
|
+
%w[input_tokens cache_creation_input_tokens cache_read_input_tokens output_tokens].sum do |key|
|
|
780
|
+
usage[key].to_i
|
|
781
|
+
end
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def token_row_to_hash(row)
|
|
785
|
+
{
|
|
786
|
+
provider: row.provider,
|
|
787
|
+
model: row.model,
|
|
788
|
+
day_tokens: row.day_tokens,
|
|
789
|
+
week_tokens: row.week_tokens,
|
|
790
|
+
month_tokens: row.month_tokens
|
|
791
|
+
}
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def window_pct(window)
|
|
795
|
+
return nil unless window.is_a?(Hash)
|
|
796
|
+
window['usedPercent'] || window['used_percent']
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
def window_reset(window)
|
|
800
|
+
return nil unless window.is_a?(Hash)
|
|
801
|
+
window['resetsAt'] || window['reset_at']
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
def map_codex_window(window)
|
|
805
|
+
return nil unless window.is_a?(Hash)
|
|
806
|
+
{
|
|
807
|
+
'used_percent' => window['used_percent'],
|
|
808
|
+
'limit_window_seconds' => window['limit_window_seconds'],
|
|
809
|
+
'reset_at' => window['reset_at']
|
|
810
|
+
}
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def map_codex_session_window(window)
|
|
814
|
+
return nil unless window.is_a?(Hash)
|
|
815
|
+
{
|
|
816
|
+
'used_percent' => window['used_percent'],
|
|
817
|
+
'windowDurationMins' => window['window_minutes'],
|
|
818
|
+
'resetsAt' => window['resets_at']
|
|
819
|
+
}
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def claude_month_fields(extra)
|
|
823
|
+
return { pct: '-', reset: '-' } unless extra.is_a?(Hash) && extra['is_enabled']
|
|
824
|
+
|
|
825
|
+
{
|
|
826
|
+
pct: format_pct(extra['utilization']),
|
|
827
|
+
reset: '-'
|
|
828
|
+
}
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
def codex_whitelist_to_rate_limits(data)
|
|
832
|
+
{
|
|
833
|
+
'primary' => map_codex_window(data.dig('rate_limit', 'primary_window')),
|
|
834
|
+
'secondary' => map_codex_window(data.dig('rate_limit', 'secondary_window'))
|
|
835
|
+
}
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def read_json(path)
|
|
839
|
+
return nil unless path && File.file?(path)
|
|
840
|
+
JSON.parse(File.read(path))
|
|
841
|
+
rescue JSON::ParserError
|
|
842
|
+
nil
|
|
843
|
+
end
|
|
844
|
+
end
|