llm_cost_tracker 0.2.0.alpha2 → 0.2.0

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +4 -3
  4. data/app/assets/llm_cost_tracker/application.css +760 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +13 -0
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
  10. data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +42 -0
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
  14. data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
  15. data/app/services/llm_cost_tracker/dashboard/filter.rb +0 -3
  16. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
  17. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
  18. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
  20. data/app/services/llm_cost_tracker/pagination.rb +6 -0
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +106 -74
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +201 -111
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +178 -78
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
  27. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
  28. data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
  29. data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
  30. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
  31. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
  32. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
  33. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
  34. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
  35. data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
  37. data/config/routes.rb +3 -0
  38. data/lib/llm_cost_tracker/assets.rb +24 -0
  39. data/lib/llm_cost_tracker/engine.rb +2 -0
  40. data/lib/llm_cost_tracker/llm_api_call.rb +1 -1
  41. data/lib/llm_cost_tracker/price_registry.rb +17 -6
  42. data/lib/llm_cost_tracker/pricing.rb +19 -6
  43. data/lib/llm_cost_tracker/retention.rb +34 -0
  44. data/lib/llm_cost_tracker/tag_query.rb +7 -2
  45. data/lib/llm_cost_tracker/tags_column.rb +13 -1
  46. data/lib/llm_cost_tracker/version.rb +1 -1
  47. data/lib/llm_cost_tracker.rb +1 -0
  48. data/lib/tasks/llm_cost_tracker.rake +8 -0
  49. data/llm_cost_tracker.gemspec +1 -2
  50. metadata +17 -5
  51. data/PLAN_0.2.md +0 -488
@@ -1,14 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  module LlmCostTracker
6
4
  module Dashboard
7
5
  TagKeyRow = Data.define(:key, :calls_count, :distinct_values)
8
6
 
9
7
  class TagKeyExplorer
10
- RUBY_FALLBACK_LIMIT = 50_000
11
-
12
8
  class << self
13
9
  def call(scope: LlmCostTracker::LlmApiCall.all)
14
10
  new(scope: scope).rows
@@ -21,10 +17,7 @@ module LlmCostTracker
21
17
  end
22
18
 
23
19
  def rows
24
- sql = build_sql
25
- return ruby_fallback_rows if sql.nil?
26
-
27
- results = @connection.select_all(sql).to_a
20
+ results = @connection.select_all(build_sql).to_a
28
21
  results.map do |row|
