claude_usage 0.1.2
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 +7 -0
- data/.rubocop.yml +74 -0
- data/CHANGELOG.md +53 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/Rakefile +8 -0
- data/app/controllers/claude_usage/application_controller.rb +11 -0
- data/app/controllers/claude_usage/usage_controller.rb +55 -0
- data/app/helpers/claude_usage/usage_helper.rb +20 -0
- data/app/views/claude_usage/usage/index.html.erb +298 -0
- data/config/routes.rb +6 -0
- data/lib/claude_usage/cost_calculator.rb +57 -0
- data/lib/claude_usage/engine.rb +23 -0
- data/lib/claude_usage/file_reader.rb +112 -0
- data/lib/claude_usage/formatters.rb +32 -0
- data/lib/claude_usage/litellm_pricing_fetcher.rb +148 -0
- data/lib/claude_usage/project_name_resolver.rb +23 -0
- data/lib/claude_usage/usage_aggregator.rb +55 -0
- data/lib/claude_usage/version.rb +5 -0
- data/lib/claude_usage.rb +52 -0
- data/lib/generators/claude_usage/install_generator.rb +30 -0
- data/lib/generators/claude_usage/templates/initializer.rb +34 -0
- data/lib/tasks/claude_usage_tasks.rake +313 -0
- data/sig/claude_usage.rbs +4 -0
- metadata +139 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Claude Code Usage Report</title>
|
|
5
|
+
<style>
|
|
6
|
+
* {
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 0;
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
body {
|
|
13
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
14
|
+
background: #f9fafb;
|
|
15
|
+
min-height: 100vh;
|
|
16
|
+
padding: 2rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
h1 {
|
|
20
|
+
color: #111827;
|
|
21
|
+
text-align: center;
|
|
22
|
+
margin-bottom: 2rem;
|
|
23
|
+
font-size: 2.5rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.container {
|
|
27
|
+
max-width: 1200px;
|
|
28
|
+
margin: 0 auto;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.error {
|
|
32
|
+
background: #fee;
|
|
33
|
+
border: 2px solid #f66;
|
|
34
|
+
color: #c33;
|
|
35
|
+
padding: 1.5rem;
|
|
36
|
+
border-radius: 8px;
|
|
37
|
+
margin-bottom: 2rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.totals-grid {
|
|
41
|
+
display: grid;
|
|
42
|
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
43
|
+
gap: 1.5rem;
|
|
44
|
+
margin-bottom: 2rem;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.stat-card {
|
|
48
|
+
background: white;
|
|
49
|
+
padding: 1.5rem;
|
|
50
|
+
border-radius: 12px;
|
|
51
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
52
|
+
transition: transform 0.2s;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.stat-card:hover {
|
|
56
|
+
transform: translateY(-4px);
|
|
57
|
+
box-shadow: 0 8px 12px rgba(0,0,0,0.15);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.stat-label {
|
|
61
|
+
font-size: 0.875rem;
|
|
62
|
+
color: #666;
|
|
63
|
+
text-transform: uppercase;
|
|
64
|
+
letter-spacing: 0.5px;
|
|
65
|
+
margin-bottom: 0.5rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.stat-value {
|
|
69
|
+
font-size: 2rem;
|
|
70
|
+
font-weight: bold;
|
|
71
|
+
color: #667eea;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.stat-value.cost {
|
|
75
|
+
color: #10b981;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
table {
|
|
79
|
+
width: 100%;
|
|
80
|
+
background: white;
|
|
81
|
+
border-radius: 12px;
|
|
82
|
+
overflow: hidden;
|
|
83
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
84
|
+
border-collapse: collapse;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
thead {
|
|
88
|
+
background: #374151;
|
|
89
|
+
color: white;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
th {
|
|
93
|
+
padding: 1rem;
|
|
94
|
+
text-align: left;
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
text-transform: uppercase;
|
|
97
|
+
font-size: 0.875rem;
|
|
98
|
+
letter-spacing: 0.5px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
td {
|
|
102
|
+
padding: 1rem;
|
|
103
|
+
border-bottom: 1px solid #e5e7eb;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
tbody tr:last-child td {
|
|
107
|
+
border-bottom: none;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
tbody tr:hover {
|
|
111
|
+
background: #f9fafb;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.date-cell {
|
|
115
|
+
font-weight: 600;
|
|
116
|
+
color: #374151;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.number-cell {
|
|
120
|
+
text-align: right;
|
|
121
|
+
font-variant-numeric: tabular-nums;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.cost-cell {
|
|
125
|
+
text-align: right;
|
|
126
|
+
font-weight: bold;
|
|
127
|
+
color: #10b981;
|
|
128
|
+
font-variant-numeric: tabular-nums;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.model-badge {
|
|
132
|
+
display: inline-block;
|
|
133
|
+
background: #e0e7ff;
|
|
134
|
+
color: #4338ca;
|
|
135
|
+
padding: 0.25rem 0.75rem;
|
|
136
|
+
border-radius: 9999px;
|
|
137
|
+
font-size: 0.75rem;
|
|
138
|
+
font-weight: 500;
|
|
139
|
+
margin: 0.125rem;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.footer {
|
|
143
|
+
text-align: center;
|
|
144
|
+
color: #6b7280;
|
|
145
|
+
margin-top: 2rem;
|
|
146
|
+
font-size: 0.875rem;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.footer a {
|
|
150
|
+
color: #4f46e5;
|
|
151
|
+
text-decoration: underline;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.api-link {
|
|
155
|
+
display: inline-block;
|
|
156
|
+
background: #4f46e5;
|
|
157
|
+
color: white;
|
|
158
|
+
padding: 0.75rem 1.5rem;
|
|
159
|
+
border-radius: 8px;
|
|
160
|
+
text-decoration: none;
|
|
161
|
+
font-weight: 600;
|
|
162
|
+
margin: 1rem auto;
|
|
163
|
+
display: block;
|
|
164
|
+
text-align: center;
|
|
165
|
+
max-width: 200px;
|
|
166
|
+
transition: all 0.2s;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.api-link:hover {
|
|
170
|
+
background: #4338ca;
|
|
171
|
+
transform: translateY(-2px);
|
|
172
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@media (max-width: 768px) {
|
|
176
|
+
body {
|
|
177
|
+
padding: 1rem;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
h1 {
|
|
181
|
+
font-size: 1.75rem;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.totals-grid {
|
|
185
|
+
grid-template-columns: 1fr;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
table {
|
|
189
|
+
font-size: 0.875rem;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
th, td {
|
|
193
|
+
padding: 0.75rem 0.5rem;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
</style>
|
|
197
|
+
</head>
|
|
198
|
+
<body>
|
|
199
|
+
<div class="container">
|
|
200
|
+
<h1>Claude Code Usage Report</h1>
|
|
201
|
+
|
|
202
|
+
<% if @error %>
|
|
203
|
+
<div class="error">
|
|
204
|
+
<strong>Error:</strong> <%= @error %>
|
|
205
|
+
</div>
|
|
206
|
+
<% else %>
|
|
207
|
+
<div class="totals-grid">
|
|
208
|
+
<div class="stat-card">
|
|
209
|
+
<div class="stat-label">Total Cost</div>
|
|
210
|
+
<div class="stat-value cost">$<%= sprintf('%.4f', @totals[:total_cost] || 0) %></div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<div class="stat-card">
|
|
214
|
+
<div class="stat-label">Total Tokens</div>
|
|
215
|
+
<div class="stat-value"><%= format_number(@totals[:total_tokens] || 0) %></div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div class="stat-card">
|
|
219
|
+
<div class="stat-label">Input Tokens</div>
|
|
220
|
+
<div class="stat-value"><%= format_number(@totals[:total_input_tokens] || 0) %></div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div class="stat-card">
|
|
224
|
+
<div class="stat-label">Output Tokens</div>
|
|
225
|
+
<div class="stat-value"><%= format_number(@totals[:total_output_tokens] || 0) %></div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div class="stat-card">
|
|
229
|
+
<div class="stat-label">Cache Creation</div>
|
|
230
|
+
<div class="stat-value"><%= format_number(@totals[:total_cache_creation_tokens] || 0) %></div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div class="stat-card">
|
|
234
|
+
<div class="stat-label">Cache Read</div>
|
|
235
|
+
<div class="stat-value"><%= format_number(@totals[:total_cache_read_tokens] || 0) %></div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<div class="stat-card">
|
|
239
|
+
<div class="stat-label">Days Active</div>
|
|
240
|
+
<div class="stat-value"><%= @totals[:days_active] || 0 %></div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div class="stat-card">
|
|
244
|
+
<div class="stat-label">Models Used</div>
|
|
245
|
+
<div class="stat-value"><%= (@totals[:models_used] || []).size %></div>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<% if @daily_usage.any? %>
|
|
250
|
+
<table>
|
|
251
|
+
<thead>
|
|
252
|
+
<tr>
|
|
253
|
+
<th>Date</th>
|
|
254
|
+
<th style="text-align: right;">Input</th>
|
|
255
|
+
<th style="text-align: right;">Output</th>
|
|
256
|
+
<th style="text-align: right;">Cache Create</th>
|
|
257
|
+
<th style="text-align: right;">Cache Read</th>
|
|
258
|
+
<th style="text-align: right;">Total Tokens</th>
|
|
259
|
+
<th style="text-align: right;">Cost</th>
|
|
260
|
+
<th style="text-align: right;">Requests</th>
|
|
261
|
+
<th>Models</th>
|
|
262
|
+
</tr>
|
|
263
|
+
</thead>
|
|
264
|
+
<tbody>
|
|
265
|
+
<% @daily_usage.each do |day| %>
|
|
266
|
+
<tr>
|
|
267
|
+
<td class="date-cell"><%= day[:date].strftime('%Y-%m-%d') %></td>
|
|
268
|
+
<td class="number-cell"><%= format_number(day[:input_tokens]) %></td>
|
|
269
|
+
<td class="number-cell"><%= format_number(day[:output_tokens]) %></td>
|
|
270
|
+
<td class="number-cell"><%= format_number(day[:cache_creation_tokens]) %></td>
|
|
271
|
+
<td class="number-cell"><%= format_number(day[:cache_read_tokens]) %></td>
|
|
272
|
+
<td class="number-cell"><%= format_number(day[:total_tokens]) %></td>
|
|
273
|
+
<td class="cost-cell">$<%= sprintf('%.4f', day[:cost]) %></td>
|
|
274
|
+
<td class="number-cell"><%= day[:request_count] %></td>
|
|
275
|
+
<td>
|
|
276
|
+
<% day[:models_used].each do |model| %>
|
|
277
|
+
<span class="model-badge"><%= model %></span>
|
|
278
|
+
<% end %>
|
|
279
|
+
</td>
|
|
280
|
+
</tr>
|
|
281
|
+
<% end %>
|
|
282
|
+
</tbody>
|
|
283
|
+
</table>
|
|
284
|
+
<% else %>
|
|
285
|
+
<div class="error">
|
|
286
|
+
<strong>No usage data found.</strong> Make sure you have Claude Code usage logs at ~/.config/claude/projects/
|
|
287
|
+
</div>
|
|
288
|
+
<% end %>
|
|
289
|
+
|
|
290
|
+
<a href="<%= claude_usage_engine.json_path %>" class="api-link">📊 View JSON API</a>
|
|
291
|
+
|
|
292
|
+
<div class="footer">
|
|
293
|
+
<p>Powered by <strong>claude_usage</strong> gem | Pricing from <a href="https://github.com/BerriAI/litellm" target="_blank">LiteLLM</a></p>
|
|
294
|
+
</div>
|
|
295
|
+
<% end %>
|
|
296
|
+
</div>
|
|
297
|
+
</body>
|
|
298
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeUsage
|
|
4
|
+
class CostCalculator
|
|
5
|
+
TIERED_THRESHOLD = 200_000
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@pricing_fetcher = LiteLLMPricingFetcher.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def calculate_cost(entry)
|
|
12
|
+
return entry[:pre_calculated_cost] if entry[:pre_calculated_cost]
|
|
13
|
+
|
|
14
|
+
# Skip if no model information
|
|
15
|
+
return 0 if entry[:model].nil? || entry[:model].empty?
|
|
16
|
+
|
|
17
|
+
model_pricing = @pricing_fetcher.get_model_pricing(entry[:model])
|
|
18
|
+
return 0 unless model_pricing
|
|
19
|
+
|
|
20
|
+
calculate_tiered_cost(
|
|
21
|
+
entry[:input_tokens],
|
|
22
|
+
model_pricing[:input_cost_per_token],
|
|
23
|
+
model_pricing[:input_cost_per_token_above_200k]
|
|
24
|
+
) +
|
|
25
|
+
calculate_tiered_cost(
|
|
26
|
+
entry[:output_tokens],
|
|
27
|
+
model_pricing[:output_cost_per_token],
|
|
28
|
+
model_pricing[:output_cost_per_token_above_200k]
|
|
29
|
+
) +
|
|
30
|
+
calculate_tiered_cost(
|
|
31
|
+
entry[:cache_creation_tokens],
|
|
32
|
+
model_pricing[:cache_creation_input_token_cost],
|
|
33
|
+
model_pricing[:cache_creation_cost_above_200k]
|
|
34
|
+
) +
|
|
35
|
+
calculate_tiered_cost(
|
|
36
|
+
entry[:cache_read_tokens],
|
|
37
|
+
model_pricing[:cache_read_input_token_cost],
|
|
38
|
+
model_pricing[:cache_read_cost_above_200k]
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def calculate_tiered_cost(tokens, base_price, tiered_price)
|
|
45
|
+
return 0 if tokens.nil? || tokens <= 0 || base_price.nil?
|
|
46
|
+
|
|
47
|
+
if tokens > TIERED_THRESHOLD && tiered_price
|
|
48
|
+
tokens_below = [tokens, TIERED_THRESHOLD].min
|
|
49
|
+
tokens_above = [0, tokens - TIERED_THRESHOLD].max
|
|
50
|
+
|
|
51
|
+
(tokens_below * base_price) + (tokens_above * tiered_price)
|
|
52
|
+
else
|
|
53
|
+
tokens * base_price
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeUsage
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace ClaudeUsage
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :rspec
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Auto-mount routes in host app
|
|
12
|
+
initializer 'claude_usage.routes' do |app|
|
|
13
|
+
app.routes.append do
|
|
14
|
+
mount ClaudeUsage::Engine => '/claude-usage', as: :claude_usage_engine
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Add view paths
|
|
19
|
+
initializer 'claude_usage.assets' do |app|
|
|
20
|
+
app.config.assets.paths << root.join('app/assets')
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ClaudeUsage
|
|
6
|
+
class FileReader
|
|
7
|
+
class InvalidProjectNameError < StandardError; end
|
|
8
|
+
|
|
9
|
+
CLAUDE_PATHS = [
|
|
10
|
+
File.expand_path('~/.config/claude/projects'),
|
|
11
|
+
File.expand_path('~/.claude/projects')
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(project_name: nil)
|
|
15
|
+
# Use provided name or auto-detect from Rails.root
|
|
16
|
+
@project_name = project_name || ClaudeUsage.configuration.resolved_project_name
|
|
17
|
+
validate_project_name!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read_usage_data
|
|
21
|
+
entries = []
|
|
22
|
+
processed_hashes = Set.new
|
|
23
|
+
|
|
24
|
+
jsonl_files.each do |file|
|
|
25
|
+
File.foreach(file) do |line|
|
|
26
|
+
next if line.strip.empty?
|
|
27
|
+
|
|
28
|
+
begin
|
|
29
|
+
data = JSON.parse(line)
|
|
30
|
+
|
|
31
|
+
# Deduplication
|
|
32
|
+
message_id = data.dig('message', 'id')
|
|
33
|
+
request_id = data['requestId']
|
|
34
|
+
|
|
35
|
+
if message_id && request_id
|
|
36
|
+
unique_hash = "#{message_id}:#{request_id}"
|
|
37
|
+
next if processed_hashes.include?(unique_hash)
|
|
38
|
+
|
|
39
|
+
processed_hashes.add(unique_hash)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
entries << parse_entry(data)
|
|
43
|
+
rescue JSON::ParserError => e
|
|
44
|
+
logger&.debug "Skipping invalid JSON line: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
entries
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def validate_project_name!
|
|
55
|
+
return if @project_name.nil? # Allow nil for cases where no project is configured
|
|
56
|
+
|
|
57
|
+
# Prevent path traversal attacks by validating project name format
|
|
58
|
+
# Allow alphanumeric, dashes, underscores, and dots (for typical project names)
|
|
59
|
+
unless @project_name.match?(/\A[\w.-]+\z/)
|
|
60
|
+
raise InvalidProjectNameError,
|
|
61
|
+
"Invalid project name '#{@project_name}'. Only alphanumeric characters, " \
|
|
62
|
+
'dashes, underscores, and dots are allowed.'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Prevent path traversal patterns
|
|
66
|
+
return unless @project_name.include?('..') || @project_name.start_with?('/')
|
|
67
|
+
|
|
68
|
+
raise InvalidProjectNameError,
|
|
69
|
+
"Invalid project name '#{@project_name}'. Path traversal patterns are not allowed."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def claude_paths
|
|
73
|
+
ClaudeUsage.configuration.claude_paths || CLAUDE_PATHS
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def jsonl_files
|
|
77
|
+
files = []
|
|
78
|
+
claude_paths.each do |base_path|
|
|
79
|
+
next unless Dir.exist?(base_path)
|
|
80
|
+
|
|
81
|
+
project_dir = File.join(base_path, @project_name)
|
|
82
|
+
next unless Dir.exist?(project_dir)
|
|
83
|
+
|
|
84
|
+
files.concat(Dir.glob(File.join(project_dir, '**', '*.jsonl')))
|
|
85
|
+
end
|
|
86
|
+
files.sort_by { |f| File.mtime(f) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse_entry(data)
|
|
90
|
+
# Handle missing or invalid timestamp
|
|
91
|
+
timestamp = begin
|
|
92
|
+
data['timestamp'] ? Time.parse(data['timestamp']) : Time.now
|
|
93
|
+
rescue ArgumentError
|
|
94
|
+
Time.now
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
timestamp: timestamp,
|
|
99
|
+
model: data.dig('message', 'model'),
|
|
100
|
+
input_tokens: data.dig('message', 'usage', 'input_tokens') || 0,
|
|
101
|
+
output_tokens: data.dig('message', 'usage', 'output_tokens') || 0,
|
|
102
|
+
cache_creation_tokens: data.dig('message', 'usage', 'cache_creation_input_tokens') || 0,
|
|
103
|
+
cache_read_tokens: data.dig('message', 'usage', 'cache_read_input_tokens') || 0,
|
|
104
|
+
pre_calculated_cost: data['costUSD']
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def logger
|
|
109
|
+
defined?(Rails) ? Rails.logger : nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeUsage
|
|
4
|
+
# Shared formatting utilities for displaying usage data
|
|
5
|
+
module Formatters
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Format a number with comma separators
|
|
9
|
+
# Example: 1234567 => "1,234,567"
|
|
10
|
+
def format_number(number)
|
|
11
|
+
return '0' if number.nil? || number.zero?
|
|
12
|
+
|
|
13
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Format a dollar amount with 2 decimal places
|
|
17
|
+
# Example: 1.234 => "$1.23"
|
|
18
|
+
def format_currency(amount)
|
|
19
|
+
return '$0.00' if amount.nil? || amount.zero?
|
|
20
|
+
|
|
21
|
+
format('$%.2f', amount)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Format a percentage with 1 decimal place
|
|
25
|
+
# Example: 0.1234 => "12.3%"
|
|
26
|
+
def format_percentage(decimal)
|
|
27
|
+
return '0.0%' if decimal.nil? || decimal.zero?
|
|
28
|
+
|
|
29
|
+
format('%.1f%%', decimal * 100)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'httparty'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module ClaudeUsage
|
|
7
|
+
class LiteLLMPricingFetcher
|
|
8
|
+
include HTTParty
|
|
9
|
+
|
|
10
|
+
LITELLM_PRICING_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
|
|
11
|
+
|
|
12
|
+
def initialize(offline: nil)
|
|
13
|
+
@offline = offline.nil? ? ClaudeUsage.configuration.offline_mode : offline
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fetch_pricing_data
|
|
17
|
+
cache_key = 'claude_usage/litellm_pricing_data'
|
|
18
|
+
cache_expiry = ClaudeUsage.configuration.cache_expiry
|
|
19
|
+
|
|
20
|
+
return Rails.cache.read(cache_key) || {} if @offline
|
|
21
|
+
|
|
22
|
+
Rails.cache.fetch(cache_key, expires_in: cache_expiry) do
|
|
23
|
+
fetch_from_litellm
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get_model_pricing(model_name, debug: false)
|
|
28
|
+
return nil if model_name.nil? || model_name.empty?
|
|
29
|
+
|
|
30
|
+
pricing_data = fetch_pricing_data
|
|
31
|
+
debug_info = { tried: [], fallbacks: [] } if debug
|
|
32
|
+
|
|
33
|
+
# Direct match (this should work for claude-haiku-4-5-20251001, claude-sonnet-4-5-20250929)
|
|
34
|
+
if pricing_data.key?(model_name)
|
|
35
|
+
debug_info[:tried] << { candidate: model_name, found: true } if debug
|
|
36
|
+
return debug ? [pricing_data[model_name], debug_info] : pricing_data[model_name]
|
|
37
|
+
end
|
|
38
|
+
debug_info[:tried] << { candidate: model_name, found: false } if debug
|
|
39
|
+
|
|
40
|
+
# Try with anthropic/ prefix
|
|
41
|
+
anthropic_model = "anthropic/#{model_name}"
|
|
42
|
+
debug_info[:tried] << { candidate: anthropic_model, found: false } if debug
|
|
43
|
+
if pricing_data.key?(anthropic_model)
|
|
44
|
+
debug_info[:tried].last[:found] = true if debug
|
|
45
|
+
return debug ? [pricing_data[anthropic_model], debug_info] : pricing_data[anthropic_model]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# For fallbacks, only try if it's a known Claude 4.5 model that might not be in LiteLLM yet
|
|
49
|
+
if model_name.include?('haiku-4-5')
|
|
50
|
+
fallback_models = [
|
|
51
|
+
'claude-3-5-haiku-20241022',
|
|
52
|
+
'anthropic/claude-3-5-haiku-20241022',
|
|
53
|
+
'claude-3-haiku-20240307',
|
|
54
|
+
'anthropic/claude-3-haiku-20240307'
|
|
55
|
+
]
|
|
56
|
+
fallback_models.each do |fallback|
|
|
57
|
+
debug_info[:fallbacks] << { candidate: fallback, found: false } if debug
|
|
58
|
+
if pricing_data.key?(fallback)
|
|
59
|
+
debug_info[:fallbacks].last[:found] = true if debug
|
|
60
|
+
return debug ? [pricing_data[fallback], debug_info] : pricing_data[fallback]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
elsif model_name.include?('sonnet-4-5')
|
|
64
|
+
fallback_models = [
|
|
65
|
+
'claude-sonnet-4-5-20250929',
|
|
66
|
+
'claude-3-5-sonnet-20241022',
|
|
67
|
+
'anthropic/claude-3-5-sonnet-20241022'
|
|
68
|
+
]
|
|
69
|
+
fallback_models.each do |fallback|
|
|
70
|
+
debug_info[:fallbacks] << { candidate: fallback, found: false } if debug
|
|
71
|
+
if pricing_data.key?(fallback)
|
|
72
|
+
debug_info[:fallbacks].last[:found] = true if debug
|
|
73
|
+
return debug ? [pricing_data[fallback], debug_info] : pricing_data[fallback]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
debug ? [nil, debug_info] : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def fetch_from_litellm
|
|
84
|
+
logger.info 'Fetching pricing from LiteLLM...'
|
|
85
|
+
|
|
86
|
+
# HTTParty configuration with configurable SSL verification
|
|
87
|
+
# Note: verify_ssl defaults to false for macOS compatibility
|
|
88
|
+
# Users can enable it via: config.verify_ssl = true
|
|
89
|
+
options = {
|
|
90
|
+
timeout: 30,
|
|
91
|
+
verify: ClaudeUsage.configuration.verify_ssl
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
response = self.class.get(LITELLM_PRICING_URL, options)
|
|
95
|
+
|
|
96
|
+
if response.success?
|
|
97
|
+
# Manually parse JSON to ensure we get a hash
|
|
98
|
+
raw_data = JSON.parse(response.body)
|
|
99
|
+
parsed_data = parse_pricing_data(raw_data)
|
|
100
|
+
logger.info "Successfully fetched pricing for #{parsed_data.keys.size} models"
|
|
101
|
+
|
|
102
|
+
# Log if our specific models are found
|
|
103
|
+
['claude-haiku-4-5-20251001', 'claude-sonnet-4-5-20250929'].each do |model|
|
|
104
|
+
if parsed_data.key?(model)
|
|
105
|
+
logger.info "✅ Found pricing for #{model}"
|
|
106
|
+
else
|
|
107
|
+
logger.warn "❌ Missing pricing for #{model}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
parsed_data
|
|
112
|
+
else
|
|
113
|
+
logger.error "Failed to fetch LiteLLM pricing: HTTP #{response.code}"
|
|
114
|
+
{}
|
|
115
|
+
end
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
logger.error "Error fetching LiteLLM pricing: #{e.class} - #{e.message}"
|
|
118
|
+
logger.error e.backtrace.first(5).join("\n")
|
|
119
|
+
{}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def parse_pricing_data(raw_data)
|
|
123
|
+
pricing = {}
|
|
124
|
+
|
|
125
|
+
raw_data.each do |model_name, model_data|
|
|
126
|
+
next unless model_data.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
pricing[model_name] = {
|
|
129
|
+
input_cost_per_token: model_data['input_cost_per_token'],
|
|
130
|
+
output_cost_per_token: model_data['output_cost_per_token'],
|
|
131
|
+
cache_creation_input_token_cost: model_data['cache_creation_input_token_cost'],
|
|
132
|
+
cache_read_input_token_cost: model_data['cache_read_input_token_cost'],
|
|
133
|
+
input_cost_per_token_above_200k: model_data['input_cost_per_token_above_200k_tokens'],
|
|
134
|
+
output_cost_per_token_above_200k: model_data['output_cost_per_token_above_200k_tokens'],
|
|
135
|
+
cache_creation_cost_above_200k: model_data['cache_creation_input_token_cost_above_200k_tokens'],
|
|
136
|
+
cache_read_cost_above_200k: model_data['cache_read_input_token_cost_above_200k_tokens'],
|
|
137
|
+
max_input_tokens: model_data['max_input_tokens']
|
|
138
|
+
}.compact
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
pricing
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def logger
|
|
145
|
+
defined?(Rails) ? Rails.logger : Logger.new($stdout)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeUsage
|
|
4
|
+
# Shared utility for detecting and resolving project names
|
|
5
|
+
module ProjectNameResolver
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Auto-detect project name from Rails.root
|
|
9
|
+
# Converts: /Users/name/projects/myapp → -Users-name-projects-myapp
|
|
10
|
+
def detect_from_rails_root
|
|
11
|
+
return nil unless defined?(Rails) && Rails.root
|
|
12
|
+
|
|
13
|
+
# Claude Code uses the absolute path with dashes as the folder name
|
|
14
|
+
# Remove leading slash, then replace remaining slashes with dashes, then prepend dash
|
|
15
|
+
Rails.root.to_s.sub(%r{^/}, '').gsub('/', '-').prepend('-')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Get the project name with fallback
|
|
19
|
+
def resolve(configured_name = nil, fallback: 'your-project-name')
|
|
20
|
+
configured_name || detect_from_rails_root || fallback
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|