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,142 +1,232 @@
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
+ <div class="lct-filter-actions">
33
+ <button class="lct-button" type="submit">Apply</button>
34
+ <%= link_to("Reset", root_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
35
+ </div>
36
+ </div>
37
+ </form>
38
+
39
+ <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: root_path %>
40
+ </section>
41
+
1
42
  <% if @stats.total_calls.zero? %>
2
43
  <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>
44
+ <h2 class="lct-state-title">No LLM calls yet</h2>
45
+ <p class="lct-state-copy">Tracked requests will appear here after your application records its first LLM API call.</p>
46
+ <div class="lct-state-actions">
47
+ <%= link_to "View calls", calls_path, class: "lct-button lct-button-secondary" %>
48
+ </div>
5
49
  </section>
6
50
  <% 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>
51
+ <% if @stats.unknown_pricing_count.positive? %>
52
+ <aside class="lct-banner lct-banner-warning" role="status">
53
+ <div class="lct-banner-body">
54
+ <p class="lct-banner-title">
55
+ <%= number(@stats.unknown_pricing_count) %> call<%= "s" unless @stats.unknown_pricing_count == 1 %> missing pricing
56
+ <span class="lct-banner-muted">· <%= percent(coverage_percent(@stats.unknown_pricing_count, @stats.total_calls)) %> of the slice</span>
57
+ </p>
58
+ <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>
59
+ </div>
60
+ <%= link_to "Fix now →", data_quality_path, class: "lct-button lct-button-secondary" %>
61
+ </aside>
62
+ <% end %>
14
63
 
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>
64
+ <% if @spend_anomaly %>
65
+ <aside class="lct-banner lct-banner-danger" role="status">
66
+ <div class="lct-banner-body">
67
+ <p class="lct-banner-title">
68
+ Spend anomaly detected
69
+ <span class="lct-banner-muted">· <code class="lct-code"><%= @spend_anomaly.model %></code> on <%= @spend_anomaly.day.strftime("%b %-d") %></span>
70
+ </p>
71
+ <p class="lct-banner-copy">
72
+ <% if @spend_anomaly.ratio %>
73
+ <%= number_with_precision(@spend_anomaly.ratio, precision: 1) %>× its prior 7-day average in this slice
74
+ <% else %>
75
+ <%= money(@spend_anomaly.latest_spend) %> after seven quiet days in this slice
76
+ <% end %>
77
+ </p>
78
+ </div>
79
+ <%= link_to "Review calls →",
80
+ 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)),
81
+ class: "lct-button lct-button-secondary" %>
82
+ </aside>
83
+ <% end %>
21
84
 
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>
85
+ <section class="lct-hero">
86
+ <article class="lct-panel lct-hero-primary">
87
+ <div>
88
+ <p class="lct-stat-label">Total spend</p>
89
+ <p class="lct-hero-value"><%= money(@stats.total_cost) %></p>
90
+ <% badge = delta_badge(@stats.cost_delta_percent) %>
91
+ <span class="<%= badge[:css_class] %>"><span class="lct-delta-dot" aria-hidden="true"></span><%= badge[:text] %></span>
92
+ </div>
25
93
  </article>
26
94
 
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 %>
33
-
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>
95
+ <div class="lct-hero-side">
96
+ <div class="lct-stat-grid">
97
+ <article class="lct-stat">
98
+ <p class="lct-stat-label">Calls</p>
99
+ <p class="lct-stat-value"><%= number(@stats.total_calls) %></p>
100
+ <% badge = delta_badge(@stats.calls_delta_percent, mode: :neutral) %>
101
+ <span class="<%= badge[:css_class] %>"><span class="lct-delta-dot" aria-hidden="true"></span><%= badge[:text] %></span>
102
+ </article>
45
103
 
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 %>
104
+ <article class="lct-stat">
105
+ <p class="lct-stat-label">Avg cost / call</p>
106
+ <p class="lct-stat-value"><%= money(@stats.average_cost_per_call) %></p>
107
+ </article>
70
108
 
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>
109
+ <% if @stats.average_latency_ms %>
110
+ <article class="lct-stat">
111
+ <p class="lct-stat-label">Avg latency</p>
112
+ <p class="lct-stat-value"><%= number(@stats.average_latency_ms.round) %>ms</p>
113
+ </article>
89
114
  <% end %>
