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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/AGENTS.md +16 -6
- data/README.md +83 -13
- data/lib/hammer/builder.rb +6 -1
- data/lib/hammer/input.rb +121 -0
- data/lib/hammer/option.rb +23 -9
- data/lib/hammer/parser.rb +12 -14
- data/lib/lux-hammer.rb +98 -16
- data/recipes/deploy.rb +15 -7
- data/recipes/lib/llm/usage.rb +353 -16
- data/recipes/lib/llm/wrap.rb +882 -0
- data/recipes/llm.rb +97 -13
- metadata +4 -11
- data/recipes/lib/deploy/boot.rb +0 -52
- data/recipes/lib/deploy/commands.rb +0 -555
- data/recipes/lib/deploy/config.rb +0 -62
- data/recipes/lib/deploy/context.rb +0 -149
- data/recipes/lib/deploy/doctor.rb +0 -238
- data/recipes/lib/deploy/hammer.rb +0 -168
- data/recipes/lib/deploy/manifest.rb +0 -169
- data/recipes/lib/deploy/ssh.rb +0 -129
- data/recipes/lib/deploy/template.rb +0 -39
data/recipes/lib/llm/usage.rb
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
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\
|
|
666
|
+
%w[Name month\ utilization month\ reset]
|
|
397
667
|
else
|
|
398
|
-
%w[Name session\
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|