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
@@ -58,7 +58,11 @@ module LlmCostTracker
58
58
  def self.from_token_usage(token_usage)
59
59
  return [] unless token_usage
60
60
 
61
- token_usage.priced_quantities.filter_map do |key, quantity|
61
+ from_quantities(token_usage.priced_quantities)
62
+ end
63
+
64
+ def self.from_quantities(quantities)
65
+ quantities.filter_map do |key, quantity|
62
66
  next unless quantity.positive?
63
67
 
64
68
  dimension = Usage::Catalog.fetch(key)
@@ -35,7 +35,7 @@ module LlmCostTracker
35
35
  end
36
36
 
37
37
  def finalize!
38
- @per_tag = deep_freeze(@per_tag || {})
38
+ @per_tag = deep_freeze(@per_tag)
39
39
  end
40
40
 
41
41
  private
@@ -29,7 +29,7 @@ module LlmCostTracker
29
29
  end
30
30
 
31
31
  def finalize!
32
- @overrides = deep_freeze(@overrides || {})
32
+ @overrides = deep_freeze(@overrides)
33
33
  end
34
34
  end
35
35
  end
@@ -18,8 +18,6 @@ module LlmCostTracker
18
18
  budgets: Budgets, capture: Capture, ingestion: Ingestion, pricing: Pricing, tags: Tags
19
19
  }.freeze
20
20
 
21
- SCALAR_ATTRIBUTES = %i[enabled].freeze
22
-
23
21
  DEPRECATED_OPTIONS = {
24
22
  monthly_budget: { to: %i[budgets monthly] },
25
23
  daily_budget: { to: %i[budgets daily] },
@@ -46,7 +44,7 @@ module LlmCostTracker
46
44
  log_level: { to: nil, note: "LlmCostTracker logs through Rails.logger, which owns the level" }
47
45
  }.freeze
48
46
 
49
- attr_reader(*SCALAR_ATTRIBUTES, *SECTIONS.keys, :instrumented_integrations)
47
+ attr_reader(:enabled, *SECTIONS.keys, :instrumented_integrations)
50
48
 
51
49
  def initialize
52
50
  SECTIONS.each { |name, klass| instance_variable_set(:"@#{name}", klass.new(self)) }
@@ -67,11 +65,9 @@ module LlmCostTracker
67
65
  @instrumented_integrations.include?(name)
68
66
  end
69
67
 
70
- SCALAR_ATTRIBUTES.each do |name|
71
- define_method(:"#{name}=") do |value|
72
- ensure_mutable!
73
- instance_variable_set(:"@#{name}", value)
74
- end
68
+ def enabled=(value)
69
+ ensure_mutable!
70
+ @enabled = value
75
71
  end
76
72
 
77
73
  DEPRECATED_OPTIONS.each do |old_name, spec|
@@ -101,7 +97,7 @@ module LlmCostTracker
101
97
 
102
98
  def finalize!
103
99
  SECTIONS.each_key { |name| public_send(name).finalize! }
104
- @instrumented_integrations = deep_freeze(@instrumented_integrations || Set.new)
100
+ @instrumented_integrations = deep_freeze(@instrumented_integrations)
105
101
  @finalized = true
106
102
  end
107
103
 
@@ -20,8 +20,8 @@ module LlmCostTracker
20
20
  updated_at = metadata["updated_at"] || metadata[:updated_at]
21
21
  return configured_check(:warn, path, count, "metadata.updated_at missing; #{REFRESH_COMMAND}") unless updated_at
22
22
 
23
- age_days = (Date.today - Date.iso8601(updated_at.to_s)).to_i
24
- if age_days > STALE_AFTER_DAYS
23
+ file_date = Date.iso8601(updated_at.to_s)
24
+ if (Date.today - file_date).to_i > STALE_AFTER_DAYS
25
25
  return configured_check(
26
26
  :warn,
27
27
  path,
@@ -29,6 +29,15 @@ module LlmCostTracker
29
29
  "updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; #{REFRESH_COMMAND}"
30
30
  )
31
31
  end
