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
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "base"
4
4
  require_relative "../providers/anthropic/response_parser"
5
+ require_relative "anthropic/patches"
6
+ require_relative "anthropic/batch_results_capture"
5
7
 
6
8
  module LlmCostTracker
7
9
  module Integrations
@@ -17,28 +19,47 @@ module LlmCostTracker
17
19
  patch_target("Anthropic::Resources::Beta::Messages", with: MessagesPatch, optional: true),
18
20
  patch_target("Anthropic::Resources::Messages::Batches", with: BatchesPatch, optional: true),
19
21
  patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true),
20
- patch_target("Anthropic::BetaRefusalFallbackMiddleware", with: FallbackMiddlewarePatch, 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
61
  request: request,
41
- **response_fields(message)
62
+ host: host
42
63
  ),
43
64
  latency_ms: latency_ms
44
65
  )
@@ -49,26 +70,19 @@ module LlmCostTracker
49
70
  return unless active?
50
71
 
51
72
  record_safely do
52
- next unless response.respond_to?(:result) && response.result
53
-
54
- result = response.result
55
- next unless result.respond_to?(:type) && result.type.to_s == "succeeded"
56
-
57
- message = result.respond_to?(:message) ? result.message : nil
73
+ message = succeeded_message(response)
58
74
  next unless message
59
- 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)
60
76
 
61
77
  usage = message.usage
62
- next unless usage
63
- next if usage.input_tokens.nil? && usage.output_tokens.nil?
78
+ next unless tokens_reported?(usage)
64
79
 