90
- </tbody>
91
- </table>
115
+
116
+ </div>
117
+
118
+ <% if @stats.monthly_budget_status %>
119
+ <% budget = @stats.monthly_budget_status %>
120
+ <% fill_mod = budget_fill_modifier(budget[:percent_used]) %>
121
+ <% projected_marker = [[budget[:projected_percent_used].to_f, 0.0].max, 100.0].min %>
122
+ <% projected_delta = budget[:projected_delta].to_f %>
123
+ <section class="lct-panel lct-panel-tight">
124
+ <div class="lct-section-head">
125
+ <div>
126
+ <h2 class="lct-section-title">Monthly Budget</h2>
127
+ <p class="lct-section-copy">Current-month spend across <strong>all</strong> calls.</p>
128
+ </div>
129
+ </div>
130
+ <div class="lct-budget">
131
+ <div class="lct-budget-head">
132
+ <span>
133
+ <span class="lct-budget-spent"><%= money(budget[:spent]) %></span>
134
+ <span class="lct-budget-of"> of <%= money(budget[:budget]) %></span>
135
+ </span>
136
+ <span class="lct-budget-percent"><%= percent(budget[:percent_used]) %></span>
137
+ </div>
138
+ <div class="lct-budget-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= [budget[:percent_used].to_f, 100.0].min.round %>">
139
+ <div class="lct-budget-fill <%= fill_mod %>" style="width: <%= [budget[:percent_used].to_f, 100.0].min %>%"></div>
140
+ <% if budget[:projected_spent].positive? %>
141
+ <span class="lct-budget-marker" aria-hidden="true" style="left: calc(<%= projected_marker %>% - 1px)"></span>
142
+ <% end %>
143
+ </div>
144
+ <p class="lct-budget-projection">
145
+ <span>Projected <strong><%= money(budget[:projected_spent]) %></strong> by <%= budget[:projection_end_label] %></span>
146
+ <span class="lct-budget-projection-status <%= projected_delta.positive? ? "lct-budget-projection-status--over" : "lct-budget-projection-status--under" %>">
147
+ <%= money(projected_delta.abs) %> <%= projected_delta.positive? ? "over" : "under" %> budget
148
+ </span>
149
+ </p>
150
+ <p class="lct-budget-meta">Soft limit: blocking is not atomic under concurrency.</p>
151
+ </div>
152
+ </section>
153
+ <% end %>
154
+ </div>
92
155
  </section>
93
156
 
94
- <% if @providers.any? %>
157
+ <section class="lct-grid lct-two-col">
95
158
  <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| %>
159
+ <div class="lct-section-head">
160
+ <div>
161
+ <h2 class="lct-section-title">Daily Spend</h2>
162
+ <p class="lct-section-copy">Current slice vs. previous <%= number(@comparison_series.size) %>-day slice.</p>
163
+ </div>
164
+ </div>
165
+ <%= render "llm_cost_tracker/shared/spend_chart", series: @time_series, comparison_series: @comparison_series %>
166
+ </section>
167
+
168
+ <% if @providers.any? %>
169
+ <section class="lct-panel">
170
+ <div class="lct-section-head">
171
+ <div>
172
+ <h2 class="lct-section-title">By Provider</h2>
173
+ <p class="lct-section-copy">Spend share across the selected slice.</p>
174
+ </div>
175
+ </div>
176
+ <table class="lct-table lct-table-compact">
177
+ <thead>
108
178
  <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>
179
+ <th>Provider</th>
180
+ <th class="lct-num">Calls</th>
181
+ <th class="lct-num">Spend</th>
182
+ <th class="lct-num">Share</th>
183
+ <th></th>
113
184
  </tr>
