llm_cost_tracker 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
@@ -24,33 +24,36 @@ module LlmCostTracker
24
24
  :cost_status,
25
25
  :pricing_snapshot,
26
26
  :line_items
27
- ) do
28
- def self.build(**attributes)
29
- token_usage = attributes.fetch(:token_usage)
30
- line_items = attributes[:line_items] || resolve_line_items(attributes[:service_line_items])
27
+ )
28
+
29
+ class Event
30
+ UNKNOWN_MODEL = "unknown"
31
+ PROVIDER_IDS = %i[provider_response_id provider_project_id provider_api_key_id provider_workspace_id].freeze
32
+ PASSTHROUGH_FIELDS = %i[event_id pricing_mode cost tags latency_ms tracked_at cost_status pricing_snapshot].freeze
33
+ private_constant :PROVIDER_IDS, :PASSTHROUGH_FIELDS
31
34
 
35
+ def self.build(**attributes)
32
36
  new(
33
- event_id: attributes[:event_id],
37
+ token_usage: attributes.fetch(:token_usage),
38
+ line_items: attributes[:line_items] || resolve_line_items(attributes[:service_line_items]),
34
39
  provider: attributes.fetch(:provider).to_s,
35
- model: attributes.fetch(:model).to_s.strip.presence || Event::UNKNOWN_MODEL,
36
- token_usage: token_usage,
37
- pricing_mode: attributes[:pricing_mode],
38
- cost: attributes[:cost],
39
- tags: attributes[:tags],
40
- latency_ms: attributes[:latency_ms],
40
+ model: identifier(attributes.fetch(:model)) || UNKNOWN_MODEL,
41
41
  stream: attributes[:stream] || false,
42
42
  usage_source: attributes[:usage_source]&.to_s,
43
- provider_response_id: attributes[:provider_response_id].to_s.strip.presence,
44
- provider_project_id: attributes[:provider_project_id].to_s.strip.presence,
45
- provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
46
- provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
47
- tracked_at: attributes[:tracked_at],
48
- cost_status: attributes[:cost_status],
49
- pricing_snapshot: attributes[:pricing_snapshot],
50
- line_items: line_items
43
+ **PASSTHROUGH_FIELDS.to_h { |field| [field, attributes[field]] },
44
+ **PROVIDER_IDS.to_h { |field| [field, identifier(attributes[field])] }
51
45
  )
52
46
  end
53
47
 
48
+ def self.resolve_line_items(service_items)
49
+ Array(service_items).map do |item|
50
+ item.is_a?(Charges::LineItem) ? item : Charges::LineItem.build(item)
51
+ end
52
+ end
53
+
54
+ def self.identifier(value) = value.to_s.strip.presence
55
+ private_class_method :identifier
56
+
54
57
  def batch?
55
58
  Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
56
59
  end
@@ -61,12 +64,6 @@ module LlmCostTracker
61
64
  with(event_id: Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, "#{provider}/#{provider_response_id}"))
62
65
  end
63
66
 
64
- def self.resolve_line_items(service_items)
65
- Array(service_items).map do |item|
66
- item.is_a?(Charges::LineItem) ? item : Charges::LineItem.build(item)
67
- end
68
- end
69
-
70
67
  def total_cost
71
68
  cost&.total
72
69
  end
@@ -80,8 +77,4 @@ module LlmCostTracker
80
77
  )
81
78
  end
82
79
  end
83
-
84
- class Event
85
- UNKNOWN_MODEL = "unknown"
86
- end
87
80
  end
