llm_cost_tracker 0.8.0 → 0.10.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 +136 -0
- data/README.md +14 -6
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
- data/lib/llm_cost_tracker/budget.rb +31 -7
- data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +72 -17
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -14
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +48 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
- data/lib/llm_cost_tracker/ingestion.rb +48 -11
- data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
- data/lib/llm_cost_tracker/integrations/base.rb +35 -15
- data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
- data/lib/llm_cost_tracker/integrations.rb +33 -14
- data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
- data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
- data/lib/llm_cost_tracker/ledger/store.rb +34 -31
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
- data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -43
- data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +572 -493
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
- data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +117 -44
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/railtie.rb +8 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +4 -1
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +31 -6
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
- data/lib/llm_cost_tracker/token_usage.rb +14 -2
- data/lib/llm_cost_tracker/tracker.rb +41 -55
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +19 -14
- data/lib/tasks/llm_cost_tracker.rake +41 -4
- metadata +49 -3
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -72,6 +72,56 @@
|
|
|
72
72
|
</section>
|
|
73
73
|
|
|
74
74
|
<section class="lct-grid lct-two-col">
|
|
75
|
+
<section class="lct-panel">
|
|
76
|
+
<div class="lct-section-head">
|
|
77
|
+
<div>
|
|
78
|
+
<h2 class="lct-section-title">Next actions</h2>
|
|
79
|
+
<p class="lct-section-copy">Use these fixes to improve the trustworthiness of downstream cost reports.</p>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<table class="lct-table lct-table-compact">
|
|
84
|
+
<thead>
|
|
85
|
+
<tr>
|
|
86
|
+
<th>Issue</th>
|
|
87
|
+
<th>Why it matters</th>
|
|
88
|
+
<th>Suggested action</th>
|
|
89
|
+
</tr>
|
|
90
|
+
</thead>
|
|
91
|
+
<tbody>
|
|
92
|
+
<tr>
|
|
93
|
+
<td>Unknown pricing</td>
|
|
94
|
+
<td>Cost stays <code class="lct-code">nil</code>, so totals undercount.</td>
|
|
95
|
+
<td>Update <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code>.</td>
|
|
96
|
+
</tr>
|
|
97
|
+
<tr>
|
|
98
|
+
<td>Missing tags</td>
|
|
99
|
+
<td>Attribution by tenant, user, or feature becomes less useful.</td>
|
|
100
|
+
<td>Pass <code class="lct-code">tags:</code> from middleware using request context.</td>
|
|
101
|
+
</tr>
|
|
102
|
+
<tr>
|
|
103
|
+
<td>Missing latency</td>
|
|
104
|
+
<td>Slow requests become harder to isolate on the calls page.</td>
|
|
105
|
+
<td>Make sure latency capture is enabled on every tracked request.</td>
|
|
106
|
+
</tr>
|
|
107
|
+
<% if @summary.streaming_missing_usage.positive? %>
|
|
108
|
+
<tr>
|
|
109
|
+
<td>Streams without usage</td>
|
|
110
|
+
<td>Token totals undercount when streaming responses drop the final usage event.</td>
|
|
111
|
+
<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>
|
|
112
|
+
</tr>
|
|
113
|
+
<% end %>
|
|
114
|
+
<% if @summary.missing_provider_response_id_count.positive? %>
|
|
115
|
+
<tr>
|
|
116
|
+
<td>Missing provider response IDs</td>
|
|
117
|
+
<td>Proof of provider-issued responses is weaker when calls cannot be tied back to provider objects.</td>
|
|
118
|
+
<td>Upgrade to the latest parser coverage and pass <code class="lct-code">provider_response_id:</code> for custom clients when the provider exposes one.</td>
|
|
119
|
+
</tr>
|
|
120
|
+
<% end %>
|
|
121
|
+
</tbody>
|
|
122
|
+
</table>
|
|
123
|
+
</section>
|
|
124
|
+
|
|
75
125
|
<section class="lct-panel">
|
|
76
126
|
<div class="lct-section-head">
|
|
77
127
|
<div>
|
|
@@ -129,56 +179,6 @@
|
|
|
129
179
|
</tbody>
|
|
130
180
|
</table>
|
|
131
181
|
</section>
|
|
132
|
-
|
|
133
|
-
<section class="lct-panel">
|
|
134
|
-
<div class="lct-section-head">
|
|
135
|
-
<div>
|
|
136
|
-
<h2 class="lct-section-title">Next actions</h2>
|
|
137
|
-
<p class="lct-section-copy">Use these fixes to improve the trustworthiness of downstream cost reports.</p>
|
|
138
|
-
</div>
|
|
139
|
-
</div>
|
|
140
|
-
|
|
141
|
-
<table class="lct-table lct-table-compact">
|
|
142
|
-
<thead>
|
|
143
|
-
<tr>
|
|
144
|
-
<th>Issue</th>
|
|
145
|
-
<th>Why it matters</th>
|
|
146
|
-
<th>Suggested action</th>
|
|
147
|
-
</tr>
|
|
148
|
-
</thead>
|
|
149
|
-
<tbody>
|
|
150
|
-
<tr>
|
|
151
|
-
<td>Unknown pricing</td>
|
|
152
|
-
<td>Cost stays <code class="lct-code">nil</code>, so totals undercount.</td>
|
|
153
|
-
<td>Update <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code>.</td>
|
|
154
|
-
</tr>
|
|
155
|
-
<tr>
|
|
156
|
-
<td>Missing tags</td>
|
|
157
|
-
<td>Attribution by tenant, user, or feature becomes less useful.</td>
|
|
158
|
-
<td>Pass <code class="lct-code">tags:</code> from middleware using request context.</td>
|
|
159
|
-
</tr>
|
|
160
|
-
<tr>
|
|
161
|
-
<td>Missing latency</td>
|
|
162
|
-
<td>Slow requests become harder to isolate on the calls page.</td>
|
|
163
|
-
<td>Make sure latency capture is enabled on every tracked request.</td>
|
|
164
|
-
</tr>
|
|
165
|
-
<% if @summary.streaming_missing_usage.positive? %>
|
|
166
|
-
<tr>
|
|
167
|
-
<td>Streams without usage</td>
|
|
168
|
-
<td>Token totals undercount when streaming responses drop the final usage event.</td>
|
|
169
|
-
<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>
|
|
170
|
-
</tr>
|
|
171
|
-
<% end %>
|
|
172
|
-
<% if @summary.missing_provider_response_id_count.positive? %>
|
|
173
|
-
<tr>
|
|
174
|
-
<td>Missing provider response IDs</td>
|
|
175
|
-
<td>Proof of provider-issued responses is weaker when calls cannot be tied back to provider objects.</td>
|
|
176
|
-
<td>Upgrade to the latest parser coverage and pass <code class="lct-code">provider_response_id:</code> for custom clients when the provider exposes one.</td>
|
|
177
|
-
</tr>
|
|
178
|
-
<% end %>
|
|
179
|
-
</tbody>
|
|
180
|
-
</table>
|
|
181
|
-
</section>
|
|
182
182
|
</section>
|
|
183
183
|
|
|
184
184
|
<section class="lct-panel">
|
|
@@ -243,13 +243,50 @@
|
|
|
243
243
|
</thead>
|
|
244
244
|
<tbody>
|
|
245
245
|
<% @service_charge_rows.each do |row| %>
|
|
246
|
+
<% unknown_cost = row.cost_status.to_s == LlmCostTracker::Billing::CostStatus::UNKNOWN %>
|
|
246
247
|
<tr>
|
|
247
248
|
<td><code class="lct-code"><%= row.provider %></code></td>
|
|
248
249
|
<td><code class="lct-code"><%= row.component %></code></td>
|
|
249
250
|
<td><%= row.cost_status %></td>
|
|
250
251
|
<td class="lct-num"><%= number(row.charges_count) %></td>
|
|
251
252
|
<td class="lct-num"><%= number(row.quantity) %></td>
|
|
252
|
-
<td class="lct-num"><%=
|
|
253
|
+
<td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(row.total_cost) %></td>
|
|
254
|
+
</tr>
|
|
255
|
+
<% end %>
|
|
256
|
+
</tbody>
|
|
257
|
+
</table>
|
|
258
|
+
</div>
|
|
259
|
+
</section>
|
|
260
|
+
<% end %>
|
|
261
|
+
|
|
262
|
+
<% if @streaming_health_rows.any? %>
|
|
263
|
+
<section class="lct-panel">
|
|
264
|
+
<div class="lct-section-head">
|
|
265
|
+
<div>
|
|
266
|
+
<h2 class="lct-section-title">Streaming health by provider</h2>
|
|
267
|
+
<p class="lct-section-copy">Streams without a final usage chunk land as <code class="lct-code">usage_source: unknown</code> and undercount tokens. A high unknown share for an OpenAI-compatible provider usually means <code class="lct-code">stream_options: { include_usage: true }</code> is not being injected for that host.</p>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div class="lct-table-wrap">
|
|
272
|
+
<table class="lct-table lct-table-compact">
|
|
273
|
+
<thead>
|
|
274
|
+
<tr>
|
|
275
|
+
<th>Provider</th>
|
|
276
|
+
<th class="lct-num">Streams</th>
|
|
277
|
+
<th class="lct-num">With usage</th>
|
|
278
|
+
<th class="lct-num">Unknown</th>
|
|
279
|
+
<th class="lct-num">Unknown share</th>
|
|
280
|
+
</tr>
|
|
281
|
+
</thead>
|
|
282
|
+
<tbody>
|
|
283
|
+
<% @streaming_health_rows.each do |row| %>
|
|
284
|
+
<tr>
|
|
285
|
+
<td><code class="lct-code"><%= row.provider %></code></td>
|
|
286
|
+
<td class="lct-num"><%= number(row.streams) %></td>
|
|
287
|
+
<td class="lct-num"><%= number(row.with_usage) %></td>
|
|
288
|
+
<td class="lct-num"><%= number(row.unknown) %></td>
|
|
289
|
+
<td class="lct-num"><%= percent(row.unknown_share) %></td>
|
|
253
290
|
</tr>
|
|
254
291
|
<% end %>
|
|
255
292
|
</tbody>
|
|
@@ -263,15 +300,16 @@
|
|
|
263
300
|
<div class="lct-section-head">
|
|
264
301
|
<div>
|
|
265
302
|
<h2 class="lct-section-title">Unknown pricing by model</h2>
|
|
266
|
-
<p class="lct-section-copy">These models have token counts but no configured rates, so cost stays unknown.</p>
|
|
303
|
+
<p class="lct-section-copy">These models have token counts but no configured rates, so cost stays unknown. After the next price refresh or a <code class="lct-code">pricing_overrides</code> update, run <code class="lct-code">bin/rails llm_cost_tracker:backfill_unknown_pricing</code> to recompute these calls.</p>
|
|
267
304
|
</div>
|
|
268
|
-
<%= link_to "
|
|
305
|
+
<%= link_to "Calls", calls_path(current_query(sort: "unknown_pricing")), class: "lct-button lct-button-secondary" %>
|
|
269
306
|
</div>
|
|
270
307
|
|
|
271
308
|
<div class="lct-table-wrap">
|
|
272
309
|
<table class="lct-table lct-table-compact">
|
|
273
310
|
<thead>
|
|
274
311
|
<tr>
|
|
312
|
+
<th>Provider</th>
|
|
275
313
|
<th>Model</th>
|
|
276
314
|
<th class="lct-num">Calls without cost</th>
|
|
277
315
|
<th class="lct-num">Share of total</th>
|
|
@@ -280,6 +318,7 @@
|
|
|
280
318
|
<tbody>
|
|
281
319
|
<% @unknown_pricing_by_model.each do |row| %>
|
|
282
320
|
<tr>
|
|
321
|
+
<td><code class="lct-code"><%= row.provider %></code></td>
|
|
283
322
|
<td><code class="lct-code"><%= row.model %></code></td>
|
|
284
323
|
<td class="lct-num"><%= number(row.calls) %></td>
|
|
285
324
|
<td class="lct-num"><%= percent(row.share_percent) %></td>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<section class="lct-panel">
|
|
2
|
+
<div class="lct-toolbar-head">
|
|
3
|
+
<h2 class="lct-section-title">Provider Invoice Reconciliation <span class="lct-badge lct-badge-warn">Experimental</span></h2>
|
|
4
|
+
<% if @last_imported_at %>
|
|
5
|
+
<p class="lct-state-copy">Last import: <%= @last_imported_at.utc.iso8601 %></p>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<p class="lct-state-copy">
|
|
9
|
+
Experimental in v0.9.0 — public API may change in v0.9.x based on feedback.
|
|
10
|
+
<%= link_to "Open an issue", "https://github.com/sergey-homenko/llm_cost_tracker/issues", target: "_blank", rel: "noopener" %>
|
|
11
|
+
if you use it.
|
|
12
|
+
</p>
|
|
13
|
+
</section>
|
|
14
|
+
|
|
15
|
+
<% if flash[:notice] %>
|
|
16
|
+
<section class="lct-panel"><p class="lct-state-copy"><%= flash[:notice] %></p></section>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if flash[:alert] %>
|
|
19
|
+
<section class="lct-panel"><p class="lct-state-copy"><%= flash[:alert] %></p></section>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<% if @configured_importers.any? %>
|
|
23
|
+
<section class="lct-panel">
|
|
24
|
+
<h3 class="lct-section-title">Trigger import</h3>
|
|
25
|
+
<% @configured_importers.each_key do |source| %>
|
|
26
|
+
<%= button_to "Re-import #{source}",
|
|
27
|
+
reconciliation_import_path(source: source),
|
|
28
|
+
method: :post,
|
|
29
|
+
class: "lct-button lct-button-secondary" %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</section>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<% if !@reconciliation_enabled %>
|
|
35
|
+
<section class="lct-panel lct-empty">
|
|
36
|
+
<h2 class="lct-state-title">Reconciliation disabled</h2>
|
|
37
|
+
<p class="lct-state-copy">
|
|
38
|
+
Provider invoice reconciliation is opt-in because it requires admin/org-level
|
|
39
|
+
provider API keys (OpenAI <code>sk-admin-…</code>, Anthropic admin keys, GCP
|
|
40
|
+
<code>billing.viewer</code>) — separate from the runtime inference key the
|
|
41
|
+
tracker uses. Enable explicitly in the initializer:
|
|
42
|
+
</p>
|
|
43
|
+
<pre class="lct-state-pre"><code>LlmCostTracker.configure do |config|
|
|
44
|
+
config.reconciliation_enabled = true
|
|
45
|
+
end</code></pre>
|
|
46
|
+
</section>
|
|
47
|
+
<% elsif !@reconciliation_installed %>
|
|
48
|
+
<section class="lct-panel lct-empty">
|
|
49
|
+
<h2 class="lct-state-title">Reconciliation not installed</h2>
|
|
50
|
+
<p class="lct-state-copy">
|
|
51
|
+
Run the optional migration to create the reconciliation tables:
|
|
52
|
+
</p>
|
|
53
|
+
<pre class="lct-state-pre"><code>bin/rails generate llm_cost_tracker:reconciliation
|
|
54
|
+
bin/rails db:migrate</code></pre>
|
|
55
|
+
</section>
|
|
56
|
+
<% elsif @diffs.empty? %>
|
|
57
|
+
<section class="lct-panel lct-empty">
|
|
58
|
+
<h2 class="lct-state-title">No invoices imported yet</h2>
|
|
59
|
+
<p class="lct-state-copy">
|
|
60
|
+
Reconciliation compares provider-side invoices against local cost. Once you import
|
|
61
|
+
invoice rows via <code>LlmCostTracker::Reconciliation.import</code>, they appear here.
|
|
62
|
+
</p>
|
|
63
|
+
</section>
|
|
64
|
+
<% else %>
|
|
65
|
+
<section class="lct-panel">
|
|
66
|
+
<h3 class="lct-section-title">Latest period per source / provider / currency</h3>
|
|
67
|
+
<table class="lct-table">
|
|
68
|
+
<thead>
|
|
69
|
+
<tr>
|
|
70
|
+
<th>Source</th>
|
|
71
|
+
<th>Provider</th>
|
|
72
|
+
<th>Currency</th>
|
|
73
|
+
<th>Period</th>
|
|
74
|
+
<th class="lct-num">Provider total</th>
|
|
75
|
+
<th class="lct-num">Local total</th>
|
|
76
|
+
<th class="lct-num">Delta</th>
|
|
77
|
+
<th class="lct-num">%</th>
|
|
78
|
+
<th>Status</th>
|
|
79
|
+
</tr>
|
|
80
|
+
</thead>
|
|
81
|
+
<tbody>
|
|
82
|
+
<% @diffs.each do |diff| %>
|
|
83
|
+
<tr>
|
|
84
|
+
<td><%= diff.source %></td>
|
|
85
|
+
<td><%= diff.provider %></td>
|
|
86
|
+
<td><%= diff.currency %></td>
|
|
87
|
+
<td><%= diff.period_start %> → <%= diff.period_end %></td>
|
|
88
|
+
<td class="lct-num"><%= money(diff.provider_total) %></td>
|
|
89
|
+
<td class="lct-num"><%= money(diff.local_total) %></td>
|
|
90
|
+
<td class="lct-num"><%= money(diff.delta_amount) %></td>
|
|
91
|
+
<td class="lct-num"><%= diff.delta_percent.nil? ? "—" : "#{diff.delta_percent}%" %></td>
|
|
92
|
+
<td>
|
|
93
|
+
<% if diff.aligned?(threshold_percent: @threshold) %>
|
|
94
|
+
<span class="lct-badge lct-badge-ok">Aligned</span>
|
|
95
|
+
<% else %>
|
|
96
|
+
<span class="lct-badge lct-badge-warn">Drift</span>
|
|
97
|
+
<% end %>
|
|
98
|
+
</td>
|
|
99
|
+
</tr>
|
|
100
|
+
<% end %>
|
|
101
|
+
</tbody>
|
|
102
|
+
</table>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<% @diffs.each do |diff| %>
|
|
106
|
+
<% next if diff.unmatched_provider_rows.empty? && diff.unmatched_local_calls.empty? && diff.non_cost_rows.empty? %>
|
|
107
|
+
|
|
108
|
+
<section class="lct-panel">
|
|
109
|
+
<h3 class="lct-section-title"><%= diff.source %> / <%= diff.provider %> / <%= diff.currency %> — drill down</h3>
|
|
110
|
+
|
|
111
|
+
<% if diff.unmatched_provider_rows.any? %>
|
|
112
|
+
<h4 class="lct-state-title">
|
|
113
|
+
Provider rows without a matching local call
|
|
114
|
+
<% if diff.unmatched_provider_rows_truncated? %>
|
|
115
|
+
<small>(showing <%= diff.unmatched_provider_rows.size %> of <%= diff.unmatched_provider_rows_total %>, ranked by billed amount)</small>
|
|
116
|
+
<% end %>
|
|
117
|
+
</h4>
|
|
118
|
+
<table class="lct-table">
|
|
119
|
+
<thead>
|
|
120
|
+
<tr><th>External ID</th><th>Match basis</th><th>Attribution</th><th class="lct-num">Billed</th></tr>
|
|
121
|
+
</thead>
|
|
122
|
+
<tbody>
|
|
123
|
+
<% diff.unmatched_provider_rows.each do |row| %>
|
|
124
|
+
<tr>
|
|
125
|
+
<td><%= row[:external_id] %></td>
|
|
126
|
+
<td><%= row[:match_basis] %></td>
|
|
127
|
+
<td><%= attribution_summary(row[:attribution]) %></td>
|
|
128
|
+
<td class="lct-num"><%= optional_money(row[:billed_amount]) %></td>
|
|
129
|
+
</tr>
|
|
130
|
+
<% end %>
|
|
131
|
+
</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
<% end %>
|
|
134
|
+
|
|
135
|
+
<% if diff.unmatched_local_calls.any? %>
|
|
136
|
+
<h4 class="lct-state-title">
|
|
137
|
+
Local calls no provider invoice can explain
|
|
138
|
+
<% if diff.unmatched_local_calls_truncated? %>
|
|
139
|
+
<small>(showing <%= diff.unmatched_local_calls.size %> of <%= diff.unmatched_local_calls_total %>, ranked by total cost)</small>
|
|
140
|
+
<% end %>
|
|
141
|
+
</h4>
|
|
142
|
+
<table class="lct-table">
|
|
143
|
+
<thead>
|
|
144
|
+
<tr><th>Attribution</th><th class="lct-num">Calls</th><th class="lct-num">Total cost</th></tr>
|
|
145
|
+
</thead>
|
|
146
|
+
<tbody>
|
|
147
|
+
<% diff.unmatched_local_calls.each do |row| %>
|
|
148
|
+
<tr>
|
|
149
|
+
<td><%= attribution_summary(row[:attribution]) %></td>
|
|
150
|
+
<td class="lct-num"><%= number(row[:count]) %></td>
|
|
151
|
+
<td class="lct-num"><%= money(row[:total_cost]) %></td>
|
|
152
|
+
</tr>
|
|
153
|
+
<% end %>
|
|
154
|
+
</tbody>
|
|
155
|
+
</table>
|
|
156
|
+
<% end %>
|
|
157
|
+
|
|
158
|
+
<% if diff.non_cost_rows.any? %>
|
|
159
|
+
<h4 class="lct-state-title">
|
|
160
|
+
Non-cost evidence (free quota, credits, adjustments)
|
|
161
|
+
<% if diff.non_cost_rows_truncated? %>
|
|
162
|
+
<small>(showing <%= diff.non_cost_rows.size %> of <%= diff.non_cost_rows_total %>, ranked by amount)</small>
|
|
163
|
+
<% end %>
|
|
164
|
+
</h4>
|
|
165
|
+
<table class="lct-table">
|
|
166
|
+
<thead>
|
|
167
|
+
<tr><th>Row type</th><th>Meter</th><th>Attribution</th><th class="lct-num">Amount</th></tr>
|
|
168
|
+
</thead>
|
|
169
|
+
<tbody>
|
|
170
|
+
<% diff.non_cost_rows.each do |row| %>
|
|
171
|
+
<tr>
|
|
172
|
+
<td><%= row[:row_type] %></td>
|
|
173
|
+
<td><%= row[:meter] %></td>
|
|
174
|
+
<td><%= attribution_summary(row[:attribution]) %></td>
|
|
175
|
+
<td class="lct-num"><%= optional_money(row[:billed_amount]) %></td>
|
|
176
|
+
</tr>
|
|
177
|
+
<% end %>
|
|
178
|
+
</tbody>
|
|
179
|
+
</table>
|
|
180
|
+
<% end %>
|
|
181
|
+
</section>
|
|
182
|
+
<% end %>
|
|
183
|
+
<% end %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<% variant_class = local_assigns[:variant] == "budget" ? " lct-budget-fill" : "" %>
|
|
2
2
|
|
|
3
3
|
<div class="lct-bar-track" aria-hidden="true">
|
|
4
|
-
<div
|
|
4
|
+
<div data-lct-style="<%= inline_style("width: #{bar_width(value, max)}") %>" class="lct-bar-fill<%= variant_class %>"></div>
|
|
5
5
|
</div>
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
%>
|
|
11
11
|
|
|
12
12
|
<form class="lct-filters" action="<%= path %>" method="get">
|
|
13
|
+
<% local_assigns.fetch(:hidden_fields, {}).each do |key, val| %>
|
|
14
|
+
<%= hidden_field_tag(key, val) %>
|
|
15
|
+
<% end %>
|
|
13
16
|
<div class="lct-filter-row">
|
|
14
17
|
<% if fields.include?(:from) %>
|
|
15
18
|
<div class="lct-field">
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<% else %>
|
|
6
6
|
<div class="lct-stack-track" aria-hidden="true">
|
|
7
7
|
<% visible_segments.each do |segment| %>
|
|
8
|
-
<span
|
|
8
|
+
<span data-lct-style="<%= inline_style("width: #{segment[:percent].round(2)}%") %>" class="lct-stack-fill <%= segment[:css_class] %>"></span>
|
|
9
9
|
<% end %>
|
|
10
10
|
</div>
|
|
11
11
|
|
|
@@ -1,3 +1,61 @@
|
|
|
1
|
+
<% if @value.present? %>
|
|
2
|
+
<section class="lct-panel lct-toolbar">
|
|
3
|
+
<div class="lct-toolbar-head">
|
|
4
|
+
<div>
|
|
5
|
+
<p class="lct-muted"><%= link_to "← All values for #{params[:key]}", tag_path(params[:key], current_query.except(:tag_value)) %></p>
|
|
6
|
+
<h2 class="lct-section-title">Tag: <code class="lct-code"><%= params[:key] %></code> = <code class="lct-code"><%= @value %></code></h2>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<%= render "llm_cost_tracker/shared/filters",
|
|
11
|
+
path: tag_path(params[:key]),
|
|
12
|
+
fields: %i[from to provider model],
|
|
13
|
+
hidden_fields: { tag_value: @value },
|
|
14
|
+
reset_path: tag_path(params[:key], tag_value: @value) %>
|
|
15
|
+
</section>
|
|
16
|
+
|
|
17
|
+
<% if @value_calls.zero? %>
|
|
18
|
+
<section class="lct-panel lct-empty">
|
|
19
|
+
<h2 class="lct-state-title">No calls tagged with <%= params[:key] %>=<%= @value %></h2>
|
|
20
|
+
<p class="lct-state-copy">No matching calls in the current slice.</p>
|
|
21
|
+
<div class="lct-state-actions">
|
|
22
|
+
<%= link_to "Back to values", tag_path(params[:key]), class: "lct-button lct-button-secondary" %>
|
|
23
|
+
</div>
|
|
24
|
+
</section>
|
|
25
|
+
<% else %>
|
|
26
|
+
<section class="lct-stat-grid lct-stat-grid-spaced">
|
|
27
|
+
<article class="lct-stat">
|
|
28
|
+
<p class="lct-stat-label">Total cost</p>
|
|
29
|
+
<p class="lct-stat-value"><%= money(@value_total_cost) %></p>
|
|
30
|
+
<p class="lct-stat-copy">Across <%= number(@value_calls) %> calls</p>
|
|
31
|
+
</article>
|
|
32
|
+
|
|
33
|
+
<article class="lct-stat">
|
|
34
|
+
<p class="lct-stat-label">Calls</p>
|
|
35
|
+
<p class="lct-stat-value"><%= number(@value_calls) %></p>
|
|
36
|
+
<p class="lct-stat-copy">Tagged with <code class="lct-code"><%= @value %></code></p>
|
|
37
|
+
</article>
|
|
38
|
+
|
|
39
|
+
<article class="lct-stat">
|
|
40
|
+
<p class="lct-stat-label">Avg cost / call</p>
|
|
41
|
+
<p class="lct-stat-value"><%= money(@value_calls.positive? ? @value_total_cost / @value_calls : 0) %></p>
|
|
42
|
+
<p class="lct-stat-copy">Mean over the slice</p>
|
|
43
|
+
</article>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section class="lct-panel">
|
|
47
|
+
<div class="lct-section-head">
|
|
48
|
+
<div>
|
|
49
|
+
<h2 class="lct-section-title">Spend over time</h2>
|
|
50
|
+
<p class="lct-section-copy">Daily total cost for calls tagged <code class="lct-code"><%= params[:key] %>=<%= @value %></code>.</p>
|
|
51
|
+
</div>
|
|
52
|
+
<%= link_to "Calls", calls_path(calls_query_for_tag(key: @key, value: @value)), class: "lct-button lct-button-secondary lct-button-compact" %>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<%= render "llm_cost_tracker/shared/spend_chart", series: @value_points %>
|
|
56
|
+
</section>
|
|
57
|
+
<% end %>
|
|
58
|
+
<% else %>
|
|
1
59
|
<section class="lct-panel lct-toolbar">
|
|
2
60
|
<div class="lct-toolbar-head">
|
|
3
61
|
<div>
|
|
@@ -78,6 +136,7 @@
|
|
|
78
136
|
<% if row.value == "(untagged)" %>
|
|
79
137
|
<span class="lct-muted">n/a</span>
|
|
80
138
|
<% else %>
|
|
139
|
+
<%= link_to "Trend", tag_path(params[:key], current_query.merge(tag_value: row.value)), class: "lct-button lct-button-secondary lct-button-compact" %>
|
|
81
140
|
<%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-button lct-button-secondary lct-button-compact" %>
|
|
82
141
|
<% end %>
|
|
83
142
|
</td>
|
|
@@ -88,3 +147,4 @@
|
|
|
88
147
|
</div>
|
|
89
148
|
</section>
|
|
90
149
|
<% end %>
|
|
150
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -4,9 +4,10 @@ LlmCostTracker::Engine.routes.draw do
|
|
|
4
4
|
root "dashboard#index"
|
|
5
5
|
resources :calls, only: %i[index show], constraints: { id: /\d+/ }, defaults: { format: :html }
|
|
6
6
|
resources :models, only: :index
|
|
7
|
-
|
|
8
|
-
get "tags/:key", to: "tags#show", as: :tag, format: false
|
|
7
|
+
resources :tags, only: %i[index show], param: :key, format: false
|
|
9
8
|
get "data_quality", to: "data_quality#index", as: :data_quality
|
|
9
|
+
get "reconciliation", to: "reconciliation#index", as: :reconciliation
|
|
10
|
+
post "reconciliation/import", to: "reconciliation#trigger_import", as: :reconciliation_import
|
|
10
11
|
|
|
11
12
|
get "assets/#{LlmCostTracker::Assets::STYLESHEET_FILENAME}",
|
|
12
13
|
to: "assets#stylesheet", as: :stylesheet
|
|
@@ -6,6 +6,37 @@ require_relative "../errors"
|
|
|
6
6
|
|
|
7
7
|
module LlmCostTracker
|
|
8
8
|
module Billing
|
|
9
|
+
RATE_BASES = %i[
|
|
10
|
+
per_million_tokens
|
|
11
|
+
per_million_characters
|
|
12
|
+
per_request
|
|
13
|
+
per_1k_requests
|
|
14
|
+
per_session
|
|
15
|
+
per_hour
|
|
16
|
+
per_gb_day
|
|
17
|
+
per_image
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
RATE_BASIS_QUANTITIES = {
|
|
21
|
+
per_million_tokens: 1_000_000,
|
|
22
|
+
per_million_characters: 1_000_000,
|
|
23
|
+
per_request: 1,
|
|
24
|
+
per_1k_requests: 1_000,
|
|
25
|
+
per_session: 1,
|
|
26
|
+
per_hour: 1,
|
|
27
|
+
per_gb_day: 1,
|
|
28
|
+
per_image: 1
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
DEFAULT_RATE_BASIS_BY_UNIT = {
|
|
32
|
+
token: :per_million_tokens,
|
|
33
|
+
character: :per_million_characters,
|
|
34
|
+
request: :per_request,
|
|
35
|
+
session: :per_session,
|
|
36
|
+
hour: :per_hour,
|
|
37
|
+
image: :per_image
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
9
40
|
module Components
|
|
10
41
|
Component = Data.define(
|
|
11
42
|
:key,
|
|
@@ -16,7 +47,8 @@ module LlmCostTracker
|
|
|
16
47
|
:unit,
|
|
17
48
|
:category,
|
|
18
49
|
:token_key,
|
|
19
|
-
:cost_key
|
|
50
|
+
:cost_key,
|
|
51
|
+
:rate_basis
|
|
20
52
|
)
|
|
21
53
|
|
|
22
54
|
REQUIRED_FIELDS = %i[key kind direction modality cache_state unit category].freeze
|
|
@@ -32,16 +64,26 @@ module LlmCostTracker
|
|
|
32
64
|
missing = REQUIRED_FIELDS - attributes.keys
|
|
33
65
|
raise Error, "components.yml entry missing #{missing.join(', ')}: #{attributes.inspect}" if missing.any?
|
|
34
66
|
|
|
67
|
+
unit = attributes.fetch(:unit).to_sym
|
|
68
|
+
rate_basis = attributes[:rate_basis]&.to_sym || Billing::DEFAULT_RATE_BASIS_BY_UNIT[unit]
|
|
69
|
+
if rate_basis.nil?
|
|
70
|
+
raise Error, "components.yml entry needs rate_basis for unit #{unit.inspect}: #{attributes.inspect}"
|
|
71
|
+
end
|
|
72
|
+
unless Billing::RATE_BASES.include?(rate_basis)
|
|
73
|
+
raise Error, "components.yml entry has unknown rate_basis #{rate_basis.inspect}: #{attributes.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
35
76
|
Component.new(
|
|
36
77
|
key: attributes.fetch(:key).to_sym,
|
|
37
78
|
kind: attributes.fetch(:kind).to_sym,
|
|
38
79
|
direction: attributes.fetch(:direction).to_sym,
|
|
39
80
|
modality: attributes.fetch(:modality).to_sym,
|
|
40
81
|
cache_state: attributes.fetch(:cache_state).to_sym,
|
|
41
|
-
unit:
|
|
82
|
+
unit: unit,
|
|
42
83
|
category: attributes.fetch(:category).to_sym,
|
|
43
84
|
token_key: attributes[:token_key]&.to_sym,
|
|
44
|
-
cost_key: attributes[:cost_key]&.to_sym
|
|
85
|
+
cost_key: attributes[:cost_key]&.to_sym,
|
|
86
|
+
rate_basis: rate_basis
|
|
45
87
|
)
|
|
46
88
|
end
|
|
47
89
|
|