llm_cost_tracker 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -16
  3. data/README.md +16 -28
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +3 -2
  5. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +2 -0
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  7. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -4
  8. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +11 -0
  9. data/app/models/llm_cost_tracker/call.rb +9 -3
  10. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  11. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +1 -0
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +25 -0
  13. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  14. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  16. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  17. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -10
  18. data/app/views/llm_cost_tracker/data_quality/index.html.erb +22 -0
  19. data/app/views/llm_cost_tracker/pricing/index.html.erb +1 -1
  20. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  21. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  22. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  23. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  24. data/lib/llm_cost_tracker/budget/per_tag.rb +159 -0
  25. data/lib/llm_cost_tracker/budget.rb +120 -32
  26. data/lib/llm_cost_tracker/capture/stream_collector.rb +5 -4
  27. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  28. data/lib/llm_cost_tracker/charges/line_item.rb +3 -2
  29. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  30. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  31. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  32. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  33. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  34. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  35. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  36. data/lib/llm_cost_tracker/configuration.rb +69 -125
  37. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  38. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  39. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  40. data/lib/llm_cost_tracker/doctor.rb +4 -4
  41. data/lib/llm_cost_tracker/engine.rb +4 -0
  42. data/lib/llm_cost_tracker/errors.rb +12 -3
  43. data/lib/llm_cost_tracker/event.rb +3 -1
  44. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  49. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  53. data/lib/llm_cost_tracker/ingestion/batch.rb +27 -7
  54. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  55. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  56. data/lib/llm_cost_tracker/integrations/anthropic.rb +8 -7
  57. data/lib/llm_cost_tracker/integrations/base.rb +1 -1
  58. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +10 -12
  59. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  60. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -4
  61. data/lib/llm_cost_tracker/ledger/store.rb +16 -2
  62. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  63. data/lib/llm_cost_tracker/logging.rb +3 -5
  64. data/lib/llm_cost_tracker/middleware/faraday.rb +1 -1
  65. data/lib/llm_cost_tracker/prices.json +1384 -304
  66. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  67. data/lib/llm_cost_tracker/pricing/calculation.rb +44 -25
  68. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  69. data/lib/llm_cost_tracker/pricing/matcher.rb +24 -3
  70. data/lib/llm_cost_tracker/pricing/mode.rb +7 -7
  71. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  72. data/lib/llm_cost_tracker/pricing/registry.rb +5 -5
  73. data/lib/llm_cost_tracker/pricing/sync.rb +1 -1
  74. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  75. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  76. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  77. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +5 -2
  78. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +5 -4
  79. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +2 -2
  80. data/lib/llm_cost_tracker/railtie.rb +3 -7
  81. data/lib/llm_cost_tracker/report/data.rb +2 -2
  82. data/lib/llm_cost_tracker/retention.rb +22 -10
  83. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  84. data/lib/llm_cost_tracker/tags/sanitizer.rb +4 -4
  85. data/lib/llm_cost_tracker/tracker.rb +16 -26
  86. data/lib/llm_cost_tracker/usage/catalog.rb +12 -2
  87. data/lib/llm_cost_tracker/usage/dimensions.yml +0 -24
  88. data/lib/llm_cost_tracker/usage/token_usage.rb +20 -75
  89. data/lib/llm_cost_tracker/version.rb +1 -1
  90. data/lib/llm_cost_tracker.rb +7 -2
  91. data/lib/tasks/llm_cost_tracker.rake +23 -10
  92. metadata +22 -9
@@ -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 = %w[api_key access_token authorization credential password refresh_token secret]
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 reject models that have no rate at all; a model priced with one
38
+ # 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 keeps running totals in llm_cost_tracker_call_rollups so checks
67
+ # read one row instead — switch once the live SUM gets slow, and generate the table
68
+ # 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
+ # OpenAI-compatible streaming request bodies that don't already set 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
@@ -8,6 +9,12 @@ module LlmCostTracker
8
9
  class Batch
9
10
  BATCH_SIZE = 100
10
11
  LOCK_TIMEOUT_SECONDS = 30
12
+ TRANSIENT_PERSIST_ERRORS = [
13
+ ActiveRecord::Deadlocked,
14
+ ActiveRecord::LockWaitTimeout,
15
+ ActiveRecord::StatementTimeout,
16
+ ActiveRecord::ConnectionNotEstablished
17
+ ].freeze
11
18
 
12
19
  def initialize(identity:)
13
20
  @identity = identity
@@ -22,7 +29,10 @@ module LlmCostTracker
22
29
  rows.size
23
30
  rescue StandardError => e
