llm_cost_tracker 0.14.1 → 0.15.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
@@ -1,5 +1,3 @@
1
- <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
- <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
1
  <% active = params[:model].presence %>
4
2
  <details class="lct-filter-pop" name="lct-filter">
5
3
  <summary class="lct-filter-pill <%= 'lct-active' if active %>">
@@ -9,8 +7,7 @@
9
7
  <svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
10
8
  </summary>
11
9
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
12
- <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
13
- <%= hidden_query_fields(current_query.except(:model, :page, :per, *extra_except)) %>
10
+ <%= hidden_query_fields(current_query.except(:model, :page, :per)) %>
14
11
  <div class="lct-filter-pop-field">
15
12
  <label for="lct-filter-model">Model</label>
16
13
  <%= select_tag :model, options_for_select(filter_options_for(:model), active), include_blank: "All models", id: "lct-filter-model" %>
@@ -1,5 +1,3 @@
1
- <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
- <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
1
  <% active = params[:provider].presence %>
4
2
  <details class="lct-filter-pop" name="lct-filter">
5
3
  <summary class="lct-filter-pill <%= 'lct-active' if active %>">
@@ -9,8 +7,7 @@
9
7
  <svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
10
8
  </summary>
11
9
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
12
- <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
13
- <%= hidden_query_fields(current_query.except(:provider, :page, :per, *extra_except)) %>
10
+ <%= hidden_query_fields(current_query.except(:provider, :page, :per)) %>
14
11
  <div class="lct-filter-pop-field">
15
12
  <label for="lct-filter-provider">Provider</label>
16
13
  <%= select_tag :provider, options_for_select(filter_options_for(:provider), active), include_blank: "All providers", id: "lct-filter-provider" %>
@@ -1,7 +1,5 @@
1
- <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
- <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
1
  <% active = params[:stream].presence %>
4
- <% display_value = case active when "yes" then "Streaming" when "no" then "Non-streaming" else "All" end %>
2
+ <% display_value = LlmCostTracker::Dashboard::Filter::STREAM_FILTER_LABELS.fetch(active, "All") %>
5
3
  <details class="lct-filter-pop" name="lct-filter">
6
4
  <summary class="lct-filter-pill <%= 'lct-active' if active %>">
7
5
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
@@ -10,8 +8,7 @@
10
8
  <svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
11
9
  </summary>
12
10
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
13
- <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
14
- <%= hidden_query_fields(current_query.except(:stream, :page, :per, *extra_except)) %>
11
+ <%= hidden_query_fields(current_query.except(:stream, :page, :per)) %>
15
12
  <div class="lct-filter-pop-field">
16
13
  <label for="lct-filter-stream">Stream</label>
17
14
  <%= select_tag :stream, options_for_select(LlmCostTracker::Dashboard::Filter::STREAM_FILTER_OPTIONS, active), include_blank: "All calls", id: "lct-filter-stream" %>
@@ -1,13 +1,13 @@
1
1
  <% form_url = tag_path(params[:key]) %>
2
2
 
3
- <% if @value.present? %>
3
+ <% if @breakdown.nil? %>
4
4
  <p class="lct-breadcrumb-back"><%= link_to "← All values for #{params[:key]}", tag_path(params[:key], current_query.except(:tag_value)) %></p>
5
5
  <h2 class="lct-page-title">Tag: <code class="lct-code-id"><%= params[:key] %></code> = <code class="lct-code-id"><%= @value %></code></h2>
6
6
 
7
7
  <div class="lct-filter-row">
8
- <%= render "llm_cost_tracker/shared/filter_pill_date", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
9
- <%= render "llm_cost_tracker/shared/filter_pill_provider", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
10
- <%= render "llm_cost_tracker/shared/filter_pill_model", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
8
+ <%= render "llm_cost_tracker/shared/filter_pill_date", path: form_url %>
9
+ <%= render "llm_cost_tracker/shared/filter_pill_provider", path: form_url %>
10
+ <%= render "llm_cost_tracker/shared/filter_pill_model", path: form_url %>
11
11
 
12
12
  <% if params[:provider].present? || params[:model].present? %>
13
13
  <%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
@@ -35,7 +35,7 @@
35
35
  </div>
36
36
  <div class="lct-stat">
37
37
  <div class="lct-stat-head"><p class="lct-stat-label">Avg cost / call</p></div>
