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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -1
- data/README.md +114 -70
- data/Rakefile +2 -0
- data/app/assets/llm_cost_tracker/application.css +760 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
- data/app/controllers/llm_cost_tracker/assets_controller.rb +12 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
- data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +47 -0
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
- data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +22 -3
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
- data/app/services/llm_cost_tracker/pagination.rb +6 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
- data/app/views/llm_cost_tracker/calls/index.html.erb +116 -74
- data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +211 -111
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +224 -78
- data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
- data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
- data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
- data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
- data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
- data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
- data/config/routes.rb +3 -0
- data/lib/llm_cost_tracker/assets.rb +19 -0
- data/lib/llm_cost_tracker/configuration.rb +78 -42
- data/lib/llm_cost_tracker/engine.rb +2 -0
- data/lib/llm_cost_tracker/event.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
- data/lib/llm_cost_tracker/llm_api_call.rb +9 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +57 -9
- data/lib/llm_cost_tracker/parsed_usage.rb +7 -3
- data/lib/llm_cost_tracker/parsers/anthropic.rb +79 -1
- data/lib/llm_cost_tracker/parsers/base.rb +17 -5
- data/lib/llm_cost_tracker/parsers/gemini.rb +59 -6
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +8 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +55 -1
- data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
- data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
- data/lib/llm_cost_tracker/price_registry.rb +18 -7
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
- data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
- data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
- data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
- data/lib/llm_cost_tracker/price_sync.rb +310 -0
- data/lib/llm_cost_tracker/pricing.rb +19 -6
- data/lib/llm_cost_tracker/retention.rb +34 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +3 -1
- data/lib/llm_cost_tracker/stream_collector.rb +158 -0
- data/lib/llm_cost_tracker/tag_query.rb +7 -2
- data/lib/llm_cost_tracker/tags_column.rb +21 -1
- data/lib/llm_cost_tracker/tracker.rb +15 -12
- data/lib/llm_cost_tracker/value_helpers.rb +40 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +51 -29
- data/lib/tasks/llm_cost_tracker.rake +124 -0
- data/llm_cost_tracker.gemspec +9 -8
- metadata +40 -12
- data/PLAN_0.2.md +0 -488
|
@@ -1,110 +1,256 @@
|
|
|
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
|
+
<% streaming_count = @stats.streaming_count %>
|
|
6
|
+
<% streaming_missing_usage = @stats.streaming_missing_usage_count %>
|
|
7
|
+
<% streams_with_usage = streaming_count && streaming_missing_usage ? streaming_count - streaming_missing_usage : nil %>
|
|
8
|
+
|
|
9
|
+
<section class="lct-panel lct-toolbar">
|
|
10
|
+
<div class="lct-toolbar-head">
|
|
11
|
+
<h2 class="lct-section-title">Data Quality</h2>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<form class="lct-filters" action="<%= data_quality_path %>" method="get">
|
|
15
|
+
<div class="lct-filter-row lct-filter-row-basic">
|
|
16
|
+
<div class="lct-field">
|
|
17
|
+
<label for="lct-quality-from">From</label>
|
|
18
|
+
<input id="lct-quality-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] %>">
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div class="lct-field">
|
|
22
|
+
<label for="lct-quality-to">To</label>
|
|
23
|
+
<input id="lct-quality-to" type="date" name="to" value="<%= params[:to] %>">
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div class="lct-field">
|
|
27
|
+
<label for="lct-quality-provider">Provider</label>
|
|
28
|
+
<%= select_tag :provider,
|
|
29
|
+
options_for_select(provider_filter_options, params[:provider]),
|
|
30
|
+
include_blank: "All providers",
|
|
31
|
+
id: "lct-quality-provider" %>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="lct-field">
|
|
35
|
+
<label for="lct-quality-model">Model</label>
|
|
36
|
+
<%= select_tag :model,
|
|
37
|
+
options_for_select(model_filter_options, params[:model]),
|
|
38
|
+
include_blank: "All models",
|
|
39
|
+
id: "lct-quality-model" %>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<% if LlmCostTracker::LlmApiCall.stream_column? %>
|
|
43
|
+
<div class="lct-field">
|
|
44
|
+
<label for="lct-quality-stream">Stream</label>
|
|
45
|
+
<%= select_tag :stream,
|
|
46
|
+
options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, params[:stream]),
|
|
47
|
+
include_blank: "All calls",
|
|
48
|
+
id: "lct-quality-stream" %>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
51
|
+
|
|
52
|
+
<div class="lct-filter-actions">
|
|
53
|
+
<button class="lct-button" type="submit">Apply</button>
|
|
54
|
+
<%= link_to("Reset", data_quality_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</form>
|
|
58
|
+
|
|
59
|
+
<%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: data_quality_path %>
|
|
60
|
+
</section>
|
|
2
61
|
|
|
3
62
|
<% if total.zero? %>
|
|
4
63
|
<section class="lct-panel lct-empty">
|
|
5
|
-
<h2 class="lct-
|
|
6
|
-
<p class="lct-
|
|
64
|
+
<h2 class="lct-state-title">No data yet</h2>
|
|
65
|
+
<p class="lct-state-copy">Quality metrics will appear here once calls are recorded in the current slice.</p>
|
|
66
|
+
<div class="lct-state-actions">
|
|
67
|
+
<%= link_to "Clear filters", data_quality_path, class: "lct-button lct-button-secondary" %>
|
|
68
|
+
</div>
|
|
7
69
|
</section>
|
|
8
70
|
<% else %>
|
|
9
|
-
<section class="lct-
|
|
10
|
-
<article class="lct-
|
|
11
|
-
<
|
|
12
|
-
|
|
71
|
+
<section class="lct-hero">
|
|
72
|
+
<article class="lct-panel lct-hero-primary">
|
|
73
|
+
<div>
|
|
74
|
+
<p class="lct-stat-label">Calls inspected</p>
|
|
75
|
+
<p class="lct-hero-value"><%= number(total) %></p>
|
|
76
|
+
</div>
|
|
13
77
|
</article>
|
|
14
78
|
|
|
15
|
-
<
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
79
|
+
<div class="lct-hero-side">
|
|
80
|
+
<div class="lct-stat-grid">
|
|
81
|
+
<article class="lct-stat">
|
|
82
|
+
<p class="lct-stat-label">Unknown pricing</p>
|
|
83
|
+
<p class="lct-stat-value"><%= number(@stats.unknown_pricing_count) %></p>
|
|
84
|
+
<p class="lct-stat-sub"><%= percent(coverage_percent(@stats.unknown_pricing_count, total)) %> of calls</p>
|
|
85
|
+
</article>
|
|
20
86
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
87
|
+
<article class="lct-stat">
|
|
88
|
+
<p class="lct-stat-label">Calls without tags</p>
|
|
89
|
+
<p class="lct-stat-value"><%= number(@stats.untagged_calls_count) %></p>
|
|
90
|
+
<p class="lct-stat-sub"><%= percent(coverage_percent(@stats.untagged_calls_count, total)) %> of calls</p>
|
|
91
|
+
</article>
|
|
92
|
+
|
|
93
|
+
<% if @stats.latency_column_present %>
|
|
94
|
+
<article class="lct-stat">
|
|
95
|
+
<p class="lct-stat-label">Missing latency</p>
|
|
96
|
+
<p class="lct-stat-value"><%= number(@stats.missing_latency_count) %></p>
|
|
97
|
+
<p class="lct-stat-sub"><%= percent(coverage_percent(@stats.missing_latency_count, total)) %> of calls</p>
|
|
98
|
+
</article>
|
|
99
|
+
<% end %>
|
|
100
|
+
|
|
101
|
+
<% if @stats.stream_column_present %>
|
|
102
|
+
<article class="lct-stat">
|
|
103
|
+
<p class="lct-stat-label">Streaming calls</p>
|
|
104
|
+
<p class="lct-stat-value"><%= number(streaming_count) %></p>
|
|
105
|
+
<p class="lct-stat-sub"><%= percent(coverage_percent(streaming_count, total)) %> of calls</p>
|
|
106
|
+
</article>
|
|
107
|
+
|
|
108
|
+
<% if streaming_missing_usage && streaming_count.positive? %>
|
|
109
|
+
<article class="lct-stat">
|
|
110
|
+
<p class="lct-stat-label">Streams without usage</p>
|
|
111
|
+
<p class="lct-stat-value"><%= number(streaming_missing_usage) %></p>
|
|
112
|
+
<p class="lct-stat-sub"><%= percent(coverage_percent(streaming_missing_usage, streaming_count)) %> of streams</p>
|
|
113
|
+
</article>
|
|
114
|
+
<% end %>
|
|
115
|
+
<% end %>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</section>
|
|
119
|
+
|
|
120
|
+
<section class="lct-grid lct-two-col">
|
|
121
|
+
<section class="lct-panel">
|
|
122
|
+
<div class="lct-section-head">
|
|
123
|
+
<div>
|
|
124
|
+
<h2 class="lct-section-title">Coverage summary</h2>
|
|
125
|
+
<p class="lct-section-copy">Good dashboards start with clean pricing, tags, and latency coverage.</p>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<table class="lct-table lct-table-compact">
|
|
130
|
+
<thead>
|
|
131
|
+
<tr>
|
|
132
|
+
<th>Dimension</th>
|
|
133
|
+
<th class="lct-num">Coverage</th>
|
|
134
|
+
<th class="lct-num">Calls with data</th>
|
|
135
|
+
<th>Visual</th>
|
|
136
|
+
</tr>
|
|
137
|
+
</thead>
|
|
138
|
+
<tbody>
|
|
139
|
+
<% cost_coverage = coverage_percent(known_pricing_calls, total) %>
|
|
140
|
+
<tr>
|
|
141
|
+
<td>Cost (pricing known)</td>
|
|
142
|
+
<td class="lct-num"><%= percent(cost_coverage) %></td>
|
|
143
|
+
<td class="lct-num"><%= number(known_pricing_calls) %></td>
|
|
144
|
+
<td><%= render "llm_cost_tracker/shared/bar", value: cost_coverage, max: 100.0 %></td>
|
|
145
|
+
</tr>
|
|
146
|
+
|
|
147
|
+
<% tag_coverage = coverage_percent(tagged_calls, total) %>
|
|
148
|
+
<tr>
|
|
149
|
+
<td>Tags (at least one tag)</td>
|
|
150
|
+
<td class="lct-num"><%= percent(tag_coverage) %></td>
|
|
151
|
+
<td class="lct-num"><%= number(tagged_calls) %></td>
|
|
152
|
+
<td><%= render "llm_cost_tracker/shared/bar", value: tag_coverage, max: 100.0 %></td>
|
|
153
|
+
</tr>
|
|
154
|
+
|
|
155
|
+
<% if @stats.latency_column_present %>
|
|
156
|
+
<% latency_coverage = coverage_percent(latency_calls, total) %>
|
|
157
|
+
<tr>
|
|
158
|
+
<td>Latency</td>
|
|
159
|
+
<td class="lct-num"><%= percent(latency_coverage) %></td>
|
|
160
|
+
<td class="lct-num"><%= number(latency_calls) %></td>
|
|
161
|
+
<td><%= render "llm_cost_tracker/shared/bar", value: latency_coverage, max: 100.0 %></td>
|
|
162
|
+
</tr>
|
|
163
|
+
<% end %>
|
|
164
|
+
|
|
165
|
+
<% if @stats.stream_column_present && streams_with_usage && streaming_count.to_i.positive? %>
|
|
166
|
+
<% stream_coverage = coverage_percent(streams_with_usage, streaming_count) %>
|
|
167
|
+
<tr>
|
|
168
|
+
<td>Streaming usage captured</td>
|
|
169
|
+
<td class="lct-num"><%= percent(stream_coverage) %></td>
|
|
170
|
+
<td class="lct-num"><%= number(streams_with_usage) %> / <%= number(streaming_count) %></td>
|
|
171
|
+
<td><%= render "llm_cost_tracker/shared/bar", value: stream_coverage, max: 100.0 %></td>
|
|
172
|
+
</tr>
|
|
173
|
+
<% end %>
|
|
174
|
+
</tbody>
|
|
175
|
+
</table>
|
|
176
|
+
</section>
|
|
26
177
|
|
|
27
|
-
|
|
28
|
-
<
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
178
|
+
<section class="lct-panel">
|
|
179
|
+
<div class="lct-section-head">
|
|
180
|
+
<div>
|
|
181
|
+
<h2 class="lct-section-title">Next actions</h2>
|
|
182
|
+
<p class="lct-section-copy">Use these fixes to improve the trustworthiness of downstream cost reports.</p>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<table class="lct-table lct-table-compact">
|
|
187
|
+
<thead>
|
|
188
|
+
<tr>
|
|
189
|
+
<th>Issue</th>
|
|
190
|
+
<th>Why it matters</th>
|
|
191
|
+
<th>Suggested action</th>
|
|
192
|
+
</tr>
|
|
193
|
+
</thead>
|
|
194
|
+
<tbody>
|
|
195
|
+
<tr>
|
|
196
|
+
<td>Unknown pricing</td>
|
|
197
|
+
<td>Cost stays <code class="lct-code">nil</code>, so totals undercount.</td>
|
|
198
|
+
<td>Update <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code>.</td>
|
|
199
|
+
</tr>
|
|
200
|
+
<tr>
|
|
201
|
+
<td>Missing tags</td>
|
|
202
|
+
<td>Attribution by tenant, user, or feature becomes less useful.</td>
|
|
203
|
+
<td>Pass <code class="lct-code">tags:</code> from middleware using request context.</td>
|
|
204
|
+
</tr>
|
|
205
|
+
<% if @stats.latency_column_present %>
|
|
206
|
+
<tr>
|
|
207
|
+
<td>Missing latency</td>
|
|
208
|
+
<td>Slow requests become harder to isolate on the calls page.</td>
|
|
209
|
+
<td>Make sure latency capture is enabled on every tracked request.</td>
|
|
210
|
+
</tr>
|
|
211
|
+
<% end %>
|
|
212
|
+
<% if @stats.stream_column_present && streaming_missing_usage.to_i.positive? %>
|
|
213
|
+
<tr>
|
|
214
|
+
<td>Streams without usage</td>
|
|
215
|
+
<td>Token totals undercount when streaming responses drop the final usage event.</td>
|
|
216
|
+
<td>Send OpenAI requests with <code class="lct-code">stream_options: { include_usage: true }</code>, or wrap custom clients with <code class="lct-code">LlmCostTracker.track_stream</code>.</td>
|
|
217
|
+
</tr>
|
|
218
|
+
<% end %>
|
|
219
|
+
</tbody>
|
|
220
|
+
</table>
|
|
221
|
+
</section>
|
|
34
222
|
</section>
|
|
35
223
|
|
|
36
224
|
<% unless @stats.unknown_pricing_by_model.empty? %>
|
|
37
225
|
<section class="lct-panel">
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
226
|
+
<div class="lct-section-head">
|
|
227
|
+
<div>
|
|
228
|
+
<h2 class="lct-section-title">Unknown pricing by model</h2>
|
|
229
|
+
<p class="lct-section-copy">These models have token counts but no configured rates, so cost stays unknown.</p>
|
|
230
|
+
</div>
|
|
231
|
+
<%= link_to "Review calls", calls_path(current_query(sort: "unknown_pricing")), class: "lct-button lct-button-secondary" %>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
43
234
|
<div class="lct-table-wrap">
|
|
44
|
-
<table class="lct-table">
|
|
235
|
+
<table class="lct-table lct-table-compact">
|
|
45
236
|
<thead>
|
|
46
237
|
<tr>
|
|
47
238
|
<th>Model</th>
|
|
48
|
-
<th>Calls without cost</th>
|
|
49
|
-
<th>Share of total</th>
|
|
239
|
+
<th class="lct-num">Calls without cost</th>
|
|
240
|
+
<th class="lct-num">Share of total</th>
|
|
50
241
|
</tr>
|
|
51
242
|
</thead>
|
|
52
243
|
<tbody>
|
|
53
244
|
<% @stats.unknown_pricing_by_model.each do |model, count| %>
|
|
54
245
|
<tr>
|
|
55
246
|
<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>
|
|
247
|
+
<td class="lct-num"><%= number(count) %></td>
|
|
248
|
+
<td class="lct-num"><%= percent(total.positive? ? (count.to_f / total) * 100.0 : 0.0) %></td>
|
|
58
249
|
</tr>
|
|
59
250
|
<% end %>
|
|
60
251
|
</tbody>
|
|
61
252
|
</table>
|
|
62
253
|
</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
254
|
</section>
|
|
67
255
|
<% 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
256
|
<% end %>
|
|
110
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
<section class="lct-panel">
|
|
2
|
-
<h2>Database unavailable</h2>
|
|
3
|
-
<p class="lct-
|
|
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.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<section class="lct-panel">
|
|
2
|
-
<h2>Invalid filter</h2>
|
|
3
|
-
<p class="lct-
|
|
1
|
+
<section class="lct-panel lct-empty">
|
|
2
|
+
<h2 class="lct-state-title">Invalid filter</h2>
|
|
3
|
+
<p class="lct-state-copy"><%= @error_message %></p>
|
|
4
4
|
</section>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
<section class="lct-panel">
|
|
2
|
-
<h2>Call not found</h2>
|
|
3
|
-
<p class="lct-
|
|
1
|
+
<section class="lct-panel lct-empty">
|
|
2
|
+
<h2 class="lct-state-title">Call not found</h2>
|
|
3
|
+
<p class="lct-state-copy">The requested LLM API call could not be found.</p>
|
|
4
4
|
<p><%= link_to "Back to calls", calls_path, class: "lct-button lct-button-secondary" %></p>
|
|
5
5
|
</section>
|
|
@@ -1,91 +1,99 @@
|
|
|
1
|
-
<section class="lct-panel">
|
|
2
|
-
<
|
|
1
|
+
<section class="lct-panel lct-toolbar">
|
|
2
|
+
<div class="lct-toolbar-head">
|
|
3
|
+
<h2 class="lct-section-title">Models</h2>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
3
6
|
<form class="lct-filters" action="<%= models_path %>" method="get">
|
|
4
|
-
<div class="lct-
|
|
5
|
-
<
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
<div class="lct-filter-row lct-filter-row-with-sort">
|
|
8
|
+
<div class="lct-field">
|
|
9
|
+
<label for="lct-models-from">From</label>
|
|
10
|
+
<input id="lct-models-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] %>">
|
|
11
|
+
</div>
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
<div class="lct-field">
|
|
14
|
+
<label for="lct-models-to">To</label>
|
|
15
|
+
<input id="lct-models-to" type="date" name="to" value="<%= params[:to] %>">
|
|
16
|
+
</div>
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
<div class="lct-field">
|
|
19
|
+
<label for="lct-models-provider">Provider</label>
|
|
20
|
+
<%= select_tag :provider,
|
|
21
|
+
options_for_select(provider_filter_options, params[:provider]),
|
|
22
|
+
include_blank: "All providers",
|
|
23
|
+
id: "lct-models-provider" %>
|
|
24
|
+
</div>
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<input id="lct-tag-key" type="text" name="tag_key" value="<%= params[:tag_key] %>">
|
|
27
|
-
</div>
|
|
26
|
+
<div class="lct-field">
|
|
27
|
+
<label for="lct-models-model">Model</label>
|
|
28
|
+
<%= select_tag :model,
|
|
29
|
+
options_for_select(model_filter_options, params[:model]),
|
|
30
|
+
include_blank: "All models",
|
|
31
|
+
id: "lct-models-model" %>
|
|
32
|
+
</div>
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
<% if @latency_available %>
|
|
41
|
-
<option value="latency" <%= "selected" if @sort == "latency" %>>Avg latency</option>
|
|
42
|
-
<% end %>
|
|
43
|
-
</select>
|
|
44
|
-
</div>
|
|
34
|
+
<div class="lct-field">
|
|
35
|
+
<label for="lct-models-sort">Sort</label>
|
|
36
|
+
<select id="lct-models-sort" name="sort">
|
|
37
|
+
<option value="cost" <%= "selected" if @sort.blank? || @sort == "cost" %>>Total spend</option>
|
|
38
|
+
<option value="calls" <%= "selected" if @sort == "calls" %>>Call volume</option>
|
|
39
|
+
<option value="avg_cost" <%= "selected" if @sort == "avg_cost" %>>Avg cost / call</option>
|
|
40
|
+
<% if @latency_available %>
|
|
41
|
+
<option value="latency" <%= "selected" if @sort == "latency" %>>Avg latency</option>
|
|
42
|
+
<% end %>
|
|
43
|
+
</select>
|
|
44
|
+
</div>
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
<div class="lct-filter-actions">
|
|
47
|
+
<button class="lct-button" type="submit">Apply</button>
|
|
48
|
+
<%= link_to("Reset", models_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
|
|
49
|
+
</div>
|
|
49
50
|
</div>
|
|
50
51
|
</form>
|
|
52
|
+
|
|
53
|
+
<%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: models_path %>
|
|
51
54
|
</section>
|
|
52
55
|
|
|
53
56
|
<% if @rows.empty? %>
|
|
54
57
|
<section class="lct-panel lct-empty">
|
|
55
|
-
<h2 class="lct-
|
|
56
|
-
<p class="lct-
|
|
58
|
+
<h2 class="lct-state-title">No models in this slice</h2>
|
|
59
|
+
<p class="lct-state-copy">Tracked models will appear here once calls match the current provider, model, and date filters.</p>
|
|
60
|
+
<div class="lct-state-actions">
|
|
61
|
+
<%= link_to "Clear filters", models_path, class: "lct-button lct-button-secondary" %>
|
|
62
|
+
</div>
|
|
57
63
|
</section>
|
|
58
64
|
<% else %>
|
|
59
65
|
<section class="lct-panel">
|
|
60
66
|
<div class="lct-table-wrap">
|
|
61
|
-
<table class="lct-table">
|
|
67
|
+
<table class="lct-table lct-table-compact">
|
|
62
68
|
<thead>
|
|
63
69
|
<tr>
|
|
64
70
|
<th>Provider</th>
|
|
65
71
|
<th>Model</th>
|
|
66
|
-
<th>Calls</th>
|
|
67
|
-
<th>Input</th>
|
|
68
|
-
<th>Output</th>
|
|
69
|
-
<th>Total cost</th>
|
|
70
|
-
<th>Avg cost / call</th>
|
|
72
|
+
<th class="lct-num">Calls</th>
|
|
73
|
+
<th class="lct-num">Input</th>
|
|
74
|
+
<th class="lct-num">Output</th>
|
|
75
|
+
<th class="lct-num">Total cost</th>
|
|
76
|
+
<th class="lct-num">Avg cost / call</th>
|
|
71
77
|
<% if @latency_available %>
|
|
72
|
-
<th>Avg latency</th>
|
|
78
|
+
<th class="lct-num">Avg latency</th>
|
|
73
79
|
<% end %>
|
|
80
|
+
<th></th>
|
|
74
81
|
</tr>
|
|
75
82
|
</thead>
|
|
76
83
|
<tbody>
|
|
77
84
|
<% @rows.each do |row| %>
|
|
78
85
|
<tr>
|
|
79
86
|
<td><%= row.provider %></td>
|
|
80
|
-
<td><%= row.model %></td>
|
|
81
|
-
<td><%= number(row.calls) %></td>
|
|
82
|
-
<td><%= format_tokens(row.input_tokens) %></td>
|
|
83
|
-
<td><%= format_tokens(row.output_tokens) %></td>
|
|
84
|
-
<td><%= money(row.total_cost) %></td>
|
|
85
|
-
<td><%= money(row.average_cost_per_call) %></td>
|
|
87
|
+
<td><code class="lct-code"><%= row.model %></code></td>
|
|
88
|
+
<td class="lct-num"><%= number(row.calls) %></td>
|
|
89
|
+
<td class="lct-num"><%= format_tokens(row.input_tokens) %></td>
|
|
90
|
+
<td class="lct-num"><%= format_tokens(row.output_tokens) %></td>
|
|
91
|
+
<td class="lct-num"><%= money(row.total_cost) %></td>
|
|
92
|
+
<td class="lct-num"><%= money(row.average_cost_per_call) %></td>
|
|
86
93
|
<% if @latency_available %>
|
|
87
|
-
<td><%= row.average_latency_ms ? "#{number(row.average_latency_ms.round)}ms" : "n/a" %></td>
|
|
94
|
+
<td class="lct-num<%= ' lct-num-muted' if row.average_latency_ms.nil? %>"><%= row.average_latency_ms ? "#{number(row.average_latency_ms.round)}ms" : "n/a" %></td>
|
|
88
95
|
<% end %>
|
|
96
|
+
<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>
|
|
89
97
|
</tr>
|
|
90
98
|
<% end %>
|
|
91
99
|
</tbody>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<% if chips.any? %>
|
|
2
|
+
<div class="lct-chip-row" aria-label="Active filters">
|
|
3
|
+
<% chips.each do |chip| %>
|
|
4
|
+
<span class="lct-chip">
|
|
5
|
+
<span class="lct-chip-label"><%= chip[:label] %></span>
|
|
6
|
+
<span><%= chip[:value] %></span>
|
|
7
|
+
<% if chip[:path] %>
|
|
8
|
+
<%= link_to "×", chip[:path], class: "lct-chip-remove", aria: { label: "Remove #{chip[:label]} #{chip[:value]}" } %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</span>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% if local_assigns[:clear_path] %>
|
|
13
|
+
<%= link_to "Clear all", clear_path, class: "lct-clear-link" %>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<% visible_segments = stack_segments(segments) %>
|
|
2
|
+
|
|
3
|
+
<% if visible_segments.empty? %>
|
|
4
|
+
<p class="lct-call-breakdown-empty"><%= empty_message %></p>
|
|
5
|
+
<% else %>
|
|
6
|
+
<div class="lct-stack-track" aria-hidden="true">
|
|
7
|
+
<% visible_segments.each do |segment| %>
|
|
8
|
+
<span class="lct-stack-fill <%= segment[:css_class] %>" style="width: <%= segment[:percent].round(2) %>%"></span>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="lct-stack-legend">
|
|
13
|
+
<% visible_segments.each do |segment| %>
|
|
14
|
+
<div class="lct-stack-legend-item">
|
|
15
|
+
<span class="lct-stack-key">
|
|
16
|
+
<span class="lct-stack-swatch <%= segment[:css_class] %>"></span>
|
|
17
|
+
<span><%= segment[:label] %></span>
|
|
18
|
+
</span>
|
|
19
|
+
<span class="lct-stack-meta"><%= segment[:formatted_value] %> · <%= percent(segment[:percent]) %></span>
|
|
20
|
+
</div>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%# locals: series: Array[{ label:, cost: }], comparison_series: nil %>
|
|
2
|
+
<% if series.blank? %>
|
|
3
|
+
<div class="lct-chart-empty">No spend in this range.</div>
|
|
4
|
+
<% else %>
|
|
5
|
+
<%= spend_chart_svg(series, comparison_points: local_assigns[:comparison_series]) %>
|
|
6
|
+
<div class="lct-chart-legend">
|
|
7
|
+
<span><%= series.first[:label] %></span>
|
|
8
|
+
<% if local_assigns[:comparison_series].present? %>
|
|
9
|
+
<span class="lct-chart-legend-compare">
|
|
10
|
+
<span class="lct-chart-key"><span class="lct-chart-key-line"></span> Current</span>
|
|
11
|
+
<span class="lct-chart-key"><span class="lct-chart-key-line lct-chart-key-line-secondary"></span> Previous</span>
|
|
12
|
+
</span>
|
|
13
|
+
<% else %>
|
|
14
|
+
<span>Peak <%= money(series.map { |p| p[:cost] }.max) %></span>
|
|
15
|
+
<% end %>
|
|
16
|
+
<span><%= series.last[:label] %></span>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# locals: tags: Hash, limit: Integer (optional) %>
|
|
2
|
+
<% entries = tag_chip_entries(tags, limit: local_assigns.fetch(:limit, 3)) %>
|
|
3
|
+
<% if entries.empty? %>
|
|
4
|
+
<span class="lct-tag-empty">(untagged)</span>
|
|
5
|
+
<% else %>
|
|
6
|
+
<span class="lct-tag-chips" title="<%= safe_json(tags) %>">
|
|
7
|
+
<% entries.each do |entry| %>
|
|
8
|
+
<% if entry[:more] %>
|
|
9
|
+
<span class="lct-tag-chip lct-tag-chip-more">+<%= entry[:more] %></span>
|
|
10
|
+
<% else %>
|
|
11
|
+
<span class="lct-tag-chip"><%= entry[:key] %>=<%= entry[:value] %></span>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% end %>
|
|
14
|
+
</span>
|
|
15
|
+
<% end %>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
<section class="lct-panel">
|
|
2
|
-
<
|
|
1
|
+
<section class="lct-panel lct-empty">
|
|
2
|
+
<h2 class="lct-state-title">Setup required</h2>
|
|
3
|
+
<p class="lct-state-copy">
|
|
3
4
|
The <span class="lct-code">llm_api_calls</span> table is not available yet.
|
|
4
5
|
Run <span class="lct-code">rails generate llm_cost_tracker:install</span> and migrate your database.
|
|
5
6
|
</p>
|