llm_cost_tracker 0.13.0 → 0.14.1

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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -16
  3. data/README.md +17 -29
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
  5. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  8. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  10. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
  11. data/app/models/llm_cost_tracker/call.rb +10 -4
  12. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  13. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
  14. data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
  15. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  16. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  17. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  18. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  19. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  20. data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
  21. data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
  22. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  23. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  26. data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
  27. data/config/routes.rb +6 -1
  28. data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
  29. data/lib/llm_cost_tracker/budget.rb +120 -32
  30. data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
  31. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  32. data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
  33. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
  35. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  36. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  37. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  38. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  39. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  40. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  41. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  42. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  43. data/lib/llm_cost_tracker/configuration.rb +69 -125
  44. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  45. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  46. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
  47. data/lib/llm_cost_tracker/doctor.rb +4 -4
  48. data/lib/llm_cost_tracker/engine.rb +4 -0
  49. data/lib/llm_cost_tracker/errors.rb +25 -3
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  59. data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
  60. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  61. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  62. data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
  63. data/lib/llm_cost_tracker/integrations/base.rb +17 -1
  64. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  65. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
  66. data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
  67. data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
  68. data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
  69. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  70. data/lib/llm_cost_tracker/ledger/store.rb +21 -13
  71. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  72. data/lib/llm_cost_tracker/ledger.rb +1 -0
  73. data/lib/llm_cost_tracker/logging.rb +5 -5
  74. data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
  75. data/lib/llm_cost_tracker/parsers.rb +5 -1
  76. data/lib/llm_cost_tracker/prices.json +2089 -372
  77. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  78. data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
  79. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  80. data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
  81. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  82. data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
  83. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  84. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  85. data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
  86. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  87. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  88. data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
  89. data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
  90. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  91. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
  92. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
  93. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
  94. data/lib/llm_cost_tracker/railtie.rb +3 -7
  95. data/lib/llm_cost_tracker/redaction.rb +32 -0
  96. data/lib/llm_cost_tracker/report/data.rb +2 -2
  97. data/lib/llm_cost_tracker/retention.rb +22 -10
  98. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  99. data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
  100. data/lib/llm_cost_tracker/tracker.rb +16 -26
  101. data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
  102. data/lib/llm_cost_tracker/version.rb +1 -1
  103. data/lib/llm_cost_tracker.rb +10 -3
  104. data/lib/tasks/llm_cost_tracker.rake +29 -14
  105. metadata +37 -12
@@ -11,20 +11,20 @@ module LlmCostTracker
11
11
  return unless Probe.table_exists?("llm_cost_tracker_calls")
12
12
  return inline_check unless LlmCostTracker::Ingestion.async?
13
13
 
14
- missing = missing_parts
15
- return async_ok if missing.empty?
14
+ problems = missing_parts + drifted_parts
15
+ return async_ok if problems.empty?
16
16
 
17
17
  Check.new(
18
18
  :error,
19
19
  "async ingestion",
20
- "missing #{missing.join(', ')}; see docs/upgrading.md for the recovery steps"
20
+ "#{problems.join('; ')}; see docs/upgrading.md for the recovery steps"
21
21
  )
22
22
  end
23
23
 
24
24
  private
25
25
 
26
26
  def async_ok
27
- Check.new(:ok, "async ingestion", "inbox and ingestion lease tables available")
27
+ Check.new(:ok, "async ingestion", "inbox and ingestion lease tables are on the current schema")
28
28
  end
29
29
 
30
30
  def inline_check
@@ -32,14 +32,14 @@ module LlmCostTracker
32
32
  if leftovers.empty?
33
33
  return Check.new(:ok,
34
34
  "inline ingestion",
35
- "config.ingestion = :inline; events write directly to the ledger")
35
+ "config.ingestion.mode = :inline; events write directly to the ledger")
36
36
  end
37
37
 
38
38
  Check.new(
39
39
  :warn,
40
40
  "inline ingestion",
41
- "config.ingestion = :inline but found unused async ingestion tables: #{leftovers.join(', ')}. " \
42
- "Set config.ingestion = :async to keep the inbox path or drop the tables."
41
+ "config.ingestion.mode = :inline but found unused async ingestion tables: #{leftovers.join(', ')}. " \
42
+ "Set config.ingestion.mode = :async to keep the inbox path or drop the tables."
43
43
  )