29
22
  TagKeyRow.new(
30
23
  key: row["key"].to_s,
@@ -48,48 +41,28 @@ module LlmCostTracker
48
41
  def build_sql
49
42
  case connection.adapter_name
50
43
  when /postgres/i then postgresql_sql
51
- when /mysql/i then nil
44
+ when /mysql/i then mysql_sql
52
45
  else sqlite_sql
53
46
  end
54
47
  end
55
48
 
56
- def ruby_fallback_rows
57
- calls_counter = Hash.new(0)
58
- values_per_key = Hash.new { |h, k| h[k] = Set.new }
59
-
60
- scope.limit(RUBY_FALLBACK_LIMIT).pluck(:tags).each do |raw|
61
- tags = parse_tags(raw)
62
- next if tags.empty?
63
-
64
- tags.each do |key, value|
65
- calls_counter[key] += 1
66
- values_per_key[key] << value.to_s
67
- end
68
- end
69
-
70
- calls_counter
71
- .sort_by { |_, count| -count }
72
- .map do |key, count|
73
- TagKeyRow.new(key: key.to_s, calls_count: count, distinct_values: values_per_key[key].size)
74
- end
75
- rescue StandardError => e
76
- LlmCostTracker::Logging.warn("Tag key Ruby fallback failed: #{e.class}: #{e.message}")
77
- []
78
- end
79
-
80
- def parse_tags(raw)
81
- case raw
82
- when Hash then raw
83
- when String
84
- return {} if raw.strip.empty?
85
-
86
- parsed = JSON.parse(raw)
87
- parsed.is_a?(Hash) ? parsed : {}
88
- else
89
- {}
90
- end
91
- rescue JSON::ParserError
92
- {}
49
+ def mysql_sql
50
+ <<~SQL.squish
51
+ SELECT jt.key AS key,
52
+ COUNT(*) AS calls_count,
53
+ COUNT(DISTINCT JSON_UNQUOTE(JSON_EXTRACT(sub.tags, CONCAT('$.', JSON_QUOTE(jt.key))))) AS distinct_values
54
+ FROM (#{subquery}) AS sub
55
+ JOIN JSON_TABLE(
56
+ COALESCE(JSON_KEYS(sub.tags), JSON_ARRAY()),
57
+ '$[*]' COLUMNS(
58
+ key VARCHAR(255) PATH '$'
59
+ )
60
+ ) AS jt
61
+ WHERE sub.tags IS NOT NULL
62
+ AND sub.tags != ''
63
+ GROUP BY jt.key
64
+ ORDER BY calls_count DESC
65
+ SQL
93
66
  end
94
67
 
95
68
  def postgresql_sql
@@ -15,12 +15,7 @@ module LlmCostTracker
15
15
 
16
16
  class TopModels
17
17
  DEFAULT_LIMIT = 5
18
- SORT_OPTIONS = {
19
- "cost" => "total_cost_sum DESC",
20
- "calls" => "calls_count DESC",
21
- "avg_cost" => "total_cost_sum / calls_count DESC",
22
- "latency" => "average_latency DESC NULLS LAST"
23
- }.freeze
18
+ SORT_OPTIONS = %w[cost calls avg_cost latency].freeze
24
19
  DEFAULT_SORT = "cost"
25
20
 
26
21
  class << self
@@ -32,7 +27,7 @@ module LlmCostTracker
32
27
  def initialize(scope:, limit:, sort: DEFAULT_SORT)
33
28
  @scope = scope
34
29
  @limit = limit
35
- @sort = SORT_OPTIONS.key?(sort.to_s) ? sort.to_s : DEFAULT_SORT
30
+ @sort = SORT_OPTIONS.include?(sort.to_s) ? sort.to_s : DEFAULT_SORT
36
31
  end
37
32
 
38
33
  def rows
@@ -58,7 +53,6 @@ module LlmCostTracker
58
53
  attr_reader :scope, :limit, :sort
59
54
 
60
55
  def grouped_rows
61
- order_sql = sort == "latency" && !scope.klass.latency_column? ? "total_cost_sum DESC" : SORT_OPTIONS[sort]
62
56
  scope
63
57
  .group(:provider, :model)
64
58
  .select(selects)
@@ -66,6 +60,21 @@ module LlmCostTracker
66
60
  .then { |r| limit ? r.limit(limit) : r }
67
61
  end
68
62
 
63
+ def order_sql
64
+ case sort
65
+ when "calls"
66
+ "COUNT(*) DESC"
67
+ when "avg_cost"
68
+ "COALESCE(SUM(total_cost), 0) / NULLIF(COUNT(*), 0) DESC"
69
+ when "latency"
70
+ return "COALESCE(SUM(total_cost), 0) DESC" unless scope.klass.latency_column?
71
+
72
+ "CASE WHEN AVG(latency_ms) IS NULL THEN 1 ELSE 0 END ASC, AVG(latency_ms) DESC"
73
+ else
74
+ "COALESCE(SUM(total_cost), 0) DESC"
75
+ end
76
+ end
77
+
69
78
  def selects
70
79
  columns = [
71
80
  "provider",
@@ -55,5 +55,11 @@ module LlmCostTracker
55
55
  def next_page?(total_count)
56
56
  offset + per < total_count.to_i
57
57
  end
58
+
59
+ def total_pages(total_count)
60
+ return MIN_PAGE if total_count.to_i <= 0
61
+
62
+ [(total_count.to_f / per).ceil, MIN_PAGE].max
63
+ end
58
64
  end
59
65
  end
@@ -3,340 +3,42 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>LLM Costs</title>
7
- <style>
8
- :root {
9
- --lct-bg: #f7f8fa;
10
- --lct-panel: #ffffff;
11
- --lct-border: #d9dee7;
12
- --lct-text: #1f2937;
13
- --lct-muted: #64748b;
14
- --lct-accent: #0f766e;
15
- --lct-accent-soft: #ccfbf1;
16
- --lct-danger: #b91c1c;
17
- }
18
-
19
- body {
20
- margin: 0;
21
- background: var(--lct-bg);
22
- color: var(--lct-text);
23
- font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
24
- }
25
-
26
- .lct-shell {
27
- max-width: 1120px;
28
- margin: 0 auto;
29
- padding: 24px;
30
- }
31
-
32
- .lct-header {
33
- display: flex;
34
- align-items: center;
35
- justify-content: space-between;
36
- gap: 24px;
37
- margin-bottom: 24px;
38
- }
39
-
40
- .lct-title {
41
- margin: 0;
42
- font-size: 24px;
43
- line-height: 1.2;
44
- }
45
-
46
- .lct-nav {
47
- display: flex;
48
- gap: 12px;
49
- flex-wrap: wrap;
50
- }
51
-
52
- .lct-nav a {
53
- color: var(--lct-muted);
54
- text-decoration: none;
55
- font-size: 14px;
56
- }
57
-
58
- .lct-nav a:hover {
59
- color: var(--lct-accent);
60
- }
61
-
62
- .lct-nav .lct-active {
63
- color: var(--lct-accent);
64
- font-weight: 700;
65
- }
66
-
67
- .lct-panel {
68
- background: var(--lct-panel);
69
- border: 1px solid var(--lct-border);
70
- border-radius: 8px;
71
- padding: 24px;
72
- margin-bottom: 20px;
73
- }
74
-
75
- .lct-muted {
76
- color: var(--lct-muted);
77
- }
78
-
79
- .lct-code {
80
- background: #eef2f7;
81
- border-radius: 6px;
82
- padding: 2px 6px;
83
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
84
- font-size: 0.95em;
85
- }
86
-
87
- .lct-grid {
88
- display: grid;
89
- gap: 16px;
90
- }
91
-
92
- .lct-stats {
93
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
94
- }
95
-
96
- .lct-two-col {
97
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
98
- }
99
-
100
- .lct-stat {
101
- background: var(--lct-panel);
102
- border: 1px solid var(--lct-border);
103
- border-radius: 8px;
104
- padding: 18px;
105
- }
106
-
107
- .lct-stat-label {
108
- color: var(--lct-muted);
109
- font-size: 13px;
110
- margin: 0 0 8px;
111
- }
112
-
113
- .lct-stat-value {
114
- font-size: 24px;
115
- font-weight: 700;
116
- margin: 0;
117
- }
118
-
119
- .lct-section-title {
120
- margin: 0 0 16px;
121
- font-size: 18px;
122
- }
123
-
124
- .lct-table {
125
- border-collapse: collapse;
126
- width: 100%;
127
- font-size: 14px;
128
- }
129
-
130
- .lct-stat-sub {
131
- color: var(--lct-muted);
132
- font-size: 12px;
133
- margin: 4px 0 0;
134
- }
135
-
136
- .lct-delta {
137
- font-size: 12px;
138
- margin: 4px 0 0;
139
- }
140
-
141
- .lct-delta-neutral { color: var(--lct-muted); }
142
- .lct-delta-up { color: #b91c1c; }
143
- .lct-delta-down { color: #047857; }
144
-
145
- .lct-table-wrap {
146
- overflow-x: auto;
147
- }
148
-
149
- .lct-table th,
150
- .lct-table td {
151
- border-bottom: 1px solid var(--lct-border);
152
- padding: 10px 8px;
153
- text-align: left;
154
- vertical-align: middle;
155
- }
156
-
157
- .lct-table th {
158
- color: var(--lct-muted);
159
- font-weight: 600;
160
- font-size: 12px;
161
- text-transform: uppercase;
162
- }
163
-
164
- .lct-table td:last-child,
165
- .lct-table th:last-child {
166
- text-align: right;
167
- }
168
-
169
- .lct-calls-table td:last-child,
170
- .lct-calls-table th:last-child {
171
- text-align: left;
172
- }
173
-
174
- .lct-bar-track {
175
- background: #e5e7eb;
176
- border-radius: 999px;
177
- height: 8px;
178
- overflow: hidden;
179
- min-width: 96px;
180
- }
181
-
182
- .lct-bar-fill {
183
- background: var(--lct-accent);
184
- height: 100%;
185
- }
186
-
187
- .lct-budget-fill {
188
- background: var(--lct-danger);
189
- }
190
-
191
- .lct-empty {
192
- padding: 32px;
193
- text-align: center;
194
- }
195
-
196
- .lct-filters {
197
- display: grid;
198
- gap: 12px;
199
- grid-template-columns: repeat(6, minmax(0, 1fr));
200
- }
201
-
202
- .lct-field {
203
- display: flex;
204
- flex-direction: column;
205
- gap: 6px;
206
- }
207
-
208
- .lct-field label {
209
- color: var(--lct-muted);
210
- font-size: 12px;
211
- font-weight: 600;
212
- text-transform: uppercase;
213
- }
214
-
215
- .lct-field input,
216
- .lct-field select {
217
- border: 1px solid var(--lct-border);
218
- border-radius: 6px;
219
- color: var(--lct-text);
220
- font: inherit;
221
- padding: 8px 10px;
222
- background: var(--lct-panel);
223
- }
224
-
225
- .lct-button-row {
226
- align-items: end;
227
- display: flex;
228
- gap: 8px;
229
- }
230
-
231
- .lct-button {
232
- background: var(--lct-accent);
233
- border: 1px solid var(--lct-accent);
234
- border-radius: 6px;
235
- color: #ffffff;
236
- display: inline-block;
237
- font: inherit;
238
- padding: 8px 12px;
239
- text-decoration: none;
240
- }
241
-
242
- .lct-button-secondary {
243
- background: var(--lct-panel);
244
- color: var(--lct-accent);
245
- }
246
-
247
- .lct-pagination {
248
- align-items: center;
249
- display: flex;
250
- justify-content: space-between;
251
- gap: 12px;
252
- margin-top: 16px;
253
- }
254
-
255
- .lct-tags {
256
- display: inline-block;
257
- max-width: 280px;
258
- overflow: hidden;
259
- text-overflow: ellipsis;
260
- white-space: nowrap;
261
- }
262
-
263
- .lct-nowrap {
264
- white-space: nowrap;
265
- }
266
-
267
- .lct-detail-grid {
268
- display: grid;
269
- gap: 16px;
270
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
271
- }
272
-
273
- .lct-dl {
274
- display: grid;
275
- gap: 12px;
276
- grid-template-columns: minmax(120px, 0.4fr) minmax(0, 1fr);
277
- margin: 0;
278
- }
279
-
280
- .lct-dl dt {
281
- color: var(--lct-muted);
282
- font-size: 12px;
283
- font-weight: 600;
284
- text-transform: uppercase;
285
- }
286
-
287
- .lct-dl dd {
288
- margin: 0;
289
- }
290
-
291
- .lct-pre {
292
- background: #0f172a;
293
- border-radius: 8px;
294
- color: #e5e7eb;
295
- margin: 0;
296
- overflow: auto;
297
- padding: 16px;
298
- }
299
-
300
- @media (max-width: 800px) {
301
- .lct-header {
302
- align-items: flex-start;
303
- flex-direction: column;
304
- }
305
-
306
- .lct-stats,
307
- .lct-two-col {
308
- grid-template-columns: 1fr;
309
- }
310
-
311
- .lct-filters {
312
- grid-template-columns: 1fr;
313
- }
314
-
315
- .lct-pagination {
316
- align-items: flex-start;
317
- flex-direction: column;
318
- }
319
-
320
- .lct-detail-grid {
321
- grid-template-columns: 1fr;
322
- }
323
- }
324
- </style>
6
+ <title>LLM Cost Tracker</title>
7
+ <%= stylesheet_link_tag stylesheet_path %>
325
8
  </head>
326
9
  <body>
327
- <main class="lct-shell">
328
- <header class="lct-header">
329
- <h1 class="lct-title">LLM Costs</h1>
330
- <nav class="lct-nav" aria-label="Dashboard">
331
- <%= link_to "Overview", root_path, class: ("lct-active" if request.path.delete_suffix("/") == root_path.delete_suffix("/")) %>
332
- <%= link_to "Models", models_path, class: ("lct-active" if request.path.start_with?(models_path)) %>
333
- <%= link_to "Calls", calls_path, class: ("lct-active" if request.path.start_with?(calls_path)) %>
334
- <%= link_to "Tags", tags_path, class: ("lct-active" if request.path.start_with?(tags_path)) %>
335
- <%= link_to "Data Quality", data_quality_path, class: ("lct-active" if request.path.start_with?(data_quality_path)) %>
336
- </nav>
337
- </header>
338
-
339
- <%= yield %>
340
- </main>
10
+ <div class="lct-app">
11
+ <main class="lct-shell">
12
+ <header class="lct-header">
13
+ <div class="lct-header-copy">
14
+ <h1 class="lct-title">LLM Cost Tracker</h1>
15
+ </div>
16
+ <nav class="lct-nav" aria-label="Dashboard">
17
+ <%= link_to "Overview", root_path, class: ("lct-active" if request.path.delete_suffix("/") == root_path.delete_suffix("/")) %>
18
+ <%= link_to "Models", models_path, class: ("lct-active" if request.path.start_with?(models_path)) %>
19
+ <%= link_to "Calls", calls_path, class: ("lct-active" if request.path.start_with?(calls_path)) %>
20
+ <%= link_to "Tags", tags_path, class: ("lct-active" if request.path.start_with?(tags_path)) %>
21
+ <%= link_to "Data Quality", data_quality_path, class: ("lct-active" if request.path.start_with?(data_quality_path)) %>
22
+ </nav>
23
+ </header>
24
+
25
+ <%= yield %>
26
+ </main>
27
+ </div>
28
+ <script>
29
+ document.addEventListener("keydown", function(event) {
30
+ if (event.key !== "/" || event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
31
+
32
+ var target = event.target;
33
+ if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
34
+
35
+ var input = document.querySelector("[data-lct-filter-input]");
36
+ if (!input) return;
37
+
38
+ event.preventDefault();
39
+ input.focus();
40
+ if (typeof input.select === "function") input.select();
41
+ });
42
+ </script>
341
43
  </body>
342
44
  </html>