llm_cost_tracker 0.11.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -7,7 +7,7 @@
7
7
  <%= link_to "× Clear filters", tags_path(current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
8
8
  <% end %>
9
9
 
10
- <span class="lct-filter-row-meta"><%= number(@rows.size) %> tag key<%= "s" unless @rows.size == 1 %></span>
10
+ <span class="lct-filter-row-meta"><%= number_with_delimiter(@rows.size) %> tag key<%= "s" unless @rows.size == 1 %></span>
11
11
  </div>
12
12
 
13
13
  <% if @rows.empty? %>
@@ -30,8 +30,8 @@
30
30
  <% @rows.each do |row| %>
31
31
  <tr>
32
32
  <td><code class="lct-code-id"><%= row.key %></code></td>
33
- <td class="lct-num"><%= number(row.calls_count) %></td>
34
- <td class="lct-num"><%= number(row.distinct_values) %></td>
33
+ <td class="lct-num"><%= number_with_delimiter(row.calls_count) %></td>
34
+ <td class="lct-num"><%= number_with_delimiter(row.distinct_values) %></td>
35
35
  <td class="lct-num"><%= link_to "Breakdown", tag_path(row.key, current_query), class: "lct-page-link" %></td>
36
36
  </tr>
37
37
  <% end %>
@@ -13,7 +13,7 @@
13
13
  <%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
14
14
  <% end %>
15
15
 
16
- <span class="lct-filter-row-meta"><%= number(@value_calls) %> call<%= "s" unless @value_calls == 1 %> · <%= money(@value_total_cost) %></span>
16
+ <span class="lct-filter-row-meta"><%= number_with_delimiter(@value_calls) %> call<%= "s" unless @value_calls == 1 %> · <%= money(@value_total_cost) %></span>
17
17
  </div>
18
18
 
19
19
  <% if @value_calls.zero? %>
@@ -26,11 +26,11 @@
26
26
  <div class="lct-stat">
27
27
  <div class="lct-stat-head"><p class="lct-stat-label">Total cost</p></div>
28
28
  <p class="lct-stat-value"><%= money(@value_total_cost) %></p>
29
- <p class="lct-stat-foot">Across <%= number(@value_calls) %> calls</p>
29
+ <p class="lct-stat-foot">Across <%= number_with_delimiter(@value_calls) %> calls</p>
30
30
  </div>
31
31
  <div class="lct-stat">
32
32
  <div class="lct-stat-head"><p class="lct-stat-label">Calls</p></div>
33
- <p class="lct-stat-value"><%= number(@value_calls) %></p>
33
+ <p class="lct-stat-value"><%= number_with_delimiter(@value_calls) %></p>
34
34
  <p class="lct-stat-foot">Tagged with <code class="lct-code-id"><%= @value %></code></p>
35
35
  </div>
36
36
  <div class="lct-stat">
@@ -62,7 +62,7 @@
62
62
  <%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
63
63
  <% end %>
64
64
 
65
- <span class="lct-filter-row-meta"><%= number(@breakdown.tagged_calls) %> tagged call<%= "s" unless @breakdown.tagged_calls == 1 %> · <%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %> coverage · <%= number(@breakdown.distinct_values) %> distinct value<%= "s" unless @breakdown.distinct_values == 1 %></span>
65
+ <span class="lct-filter-row-meta"><%= number_with_delimiter(@breakdown.tagged_calls) %> tagged call<%= "s" unless @breakdown.tagged_calls == 1 %> · <%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %> coverage · <%= number_with_delimiter(@breakdown.distinct_values) %> distinct value<%= "s" unless @breakdown.distinct_values == 1 %></span>
66
66
  </div>
67
67
 
68
68
  <% if @breakdown.rows.empty? %>
@@ -74,23 +74,23 @@
74
74
  <div class="lct-stat-grid">
75
75
  <div class="lct-stat">
76
76
  <div class="lct-stat-head"><p class="lct-stat-label">Tagged calls</p></div>
77
- <p class="lct-stat-value"><%= number(@breakdown.tagged_calls) %></p>
77
+ <p class="lct-stat-value"><%= number_with_delimiter(@breakdown.tagged_calls) %></p>
78
78
  <p class="lct-stat-foot">Rows that include <code class="lct-code-id"><%= params[:key] %></code></p>
79
79
  </div>
80
80
  <div class="lct-stat">
81
81
  <div class="lct-stat-head"><p class="lct-stat-label">Coverage</p></div>
82
82
  <p class="lct-stat-value"><%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %></p>
83
- <p class="lct-stat-foot"><%= number(@breakdown.total_calls) %> total calls in this slice</p>
83
+ <p class="lct-stat-foot"><%= number_with_delimiter(@breakdown.total_calls) %> total calls in this slice</p>
84
84
  </div>
85
85
  <div class="lct-stat">
86
86
  <div class="lct-stat-head"><p class="lct-stat-label">Distinct values</p></div>
87
- <p class="lct-stat-value"><%= number(@breakdown.distinct_values) %></p>
87
+ <p class="lct-stat-value"><%= number_with_delimiter(@breakdown.distinct_values) %></p>
88
88
  </div>
89
89
  </div>
90
90
 
91
91
  <% if @breakdown.distinct_values > @breakdown.rows.size %>
92
92
  <div class="lct-alert lct-alert-info">
93
- <span>Showing top <%= number(@breakdown.limit) %> values by spend.</span>
93
+ <span>Showing top <%= number_with_delimiter(@breakdown.limit) %> values by spend.</span>
94
94
  </div>
95
95
  <% end %>
96
96
 
@@ -101,7 +101,7 @@
101
101
  <%= sortable_header("Value", "value") %>
102
102
  <%= sortable_header("Calls", "calls", num: true) %>
103
103
  <th class="lct-num">Share</th>
104
- <%= sortable_header("Total cost", "cost", num: true) %>
104
+ <%= sortable_header("Total cost", "cost", num: true, default: true) %>
105
105
  <%= sortable_header("Avg cost / call", "avg_cost", num: true) %>
106
106
  <th></th>
107
107
  </tr>
@@ -110,7 +110,7 @@
110
110
  <% @breakdown.rows.each do |row| %>
111
111
  <tr>
112
112
  <td><code class="lct-code-id"><%= row.value %></code></td>
113
- <td class="lct-num"><%= number(row.calls) %></td>
113
+ <td class="lct-num"><%= number_with_delimiter(row.calls) %></td>
114
114
  <td class="lct-num"><%= percent(row.share_percent) %></td>
115
115
  <td class="lct-num"><%= money(row.total_cost) %></td>
116
116
  <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
data/config/routes.rb CHANGED
@@ -7,9 +7,8 @@ LlmCostTracker::Engine.routes.draw do
7
7
  resources :tags, only: %i[index show], param: :key, format: false
8
8
  get "data_quality", to: "data_quality#index", as: :data_quality
9
9
  get "pricing", to: "pricing#index", as: :pricing
10
- get "reconciliation", to: "reconciliation#index", as: :reconciliation
11
- post "reconciliation/import", to: "reconciliation#trigger_import", as: :reconciliation_import
12
10
 
13
11
  get "assets/#{LlmCostTracker::Assets::STYLESHEET_FILENAME}",
14
- to: "assets#stylesheet", as: :stylesheet
12
+ to: "assets#stylesheet",
13
+ as: :stylesheet
15
14
  end
@@ -2,31 +2,25 @@
2
2
 
3
3
  require "bigdecimal"
4
4
 
5
- require_relative "logging"
6
5
  require_relative "ledger"
7
6
  require_relative "pricing/estimator"
8
7
 
9
8
  module LlmCostTracker
10
- class Budget
9
+ module Budget
11
10
  BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
12
11
 
13
12
  class << self
14
- def enforce!(provider: nil, model: nil, request: nil)
13
+ def enforce!(provider: nil, model: nil, request: nil, estimate: nil, force: false)
15
14
  config = LlmCostTracker.configuration
16
- return unless config.budget_exceeded_behavior == :block_requests
15
+ return unless config.enabled
16
+ return unless force || config.budget_exceeded_behavior == :block_requests
17
17
 
18
- estimate = estimate_cost(provider: provider, model: model, request: request)
18
+ estimate ||= estimate_cost(provider: provider, model: model, request: request)
19
19
  raise_per_call_pre_send(estimate, config.per_call_budget) if config.per_call_budget && estimate.positive?
20
20
 
21
- budgets = { monthly: config.monthly_budget, daily: config.daily_budget }.compact
22
- return if budgets.empty?
23
-
24
- totals = totals_for(budgets.keys, time: Time.now.utc)
25
-
26
- budgets.each do |budget_type, budget|
27
- total = totals.fetch(budget_type) + estimate
28
- next unless total >= budget
29
-
21
+ check_windowed({ monthly: config.monthly_budget, daily: config.daily_budget }.compact,
22
+ time: Time.now.utc,
23
+ estimate: estimate) do |budget_type, total, budget|
30
24
  raise BudgetExceededError.new(**budget_payload(
31
25
  budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send
32
26
  ))
@@ -38,13 +32,9 @@ module LlmCostTracker
38
32
  return unless event.total_cost
39
33
 
40
34
  check_per_call_budget(event, config)
41
- budgets = { daily: config.daily_budget, monthly: config.monthly_budget }.compact
42
- totals = totals_for(budgets.keys, time: event.tracked_at)
43
-
44
- budgets.each do |budget_type, budget|
45
- total = totals.fetch(budget_type)
46
-
47
- handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event) if total >= budget
35
+ check_windowed({ daily: config.daily_budget, monthly: config.monthly_budget }.compact,
36
+ time: event.tracked_at) do |budget_type, total, budget|
37
+ handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event)
48
38
  end