32
+ bundled_at = LlmCostTracker::Pricing::Registry.metadata["updated_at"]
33
+ if bundled_at && file_date < Date.iso8601(bundled_at.to_s)
34
+ return configured_check(
35
+ :warn,
36
+ path,
37
+ count,
38
+ "updated_at=#{updated_at} is older than the bundled prices (#{bundled_at}); #{REFRESH_COMMAND}"
39
+ )
40
+ end
32
41
 
33
42
  configured_check(:ok, path, count, "updated_at=#{updated_at}")
34
43
  rescue Date::Error
@@ -49,7 +58,8 @@ module LlmCostTracker
49
58
  Check.new(
50
59
  :warn,
51
60
  "prices",
52
- "using bundled prices updated_at=#{updated_at}; commit a prices_file for production releases"
61
+ "using bundled prices updated_at=#{updated_at}; " \
62
+ "commit a prices_file (config.pricing.file) for production releases"
53
63
  )
54
64
  end
55
65
 
@@ -22,8 +22,7 @@ module LlmCostTracker
22
22
  Check.new(
23
23
  :error,
24
24
  @name,
25
- "current schema required; #{errors.join('; ')}; " \
26
- "run bin/rails generate llm_cost_tracker:install && bin/rails db:migrate"
25
+ "current schema required; #{errors.join('; ')}; see docs/upgrading.md"
27
26
  )
28
27
  end
29
28
  end
@@ -167,7 +167,7 @@ module LlmCostTracker
167
167
  return live_rollups_check unless LlmCostTracker.configuration.budgets.totals_source == :cache
168
168
 
169
169
  errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
170
- return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if errors.empty?
170
+ return rollups_drift_check if errors.empty?
171
171
 
172
172
  Check.new(
173
173
  :error,
@@ -176,13 +176,30 @@ module LlmCostTracker
176
176
  )
177
177
  end
178
178
 
179
+ def rollups_drift_check
180
+ month_start = LlmCostTracker::Ledger::Period.range_start(:month, Time.now)
181
+ today = LlmCostTracker::Ledger::Period.range_start(:day, Time.now)
182
+ rolled = LlmCostTracker::CallRollup.where(period: "day", period_start: month_start.to_date...today.to_date)
183
+ .sum(:total_cost)
184
+ recorded = LlmCostTracker::Call.where(tracked_at: month_start...today).sum(:total_cost)
185
+ return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if rolled == recorded
186
+
187
+ Check.new(
188
+ :warn,
189
+ "call rollups",
190
+ "this month's day rollups before today total #{rolled.to_d.to_s('F')} but the calls total " \
191
+ "#{recorded.to_d.to_s('F')}, and monthly budgets read the rollups; " \
192
+ "run bin/rails llm_cost_tracker:rebuild_rollups"
193
+ )
194
+ end
195
+
179
196
  def live_rollups_check
180
197
  if Probe.table_exists?("llm_cost_tracker_call_rollups")
181
198
  Check.new(
182
199
  :warn,
183
200
  "call rollups",
184
201
  "budgets.totals_source=:ledger but llm_cost_tracker_call_rollups exists. " \
185
- "Set config.budgets.totals_source = :cache to keep budget reads on the rollups fast path or drop the table."
202
+ "Set config.budgets.totals_source = :cache to use it or drop the table."
186
203
  )
187
204
  else
188
205
  Check.new(
@@ -3,7 +3,6 @@
3
3
  require "rails"
4
4
  require_relative "../llm_cost_tracker"
5
5
  require_relative "assets"
6
- require "rack/files"
7
6
 
8
7
  module LlmCostTracker
9
8
  class Engine < ::Rails::Engine
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/digest/uuid"
4
+
3
5
  require_relative "pricing/mode"
4
6
 
5
7
  module LlmCostTracker
@@ -53,6 +55,12 @@ module LlmCostTracker
53
55
  Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
54
56
  end
55
57
 
58
+ def keyed_by_response_id
59
+ return self unless provider_response_id
60
+
61
+ with(event_id: Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, "#{provider}/#{provider_response_id}"))
62
+ end
63
+
56
64
  def self.resolve_line_items(service_items)
57
65
  Array(service_items).map do |item|
58
66
  item.is_a?(Charges::LineItem) ? item : Charges::LineItem.build(item)
@@ -32,12 +32,6 @@ module LlmCostTracker
32
32
  inline. The doctor check warns about unused async ingestion tables.