24
31
  rows_to_mark = valid_rows&.any? ? valid_rows : rows
25
- mark_failed_with_message(rows_to_mark, error_message_for(e)) if rows_to_mark&.any?
32
+ if rows_to_mark&.any?
33
+ transient = valid_rows&.any? && TRANSIENT_PERSIST_ERRORS.any? { |klass| e.is_a?(klass) }
34
+ mark_failed_with_message(rows_to_mark, error_message_for(e), decrement_attempts: transient)
35
+ end
26
36
  raise
27
37
  end
28
38
 
@@ -34,12 +44,20 @@ module LlmCostTracker
34
44
  claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
35
45
  end
36
46
 
37
- def mark_failed_with_message(rows, message)
47
+ def mark_failed_with_message(rows, message, decrement_attempts: false)
38
48
  now = Time.now.utc
39
- Ingestion::InboxEntry
40
- .where(id: rows.map(&:id), locked_by: identity)
41
- .update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
42
- warn_on_quarantine(rows)
49
+ scope = Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity)
50
+ if decrement_attempts
51
+ scope.update_all(
52
+ Ingestion::InboxEntry.sanitize_sql_array(
53
+ ["last_error = ?, locked_at = ?, locked_by = NULL, " \
54
+ "attempts = GREATEST(attempts - 1, 0), updated_at = ?", message, now, now]
55
+ )
56
+ )
57
+ else
58
+ scope.update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
59
+ warn_on_quarantine(rows)
60
+ end
43
61
  rescue StandardError => e
44
62
  LlmCostTracker::Logging.warn(
45
63
  "Inbox mark_failed_with_message failed for #{rows.size} rows: #{e.class}: #{e.message} " \
@@ -101,9 +119,11 @@ module LlmCostTracker
101
119
 
102
120
  def persist(rows, events, retry_on_conflict: true)
103
121
  LlmCostTracker::Call.transaction do
104
- Ledger::Store.insert(events)
122
+ Ledger::Store.persist_records(events)
105
123
  Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
106
124
  end
125
+ Ledger::Rollups.increment_safely!(events)
126
+ Budget.notify_persisted_safely!(events)
107
127
  rescue ActiveRecord::RecordNotUnique
108
128
  raise unless retry_on_conflict
109
129
 
@@ -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:)
@@ -46,16 +46,17 @@ module LlmCostTracker
46
46
 
47
47
  def record_batch_result(response)
48
48
  return unless active?
49
- return unless response.respond_to?(:result) && response.result
50
49
 
51
- result = response.result
52
- return unless result.respond_to?(:type) && result.type.to_s == "succeeded"
50
+ record_safely do
51
+ next unless response.respond_to?(:result) && response.result
53
52
 
54
- message = result.respond_to?(:message) ? result.message : nil
55
- return unless message
56
- return if LlmCostTracker::Call.already_recorded?(provider: "anthropic", provider_response_id: message.id)
53
+ result = response.result
54
+ next unless result.respond_to?(:type) && result.type.to_s == "succeeded"
55
+
56
+ message = result.respond_to?(:message) ? result.message : nil
57
+ next unless message
58
+ next if LlmCostTracker::Call.already_recorded?(provider: "anthropic", provider_response_id: message.id)
57
59
 
58
- record_safely do
59
60
  usage = message.usage
60
61
  next unless usage
61
62
  next if usage.input_tokens.nil? && usage.output_tokens.nil?
@@ -83,7 +83,7 @@ module LlmCostTracker
83
83
  enforce_budget!(request: request, provider: provider)
84
84
  started_at = LlmCostTracker::Timing.now_monotonic
85
85
  response = yield
86
- record.call(response, request, LlmCostTracker::Timing.elapsed_ms(started_at))
86
+ record_safely { record.call(response, request, LlmCostTracker::Timing.elapsed_ms(started_at)) }
87
87
  response
88
88
  end
89
89
 
@@ -13,32 +13,30 @@ module LlmCostTracker
13
13
  class << self
14
14
  def maybe_capture(batch, resource:)
15
15
  return unless Openai.active?
16
- return unless batch.respond_to?(:status) && batch.status.to_s == "completed"
17
-
18
- output_file_id = batch.respond_to?(:output_file_id) ? batch.output_file_id : nil
19
- return unless output_file_id
20
-
21
- batch_id = batch.respond_to?(:id) ? batch.id : nil
22
- return unless batch_id && claim(batch_id)
16
+ return unless batch.status.to_s == "completed"
17
+ return unless batch.output_file_id && batch.id
18
+ return if captured?(batch.id)
23
19
 
