llm_cost_tracker 0.14.1 → 0.14.2

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +16 -5
  4. data/app/assets/llm_cost_tracker/application.css +16 -0
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  7. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +4 -25
  9. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  10. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  11. data/app/models/llm_cost_tracker/call.rb +10 -14
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -9
  14. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  16. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  17. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  18. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  19. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  21. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  22. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  23. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  26. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  27. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  28. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -5
  29. data/lib/llm_cost_tracker/budget/per_tag.rb +15 -9
  30. data/lib/llm_cost_tracker/budget.rb +44 -55
  31. data/lib/llm_cost_tracker/capture/event_window.rb +3 -2
  32. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  33. data/lib/llm_cost_tracker/capture/stream_collector.rb +25 -26
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +9 -23
  35. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  36. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  37. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  38. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  39. data/lib/llm_cost_tracker/configuration.rb +5 -9
  40. data/lib/llm_cost_tracker/doctor/price_check.rb +13 -3
  41. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  42. data/lib/llm_cost_tracker/doctor.rb +19 -2
  43. data/lib/llm_cost_tracker/engine.rb +0 -1
  44. data/lib/llm_cost_tracker/event.rb +8 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  49. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +11 -9
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  62. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  63. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  64. data/lib/llm_cost_tracker/integrations/anthropic.rb +63 -10
  65. data/lib/llm_cost_tracker/integrations/base.rb +39 -18
  66. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  67. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  68. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  69. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +241 -76
  70. data/lib/llm_cost_tracker/integrations.rb +1 -20
  71. data/lib/llm_cost_tracker/ledger/isolation.rb +0 -7
  72. data/lib/llm_cost_tracker/ledger/period/totals.rb +14 -10
  73. data/lib/llm_cost_tracker/ledger/rollups.rb +14 -14
  74. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  75. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  76. data/lib/llm_cost_tracker/ledger/store.rb +2 -2
  77. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  78. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  79. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  80. data/lib/llm_cost_tracker/middleware/faraday.rb +36 -19
  81. data/lib/llm_cost_tracker/parsers.rb +7 -40
  82. data/lib/llm_cost_tracker/prices.json +2237 -154
  83. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  84. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  85. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  86. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  87. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  88. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  89. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  90. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  91. data/lib/llm_cost_tracker/pricing/registry.rb +5 -16
  92. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  93. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  94. data/lib/llm_cost_tracker/pricing/sync.rb +22 -71
  95. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  96. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  97. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  98. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -7
  99. data/lib/llm_cost_tracker/providers/gemini/parser.rb +172 -48
  100. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  101. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  102. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  103. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +96 -26
  104. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  105. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -1
  106. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +3 -1
  107. data/lib/llm_cost_tracker/report/data.rb +1 -1
  108. data/lib/llm_cost_tracker/retention.rb +1 -3
  109. data/lib/llm_cost_tracker/tracker.rb +17 -9
  110. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  111. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  112. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  113. data/lib/llm_cost_tracker/version.rb +1 -1
  114. data/lib/tasks/llm_cost_tracker.rake +24 -21
  115. data/llm_cost_tracker.gemspec +63 -0
  116. metadata +8 -7
@@ -4,7 +4,6 @@ module LlmCostTracker
4
4
  module Dashboard
5
5
  class TopModels
6
6
  DEFAULT_LIMIT = 5
7
- SORT_OPTIONS = %w[cost calls avg_cost latency tokens provider name].freeze
8
7
  DEFAULT_SORT = "cost"