38
- <p class="lct-stat-value"><%= money(@value_calls.positive? ? @value_total_cost / @value_calls : 0) %></p>
38
+ <p class="lct-stat-value"><%= money(@value_total_cost / @value_calls) %></p>
39
39
  </div>
40
40
  </div>
41
41
 
@@ -44,7 +44,7 @@
44
44
  <h2 class="lct-panel-title">Spend over time</h2>
45
45
  <span class="lct-panel-meta"><%= link_to "View calls →", calls_path(calls_query_for_tag(key: @key, value: @value)) %></span>
46
46
  </div>
47
- <%= render "llm_cost_tracker/shared/spend_chart", series: @value_points, comparison_series: [] %>
47
+ <%= render "llm_cost_tracker/shared/spend_chart", series: @value_points %>
48
48
  </section>
49
49
  <% end %>
50
50
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "../pricing/estimator"
6
+
7
+ module LlmCostTracker
8
+ module Budget
9
+ Estimate = Data.define(:total, :largest) do
10
+ def self.for(provider:, model:, request:)
11
+ costs = request_costs(provider, model, request)
12
+ total = costs.sum(BigDecimal("0"))
13
+ new(total: total, largest: costs.max || total)
14
+ end
15
+
16
+ def self.request_costs(provider, model, request)
17
+ batch = request["requests"] if request && !model
18
+ return [cost(provider, model, request)] unless batch.is_a?(Array)
19
+
20
+ batch.map do |entry|
21
+ params = entry["params"] if entry.is_a?(Hash)
22
+ params.is_a?(Hash) ? cost(provider, params["model"], params) : 0
23
+ end
24
+ end
25
+
26
+ def self.cost(provider, model, request)
27
+ return BigDecimal("0") unless provider && model && request
28
+
29
+ Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
30
+ end
31
+
32
+ private_class_method :request_costs, :cost
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "limit"
6
+ require_relative "../ledger"
7
+
8
+ module LlmCostTracker
9
+ module Budget
10
+ module Global
11
+ PERIODS = { monthly: :month, daily: :day }.freeze
12
+
13
+ class << self
14
+ def enforce!(estimate, time:)
15
+ budgets = LlmCostTracker.configuration.budgets
16
+ per_call = budgets.per_call
17
+ largest = estimate.largest
18
+ Limit.global(:per_call, per_call).block!(largest) if per_call && largest.positive? && largest >= per_call
19
+
20
+ windows = { monthly: budgets.monthly, daily: budgets.daily }
21
+ each_exceeded(windows, time: time, estimate: estimate.total) { |limit, total| limit.block!(total) }
22
+ end
23
+
24
+ def post_spend_errors(event, behavior_override)
25
+ budgets = LlmCostTracker.configuration.budgets
26
+ errors = [per_call_error(event, budgets.per_call, behavior_override)]
27
+ each_exceeded({ daily: budgets.daily, monthly: budgets.monthly }, time: event.tracked_at) do |limit, total|
28
+ errors << limit.handle_exceeded(total: total,
29
+ previous_total: total - event.total_cost,
30
+ last_event: event,
31
+ behavior_override: behavior_override)
32
+ end
33
+ errors
34
+ end
35
+
36
+ def notify_repriced(changes, time:)
37
+ budgets = LlmCostTracker.configuration.budgets
38
+ each_exceeded({ daily: budgets.daily, monthly: budgets.monthly }, time: time) do |limit, total|
39
+ limit.handle_exceeded(total: total,
40
+ previous_total: total - limit.amount_in_window(changes, time),
41
+ behavior_override: :notify)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def per_call_error(event, per_call, behavior_override)
48
+ total = event.total_cost
49
+ return unless per_call && total >= per_call
50
+
51
+ Limit.global(:per_call, per_call).handle_exceeded(
52
+ total: total, previous_total: nil, last_event: event, behavior_override: behavior_override
53
+ )
54
+ end
55
+
56
+ def each_exceeded(windows, time:, estimate: BigDecimal("0"))
57
+ windows = windows.compact
58
+ return if windows.empty?
59
+
60
+ totals = Ledger::Period::Totals.call(windows.keys.map { |type| PERIODS.fetch(type) }, time: time)
61
+ windows.each do |type, budget|
62
+ total = totals.fetch(PERIODS.fetch(type)) + estimate
63
+ yield Limit.global(type, budget), total if total >= budget
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module LlmCostTracker
6
+ module Budget
7
+ Limit = Data.define(:budget_type, :budget, :scope, :behavior, :on_exceeded) do
8
+ def self.global(budget_type, budget)
9
+ new(budget_type: budget_type, budget: budget, scope: nil, behavior: nil, on_exceeded: nil)
10
+ end
11
+
12
+ def over?(total)
13
+ budget_type == :calls ? total > budget : total >= budget
14
+ end
15
+
16
+ def charge(cost)
17
+ budget_type == :calls ? 1 : cost
18
+ end
19
+
20
+ def counts?(event)
21
+ budget_type == :calls || !event.total_cost.nil?
22
+ end
23
+
24
+ def amount_in_window(changes, time)
25
+ start = PerTag.window_start(budget_type, time)
26
+ changes.sum(BigDecimal("0")) { |change| start.nil? || change.tracked_at >= start ? change.total_cost : 0 }
27
+ end
28
+
29
+ def block!(total)
30
+ raise BudgetExceededError.new(
31
+ budget_type: budget_type, total: total, budget: budget, stage: :pre_send, scope: scope
32
+ )
33
+ end
34
+
35
+ def handle_exceeded(total:, previous_total:, last_event: nil, behavior_override: nil)
36
+ budgets = LlmCostTracker.configuration.budgets
37
+ raising = %i[raise block_requests].include?(behavior_override || behavior || budgets.exceeded_behavior)
38
+ callback = on_exceeded || budgets.on_exceeded
39
+ payload = {
40
+ budget_type: budget_type,
41
+ total: total,
42
+ budget: budget,
43
+ last_event: last_event,
44
+ stage: :post_spend,
45
+ scope: scope
46
+ }
47
+ callback.call(payload) if callback && (previous_total.nil? || !over?(previous_total))
48
+ BudgetExceededError.new(**payload) if raising
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "limit"
4
+ require_relative "../timing"
3
5
  require_relative "../ledger/isolation"
