lux-hammer 0.3.15 → 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/AGENTS.md +1 -1
- data/README.md +2 -2
- data/lib/hammer/builtins.rb +2 -2
- data/lib/hammer/cron_server.rb +67 -32
- 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
|
data/AGENTS.md
CHANGED
|
@@ -313,7 +313,7 @@ Task contracts:
|
|
|
313
313
|
`hammer/cron_web.rb` - no webrick, keep it dependency-free): jobs
|
|
314
314
|
table, per-job log tail, POST run-now, `/json` status. Flags:
|
|
315
315
|
`--port`, `--pass` (HTTP basic auth on every route, any username,
|
|
316
|
-
constant-time compare; falls back to `
|
|
316
|
+
constant-time compare; falls back to `CRON_HTTP_PASS` env),
|
|
317
317
|
`--list` (print jobs + next runs, exit), `--service` (print launchd
|
|
318
318
|
plist / systemd unit, carries `--pass` when set, install hints on
|
|
319
319
|
stderr).
|
data/README.md
CHANGED
|
@@ -683,11 +683,11 @@ On a shared machine, protect the UI with HTTP basic auth - useful since
|
|
|
683
683
|
"run now" executes tasks:
|
|
684
684
|
|
|
685
685
|
```sh
|
|
686
|
-
hammer h:cron --pass=secret # or:
|
|
686
|
+
hammer h:cron --pass=secret # or: CRON_HTTP_PASS=secret hammer h:cron
|
|
687
687
|
curl -u :secret http://127.0.0.1:4267/json # any username, password checked
|
|
688
688
|
```
|
|
689
689
|
|
|
690
|
-
Prefer the `
|
|
690
|
+
Prefer the `CRON_HTTP_PASS` env var when other users can read your
|
|
691
691
|
process list (`ps` shows flag values). A `--service` unit generated
|
|
692
692
|
while `--pass` is set carries the flag along, so keep that file private.
|
|
693
693
|
Note this is plain HTTP - credentials are encoded, not encrypted; for
|
data/lib/hammer/builtins.rb
CHANGED
|
@@ -135,7 +135,7 @@ class Hammer
|
|
|
135
135
|
example 'h:cron --list'
|
|
136
136
|
example 'h:cron --service > ~/.config/systemd/user/hammer-cron.service'
|
|
137
137
|
opt :port, type: :integer, default: 4267, desc: 'web UI port (binds 127.0.0.1 only)'
|
|
138
|
-
opt :pass, type: :string, desc: 'HTTP basic-auth password for the web UI (or
|
|
138
|
+
opt :pass, type: :string, desc: 'HTTP basic-auth password for the web UI (or CRON_HTTP_PASS env)'
|
|
139
139
|
opt :list, type: :boolean, desc: 'print scheduled jobs + next runs, then exit'
|
|
140
140
|
opt :service, type: :boolean, desc: 'print a launchd plist (macOS) / systemd unit (linux)'
|
|
141
141
|
proc do |opts|
|
|
@@ -144,7 +144,7 @@ class Hammer
|
|
|
144
144
|
end
|
|
145
145
|
require_relative 'cron_server'
|
|
146
146
|
server = Hammer::CronServer.new(self.class.root, port: opts[:port],
|
|
147
|
-
pass: opts[:pass] || ENV['
|
|
147
|
+
pass: opts[:pass] || ENV['CRON_HTTP_PASS'])
|
|
148
148
|
if opts[:service]
|
|
149
149
|
server.print_service_unit
|
|
150
150
|
elsif opts[:list]
|
data/lib/hammer/cron_server.rb
CHANGED
|
@@ -265,41 +265,11 @@ class Hammer
|
|
|
265
265
|
def print_service_unit
|
|
266
266
|
if RUBY_PLATFORM.include?('darwin')
|
|
267
267
|
label = "com.lux-hammer.cron.#{File.basename(@root_dir)}"
|
|
268
|
-
puts
|
|
269
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
270
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
271
|
-
<plist version="1.0">
|
|
272
|
-
<dict>
|
|
273
|
-
<key>Label</key><string>#{label}</string>
|
|
274
|
-
<key>ProgramArguments</key>
|
|
275
|
-
<array>
|
|
276
|
-
<string>#{@hammer_bin}</string>
|
|
277
|
-
<string>h:cron</string>
|
|
278
|
-
<string>--port=#{@port}</string>#{@pass ? "\n <string>--pass=#{@pass}</string>" : ''}
|
|
279
|
-
</array>
|
|
280
|
-
<key>WorkingDirectory</key><string>#{@root_dir}</string>
|
|
281
|
-
<key>RunAtLoad</key><true/>
|
|
282
|
-
<key>KeepAlive</key><true/>
|
|
283
|
-
<key>StandardOutPath</key><string>#{@root_dir}/log/hammer/cron-server.log</string>
|
|
284
|
-
<key>StandardErrorPath</key><string>#{@root_dir}/log/hammer/cron-server.log</string>
|
|
285
|
-
</dict>
|
|
286
|
-
</plist>
|
|
287
|
-
XML
|
|
268
|
+
puts launchd_service_unit(label)
|
|
288
269
|
warn Shell.paint("# save to ~/Library/LaunchAgents/#{label}.plist, then:", :gray)
|
|
289
270
|
warn Shell.paint("# launchctl load ~/Library/LaunchAgents/#{label}.plist", :gray)
|
|
290
271
|
else
|
|
291
|
-
puts
|
|
292
|
-
[Unit]
|
|
293
|
-
Description=hammer h:cron job server (#{File.basename(@root_dir)})
|
|
294
|
-
|
|
295
|
-
[Service]
|
|
296
|
-
ExecStart=#{@hammer_bin} h:cron --port=#{@port}#{" --pass=#{@pass}" if @pass}
|
|
297
|
-
WorkingDirectory=#{@root_dir}
|
|
298
|
-
Restart=on-failure
|
|
299
|
-
|
|
300
|
-
[Install]
|
|
301
|
-
WantedBy=default.target
|
|
302
|
-
UNIT
|
|
272
|
+
puts systemd_service_unit
|
|
303
273
|
warn Shell.paint('# save to ~/.config/systemd/user/hammer-cron.service, then:', :gray)
|
|
304
274
|
warn Shell.paint('# systemctl --user enable --now hammer-cron', :gray)
|
|
305
275
|
end
|
|
@@ -326,6 +296,71 @@ class Hammer
|
|
|
326
296
|
|
|
327
297
|
private
|
|
328
298
|
|
|
299
|
+
def launchd_service_unit(label = "com.lux-hammer.cron.#{File.basename(@root_dir)}")
|
|
300
|
+
args = [@hammer_bin, 'h:cron', "--port=#{@port}"]
|
|
301
|
+
args << "--pass=#{@pass}" if @pass
|
|
302
|
+
arguments = args.map { |arg| " <string>#{xml_escape(arg)}</string>" }.join("\n")
|
|
303
|
+
log_path = File.join(@root_dir, 'log/hammer/cron-server.log')
|
|
304
|
+
|
|
305
|
+
<<~XML
|
|
306
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
307
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
308
|
+
<plist version="1.0">
|
|
309
|
+
<dict>
|
|
310
|
+
<key>Label</key><string>#{xml_escape(label)}</string>
|
|
311
|
+
<key>ProgramArguments</key>
|
|
312
|
+
<array>
|
|
313
|
+
#{arguments}
|
|
314
|
+
</array>
|
|
315
|
+
<key>WorkingDirectory</key><string>#{xml_escape(@root_dir)}</string>
|
|
316
|
+
<key>RunAtLoad</key><true/>
|
|
317
|
+
<key>KeepAlive</key><true/>
|
|
318
|
+
<key>StandardOutPath</key><string>#{xml_escape(log_path)}</string>
|
|
319
|
+
<key>StandardErrorPath</key><string>#{xml_escape(log_path)}</string>
|
|
320
|
+
</dict>
|
|
321
|
+
</plist>
|
|
322
|
+
XML
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def systemd_service_unit
|
|
326
|
+
args = [@hammer_bin, 'h:cron', "--port=#{@port}"]
|
|
327
|
+
args << "--pass=#{@pass}" if @pass
|
|
328
|
+
|
|
329
|
+
<<~UNIT
|
|
330
|
+
[Unit]
|
|
331
|
+
Description=#{systemd_quote("hammer h:cron job server (#{File.basename(@root_dir)})")}
|
|
332
|
+
|
|
333
|
+
[Service]
|
|
334
|
+
ExecStart=#{args.map { |arg| systemd_quote(arg) }.join(' ')}
|
|
335
|
+
WorkingDirectory=#{systemd_quote(@root_dir)}
|
|
336
|
+
Restart=on-failure
|
|
337
|
+
|
|
338
|
+
[Install]
|
|
339
|
+
WantedBy=default.target
|
|
340
|
+
UNIT
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def xml_escape(value)
|
|
344
|
+
value.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>')
|
|
345
|
+
.gsub('"', '"').gsub("'", ''')
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def systemd_quote(value)
|
|
349
|
+
escaped = value.to_s.gsub(/[\x00-\x1f\x7f%$\\"]/) do |char|
|
|
350
|
+
case char
|
|
351
|
+
when '%' then '%%'
|
|
352
|
+
when '$' then '$$'
|
|
353
|
+
when '\\' then '\\\\'
|
|
354
|
+
when '"' then '\\"'
|
|
355
|
+
when "\n" then '\\n'
|
|
356
|
+
when "\r" then '\\r'
|
|
357
|
+
when "\t" then '\\t'
|
|
358
|
+
else format('\\x%02x', char.ord)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
%Q{"#{escaped}"}
|
|
362
|
+
end
|
|
363
|
+
|
|
329
364
|
# Refuse to start when the state file points at another live h:cron -
|
|
330
365
|
# two schedulers would double-fire every job. Best-effort (no lock
|
|
331
366
|
# file): kill(0) tells us whether that pid is still alive.
|
|
@@ -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
|