9
8
  DEFAULT_DIRECTIONS = {
10
9
  "provider" => "asc",
@@ -43,7 +42,7 @@ module LlmCostTracker
43
42
  def initialize(scope:, limit:, sort: DEFAULT_SORT, direction: nil)
44
43
  @scope = scope
45
44
  @limit = limit
46
- @sort = SORT_OPTIONS.include?(sort.to_s) ? sort.to_s : DEFAULT_SORT
45
+ @sort = DEFAULT_DIRECTIONS.key?(sort.to_s) ? sort.to_s : DEFAULT_SORT
47
46
  @direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
48
47
  end
49
48
 
@@ -5,13 +5,10 @@
5
5
  value = @call[token_key]
6
6
  { label: component.fetch(:label), value: value, formatted_value: number_with_delimiter(value), css_class: component.fetch(:css_class) }
7
7
  end %>
8
- <% cost_segments = [] %>
9
- <% unless @call.total_cost.nil? %>
10
- <% cost_segments = priced_components.map do |component|
11
- value = line_item_costs_by_component[component.fetch(:price_key)]
12
- { label: component.fetch(:label), value: value, formatted_value: optional_money(value), css_class: component.fetch(:css_class) }
13
- end %>
14
- <% end %>
8
+ <% cost_segments = priced_components.map do |component|
9
+ value = line_item_costs_by_component[component.fetch(:price_key)]
10
+ { label: component.fetch(:label), value: value, formatted_value: optional_money(value), css_class: component.fetch(:css_class) }
11
+ end %>
15
12
  <% service_line_items = @call.line_items.reject { |li| li.unit == "token" }.sort_by(&:position) %>
16
13
 
17
14
  <p class="lct-breadcrumb-back"><%= link_to "← All calls", calls_path %></p>
@@ -84,22 +81,11 @@ end %>
84
81
  <dd><%= content_tag(:code, @call.provider_response_id, class: "lct-code-id") %></dd>
85
82
  </div>
86
83
  <% end %>
87
- <% if @call.provider_project_id.present? %>
88
- <div class="lct-meta-strip-item">
89
- <dt>Project ID</dt>
90
- <dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(:provider_project_id, @call.provider_project_id), class: "lct-code-id") %></dd>
91
- </div>
92
- <% end %>
93
- <% if @call.provider_api_key_id.present? %>
94
- <div class="lct-meta-strip-item">
95
- <dt>API Key ID</dt>
96
- <dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(:provider_api_key_id, @call.provider_api_key_id), class: "lct-code-id") %></dd>
97
- </div>
98
- <% end %>
99
- <% if @call.provider_workspace_id.present? %>
84
+ <% { provider_project_id: "Project ID", provider_api_key_id: "API Key ID", provider_workspace_id: "Workspace ID" }.each do |attribute, label| %>
85
+ <% next if @call[attribute].blank? %>
100
86
  <div class="lct-meta-strip-item">
101
- <dt>Workspace ID</dt>
102
- <dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(:provider_workspace_id, @call.provider_workspace_id), class: "lct-code-id") %></dd>
87
+ <dt><%= label %></dt>
88
+ <dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(attribute, @call[attribute]), class: "lct-code-id") %></dd>
103
89
  </div>
104
90
  <% end %>
105
91
  </dl>
@@ -271,7 +271,7 @@
271
271
  <h2 class="lct-panel-title">Incomplete pricing by model</h2>
272
272
  <span class="lct-panel-meta"><%= link_to "View calls →", calls_path(current_query(cost_status: "incomplete", sort: nil)) %></span>
273
273
  </div>
274
- <p class="lct-panel-intro">These models have line items without configured rates, so totals undercount (a full row is missing all rates; a partial row has some line items priced and others not). After the next price refresh or a <code>pricing_overrides</code> update, run <code>bin/rails llm_cost_tracker:backfill_unknown_pricing</code> to recompute these calls.</p>
274
+ <p class="lct-panel-intro">These models have line items without configured rates, so totals undercount (a full row is missing all rates; a partial row has some line items priced and others not). After the next price refresh or a <code>pricing_overrides</code> update, run <code>bin/rails llm_cost_tracker:backfill_unknown_pricing</code> to recompute these calls. A partial call is left as recorded when a rate it already used has changed since.</p>
275
275
 
276
276
  <div class="lct-table-wrap">
277
277
  <table class="lct-tbl">
@@ -1,5 +1,3 @@
1
- <% variant_class = local_assigns[:variant] == "budget" ? " lct-budget-fill" : "" %>
2
-
3
1
  <div class="lct-bar-track" aria-hidden="true">
4
- <div data-lct-style="<%= inline_style("width: #{bar_width(value, max)}") %>" class="lct-bar-fill<%= variant_class %>"></div>
2
+ <div data-lct-style="<%= inline_style("width: #{bar_width(value, max)}") %>" class="lct-bar-fill"></div>
5
3
  </div>
@@ -1,5 +1,3 @@
1
- <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
- <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
1
  <details class="lct-filter-pop" name="lct-filter">