4
6
  require_relative "../ledger/schema/adapter"
5
7
  require_relative "../ledger/tags/encoding"
@@ -14,7 +16,15 @@ module LlmCostTracker
14
16
  SLOW_READ_SECONDS = 0.1
15
17
  DEFAULT_BACKFILL_BATCH = 5_000
16
18
 
17
- Rule = Data.define(:key, :value, :windows, :behavior, :on_exceeded)
19
+ Rule = Data.define(:key, :value, :windows, :behavior, :on_exceeded) do
20
+ def limit(window)
21
+ Limit.new(budget_type: window,
22
+ budget: windows.fetch(window),
23
+ scope: { key: key, value: value },
24
+ behavior: behavior,
25
+ on_exceeded: on_exceeded)
26
+ end
27
+ end
18
28
 
19
29
  class << self
20
30
  def configured
@@ -30,7 +40,7 @@ module LlmCostTracker
30
40
  end
31
41
 
32
42
  def blocking?
33
- active? && configured.each_value.any? { |entry| behavior_for(entry) == :block_requests }
43
+ configured.each_value.any? { |entry| behavior_for(entry) == :block_requests } && active?
34
44
  end
35
45
 
36
46
  def rules_for(tags, blocking_only: false)
@@ -38,13 +48,13 @@ module LlmCostTracker
38
48
 
39
49
  normalized = (tags || {}).to_h.transform_keys(&:to_s)
40
50
  configured.filter_map do |key, entry|
41
- raw = normalized[key]
42
- next if raw.nil?
51
+ value = Ledger::Tags::Encoding.encode(normalized[key])
52
+ next if value.empty?
43
53
  next if blocking_only && behavior_for(entry) != :block_requests
44
54
 
