llm_cost_tracker 0.14.1 → 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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
@@ -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)
@@ -18,7 +21,7 @@ module LlmCostTracker
18
21
  end
19
22
 
20
23
  def event_from_row(row)
21
- payload = JSON.parse(row.payload, symbolize_names: true)
24
+ payload = JSON.parse(row.payload, symbolize_names: true, allow_duplicate_key: true)
22
25
  schema_version = payload[:schema_version]
23
26
  unless schema_version == PAYLOAD_SCHEMA_VERSION
24
27
  raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
@@ -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
  {
@@ -69,17 +78,11 @@ module LlmCostTracker
69
78
  end
70
79
 
71
80
  def payload_for(event)
72
- event.to_h.merge(
73
- schema_version: PAYLOAD_SCHEMA_VERSION,
74
- event_id: event.event_id,
75
- provider: event.provider,
76
- model: event.model,
77
- tracked_at: event.tracked_at.iso8601(6)
78
- )
81
+ event.to_h.merge(schema_version: PAYLOAD_SCHEMA_VERSION, tracked_at: event.tracked_at.iso8601(6))
79
82
  end
80
83
 
81
84
  def insert_row(row)
82
- Pool.with_connection { |connection| execute_insert(connection, row) }
85
+ Ledger::Isolation.writing { Pool.with_connection { |connection| execute_insert(connection, row) } }
83
86
  rescue ActiveRecord::ConnectionTimeoutError => e
84
87
  raise LlmCostTracker::Error,
85
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,11 +25,11 @@ 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
+ next if schema_module == Ledger::Schema::CallRollups && !Ledger::Rollups.cache_active?
32
+
31
33
  errors = schema_module.current_schema_errors
32
34
  next if errors.empty?
33
35
 
@@ -49,13 +51,7 @@ module LlmCostTracker
49
51
 
50
52
  def verify
51
53
  unless LlmCostTracker::Call.table_exists?
52
- return [
53
- LlmCostTracker::Check.new(
54
- :error,
55
- "active_record",
56
- "llm_cost_tracker_calls table is missing; run install generator and migrate"
57
- )
58
- ]
54
+ return [LlmCostTracker::Check.new(:error, "active_record", MISSING_CALLS_TABLE)]
59
55
  end
60
56
 
61
57
  [capture_check]
@@ -70,7 +66,6 @@ module LlmCostTracker
70
66
  response_id = "lct_verify_#{SecureRandom.hex(8)}"
71
67
  notifications = []
72
68
  subscription = subscribe_to_verification(response_id, notifications)
73
-
74
69
  event = LlmCostTracker.track(
75
70
  provider: provider,
76
71
  model: model,
@@ -79,21 +74,9 @@ module LlmCostTracker
79
74
  tags: { feature: VERIFY_TAG }
80
75
  )
81
76
  LlmCostTracker::Ingestion::Worker.flush! if async?
82
- persisted = LlmCostTracker::Call.where(provider_response_id: response_id).exists?
83
-
84
- return capture_success if persisted && notifications.any?
85
-
86
- LlmCostTracker::Check.new(
87
- :error,
88
- "active_record capture",
89
- capture_failure_message(persisted, notifications)
90
- )
91
- rescue LlmCostTracker::BudgetExceededError => e
92
- LlmCostTracker::Check.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
93
- rescue LlmCostTracker::Error => e
94
- LlmCostTracker::Check.new(:error, "active_record capture", e.message)
77
+ capture_result(LlmCostTracker::Call.where(provider_response_id: response_id).exists?, notifications)
95
78
  rescue StandardError => e
96
- LlmCostTracker::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
79
+ LlmCostTracker::Check.new(:error, CAPTURE_CHECK, capture_error_message(e))
97
80
  ensure
98
81
  cleanup_verification_call(response_id) if response_id
99
82
  cleanup_verification_inbox(event: event, response_id: response_id)
@@ -106,25 +89,29 @@ module LlmCostTracker
106
89
  end
107
90
  end
108
91
 
109
- def capture_success
110
- path = async? ? "async inbox" : "inline writer"
111
- LlmCostTracker::Check.new(
112
- :ok,
113
- "active_record capture",
114
- "manual event emitted and persisted through #{path}"
115
- )
116
- 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
117
97
 
118
- def capture_failure_message(persisted, notifications)
119
98
  missing = []
120
99
  missing << "notification" if notifications.empty?
121
100
  missing << "persisted row" unless persisted
122
- "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
123
110
  end
124
111
 
125
112
  def cleanup_verification_call(response_id)
126
113
  relation = LlmCostTracker::Call.where(provider_response_id: response_id)
127
- records = relation.select(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider).to_a
114
+ records = relation.select(:id, *LlmCostTracker::Ledger::Rollups::SOURCE_COLUMNS).to_a
128
115
  return if records.empty?
129
116
 
130
117
  relation.delete_all
@@ -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
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "../providers/anthropic/usage_extractor"
5
4
  require_relative "../providers/anthropic/response_parser"
5
+ require_relative "anthropic/patches"
6
+ require_relative "anthropic/batch_results_capture"
6
7
 
7
8
  module LlmCostTracker
8
9
  module Integrations
@@ -17,27 +18,48 @@ module LlmCostTracker
17
18
  patch_target("Anthropic::Resources::Messages", with: MessagesPatch),
18
19
  patch_target("Anthropic::Resources::Beta::Messages", with: MessagesPatch, optional: true),
19
20
  patch_target("Anthropic::Resources::Messages::Batches", with: BatchesPatch, optional: true),
20
- patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true)
21
+ patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true),
22
+ patch_target("Anthropic::BetaRefusalFallbackMiddleware",
23
+ with: FallbackMiddlewarePatch,
24
+ optional: true,
25
+ since: "1.54.0")
21
26
  ]