49
39
  end
50
40
 
@@ -74,14 +64,22 @@ module LlmCostTracker
74
64
  handle_exceeded(budget_type: :per_call, total: total, budget: budget, last_event: event)
75
65
  end
76
66
 
67
+ def check_windowed(budgets, time:, estimate: BigDecimal("0"))
68
+ return if budgets.empty?
69
+
70
+ totals = totals_for(budgets.keys, time: time)
71
+ budgets.each do |budget_type, budget|
72
+ total = totals.fetch(budget_type) + estimate
73
+ yield(budget_type, total, budget) if total >= budget
74
+ end
75
+ end
76
+
77
77
  def totals_for(budget_types, time:)
78
78
  return {} if budget_types.empty?
79
79
 
80
- periods = budget_types.map { |type| BUDGET_TYPE_TO_PERIOD.fetch(type) }
81
- period_totals = LlmCostTracker::Ledger::Period::Totals.call(periods, time: time)
82
- BUDGET_TYPE_TO_PERIOD.each_with_object({}) do |(budget_type, period), totals|
83
- totals[budget_type] = period_totals[period] if period_totals.key?(period)
84
- end
80
+ period_for = budget_types.to_h { |type| [type, BUDGET_TYPE_TO_PERIOD.fetch(type)] }
81
+ period_totals = LlmCostTracker::Ledger::Period::Totals.call(period_for.values, time: time)
82
+ period_for.transform_values { |period| period_totals.fetch(period) }
85
83
  end
