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.
Files changed (150) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +14 -6
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  26. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  27. data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
  28. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  29. data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
  30. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  31. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  32. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  34. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  35. data/config/routes.rb +3 -2
  36. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  37. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  38. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  39. data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
  40. data/lib/llm_cost_tracker/budget.rb +31 -7
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  43. data/lib/llm_cost_tracker/configuration.rb +72 -17
  44. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  45. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  46. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
  47. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  48. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  49. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  51. data/lib/llm_cost_tracker/doctor.rb +72 -14
  52. data/lib/llm_cost_tracker/engine.rb +8 -0
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +48 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  74. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  75. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
  76. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  77. data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
  78. data/lib/llm_cost_tracker/ingestion.rb +48 -11
  79. data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
  80. data/lib/llm_cost_tracker/integrations/base.rb +35 -15
  81. data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
  82. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
  83. data/lib/llm_cost_tracker/integrations.rb +33 -14
  84. data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
  85. data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
  86. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
  87. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
  88. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
  89. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
  90. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
  91. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
  92. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
  93. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
  94. data/lib/llm_cost_tracker/ledger/store.rb +34 -31
  95. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  96. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
  97. data/lib/llm_cost_tracker/ledger.rb +2 -1
  98. data/lib/llm_cost_tracker/logging.rb +0 -4
  99. data/lib/llm_cost_tracker/masking.rb +39 -0
  100. data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
  101. data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
  102. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  103. data/lib/llm_cost_tracker/parsers/base.rb +53 -43
  104. data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
  105. data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
  106. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
  107. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
  108. data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
  109. data/lib/llm_cost_tracker/parsers.rb +31 -4
  110. data/lib/llm_cost_tracker/prices.json +572 -493
  111. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  112. data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
  113. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  114. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  115. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
  116. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  117. data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
  118. data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
  119. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
  121. data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  123. data/lib/llm_cost_tracker/pricing.rb +117 -44
  124. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  125. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  126. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  127. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  128. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  129. data/lib/llm_cost_tracker/railtie.rb +8 -0
  130. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  131. data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
  132. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
  133. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  134. data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
  135. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
  136. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  137. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  138. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  139. data/lib/llm_cost_tracker/report/data.rb +4 -1
  140. data/lib/llm_cost_tracker/report.rb +0 -4
  141. data/lib/llm_cost_tracker/retention.rb +31 -6
  142. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  143. data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
  144. data/lib/llm_cost_tracker/token_usage.rb +14 -2
  145. data/lib/llm_cost_tracker/tracker.rb +41 -55
  146. data/lib/llm_cost_tracker/version.rb +1 -1
  147. data/lib/llm_cost_tracker.rb +19 -14
  148. data/lib/tasks/llm_cost_tracker.rake +41 -4
  149. metadata +49 -3
  150. 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"><%= optional_money(row.total_cost) %></td>
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 "Review calls", calls_path(current_query(sort: "unknown_pricing")), class: "lct-button lct-button-secondary" %>
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 class="lct-bar-fill<%= variant_class %>" style="width: <%= bar_width(value, max) %>"></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 class="lct-stack-fill <%= segment[:css_class] %>" style="width: <%= segment[:percent].round(2) %>%"></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
- get "tags", to: "tags#index", as: :tags
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: attributes.fetch(:unit).to_sym,
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