65
80
  record_once(
66
- Providers::Anthropic::ResponseParser.event_from_usage(
81
+ message_event(
82
+ message,
67
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
- **response_fields(message)
85
+ usage_source: Usage::Source::SDK_BATCH_RESULT
72
86
  )
73
87
  )
74
88
  end
@@ -76,7 +90,7 @@ module LlmCostTracker
76
90
 
77
91
  def record_refused_hop(request, response)
78
92
  record_safely { record_message(response.parse, request: request.body, latency_ms: nil) }
79
- rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
93
+ rescue BudgetExceededError, UnknownPricingError
80
94
  nil
81
95
  end
82
96
 
@@ -84,123 +98,48 @@ module LlmCostTracker
84
98
  return unless active?
85
99
 
86
100
  usage = hop.dig(:refused, :usage).to_h.deep_symbolize_keys
87
- output = usage[:output_tokens].to_i.positive?
88
- usage = usage.slice(:server_tool_use) if output
89
- return if output && Providers::Anthropic::UsageExtractor.service_line_items(usage).empty?
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?
90
104
 
91
105
  record_safely do
92
- LlmCostTracker::Tracker.record(
106
+ Tracker.record(
93
107
  event: Providers::Anthropic::ResponseParser.event_from_usage(
94
108
  usage: usage,
95
109
  model: hop[:model],
96
110
  provider_response_id: nil,
97
111
  usage_source: Usage::Source::STREAM_FINAL,
98
112
  stream: true,
99
- stop_reason: ("refusal" unless output),
113
+ stop_reason: ("refusal" unless produced_output),
100
114
  refusal_category: hop.dig(:refused, :stop_details, "category")
101
115
  )
102
116
  )
103
117
  end
104
- rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
118
+ rescue BudgetExceededError, UnknownPricingError
105
119
  nil
106
120
  end
107
121
 
108
- def response_fields(message)
109
- { stop_reason: message.stop_reason, refusal_category: message.stop_details&.category,
110
- content: message.deep_to_h[:content] }
111
- end
112
- end
113
-
114
- module MessagesPatch
115
- def create(*args, **kwargs)
116
- LlmCostTracker::Integrations::Anthropic.wrap_blocking(
117
- args,
118
- kwargs,
119
- record: lambda do |message, request, latency_ms|
120
- LlmCostTracker::Integrations::Anthropic.record_message(
121
- message, request: request, latency_ms: latency_ms
122
- )
123
- end
124
- ) { super }
125
- end
126
-
127
- def stream(*args, **kwargs)
128
- LlmCostTracker::Integrations::Anthropic.wrap_stream(
129
- args,
130
- kwargs,
131
- collector: ->(request) { LlmCostTracker::Integrations::Anthropic.stream_collector(request) }
132
- ) { super }
133
- end
134
-
135
- def stream_raw(*args, **kwargs)
136
- LlmCostTracker::Integrations::Anthropic.wrap_stream(
137
- args,
138
- kwargs,
139
- collector: ->(request) { LlmCostTracker::Integrations::Anthropic.stream_collector(request) }
140
- ) { super }
141
- end
142
- end
143
-
144
- module FallbackMiddlewarePatch
145
- def call(req, nxt)
146
- return super if req.streaming? || !LlmCostTracker::Integrations::Anthropic.active?
147
-
148
- hops = []
149
- response = super(req, ->(hop_req) { nxt.call(hop_req).tap { |hop| hops << [hop_req, hop] } })
150
- answered = hops.select { |_hop_req, hop| hop.status < 300 }
151
- answered[...-1].each { |hop| LlmCostTracker::Integrations::Anthropic.record_refused_hop(*hop) }
152
- response
153
- end
154
-
155
122
  private
156
123
 
157
- def consume_hop(*args, **kwargs)
158
- refused = Thread.current[:llm_cost_tracker_refused_hop]
159
- Thread.current[:llm_cost_tracker_refused_hop] = nil
160
- LlmCostTracker::Integrations::Anthropic.record_refused_stream_hop(refused) if refused && kwargs[:splice]
161
- super.tap { |hop| Thread.current[:llm_cost_tracker_refused_hop] = hop if hop[:refused] }
162
- end
163
- end
164
-
165
- module BatchesPatch
166
- def results_streaming(*args, **kwargs)
167
- raw = super
168
- return raw unless LlmCostTracker::Integrations::Anthropic.active?
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"
169
127
 
170
- BatchResultsCapture.new(raw)
128
+ result.message if result.respond_to?(:message)
171
129
  end
172
- end
173
-
174
- class BatchResultsCapture
175
- include Enumerable
176
-
177
- def initialize(raw_stream)
178
- @raw_stream = raw_stream
179
- end
180
-
181
- def each(&block)
182
- return enum_for(:each) unless block
183
130
 
184
- deferred = nil
185
- @raw_stream.each do |response|
186
- begin
187
- LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
188
- rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
189
- deferred ||= e
190
- end
191
- block.call(response)
192
- end
193
- raise deferred if deferred
131
+ def tokens_reported?(usage)
132
+ usage && !(usage.input_tokens.nil? && usage.output_tokens.nil?)
194
133
  end
195
134
 
196
- def respond_to_missing?(name, include_private = false)
197
- @raw_stream.respond_to?(name, include_private) || super
198
- end
199
-
200
- def method_missing(name, ...)
201
- return super unless @raw_stream.respond_to?(name)
202
-
203
- @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
+ )
204
143
  end
205
144
  end
206
145
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module LlmCostTracker
6
+ module Integrations
7
+ module Base
8
+ PatchTarget = Data.define(:constant_name, :patch, :optional, :skip_when_methods_missing, :since) do
9
+ def target_class = constant_name.to_s.safe_constantize
10
+
11
+ def installed? = target_class&.ancestors&.include?(patch)
12
+
13
+ def missing?(version) = target_class.nil? && (since.nil? || version.nil? || version >= Gem::Version.new(since))
14
+
15
+ def install
16
+ target = target_class
17
+ target.prepend(patch) unless target.nil? || target.ancestors.include?(patch)
18
+ end
19
+
20
+ def problems
21
+ target = target_class
22
+ return [] if target.nil? && optional
23
+ return ["#{constant_name} is not loaded"] unless target
24
+ return [] if skip_when_methods_missing
25
+
26
+ patch.instance_methods.filter_map do |method_name|
27
+ next if target.method_defined?(method_name) || target.private_method_defined?(method_name)
28
+
29
+ "#{constant_name}##{method_name} is not available"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -8,6 +8,7 @@ require_relative "../logging"
8
8
  require_relative "../timing"
9
9
  require_relative "../capture/stream_collector"
10
10
  require_relative "../capture/stream_tracker"
11
+ require_relative "base/patch_target"
11
12
 
12
13
  module LlmCostTracker
13
14
  module Integrations
@@ -22,39 +23,57 @@ module LlmCostTracker
22
23
  LlmCostTracker.configuration.enabled && LlmCostTracker.configuration.instrumented?(integration_name)
23
24
  end
24
25
 
26
+ def minimum_version(value = nil)
27
+ @minimum_version = value if value
28
+ @minimum_version
29
+ end
30
+
31
+ def maximum_version(value = nil)
32
+ @maximum_version = value if value
33
+ @maximum_version
34
+ end
35
+
36
+ def gem_version
37
+ Gem.loaded_specs[integration_name.to_s]&.version
38
+ end
39
+
40
+ def patch_targets = []
41
+
42
+ def patch_target(constant_name, with:, optional: false, skip_when_methods_missing: false, since: nil)
43
+ PatchTarget.new(constant_name:, patch: with, optional:, skip_when_methods_missing:, since:)
44
+ end
45
+
25
46
  def install
26
47
  validate_contract!
27
48
  Logging.warn(untested_version_message) if untested_version?
28
- patch_targets.each do |target|
29
- target_class = target.fetch(:constant_name).to_s.safe_constantize
30
- patch = target.fetch(:patch)
31
- target_class.prepend(patch) if target_class && !target_class.ancestors.include?(patch)
32
- end
49
+ patch_targets.each(&:install)
33
50
  end
34
51
 
35
52
  def status
36
53
  name = integration_name.to_s
37
- problems = version_problems + target_problems
38
- if problems.any?
39
- return Check.new(:warn, name, "#{name} integration cannot be installed: #{problems.join('; ')}")
40
- end
54
+ violation = contract_violation
55
+ return Check.new(:warn, name, violation) if violation
41
56
  return Check.new(:warn, name, untested_version_message) if untested_version?
57
+ return Check.new(:warn, name, "#{name} integration is enabled but not installed") unless installed?
42
58
 
43
- installed = patch_targets.reject { |target| target.fetch(:optional) }.all? do |target|
44
- target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
45
- end
46
- return Check.new(:ok, name, "#{name} integration installed") if installed
59
+ missing = missing_targets
60
+ return Check.new(:ok, name, "#{name} integration installed") if missing.empty?
47
61
 
48
- Check.new(:warn, name, "#{name} integration is enabled but not installed")
62
+ Check.new(
63
+ :warn,
64
+ name,
65
+ "#{name} integration installed without #{missing.join(', ')}; their calls are not recorded"
66
+ )
49
67
  end
50
68
 
51
- def enforce_budget!(request:, provider: self.provider)
69
+ def enforce_budget!(request:, provider: self.provider, tags: nil)
52
70
  return unless active?
53
71
 
54
72
  LlmCostTracker::Budget.enforce!(
55
73
  provider: provider,
56
74
  model: request[:model],
57
- request: request
75
+ request: request,
76
+ tags: tags
58
77
  )
59
78
  end
60
79
 
@@ -92,21 +111,23 @@ module LlmCostTracker
92
111
  end
93
112
  end
94
113
 
95
- def record_once(event)
96
- LlmCostTracker::Tracker.record(event: event.keyed_by_response_id)
114
+ def record_once(event, **)
115
+ LlmCostTracker::Tracker.record(event: event.keyed_by_response_id, **)
97
116
  rescue ActiveRecord::RecordNotUnique
98
117
  nil
99
118
  end
100
119
 
101
- def provider_response_id_for(response) = response&.try(:id)
120
+ def provider_response_id_for(response) = (response.id if response.respond_to?(:id))
121
+
122
+ def client_host(client)
123
+ URI.parse(client.base_url.to_s).host if client
124
+ rescue URI::InvalidURIError
125
+ nil
126
+ end
102
127
 
103
128
  def request_params(args, kwargs)
104
- params =
105
- case args.first
106
- when Hash then args.first
107
- when nil then {}
108
- else args.first.to_h
109
- end
129
+ params = args.first
130
+ params = params.to_h unless params.is_a?(Hash)
110
131
  params.merge(kwargs).with_indifferent_access
111
132
  rescue StandardError
112
133
  kwargs.to_h.with_indifferent_access
@@ -129,58 +150,36 @@ module LlmCostTracker
129
150
  track_stream(stream, collector: built)
130
151
  end
131
152
 
132
- def track_stream(stream, collector:)
133
- return stream unless active?
134
-
135
- LlmCostTracker::Capture::StreamTracker.new(
136
- stream: stream,
137
- collector: collector,
138
- active: -> { active? },
139
- finish: ->(errored) { record_safely { collector.finish!(errored: errored) } }
140
- ).wrap
141
- end
142
-
143
- def stream_collector(request)
153
+ def stream_collector(request, pricing_mode: nil)
144
154
  LlmCostTracker::Capture::StreamCollector.new(
145
155
  provider: provider,
146
156
  model: request[:model],
157
+ pricing_mode: pricing_mode,
147
158
  request: request
148
159
  )
149
160
  end
150
161
 
151
- def minimum_version(value = nil)
152
- @minimum_version = value if value
153
- @minimum_version
154
- end
162
+ private
155
163
 
156
- def maximum_version(value = nil)
157
- @maximum_version = value if value
158
- @maximum_version
159
- end
164
+ def track_stream(stream, collector:)
165
+ return stream unless active?
160
166
 
161
- def gem_version
162
- Gem.loaded_specs[integration_name.to_s]&.version
167
+ LlmCostTracker::Capture::StreamTracker.new(
168
+ stream: stream,
169
+ collector: collector,
170
+ active: -> { active? },
171
+ finish: ->(errored) { record_safely { collector.finish!(errored: errored) } }
172
+ ).wrap
163
173
  end
164
174
 
165
- def patch_targets = []
166
-
167
- def patch_target(constant_name, with:, optional: false, skip_when_methods_missing: false)
168
- {
169
- constant_name: constant_name,
170
- patch: with,
171
- method_names: with.instance_methods,
172
- optional: optional,
173
- skip_when_methods_missing: skip_when_methods_missing
174
- }
175
+ def validate_contract!
176
+ violation = contract_violation
177
+ raise Error, violation if violation
175
178
  end
176
179
 
177
- private
178
-
179
- def validate_contract!
180
+ def contract_violation
180
181
  problems = version_problems + target_problems
181
- return if problems.empty?
182
-
183
- raise Error, "#{integration_name} integration cannot be installed: #{problems.join('; ')}"
182
+ "#{integration_name} integration cannot be installed: #{problems.join('; ')}" if problems.any?
184
183
  end
185
184
 
186
185
  def version_problems
@@ -203,26 +202,11 @@ module LlmCostTracker
203
202
  "its calls may not be recorded"
204
203
  end
205
204
 
206
- def target_problems
207
- patch_targets.flat_map do |target|
208
- constant_name = target.fetch(:constant_name)
209
- target_class = constant_name.to_s.safe_constantize
210
- next [] if target_class.nil? && target.fetch(:optional)
211
- next ["#{constant_name} is not loaded"] unless target_class
212
-
213
- missing_methods(target_class, target)
214
- end
215
- end
216
-
217
- def missing_methods(target_class, target)
218
- return [] if target[:skip_when_methods_missing]
205
+ def target_problems = patch_targets.flat_map(&:problems)
219
206
 
220
- target.fetch(:method_names).filter_map do |method_name|
221
- next if target_class.method_defined?(method_name) || target_class.private_method_defined?(method_name)
207
+ def missing_targets = patch_targets.select { |target| target.missing?(gem_version) }.map(&:constant_name).uniq
222
208
 
223
- "#{target.fetch(:constant_name)}##{method_name} is not available"
224
- end
225
- end
209
+ def installed? = patch_targets.reject(&:optional).all?(&:installed?)
226
210
  end
227
211
  end
228
212
  end
@@ -6,19 +6,16 @@ module LlmCostTracker
6
6
  module Integrations
7
7
  module Openai
8
8
  module BatchCapture
9
+ FINISHED_STATUSES = %w[completed expired cancelled].freeze
9
10
  DEDUP_LIMIT = 1024
10
11
  MUTEX = Mutex.new
11
- private_constant :DEDUP_LIMIT, :MUTEX
12
+ private_constant :FINISHED_STATUSES, :DEDUP_LIMIT, :MUTEX
12
13
 
13
14
  class << self
14
- def maybe_capture(batch, resource:)
15
- return unless Openai.active?
16
- return unless %w[completed expired cancelled].include?(batch.status.to_s)
17
- return unless batch.output_file_id && batch.id
18
- return if captured?(batch.id)
15
+ def capture(batch, client:)
16
+ return unless Openai.active? && capturable?(batch)
19
17
 
20
- client = resource.instance_variable_get(:@client)
21
- host = Openai.client_host_for(resource)
18
+ host = Openai.client_host(client)
22
19
  Openai.record_safely do
23
20
  jsonl = client.files.content(batch.output_file_id).read
24
21
  deferred = capture_jsonl(jsonl, host: host, model: batch.model)
@@ -29,6 +26,10 @@ module LlmCostTracker
29
26
 
30
27
  private
31
28
 
29
+ def capturable?(batch)
30
+ FINISHED_STATUSES.include?(batch.status.to_s) && batch.output_file_id && batch.id && !captured?(batch.id)
31
+ end
32
+
32
33
  def captured?(batch_id)
33
34
  MUTEX.synchronize { @dedup&.include?(batch_id) || false }
34
35
  end
@@ -51,7 +52,7 @@ module LlmCostTracker
51
52
  next unless response.is_a?(Hash) && response["usage"]
52
53
 
53
54
  record_result({ "id" => entry["id"] }.merge(response), host: host, model: model)
54
- rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
55
+ rescue BudgetExceededError, UnknownPricingError => e
55
56
  deferred ||= e
56
57
  end
57
58
  deferred
@@ -65,24 +66,24 @@ module LlmCostTracker
65
66
 
66
67
  def record_result(response, host:, model:)
67
68
  provider = Openai.provider_for_host(host)
68
- return if LlmCostTracker::Call.already_recorded?(provider: provider, provider_response_id: response["id"])
69
+ return if Call.already_recorded?(provider: provider, provider_response_id: response["id"])
69
70
 
70
- parser = LlmCostTracker::Providers::Openai::ResponseParser
71
- event = parser.event_from_response(
71
+ event = Providers::Openai::ResponseParser.event_from_response(
72
72
  response: response,
73
73
  request: { "model" => model },
74
74
  provider: provider,
75
75
  host: host,
76
- usage_source: LlmCostTracker::Usage::Source::SDK_BATCH_RESULT,
77
- # /v1/batches runs regional processing only on the us and eu hosts.
78
- pricing_mode: parser.combined_pricing_mode(
79
- host: (host if host.to_s.match?(/\A(?:us|eu)\./i)),
80
- model: response["model"] || model,
81
- service_tier: "batch"
82
- )
76
+ usage_source: Usage::Source::SDK_BATCH_RESULT,
77
+ pricing_mode: batch_pricing_mode(host, response["model"] || model)
83
78
  )
84
79
  Openai.record_once(event)
85
80
  end
81
+
82
+ def batch_pricing_mode(host, model)
83
+ Providers::Openai::ResponseParser.combined_pricing_mode(
84
+ host: (host if host.to_s.match?(/\A(?:us|eu)\./i)), model: model, service_tier: "batch"
85
+ )
86
+ end
86
87
  end
87
88
  end
88
89
  end