4
2
  <summary class="lct-filter-pill">
5
3
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
@@ -8,8 +6,7 @@
8
6
  <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>
9
7
  </summary>
10
8
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
11
- <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
12
- <%= hidden_query_fields(current_query.except(:from, :to, :page, :per, *extra_except)) %>
9
+ <%= hidden_query_fields(current_query.except(:from, :to, :page, :per)) %>
13
10
  <div class="lct-filter-pop-field"><label for="lct-filter-from">From</label><input type="date" name="from" id="lct-filter-from" value="<%= @from_date.iso8601 %>"></div>
14
11
  <div class="lct-filter-pop-field"><label for="lct-filter-to">To</label><input type="date" name="to" id="lct-filter-to" value="<%= @to_date.iso8601 %>"></div>
15
12
  <button type="submit" class="lct-button lct-button-primary">Apply</button>
@@ -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,5 +1,3 @@
1
- <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
- <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
1
  <% active = params[:stream].presence %>
4
2
  <% display_value = case active when "yes" then "Streaming" when "no" then "Non-streaming" else "All" end %>
5
3
  <details class="lct-filter-pop" name="lct-filter">
@@ -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" %>
@@ -5,9 +5,9 @@
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
 
@@ -30,7 +30,7 @@ module LlmCostTracker
30
30
  end
31
31
 
32
32
  def blocking?
33
- active? && configured.each_value.any? { |entry| behavior_for(entry) == :block_requests }
33
+ configured.each_value.any? { |entry| behavior_for(entry) == :block_requests } && active?
34
34
  end
35
35
 
36
36
  def rules_for(tags, blocking_only: false)
@@ -38,13 +38,13 @@ module LlmCostTracker
38
38
 
39
39
  normalized = (tags || {}).to_h.transform_keys(&:to_s)
40
40
  configured.filter_map do |key, entry|
41
- raw = normalized[key]
42
- next if raw.nil?
41
+ value = Ledger::Tags::Encoding.encode(normalized[key])
42
+ next if value.empty?
43
43
  next if blocking_only && behavior_for(entry) != :block_requests
44
44
 