114
- <% end %>
115
- </tbody>
116
- </table>
117
- </section>
118
- <% end %>
185
+ </thead>
186
+ <tbody>
187
+ <% @providers.each do |row| %>
188
+ <tr>
189
+ <td><%= row.provider %></td>
190
+ <td class="lct-num"><%= number(row.calls) %></td>
191
+ <td class="lct-num"><%= money(row.total_cost) %></td>
192
+ <td class="lct-num"><%= percent(row.share_percent) %></td>
193
+ <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>
194
+ </tr>
195
+ <% end %>
196
+ </tbody>
197
+ </table>
198
+ </section>
199
+ <% end %>
200
+ </section>
119
201
 
120
202
  <section class="lct-panel">
121
- <h2 class="lct-section-title">Top Models</h2>
122
- <table class="lct-table">
203
+ <div class="lct-section-head">
204
+ <div>
205
+ <h2 class="lct-section-title">Top Models</h2>
206
+ <p class="lct-section-copy">The heaviest contributors in the current slice.</p>
207
+ </div>
208
+ <%= link_to "View all models", models_path(current_query), class: "lct-button lct-button-secondary lct-button-compact" %>
209
+ </div>
210
+ <table class="lct-table lct-table-compact">
123
211
  <thead>
124
212
  <tr>
125
213
  <th>Provider</th>
126
214
  <th>Model</th>
127
- <th>Calls</th>
128
- <th>Spend</th>
129
- <th>Avg cost / call</th>
215
+ <th class="lct-num">Calls</th>
216
+ <th class="lct-num">Spend</th>
217
+ <th class="lct-num">Avg cost / call</th>
218
+ <th></th>
130
219
  </tr>
131
220
  </thead>
132
221
  <tbody>
133
222
  <% @top_models.each do |row| %>
134
223
  <tr>
135
224
  <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>
225
+ <td><code class="lct-code"><%= row.model %></code></td>
226
+ <td class="lct-num"><%= number(row.calls) %></td>
227
+ <td class="lct-num"><%= money(row.total_cost) %></td>
228
+ <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
229
+ <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
230
  </tr>
141
231
  <% end %>
142
232
  </tbody>
@@ -1,110 +1,210 @@
1
1
  <% total = @stats.total_calls %>