24
20
  client = resource.instance_variable_get(:@client)
25
21
  host = Openai.client_host_for(resource)
26
22
  Openai.record_safely do
27
- io = client.files.content(output_file_id)
23
+ io = client.files.content(batch.output_file_id)
28
24
  capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
25
+ mark_captured(batch.id)
29
26
  end
30
27
  end
31
28
 
32
29
  private
33
30
 
34
- def claim(batch_id)
31
+ def captured?(batch_id)
32
+ MUTEX.synchronize { @dedup&.include?(batch_id) || false }
33
+ end
34
+
35
+ def mark_captured(batch_id)
35
36
  MUTEX.synchronize do
36
37
  @dedup ||= Set.new
37
- next false if @dedup.include?(batch_id)
38
-
39
38
  @dedup.clear if @dedup.size >= DEDUP_LIMIT
40
39
  @dedup.add(batch_id)
41
- true
42
40
  end
43
41
  end
44
42
 
@@ -45,7 +45,7 @@ module LlmCostTracker
45
45
 
46
46
  def recorded_sql(period, start)
47
47
  calls = "COALESCE(#{sum_sql(LlmCostTracker::Call.between(start, time))}, 0)"
48
- return calls unless LlmCostTracker.configuration.cache_rollups
48
+ return calls unless Rollups.cache_active?
49
49
 
50
50
  rollup = "COALESCE(#{sum_sql(rollup_scope(period))}, 0)"
51
51
  "GREATEST(#{rollup}, #{calls})"
@@ -8,7 +8,17 @@ module LlmCostTracker
8
8
  module Ledger
9
9
  module Rollups
10
10
  class << self
11
+ def cache_active?
12
+ return false unless LlmCostTracker.configuration.budgets.totals_source == :cache
13
+ return true if LlmCostTracker::CallRollup.table_exists?
14
+
15
+ warn_missing_table
16
+ false
17
+ end
18
+
11
19
  def increment!(events)
20
+ return unless cache_active?
21
+
12
22
  events = Array(events).select(&:total_cost)
13
23
  return if events.empty?
14
24
 
@@ -18,7 +28,9 @@ module LlmCostTracker
18
28
  ROLLUP_INCREMENT_ATTEMPTS = 3
19
29
  ROLLUP_INCREMENT_BASE_DELAY_SECONDS = 0.05
20
30
  REBUILD_INSERT_SLICE = 1_000
21
- private_constant :ROLLUP_INCREMENT_ATTEMPTS, :ROLLUP_INCREMENT_BASE_DELAY_SECONDS, :REBUILD_INSERT_SLICE
31
+ private_constant :ROLLUP_INCREMENT_ATTEMPTS,
32
+ :ROLLUP_INCREMENT_BASE_DELAY_SECONDS,
33
+ :REBUILD_INSERT_SLICE
22
34
 
23
35
  def increment_safely!(events)
24
36
  attempt = 0
@@ -28,19 +40,26 @@ module LlmCostTracker
28
40
  rescue StandardError => e
29
41
  raise if LlmCostTracker::Call.connection.open_transactions.positive?
30
42
 
31
- if attempt < ROLLUP_INCREMENT_ATTEMPTS
43
+ if attempt < ROLLUP_INCREMENT_ATTEMPTS && rolled_back?(e)
32
44
  sleep(ROLLUP_INCREMENT_BASE_DELAY_SECONDS * (2**(attempt - 1)))
33
45
  retry
34
46
  end
35
47
 
36
48
  LlmCostTracker::Logging.warn(
37
- "Rollup increment failed for #{events.size} events after #{attempt} attempts: " \
38
- "#{e.class}: #{e.message}"
49
+ "Rollup increment failed for #{events.size} events after #{attempt} attempt(s): " \
50
+ "#{e.class}: #{e.message}. Budget reads fall back to the calls ledger; " \
51
+ "run bin/rails llm_cost_tracker:rebuild_rollups to resync the cache."
39
52
  )
40
53
  end
41
54
  end
42
55
 
56
+ def rolled_back?(error)
57
+ error.is_a?(ActiveRecord::Deadlocked) || error.is_a?(ActiveRecord::LockWaitTimeout)
58
+ end
59
+
43
60
  def decrement!(records)
61
+ return unless cache_active?
62
+
44
63
  buckets = bucket_totals(records)
45
64
  return if buckets.empty?
46
65
 
@@ -60,6 +79,17 @@ module LlmCostTracker
60
79
 
61
80
  private
62
81
 