45
45
  Rule.new(
46
46
  key: key,
47
- value: Ledger::Tags::Encoding.encode(raw),
47
+ value: value,
48
48
  windows: entry[:windows],
49
49
  behavior: behavior_for(entry),
50
50
  on_exceeded: on_exceeded_for(entry)
@@ -53,21 +53,27 @@ module LlmCostTracker
53
53
  end
54
54
 
55
55
  def rules_for_events(events)
56
- events.each_with_object({}) do |event, grouped|
56
+ events.select(&:total_cost).each_with_object({}) do |event, grouped|
57
57
  rules_for(event.tags).each { |rule| (grouped[rule] ||= []) << event }
58
58
  end
59
59
  end
60
60
 
61
61
  def spend(key, value, window, time:)
62
- spend_by_value(key, [value], window, window_start(window, time)).fetch(value, 0).to_d
62
+ spend_by_value(key, [value], window, window_start(window, time)).fetch(value, [0]).first.to_d
63
63
  end
64
64
 
65
- def spend_by_value(key, values, window, bucket)
65
+ def spend_by_value(key, values, window, bucket, upto = nil)
66
66
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ range = window_range(window, bucket)
68
+ upto_sum = LlmCostTracker::CallTag.sanitize_sql_array(
69
+ ["SUM(CASE WHEN #{TIME_COLUMN} <= ? THEN #{COST_COLUMN} ELSE 0 END)", upto || range.end]
70
+ )
67
71
  totals = Ledger::Isolation.guard(LlmCostTracker::CallTag) do
68
72
  LlmCostTracker::CallTag
69
- .where(key: key, value: values, TIME_COLUMN => window_range(window, bucket))
70
- .group(:value).sum(COST_COLUMN)
73
+ .where(key: key, value: values, TIME_COLUMN => range)
74
+ .group(:value)
75
+ .pluck(:value, Arel.sql("SUM(#{COST_COLUMN})"), Arel.sql(upto_sum))
76
+ .to_h { |value, *sums| [value, sums.map(&:to_d)] }
71
77
  end
72
78
  warn_slow_read(key, window, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
73
79
  totals
@@ -37,25 +37,40 @@ module LlmCostTracker
37
37
  config = LlmCostTracker.configuration
38
38
  return unless event.total_cost
39
39
 
40
- check_per_call_budget(event, config, behavior_override)
40
+ errors = [check_per_call_budget(event, config, behavior_override)]
41
41
  check_windowed({ daily: config.budgets.daily, monthly: config.budgets.monthly }.compact,
42
42
  time: event.tracked_at) do |budget_type, total, budget|
43
- handle_exceeded(budget_type: budget_type,
44
- total: total,
45
- budget: budget,
46
- previous_total: total - event.total_cost,
47
- last_event: event,
48
- behavior: behavior_override)
43
+ errors << handle_exceeded(budget_type: budget_type,
44
+ total: total,
45
+ budget: budget,
46
+ previous_total: total - event.total_cost,
47
+ last_event: event,
48
+ behavior: behavior_override)
49
49
  end
50
+ errors.concat(persisted_errors([event], behavior_override)) unless Ingestion.async?
51
+ raise_first(errors)
50
52
  end
51
53
 
52
54
  def check_persisted!(events, behavior_override: nil)
53
- by_rule = PerTag.rules_for_events(events.select(&:total_cost))
55
+ raise_first(persisted_errors(events, behavior_override))
56
+ end
57
+
58
+ def notify_persisted_safely!(events)
59
+ check_persisted!(events, behavior_override: :notify)
60
+ rescue StandardError => e
61
+ Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
62
+ end
63
+
64
+ private
65
+
66
+ def persisted_errors(events, behavior_override)
67
+ by_rule = PerTag.rules_for_events(events)
54
68
  by_rule = by_rule.reject { |rule, _| rule.on_exceeded.nil? } if behavior_override == :notify
55
- window_buckets(by_rule).each do |(key, window, bucket), scored|
56
- totals = PerTag.spend_by_value(key, scored.keys.map(&:value), window, bucket)
57
- scored.each do |rule, recorded|
58
- total = totals.fetch(rule.value, 0).to_d
69
+ window_buckets(by_rule).flat_map do |(key, window, bucket), scored|
70
+ upto = scored.values.flatten.map(&:tracked_at).max unless Ingestion.async?
71
+ totals = PerTag.spend_by_value(key, scored.keys.map(&:value), window, bucket, upto)
72
+ scored.filter_map do |rule, recorded|
73
+ total, upto_total = totals.fetch(rule.value, [0, 0])
59
74
  limit = rule.windows.fetch(window)
60
75
  next if total < limit
61
76
 
@@ -63,7 +78,7 @@ module LlmCostTracker
63
78
  budget_type: window,
64
79
  total: total,
65
80
  budget: limit,
66
- previous_total: total - recorded.sum(&:total_cost),
81
+ previous_total: (upto_total >= limit ? upto_total : total) - recorded.sum(&:total_cost),
67
82
  last_event: recorded.last,
68
83
  scope: scope_for(rule),
69
84
  behavior: behavior_override || rule.behavior,
@@ -73,14 +88,11 @@ module LlmCostTracker
73
88
  end
74
89
  end
75
90
 
76
- def notify_persisted_safely!(events)
77
- check_persisted!(events, behavior_override: :notify)
78
- rescue StandardError => e
79
- Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
91
+ def raise_first(errors)
92
+ error = errors.compact.first
93
+ raise error if error
80
94
  end
81
95
 
82
- private
83
-
84
96
  def window_buckets(by_rule)
85
97
  by_rule.each_with_object({}) do |(rule, events), grouped|
86
98
  rule.windows.each_key do |window|
@@ -98,14 +110,6 @@ module LlmCostTracker
98
110
  Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
99
111
  end
100
112
 
101
- def raise_per_call_pre_send(estimate, budget)
102
- return unless estimate >= budget
103
-
104
- raise BudgetExceededError.new(**budget_payload(
105
- budget_type: :per_call, total: estimate, budget: budget, last_event: nil, stage: :pre_send
106
- ))
107
- end
108
-
109
113
  def check_per_call_budget(event, config, behavior_override)
110
114
  budget = config.budgets.per_call
111
115
  return unless budget
@@ -122,7 +126,10 @@ module LlmCostTracker
122
126
  end
123
127
 
124
128
  def enforce_globally(config, estimate:, time:)
125
- raise_per_call_pre_send(estimate, config.budgets.per_call) if config.budgets.per_call && estimate.positive?
129
+ per_call = config.budgets.per_call
130
+ if per_call && estimate.positive? && estimate >= per_call
131
+ raise_pre_send(budget_type: :per_call, total: estimate, budget: per_call)
132
+ end
126
133
 
127
134
  check_windowed({ monthly: config.budgets.monthly, daily: config.budgets.daily }.compact,
128
135
  time: time,
@@ -132,9 +139,9 @@ module LlmCostTracker
132
139
  end
133
140
 
134
141
  def raise_pre_send(budget_type:, total:, budget:, scope: nil)
135
- raise BudgetExceededError.new(**budget_payload(
136
- budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send, scope: scope
137
- ))
142
+ raise BudgetExceededError.new(
143
+ budget_type: budget_type, total: total, budget: budget, stage: :pre_send, scope: scope
144
+ )
138
145
  end
139
146
 
140
147
  def check_per_tag(tags, time:, estimate: BigDecimal("0"), blocking_only: false)
@@ -153,21 +160,14 @@ module LlmCostTracker
153
160
  def check_windowed(budgets, time:, estimate: BigDecimal("0"))
154
161
  return if budgets.empty?
155
162
 
156
- totals = totals_for(budgets.keys, time: time)
163
+ periods = budgets.keys.map { |type| BUDGET_TYPE_TO_PERIOD.fetch(type) }
164
+ totals = LlmCostTracker::Ledger::Period::Totals.call(periods, time: time)
157
165
  budgets.each do |budget_type, budget|
158
- total = totals.fetch(budget_type) + estimate
166
+ total = totals.fetch(BUDGET_TYPE_TO_PERIOD.fetch(budget_type)) + estimate
159
167
  yield(budget_type, total, budget) if total >= budget
160
168
  end
161
169
  end
162
170
 
163
- def totals_for(budget_types, time:)
164
- return {} if budget_types.empty?
165
-
166
- period_for = budget_types.to_h { |type| [type, BUDGET_TYPE_TO_PERIOD.fetch(type)] }
167
- period_totals = LlmCostTracker::Ledger::Period::Totals.call(period_for.values, time: time)
168
- period_for.transform_values { |period| period_totals.fetch(period) }
169
- end
170
-
171
171
  def handle_exceeded(budget_type:,
172
172
  total:,
173
173
  budget:,
@@ -179,28 +179,17 @@ module LlmCostTracker
179
179
  config = LlmCostTracker.configuration
180
180
  behavior ||= config.budgets.exceeded_behavior
181
181
  on_exceeded ||= config.budgets.on_exceeded
182
- payload = budget_payload(
182
+ payload = {
183
183
  budget_type: budget_type,
184
184
  total: total,
185
185
  budget: budget,
186
186
  last_event: last_event,
187
187
  stage: :post_spend,
188
188
  scope: scope
189
- )
189
+ }
190
190
 
191
191
  on_exceeded.call(payload) if on_exceeded && (previous_total.nil? || previous_total < budget)
192
- raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
193
- end
194
-
195
- def budget_payload(budget_type:, total:, budget:, last_event:, stage:, scope: nil)
196
- {
197
- budget_type: budget_type,
198
- total: total,
199
- budget: budget,
200
- last_event: last_event,
201
- stage: stage,
202
- scope: scope
203
- }
192
+ BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
204
193
  end
205
194
  end
206
195
  end
@@ -8,7 +8,7 @@ module LlmCostTracker
8
8
  HEAD_EVENTS = 16
9
9
  TAIL_EVENTS = 32
10
10
  HEAVY_STRING_BYTES = 8 * 1024
11
- IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
11
+ IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64 snapshot logprobs].freeze
12
12
 
13
13
  def initialize(notable: nil)
14
14
  @notable = notable
@@ -20,7 +20,8 @@ module LlmCostTracker
20
20
  end
21
21
 
22
22
  def push(data, type: nil)
23
- return if @overflowed
23
+ # The openai gem's chat stream helper resends all logprobs so far in logprobs.* events; none carry usage.
24
+ return if @overflowed || type&.start_with?("logprobs.")
24
25
 
25
26
  event = { event: type, data: strip_heavy_payload(data) }
26
27
  size = approximate_bytesize(event)
@@ -3,6 +3,8 @@
3
3
  require "active_support/core_ext/object/deep_dup"
4
4
  require "active_support/core_ext/object/try"
5
5
 
6
+ require_relative "event_window"
7
+
6
8
  module LlmCostTracker
7
9
  module Capture
8
10
  module SdkPayload
@@ -11,7 +13,9 @@ module LlmCostTracker
11
13
  def normalize(value)
12
14
  case value
13
15
  when Hash
14
- value.each_with_object({}) { |(key, nested), out| out[key.to_s] = normalize(nested) }
16
+ value.each_with_object({}) do |(key, nested), out|
17
+ out[key.to_s] = normalize(nested) unless EventWindow::IGNORED_PAYLOAD_KEYS.include?(key.to_s)
18
+ end
15
19
  when Array
16
20
  value.map { |nested| normalize(nested) }
17
21
  when Symbol
@@ -5,6 +5,7 @@ require "active_support/core_ext/object/deep_dup"
5
5
  require "json"
6
6
 
7
7
  require_relative "event_window"
8
+ require_relative "sdk_payload"
8
9
  require_relative "../timing"
9
10
 
10
11
  module LlmCostTracker
@@ -21,7 +22,6 @@ module LlmCostTracker
21
22
  provider_workspace_id: nil,
22
23
  pricing_mode: nil,
23
24
  metadata: {},
24
- context_tags: nil,
25
25
  request: nil)
26
26
  @provider = provider.to_s
27
27
  @model = model
@@ -32,7 +32,7 @@ module LlmCostTracker
32
32
  @provider_workspace_id = provider_workspace_id
33
33
  @pricing_mode = pricing_mode
34
34
  @metadata = (metadata || {}).deep_dup
35
- @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
35
+ @context_tags = LlmCostTracker::Tags::Context.tags.deep_dup
36
36
  @request = request
37
37
  @window = EventWindow.new(notable: Parsers.find_for_provider(@provider)&.method(:retain_stream_event?))
38
38
  @explicit_usage = nil
@@ -57,9 +57,10 @@ module LlmCostTracker
57
57
  end
58
58
 
59
59
  def event(data, type: nil)
60
+ data = SdkPayload.normalize(data) if data.is_a?(Hash)
60
61
  @mutex.synchronize do
61
62
  ensure_open!
62
- @window.push(data, type: type) unless data.nil?
63
+ @window.push(data, type: type&.to_s) unless data.nil?
63
64
  end
64
65
  end
65
66
 
@@ -91,6 +92,8 @@ module LlmCostTracker
91
92
  record_snapshot(snapshot, errored: errored)
92
93
  rescue TransactionAbortedError
93
94
  raise
95
+ rescue ActiveRecord::RecordNotUnique
96
+ nil
94
97
  rescue StandardError => e
95
98
  raise unless errored
96
99
 
@@ -165,20 +168,27 @@ module LlmCostTracker
165
168
  end
166
169
 
167
170
  def build_event(snapshot)
168
- return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
169
- return build_unknown_usage(snapshot) if snapshot[:overflowed]
171
+ return build_unparsed_event(snapshot) if snapshot[:explicit_usage]
170
172
 
171
- event = Parsers.find_for_provider(@provider)&.parse_stream(
172
- response_status: 200,
173
- events: snapshot[:events],
174
- request_body: request_body_for(snapshot[:request])
175
- )
173
+ if snapshot[:overflowed]
174
+ Logging.warn("#{@provider} stream events exceeded #{SSE::LIMIT_BYTES} bytes; " \
175
+ "recording usage_source=#{Usage::Source::UNKNOWN}.")
176
+ return build_unparsed_event(snapshot)
177
+ end
178
+
179
+ request_body = request_body_for(snapshot[:request])
180
+ events = Parsers.all_for_provider(@provider).filter_map do |parser|
181
+ parser.parse_stream(
182
+ response_status: 200, events: snapshot[:events], request_body: request_body, model: snapshot[:model]
183
+ )
184
+ end
185
+ event = events.find { |parsed| parsed.usage_source != Usage::Source::UNKNOWN } || events.first
176
186
  if event
177
187
  model = present_model(event.model) || present_model(snapshot[:model]) || Event::UNKNOWN_MODEL
178
188
  return event.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
179
189
  end
180
190
 
181
- build_unknown_usage(snapshot)
191
+ build_unparsed_event(snapshot)
182
192
  end
183
193
 
184
194
  def request_body_for(request)
@@ -194,25 +204,14 @@ module LlmCostTracker
194
204
  string unless string == Event::UNKNOWN_MODEL
195
205
  end
196
206
 
197
- def build_from_explicit_usage(snapshot)
198
- Event.build(
199
- provider: @provider,
200
- model: snapshot[:model] || Event::UNKNOWN_MODEL,
201
- token_usage: snapshot[:explicit_usage],
202
- stream: true,
203
- usage_source: Usage::Source::MANUAL,
204
- pricing_mode: snapshot[:pricing_mode],
205
- **snapshot.fetch(:capture_dimensions)
206
- )
207
- end
208
-
209
- def build_unknown_usage(snapshot)
207
+ def build_unparsed_event(snapshot)
208
+ explicit_usage = snapshot[:explicit_usage]
210
209
  Event.build(
211
210
  provider: @provider,
212
211
  model: snapshot[:model] || Event::UNKNOWN_MODEL,
213
- token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
212
+ token_usage: explicit_usage || Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
214
213
  stream: true,
215
- usage_source: Usage::Source::UNKNOWN,
214
+ usage_source: explicit_usage ? Usage::Source::MANUAL : Usage::Source::UNKNOWN,
216
215
  pricing_mode: snapshot[:pricing_mode],
217
216
  **snapshot.fetch(:capture_dimensions)
218
217
  )
@@ -1,10 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/object/deep_dup"
4
3
  require "active_support/core_ext/object/try"
5
4
 
6
- require_relative "sdk_payload"
7
-
8
5
  module LlmCostTracker
9
6
  module Capture
10
7
  class StreamTracker
@@ -21,23 +18,15 @@ module LlmCostTracker
21
18
  def wrap
22
19
  return @stream unless @stream
23
20
 
24
- iterator_wrapped = false
25
- if @stream.instance_variable_defined?(:@iterator)
26
- iterator = @stream.instance_variable_get(:@iterator)
27
- if iterator.respond_to?(:each)
28
- @stream.instance_variable_set(:@iterator,
29
- Enumerator.new do |yielder|
30
- each_from(iterator) { |event| yielder << event }
31
- end)
32
- iterator_wrapped = true
33
- end
34
- end
35
- each_wrapped = false
36
- if !iterator_wrapped && @stream.respond_to?(:each)
21
+ iterator = @stream.instance_variable_get(:@iterator)
22
+ if iterator.respond_to?(:each)
23
+ @stream.instance_variable_set(:@iterator,
24
+ Enumerator.new do |yielder|
25
+ each_from(iterator) { |event| yielder << event }
26
+ end)
27
+ elsif @stream.respond_to?(:each)
37
28
  wrap_each
38
- each_wrapped = true
39
- end
40
- unless iterator_wrapped || each_wrapped
29
+ else
41
30
  Logging.warn(
42
31
  "stream integration found no wrappable iterator on #{@stream.class} " \
43
32
  "(missing both `@iterator` ivar and `#each`); usage will not be captured"
@@ -83,10 +72,7 @@ module LlmCostTracker
83
72
  end
84
73
 
85
74
  def capture(event)
86
- raw_payload = event.try(:deep_to_h) || event.try(:to_h) || {}
87
- payload = SdkPayload.normalize(raw_payload)
88
- type = event.try(:type) || payload["type"]
89
- @collector.event(payload, type: type&.to_s)
75
+ @collector.event(event.try(:to_h) || {}, type: event.try(:type)&.to_s)
90
76
  rescue StandardError => e
91
77
  warn_capture_failure(e)
92
78
  end
@@ -25,7 +25,7 @@ module LlmCostTracker
25
25
  [
26
26
  enabled_check,
27
27
  *integration_checks,
28
- *storage_checks
28
+ *LlmCostTracker::Ingestion.verify
29
29
  ].compact
30
30
  end
31
31
 
@@ -49,11 +49,5 @@ module LlmCostTracker
49
49
  check.with(name: "sdk integration #{check.name}")
50
50
  end
51
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
52
  end
59
53
  end