86
84
 
87
85
  def handle_exceeded(budget_type:, total:, budget:, last_event: nil)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/deep_dup"
4
+ require "active_support/core_ext/object/try"
5
+
6
+ module LlmCostTracker
7
+ module Capture
8
+ module SdkPayload
9
+ module_function
10
+
11
+ def normalize(value)
12
+ case value
13
+ when Hash
14
+ value.each_with_object({}) { |(key, nested), out| out[key.to_s] = normalize(nested) }
15
+ when Array
16
+ value.map { |nested| normalize(nested) }
17
+ when Symbol
18
+ value.to_s
19
+ when NilClass
20
+ nil
21
+ else
22
+ converted = container_for(value)
23
+ converted ? normalize(converted) : value.deep_dup
24
+ end
25
+ end
26
+
27
+ def container_for(value)
28
+ value.try(:deep_to_h) || value.try(:to_h)
29
+ rescue StandardError
30
+ nil
31
+ end
32
+ end
33
+ end
34
+ end
@@ -7,6 +7,7 @@ module LlmCostTracker
7
7
  module Capture
8
8
  module SSE
9
9
  DONE_MARKER = "[DONE]"
10
+ LIMIT_BYTES = 1_048_576
10
11
 
11
12
  class << self
12
13
  def parse(body)
@@ -4,8 +4,7 @@ require "active_support/core_ext/object/blank"
4
4
  require "active_support/core_ext/object/deep_dup"