33
33
  MSG
34
34
  end
35
-
36
- private
37
-
38
- def migration_version
39
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
40
- end
41
35
  end
42
36
  end
43
37
  end
@@ -10,7 +10,7 @@ module LlmCostTracker
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Creates the optional llm_cost_tracker_call_rollups table for fast budget reads. " \
13
+ desc "Creates the optional llm_cost_tracker_call_rollups table of running budget totals. " \
14
14
  "Required when config.budgets.totals_source = :cache."
15
15
 
16
16
  def create_migration_file
@@ -28,16 +28,13 @@ module LlmCostTracker
28
28
  config.budgets.totals_source = :cache
29
29
  end
30
30
 
31
- Without it Tracker keeps reading budget totals as live SUM aggregates over
31
+ Once that is deployed, run bin/rails llm_cost_tracker:rebuild_rollups. Until then
32
+ calls recorded before the switch are missing from monthly budget totals.
33
+
34
+ Without the setting Tracker keeps reading budget totals as live SUM aggregates over
32
35
  llm_cost_tracker_calls. The doctor check warns about an unused rollups table.
33
36
  MSG
34
37
  end
35
-
36
- private
37
-
38
- def migration_version
39
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
40
- end
41
38
  end
42
39
  end
43
40
  end
@@ -53,12 +53,6 @@ module LlmCostTracker
53
53
  unauthenticated would expose spend, tags, and provider IDs to anyone.
54
54
  MSG
55
55
  end
56
-
57
- private
58
-
59
- def migration_version
60
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
61
- end
62
56
  end
63
57
  end
64
58
  end
@@ -1,4 +1,4 @@
1
- class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_version %>
1
+ class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
4
4
  t.string :event_id, null: false
@@ -1,4 +1,4 @@
1
- class CreateLlmCostTrackerCallRollups < ActiveRecord::Migration<%= migration_version %>
1
+ class CreateLlmCostTrackerCallRollups < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  create_table :llm_cost_tracker_call_rollups do |t|
4
4
  t.string :period, null: false
@@ -1,7 +1,7 @@
1
1
  require "llm_cost_tracker/charges/cost_status"
2
2
  require "llm_cost_tracker/ledger/schema/adapter"
3
3
 
4
- class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %>
4
+ class CreateLlmCostTrackerCalls < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
5
5
  def change
6
6
  create_table :llm_cost_tracker_calls do |t|
7
7
  t.string :event_id, null: false
@@ -50,8 +50,9 @@ LlmCostTracker.configure do |config|
50
50
 
51
51
  # One budget applied to every distinct value of a tag: each tenant gets its own
52
52
  # 1000 a month, not 1000 shared between them. Declare as many tags as you need; a rule
53
- # can set its own behavior and callback instead of following the global ones. Needs the
54
- # cost columns from `bin/rails generate llm_cost_tracker:upgrade_per_tag_budgets`.
53
+ # can set its own behavior and callback instead of following the global ones. Installs created
54
+ # before v0.14 add the cost columns with `bin/rails generate llm_cost_tracker:upgrade_per_tag_budgets`
55
+ # and `bin/rails db:migrate`, then run `bin/rails llm_cost_tracker:backfill_tag_costs` so earlier calls count.
55
56
  # Budget high-cardinality tags only — a tag with a handful of values covers most of the
56
57
  # ledger, so its check cannot use an index and every call pays for a near-full scan.
57
58
  # config.budgets.per_tag = {
@@ -63,9 +64,9 @@ LlmCostTracker.configure do |config|
63
64
  # }
64
65
 
65
66
  # Where budget checks read period spend from. :ledger sums llm_cost_tracker_calls on
66
- # every check; :cache also keeps running totals in llm_cost_tracker_call_rollups and
67
- # takes the greater of the two, so it guards against drift but does not make checks
68
- # faster. Generate the table first with `bin/rails generate llm_cost_tracker:call_rollups`.
67
+ # every check; :cache reads earlier days from llm_cost_tracker_call_rollups and sums
68
+ # only today's calls. Run `bin/rails generate llm_cost_tracker:call_rollups`, deploy
69
+ # this setting, then run `bin/rails llm_cost_tracker:rebuild_rollups`.
69
70
  # config.budgets.totals_source = :cache