@@ -84,8 +84,10 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration[<%= ActiveRecord::Migr
84
84
  add_index :llm_cost_tracker_calls, %i[model tracked_at]
85
85
  add_index :llm_cost_tracker_calls, :cost_status
86
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"
87
+ if postgresql?
88
+ add_index :llm_cost_tracker_calls, :id, name: :index_llm_cost_tracker_calls_on_unpriced,
89
+ where: "total_cost IS NULL"
90
+ end
89
91
  add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
90
92
  add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
91
93
  if postgresql?
@@ -60,7 +60,8 @@ LlmCostTracker.configure do |config|
60
60
  # user_id: { daily: 25.00, behavior: :notify }
61
61
  # }
62
62
  # config.budgets.on_exceeded = ->(data) {
63
- # Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}")
63
+ # unit = data[:budget_type] == :calls ? "" : "$"
64
+ # Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: #{unit}#{data[:total]} / #{unit}#{data[:budget]}")
64
65
  # }
65
66
 
66
67
  # Where budget checks read period spend from. :ledger sums llm_cost_tracker_calls on
@@ -80,9 +81,10 @@ LlmCostTracker.configure do |config|
80
81
  # config.ingestion.mode = :async
81
82
  # config.ingestion.pool_size = 5
82
83
 
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
+ # Register OpenAI-compatible gateway hosts beyond the built-in OpenRouter, DeepSeek, Groq,
85
+ # xAI, Mistral and Perplexity; the Faraday middleware captures only listed hosts. Its calls,
86
+ # and openai gem calls whose base_url is a listed host, are recorded under the provider name
87
+ # given here.
86
88
  # config.capture.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
87
89
 
88
90
  # Streaming calls only report token usage when the request asks for it. Leave this
@@ -27,6 +27,7 @@ class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration[<%= Act
27
27
 
28
28
  def add_unique_index(columns)
29
29
  if postgresql?
30
+ LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, connection.index_name(TABLE, columns))
30
31
  add_index TABLE, columns, unique: true, algorithm: :concurrently, if_not_exists: true
31
32
  else
32
33
  add_index TABLE, columns, unique: true, if_not_exists: true
@@ -8,6 +8,7 @@ class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration[<%= A
8
8
 
9
9
  def up
10
10
  if postgresql?
11
+ LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, connection.index_name(TABLE, INDEX_COLUMNS))
11
12
  add_index TABLE, INDEX_COLUMNS, algorithm: :concurrently, if_not_exists: true
12
13
  elsif mysql?
13
14
  add_index TABLE, INDEX_COLUMNS, length: { value: 191 }, if_not_exists: true
@@ -9,7 +9,8 @@ class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration[<%= ActiveRecord::M
9
9
  UNPRICED_INDEX = :index_llm_cost_tracker_calls_on_unpriced
10
10
 
11
11
  def up
12
- add_index CALLS, :id, name: UNPRICED_INDEX, where: "total_cost IS NULL", **add_index_options
12
+ LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, UNPRICED_INDEX)
13
+ add_index CALLS, :id, name: UNPRICED_INDEX, where: "total_cost IS NULL", **add_index_options if postgresql?
13
14
  return unless table_exists?(INBOX)
14
15
 
15
16
  remove_index INBOX, column: UNUSED_INBOX_INDEX, **remove_index_options
@@ -10,6 +10,7 @@ class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration[<%= ActiveRec
10
10
  def up
11
11
  add_column TABLE, :total_cost, :decimal, precision: 20, scale: 8 unless column_exists?(TABLE, :total_cost)
12
12
  add_column TABLE, :tracked_at, :datetime unless column_exists?(TABLE, :tracked_at)
13
+ LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, connection.index_name(TABLE, INDEX))
13
14
  add_index TABLE, INDEX, **add_index_options
14
15
  remove_index TABLE, column: REPLACED_INDEX, **remove_index_options
15
16
  end
@@ -12,7 +12,7 @@ module LlmCostTracker
12
12
  TRANSIENT_PERSIST_ERRORS = [
13
13
  ActiveRecord::Deadlocked,
14
14
  ActiveRecord::LockWaitTimeout,
15
- ActiveRecord::StatementTimeout,
15
+ ActiveRecord::QueryAborted,
16
16
  ActiveRecord::ConnectionNotEstablished,
17
17
  LlmCostTracker::TransactionAbortedError
18
18
  ].freeze
@@ -29,20 +29,16 @@ module LlmCostTracker
29
29
  persist_batch(valid_rows, events) if events.any?
30
30
  rows.size
31
31
  rescue StandardError => e
32
- rows_to_mark = valid_rows&.any? ? valid_rows : rows
33
- if rows_to_mark&.any?
34
- transient = valid_rows&.any? && TRANSIENT_PERSIST_ERRORS.any? { |klass| e.is_a?(klass) }
35
- mark_failed_with_message(rows_to_mark, error_message_for(e), decrement_attempts: transient)
36
- end
32
+ release_after_failure(rows, valid_rows, e)
37
33
  raise
38
34
  end
39
35
 
40
36
  def pending?
41
- Ingestion::InboxEntry.pending.exists?
37
+ Ingestion::InboxEntry.uncached { Ingestion::InboxEntry.pending.exists? }
42
38
  end
43
39
 
44
40
  def claimable?
45
- claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
41
+ Ingestion::InboxEntry.uncached { claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists? }
46
42
  end
47
43
 
48
44
  def mark_failed_with_message(rows, message, decrement_attempts: false)
@@ -67,6 +63,10 @@ module LlmCostTracker
67
63
  nil
68
64
  end
69
65
 
66
+ private
67
+
68
+ attr_reader :identity
69
+
70
70
  def error_message_for(error)
71
71
  "#{error.class}: #{Redaction.text(error.message)}".byteslice(0, 1_000).scrub("")
72
72
  end
@@ -83,10 +83,6 @@ module LlmCostTracker
83
83
  )
