lux-hammer 0.3.16 → 0.3.17
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/recipes/lib/llm/usage.rb +507 -0
- data/recipes/llm.rb +42 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3fcc893fd7391f6b7cdd1694fd243729b3319344727a88bcf8599cdd1d23e6d
|
|
4
|
+
data.tar.gz: f722dd982a954141f551832b8d742ba317d5aff3430611929dd2a261df550545
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 43d2baaf2c2451279d8d9ffb1d78209cff145471781858401efa313c274914795ec03aa078be8b1b083d7ed89bf6fe327b8d592c6e4a25ed6744e1d15b382fe7
|
|
7
|
+
data.tar.gz: 37e28dc827e1492f3ac5efd346edd2bdafac3bd6d53a6e267f8f4a49937813d46ca514ecf9a2bb0f39ec6b52e8b098ee04f4dc58633948c9bf5e042e3512707c
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.17
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'shellwords'
|
|
7
|
+
require 'time'
|
|
8
|
+
|
|
9
|
+
module LlmUsage
|
|
10
|
+
UsageRow = Struct.new(
|
|
11
|
+
:name,
|
|
12
|
+
:session_pct,
|
|
13
|
+
:session_reset,
|
|
14
|
+
:week_pct,
|
|
15
|
+
:week_reset,
|
|
16
|
+
:month_pct,
|
|
17
|
+
:month_reset,
|
|
18
|
+
keyword_init: true
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
CACHE_TTL ||= 180
|
|
22
|
+
CACHE_DIR ||= File.expand_path('~/.cache/llm')
|
|
23
|
+
GROK_LOG_PATH ||= File.expand_path('~/.grok/logs/unified.jsonl')
|
|
24
|
+
CODEX_INIT_RPC ||= '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"llm-usage","version":"1.0"}}}'
|
|
25
|
+
CODEX_LIMITS_RPC ||= '{"jsonrpc":"2.0","id":2,"method":"account/rateLimits/read","params":{}}'
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def collect_rows(period: nil, providers: nil, now: Time.now, cache: true)
|
|
29
|
+
wanted = normalize_providers(providers)
|
|
30
|
+
rows = []
|
|
31
|
+
notes = []
|
|
32
|
+
label = period_label(period)
|
|
33
|
+
|
|
34
|
+
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'
|
|
38
|
+
rows.concat(parse_claude(data, now: now)) if data
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if wanted.include?(:codex)
|
|
42
|
+
data, note = fetch_cached(:codex, cache) { fetch_codex_usage }
|
|
43
|
+
notes << note if note
|
|
44
|
+
row = parse_codex(data, now: now)
|
|
45
|
+
rows << row if row
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if wanted.include?(:grok)
|
|
49
|
+
data, note = fetch_cached(:grok, cache) { fetch_grok_usage }
|
|
50
|
+
notes << note if note
|
|
51
|
+
row = parse_grok(data, now: now)
|
|
52
|
+
rows << row if row
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
[rows, notes, label]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_table(rows, period: nil)
|
|
59
|
+
return '' if rows.empty?
|
|
60
|
+
|
|
61
|
+
headers = table_headers(period)
|
|
62
|
+
widths = column_widths(rows, headers)
|
|
63
|
+
lines = []
|
|
64
|
+
lines << format_row(headers, widths, align: headers.map { :left })
|
|
65
|
+
rows.each do |row|
|
|
66
|
+
lines << format_row(row_cells(row, period), widths)
|
|
67
|
+
end
|
|
68
|
+
lines.join("\n")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def rows_to_json(rows, period: nil, notes: [])
|
|
72
|
+
{
|
|
73
|
+
period: period_label(period),
|
|
74
|
+
rows: rows.map { |row| row_to_hash(row, period: period) },
|
|
75
|
+
notes: notes
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def format_pct(value)
|
|
80
|
+
return '-' if value.nil?
|
|
81
|
+
"#{value.round}%"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def format_reset_short(at, now: Time.now)
|
|
85
|
+
target = parse_time(at)
|
|
86
|
+
return '-' unless target
|
|
87
|
+
|
|
88
|
+
remaining = (target - now).to_i
|
|
89
|
+
return '-' if remaining <= 0
|
|
90
|
+
|
|
91
|
+
days, rem = remaining.divmod(86_400)
|
|
92
|
+
if days >= 1
|
|
93
|
+
hours = rem / 3600
|
|
94
|
+
parts = ["#{days}d"]
|
|
95
|
+
parts << "#{hours}h" if hours.positive?
|
|
96
|
+
return parts.join(' ')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
hours, hour_rem = remaining.divmod(3600)
|
|
100
|
+
if hours >= 1
|
|
101
|
+
mins = hour_rem / 60
|
|
102
|
+
parts = ["#{hours}h"]
|
|
103
|
+
parts << "#{mins}m" if mins.positive?
|
|
104
|
+
return parts.join(' ')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
mins = remaining / 60
|
|
108
|
+
return '-' if mins <= 0
|
|
109
|
+
|
|
110
|
+
"#{mins}m"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def parse_claude(data, now: Time.now)
|
|
114
|
+
return [] unless data.is_a?(Hash)
|
|
115
|
+
|
|
116
|
+
five = data['five_hour']
|
|
117
|
+
session_pct = five&.dig('utilization')
|
|
118
|
+
session_reset = five&.dig('resets_at')
|
|
119
|
+
month = claude_month_fields(data['extra_usage'])
|
|
120
|
+
|
|
121
|
+
opus = data['seven_day_opus']
|
|
122
|
+
sonnet = data['seven_day_sonnet']
|
|
123
|
+
rows = []
|
|
124
|
+
|
|
125
|
+
if opus.is_a?(Hash) || sonnet.is_a?(Hash)
|
|
126
|
+
if opus.is_a?(Hash)
|
|
127
|
+
rows << UsageRow.new(
|
|
128
|
+
name: 'Opus',
|
|
129
|
+
session_pct: format_pct(session_pct),
|
|
130
|
+
session_reset: format_reset_short(session_reset, now: now),
|
|
131
|
+
week_pct: format_pct(opus['utilization']),
|
|
132
|
+
week_reset: format_reset_short(opus['resets_at'], now: now),
|
|
133
|
+
month_pct: month[:pct],
|
|
134
|
+
month_reset: month[:reset]
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
if sonnet.is_a?(Hash)
|
|
138
|
+
rows << UsageRow.new(
|
|
139
|
+
name: 'Sonnet',
|
|
140
|
+
session_pct: '-',
|
|
141
|
+
session_reset: '-',
|
|
142
|
+
week_pct: format_pct(sonnet['utilization']),
|
|
143
|
+
week_reset: format_reset_short(sonnet['resets_at'], now: now),
|
|
144
|
+
month_pct: '-',
|
|
145
|
+
month_reset: '-'
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
return rows
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
week = data['seven_day']
|
|
152
|
+
return [] unless five.is_a?(Hash) || week.is_a?(Hash)
|
|
153
|
+
|
|
154
|
+
[
|
|
155
|
+
UsageRow.new(
|
|
156
|
+
name: 'Claude',
|
|
157
|
+
session_pct: format_pct(session_pct),
|
|
158
|
+
session_reset: format_reset_short(session_reset, now: now),
|
|
159
|
+
week_pct: format_pct(week&.dig('utilization')),
|
|
160
|
+
week_reset: format_reset_short(week&.dig('resets_at'), now: now),
|
|
161
|
+
month_pct: month[:pct],
|
|
162
|
+
month_reset: month[:reset]
|
|
163
|
+
)
|
|
164
|
+
]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parse_codex(data, now: Time.now)
|
|
168
|
+
return nil unless data.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
limits = data['rateLimits']
|
|
171
|
+
limits ||= codex_whitelist_to_rate_limits(data) if data['rate_limit']
|
|
172
|
+
return nil unless limits
|
|
173
|
+
|
|
174
|
+
primary = limits['primary'] || limits['primary_window']
|
|
175
|
+
secondary = limits['secondary'] || limits['secondary_window']
|
|
176
|
+
session = nil
|
|
177
|
+
week = nil
|
|
178
|
+
[primary, secondary].compact.each do |window|
|
|
179
|
+
mins = window['windowDurationMins'] || (window['limit_window_seconds'].to_i / 60)
|
|
180
|
+
if mins <= 360
|
|
181
|
+
session = window
|
|
182
|
+
elsif mins >= 1440
|
|
183
|
+
week = window
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
week ||= primary if primary && !session
|
|
187
|
+
session ||= primary if primary && !week
|
|
188
|
+
|
|
189
|
+
UsageRow.new(
|
|
190
|
+
name: 'Codex',
|
|
191
|
+
session_pct: format_pct(window_pct(session)),
|
|
192
|
+
session_reset: format_reset_short(window_reset(session), now: now),
|
|
193
|
+
week_pct: format_pct(window_pct(week)),
|
|
194
|
+
week_reset: format_reset_short(window_reset(week), now: now)
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def parse_grok(data, now: Time.now)
|
|
199
|
+
return nil unless data.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
config = data['config'] || data
|
|
202
|
+
week_pct = config['creditUsagePercent']
|
|
203
|
+
week_reset = config['billingPeriodEnd'] || config.dig('currentPeriod', 'end')
|
|
204
|
+
build = Array(config['productUsage']).find { |p| p['product'] == 'GrokBuild' }
|
|
205
|
+
|
|
206
|
+
UsageRow.new(
|
|
207
|
+
name: 'Grok',
|
|
208
|
+
session_pct: '-',
|
|
209
|
+
session_reset: '-',
|
|
210
|
+
week_pct: format_pct(build ? build['usagePercent'] : week_pct),
|
|
211
|
+
week_reset: format_reset_short(week_reset, now: now)
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def fetch_claude_usage
|
|
216
|
+
[nil, 'claude: no local snapshot']
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def fetch_codex_usage
|
|
220
|
+
data, note = codex_app_server_rate_limits
|
|
221
|
+
return [data, note] if data
|
|
222
|
+
|
|
223
|
+
data, fallback_note = codex_session_rate_limits
|
|
224
|
+
return [data, fallback_note] if data
|
|
225
|
+
|
|
226
|
+
[nil, note || fallback_note || 'codex: no local rate limits']
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def fetch_grok_usage
|
|
230
|
+
grok_log_billing_config
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def codex_app_server_rate_limits
|
|
234
|
+
bin = find_executable('codex')
|
|
235
|
+
return [nil, 'codex: not in PATH'] unless bin
|
|
236
|
+
|
|
237
|
+
pipe = Shellwords.shelljoin([bin, 'app-server'])
|
|
238
|
+
cmd = "( printf '%s\\n' #{Shellwords.escape(CODEX_INIT_RPC)}; sleep 0.2; " \
|
|
239
|
+
"printf '%s\\n' #{Shellwords.escape(CODEX_LIMITS_RPC)}; sleep 2 ) | #{pipe}"
|
|
240
|
+
out, status = Open3.capture2('sh', '-c', cmd)
|
|
241
|
+
return [nil, 'codex: app-server rate limits unavailable'] unless status.success?
|
|
242
|
+
|
|
243
|
+
limits = nil
|
|
244
|
+
out.each_line do |line|
|
|
245
|
+
msg = JSON.parse(line)
|
|
246
|
+
next unless msg['id'] == 2 && msg['result']
|
|
247
|
+
|
|
248
|
+
limits = msg.dig('result', 'rateLimits')
|
|
249
|
+
rescue JSON::ParserError
|
|
250
|
+
next
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
return [nil, 'codex: app-server rate limits unavailable'] unless limits.is_a?(Hash)
|
|
254
|
+
|
|
255
|
+
[{ 'rateLimits' => limits }, nil]
|
|
256
|
+
rescue Errno::ENOENT
|
|
257
|
+
[nil, 'codex: not in PATH']
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
[nil, "codex: #{e.message}"]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def codex_session_rate_limits
|
|
263
|
+
pattern = File.expand_path('~/.codex/sessions/**/rollout-*.jsonl')
|
|
264
|
+
files = Dir.glob(pattern)
|
|
265
|
+
return [nil, nil] if files.empty?
|
|
266
|
+
|
|
267
|
+
newest = files.max_by { |f| File.mtime(f) }
|
|
268
|
+
limits = nil
|
|
269
|
+
File.foreach(newest) do |line|
|
|
270
|
+
next if line.strip.empty?
|
|
271
|
+
|
|
272
|
+
data = JSON.parse(line)
|
|
273
|
+
next unless data.dig('payload', 'type') == 'token_count'
|
|
274
|
+
|
|
275
|
+
rl = data.dig('payload', 'rate_limits')
|
|
276
|
+
limits = rl if rl.is_a?(Hash)
|
|
277
|
+
rescue JSON::ParserError
|
|
278
|
+
next
|
|
279
|
+
end
|
|
280
|
+
return [nil, nil] unless limits
|
|
281
|
+
|
|
282
|
+
mapped = {
|
|
283
|
+
'rateLimits' => {
|
|
284
|
+
'primary' => map_codex_session_window(limits['primary']),
|
|
285
|
+
'secondary' => map_codex_session_window(limits['secondary'])
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
date = File.mtime(newest).strftime('%b %-d')
|
|
289
|
+
[mapped, "codex: from session log (#{date}, may be stale)"]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def grok_log_billing_config
|
|
293
|
+
return [nil, 'grok: no billing log (run grok once)'] unless File.file?(GROK_LOG_PATH)
|
|
294
|
+
|
|
295
|
+
entry = reverse_find_jsonl(GROK_LOG_PATH) { |obj| obj['msg'] == 'billing: fetched credits config' }
|
|
296
|
+
return [nil, 'grok: no billing log (run grok once)'] unless entry
|
|
297
|
+
|
|
298
|
+
config = entry.dig('ctx', 'config')
|
|
299
|
+
return [nil, 'grok: billing log missing config'] unless config.is_a?(Hash)
|
|
300
|
+
|
|
301
|
+
note = grok_staleness_note(entry['ts'], now: Time.now)
|
|
302
|
+
[{ 'config' => config }, note]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def reverse_find_jsonl(path)
|
|
306
|
+
File.readlines(path).reverse_each do |line|
|
|
307
|
+
next if line.strip.empty?
|
|
308
|
+
|
|
309
|
+
obj = JSON.parse(line)
|
|
310
|
+
return obj if yield(obj)
|
|
311
|
+
rescue JSON::ParserError
|
|
312
|
+
next
|
|
313
|
+
end
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def grok_staleness_note(ts, now: Time.now)
|
|
318
|
+
return nil unless ts
|
|
319
|
+
|
|
320
|
+
logged_at = Time.parse(ts)
|
|
321
|
+
age = now - logged_at
|
|
322
|
+
return nil if age < 3600
|
|
323
|
+
|
|
324
|
+
format_age_note('grok: billing log from', age)
|
|
325
|
+
rescue ArgumentError
|
|
326
|
+
nil
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def format_age_note(prefix, seconds)
|
|
330
|
+
if seconds >= 86_400
|
|
331
|
+
days = (seconds / 86_400).to_i
|
|
332
|
+
"#{prefix} #{days}d ago"
|
|
333
|
+
elsif seconds >= 3600
|
|
334
|
+
hours = (seconds / 3600).to_i
|
|
335
|
+
"#{prefix} #{hours}h ago"
|
|
336
|
+
else
|
|
337
|
+
mins = (seconds / 60).to_i
|
|
338
|
+
"#{prefix} #{mins}m ago"
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def find_executable(cmd)
|
|
343
|
+
path, status = Open3.capture2('sh', '-c', "command -v #{cmd}")
|
|
344
|
+
path = path.strip
|
|
345
|
+
return nil unless status.success? && !path.empty? && File.executable?(path)
|
|
346
|
+
|
|
347
|
+
path
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def fetch_cached(provider, use_cache)
|
|
351
|
+
return yield unless use_cache
|
|
352
|
+
|
|
353
|
+
path = cache_path(provider)
|
|
354
|
+
if File.file?(path)
|
|
355
|
+
age = Time.now - File.mtime(path)
|
|
356
|
+
if age < CACHE_TTL
|
|
357
|
+
payload = read_json(path)
|
|
358
|
+
return [payload['data'], payload['note']] if payload && payload.key?('data')
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
data, note = yield
|
|
363
|
+
if data
|
|
364
|
+
FileUtils.mkdir_p(CACHE_DIR)
|
|
365
|
+
File.write(path, JSON.generate('fetched_at' => Time.now.iso8601, 'data' => data, 'note' => note))
|
|
366
|
+
end
|
|
367
|
+
[data, note]
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def cache_path(provider)
|
|
371
|
+
File.join(CACHE_DIR, "usage-#{provider}.json")
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def normalize_providers(providers)
|
|
375
|
+
list = Array(providers).map(&:to_s).map(&:downcase).reject(&:empty?)
|
|
376
|
+
return %i[claude codex grok] if list.empty?
|
|
377
|
+
|
|
378
|
+
valid = %w[claude codex grok]
|
|
379
|
+
bad = list - valid
|
|
380
|
+
raise Hammer::Error, "unknown provider(s): #{bad.join(', ')} (valid: #{valid.join(', ')})" unless bad.empty?
|
|
381
|
+
|
|
382
|
+
list.map(&:to_sym)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def period_label(period)
|
|
386
|
+
case period.to_s.downcase
|
|
387
|
+
when '', 'default', 'session', 'week' then 'default'
|
|
388
|
+
when 'month' then 'month'
|
|
389
|
+
else
|
|
390
|
+
raise Hammer::Error, "unknown period: #{period} (valid: week, month)"
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def table_headers(period)
|
|
395
|
+
if period_label(period) == 'month'
|
|
396
|
+
%w[Name month\ util month\ reset]
|
|
397
|
+
else
|
|
398
|
+
%w[Name session\ util session\ reset week\ util week\ reset]
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def row_cells(row, period)
|
|
403
|
+
if period_label(period) == 'month'
|
|
404
|
+
[row.name, row.month_pct || '-', row.month_reset || '-']
|
|
405
|
+
else
|
|
406
|
+
[row.name, row.session_pct, row.session_reset, row.week_pct, row.week_reset]
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def row_to_hash(row, period: nil)
|
|
411
|
+
if period_label(period) == 'month'
|
|
412
|
+
{ name: row.name, month_util: row.month_pct, month_reset: row.month_reset }
|
|
413
|
+
else
|
|
414
|
+
{
|
|
415
|
+
name: row.name,
|
|
416
|
+
session_util: row.session_pct,
|
|
417
|
+
session_reset: row.session_reset,
|
|
418
|
+
week_util: row.week_pct,
|
|
419
|
+
week_reset: row.week_reset
|
|
420
|
+
}
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
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
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def format_row(cells, widths, align: nil)
|
|
432
|
+
align ||= [:left] + [:right] * (cells.length - 1)
|
|
433
|
+
cells.each_with_index.map do |cell, i|
|
|
434
|
+
text = cell.to_s
|
|
435
|
+
if align[i] == :right
|
|
436
|
+
text.rjust(widths[i])
|
|
437
|
+
else
|
|
438
|
+
text.ljust(widths[i])
|
|
439
|
+
end
|
|
440
|
+
end.join(' ')
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def parse_time(at)
|
|
444
|
+
case at
|
|
445
|
+
when nil then nil
|
|
446
|
+
when Numeric then Time.at(at.to_i)
|
|
447
|
+
when String
|
|
448
|
+
return Time.at(at.to_i) if at.match?(/\A\d+\z/)
|
|
449
|
+
Time.parse(at)
|
|
450
|
+
else
|
|
451
|
+
nil
|
|
452
|
+
end
|
|
453
|
+
rescue ArgumentError
|
|
454
|
+
nil
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def window_pct(window)
|
|
458
|
+
return nil unless window.is_a?(Hash)
|
|
459
|
+
window['usedPercent'] || window['used_percent']
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def window_reset(window)
|
|
463
|
+
return nil unless window.is_a?(Hash)
|
|
464
|
+
window['resetsAt'] || window['reset_at']
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def map_codex_window(window)
|
|
468
|
+
return nil unless window.is_a?(Hash)
|
|
469
|
+
{
|
|
470
|
+
'used_percent' => window['used_percent'],
|
|
471
|
+
'limit_window_seconds' => window['limit_window_seconds'],
|
|
472
|
+
'reset_at' => window['reset_at']
|
|
473
|
+
}
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def map_codex_session_window(window)
|
|
477
|
+
return nil unless window.is_a?(Hash)
|
|
478
|
+
{
|
|
479
|
+
'used_percent' => window['used_percent'],
|
|
480
|
+
'windowDurationMins' => window['window_minutes'],
|
|
481
|
+
'resetsAt' => window['resets_at']
|
|
482
|
+
}
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def claude_month_fields(extra)
|
|
486
|
+
return { pct: '-', reset: '-' } unless extra.is_a?(Hash) && extra['is_enabled']
|
|
487
|
+
|
|
488
|
+
{
|
|
489
|
+
pct: format_pct(extra['utilization']),
|
|
490
|
+
reset: '-'
|
|
491
|
+
}
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def codex_whitelist_to_rate_limits(data)
|
|
495
|
+
{
|
|
496
|
+
'primary' => map_codex_window(data.dig('rate_limit', 'primary_window')),
|
|
497
|
+
'secondary' => map_codex_window(data.dig('rate_limit', 'secondary_window'))
|
|
498
|
+
}
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def read_json(path)
|
|
502
|
+
return nil unless path && File.file?(path)
|
|
503
|
+
JSON.parse(File.read(path))
|
|
504
|
+
rescue JSON::ParserError
|
|
505
|
+
nil
|
|
506
|
+
end
|
|
507
|
+
end
|
data/recipes/llm.rb
CHANGED
|
@@ -8,6 +8,9 @@ desc <<~TXT
|
|
|
8
8
|
Namespaces:
|
|
9
9
|
memory persistent memory store (backs the Claude Code memory plugin)
|
|
10
10
|
prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
|
|
11
|
+
|
|
12
|
+
Commands:
|
|
13
|
+
usage Claude, Codex, and Grok subscription usage (session + week table)
|
|
11
14
|
TXT
|
|
12
15
|
|
|
13
16
|
require 'fileutils'
|
|
@@ -15,11 +18,50 @@ require 'json'
|
|
|
15
18
|
require 'pathname'
|
|
16
19
|
require 'set'
|
|
17
20
|
|
|
21
|
+
_llm_root = File.dirname(Hammer::Recipe.path('llm') || File.join(Hammer::Recipe::GEM_DIR, 'llm.rb'))
|
|
22
|
+
require File.join(_llm_root, 'lib/llm/usage')
|
|
23
|
+
|
|
18
24
|
STORE ||= ENV['CLAUDE_MEMORY_STORE'] || File.expand_path('~/dev/skills/memory')
|
|
19
25
|
VALID_TYPES ||= %w[user feedback project reference].freeze
|
|
20
26
|
|
|
21
27
|
FileUtils.mkdir_p(STORE)
|
|
22
28
|
|
|
29
|
+
task :usage do
|
|
30
|
+
desc <<~D
|
|
31
|
+
Show subscription usage for Claude, Codex, and Grok in one table.
|
|
32
|
+
|
|
33
|
+
Default view lists session and weekly windows side by side.
|
|
34
|
+
Reads Codex via `codex app-server`, Grok from `~/.grok/logs/unified.jsonl`.
|
|
35
|
+
Claude has no local snapshot. Pass `month` for Claude extra-usage when enabled.
|
|
36
|
+
D
|
|
37
|
+
example 'llm usage'
|
|
38
|
+
example 'llm usage month'
|
|
39
|
+
example 'llm usage --json'
|
|
40
|
+
example 'llm usage --provider grok'
|
|
41
|
+
|
|
42
|
+
opt :json, type: :boolean, default: false, desc: 'emit JSON'
|
|
43
|
+
opt :provider, desc: 'single provider (claude, codex, grok)'
|
|
44
|
+
|
|
45
|
+
proc do |opts|
|
|
46
|
+
period = opts[:args].first
|
|
47
|
+
providers = opts[:provider] ? [opts[:provider]] : nil
|
|
48
|
+
rows, notes, = LlmUsage.collect_rows(period: period, providers: providers)
|
|
49
|
+
|
|
50
|
+
if rows.empty?
|
|
51
|
+
notes.each { |note| say note, :yellow }
|
|
52
|
+
error 'no usage data (sign in to Claude, Codex, or Grok first)' if notes.empty?
|
|
53
|
+
error 'no usage data available'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if opts[:json]
|
|
57
|
+
puts JSON.generate(LlmUsage.rows_to_json(rows, period: period, notes: notes))
|
|
58
|
+
else
|
|
59
|
+
say LlmUsage.render_table(rows, period: period)
|
|
60
|
+
notes.each { |note| say note, :gray }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
23
65
|
namespace :memory do
|
|
24
66
|
# Helpers are defined inside the namespace block (class_eval'd on the
|
|
25
67
|
# namespace's anonymous Hammer subclass) so the task procs reach them.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lux-hammer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.17
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dino Reic
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|
|
@@ -63,6 +63,7 @@ files:
|
|
|
63
63
|
- "./recipes/lib/deploy/manifest.rb"
|
|
64
64
|
- "./recipes/lib/deploy/ssh.rb"
|
|
65
65
|
- "./recipes/lib/deploy/template.rb"
|
|
66
|
+
- "./recipes/lib/llm/usage.rb"
|
|
66
67
|
- "./recipes/llm.rb"
|
|
67
68
|
- "./recipes/srt.rb"
|
|
68
69
|
- bin/hammer
|