5
5
  require "json"
6
6
 
7
- require_relative "stream"
8
- require_relative "../pricing/mode"
7
+ require_relative "sse"
9
8
  require_relative "../timing"
10
9
 
11
10
  module LlmCostTracker
@@ -13,9 +12,17 @@ module LlmCostTracker
13
12
  class StreamCollector
14
13
  attr_reader :provider
15
14
 
16
- def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, provider_project_id: nil,
17
- provider_api_key_id: nil, provider_workspace_id: nil, batch: nil, pricing_mode: nil,
18
- metadata: {}, context_tags: nil, request: nil)
15
+ def initialize(provider:,
16
+ model:,
17
+ latency_ms: nil,
18
+ provider_response_id: nil,
19
+ provider_project_id: nil,
20
+ provider_api_key_id: nil,
21
+ provider_workspace_id: nil,
22
+ pricing_mode: nil,
23
+ metadata: {},
24
+ context_tags: nil,
25
+ request: nil)
19
26
  @provider = provider.to_s
20
27
  @model = model
21
28
  @latency_ms = latency_ms
@@ -23,7 +30,6 @@ module LlmCostTracker
23
30
  @provider_project_id = provider_project_id
24
31
  @provider_api_key_id = provider_api_key_id
25
32
  @provider_workspace_id = provider_workspace_id
26
- @batch = batch
27
33
  @pricing_mode = pricing_mode
28
34
  @metadata = (metadata || {}).deep_dup
29
35
  @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
@@ -38,18 +44,6 @@ module LlmCostTracker
38
44
  @mutex = Mutex.new
39
45
  end
40
46
 
41
- def model
42
- @mutex.synchronize { @model }
43
- end
44
-
45
- def metadata
46
- @mutex.synchronize { @metadata.deep_dup }
47
- end
48
-
49
- def provider_response_id
50
- @mutex.synchronize { @provider_response_id }
51
- end
52
-
53
47
  def model=(value)
54
48
  @mutex.synchronize do
55
49
  ensure_open!
@@ -72,16 +66,20 @@ module LlmCostTracker
72
66
  end
73
67
 
74
68
  def usage(input_tokens:, output_tokens:, **extra)
69
+ if extra.key?(:batch)
70
+ raise ArgumentError,
71
+ "`batch:` is no longer accepted by stream.usage; " \
72
+ "pass `pricing_mode: :batch` to track_stream"
73
+ end
74
+
75
75
  @mutex.synchronize do
76
76
  ensure_open!
77
77
  @provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
78
78
  @provider_project_id = extra.delete(:provider_project_id) || @provider_project_id
79
79
  @provider_api_key_id = extra.delete(:provider_api_key_id) || @provider_api_key_id
80
80
  @provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
81
- batch = extra.delete(:batch)
82
- @batch = batch unless batch.nil?
83
- @explicit_usage = TokenUsage.build(
84
- **extra.slice(*TokenUsage.members),
81
+ @explicit_usage = Usage::TokenUsage.build(
82
+ **extra.slice(*Usage::TokenUsage.members),
85
83
  input_tokens: input_tokens,
86
84
  output_tokens: output_tokens
87
85
  )
@@ -110,7 +108,7 @@ module LlmCostTracker
110
108
  model: @model,
111
109
  latency_ms: @latency_ms,
112
110
  provider_response_id: @provider_response_id,
113
- capture_dimensions: capture_dimensions(pricing_mode),
111
+ capture_dimensions: capture_dimensions,
114
112
  pricing_mode: pricing_mode,
115
113
  metadata: @metadata.deep_dup,
116
114
  context_tags: @context_tags.deep_dup,
@@ -141,13 +139,11 @@ module LlmCostTracker
141
139
  end
142
140
  end
143
141
 
144
- def capture_dimensions(pricing_mode)
145
- batch = @batch.nil? ? Event.batch_from_pricing_mode?(pricing_mode).presence : @batch
142
+ def capture_dimensions
146
143
  {
147
144
  provider_project_id: @provider_project_id.to_s.strip.presence,
148
145
  provider_api_key_id: @provider_api_key_id.to_s.strip.presence,
149
- provider_workspace_id: @provider_workspace_id.to_s.strip.presence,
150
- batch: batch
146
+ provider_workspace_id: @provider_workspace_id.to_s.strip.presence
151
147
  }.compact
152
148
  end
153
149
 
@@ -183,12 +179,8 @@ module LlmCostTracker
183
179
  end
184
180
 
185
181
  def present_model(value)
186
- return nil if value.nil?
187
-
188
182
  string = value.to_s.presence
189
- return nil if string.nil? || string == Event::UNKNOWN_MODEL
190
-
191
- string
183
+ string unless string == Event::UNKNOWN_MODEL
192
184
  end
193
185
 
194
186
  def build_from_explicit_usage(snapshot)
@@ -197,7 +189,7 @@ module LlmCostTracker
197
189
  model: snapshot[:model] || Event::UNKNOWN_MODEL,
198
190
  token_usage: snapshot[:explicit_usage],
199
191
  stream: true,
200
- usage_source: :manual,
192
+ usage_source: Usage::Source::MANUAL,
201
193
  pricing_mode: snapshot[:pricing_mode],
202
194
  **snapshot.fetch(:capture_dimensions)
203
195
  )