84
84
  end
85
85
 
86
- private
87
-
88
- attr_reader :identity
89
-
90
86
  def id_sample(rows)
91
87
  sample = rows.first(10).map(&:id).join(", ")
92
88
  rows.size > 10 ? "#{sample}..." : sample
@@ -121,6 +117,18 @@ module LlmCostTracker
121
117
  [valid_rows, events]
122
118
  end
123
119
 
120
+ def release_after_failure(rows, valid_rows, error)
121
+ if valid_rows&.any?
122
+ mark_failed_with_message(valid_rows, error_message_for(error), decrement_attempts: transient?(error))
123
+ elsif rows&.any?
124
+ mark_failed_with_message(rows, error_message_for(error))
125
+ end
126
+ end
127
+
128
+ def transient?(error)
129
+ TRANSIENT_PERSIST_ERRORS.any? { |klass| error.is_a?(klass) }
130
+ end
131
+
124
132
  def persist_batch(rows, events)
125
133
  landed = []
126
134
  failed = Hash.new { |hash, message| hash[message] = [] }
@@ -128,14 +136,20 @@ module LlmCostTracker
128
136
  rescue *TRANSIENT_PERSIST_ERRORS
129
137
  raise
130
138
  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
139
+ rows.zip(events) { |row, event| persist_alone(row, event, landed, failed) }
138
140
  ensure
141
+ settle(landed, failed)
142
+ end
143
+
144
+ def persist_alone(row, event, landed, failed)
145
+ landed.concat(persist([row], [event]))
146
+ rescue *TRANSIENT_PERSIST_ERRORS
147
+ raise
148
+ rescue StandardError => e
149
+ failed[error_message_for(e)] << row
150
+ end
151
+
152
+ def settle(landed, failed)
139
153
  failed.each do |message, failed_rows|