44
44
  end
45
45
 
@@ -48,7 +48,16 @@ module LlmCostTracker
48
48
  end
49
49
 
50
50
  def missing_parts
51
- async_tables.reject { |table| Probe.table_exists?(table) }
51
+ async_tables.reject { |table| Probe.table_exists?(table) }.map { |table| "missing #{table}" }
52
+ end
53
+
54
+ def drifted_parts
55
+ LlmCostTracker::Ledger::Schema::ASYNC_SCHEMAS.filter_map do |schema, table|
56
+ next unless Probe.table_exists?(table)
57
+
58
+ errors = schema.current_schema_errors
59
+ "#{table} #{errors.join(', ')}" unless errors.empty?
60
+ end
52
61
  end
53
62
 
54
63
  def async_tables
@@ -11,8 +11,9 @@ module LlmCostTracker
11
11
  REFRESH_COMMAND = "refresh the source-controlled prices file with bin/rails llm_cost_tracker:prices:refresh"
12
12
 
13
13
  def call
14
- path = LlmCostTracker.configuration.prices_file
14
+ path = LlmCostTracker.configuration.pricing.file
15
15
  return bundled_check unless path
16
+ return Check.new(:error, "prices", "#{path} does not exist; #{REFRESH_COMMAND}") unless File.exist?(path)
16
17
 
17
18
  count = LlmCostTracker::Pricing::Registry.file_prices(path).size
18
19
  metadata = LlmCostTracker::Pricing::Registry.file_metadata(path)
@@ -164,7 +164,7 @@ module LlmCostTracker
164
164
 
165
165
  def call_rollups_check
166
166
  return unless llm_cost_tracker_calls_table?
167
- return live_rollups_check unless LlmCostTracker.configuration.cache_rollups
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
170
  return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if errors.empty?
@@ -181,14 +181,14 @@ module LlmCostTracker
181
181
  Check.new(
182
182
  :warn,
183
183
  "call rollups",
184
- "cache_rollups=false but llm_cost_tracker_call_rollups exists. " \
185
- "Set config.cache_rollups = true to keep budget reads on the rollups fast path or drop the table."
184
+ "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."
186
186
  )
187
187
  else
188
188
  Check.new(
189
189
  :ok,
190
190
  "call rollups",
191
- "cache_rollups=false; budget reads aggregate from llm_cost_tracker_calls directly"
191
+ "budgets.totals_source=:ledger; budget reads aggregate from llm_cost_tracker_calls directly"
192
192
  )
193
193
  end
194
194
  end
@@ -9,6 +9,10 @@ module LlmCostTracker
9
9
  class Engine < ::Rails::Engine
10
10
  isolate_namespace LlmCostTracker
11
11
 
12
+ initializer "llm_cost_tracker.deprecator" do |app|
13
+ app.deprecators[:llm_cost_tracker] = LlmCostTracker.deprecator
14
+ end
15
+
12
16
  initializer "llm_cost_tracker.dashboard_setup_state" do |app|
13
17
  app.reloader.to_prepare { LlmCostTracker::Dashboard::SetupState.reset! }
14
18
  end
@@ -1,25 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "redaction"
4
+
3
5
  module LlmCostTracker
4
6
  class Error < StandardError; end
5
7
 
6
8
  class InvalidFilterError < Error; end
7
9
 
10
+ class TransactionAbortedError < Error
11
+ def initialize(error)
12
+ super(
13
+ "The database rolled back the whole surrounding transaction while recording LLM usage " \
14
+ "(#{error.class}: #{Redaction.text(error.message)}); the caller's transaction no longer exists"
15
+ )
16
+ end
17
+ end
18
+
8
19
  class BudgetExceededError < Error
9
- attr_reader :total, :budget, :budget_type, :last_event, :stage
20
+ attr_reader :total, :budget, :budget_type, :last_event, :stage, :scope
10
21
 
11
- def initialize(budget:, budget_type:, total:, last_event: nil, stage: :post_spend)
22
+ def initialize(budget:, budget_type:, total:, last_event: nil, stage: :post_spend, scope: nil)
12
23
  @total = total
13
24
  @budget = budget
14
25
  @budget_type = budget_type
15
26
  @last_event = last_event