70
71
 
71
72
  # Ingestion path — :inline (default) writes events synchronously from the request
@@ -74,13 +75,14 @@ LlmCostTracker.configure do |config|
74
75
  # inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
75
76
  # Synchronous inbox writes use a dedicated ActiveRecord pool (defaults to 2 connections)
76
77
  # so they don't compete with request threads for the default pool when a tracked call
77
- # happens inside an open caller transaction. Bump ingestion.pool_size if your Puma
78
- # worker count outgrows that.
78
+ # happens inside an open caller transaction. The pool is per process: bump ingestion.pool_size
79
+ # if concurrent tracked calls in one process (e.g. Puma threads) outgrow that.
79
80
  # config.ingestion.mode = :async
80
81
  # config.ingestion.pool_size = 5
81
82
 
82
- # Register OpenAI-compatible gateway hosts so their calls are attributed to a
83
- # provider name instead of the generic openai_compatible bucket.
83
+ # Register OpenAI-compatible gateway hosts beyond the built-in OpenRouter, DeepSeek and Groq;
84
+ # the Faraday middleware captures only listed hosts. Its calls, and openai gem calls whose
85
+ # base_url is a listed host, are recorded under the provider name given here.
84
86
  # config.capture.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
85
87
 
86
88
  # Streaming calls only report token usage when the request asks for it. Leave this
@@ -1,6 +1,6 @@
1
1
  require "llm_cost_tracker/ledger/schema/adapter"
2
2
 
3
- class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migration_version %>
3
+ class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  disable_ddl_transaction!
5
5
 
6
6
  TABLE = :llm_cost_tracker_call_rollups
@@ -1,6 +1,6 @@
1
1
  require "llm_cost_tracker/ledger/schema/adapter"
2
2
 
3
- class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= migration_version %>
3
+ class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  disable_ddl_transaction!
5
5
 
6
6
  TABLE = :llm_cost_tracker_call_tags
@@ -1,4 +1,4 @@
1
- class UpgradeLlmCostTrackerImageTokens < ActiveRecord::Migration<%= migration_version %>
1
+ class UpgradeLlmCostTrackerImageTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  TABLE = :llm_cost_tracker_calls
3
3
  COLUMNS = %i[image_input_tokens image_output_tokens].freeze
4
4
 
@@ -1,6 +1,6 @@
1
1
  require "llm_cost_tracker/ledger/schema/adapter"
2
2
 
3
- class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration<%= migration_version %>
3
+ class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  disable_ddl_transaction!
5
5
 
6
6
  CALLS = :llm_cost_tracker_calls
@@ -1,6 +1,6 @@
1
1
  require "llm_cost_tracker/ledger/schema/adapter"
2
2
 
3
- class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration<%= migration_version %>
3
+ class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  disable_ddl_transaction!
5
5
 
6
6
  TABLE = :llm_cost_tracker_call_tags
@@ -18,12 +18,6 @@ module LlmCostTracker
18
18
  "db/migrate/upgrade_llm_cost_tracker_call_rollups_provider.rb"
19
19
  )
20
20
  end
21
-
22
- private
23
-
24
- def migration_version
25
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
- end
27
21
  end
28
22
  end
29
23
  end
@@ -19,12 +19,6 @@ module LlmCostTracker
19
19
  "db/migrate/upgrade_llm_cost_tracker_call_tags_key_value_index.rb"
20
20
  )
21
21
  end
22
-
23
- private
24
-
25
- def migration_version
26
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
- end
28
22
  end
29
23
  end
30
24
  end
@@ -18,12 +18,6 @@ module LlmCostTracker
18
18
  "db/migrate/upgrade_llm_cost_tracker_image_tokens.rb"
19
19
  )
20
20
  end
21
-
22
- private
23
-
24
- def migration_version
25
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
- end
27
21
  end
28
22
  end
29
23
  end
@@ -10,8 +10,9 @@ module LlmCostTracker
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Adds a partial index over unpriced calls and drops the unused ingestion inbox lock index" \
14
- "scanning the whole ledger."
13
+ desc "Adds the unpriced-calls index (partial on PostgreSQL, so backfill_unknown_pricing stops scanning " \
14
+ "the whole ledger; a plain id index on MySQL, which has no partial indexes) and drops the unused " \
15
+ "ingestion inbox lock index."
15
16
 
