llm_cost_tracker 0.10.0 → 0.12.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,183 +0,0 @@
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,16 +0,0 @@
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 %>
@@ -1,66 +0,0 @@
1
- <%
2
- fields = local_assigns.fetch(:fields, %i[from to provider model stream])
3
- default_range = LlmCostTracker::Dashboard::DateRange.call(params: params)
4
- defaults = {
5
- from: default_range.from.iso8601,
6
- to: default_range.to.iso8601
7
- }.merge(local_assigns.fetch(:defaults, {}))
8
- reset_path = local_assigns.fetch(:reset_path, path)
9
- filter_scope = local_assigns.fetch(:filter_scope, params)
10
- %>
11
-
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 %>
16
- <div class="lct-filter-row">
17
- <% if fields.include?(:from) %>
18
- <div class="lct-field">
19
- <label for="lct-filter-from">From</label>
20
- <input id="lct-filter-from" type="date" name="from" value="<%= params[:from] || defaults[:from] %>">
21
- </div>
22
- <% end %>
23
-
24
- <% if fields.include?(:to) %>
25
- <div class="lct-field">
26
- <label for="lct-filter-to">To</label>
27
- <input id="lct-filter-to" type="date" name="to" value="<%= params[:to] || defaults[:to] %>">
28
- </div>
29
- <% end %>
30
-
31
- <% if fields.include?(:provider) %>
32
- <div class="lct-field">
33
- <label for="lct-filter-provider">Provider</label>
34
- <%= select_tag :provider,
35
- options_for_select(provider_filter_options(filter_params: filter_scope), params[:provider]),
36
- include_blank: "All providers",
37
- id: "lct-filter-provider" %>
38
- </div>
39
- <% end %>
40
-
41
- <% if fields.include?(:model) %>
42
- <div class="lct-field">
43
- <label for="lct-filter-model">Model</label>
44
- <%= select_tag :model,
45
- options_for_select(model_filter_options(filter_params: filter_scope), params[:model]),
46
- include_blank: "All models",
47
- id: "lct-filter-model" %>
48
- </div>
49
- <% end %>
50
-
51
- <% if fields.include?(:stream) %>
52
- <div class="lct-field">
53
- <label for="lct-filter-stream">Stream</label>
54
- <%= select_tag :stream,
55
- options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, params[:stream]),
56
- include_blank: "All calls",
57
- id: "lct-filter-stream" %>
58
- </div>
59
- <% end %>
60
-
61
- <div class="lct-filter-actions">
62
- <button class="lct-button" type="submit">Apply</button>
63
- <%= link_to("Reset", reset_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
64
- </div>
65
- </div>
66
- </form>
@@ -1,13 +0,0 @@
1
- <%
2
- current = local_assigns.fetch(:current).to_s
3
- options = local_assigns.fetch(:options)
4
- %>
5
-
6
- <nav class="lct-sort" aria-label="Sort by">
7
- <% options.each do |label, value| %>
8
- <%= link_to label,
9
- path_for_sort.call(value),
10
- class: ["lct-sort-option", ("is-active" if current == value.to_s)].compact.join(" "),
11
- aria: ({ current: "true" } if current == value.to_s) %>
12
- <% end %>
13
- </nav>
@@ -1,95 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "psych"
4
-
5
- require_relative "../errors"
6
-
7
- module LlmCostTracker
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
-
40
- module Components
41
- Component = Data.define(
42
- :key,
43
- :kind,
44
- :direction,
45
- :modality,
46
- :cache_state,
47
- :unit,
48
- :category,
49
- :token_key,
50
- :cost_key,
51
- :rate_basis
52
- )
53
-
54
- REQUIRED_FIELDS = %i[key kind direction modality cache_state unit category].freeze
55
- DEFINITIONS_PATH = File.expand_path("components.yml", __dir__)
56
-
57
- def self.load_registry
58
- Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
59
- .map { |attributes| build(attributes) }
60
- .freeze
61
- end
62
-
63
- def self.build(attributes)
64
- missing = REQUIRED_FIELDS - attributes.keys
65
- raise Error, "components.yml entry missing #{missing.join(', ')}: #{attributes.inspect}" if missing.any?
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
-
76
- Component.new(
77
- key: attributes.fetch(:key).to_sym,
78
- kind: attributes.fetch(:kind).to_sym,
79
- direction: attributes.fetch(:direction).to_sym,
80
- modality: attributes.fetch(:modality).to_sym,
81
- cache_state: attributes.fetch(:cache_state).to_sym,
82
- unit: unit,
83
- category: attributes.fetch(:category).to_sym,
84
- token_key: attributes[:token_key]&.to_sym,
85
- cost_key: attributes[:cost_key]&.to_sym,
86
- rate_basis: rate_basis
87
- )
88
- end
89
-
90
- REGISTRY = load_registry
91
- BY_KEY = REGISTRY.to_h { |component| [component.key, component] }.freeze
92
- TOKEN_PRICED = REGISTRY.select { |component| component.token_key && component.cost_key }.freeze
93
- end
94
- end
95
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Capture
5
- module Stream
6
- LIMIT_BYTES = 1_048_576
7
- end
8
- end
9
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "check"
4
- require_relative "../ingestion"
5
-
6
- module LlmCostTracker
7
- class Doctor
8
- class CaptureVerifier
9
- class << self
10
- def call
11
- new.checks
12
- end
13
-
14
- def report(checks = call)
15
- (["LLM Cost Tracker capture verification"] + checks.map do |check|
16
- "[#{check.status}] #{check.name}: #{check.message}"
17
- end).join("\n")
18
- end
19
-
20
- def healthy?(checks = call)
21
- checks.none? { |check| check.status == :error }
22
- end
23
- end
24
-
25
- def checks
26
- [
27
- enabled_check,
28
- *integration_checks,
29
- *storage_checks
30
- ].compact
31
- end
32
-
33
- private
34
-
35
- def enabled_check
36
- return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
37
-
38
- Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
39
- end
40
-
41
- def integration_checks
42
- enabled = LlmCostTracker.configuration.instrumented_integrations
43
- if enabled.empty?
44
- return [
45
- Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
46
- ]
47
- end
48
-
49
- LlmCostTracker::Integrations.checks.map do |check|
50
- check.with(name: "sdk integration #{check.name}")
51
- end
52
- end
53
-
54
- def storage_checks
55
- LlmCostTracker::Ingestion.verify
56
- rescue LlmCostTracker::Error => e
57
- [Check.new(:error, "storage", e.message)]
58
- end
59
- end
60
- end
61
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- class Doctor
5
- Check = Data.define(:status, :name, :message)
6
- end
7
- end
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
-
5
- require_relative "check"
6
- require_relative "probe"
7
- require_relative "../ledger/rollups"
8
-
9
- module LlmCostTracker
10
- class Doctor
11
- class CostDriftCheck
12
- SAMPLE_SIZE = 200
13
- EPSILON = BigDecimal("0.00000001")
14
-
15
- def call
16
- return unless Probe.table_exists?("llm_cost_tracker_calls")
17
- return unless Probe.table_exists?("llm_cost_tracker_call_line_items")
18
-
19
- sampled = LlmCostTracker::Call
20
- .where.not(total_cost: nil)
21
- .where(cost_status: %w[complete free partial])
22
- .order(id: :desc)
23
- .limit(SAMPLE_SIZE)
24
- .pluck(:id, :total_cost, :cost_status)
25
- return Check.new(:ok, "cost drift", "no priced calls to inspect") if sampled.empty?
26
-
27
- line_item_totals = LlmCostTracker::CallLineItem
28
- .where(llm_cost_tracker_call_id: sampled.map(&:first))
29
- .where(currency: Ledger::Rollups::DEFAULT_CURRENCY)
30
- .group(:llm_cost_tracker_call_id)
31
- .sum(:cost)
32
-
33
- drifted = sampled.filter_map do |id, total_cost, cost_status|
34
- line_total = line_item_totals[id] || BigDecimal("0")
35
- header = BigDecimal(total_cost.to_s)
36
- next if cost_status == "partial" && header >= line_total
37
- next if (header - line_total).abs <= EPSILON
38
-
39
- "##{id}: header=#{header.to_s('F')} line_items=#{line_total.to_s('F')}"
40
- end
41
-
42
- if drifted.empty?
43
- return Check.new(:ok, "cost drift",
44
- "header total_cost matches line items in #{sampled.size} sampled calls")
45
- end
46
-
47
- Check.new(
48
- :warn,
49
- "cost drift",
50
- "header total_cost diverges from line items in #{drifted.size}/#{sampled.size} sampled calls: " \
51
- "#{drifted.first(5).join('; ')}#{'; ...' if drifted.size > 5}"
52
- )
53
- end
54
- end
55
- end
56
- end
@@ -1,164 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
-
5
- require_relative "check"
6
- require_relative "probe"
7
- require_relative "../ledger/schema/adapter"
8
-
9
- module LlmCostTracker
10
- class Doctor
11
- class InvoiceReconciliationCheck
12
- def call
13
- return unless LlmCostTracker.reconciliation_enabled?
14
- return unless Probe.table_exists?("llm_cost_tracker_provider_invoices")
15
- return if no_imports?
16
-
17
- scopes = imported_scopes
18
- return Check.new(:ok, "invoice reconciliation", "no provider invoices imported yet") if scopes.empty?
19
-
20
- non_canonical = non_canonical_currency_check
21
- checks = scopes.map { |scope| check_scope_safely(scope) }
22
- checks << non_canonical if non_canonical
23
- checks
24
- rescue StandardError => e
25
- Check.new(:error, "invoice reconciliation", e.message)
26
- end
27
-
28
- private
29
-
30
- def no_imports?
31
- LlmCostTracker::ProviderInvoice.none?
32
- end
33
-
34
- def non_canonical_currency_check
35
- legacy = LlmCostTracker::ProviderInvoice.where("currency <> UPPER(currency)").count
36
- return nil if legacy.zero?
37
-
38
- Check.new(
39
- :warn,
40
- "invoice reconciliation: currency canonicalisation",
41
- "#{legacy} provider invoice row(s) stored with non-uppercase currency. Diff queries " \
42
- "are case-sensitive — run " \
43
- "`UPDATE llm_cost_tracker_provider_invoices SET currency = UPPER(currency);` to backfill."
44
- )
45
- end
46
-
47
- def threshold
48
- Reconciliation::DEFAULT_THRESHOLD_PERCENT
49
- end
50
-
51
- def imported_scopes
52
- connection = LlmCostTracker::ProviderInvoice.connection
53
- provider_expr =
54
- if Ledger::Schema::Adapter.postgresql?(connection)
55
- Arel.sql("metadata->>'provider'")
56
- else
57
- Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))")
58
- end
59
- LlmCostTracker::ProviderInvoice
60
- .group(:source, provider_expr, :currency)
61
- .order(:source, :currency)
62
- .pluck(:source, provider_expr, :currency)
63
- .map { |source, provider, currency| { source: source, provider: provider, currency: currency.upcase } }
64
- end
65
-
66
- def scope_label(scope)
67
- "#{scope[:source]}/#{scope[:provider]}/#{scope[:currency]}"
68
- end
69
-
70
- def check_scope_safely(scope)
71
- check_scope(scope)
72
- rescue ArgumentError => e
73
- Check.new(:warn, "invoice reconciliation: #{scope_label(scope)}", e.message)
74
- end
75
-
76
- def check_scope(scope)
77
- window = latest_window_for(scope)
78
- return stale_check(scope) if window.nil?
79
-
80
- diff = run_diff(scope, window)
81
- return ok_check(scope, window, diff) if diff.aligned?(threshold_percent: threshold)
82
-
83
- warn_check(scope, window, diff)
84
- end
85
-
86
- def scope_relation(scope)
87
- relation = LlmCostTracker::ProviderInvoice
88
- .where(source: scope[:source], currency: scope[:currency])
89
- provider = scope[:provider]
90
- return relation if provider.nil? || provider.to_s.empty?
91
-
92
- connection = LlmCostTracker::ProviderInvoice.connection
93
- if Ledger::Schema::Adapter.postgresql?(connection)
94
- relation.where("metadata->>'provider' = ?", provider)
95
- else
96
- relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider')) = ?", provider)
97
- end
98
- end
99
-
100
- def latest_window_for(scope)
101
- latest = scope_relation(scope)
102
- .select(:period_start, :period_end)
103
- .order(period_end: :desc, period_start: :desc)
104
- .limit(1)
105
- .first
106
- return nil unless latest
107
- return nil if (Time.now.utc.to_date - latest.period_end).to_i > Reconciliation::INVOICE_FRESHNESS_DAYS
108
-
109
- latest
110
- end
111
-
112
- def run_diff(scope, window)
113
- Reconciliation.diff(
114
- source: scope[:source],
115
- provider: scope[:provider],
116
- currency: scope[:currency],
117
- period_start: window.period_start,
118
- period_end: window.period_end
119
- )
120
- end
121
-
122
- def stale_check(scope)
123
- latest = scope_relation(scope).maximum(:period_end)
124
- return scope_unreachable_check(scope) if latest.nil?
125
-
126
- days = (Time.now.utc.to_date - latest).to_i
127
- Check.new(
128
- :warn,
129
- "invoice reconciliation: #{scope_label(scope)}",
130
- "no invoice imported in #{days} days (threshold #{Reconciliation::INVOICE_FRESHNESS_DAYS} days); " \
131
- "run reconciliation import"
132
- )
133
- end
134
-
135
- def scope_unreachable_check(scope)
136
- Check.new(
137
- :warn,
138
- "invoice reconciliation: #{scope_label(scope)}",
139
- "scope grouped invoices but the filter (likely currency case mismatch) matches zero rows; " \
140
- "the currency-canonicalisation check below points at the backfill SQL"
141
- )
142
- end
143
-
144
- def ok_check(scope, window, diff)
145
- Check.new(
146
- :ok,
147
- "invoice reconciliation: #{scope_label(scope)}",
148
- "#{window.period_start}..#{window.period_end} aligned " \
149
- "(local=#{diff.local_total.to_s('F')}, provider=#{diff.provider_total.to_s('F')})"
150
- )
151
- end
152
-
153
- def warn_check(scope, window, diff)
154
- Check.new(
155
- :warn,
156
- "invoice reconciliation: #{scope_label(scope)}",
157
- "#{window.period_start}..#{window.period_end} drift " \
158
- "delta=#{diff.delta_amount.to_s('F')} (#{diff.delta_percent}%) " \
159
- "exceeds #{threshold}% threshold"
160
- )
161
- end
162
- end
163
- end
164
- end