140
154
  LlmCostTracker::Logging.warn(
141
155
  "Ingestion::Batch: #{failed_rows.size} inbox row(s) could not be stored " \
@@ -11,6 +11,9 @@ module LlmCostTracker
11
11
  module Ingestion
12
12
  module Inbox
13
13
  PAYLOAD_SCHEMA_VERSION = 2
14
+ OPTIONAL_FIELDS = %i[
15
+ latency_ms usage_source provider_response_id provider_project_id provider_api_key_id provider_workspace_id
16
+ ].freeze
14
17
 
15
18
  class << self
16
19
  def save(event)
@@ -30,9 +33,8 @@ module LlmCostTracker
30
33
  private
31
34
 
32
35
  def event_attributes_from(payload)
33
- cost = payload[:cost] && Charges::Cost.from_h(payload[:cost])
34
- token_usage = Usage::TokenUsage.build(**payload.fetch(:token_usage).slice(*Usage::TokenUsage.members))
35
-
36
+ cost = cost_from(payload)
37
+ token_usage = token_usage_from(payload)
36
38
  {
37
39
  event_id: payload.fetch(:event_id),
38
40
  provider: payload.fetch(:provider),
@@ -41,20 +43,27 @@ module LlmCostTracker
41
43
  pricing_mode: Pricing::Mode.normalize(payload[:pricing_mode]),
42
44
  cost: cost,
43
45
  tags: payload.fetch(:tags),
44
- latency_ms: payload[:latency_ms],
45
46
  stream: payload.fetch(:stream),
46
- usage_source: payload[:usage_source],
47
- provider_response_id: payload[:provider_response_id],
48
- provider_project_id: payload[:provider_project_id],
49
- provider_api_key_id: payload[:provider_api_key_id],
50
- provider_workspace_id: payload[:provider_workspace_id],
47
+ **OPTIONAL_FIELDS.to_h { |field| [field, payload[field]] },
51
48
  tracked_at: Time.iso8601(payload.fetch(:tracked_at)),
52
49
  cost_status: payload.fetch(:cost_status),
53
50
  pricing_snapshot: payload[:pricing_snapshot]&.deep_stringify_keys,
54
- line_items: (payload[:line_items] || []).map { |attrs| Charges::LineItem.build(attrs) }
51
+ line_items: line_items_from(payload)
55
52
  }
56
53
  end
57
54
 
55
+ def cost_from(payload)
56
+ payload[:cost] && Charges::Cost.from_h(payload[:cost])
57
+ end
58
+
59
+ def token_usage_from(payload)
60
+ Usage::TokenUsage.build(**payload.fetch(:token_usage).slice(*Usage::TokenUsage.members))
61
+ end
62
+
63
+ def line_items_from(payload)
64
+ (payload[:line_items] || []).map { |attrs| Charges::LineItem.build(attrs) }
65
+ end
66
+
58
67
  def row_for(event)
59
68
  now = Time.now.utc
60
69
  {
@@ -73,7 +82,7 @@ module LlmCostTracker
73
82
  end
74
83
 
75
84
  def insert_row(row)
76
- Pool.with_connection { |connection| execute_insert(connection, row) }
85
+ Ledger::Isolation.writing { Pool.with_connection { |connection| execute_insert(connection, row) } }
77
86
  rescue ActiveRecord::ConnectionTimeoutError => e
78
87
  raise LlmCostTracker::Error,
79
88
  "ledger inbox could not checkout a database connection: #{e.message}"
@@ -38,39 +38,21 @@ module LlmCostTracker
38
38
  def flush!(timeout: nil, require_lease: false)
39
39
  return true unless Ingestion.async?
40
40
 
41
- Ingestion.ensure_current_schema!
42
- MUTEX.synchronize { reset_after_fork! }
43
-
44
- deadline = Time.now.utc + flush_timeout_seconds(timeout)
45
- loop do
46
- return true unless Ingestion::Batch.new(identity: identity).pending?
47
- return false if Time.now.utc >= deadline
48
-
49
- processed = ingest_once(require_lease: require_lease)
50
- next unless processed.zero?
51
-
52
- duration = [INTERVAL_SECONDS, deadline - Time.now.utc].min
53
- return false unless duration.positive?
54
-
55
- sleep(duration)
41
+ prepare!
42
+ drained = drain_until(Time.now.utc + flush_timeout_seconds(timeout), require_lease)
43
+ unless drained
44
+ Logging.warn("Ingestion::Worker.flush! timed out with #{Ingestion::InboxEntry.pending.count} " \
45
+ "inbox row(s) still pending")
56
46
  end
47
+ drained && Ingestion::InboxEntry.uncached { Ingestion::InboxEntry.quarantined.none? }
57
48
  end
58
49
 
59
50
  def shutdown!(timeout: nil, drain: true)
60
51
  return true unless Ingestion.async?
61
52
 
62
53
  timeout ||= FLUSH_TIMEOUT_SECONDS
63
- thread = MUTEX.synchronize do
64
- @stop_requested = true
65
- @generation = @generation.to_i + 1
66
- @thread
67
- end
68
- begin
69
- wake_thread(thread)
70
- thread&.join(timeout)
71
- rescue StandardError => e
72
- handle_error(e)
73
- end
54
+ thread = request_stop
55
+ await_exit(thread, timeout)
74
56
  drain ? flush!(timeout: timeout, require_lease: true) : true
75
57
  rescue StandardError => e
76
58
  handle_error(e)
@@ -89,8 +71,7 @@ module LlmCostTracker
89
71
  end
90
72
 
91
73
  def ingest_once(require_lease: true)
92
- Ingestion.ensure_current_schema!
93
- MUTEX.synchronize { reset_after_fork! }
74
+ prepare!
94
75
  batch = Ingestion::Batch.new(identity: identity)
95
76
  return 0 unless batch.claimable?
96
77
  return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
@@ -108,14 +89,7 @@ module LlmCostTracker
108
89
  loop do
109
90
  break if MUTEX.synchronize { @stop_requested || generation != @generation }
110
91
 
111
- processed = Rails.application.executor.wrap { ingest_once }
112
- release_connection!
113
- if processed.zero?
114
- sleep(idle_interval)
115
- idle_interval = [idle_interval * 2, MAX_IDLE_INTERVAL_SECONDS].min
116
- else
117
- idle_interval = IDLE_INTERVAL_SECONDS
118
- end
92
+ idle_interval = ingest_or_back_off(idle_interval)
119
93
  rescue StandardError => e
120
94
  handle_error(e)
121
95
  release_connection!
@@ -126,6 +100,52 @@ module LlmCostTracker
126
100
  MUTEX.synchronize { @thread = nil if @thread.equal?(Thread.current) }
127
101
  end
128
102
 
103
+ def ingest_or_back_off(idle_interval)
104
+ processed = Rails.application.executor.wrap { ingest_once }
105
+ release_connection!
106
+ return IDLE_INTERVAL_SECONDS unless processed.zero?
107
+
108
+ sleep(idle_interval)
109
+ [idle_interval * 2, MAX_IDLE_INTERVAL_SECONDS].min
110
+ end
111
+
112
+ def prepare!
113
+ Ingestion.ensure_current_schema!
114
+ MUTEX.synchronize { reset_after_fork! }
115
+ end
116
+
117
+ def drain_until(deadline, require_lease)
118
+ loop do
119
+ return true unless Ingestion::Batch.new(identity: identity).pending?
120
+ return false unless time_left(deadline).positive?
121
+ next unless ingest_once(require_lease: require_lease).zero?
122
+
123
+ duration = [INTERVAL_SECONDS, time_left(deadline)].min
124
+ return false unless duration.positive?
125
+
126
+ sleep(duration)
127
+ end
128
+ end
129
+
130
+ def time_left(deadline)
131
+ deadline - Time.now.utc
132
+ end
133
+
134
+ def request_stop
135
+ MUTEX.synchronize do
136
+ @stop_requested = true
137
+ @generation = @generation.to_i + 1
138
+ @thread
139
+ end
140
+ end
141
+
142
+ def await_exit(thread, timeout)
143
+ wake_thread(thread)
144
+ thread&.join(timeout)
145
+ rescue StandardError => e
146
+ handle_error(e)
147
+ end
148
+
129
149
  def reset_after_fork!
130
150
  return if @pid == Process.pid
131
151
 
@@ -145,7 +165,8 @@ module LlmCostTracker
145
165
  end
146
166
 
147
167
  def handle_error(error)
148
- Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}")
168
+ Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}" \
169
+ "#{" (#{error.backtrace.first})" if error.backtrace}")
149
170
  end
150
171
 
151
172
  def release_connection!
@@ -16,6 +16,8 @@ module LlmCostTracker
16
16
  autoload :Worker, "llm_cost_tracker/ingestion/worker"
17
17
 
18
18
  VERIFY_TAG = "llm_cost_tracker_verify"
19
+ CAPTURE_CHECK = "active_record capture"
20
+ MISSING_CALLS_TABLE = "llm_cost_tracker_calls table is missing; run install generator and migrate"
19
21
 
20
22
  class << self
21
23
  def table_name_prefix
@@ -23,9 +25,7 @@ module LlmCostTracker
23
25
  end
24
26
 
25
27
  def ensure_current_schema!
26
- unless LlmCostTracker::Call.table_exists?
27
- raise Error, "llm_cost_tracker_calls table is missing; run install generator and migrate"
28
- end
28
+ raise Error, MISSING_CALLS_TABLE unless LlmCostTracker::Call.table_exists?
29
29
 
30
30
  guards_for_current_config.each do |schema_module, table_name|
31
31
  next if schema_module == Ledger::Schema::CallRollups && !Ledger::Rollups.cache_active?
@@ -51,13 +51,7 @@ module LlmCostTracker
51
51
 
52
52
  def verify
53
53
  unless LlmCostTracker::Call.table_exists?
54
- return [
55
- LlmCostTracker::Check.new(
56
- :error,
57
- "active_record",
58
- "llm_cost_tracker_calls table is missing; run install generator and migrate"
59
- )
60
- ]
54
+ return [LlmCostTracker::Check.new(:error, "active_record", MISSING_CALLS_TABLE)]
61
55
  end
62
56
 
63
57
  [capture_check]
@@ -72,7 +66,6 @@ module LlmCostTracker
72
66
  response_id = "lct_verify_#{SecureRandom.hex(8)}"
73
67
  notifications = []
74
68
  subscription = subscribe_to_verification(response_id, notifications)
75
-
76
69
  event = LlmCostTracker.track(
77
70
  provider: provider,
78
71
  model: model,
@@ -81,21 +74,9 @@ module LlmCostTracker
81
74
  tags: { feature: VERIFY_TAG }
82
75
  )
83
76
  LlmCostTracker::Ingestion::Worker.flush! if async?
84
- persisted = LlmCostTracker::Call.where(provider_response_id: response_id).exists?
85
-
86
- return capture_success if persisted && notifications.any?
87
-
88
- LlmCostTracker::Check.new(
89
- :error,
90
- "active_record capture",
91
- capture_failure_message(persisted, notifications)
92
- )
93
- rescue LlmCostTracker::BudgetExceededError => e
94
- LlmCostTracker::Check.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
95
- rescue LlmCostTracker::Error => e
96
- LlmCostTracker::Check.new(:error, "active_record capture", e.message)
77
+ capture_result(LlmCostTracker::Call.where(provider_response_id: response_id).exists?, notifications)
97
78
  rescue StandardError => e
98
- LlmCostTracker::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
79
+ LlmCostTracker::Check.new(:error, CAPTURE_CHECK, capture_error_message(e))
99
80
  ensure
100
81
  cleanup_verification_call(response_id) if response_id
101
82
  cleanup_verification_inbox(event: event, response_id: response_id)
@@ -108,20 +89,24 @@ module LlmCostTracker
108
89
  end
109
90
  end
110
91
 
111
- def capture_success
112
- path = async? ? "async inbox" : "inline writer"
113
- LlmCostTracker::Check.new(
114
- :ok,
115
- "active_record capture",
116
- "manual event emitted and persisted through #{path}"
117
- )
118
- end
92
+ def capture_result(persisted, notifications)
93
+ if persisted && notifications.any?
94
+ path = async? ? "async inbox" : "inline writer"
95
+ return LlmCostTracker::Check.new(:ok, CAPTURE_CHECK, "manual event emitted and persisted through #{path}")
96
+ end
119
97
 
120
- def capture_failure_message(persisted, notifications)
121
98
  missing = []
122
99
  missing << "notification" if notifications.empty?
123
100
  missing << "persisted row" unless persisted
124
- "missing #{missing.join(' and ')} for synthetic manual event"
101
+ LlmCostTracker::Check.new(:error, CAPTURE_CHECK, "missing #{missing.join(' and ')} for synthetic manual event")
102
+ end
103
+
104
+ def capture_error_message(error)
105
+ case error
106
+ when LlmCostTracker::BudgetExceededError then "blocked by budget guardrail: #{error.message}"
107
+ when LlmCostTracker::Error then error.message
108
+ else "#{error.class}: #{error.message}"
109
+ end
125
110
  end
126
111
 
127
112
  def cleanup_verification_call(response_id)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Integrations
5
+ module Anthropic
6
+ class BatchResultsCapture
7
+ include Enumerable
8
+
9
+ def initialize(raw_stream)
10
+ @raw_stream = raw_stream
11
+ end
12
+
13
+ def each(&block)
14
+ return enum_for(:each) unless block
15
+
16
+ deferred = nil
17
+ @raw_stream.each do |response|
18
+ begin
19
+ Integrations::Anthropic.record_batch_result(response)
20
+ rescue BudgetExceededError, UnknownPricingError => e
21
+ deferred ||= e
22
+ end
23
+ block.call(response)
24
+ end
25
+ raise deferred if deferred
26
+ end
27
+
28
+ def respond_to_missing?(name, include_private = false)
29
+ @raw_stream.respond_to?(name, include_private) || super
30
+ end
31
+
32
+ def method_missing(name, ...)
33
+ return super unless @raw_stream.respond_to?(name)
34
+
35
+ @raw_stream.public_send(name, ...)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Integrations
5
+ module Anthropic
6
+ module MessagesPatch
7
+ def create(*args, **kwargs)
8
+ Integrations::Anthropic.wrap_blocking(args, kwargs, **Integrations::Anthropic.blocking_seam(@client)) { super }
9
+ end
10
+
11
+ def stream(*args, **kwargs)
12
+ Integrations::Anthropic.wrap_stream(args, kwargs, **Integrations::Anthropic.stream_seam(@client)) { super }
13
+ end
14
+
15
+ def stream_raw(*args, **kwargs)
16
+ Integrations::Anthropic.wrap_stream(args, kwargs, **Integrations::Anthropic.stream_seam(@client)) { super }
17
+ end
18
+ end
19
+
20
+ module FallbackMiddlewarePatch
21
+ def call(req, nxt)
22
+ return super if req.streaming? || !Integrations::Anthropic.active?
23
+
24
+ hops = []
25
+ response = super(req, ->(hop_req) { nxt.call(hop_req).tap { |hop| hops << [hop_req, hop] } })
26
+ answered = hops.select { |_hop_req, hop| hop.status < 300 }
27
+ answered[...-1].each { |hop| Integrations::Anthropic.record_refused_hop(*hop) }
28
+ response
29
+ end
30
+
31
+ private
32
+
33
+ def consume_hop(*args, **kwargs)
34
+ refused = Thread.current[:llm_cost_tracker_refused_hop]
35
+ Thread.current[:llm_cost_tracker_refused_hop] = nil
36
+ Integrations::Anthropic.record_refused_stream_hop(refused) if refused && kwargs[:splice]
37
+ super.tap { |hop| Thread.current[:llm_cost_tracker_refused_hop] = hop if hop[:refused] }
38
+ end
39
+ end
40
+
41
+ module BatchesPatch
42
+ def create(*args, **kwargs)
43
+ Integrations::Anthropic.enforce_budget!(request: Integrations::Anthropic.request_params(args, kwargs))
44
+ super
45
+ end
46
+
47
+ def results_streaming(*args, **kwargs)
48
+ raw = super
49
+ return raw unless Integrations::Anthropic.active?
50
+
51
+ BatchResultsCapture.new(raw)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end