16
17
  def create_migration_file
17
18
  migration_template(
@@ -19,12 +20,6 @@ module LlmCostTracker
19
20
  "db/migrate/upgrade_llm_cost_tracker_indexes.rb"
20
21
  )
21
22
  end
22
-
23
- private
24
-
25
- def migration_version
26
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
- end
28
23
  end
29
24
  end
30
25
  end
@@ -10,8 +10,9 @@ module LlmCostTracker
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Carries each call's cost and time onto its tag rows so per-tag budgets read " \
14
- "llm_cost_tracker_call_tags without a join. Required when config.budgets.per_tag is set."
13
+ desc "Adds cost and time columns to llm_cost_tracker_call_tags so per-tag budgets read it " \
14
+ "without a join; run llm_cost_tracker:backfill_tag_costs afterwards so earlier calls count. " \
15
+ "Required for config.budgets.per_tag on installs created before v0.14."
15
16
 
16
17
  def create_migration_file
17
18
  migration_template(
@@ -19,12 +20,6 @@ module LlmCostTracker
19
20
  "db/migrate/upgrade_llm_cost_tracker_per_tag_budgets.rb"
20
21
  )
21
22
  end
22
-
23
- private
24
-
25
- def migration_version
26
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
- end
28
23
  end
29
24
  end
30
25
  end
@@ -18,7 +18,7 @@ module LlmCostTracker
18
18
  end
19
19
 
20
20
  def event_from_row(row)
21
- payload = JSON.parse(row.payload, symbolize_names: true)
21
+ payload = JSON.parse(row.payload, symbolize_names: true, allow_duplicate_key: true)
22
22
  schema_version = payload[:schema_version]
23
23
  unless schema_version == PAYLOAD_SCHEMA_VERSION
24
24
  raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
@@ -69,13 +69,7 @@ module LlmCostTracker
69
69
  end
70
70
 
71
71
  def payload_for(event)
72
- event.to_h.merge(
73
- schema_version: PAYLOAD_SCHEMA_VERSION,
74
- event_id: event.event_id,
75
- provider: event.provider,
76
- model: event.model,
77
- tracked_at: event.tracked_at.iso8601(6)
78
- )
72
+ event.to_h.merge(schema_version: PAYLOAD_SCHEMA_VERSION, tracked_at: event.tracked_at.iso8601(6))
79
73
  end
80
74
 
81
75
  def insert_row(row)
@@ -28,6 +28,8 @@ module LlmCostTracker
28
28
  end
29
29
 
30
30
  guards_for_current_config.each do |schema_module, table_name|
31
+ next if schema_module == Ledger::Schema::CallRollups && !Ledger::Rollups.cache_active?
32
+
31
33
  errors = schema_module.current_schema_errors
32
34
  next if errors.empty?
33
35
 
@@ -124,7 +126,7 @@ module LlmCostTracker
124
126
 
125
127
  def cleanup_verification_call(response_id)
126
128
  relation = LlmCostTracker::Call.where(provider_response_id: response_id)
127
- records = relation.select(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider).to_a
129
+ records = relation.select(:id, *LlmCostTracker::Ledger::Rollups::SOURCE_COLUMNS).to_a
128
130
  return if records.empty?
129
131
 
130
132
  relation.delete_all
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "../providers/anthropic/usage_extractor"
5
4
  require_relative "../providers/anthropic/response_parser"
6
5
 
7
6
  module LlmCostTracker
@@ -17,7 +16,8 @@ module LlmCostTracker
17
16
  patch_target("Anthropic::Resources::Messages", with: MessagesPatch),
18
17
  patch_target("Anthropic::Resources::Beta::Messages", with: MessagesPatch, optional: true),
19
18
  patch_target("Anthropic::Resources::Messages::Batches", with: BatchesPatch, optional: true),
20
- patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true)
19
+ patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true),
20
+ patch_target("Anthropic::BetaRefusalFallbackMiddleware", with: FallbackMiddlewarePatch, optional: true)
21
21
  ]