45
55
  Rule.new(
46
56
  key: key,
47
- value: Ledger::Tags::Encoding.encode(raw),
57
+ value: value,
48
58
  windows: entry[:windows],
49
59
  behavior: behavior_for(entry),
50
60
  on_exceeded: on_exceeded_for(entry)
@@ -59,33 +69,36 @@ module LlmCostTracker
59
69
  end
60
70
 
61
71
  def spend(key, value, window, time:)
62
- spend_by_value(key, [value], window, window_start(window, time)).fetch(value, 0).to_d
72
+ spend_by_value(key, [value], window, window_start(window, time)).fetch(value, [0]).first
63
73
  end
64
74
 
65
- def spend_by_value(key, values, window, bucket)
66
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
75
+ def spend_by_value(key, values, window, bucket, upto = nil)
76
+ started_at = Timing.now_monotonic
77
+ aggregates = sum_columns(window, upto)
67
78
  totals = Ledger::Isolation.guard(LlmCostTracker::CallTag) do
68
- LlmCostTracker::CallTag
69
- .where(key: key, value: values, TIME_COLUMN => window_range(window, bucket))
70
- .group(:value).sum(COST_COLUMN)
79
+ pluck_by_exact_value(spend_rows(key, values, window, bucket), values, aggregates).to_h do |value, *sums|
80
+ [value.dup.force_encoding(Encoding::UTF_8), sums.map { |sum| window == :calls ? sum.to_i : sum.to_d }]
81
+ end
71
82
  end
72
- warn_slow_read(key, window, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
83
+ warn_slow_read(key, window, Timing.now_monotonic - started_at)
73
84
  totals
74
85
  end
75
86
 
76
87
  def window_start(window, time)
77
- time.to_time.utc.public_send(WINDOW_STARTS.fetch(window))
88
+ WINDOW_STARTS[window]&.then { |start| time.to_time.utc.public_send(start) }
78
89
  end
79
90
 
80
91
  def backfill(batch_size: DEFAULT_BACKFILL_BATCH)
81
92
  return 0 unless columns?
82
93
 
83
94
  filled = 0
95
+ after = 0
84
96
  loop do
85
- copied = copy_next_batch(batch_size)
86
- break if copied.zero?
97
+ ids = unfilled_ids(after, batch_size)
98
+ break if ids.empty?
87
99
 
88
- filled += copied
100
+ filled += LlmCostTracker::CallTag.lease_connection.update(copy_sql(ids))
101
+ after = ids.last
89
102
  end
90
103
  filled
91
104
  end
@@ -97,22 +110,42 @@ module LlmCostTracker
97
110
 
98
111
  private
99
112
 
113
+ def sum_columns(window, upto)
114
+ measure = window == :calls ? "1" : COST_COLUMN
115
+ total = "SUM(#{measure})"
116
+ upto_sum = "SUM(CASE WHEN #{TIME_COLUMN} <= ? THEN #{measure} ELSE 0 END)"
117
+ upto_total = upto ? LlmCostTracker::CallTag.sanitize_sql_array([upto_sum, upto]) : total
118
+ [Arel.sql(total), Arel.sql(upto_total)]
119
+ end
120
+
121
+ def spend_rows(key, values, window, bucket)
122
+ rows = LlmCostTracker::CallTag.where(key: key, value: values)
123
+ bucket ? rows.where(TIME_COLUMN => window_range(window, bucket)) : rows.where.not(TIME_COLUMN => nil)
124
+ end
125
+
126
+ def pluck_by_exact_value(rows, values, aggregates)
127
+ column = :value
128
+ if Ledger::Schema::Adapter.mysql?(LlmCostTracker::CallTag.lease_connection)
129
+ column = Arel.sql("CAST(value AS BINARY)")
130
+ rows = rows.where(column.in(values))
131
+ end
132
+ rows.group(column).pluck(column, *aggregates)
133
+ end
134
+
100
135
  def window_range(window, bucket)
101
136
  bucket...bucket.public_send(WINDOW_NEXTS.fetch(window))
102
137
  end
103
138
 
104
- def copy_next_batch(batch_size)
105
- ids = LlmCostTracker::CallTag.where(TIME_COLUMN => nil).limit(batch_size).pluck(:id)
106
- return 0 if ids.empty?
107
-
108
- LlmCostTracker::CallTag.connection.update(copy_sql(ids))
139
+ def unfilled_ids(after, batch_size)
140
+ LlmCostTracker::CallTag.where(TIME_COLUMN => nil).where(id: (after + 1)..)
141
+ .order(:id).limit(batch_size).pluck(:id)
109
142
  end
110
143
 
111
144
  def copy_sql(ids)
112
145
  tags = LlmCostTracker::CallTag.quoted_table_name
113
146
  calls = LlmCostTracker::Call.quoted_table_name
114
147
  list = ids.join(",")
115
- return <<~SQL.squish unless Ledger::Schema::Adapter.postgresql?(LlmCostTracker::CallTag.connection)
148
+ return <<~SQL.squish unless Ledger::Schema::Adapter.postgresql?(LlmCostTracker::CallTag.lease_connection)
116
149
  UPDATE #{tags} t JOIN #{calls} c ON c.id = t.llm_cost_tracker_call_id
117
150
  SET t.#{COST_COLUMN} = c.#{COST_COLUMN}, t.#{TIME_COLUMN} = c.#{TIME_COLUMN}
118
151
  WHERE t.id IN (#{list})
@@ -140,10 +173,15 @@ module LlmCostTracker
140
173
  @slow_keys ||= Set.new
141
174
  return unless @slow_keys.add?(key)
142
175
 
176
+ reason = if WINDOW_STARTS.key?(window)
177
+ "A tag with few distinct values covers most of the ledger, so its budget check cannot use " \
178
+ "an index effectively. Budget high-cardinality tags such as a tenant or user id."
179
+ else
180
+ "A #{window} limit sums every call the value has recorded, so it suits short-lived values " \
181
+ "such as a run."
182
+ end
143
183
  Logging.warn(
144
- "config.budgets.per_tag[#{key.inspect}] #{window} read took #{(seconds * 1000).round} ms. " \
145
- "A tag with few distinct values covers most of the ledger, so its budget check cannot use " \
146
- "an index effectively. Budget high-cardinality tags such as a tenant or user id."
184
+ "config.budgets.per_tag[#{key.inspect}] #{window} read took #{(seconds * 1000).round} ms. #{reason}"
147
185
  )
148
186
  end
149
187
 
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "per_tag"
4
+ require_relative "../tags/context"
5
+
6
+ module LlmCostTracker
7
+ module Budget
8
+ module Tagged
9
+ class << self
10
+ def enforce!(tags, estimated_cost, time:, blocking_only:)
11
+ PerTag.rules_for(tags || Tags::Context.tags, blocking_only: blocking_only).each do |rule|
12
+ rule.windows.each_key do |window|
13
+ limit = rule.limit(window)
14
+ total = PerTag.spend(rule.key, rule.value, window, time: time) + limit.charge(estimated_cost)
15
+ limit.block!(total) if limit.over?(total)
16
+ end
17
+ end
18
+ end
19
+
20
+ def post_spend_errors(events, behavior_override)
21
+ window_buckets(scored_rules(events, behavior_override)).flat_map do |(key, window, bucket), recorded_by_rule|
22
+ upto = recorded_by_rule.values.flatten.map(&:tracked_at).max unless Ingestion.async?
23
+ totals = PerTag.spend_by_value(key, recorded_by_rule.keys.map(&:value), window, bucket, upto)
24
+ recorded_by_rule.filter_map do |rule, recorded|
25
+ total, total_through_batch = totals.fetch(rule.value, [0, 0])
26
+ bucket_error(rule.limit(window), recorded, total, total_through_batch, behavior_override)
27
+ end
28
+ end
29
+ end
30
+
31
+ def notify_repriced(changes, time:)
32
+ PerTag.rules_for_events(changes).each do |rule, repriced|
33
+ rule.windows.except(:calls).each_key do |window|
34
+ limit = rule.limit(window)
35
+ amount = limit.amount_in_window(repriced, time)
36
+ total = PerTag.spend(rule.key, rule.value, window, time: time) if rule.on_exceeded && amount.positive?
37
+ next unless total && limit.over?(total)
38
+
39
+ limit.handle_exceeded(total: total, previous_total: total - amount, behavior_override: :notify)
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def scored_rules(events, behavior_override)
47
+ by_rule = PerTag.rules_for_events(events)
48
+ behavior_override == :notify ? by_rule.reject { |rule, _| rule.on_exceeded.nil? } : by_rule
49
+ end
50
+
51
+ def bucket_error(limit, recorded, total, total_through_batch, behavior_override)
52
+ return unless limit.over?(total)
53
+
54
+ reference = limit.over?(total_through_batch) ? total_through_batch : total
55
+ limit.handle_exceeded(total: total,
56
+ previous_total: reference - recorded.sum { |event| limit.charge(event.total_cost) },
57
+ last_event: recorded.last,
58
+ behavior_override: behavior_override)
59
+ end
60
+
61
+ def window_buckets(by_rule)
62
+ by_rule.each_with_object({}) do |(rule, events), grouped|
63
+ rule.windows.each_key do |window|
64
+ limit = rule.limit(window)
65
+ events.select { |event| limit.counts?(event) }
66
+ .group_by { |event| PerTag.window_start(window, event.tracked_at) }
67
+ .each { |bucket, bucket_events| (grouped[[rule.key, window, bucket]] ||= {})[rule] = bucket_events }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end