llm_cost_tracker 0.7.3 → 0.9.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +173 -0
  4. data/README.md +60 -220
  5. data/app/assets/llm_cost_tracker/application.css +282 -45
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  14. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  15. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  16. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  17. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  18. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
  19. data/app/models/llm_cost_tracker/call.rb +166 -0
  20. data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
  21. data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
  22. data/app/models/llm_cost_tracker/call_tag.rb +12 -0
  23. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
  24. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  25. data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
  26. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  27. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
  28. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
  30. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  31. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  32. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  33. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  35. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  36. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  37. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  38. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  39. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  40. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  41. data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
  42. data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
  43. data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
  44. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  45. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  46. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  47. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  48. data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
  49. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  50. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  51. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  52. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  53. data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
  54. data/config/routes.rb +3 -2
  55. data/lib/llm_cost_tracker/billing/components.rb +95 -0
  56. data/lib/llm_cost_tracker/billing/components.yml +188 -0
  57. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  58. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  59. data/lib/llm_cost_tracker/budget.rb +26 -36
  60. data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
  61. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  62. data/lib/llm_cost_tracker/configuration.rb +86 -17
  63. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  64. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
  65. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
  66. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  67. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  68. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  69. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  70. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  71. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  72. data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
  73. data/lib/llm_cost_tracker/doctor.rb +111 -44
  74. data/lib/llm_cost_tracker/engine.rb +9 -0
  75. data/lib/llm_cost_tracker/errors.rb +5 -19
  76. data/lib/llm_cost_tracker/event.rb +11 -3
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
  92. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
  93. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  94. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
  95. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  96. data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
  97. data/lib/llm_cost_tracker/ingestion.rb +66 -22
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
  99. data/lib/llm_cost_tracker/integrations/base.rb +56 -32
  100. data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
  101. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
  102. data/lib/llm_cost_tracker/integrations.rb +21 -3
  103. data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
  104. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  105. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  106. data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
  107. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  108. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
  109. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  110. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
  111. data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
  112. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  113. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  114. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  115. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  116. data/lib/llm_cost_tracker/ledger/store.rb +103 -20
  117. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  118. data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
  119. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  120. data/lib/llm_cost_tracker/ledger.rb +5 -2
  121. data/lib/llm_cost_tracker/logging.rb +2 -5
  122. data/lib/llm_cost_tracker/masking.rb +39 -0
  123. data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
  124. data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
  125. data/lib/llm_cost_tracker/parsers/base.rb +13 -4
  126. data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
  127. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  128. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
  129. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
  130. data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
  131. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  132. data/lib/llm_cost_tracker/parsers.rb +1 -1
  133. data/lib/llm_cost_tracker/prices.json +198 -22
  134. data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
  135. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  136. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
  137. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  138. data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
  139. data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
  140. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  141. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  142. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  143. data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
  144. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  145. data/lib/llm_cost_tracker/pricing.rb +220 -28
  146. data/lib/llm_cost_tracker/railtie.rb +6 -8
  147. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  148. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  149. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  150. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  151. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  152. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  153. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  154. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  155. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  156. data/lib/llm_cost_tracker/report/data.rb +19 -8
  157. data/lib/llm_cost_tracker/report.rb +0 -4
  158. data/lib/llm_cost_tracker/retention.rb +22 -9
  159. data/lib/llm_cost_tracker/tags/context.rb +2 -5
  160. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  161. data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
  162. data/lib/llm_cost_tracker/timing.rb +15 -0
  163. data/lib/llm_cost_tracker/token_usage.rb +64 -42
  164. data/lib/llm_cost_tracker/tracker.rb +97 -27
  165. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  166. data/lib/llm_cost_tracker/version.rb +1 -1
  167. data/lib/llm_cost_tracker.rb +45 -35
  168. data/lib/tasks/llm_cost_tracker.rake +45 -17
  169. metadata +71 -41
  170. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  171. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  172. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  173. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  174. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  175. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  176. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  182. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  183. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  184. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  185. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  186. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  187. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  188. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  189. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  190. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  191. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  192. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  193. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  194. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  195. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -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>
@@ -0,0 +1,66 @@
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>
@@ -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
 
@@ -0,0 +1,13 @@
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,7 +1,7 @@
1
1
  <section class="lct-panel lct-empty">
2
2
  <h2 class="lct-state-title">Setup required</h2>
3
3
  <p class="lct-state-copy">
4
- <%= @setup_message || "The llm_api_calls table is not available yet." %>
4
+ <%= @setup_message || "The llm_cost_tracker_calls table is not available yet." %>
5
5
  <% if @setup_details.present? %>
6
6
  Run <span class="lct-code">bin/rails llm_cost_tracker:doctor</span>, apply the listed migrations, and migrate your database.
7
7
  <% else %>
@@ -3,40 +3,9 @@
3
3
  <h2 class="lct-section-title">Tag keys</h2>
4
4
  </div>
5
5
 
