llm_cost_tracker 0.2.0.alpha2 → 0.3.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +114 -70
  4. data/Rakefile +2 -0
  5. data/app/assets/llm_cost_tracker/application.css +760 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +12 -0
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
  9. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +47 -0
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
  15. data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
  16. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
  17. data/app/services/llm_cost_tracker/dashboard/filter.rb +22 -3
  18. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
  19. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
  20. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
  21. data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
  22. data/app/services/llm_cost_tracker/pagination.rb +6 -0
  23. data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
  24. data/app/views/llm_cost_tracker/calls/index.html.erb +116 -74
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
  26. data/app/views/llm_cost_tracker/dashboard/index.html.erb +211 -111
  27. data/app/views/llm_cost_tracker/data_quality/index.html.erb +224 -78
  28. data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
  29. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
  30. data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
  31. data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
  32. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
  39. data/config/routes.rb +3 -0
  40. data/lib/llm_cost_tracker/assets.rb +19 -0
  41. data/lib/llm_cost_tracker/configuration.rb +78 -42
  42. data/lib/llm_cost_tracker/engine.rb +2 -0
  43. data/lib/llm_cost_tracker/event.rb +2 -0
  44. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +4 -0
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  48. data/lib/llm_cost_tracker/llm_api_call.rb +9 -1
  49. data/lib/llm_cost_tracker/middleware/faraday.rb +57 -9
  50. data/lib/llm_cost_tracker/parsed_usage.rb +7 -3
  51. data/lib/llm_cost_tracker/parsers/anthropic.rb +79 -1
  52. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  53. data/lib/llm_cost_tracker/parsers/gemini.rb +59 -6
  54. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  55. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +8 -0
  56. data/lib/llm_cost_tracker/parsers/openai_usage.rb +55 -1
  57. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  58. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  59. data/lib/llm_cost_tracker/price_registry.rb +18 -7
  60. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  61. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  62. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  63. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  64. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  65. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  66. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  67. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  68. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  69. data/lib/llm_cost_tracker/price_sync.rb +310 -0
  70. data/lib/llm_cost_tracker/pricing.rb +19 -6
  71. data/lib/llm_cost_tracker/retention.rb +34 -0
  72. data/lib/llm_cost_tracker/storage/active_record_store.rb +3 -1
  73. data/lib/llm_cost_tracker/stream_collector.rb +158 -0
  74. data/lib/llm_cost_tracker/tag_query.rb +7 -2
  75. data/lib/llm_cost_tracker/tags_column.rb +21 -1
  76. data/lib/llm_cost_tracker/tracker.rb +15 -12
  77. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  78. data/lib/llm_cost_tracker/version.rb +1 -1
  79. data/lib/llm_cost_tracker.rb +51 -29
  80. data/lib/tasks/llm_cost_tracker.rake +124 -0
  81. data/llm_cost_tracker.gemspec +9 -8
  82. metadata +40 -12
  83. data/PLAN_0.2.md +0 -488