22
22
  end
23
23
 
@@ -37,7 +37,8 @@ module LlmCostTracker
37
37
  model: message.model || request[:model],
38
38
  provider_response_id: message.id,
39
39
  usage_source: Usage::Source::SDK_RESPONSE,
40
- request: request
40
+ request: request,
41
+ **response_fields(message)
41
42
  ),
42
43
  latency_ms: latency_ms
43
44
  )
@@ -61,21 +62,52 @@ module LlmCostTracker
61
62
  next unless usage
62
63
  next if usage.input_tokens.nil? && usage.output_tokens.nil?
63
64
 
64
- usage_hash = usage.deep_to_h
65
- LlmCostTracker::Tracker.record(
66
- event: Providers::Anthropic::ResponseParser.event_from_usage(
67
- usage: usage_hash,
65
+ record_once(
66
+ Providers::Anthropic::ResponseParser.event_from_usage(
67
+ usage: usage.deep_to_h.merge(service_tier: "batch"),
68
68
  model: message.model,
69
69
  provider_response_id: message.id,
70
70
  usage_source: Usage::Source::SDK_BATCH_RESULT,
71
- pricing_mode: "batch"
71
+ **response_fields(message)
72
+ )
73
+ )
74
+ end
75
+ end
76
+
77
+ def record_refused_hop(request, response)
78
+ record_safely { record_message(response.parse, request: request.body, latency_ms: nil) }
79
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
80
+ nil
81
+ end
82
+
83
+ def record_refused_stream_hop(hop)
84
+ return unless active?
85
+
86
+ usage = hop.dig(:refused, :usage).to_h.deep_symbolize_keys
87
+ output = usage[:output_tokens].to_i.positive?
88
+ usage = usage.slice(:server_tool_use) if output
89
+ return if output && Providers::Anthropic::UsageExtractor.service_line_items(usage).empty?
90
+
91
+ record_safely do
92
+ LlmCostTracker::Tracker.record(
93
+ event: Providers::Anthropic::ResponseParser.event_from_usage(
94
+ usage: usage,
95
+ model: hop[:model],
96
+ provider_response_id: nil,
97
+ usage_source: Usage::Source::STREAM_FINAL,
98
+ stream: true,
99
+ stop_reason: ("refusal" unless output),
100
+ refusal_category: hop.dig(:refused, :stop_details, "category")
72
101
  )
73
102
  )
74
103
  end
104
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
105
+ nil
75
106
  end
76
107
 
77
- def stream_pricing_mode(request)
78
- Providers::Anthropic::UsageExtractor.pricing_mode(request: request || {}, usage: nil)
108
+ def response_fields(message)
109
+ { stop_reason: message.stop_reason, refusal_category: message.stop_details&.category,
110
+ content: message.deep_to_h[:content] }
79
111
  end
80
112
  end
81
113
 
@@ -109,6 +141,27 @@ module LlmCostTracker
109
141
  end
110
142
  end
111
143
 
144
+ module FallbackMiddlewarePatch
145
+ def call(req, nxt)
146
+ return super if req.streaming? || !LlmCostTracker::Integrations::Anthropic.active?
147
+
148
+ hops = []
149
+ response = super(req, ->(hop_req) { nxt.call(hop_req).tap { |hop| hops << [hop_req, hop] } })
150
+ answered = hops.select { |_hop_req, hop| hop.status < 300 }
151
+ answered[...-1].each { |hop| LlmCostTracker::Integrations::Anthropic.record_refused_hop(*hop) }
152
+ response
153
+ end
154
+
155
+ private
156
+
157
+ def consume_hop(*args, **kwargs)
158
+ refused = Thread.current[:llm_cost_tracker_refused_hop]
159
+ Thread.current[:llm_cost_tracker_refused_hop] = nil
160
+ LlmCostTracker::Integrations::Anthropic.record_refused_stream_hop(refused) if refused && kwargs[:splice]
161
+ super.tap { |hop| Thread.current[:llm_cost_tracker_refused_hop] = hop if hop[:refused] }
162
+ end
163
+ end
164
+
112
165
  module BatchesPatch
113
166
  def results_streaming(*args, **kwargs)
114
167
  raw = super