6
- <form class="lct-filters" action="<%= tags_path %>" method="get">
7
- <div class="lct-filter-row lct-filter-row-basic">
8
- <div class="lct-field">
9
- <label for="lct-tags-from">From</label>
10
- <input id="lct-tags-from" type="date" name="from" value="<%= params[:from] %>">
11
- </div>
12
-
13
- <div class="lct-field">
14
- <label for="lct-tags-to">To</label>
15
- <input id="lct-tags-to" type="date" name="to" value="<%= params[:to] %>">
16
- </div>
17
-
18
- <div class="lct-field">
19
- <label for="lct-tags-provider">Provider</label>
20
- <%= select_tag :provider,
21
- options_for_select(provider_filter_options, params[:provider]),
22
- include_blank: "All providers",
23
- id: "lct-tags-provider" %>
24
- </div>
25
-
26
- <div class="lct-field">
27
- <label for="lct-tags-model">Model</label>
28
- <%= select_tag :model,
29
- options_for_select(model_filter_options, params[:model]),
30
- include_blank: "All models",
31
- id: "lct-tags-model" %>
32
- </div>
33
-
34
- <div class="lct-filter-actions">
35
- <button class="lct-button" type="submit">Apply</button>
36
- <%= link_to("Reset", tags_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
37
- </div>
38
- </div>
39
- </form>
6
+ <%= render "llm_cost_tracker/shared/filters",
7
+ path: tags_path,
8
+ fields: %i[from to provider model] %>
40
9
 
41
10
  <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tags_path %>
42
11
  </section>
@@ -1,47 +1,73 @@
1
- <% share_base = @breakdown.tagged_calls.positive? ? @breakdown.tagged_calls.to_f : 1.0 %>
2
-
3
- <section class="lct-panel lct-toolbar">
4
- <div class="lct-toolbar-head">
5
- <div>
6
- <p class="lct-muted"><%= link_to " All tag keys", tags_path(current_query) %></p>
7
- <h2 class="lct-section-title">Tag: <code class="lct-code"><%= params[:key] %></code></h2>
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
8
  </div>
9
- </div>
10
9
 
11
- <form class="lct-filters" action="<%= tag_path(params[:key]) %>" method="get">
12
- <div class="lct-filter-row lct-filter-row-basic">
13
- <div class="lct-field">
14
- <label for="lct-tag-show-from">From</label>
15
- <input id="lct-tag-show-from" type="date" name="from" value="<%= params[:from] %>">
16
- </div>
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>
17
16
 
18
- <div class="lct-field">
19
- <label for="lct-tag-show-to">To</label>
20
- <input id="lct-tag-show-to" type="date" name="to" value="<%= params[:to] %>">
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" %>
21
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>
22
32
 
23
- <div class="lct-field">
24
- <label for="lct-tag-show-provider">Provider</label>
25
- <%= select_tag :provider,
26
- options_for_select(provider_filter_options, params[:provider]),
27
- include_blank: "All providers",
28
- id: "lct-tag-show-provider" %>
29
- </div>
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>
30
38
 
31
- <div class="lct-field">
32
- <label for="lct-tag-show-model">Model</label>
33
- <%= select_tag :model,
34
- options_for_select(model_filter_options, params[:model]),
35
- include_blank: "All models",
36
- id: "lct-tag-show-model" %>
37
- </div>
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>
38
45
 
39
- <div class="lct-filter-actions">
40
- <button class="lct-button" type="submit">Apply</button>
41
- <%= link_to("Reset", tag_path(params[:key]), class: "lct-button lct-button-secondary") if any_filter_applied? %>
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" %>
42
53
  </div>
54
+
55
+ <%= render "llm_cost_tracker/shared/spend_chart", series: @value_points %>
56
+ </section>
57
+ <% end %>
58
+ <% else %>
59
+ <section class="lct-panel lct-toolbar">
60
+ <div class="lct-toolbar-head">
61
+ <div>
62
+ <p class="lct-muted"><%= link_to "← All tag keys", tags_path(current_query) %></p>
63
+ <h2 class="lct-section-title">Tag: <code class="lct-code"><%= params[:key] %></code></h2>
43
64
  </div>
44
- </form>
65
+ </div>
66
+
67
+ <%= render "llm_cost_tracker/shared/filters",
68
+ path: tag_path(params[:key]),
69
+ fields: %i[from to provider model],
70
+ reset_path: tag_path(params[:key]) %>
45
71
 
46
72
  <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tag_path(params[:key]) %>
47
73
 
@@ -103,13 +129,14 @@
103
129
  <tr>
104
130
  <td><code class="lct-code"><%= row.value %></code></td>
105
131
  <td class="lct-num"><%= number(row.calls) %></td>
106
- <td class="lct-num"><%= percent((row.calls / share_base) * 100.0) %></td>
132
+ <td class="lct-num"><%= percent(row.share_percent) %></td>
107
133
  <td class="lct-num"><%= money(row.total_cost) %></td>
108
134
  <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
109
135
  <td>
110
136
  <% if row.value == "(untagged)" %>
111
137
  <span class="lct-muted">n/a</span>
112
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" %>
113
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" %>
114
141
  <% end %>
115
142
  </td>
@@ -120,3 +147,4 @@
120
147
  </div>
121
148
  </section>
122
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
@@ -0,0 +1,95 @@
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