16
27
  @stage = stage
28
+ @scope = scope
17
29
 
18
30
  super(
19
- "LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded: " \
31
+ "LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded#{scope_suffix}: " \
20
32
  "$#{format('%.6f', @total)} / $#{format('%.6f', budget)}"
21
33
  )
22
34
  end
35
+
36
+ private
37
+
38
+ def scope_suffix
39
+ return "" unless @scope
40
+
41
+ " for #{@scope[:key]}=#{@scope[:value]}"
42
+ end
23
43
  end
24
44
 
25
45
  class UnknownPricingError < Error
@@ -31,4 +51,6 @@ module LlmCostTracker
31
51
  super("No pricing configured for LLM model: #{model.inspect}")
32
52
  end
33
53
  end
54
+
55
+ CALLER_ERRORS = [BudgetExceededError, UnknownPricingError, TransactionAbortedError].freeze
34
56
  end
@@ -11,7 +11,7 @@ module LlmCostTracker
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
13
  desc "Creates the async ingestion tables (llm_cost_tracker_ingestion_inbox_entries + _leases). " \
14
- "Required when config.ingestion = :async."
14
+ "Required when config.ingestion.mode = :async."
15
15
 
16
16
  def create_migration_file
17
17
  migration_template(
@@ -25,7 +25,7 @@ module LlmCostTracker
25
25
  After migrating, set the following in config/initializers/llm_cost_tracker.rb:
26
26
 
27
27
  LlmCostTracker.configure do |config|
28
- config.ingestion = :async
28
+ config.ingestion.mode = :async
29
29
  end
30
30
 
31
31
  Without it the async inbox tables stay unused and Tracker keeps writing
@@ -11,7 +11,7 @@ module LlmCostTracker
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
13
  desc "Creates the optional llm_cost_tracker_call_rollups table for fast budget reads. " \
14
- "Required when config.cache_rollups = true."
14
+ "Required when config.budgets.totals_source = :cache."
15
15
 
16
16
  def create_migration_file
17
17
  migration_template(
@@ -25,7 +25,7 @@ module LlmCostTracker
25
25
  After migrating, set the following in config/initializers/llm_cost_tracker.rb:
26
26
 
27
27
  LlmCostTracker.configure do |config|
28
- config.cache_rollups = true
28
+ config.budgets.totals_source = :cache
29
29
  end
30
30
 
31
31
  Without it Tracker keeps reading budget totals as live SUM aggregates over
@@ -23,7 +23,6 @@ class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_
23
23
 
24
24
  add_index :llm_cost_tracker_ingestion_inbox_entries, :event_id, unique: true
25
25
  add_index :llm_cost_tracker_ingestion_inbox_entries, [:tracked_at, :attempts]
26
- add_index :llm_cost_tracker_ingestion_inbox_entries, [:locked_at, :id]
27
26
  add_index :llm_cost_tracker_ingestion_leases, :name, unique: true
28
27
  end
29
28
  end
@@ -74,20 +74,24 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
74
74
  foreign_key: { to_table: :llm_cost_tracker_calls, on_delete: :cascade }
75
75
  t.string :key, null: false
76
76
  t.text :value, null: false
77
+ t.decimal :total_cost, precision: 20, scale: 8
78
+ t.datetime :tracked_at
77
79
  end
78
80
 
79
81
  add_index :llm_cost_tracker_calls, :event_id, unique: true
80
82
  add_index :llm_cost_tracker_calls, :tracked_at
81
- add_index :llm_cost_tracker_calls, [:provider, :tracked_at]
82
- add_index :llm_cost_tracker_calls, [:model, :tracked_at]
83
+ add_index :llm_cost_tracker_calls, %i[provider tracked_at]
84
+ add_index :llm_cost_tracker_calls, %i[model tracked_at]
83
85
  add_index :llm_cost_tracker_calls, :cost_status
84
86
  add_index :llm_cost_tracker_calls, :provider_response_id
87
+ add_index :llm_cost_tracker_calls, :id, name: :index_llm_cost_tracker_calls_on_unpriced,
88
+ where: "total_cost IS NULL"
85
89
  add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
86
90
  add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
87
91
  if postgresql?
88
- add_index :llm_cost_tracker_call_tags, [:key, :value]
92
+ add_index :llm_cost_tracker_call_tags, [:key, :value, :tracked_at]
89
93
  elsif mysql?
90
- add_index :llm_cost_tracker_call_tags, [:key, :value], length: { value: 191 }
94
+ add_index :llm_cost_tracker_call_tags, [:key, :value, :tracked_at], length: { value: 191 }
91
95
  end
92
96
  end
93
97
 
@@ -4,16 +4,16 @@ LlmCostTracker.configure do |config|
4
4
  # Set to false to temporarily disable tracking without removing middleware.
5
5
  config.enabled = true
6
6
 
7
- # LLM Cost Tracker logs warnings through Rails.logger when available.
8
- config.log_level = :info
9
-
10
7
  # Tags merged into every event. Use a callable for request/job-time context.
11
- config.default_tags = -> { { environment: Rails.env } }
8
+ config.tags.default = -> { { environment: Rails.env } }
12
9
 
13
10
  # Tag guardrails keep accidental high-cardinality or sensitive values out of the ledger.
14
- # config.max_tag_count = 50
15
- # config.max_tag_value_bytesize = 1024
16
- # config.redacted_tag_keys = <%= LlmCostTracker::Configuration::DEFAULT_REDACTED_TAG_KEYS.inspect %>
11
+ # config.tags.max_count = 50
12
+ # config.tags.max_value_bytesize = 1024
13
+ # config.tags.redacted_keys = <%= LlmCostTracker::Configuration::Tags::DEFAULT_REDACTED_KEYS.inspect %>
14
+
15
+ # Tag keys that get their own cost breakdown in bin/rails llm_cost_tracker:report.
16
+ # config.tags.report_breakdown_keys = %w[feature user_id]
17
17
 
18
18
  # Optional SDK integrations. Provider SDK gems are not installed by LLM Cost Tracker.
19
19
  # Enabled integrations are checked at boot, so enable only clients your app loads.
@@ -23,35 +23,50 @@ LlmCostTracker.configure do |config|
23
23
 
24
24
  # Pricing — local file refreshed via bin/rails llm_cost_tracker:prices:refresh
25
25
  # plus inline overrides. Rates are per 1M tokens; the snapshot's currency
26
- # is read from your prices_file's `metadata.currency` (USD in the bundled
26
+ # is read from your pricing file's `metadata.currency` (USD in the bundled
27
27
  # snapshot — set a different code per file if you maintain non-USD prices).
28
28
  <% if options[:prices] -%>
29
- config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
29
+ config.pricing.file = Rails.root.join("config/llm_cost_tracker_prices.yml")
30
30
  <% else -%>
31
- # config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
31
+ # config.pricing.file = Rails.root.join("config/llm_cost_tracker_prices.yml")
32
32
  <% end -%>
33
- # config.pricing_overrides = {
33
+ # config.pricing.overrides = {
34
34
  # "my-custom-model" => { input: 1.00, output: 2.00 }
35
35
  # }
36
36
  # :warn (default) records token usage with nil cost when a model has no rate.
37
- # Use :raise to require known pricing for every model.
38
- config.unknown_pricing_behavior = :warn
37
+ # Use :raise to record and then raise for models that have no rate at all; a model
38
+ # priced with one component rate missing lands as partial and does not trigger it.
39
+ config.pricing.unknown_model_behavior = :warn
39
40
 
40
41
  # Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
41
- # plus behavior on crossing (:notify default fires on_budget_exceeded; :raise
42
+ # plus behavior on crossing (:notify default fires budgets.on_exceeded; :raise
42
43
  # raises after recording; :block_requests preflights supported requests, also
43
44
  # estimating the current call's input cost via chars/4 so it can block before
44
- # send) and an optional callback. Cap evaluation reads from llm_cost_tracker_calls live;
45
- # flip cache_rollups to true at high volume so reads hit the rollups table
46
- # instead — generate the table with `bin/rails generate llm_cost_tracker:call_rollups`.
47
- # config.monthly_budget = 100.00
48
- # config.daily_budget = 10.00
49
- # config.per_call_budget = 1.00
50
- config.budget_exceeded_behavior = :notify
51
- # config.on_budget_exceeded = ->(data) {
45
+ # send) and an optional callback.
46
+ # config.budgets.monthly = 100.00
47
+ # config.budgets.daily = 10.00
48
+ # config.budgets.per_call = 1.00
49
+ config.budgets.exceeded_behavior = :notify
50
+
51
+ # One budget applied to every distinct value of a tag: each tenant gets its own
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`.
55
+ # Budget high-cardinality tags only — a tag with a handful of values covers most of the
56
+ # ledger, so its check cannot use an index and every call pays for a near-full scan.
57
+ # config.budgets.per_tag = {
58
+ # tenant_id: { monthly: 1000.00, weekly: 300.00 },
59
+ # user_id: { daily: 25.00, behavior: :notify }
60
+ # }
61
+ # config.budgets.on_exceeded = ->(data) {
52
62
  # Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}")
53
63
  # }
54
- # config.cache_rollups = true
64
+
65
+ # 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`.
69
+ # config.budgets.totals_source = :cache
55
70
 
56
71
  # Ingestion path — :inline (default) writes events synchronously from the request
57
72
  # thread. Set to :async for a write-ahead inbox + background worker that batches
@@ -59,13 +74,18 @@ LlmCostTracker.configure do |config|
59
74
  # inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
60
75
  # Synchronous inbox writes use a dedicated ActiveRecord pool (defaults to 2 connections)
61
76
  # so they don't compete with request threads for the default pool when a tracked call
62
- # happens inside an open caller transaction. Bump ingestion_pool_size if your Puma
77
+ # happens inside an open caller transaction. Bump ingestion.pool_size if your Puma
63
78
  # worker count outgrows that.
64
- # config.ingestion = :async
65
- # config.ingestion_pool_size = 5
79
+ # config.ingestion.mode = :async
80
+ # config.ingestion.pool_size = 5
81
+
82
+ # Register OpenAI-compatible gateway hosts so their calls are attributed to a
83
+ # provider name instead of the generic openai_compatible bucket.
84
+ # config.capture.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
85
+
86
+ # Streaming calls only report token usage when the request asks for it. Leave this
87
+ # on and the Faraday middleware adds stream_options: { include_usage: true } to
88
+ # streaming chat-completions requests on hosts known to accept it.
89
+ # config.capture.request_stream_usage = false
66
90
 
67
- # Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
68
- # for bin/rails llm_cost_tracker:report.
69
- # config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
70
- # config.report_tag_breakdowns = %w[feature user_id]
71
91
  end
@@ -0,0 +1,40 @@
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
+
3
+ class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration<%= migration_version %>
4
+ disable_ddl_transaction!
5
+
6
+ CALLS = :llm_cost_tracker_calls
7
+ INBOX = :llm_cost_tracker_ingestion_inbox_entries
8
+ UNUSED_INBOX_INDEX = %i[locked_at id].freeze
9
+ UNPRICED_INDEX = :index_llm_cost_tracker_calls_on_unpriced
10
+
11
+ def up
12
+ add_index CALLS, :id, name: UNPRICED_INDEX, where: "total_cost IS NULL", **add_index_options
13
+ return unless table_exists?(INBOX)
14
+
15
+ remove_index INBOX, column: UNUSED_INBOX_INDEX, **remove_index_options
16
+ end
17
+
18
+ def down
19
+ add_index INBOX, UNUSED_INBOX_INDEX, **add_index_options if table_exists?(INBOX)
20
+ remove_index CALLS, name: UNPRICED_INDEX, **remove_index_options
21
+ end
22
+
23
+ private
24
+
25
+ def add_index_options
26
+ return { if_not_exists: true } unless postgresql?
27
+
28
+ { if_not_exists: true, algorithm: :concurrently }
29
+ end
30
+
31
+ def remove_index_options
32
+ return { if_exists: true } unless postgresql?
33
+
34
+ { if_exists: true, algorithm: :concurrently }
35
+ end
36
+
37
+ def postgresql?
38
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
39
+ end
40
+ end
@@ -0,0 +1,41 @@
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
+
3
+ class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration<%= migration_version %>
4
+ disable_ddl_transaction!
5
+
6
+ TABLE = :llm_cost_tracker_call_tags
7
+ INDEX = %i[key value tracked_at].freeze
8
+ REPLACED_INDEX = %i[key value].freeze
9
+
10
+ def up
11
+ add_column TABLE, :total_cost, :decimal, precision: 20, scale: 8 unless column_exists?(TABLE, :total_cost)
12
+ add_column TABLE, :tracked_at, :datetime unless column_exists?(TABLE, :tracked_at)
13
+ add_index TABLE, INDEX, **add_index_options
14
+ remove_index TABLE, column: REPLACED_INDEX, **remove_index_options
15
+ end
16
+
17
+ def down
18
+ add_index TABLE, REPLACED_INDEX, **add_index_options
19
+ remove_index TABLE, column: INDEX, **remove_index_options
20
+ remove_column TABLE, :tracked_at, if_exists: true
21
+ remove_column TABLE, :total_cost, if_exists: true
22
+ end
23
+
24
+ private
25
+
26
+ def add_index_options
27
+ return { if_not_exists: true, length: { value: 191 } } unless postgresql?
28
+
29
+ { if_not_exists: true, algorithm: :concurrently }
30
+ end
31
+
32
+ def remove_index_options
33
+ return { if_exists: true } unless postgresql?
34
+
35
+ { if_exists: true, algorithm: :concurrently }
36
+ end
37
+
38
+ def postgresql?
39
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class UpgradeIndexesGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds a partial index over unpriced calls and drops the unused ingestion inbox lock index" \
14
+ "scanning the whole ledger."
15
+
16
+ def create_migration_file
17
+ migration_template(
18
+ "upgrade_indexes.rb.erb",
19
+ "db/migrate/upgrade_llm_cost_tracker_indexes.rb"
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def migration_version
26
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class UpgradePerTagBudgetsGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
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."
15
+
16
+ def create_migration_file
17
+ migration_template(
18
+ "upgrade_per_tag_budgets.rb.erb",
19
+ "db/migrate/upgrade_llm_cost_tracker_per_tag_budgets.rb"
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def migration_version
26
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "inbox"
4
+ require_relative "../budget"
4
5
  require_relative "../ledger/store"
5
6
 
6
7
  module LlmCostTracker
@@ -12,7 +13,8 @@ module LlmCostTracker
12
13
  ActiveRecord::Deadlocked,
13
14
  ActiveRecord::LockWaitTimeout,
14
15
  ActiveRecord::StatementTimeout,
15
- ActiveRecord::ConnectionNotEstablished
16
+ ActiveRecord::ConnectionNotEstablished,
17
+ LlmCostTracker::TransactionAbortedError
16
18
  ].freeze
17
19
 
18
20
  def initialize(identity:)
@@ -24,7 +26,7 @@ module LlmCostTracker
24
26
  return 0 if rows.empty?
25
27
 
26
28
  valid_rows, events = decode(rows)
27
- persist(valid_rows, events) if events.any?
29
+ persist_batch(valid_rows, events) if events.any?
28
30
  rows.size
29
31
  rescue StandardError => e
30
32
  rows_to_mark = valid_rows&.any? ? valid_rows : rows
@@ -66,7 +68,7 @@ module LlmCostTracker
66
68
  end
67
69
 
68
70
  def error_message_for(error)
69
- "#{error.class}: #{error.message}".byteslice(0, 1_000)
71
+ "#{error.class}: #{Redaction.text(error.message)}".byteslice(0, 1_000).scrub("")
70
72
  end
71
73
 
72
74
  def warn_on_quarantine(rows)
@@ -74,12 +76,10 @@ module LlmCostTracker
74
76
  quarantined = rows.select { |row| row.attempts.to_i + 1 >= threshold }
75
77
  return if quarantined.empty?
76
78
 
77
- sample = quarantined.first(10).map(&:id).join(", ")
78
- sample += "..." if quarantined.size > 10
79
79
  LlmCostTracker::Logging.warn(
80
80
  "Ingestion::Batch: #{quarantined.size} inbox row(s) reached " \
81
81
  "MAX_ATTEMPTS_BEFORE_QUARANTINE=#{threshold} and will be skipped " \
82
- "on the next claim cycle (ids: #{sample})"
82
+ "on the next claim cycle (ids: #{id_sample(quarantined)})"
83
83
  )
84
84
  end
85
85
 
@@ -87,6 +87,11 @@ module LlmCostTracker
87
87
 
88
88
  attr_reader :identity
89
89
 
90
+ def id_sample(rows)
91
+ sample = rows.first(10).map(&:id).join(", ")
92
+ rows.size > 10 ? "#{sample}..." : sample
93
+ end
94
+
90
95
  def claim
91
96
  now = Time.now.utc
92
97
  cutoff = now - LOCK_TIMEOUT_SECONDS
@@ -116,12 +121,38 @@ module LlmCostTracker
116
121
  [valid_rows, events]
117
122
  end
118
123
 
124
+ def persist_batch(rows, events)
125
+ landed = []
126
+ failed = Hash.new { |hash, message| hash[message] = [] }
127
+ landed.concat(persist(rows, events))
128
+ rescue *TRANSIENT_PERSIST_ERRORS
129
+ raise
130
+ rescue StandardError
131
+ rows.zip(events) do |row, event|
132
+ landed.concat(persist([row], [event]))
133
+ rescue *TRANSIENT_PERSIST_ERRORS
134
+ raise
135
+ rescue StandardError => e
136
+ failed[error_message_for(e)] << row
137
+ end
138
+ ensure
139
+ failed.each do |message, failed_rows|
140
+ LlmCostTracker::Logging.warn(
141
+ "Ingestion::Batch: #{failed_rows.size} inbox row(s) could not be stored " \
142
+ "(ids: #{id_sample(failed_rows)}): #{message}"
143
+ )
144
+ mark_failed_with_message(failed_rows, message)
145
+ end
146
+ Ledger::Rollups.increment_safely!(landed)
147
+ Budget.notify_persisted_safely!(landed)
148
+ end
149
+
119
150
  def persist(rows, events, retry_on_conflict: true)
120
151
  LlmCostTracker::Call.transaction do
121
152
  Ledger::Store.persist_records(events)
122
153
  Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
123
154
  end
124
- Ledger::Rollups.increment_safely!(events) if Ingestion.cache_rollups?
155
+ events
125
156
  rescue ActiveRecord::RecordNotUnique
126
157
  raise unless retry_on_conflict
127
158
 
@@ -12,7 +12,14 @@ module LlmCostTracker
12
12
  delegate :with_connection, to: :pool
13
13
 
14
14
  def pool
15
- @pool || MUTEX.synchronize { @pool ||= connect! }
15
+ return @pool if @pool && @pid == Process.pid
16
+
17
+ MUTEX.synchronize do
18
+ next @pool if @pool && @pid == Process.pid
19
+
20
+ @pid = Process.pid
21
+ @pool = connect!
22
+ end
16
23
  end
17
24
 
18
25
  private
@@ -27,7 +34,7 @@ module LlmCostTracker
27
34
  end
28
35
 
29
36
  def pool_size
30
- configured = LlmCostTracker.configuration.ingestion_pool_size.to_i
37
+ configured = LlmCostTracker.configuration.ingestion.pool_size.to_i
31
38
  configured.positive? ? configured : DEFAULT_POOL_SIZE
32
39
  end
33
40
  end
@@ -37,16 +37,12 @@ module LlmCostTracker
37
37
  end
38
38
 
39
39
  def async?
40
- LlmCostTracker.configuration.ingestion == :async
41
- end
42
-
43
- def cache_rollups?
44
- LlmCostTracker.configuration.cache_rollups
40
+ LlmCostTracker.configuration.ingestion.mode == :async
45
41
  end
46
42
 
47
43
  def guards_for_current_config
48
44
  guards = Ledger::Schema::CORE_SCHEMAS.dup
49
- guards << Ledger::Schema::CACHE_ROLLUPS_SCHEMA if cache_rollups?
45
+ guards << Ledger::Schema::CACHE_ROLLUPS_SCHEMA if LlmCostTracker.configuration.budgets.totals_source == :cache
50
46
  guards += Ledger::Schema::ASYNC_SCHEMAS if async?
51
47
  guards
52
48
  end
@@ -132,7 +128,7 @@ module LlmCostTracker
132
128
  return if records.empty?
133
129
 
134
130
  relation.delete_all
135
- LlmCostTracker::Ledger::Rollups.decrement!(records) if cache_rollups?
131
+ LlmCostTracker::Ledger::Rollups.decrement!(records)
136
132
  end
137
133
 
138
134
  def cleanup_verification_inbox(event:, response_id:)