@@ -207,9 +199,9 @@ module LlmCostTracker
207
199
  Event.build(
208
200
  provider: @provider,
209
201
  model: snapshot[:model] || Event::UNKNOWN_MODEL,
210
- token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
202
+ token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
211
203
  stream: true,
212
- usage_source: :unknown,
204
+ usage_source: Usage::Source::UNKNOWN,
213
205
  pricing_mode: snapshot[:pricing_mode],
214
206
  **snapshot.fetch(:capture_dimensions)
215
207
  )
@@ -224,7 +216,7 @@ module LlmCostTracker
224
216
  def capture_event(data, type:)
225
217
  event = { event: type, data: strip_heavy_payload(data) }
226
218
  size = approximate_bytesize(event)
227
- if @captured_bytes + size <= Capture::Stream::LIMIT_BYTES
219
+ if @captured_bytes + size <= Capture::SSE::LIMIT_BYTES
228
220
  @events << event
229
221
  @captured_bytes += size
230
222
  else
@@ -3,7 +3,7 @@
3
3
  require "active_support/core_ext/object/deep_dup"
4
4
  require "active_support/core_ext/object/try"
5
5
 
6
- require_relative "../logging"
6
+ require_relative "sdk_payload"
7
7
 
8
8
  module LlmCostTracker
9
9
  module Capture
@@ -26,13 +26,24 @@ module LlmCostTracker
26
26
  if @stream.instance_variable_defined?(:@iterator)
27
27
  iterator = @stream.instance_variable_get(:@iterator)
28
28
  if iterator.respond_to?(:each)
29
- @stream.instance_variable_set(:@iterator, Enumerator.new do |yielder|
30
- each_from(iterator) { |event| yielder << event }
31
- end)
29
+ @stream.instance_variable_set(:@iterator,
30
+ Enumerator.new do |yielder|
31
+ each_from(iterator) { |event| yielder << event }
32
+ end)
32
33
  iterator_wrapped = true
33
34
  end
34
35
  end
35
- wrap_each if !iterator_wrapped && @stream.respond_to?(:each)
36
+ each_wrapped = false
37
+ if !iterator_wrapped && @stream.respond_to?(:each)
38
+ wrap_each
39
+ each_wrapped = true
40
+ end
41
+ unless iterator_wrapped || each_wrapped
42
+ Logging.warn(
43
+ "stream integration found no wrappable iterator on #{@stream.class} " \
44
+ "(missing both `@iterator` ivar and `#each`); usage will not be captured"
45
+ )
46
+ end
36
47
 
37
48
  register_orphan_finalizer
38
49
  @stream
@@ -75,35 +86,13 @@ module LlmCostTracker
75
86
 
76
87
  def capture(event)
77
88
  raw_payload = event.try(:deep_to_h) || event.try(:to_h) || {}
78
- payload = normalize(raw_payload)
89
+ payload = SdkPayload.normalize(raw_payload)
79
90
  type = event.try(:type) || payload["type"]
80
91
  @collector.event(payload, type: type&.to_s)
81
92
  rescue StandardError => e
82
93
  warn_capture_failure(e)
83
94
  end
84
95
 
