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.
@@ -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