82
+ def warn_missing_table
83
+ return if @missing_table_warned
84
+
85
+ @missing_table_warned = true
86
+ Logging.warn(
87
+ "config.budgets.totals_source = :cache is set but llm_cost_tracker_call_rollups is missing; " \
88
+ "budget and dashboard totals fall back to aggregating llm_cost_tracker_calls. " \
89
+ "Run the call_rollups generator and migrate, or set config.budgets.totals_source = :ledger."
90
+ )
91
+ end
92
+
63
93
  def accumulate_priced_calls
64
94
  accumulated = Hash.new { |totals, key| totals[key] = BigDecimal("0") }
65
95
  priced_calls.in_batches do |batch|
@@ -4,6 +4,7 @@ require "json"
4
4
 
5
5
  require_relative "../pricing"
6
6
  require_relative "rollups"
7
+ require_relative "../budget/per_tag"
7
8
  require_relative "tags/encoding"
8
9
 
9
10
  module LlmCostTracker
@@ -14,13 +15,20 @@ module LlmCostTracker
14
15
  events = Array(events)
15
16
  return if events.empty?
16
17
 
18
+ persist_records(events)
19
+ Ledger::Rollups.increment_safely!(events)
20
+ end
21
+
22
+ def persist_records(events)
23
+ events = Array(events)
24
+ return if events.empty?
25
+
17
26
  LlmCostTracker::Call.transaction do
18
27
  rows = events.map { |event| attributes_for(event) }
19
28
  call_ids = insert_calls_returning_ids(rows, events)
20
29
  insert_line_items(events, call_ids)
21
30
  insert_call_tags(events, call_ids)
22
31
  end
23
- Ledger::Rollups.increment_safely!(events) if LlmCostTracker.configuration.cache_rollups
24
32
  end
25
33
 
26
34
  private
@@ -114,7 +122,7 @@ module LlmCostTracker
114
122
  llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
115
123
  key: key.to_s,
116
124
  value: Tags::Encoding.encode(value)
117
- }
125
+ }.merge(budget_columns_for(event))
118
126
  end
119
127
  end
120
128
  return if rows.empty?
@@ -122,6 +130,12 @@ module LlmCostTracker
122
130
  LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
123
131
  end
124
132
 
133
+ def budget_columns_for(event)
134
+ return {} unless LlmCostTracker::Budget::PerTag.columns?
135
+
136
+ { total_cost: event.cost&.total, tracked_at: event.tracked_at }
137
+ end
138
+
125
139
  def stored_details(details)
126
140
  (details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
127
141
  end
@@ -6,12 +6,22 @@ module LlmCostTracker
6
6
  module Ledger
7
7
  module Tags
8
8
  module Encoding
9
+ INDEXABLE_BYTES = 2_048
10
+
9
11
  def self.encode(value)
10
- case value
11
- when Hash then JSON.generate(normalize_hash(value))
12
- when Array then JSON.generate(normalize_array(value))
13
- else value.to_s
14
- end
12
+ encoded = case value
13
+ when Hash then JSON.generate(normalize_hash(value))
14
+ when Array then JSON.generate(normalize_array(value))
15
+ else value.to_s
16
+ end
17
+ truncate(encoded)
18
+ end
19
+
20
+ def self.truncate(string)
21
+ limit = [LlmCostTracker.configuration.tags.max_value_bytesize.to_i, INDEXABLE_BYTES].min
22
+ return string if string.bytesize <= limit
23
+
24
+ string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
15
25
  end
16
26
 
17
27
  def self.normalize_hash(hash)
@@ -3,15 +3,13 @@
3
3
  module LlmCostTracker
4
4
  module Logging
5
5
  class << self
6
- def debug(message) = tagged.debug(message)
6
+ def debug(message) = Rails.logger.debug(prefixed(message))
7
7
 
8
- def warn(message) = tagged.warn(message)
8
+ def warn(message) = Rails.logger.warn(prefixed(message))
9
9
 
10
10
  private
11
11
 
12
- def tagged
13
- Rails.logger.tagged(LlmCostTracker.name)
14
- end
12
+ def prefixed(message) = "[#{LlmCostTracker.name}] #{message}"
15
13
  end
16
14
  end
17
15
  end
@@ -94,7 +94,7 @@ module LlmCostTracker
94
94
  end
95
95
 
96
96
  def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
97
- return nil unless LlmCostTracker.configuration.auto_enable_stream_usage
97
+ return nil unless LlmCostTracker.configuration.capture.request_stream_usage
98
98
  return nil unless parser&.auto_enable_stream_usage?(request_url)
99
99
 
100
100
  stream_options = request_parsed["stream_options"]