2
+ <% known_pricing_calls = total - @stats.unknown_pricing_count %>
3
+ <% tagged_calls = total - @stats.untagged_calls_count %>
4
+ <% latency_calls = @stats.latency_column_present ? total - @stats.missing_latency_count : nil %>
5
+
6
+ <section class="lct-panel lct-toolbar">
7
+ <div class="lct-toolbar-head">
8
+ <h2 class="lct-section-title">Data Quality</h2>
9
+ </div>
10
+
11
+ <form class="lct-filters" action="<%= data_quality_path %>" method="get">
12
+ <div class="lct-filter-row lct-filter-row-basic">
13
+ <div class="lct-field">
14
+ <label for="lct-quality-from">From</label>
15
+ <input id="lct-quality-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] %>">
16
+ </div>
17
+
18
+ <div class="lct-field">
19
+ <label for="lct-quality-to">To</label>
20
+ <input id="lct-quality-to" type="date" name="to" value="<%= params[:to] %>">
21
+ </div>
22
+
23
+ <div class="lct-field">
24
+ <label for="lct-quality-provider">Provider</label>
25
+ <%= select_tag :provider,
26
+ options_for_select(provider_filter_options, params[:provider]),
27
+ include_blank: "All providers",
28
+ id: "lct-quality-provider" %>
29
+ </div>
30
+
31
+ <div class="lct-field">
32
+ <label for="lct-quality-model">Model</label>
33
+ <%= select_tag :model,
34
+ options_for_select(model_filter_options, params[:model]),
35
+ include_blank: "All models",
36
+ id: "lct-quality-model" %>
37
+ </div>
38
+
39
+ <div class="lct-filter-actions">
40
+ <button class="lct-button" type="submit">Apply</button>
41
+ <%= link_to("Reset", data_quality_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
42
+ </div>
43
+ </div>
44
+ </form>
45
+
46
+ <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: data_quality_path %>
47
+ </section>
2
48
 
3
49
  <% if total.zero? %>
4
50
  <section class="lct-panel lct-empty">
5
- <h2 class="lct-section-title">No data yet</h2>
6
- <p class="lct-muted">Data quality metrics will appear here once calls are recorded.</p>
51
+ <h2 class="lct-state-title">No data yet</h2>
52
+ <p class="lct-state-copy">Quality metrics will appear here once calls are recorded in the current slice.</p>
53
+ <div class="lct-state-actions">
54
+ <%= link_to "Clear filters", data_quality_path, class: "lct-button lct-button-secondary" %>
55
+ </div>
7
56
  </section>
8
57
  <% else %>
9
- <section class="lct-grid lct-stats">
10
- <article class="lct-stat">
11
- <p class="lct-stat-label">Total calls</p>
12
- <p class="lct-stat-value"><%= number(total) %></p>
58
+ <section class="lct-hero">
59
+ <article class="lct-panel lct-hero-primary">
60
+ <div>
61
+ <p class="lct-stat-label">Calls inspected</p>
62
+ <p class="lct-hero-value"><%= number(total) %></p>
63
+ </div>
13
64
  </article>
14
65
 
15
- <article class="lct-stat">
16
- <p class="lct-stat-label">Unknown pricing</p>
17
- <p class="lct-stat-value"><%= number(@stats.unknown_pricing_count) %></p>
18
- <p class="lct-stat-sub"><%= percent(coverage_percent(@stats.unknown_pricing_count, total)) %> of calls</p>
19
- </article>
66
+ <div class="lct-hero-side">
67
+ <div class="lct-stat-grid">
68
+ <article class="lct-stat">
69
+ <p class="lct-stat-label">Unknown pricing</p>
70
+ <p class="lct-stat-value"><%= number(@stats.unknown_pricing_count) %></p>
71
+ <p class="lct-stat-sub"><%= percent(coverage_percent(@stats.unknown_pricing_count, total)) %> of calls</p>
72
+ </article>
20
73
 
21
- <article class="lct-stat">
22
- <p class="lct-stat-label">Calls without tags</p>
23
- <p class="lct-stat-value"><%= number(@stats.untagged_calls_count) %></p>
24
- <p class="lct-stat-sub"><%= percent(coverage_percent(@stats.untagged_calls_count, total)) %> of calls</p>
25
- </article>
74
+ <article class="lct-stat">
75
+ <p class="lct-stat-label">Calls without tags</p>
76
+ <p class="lct-stat-value"><%= number(@stats.untagged_calls_count) %></p>
77
+ <p class="lct-stat-sub"><%= percent(coverage_percent(@stats.untagged_calls_count, total)) %> of calls</p>
78
+ </article>
26
79
 
27
- <% if @stats.latency_column_present %>
28
- <article class="lct-stat">
29
- <p class="lct-stat-label">Missing latency</p>
30
- <p class="lct-stat-value"><%= number(@stats.missing_latency_count) %></p>
31
- <p class="lct-stat-sub"><%= percent(coverage_percent(@stats.missing_latency_count, total)) %> of calls</p>
32
- </article>
33
- <% end %>
80
+ <% if @stats.latency_column_present %>
81
+ <article class="lct-stat">
82
+ <p class="lct-stat-label">Missing latency</p>
83
+ <p class="lct-stat-value"><%= number(@stats.missing_latency_count) %></p>
84
+ <p class="lct-stat-sub"><%= percent(coverage_percent(@stats.missing_latency_count, total)) %> of calls</p>
85
+ </article>
86
+ <% end %>
87
+ </div>
88
+ </div>
89
+ </section>
90
+
91
+ <section class="lct-grid lct-two-col">
92
+ <section class="lct-panel">
93
+ <div class="lct-section-head">
94
+ <div>
95
+ <h2 class="lct-section-title">Coverage summary</h2>
96
+ <p class="lct-section-copy">Good dashboards start with clean pricing, tags, and latency coverage.</p>
97
+ </div>
98
+ </div>
99
+
100
+ <table class="lct-table lct-table-compact">
101
+ <thead>
102
+ <tr>
103
+ <th>Dimension</th>
104
+ <th class="lct-num">Coverage</th>
105
+ <th class="lct-num">Calls with data</th>
106
+ <th>Visual</th>
107
+ </tr>
108
+ </thead>
109
+ <tbody>
110
+ <% cost_coverage = coverage_percent(known_pricing_calls, total) %>
111
+ <tr>
112
+ <td>Cost (pricing known)</td>
113
+ <td class="lct-num"><%= percent(cost_coverage) %></td>
114
+ <td class="lct-num"><%= number(known_pricing_calls) %></td>
115
+ <td><%= render "llm_cost_tracker/shared/bar", value: cost_coverage, max: 100.0 %></td>
116
+ </tr>
117
+
118
+ <% tag_coverage = coverage_percent(tagged_calls, total) %>
119
+ <tr>
120
+ <td>Tags (at least one tag)</td>
121
+ <td class="lct-num"><%= percent(tag_coverage) %></td>
122
+ <td class="lct-num"><%= number(tagged_calls) %></td>
123
+ <td><%= render "llm_cost_tracker/shared/bar", value: tag_coverage, max: 100.0 %></td>
124
+ </tr>
125
+
126
+ <% if @stats.latency_column_present %>
127
+ <% latency_coverage = coverage_percent(latency_calls, total) %>
128
+ <tr>
129
+ <td>Latency</td>
130
+ <td class="lct-num"><%= percent(latency_coverage) %></td>
131
+ <td class="lct-num"><%= number(latency_calls) %></td>
132
+ <td><%= render "llm_cost_tracker/shared/bar", value: latency_coverage, max: 100.0 %></td>
133
+ </tr>
134
+ <% end %>
135
+ </tbody>
136
+ </table>
137
+ </section>
138
+
139
+ <section class="lct-panel">
140
+ <div class="lct-section-head">
141
+ <div>
142
+ <h2 class="lct-section-title">Next actions</h2>
143
+ <p class="lct-section-copy">Use these fixes to improve the trustworthiness of downstream cost reports.</p>
144
+ </div>
145
+ </div>
146
+
147
+ <table class="lct-table lct-table-compact">
148
+ <thead>
149
+ <tr>
150
+ <th>Issue</th>
151
+ <th>Why it matters</th>
152
+ <th>Suggested action</th>
153
+ </tr>
154
+ </thead>
155
+ <tbody>
156
+ <tr>
157
+ <td>Unknown pricing</td>
158
+ <td>Cost stays <code class="lct-code">nil</code>, so totals undercount.</td>
159
+ <td>Update <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code>.</td>
160
+ </tr>
161
+ <tr>
162
+ <td>Missing tags</td>
163
+ <td>Attribution by tenant, user, or feature becomes less useful.</td>
164
+ <td>Pass <code class="lct-code">tags:</code> from middleware using request context.</td>
165
+ </tr>
166
+ <% if @stats.latency_column_present %>
167
+ <tr>
168
+ <td>Missing latency</td>
169
+ <td>Slow requests become harder to isolate on the calls page.</td>
170
+ <td>Make sure latency capture is enabled on every tracked request.</td>
171
+ </tr>
172
+ <% end %>
173
+ </tbody>
174
+ </table>
175
+ </section>
34
176
  </section>
35
177
 
36
178
  <% unless @stats.unknown_pricing_by_model.empty? %>
37
179
  <section class="lct-panel">
38
- <h2 class="lct-section-title">Unknown Pricing by Model</h2>
39
- <p class="lct-muted">
40
- These models have no pricing configured. Token counts are recorded but cost is <code class="lct-code">nil</code>.
41
- Add entries to <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code> to fix.
42
- </p>
180
+ <div class="lct-section-head">
181
+ <div>
182
+ <h2 class="lct-section-title">Unknown pricing by model</h2>
183
+ <p class="lct-section-copy">These models have token counts but no configured rates, so cost stays unknown.</p>
184
+ </div>
185
+ <%= link_to "Review calls", calls_path(current_query(sort: "unknown_pricing")), class: "lct-button lct-button-secondary" %>
186
+ </div>
187
+
43
188
  <div class="lct-table-wrap">
44
- <table class="lct-table">
189
+ <table class="lct-table lct-table-compact">
45
190
  <thead>
46
191
  <tr>
47
192
  <th>Model</th>
48
- <th>Calls without cost</th>
49
- <th>Share of total</th>
193
+ <th class="lct-num">Calls without cost</th>
194
+ <th class="lct-num">Share of total</th>
50
195
  </tr>
51
196
  </thead>
52
197
  <tbody>
53
198
  <% @stats.unknown_pricing_by_model.each do |model, count| %>
54
199
  <tr>
55
200
  <td><code class="lct-code"><%= model %></code></td>
56
- <td><%= number(count) %></td>
57
- <td><%= percent(total.positive? ? (count.to_f / total) * 100 : 0) %></td>
201
+ <td class="lct-num"><%= number(count) %></td>
202
+ <td class="lct-num"><%= percent(total.positive? ? (count.to_f / total) * 100.0 : 0.0) %></td>
58
203
  </tr>
59
204
  <% end %>
60
205
  </tbody>
61
206
  </table>
62
207
  </div>
63
- <p class="lct-muted" style="margin-top:12px">
64
- <%= link_to "View all unknown pricing calls →", calls_path(sort: "unknown_pricing") %>
65
- </p>
66
208
  </section>
67
209
  <% end %>
68
-
69
- <section class="lct-panel">
70
- <h2 class="lct-section-title">Coverage Summary</h2>
71
- <table class="lct-table">
72
- <thead>
73
- <tr>
74
- <th>Dimension</th>
75
- <th>Coverage</th>
76
- <th>Calls with data</th>
77
- <th>Visual</th>
78
- </tr>
79
- </thead>
80
- <tbody>
81
- <% cost_coverage = coverage_percent(total - @stats.unknown_pricing_count, total) %>
82
- <tr>
83
- <td>Cost (pricing known)</td>
84
- <td><%= percent(cost_coverage) %></td>
85
- <td><%= number(total - @stats.unknown_pricing_count) %></td>
86
- <td><%= render "llm_cost_tracker/shared/bar", value: cost_coverage, max: 100.0 %></td>
87
- </tr>
88
-
89
- <% tag_coverage = coverage_percent(total - @stats.untagged_calls_count, total) %>
90
- <tr>
91
- <td>Tags (at least one tag)</td>
92
- <td><%= percent(tag_coverage) %></td>
93
- <td><%= number(total - @stats.untagged_calls_count) %></td>
94
- <td><%= render "llm_cost_tracker/shared/bar", value: tag_coverage, max: 100.0 %></td>
95
- </tr>
96
-
97
- <% if @stats.latency_column_present %>
98
- <% latency_coverage = coverage_percent(total - @stats.missing_latency_count, total) %>
99
- <tr>
100
- <td>Latency</td>
101
- <td><%= percent(latency_coverage) %></td>
102
- <td><%= number(total - @stats.missing_latency_count) %></td>
103
- <td><%= render "llm_cost_tracker/shared/bar", value: latency_coverage, max: 100.0 %></td>
104
- </tr>
105
- <% end %>
106
- </tbody>
107
- </table>
108
- </section>
109
210
  <% end %>
110
-
@@ -1,6 +1,6 @@
1
- <section class="lct-panel">
2
- <h2>Database unavailable</h2>
3
- <p class="lct-muted">
1
+ <section class="lct-panel lct-empty">
2
+ <h2 class="lct-state-title">Database unavailable</h2>
3
+ <p class="lct-state-copy">
4
4
  llm_cost_tracker could not read the <span class="lct-code">llm_api_calls</span> table.
5
5
  Check that ActiveRecord is connected, then run
6
6
  <span class="lct-code">rails generate llm_cost_tracker:install</span> and migrate your database.