22
27
  end
23
28
 
24
- def record_message(message, request:, latency_ms:)
29
+ def blocking_seam(client)
30
+ host = client_host(client)
31
+ {
32
+ record: lambda do |message, request, latency_ms|
33
+ record_message(message, request: request, latency_ms: latency_ms, host: host)
34
+ end
35
+ }
36
+ end
37
+
38
+ def stream_seam(client)
39
+ host = client_host(client)
40
+ { collector: ->(request) { stream_collector(request, host: host) } }
41
+ end
42
+
43
+ def stream_collector(request, host: nil)
44
+ regional = Providers::Anthropic::UsageExtractor.regional_host?(host, request[:model])
45
+ super(request, pricing_mode: ("data_residency" if regional))
46
+ end
47
+
48
+ def record_message(message, request:, latency_ms:, host: nil)
25
49
  return unless active?
26
50
 
27
51
  record_safely do
28
52
  usage = message.usage
29
- next unless usage
30
- next if usage.input_tokens.nil? && usage.output_tokens.nil?
31
-
32
- usage_hash = usage.deep_to_h
53
+ next unless tokens_reported?(usage)
33
54
 
34
- LlmCostTracker::Tracker.record(
35
- event: Providers::Anthropic::ResponseParser.event_from_usage(
36
- usage: usage_hash,
55
+ Tracker.record(
56
+ event: message_event(
57
+ message,
58
+ usage: usage.deep_to_h,
37
59
  model: message.model || request[:model],
38
- provider_response_id: message.id,
39
60
  usage_source: Usage::Source::SDK_RESPONSE,
40
- request: request
61
+ request: request,
62
+ host: host
41
63
  ),
42
64
  latency_ms: latency_ms
43
65
  )
@@ -48,106 +70,76 @@ module LlmCostTracker
48
70
  return unless active?
49
71
 
50
72
  record_safely do
51
- next unless response.respond_to?(:result) && response.result
52
-
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
73
+ message = succeeded_message(response)
57
74
  next unless message
58
- next if LlmCostTracker::Call.already_recorded?(provider: "anthropic", provider_response_id: message.id)
75
+ next if Call.already_recorded?(provider: "anthropic", provider_response_id: message.id)
59
76
 
60
77
  usage = message.usage
61
- next unless usage
62
- next if usage.input_tokens.nil? && usage.output_tokens.nil?
78
+ next unless tokens_reported?(usage)
63
79
 
64
- usage_hash = usage.deep_to_h
65
- LlmCostTracker::Tracker.record(
66
- event: Providers::Anthropic::ResponseParser.event_from_usage(
67
- usage: usage_hash,
80
+ record_once(
81
+ message_event(
82
+ message,
83
+ usage: usage.deep_to_h.merge(service_tier: "batch"),
68
84
  model: message.model,
69
- provider_response_id: message.id,
70
- usage_source: Usage::Source::SDK_BATCH_RESULT,
71
- pricing_mode: "batch"
85
+ usage_source: Usage::Source::SDK_BATCH_RESULT
72
86
  )
73
87
  )
74
88
  end
75
89
  end
76
90
 
77
- def stream_pricing_mode(request)
78
- Providers::Anthropic::UsageExtractor.pricing_mode(request: request || {}, usage: nil)
91
+ def record_refused_hop(request, response)
92
+ record_safely { record_message(response.parse, request: request.body, latency_ms: nil) }
93
+ rescue BudgetExceededError, UnknownPricingError
94
+ nil
79
95
  end
80
- end
81
96
 
82
- module MessagesPatch
83
- def create(*args, **kwargs)
84
- LlmCostTracker::Integrations::Anthropic.wrap_blocking(
85
- args,
86
- kwargs,
87
- record: lambda do |message, request, latency_ms|
88
- LlmCostTracker::Integrations::Anthropic.record_message(
89
- message, request: request, latency_ms: latency_ms
90
- )
91
- end
92
- ) { super }
93
- end
94
-
95
- def stream(*args, **kwargs)
96
- LlmCostTracker::Integrations::Anthropic.wrap_stream(
97
- args,
98
- kwargs,
99
- collector: ->(request) { LlmCostTracker::Integrations::Anthropic.stream_collector(request) }
100
- ) { super }
101
- end
102
-
103
- def stream_raw(*args, **kwargs)
104
- LlmCostTracker::Integrations::Anthropic.wrap_stream(
105
- args,
106
- kwargs,
107
- collector: ->(request) { LlmCostTracker::Integrations::Anthropic.stream_collector(request) }
108
- ) { super }
109
- end
110
- end
97
+ def record_refused_stream_hop(hop)
98
+ return unless active?
111
99
 
112
- module BatchesPatch
113
- def results_streaming(*args, **kwargs)
114
- raw = super
115
- return raw unless LlmCostTracker::Integrations::Anthropic.active?
100
+ usage = hop.dig(:refused, :usage).to_h.deep_symbolize_keys
101
+ produced_output = usage[:output_tokens].to_i.positive?
102
+ usage = usage.slice(:server_tool_use) if produced_output
103
+ return if produced_output && Providers::Anthropic::UsageExtractor.service_line_items(usage).empty?
116
104
 
117
- BatchResultsCapture.new(raw)
105
+ record_safely do
106
+ Tracker.record(
107
+ event: Providers::Anthropic::ResponseParser.event_from_usage(
108
+ usage: usage,
109
+ model: hop[:model],
110
+ provider_response_id: nil,
111
+ usage_source: Usage::Source::STREAM_FINAL,
112
+ stream: true,
113
+ stop_reason: ("refusal" unless produced_output),
114
+ refusal_category: hop.dig(:refused, :stop_details, "category")
115
+ )
116
+ )
117
+ end
118
+ rescue BudgetExceededError, UnknownPricingError
119
+ nil
118
120
  end
119
- end
120
-
121
- class BatchResultsCapture
122
- include Enumerable
123
121
 
124
- def initialize(raw_stream)
125
- @raw_stream = raw_stream
126
- end
122
+ private
127
123
 
128
- def each(&block)
129
- return enum_for(:each) unless block
124
+ def succeeded_message(response)
125
+ result = response.result if response.respond_to?(:result)
126
+ return unless result.respond_to?(:type) && result.type.to_s == "succeeded"
130
127
 
131
- deferred = nil
132
- @raw_stream.each do |response|
133
- begin
134
- LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
135
- rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
136
- deferred ||= e
137
- end
138
- block.call(response)
139
- end
140
- raise deferred if deferred
128
+ result.message if result.respond_to?(:message)
141
129
  end
142
130
 
143
- def respond_to_missing?(name, include_private = false)
144
- @raw_stream.respond_to?(name, include_private) || super
131
+ def tokens_reported?(usage)
132
+ usage && !(usage.input_tokens.nil? && usage.output_tokens.nil?)
145
133
  end
146
134
 
147
- def method_missing(name, ...)
148
- return super unless @raw_stream.respond_to?(name)
149
-
150
- @raw_stream.public_send(name, ...)
135
+ def message_event(message, **attributes)
136
+ Providers::Anthropic::ResponseParser.event_from_usage(
137
+ provider_response_id: message.id,
138
+ stop_reason: message.stop_reason,
139
+ refusal_category: message.stop_details&.category,
140
+ content: message.deep_to_h[:content],
141
+ **attributes
142
+ )
151
143
  end
152
144
  end
153
145
  end