lux-hammer 0.3.17 → 0.3.21

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.
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
3
4
  require 'fileutils'
4
5
  require 'json'
6
+ require 'net/http'
5
7
  require 'open3'
6
8
  require 'shellwords'
7
9
  require 'time'
10
+ require 'uri'
8
11
 
9
12
  module LlmUsage
10
13
  UsageRow = Struct.new(
@@ -17,10 +20,30 @@ module LlmUsage
17
20
  :month_reset,
18
21
  keyword_init: true
19
22
  )
23
+ TokenRow = Struct.new(
24
+ :provider,
25
+ :model,
26
+ :day_tokens,
27
+ :week_tokens,
28
+ :month_tokens,
29
+ keyword_init: true
30
+ )
20
31
 
21
32
  CACHE_TTL ||= 180
22
33
  CACHE_DIR ||= File.expand_path('~/.cache/llm')
23
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')
24
47
  CODEX_INIT_RPC ||= '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"llm-usage","version":"1.0"}}}'
25
48
  CODEX_LIMITS_RPC ||= '{"jsonrpc":"2.0","id":2,"method":"account/rateLimits/read","params":{}}'
26
49
  module_function
@@ -32,9 +55,10 @@ module LlmUsage
32
55
  label = period_label(period)
33
56
 
34
57
  if wanted.include?(:claude)
35
- data, note = fetch_cached(:claude, cache) { fetch_claude_usage }
36
- notes << note if note
37
- notes << 'claude: month billing requires OAuth API (not available locally)' if label == 'month'
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?
38
62
  rows.concat(parse_claude(data, now: now)) if data
39
63
  end
40
64
 
@@ -59,7 +83,7 @@ module LlmUsage
59
83
  return '' if rows.empty?
60
84
 
61
85
  headers = table_headers(period)
62
- widths = column_widths(rows, headers)
86
+ widths = column_widths(rows, headers, period: period)
63
87
  lines = []
64
88
  lines << format_row(headers, widths, align: headers.map { :left })
65
89
  rows.each do |row|
@@ -68,19 +92,174 @@ module LlmUsage
68
92
  lines.join("\n")
69
93
  end
70
94
 
71
- def rows_to_json(rows, period: nil, notes: [])
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: [])
72
130
  {
73
131
  period: period_label(period),
74
132
  rows: rows.map { |row| row_to_hash(row, period: period) },
133
+ token_usage: token_rows.map { |row| token_row_to_hash(row) },
75
134
  notes: notes
76
135
  }
77
136
  end
78
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
+
79
252
  def format_pct(value)
80
253
  return '-' if value.nil?
81
254
  "#{value.round}%"
82
255
  end
83
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
+
84
263
  def format_reset_short(at, now: Time.now)
85
264
  target = parse_time(at)
86
265
  return '-' unless target
@@ -212,8 +391,99 @@ module LlmUsage
212
391
  )
213
392
  end
214
393
 
215
- def fetch_claude_usage
216
- [nil, 'claude: no local snapshot']
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
217
487
  end
218
488
 
219
489
  def fetch_codex_usage
@@ -393,9 +663,9 @@ module LlmUsage
393
663
 
394
664
  def table_headers(period)
395
665
  if period_label(period) == 'month'
396
- %w[Name month\ util month\ reset]
666
+ %w[Name month\ utilization month\ reset]
397
667
  else
398
- %w[Name session\ util session\ reset week\ util week\ reset]
668
+ %w[Name session\ utilization session\ reset week\ utilization week\ reset]
399
669
  end
400
670
  end
401
671
 
@@ -409,7 +679,8 @@ module LlmUsage
409
679
 
410
680
  def row_to_hash(row, period: nil)
411
681
  if period_label(period) == 'month'
412
- { name: row.name, month_util: row.month_pct, month_reset: row.month_reset }
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 || '-' }
413
684
  else
414
685
  {
415
686
  name: row.name,
@@ -421,11 +692,12 @@ module LlmUsage
421
692
  end
422
693
  end
423
694
 
424
- def column_widths(rows, headers)
425
- cells = [headers] + rows.map { |row| row_cells(row, nil) }
426
- headers.each_index.map do |i|
427
- cells.map { |line| line[i].to_s.length }.max
428
- end
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 }
429
701
  end
430
702
 
431
703
  def format_row(cells, widths, align: nil)
@@ -454,6 +726,71 @@ module LlmUsage
454
726
  nil
455
727
  end
456
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
+
457
794
  def window_pct(window)
458
795
  return nil unless window.is_a?(Hash)
459
796
  window['usedPercent'] || window['used_percent']
@@ -504,4 +841,4 @@ module LlmUsage
504
841
  rescue JSON::ParserError
505
842
  nil
506
843
  end
507
- end
844
+ end