85
- def normalize(value)
86
- case value
87
- when Hash
88
- value.each_with_object({}) do |(key, nested), normalized|
89
- normalized[key.to_s] = normalize(nested)
90
- end
91
- when Array
92
- value.map { |nested| normalize(nested) }
93
- when Symbol
94
- value.to_s
95
- when NilClass
96
- nil
97
- else
98
- converted = begin
99
- value.try(:deep_to_h) || value.try(:to_h)
100
- rescue StandardError
101
- nil
102
- end
103
- converted ? normalize(converted) : value.deep_dup
104
- end
105
- end
106
-
107
96
  def warn_capture_failure(error)
108
97
  should_warn = @mutex.synchronize do
109
98
  next false if @capture_failed
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check"
4
+ require_relative "ingestion"
5
+
6
+ module LlmCostTracker
7
+ class CaptureVerifier
8
+ class << self
9
+ def call
10
+ new.checks
11
+ end
12
+
13
+ def report(checks = call)
14
+ (["LLM Cost Tracker capture verification"] + checks.map do |check|
15
+ "[#{check.status}] #{check.name}: #{check.message}"
16
+ end).join("\n")
17
+ end
18
+
19
+ def healthy?(checks = call)
20
+ checks.none? { |check| check.status == :error }
21
+ end
22
+ end
23
+
24
+ def checks
25
+ [
26
+ enabled_check,
27
+ *integration_checks,
28
+ *storage_checks
29
+ ].compact
30
+ end
31
+
32
+ private
33
+
34
+ def enabled_check
35
+ return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
36
+
37
+ Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
38
+ end
39
+
40
+ def integration_checks
41
+ enabled = LlmCostTracker.configuration.instrumented_integrations
42
+ if enabled.empty?
43
+ return [
44
+ Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
45
+ ]
46
+ end
47
+
48
+ LlmCostTracker::Integrations.checks.map do |check|
49
+ check.with(name: "sdk integration #{check.name}")
50
+ end
51
+ end
52
+
53
+ def storage_checks
54
+ LlmCostTracker::Ingestion.verify
55
+ rescue LlmCostTracker::Error => e
56
+ [Check.new(:error, "storage", e.message)]
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module LlmCostTracker
6
+ module Charges
7
+ Cost = Data.define(:components, :total, :currency) do
8
+ def self.from_h(attributes)
9
+ components = attributes.key?(:components) ? attributes[:components] : attributes.except(:total_cost, :currency)
10
+ total = attributes.fetch(:total) { attributes[:total_cost] }
11
+ new(
12
+ components: components.transform_values { |value| BigDecimal(value.to_s) }.freeze,
13
+ total: total && BigDecimal(total.to_s),
14
+ currency: attributes[:currency]
15
+ )
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ components: components.transform_values { |value| value.to_s("F") },
21
+ total: total&.to_s("F"),
22
+ currency: currency
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,19 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "components"
3
+ require_relative "../usage/source"
4
4
 
5
5
  module LlmCostTracker
6
- module Billing
6
+ module Charges
7
7
  module CostStatus
8
8
  COMPLETE = "complete"
9
9
  FREE = "free"
10
10
  PARTIAL = "partial"
11
11
  UNKNOWN = "unknown"
12
+ INCOMPLETE = [UNKNOWN, PARTIAL].freeze
13
+
14
+ def self.unknown_pricing_sql(total_cost: "total_cost", cost_status: "cost_status")
15
+ statuses = INCOMPLETE.map { |status| ActiveRecord::Base.connection.quote(status) }.join(", ")
16
+ "#{total_cost} IS NULL OR #{cost_status} IN (#{statuses})"
17
+ end
12
18
 
13
19
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
14
- def self.call(token_usage:, usage_source:, token_cost:, service_line_items:, total_cost:,
20
+ def self.call(token_usage:,
21
+ usage_source:,
22
+ token_cost:,
23
+ service_line_items:,
24
+ total_cost:,
15
25
  token_pricing_partial: false)
16
- return UNKNOWN if usage_source == :unknown
26
+ return UNKNOWN if usage_source == Usage::Source::UNKNOWN
17
27
 
18
28
  token_billable = token_usage.priced_quantities.any? { |_key, quantity| quantity.positive? }
19
29
  service_billable = false