@@ -1,142 +1,242 @@
1
+ <% overview_filter_scope = current_query(from: @from_date.iso8601, to: @to_date.iso8601) %>
2
+
3
+ <section class="lct-panel lct-toolbar">
4
+ <form class="lct-filters" action="<%= root_path %>" method="get">
5
+ <div class="lct-filter-row lct-filter-row-basic">
6
+ <div class="lct-field">
7
+ <label for="lct-overview-from">From</label>
8
+ <input id="lct-overview-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] || @from_date.iso8601 %>">
9
+ </div>
10
+
11
+ <div class="lct-field">
12
+ <label for="lct-overview-to">To</label>
13
+ <input id="lct-overview-to" type="date" name="to" value="<%= params[:to] || @to_date.iso8601 %>">
14
+ </div>
15
+
16
+ <div class="lct-field">
17
+ <label for="lct-overview-provider">Provider</label>
18
+ <%= select_tag :provider,
19
+ options_for_select(provider_filter_options(filter_params: overview_filter_scope), params[:provider]),
20
+ include_blank: "All providers",
21
+ id: "lct-overview-provider" %>
22
+ </div>
23
+
24
+ <div class="lct-field">
25
+ <label for="lct-overview-model">Model</label>
26
+ <%= select_tag :model,
27
+ options_for_select(model_filter_options(filter_params: overview_filter_scope), params[:model]),
28
+ include_blank: "All models",
29
+ id: "lct-overview-model" %>
30
+ </div>
31
+
32
+ <% if LlmCostTracker::LlmApiCall.stream_column? %>
33
+ <div class="lct-field">
34
+ <label for="lct-overview-stream">Stream</label>
35
+ <%= select_tag :stream,
36
+ options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, params[:stream]),
37
+ include_blank: "All calls",
38
+ id: "lct-overview-stream" %>
39
+ </div>
40
+ <% end %>
41
+
42
+ <div class="lct-filter-actions">
43
+ <button class="lct-button" type="submit">Apply</button>
44
+ <%= link_to("Reset", root_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
45
+ </div>
46
+ </div>
47
+ </form>
48
+
49
+ <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: root_path %>
50
+ </section>
51
+
1
52
  <% if @stats.total_calls.zero? %>
2
53
  <section class="lct-panel lct-empty">
3
- <h2 class="lct-section-title">No LLM calls yet</h2>
4
- <p class="lct-muted">Tracked requests will appear here after your application records its first LLM API call.</p>
54
+ <h2 class="lct-state-title">No LLM calls yet</h2>
55
+ <p class="lct-state-copy">Tracked requests will appear here after your application records its first LLM API call.</p>
56
+ <div class="lct-state-actions">
57
+ <%= link_to "View calls", calls_path, class: "lct-button lct-button-secondary" %>
58
+ </div>
5
59
  </section>
6
60
  <% else %>
7
- <section class="lct-grid lct-stats">
8
- <article class="lct-stat">
9
- <p class="lct-stat-label">Total spend</p>
10
- <p class="lct-stat-value"><%= money(@stats.total_cost) %></p>
11
- <% badge = delta_badge(@stats.cost_delta_percent) %>
12
- <p class="<%= badge[:css_class] %>"><%= badge[:text] %></p>
13
- </article>
61
+ <% if @stats.unknown_pricing_count.positive? %>
62
+ <aside class="lct-banner lct-banner-warning" role="status">
63
+ <div class="lct-banner-body">
64
+ <p class="lct-banner-title">
65
+ <%= number(@stats.unknown_pricing_count) %> call<%= "s" unless @stats.unknown_pricing_count == 1 %> missing pricing
66
+ <span class="lct-banner-muted">· <%= percent(coverage_percent(@stats.unknown_pricing_count, @stats.total_calls)) %> of the slice</span>
67
+ </p>
68
+ <p class="lct-banner-copy">Totals undercount until every model has a known price. Update <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code> to fix.</p>
69
+ </div>
70
+ <%= link_to "Fix now →", data_quality_path, class: "lct-button lct-button-secondary" %>
71
+ </aside>
72
+ <% end %>
14
73
 
15
- <article class="lct-stat">
16
- <p class="lct-stat-label">Calls</p>
17
- <p class="lct-stat-value"><%= number(@stats.total_calls) %></p>
18
- <% badge = delta_badge(@stats.calls_delta_percent, mode: :neutral) %>
19
- <p class="<%= badge[:css_class] %>"><%= badge[:text] %></p>
20
- </article>
74
+ <% if @spend_anomaly %>
75
+ <aside class="lct-banner lct-banner-danger" role="status">
76
+ <div class="lct-banner-body">
77
+ <p class="lct-banner-title">
78
+ Spend anomaly detected
79
+ <span class="lct-banner-muted">· <code class="lct-code"><%= @spend_anomaly.model %></code> on <%= @spend_anomaly.day.strftime("%b %-d") %></span>
80
+ </p>
81
+ <p class="lct-banner-copy">
82
+ <% if @spend_anomaly.ratio %>
83
+ <%= number_with_precision(@spend_anomaly.ratio, precision: 1) %>× its prior 7-day average in this slice
84
+ <% else %>
85
+ <%= money(@spend_anomaly.latest_spend) %> after seven quiet days in this slice
86
+ <% end %>
87
+ </p>
88
+ </div>
89
+ <%= link_to "Review calls →",
90
+ calls_path(current_query(provider: @spend_anomaly.provider, model: @spend_anomaly.model, from: @to_date.iso8601, to: @to_date.iso8601, page: nil, per: nil, format: nil)),
91
+ class: "lct-button lct-button-secondary" %>
92
+ </aside>
93
+ <% end %>
21
94
 
22
- <article class="lct-stat">
23
- <p class="lct-stat-label">Avg cost / call</p>
24
- <p class="lct-stat-value"><%= money(@stats.average_cost_per_call) %></p>
95
+ <section class="lct-hero">
96
+ <article class="lct-panel lct-hero-primary">
97
+ <div>
98
+ <p class="lct-stat-label">Total spend</p>
99
+ <p class="lct-hero-value"><%= money(@stats.total_cost) %></p>
100
+ <% badge = delta_badge(@stats.cost_delta_percent) %>
101
+ <span class="<%= badge[:css_class] %>"><span class="lct-delta-dot" aria-hidden="true"></span><%= badge[:text] %></span>
102
+ </div>
25
103
  </article>
26
104
 
27
- <% if @stats.average_latency_ms %>
28
- <article class="lct-stat">
29
- <p class="lct-stat-label">Avg latency</p>
30
- <p class="lct-stat-value"><%= number(@stats.average_latency_ms.round) %>ms</p>
31
- </article>
32
- <% end %>
105
+ <div class="lct-hero-side">
106
+ <div class="lct-stat-grid">
107
+ <article class="lct-stat">
108
+ <p class="lct-stat-label">Calls</p>
109
+ <p class="lct-stat-value"><%= number(@stats.total_calls) %></p>
110
+ <% badge = delta_badge(@stats.calls_delta_percent, mode: :neutral) %>
111
+ <span class="<%= badge[:css_class] %>"><span class="lct-delta-dot" aria-hidden="true"></span><%= badge[:text] %></span>
112
+ </article>
33
113
 
34
- <% if @stats.unknown_pricing_count.positive? %>
35
- <article class="lct-stat">
36
- <p class="lct-stat-label">Unknown pricing</p>
37
- <p class="lct-stat-value"><%= number(@stats.unknown_pricing_count) %></p>
38
- <p class="lct-stat-sub">
39
- <%= percent(coverage_percent(@stats.unknown_pricing_count, @stats.total_calls)) %> of calls &middot;
40
- <%= link_to "Review →", data_quality_path %>
41
- </p>
42
- </article>
43
- <% end %>
44
- </section>
114
+ <article class="lct-stat">
115
+ <p class="lct-stat-label">Avg cost / call</p>
116
+ <p class="lct-stat-value"><%= money(@stats.average_cost_per_call) %></p>
117
+ </article>
45
118
 
46
- <% if @stats.monthly_budget_status %>
47
- <% budget = @stats.monthly_budget_status %>
48
- <section class="lct-panel">
49
- <h2 class="lct-section-title">Monthly Budget</h2>
50
- <p class="lct-muted">Current-month spend across <strong>all</strong> calls — overview filters do not apply. Soft limit: blocking is not atomic under concurrency.</p>
51
- <table class="lct-table">
52
- <tbody>
53
- <tr>
54
- <th>Spent</th>
55
- <td><%= money(budget[:spent]) %></td>
56
- </tr>
57
- <tr>
58
- <th>Budget</th>
59
- <td><%= money(budget[:budget]) %></td>
60
- </tr>
61
- <tr>
62
- <th>Used</th>
63
- <td><%= percent(budget[:percent_used]) %></td>
64
- </tr>
65
- </tbody>
66
- </table>
67
- <%= render "llm_cost_tracker/shared/bar", value: budget[:percent_used], max: 100.0, variant: "budget" %>
68
- </section>
69
- <% end %>
70
-
71
- <section class="lct-panel">
72
- <h2 class="lct-section-title">Daily Spend</h2>
73
- <% max_cost = @time_series.map { |point| point[:cost] }.max.to_f %>
74
- <table class="lct-table">
75
- <thead>
76
- <tr>
77
- <th>Day</th>
78
- <th>Spend</th>
79
- <th>Trend</th>
80
- </tr>
81
- </thead>
82
- <tbody>
83
- <% @time_series.each do |point| %>
84
- <tr>
85
- <td><%= point[:label] %></td>
86
- <td><%= money(point[:cost]) %></td>
87
- <td><%= render "llm_cost_tracker/shared/bar", value: point[:cost], max: max_cost %></td>
88
- </tr>
119
+ <% if @stats.average_latency_ms %>
120
+ <article class="lct-stat">
121
+ <p class="lct-stat-label">Avg latency</p>
122
+ <p class="lct-stat-value"><%= number(@stats.average_latency_ms.round) %>ms</p>
123
+ </article>
89
124
  <% end %>
90
- </tbody>
91
- </table>
125
+
126
+ </div>
127
+
128
+ <% if @stats.monthly_budget_status %>
129
+ <% budget = @stats.monthly_budget_status %>
130
+ <% fill_mod = budget_fill_modifier(budget[:percent_used]) %>
131
+ <% projected_marker = [[budget[:projected_percent_used].to_f, 0.0].max, 100.0].min %>
132
+ <% projected_delta = budget[:projected_delta].to_f %>
133
+ <section class="lct-panel lct-panel-tight">
134
+ <div class="lct-section-head">
135
+ <div>
136
+ <h2 class="lct-section-title">Monthly Budget</h2>
137
+ <p class="lct-section-copy">Current-month spend across <strong>all</strong> calls.</p>
138
+ </div>
139
+ </div>
140
+ <div class="lct-budget">
141
+ <div class="lct-budget-head">
142
+ <span>
143
+ <span class="lct-budget-spent"><%= money(budget[:spent]) %></span>
144
+ <span class="lct-budget-of"> of <%= money(budget[:budget]) %></span>
145
+ </span>
146
+ <span class="lct-budget-percent"><%= percent(budget[:percent_used]) %></span>
147
+ </div>
148
+ <div class="lct-budget-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= [budget[:percent_used].to_f, 100.0].min.round %>">
149
+ <div class="lct-budget-fill <%= fill_mod %>" style="width: <%= [budget[:percent_used].to_f, 100.0].min %>%"></div>
150
+ <% if budget[:projected_spent].positive? %>
151
+ <span class="lct-budget-marker" aria-hidden="true" style="left: calc(<%= projected_marker %>% - 1px)"></span>
152
+ <% end %>
153
+ </div>
154
+ <p class="lct-budget-projection">
155
+ <span>Projected <strong><%= money(budget[:projected_spent]) %></strong> by <%= budget[:projection_end_label] %></span>
156
+ <span class="lct-budget-projection-status <%= projected_delta.positive? ? "lct-budget-projection-status--over" : "lct-budget-projection-status--under" %>">
157
+ <%= money(projected_delta.abs) %> <%= projected_delta.positive? ? "over" : "under" %> budget
158
+ </span>
159
+ </p>
160
+ <p class="lct-budget-meta">Soft limit: blocking is not atomic under concurrency.</p>
161
+ </div>
162
+ </section>
163
+ <% end %>
164
+ </div>
92
165
  </section>
93
166
 
94
- <% if @providers.any? %>
167
+ <section class="lct-grid lct-two-col">
95
168
  <section class="lct-panel">
96
- <h2 class="lct-section-title">By Provider</h2>
97
- <table class="lct-table">
98
- <thead>
99
- <tr>
100
- <th>Provider</th>
101
- <th>Calls</th>
102
- <th>Spend</th>
103
- <th>Share</th>
104
- </tr>
105
- </thead>
106
- <tbody>
107
- <% @providers.each do |row| %>
169
+ <div class="lct-section-head">
170
+ <div>
171
+ <h2 class="lct-section-title">Daily Spend</h2>
172
+ <p class="lct-section-copy">Current slice vs. previous <%= number(@comparison_series.size) %>-day slice.</p>
173
+ </div>
174
+ </div>
175
+ <%= render "llm_cost_tracker/shared/spend_chart", series: @time_series, comparison_series: @comparison_series %>
176
+ </section>
177
+
178
+ <% if @providers.any? %>
179
+ <section class="lct-panel">
180
+ <div class="lct-section-head">
181
+ <div>
182
+ <h2 class="lct-section-title">By Provider</h2>
183
+ <p class="lct-section-copy">Spend share across the selected slice.</p>
184
+ </div>
185
+ </div>
186
+ <table class="lct-table lct-table-compact">
187
+ <thead>
108
188
  <tr>
109
- <td><%= row.provider %></td>
110
- <td><%= number(row.calls) %></td>
111
- <td><%= money(row.total_cost) %></td>
112
- <td><%= percent(row.share_percent) %></td>
189
+ <th>Provider</th>
190
+ <th class="lct-num">Calls</th>
191
+ <th class="lct-num">Spend</th>
192
+ <th class="lct-num">Share</th>
193
+ <th></th>
113
194
  </tr>
114
- <% end %>
115
- </tbody>
116
- </table>
117
- </section>
118
- <% end %>
195
+ </thead>
196
+ <tbody>
197
+ <% @providers.each do |row| %>
198
+ <tr>
199
+ <td><%= row.provider %></td>
200
+ <td class="lct-num"><%= number(row.calls) %></td>
201
+ <td class="lct-num"><%= money(row.total_cost) %></td>
202
+ <td class="lct-num"><%= percent(row.share_percent) %></td>
203
+ <td><%= link_to "Calls", calls_path(current_query(provider: row.provider, page: nil, per: nil, format: nil)), class: "lct-button lct-button-secondary lct-button-compact" %></td>
204
+ </tr>
205
+ <% end %>
206
+ </tbody>
207
+ </table>
208
+ </section>
209
+ <% end %>
210
+ </section>
119
211
 
120
212
  <section class="lct-panel">
121
- <h2 class="lct-section-title">Top Models</h2>
122
- <table class="lct-table">
213
+ <div class="lct-section-head">
214
+ <div>
215
+ <h2 class="lct-section-title">Top Models</h2>
216
+ <p class="lct-section-copy">The heaviest contributors in the current slice.</p>
217
+ </div>
218
+ <%= link_to "View all models", models_path(current_query), class: "lct-button lct-button-secondary lct-button-compact" %>
219
+ </div>
220
+ <table class="lct-table lct-table-compact">
123
221
  <thead>
124
222
  <tr>
125
223
  <th>Provider</th>
126
224
  <th>Model</th>
127
- <th>Calls</th>
128
- <th>Spend</th>
129
- <th>Avg cost / call</th>
225
+ <th class="lct-num">Calls</th>
226
+ <th class="lct-num">Spend</th>
227
+ <th class="lct-num">Avg cost / call</th>
228
+ <th></th>
130
229
  </tr>
131
230
  </thead>
132
231
  <tbody>
133
232
  <% @top_models.each do |row| %>
134
233
  <tr>
135
234
  <td><%= row.provider %></td>
136
- <td><%= row.model %></td>
137
- <td><%= number(row.calls) %></td>
138
- <td><%= money(row.total_cost) %></td>
139
- <td><%= money(row.average_cost_per_call) %></td>
235
+ <td><code class="lct-code"><%= row.model %></code></td>
236
+ <td class="lct-num"><%= number(row.calls) %></td>
237
+ <td class="lct-num"><%= money(row.total_cost) %></td>
238
+ <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
239
+ <td><%= link_to "Calls", calls_path(calls_query_for_model(provider: row.provider, model: row.model)), class: "lct-button lct-button-secondary lct-button-compact" %></td>
140
240
  </tr>
141
241
  <% end %>
142
242
  </tbody>