llm_cost_tracker 0.9.0 → 0.11.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -2,7 +2,9 @@
2
2
 
3
3
  require_relative "base"
4
4
  require_relative "../billing/line_item"
5
- require_relative "../parsers/openai_service_charges"
5
+ require_relative "../providers/azure/hosts"
6
+ require_relative "../providers/openai/model_families"
7
+ require_relative "../providers/openai/service_charges"
6
8
 
7
9
  module LlmCostTracker
8
10
  module Integrations
@@ -24,22 +26,35 @@ module LlmCostTracker
24
26
 
25
27
  def stream_collector(request, host: nil)
26
28
  LlmCostTracker::Capture::StreamCollector.new(
27
- provider: integration_name.to_s,
29
+ provider: provider_for_host(host),
28
30
  model: request[:model],
29
31
  pricing_mode: stream_pricing_mode(request, host: host),
30
32
  request: request
31
33
  )
32
34
  end
33
35
 
36
+ def wrap_stream_call(args, kwargs, resource)
37
+ request = request_params(args, kwargs)
38
+ enforce_budget!(request: request)
39
+ host = client_host_for(resource)
40
+ collector = stream_collector(request, host: host)
41
+ stream = yield(normalize_sdk_args(args, kwargs), collector)
42
+ track_stream(stream, collector: collector)
43
+ end
44
+
34
45
  def client_host_for(resource)
35
46
  client = resource.instance_variable_get(:@client)
36
- return nil unless client.respond_to?(:base_url, true)
47
+ return nil unless client
37
48
 
38
- URI.parse(client.send(:base_url).to_s).host
49
+ URI.parse(client.base_url.to_s).host
39
50
  rescue URI::InvalidURIError
40
51
  nil
41
52
  end
42
53
 
54
+ def provider_for_host(host)
55
+ LlmCostTracker::Providers::Azure::Hosts.openai?(host) ? "azure_openai" : "openai"
56
+ end
57
+
43
58
  def minimum_version
44
59
  "0.59.0"
45
60
  end
@@ -97,8 +112,8 @@ module LlmCostTracker
97
112
  cache_read = cache_read_input_tokens(usage)
98
113
  model = object_value(response, :model) || request[:model]
99
114
  LlmCostTracker::Tracker.record(
100
- capture: UsageCapture.build(
101
- provider: "openai",
115
+ event: Event.build(
116
+ provider: provider_for_host(host),
102
117
  model: model,
103
118
  pricing_mode: LlmCostTracker::Parsers::OpenaiUsage.combined_pricing_mode(
104
119
  host: host,
@@ -115,7 +130,7 @@ module LlmCostTracker
115
130
  end
116
131
  end
117
132
 
118
- def record_image(response, request:, latency_ms:)
133
+ def record_image(response, request:, latency_ms:, host: nil)
119
134
  usage = object_value(response, :usage)
120
135
  raw_input = usage ? object_value(usage, :input_tokens).to_i : 0
121
136
  raw_output = usage ? object_value(usage, :output_tokens).to_i : 0
@@ -127,6 +142,7 @@ module LlmCostTracker
127
142
  model: request[:model],
128
143
  response: response,
129
144
  latency_ms: latency_ms,
145
+ host: host,
130
146
  input_tokens: text_input,
131
147
  image_input_tokens: image_input,
132
148
  output_tokens: text_output,
@@ -144,11 +160,12 @@ module LlmCostTracker
144
160
  [image_tokens, text_tokens]
145
161
  end
146
162
 
147
- def record_transcription(response, request:, latency_ms:)
163
+ def record_transcription(response, request:, latency_ms:, host: nil)
148
164
  record_passthrough(
149
165
  model: request[:model],
150
166
  response: response,
151
167
  latency_ms: latency_ms,
168
+ host: host,
152
169
  **transcription_token_attributes(object_value(response, :usage))
153
170
  )
154
171
  end
@@ -165,24 +182,22 @@ module LlmCostTracker
165
182
  }
166
183
  end
167
184
 
168
- def record_speech(_response, request:, latency_ms:)
185
+ def record_speech(_response, request:, latency_ms:, host: nil)
169
186
  record_passthrough(
170
187
  model: request[:model],
171
188
  response: nil,
172
189
  latency_ms: latency_ms,
190
+ host: host,
173
191
  input_tokens: 0,
174
192
  output_tokens: 0,
175
193
  service_line_items: speech_line_items(request)
176
194
  )
177
195
  end
178
196
 
179
- CHARACTER_BILLED_TTS_MODELS = /\Atts-1(-hd)?\z/
180
- private_constant :CHARACTER_BILLED_TTS_MODELS
181
-
182
197
  def speech_line_items(request)
183
198
  input = request[:input]
184
199
  return [] unless input.is_a?(String)
185
- return [] unless CHARACTER_BILLED_TTS_MODELS.match?(request[:model].to_s)
200
+ return [] unless LlmCostTracker::Providers::Openai::ModelFamilies.character_billed_tts?(request[:model])
186
201
 
187
202
  [LlmCostTracker::Billing::LineItem.build(
188
203
  component_key: :text_to_speech_character,
@@ -193,23 +208,24 @@ module LlmCostTracker
193
208
  )]
194
209
  end
195
210
 
196
- def record_moderation(response, request:, latency_ms:)
211
+ def record_moderation(response, request:, latency_ms:, host: nil)
197
212
  record_passthrough(
198
213
  model: object_value(response, :model) || request[:model],
199
214
  response: response,
200
215
  latency_ms: latency_ms,
216
+ host: host,
201
217
  input_tokens: 0,
202
218
  output_tokens: 0
203
219
  )
204
220
  end
205
221
 
206
- def record_passthrough(model:, response:, latency_ms:, service_line_items: [], **token_attributes)
222
+ def record_passthrough(model:, response:, latency_ms:, host: nil, service_line_items: [], **token_attributes)
207
223
  return unless active?
208
224
 
209
225
  record_safely do
210
226
  LlmCostTracker::Tracker.record(
211
- capture: UsageCapture.build(
212
- provider: "openai",
227
+ event: Event.build(
228
+ provider: provider_for_host(host),
213
229
  model: model,
214
230
  token_usage: TokenUsage.build(**token_attributes),
215
231
  usage_source: :sdk_response,
@@ -222,16 +238,41 @@ module LlmCostTracker
222
238
  end
223
239
 
224
240
  def service_line_items_from(response, request: nil)
241
+ model = object_value(response, :model) || request&.dig(:model)
225
242
  output = object_value(response, :output)
226
- return [] unless output.respond_to?(:each)
243
+ output_items = output.respond_to?(:each) ? output.map { |item| normalize_output_item(item) }.compact : []
244
+ chat_search = output_items.empty? ? chat_completions_search_item(response, model: model) : nil
245
+ output_items << chat_search if chat_search
246
+ return [] if output_items.empty?
227
247
 
228
- LlmCostTracker::Parsers::OpenaiServiceCharges.line_items_from_output(
229
- output.map { |item| normalize_output_item(item) },
230
- request: request,
231
- model: object_value(response, :model) || request&.dig(:model)
248
+ LlmCostTracker::Providers::Openai::ServiceCharges.line_items_from_output(
249
+ output_items, request: request, model: model
232
250
  )
233
251
  end
234
252
 
253
+ def chat_completions_search_item(response, model: nil)
254
+ choices = object_value(response, :choices)
255
+ return nil unless choices.respond_to?(:any?)
256
+
257
+ provider_field = if choices.any? { |choice| choice_used_url_citation?(choice) }
258
+ LlmCostTracker::Providers::Openai::ServiceCharges::CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD
259
+ elsif LlmCostTracker::Providers::Openai::ModelFamilies.chat_completions_search?(model)
260
+ LlmCostTracker::Providers::Openai::ServiceCharges::CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD
261
+ end
262
+ return nil unless provider_field
263
+
264
+ { "type" => "web_search_call", "id" => object_value(response, :id),
265
+ "action" => { "type" => "search" }, "provider_field" => provider_field }
266
+ end
267
+
268
+ def choice_used_url_citation?(choice)
269
+ message = object_value(choice, :message)
270
+ annotations = message && object_value(message, :annotations)
271
+ return false unless annotations.respond_to?(:any?)
272
+
273
+ annotations.any? { |annotation| object_value(annotation, :type).to_s == "url_citation" }
274
+ end
275
+
235
276
  def normalize_output_item(item)
236
277
  return item if item.is_a?(Hash)
237
278
  return nil if item.nil?
@@ -252,9 +293,6 @@ module LlmCostTracker
252
293
  { "type" => object_value(action, :type)&.to_s }
253
294
  end
254
295
 
255
- IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
256
- private_constant :IMAGE_OUTPUT_MODEL_PATTERN
257
-
258
296
  def token_usage(usage:, input_tokens:, output_tokens:, cache_read:, model: nil)
259
297
  audio_input = audio_input_tokens(usage)
260
298
  audio_output = audio_output_tokens(usage)
@@ -266,7 +304,7 @@ module LlmCostTracker
266
304
  image_output_details: image_output_details,
267
305
  text_output_details: text_output_details,
268
306
  audio_output: audio_output,
269
- default_to_image: model.to_s.match?(IMAGE_OUTPUT_MODEL_PATTERN)
307
+ default_to_image: LlmCostTracker::Providers::Openai::ModelFamilies.image_output?(model)
270
308
  )
271
309
 
272
310
  TokenUsage.build(
@@ -317,137 +355,72 @@ module LlmCostTracker
317
355
  end
318
356
  end
319
357
 
320
- module ResponsesPatch
321
- def create(*args, **kwargs)
322
- LlmCostTracker::Integrations::Openai.enforce_budget!
323
- started_at = LlmCostTracker::Timing.now_monotonic
324
- response = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
325
- LlmCostTracker::Integrations::Openai.record_response(
326
- response,
327
- request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
328
- latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at),
329
- host: LlmCostTracker::Integrations::Openai.client_host_for(self)
330
- )
331
- response
332
- end
333
-
334
- def stream(*args, **kwargs)
335
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
336
- LlmCostTracker::Integrations::Openai.enforce_budget!
337
- host = LlmCostTracker::Integrations::Openai.client_host_for(self)
338
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
339
- stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
340
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
341
- end
342
-
343
- def stream_raw(*args, **kwargs)
344
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
345
- LlmCostTracker::Integrations::Openai.enforce_budget!
346
- host = LlmCostTracker::Integrations::Openai.client_host_for(self)
347
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
348
- stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
349
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
350
- end
351
-
352
- def retrieve_streaming(response_id, *args, **kwargs)
353
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
354
- LlmCostTracker::Integrations::Openai.enforce_budget!
355
- host = LlmCostTracker::Integrations::Openai.client_host_for(self)
356
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
357
- collector.provider_response_id = response_id
358
- stream = super(response_id, *LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
359
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
360
- end
361
- end
362
-
363
- module ChatCompletionsPatch
364
- def create(*args, **kwargs)
365
- LlmCostTracker::Integrations::Openai.enforce_budget!
366
- started_at = LlmCostTracker::Timing.now_monotonic
367
- response = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
368
- LlmCostTracker::Integrations::Openai.record_response(
369
- response,
370
- request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
371
- latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at),
372
- host: LlmCostTracker::Integrations::Openai.client_host_for(self)
373
- )
374
- response
375
- end
376
-
377
- def stream(*args, **kwargs)
378
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
379
- LlmCostTracker::Integrations::Openai.enforce_budget!
380
- host = LlmCostTracker::Integrations::Openai.client_host_for(self)
381
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
382
- stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
383
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
384
- end
385
-
386
- def stream_raw(*args, **kwargs)
387
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
388
- LlmCostTracker::Integrations::Openai.enforce_budget!
389
- host = LlmCostTracker::Integrations::Openai.client_host_for(self)
390
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
391
- stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
392
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
393
- end
394
- end
395
-
396
358
  module PatchBuilder
397
359
  module_function
398
360
 
399
361
  def build(record_method:, methods:)
400
362
  Module.new.tap do |mod|
401
- methods.each { |method_name| define_wrapped_method(mod, method_name, record_method) }
363
+ methods.each { |method_name| define_blocking_method(mod, method_name, record_method) }
364
+ end
365
+ end
366
+
367
+ def build_stream(methods:)
368
+ Module.new.tap do |mod|
369
+ methods.each { |method_name| define_stream_method(mod, method_name) }
402
370
  end
403
371
  end
404
372
 
405
- def define_wrapped_method(mod, method_name, record_method)
373
+ def define_blocking_method(mod, method_name, record_method)
406
374
  mod.define_method(method_name) do |*args, **kwargs, &block|
407
375
  integration = LlmCostTracker::Integrations::Openai
408
- integration.enforce_budget!
376
+ request = integration.request_params(args, kwargs)
377
+ integration.enforce_budget!(request: request)
409
378
  started_at = LlmCostTracker::Timing.now_monotonic
410
379
  response = super(*integration.normalize_sdk_args(args, kwargs), &block)
411
380
  integration.public_send(
412
381
  record_method, response,
413
- request: integration.request_params(args, kwargs),
414
- latency_ms: integration.elapsed_ms(started_at)
382
+ request: request,
383
+ latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
384
+ host: integration.client_host_for(self)
415
385
  )
416
386
  response
417
387
  end
418
388
  end
389
+
390
+ def define_stream_method(mod, method_name)
391
+ mod.define_method(method_name) do |*args, **kwargs|
392
+ LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
393
+ super(*normalized)
394
+ end
395
+ end
396
+ end
419
397
  end
420
398
 
421
- EmbeddingsPatch = PatchBuilder.build(record_method: :record_response, methods: %i[create])
422
- ImagesPatch = PatchBuilder.build(record_method: :record_image, methods: %i[generate edit create_variation])
423
- TranscriptionsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
424
- TranslationsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
425
- SpeechPatch = PatchBuilder.build(record_method: :record_speech, methods: %i[create])
426
- ModerationsPatch = PatchBuilder.build(record_method: :record_moderation, methods: %i[create])
399
+ module ResponsesPatch
400
+ include PatchBuilder.build(record_method: :record_response, methods: %i[create])
401
+ include PatchBuilder.build_stream(methods: %i[stream stream_raw])
427
402
 
428
- module StreamingImagesPatch
429
- %i[generate_stream_raw edit_stream_raw].each do |method_name|
430
- define_method(method_name) do |*args, **kwargs|
431
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
432
- LlmCostTracker::Integrations::Openai.enforce_budget!
433
- host = LlmCostTracker::Integrations::Openai.client_host_for(self)
434
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
435
- stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
436
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
403
+ def retrieve_streaming(response_id, *args, **kwargs)
404
+ LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, collector|
405
+ collector.provider_response_id = response_id
406
+ super(response_id, *normalized)
437
407
  end
438
408
  end
439
409
  end
440
410
 
441
- module StreamingTranscriptionsPatch
442
- def create_streaming(*args, **kwargs)
443
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
444
- LlmCostTracker::Integrations::Openai.enforce_budget!
445
- host = LlmCostTracker::Integrations::Openai.client_host_for(self)
446
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
447
- stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
448
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
449
- end
411
+ module ChatCompletionsPatch
412
+ include PatchBuilder.build(record_method: :record_response, methods: %i[create])
413
+ include PatchBuilder.build_stream(methods: %i[stream stream_raw])
450
414
  end
415
+
416
+ EmbeddingsPatch = PatchBuilder.build(record_method: :record_response, methods: %i[create])
417
+ ImagesPatch = PatchBuilder.build(record_method: :record_image, methods: %i[generate edit create_variation])
418
+ TranscriptionsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
419
+ TranslationsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
420
+ SpeechPatch = PatchBuilder.build(record_method: :record_speech, methods: %i[create])
421
+ ModerationsPatch = PatchBuilder.build(record_method: :record_moderation, methods: %i[create])
422
+ StreamingImagesPatch = PatchBuilder.build_stream(methods: %i[generate_stream_raw edit_stream_raw])
423
+ StreamingTranscriptionsPatch = PatchBuilder.build_stream(methods: %i[create_streaming])
451
424
  end
452
425
  end
453
426
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../providers/anthropic/tier_classification"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Integrations
@@ -30,20 +31,16 @@ module LlmCostTracker
30
31
  ]
31
32
  end
32
33
 
33
- def record_completion(provider, response, request:, latency_ms:, stream:)
34
+ def record_completion(provider, response, request:, latency_ms:, has_block:)
34
35
  record_usage(
35
36
  provider: provider_slug(provider),
36
37
  model: response_model_id(response) || model_id(request[:model]),
37
38
  response: response,
38
39
  latency_ms: latency_ms,
39
- stream: stream
40
+ stream: has_block || request[:stream] == true
40
41
  )
41
42
  end
42
43
 
43
- def streaming_request?(request, has_block:)
44
- has_block || request[:stream] == true
45
- end
46
-
47
44
  def record_embedding(provider, response, request:, latency_ms:)
48
45
  record_usage(
49
46
  provider: provider_slug(provider),
@@ -111,7 +108,7 @@ module LlmCostTracker
111
108
 
112
109
  record_safely do
113
110
  LlmCostTracker::Tracker.record(
114
- capture: UsageCapture.build(
111
+ event: Event.build(
115
112
  provider: provider,
116
113
  model: model,
117
114
  token_usage: TokenUsage.build(
@@ -140,7 +137,7 @@ module LlmCostTracker
140
137
  hidden_output = object_value(response, :thinking_tokens, :reasoning_tokens).to_i
141
138
 
142
139
  LlmCostTracker::Tracker.record(
143
- capture: UsageCapture.build(
140
+ event: Event.build(
144
141
  provider: provider,
145
142
  model: model,
146
143
  pricing_mode: pricing_mode(provider: provider, response: response),
@@ -182,17 +179,14 @@ module LlmCostTracker
182
179
  end
183
180
 
184
181
  def provider_response_id(response)
185
- object_value(response, :id, :provider_response_id) || object_dig(response, :raw, :id)
182
+ object_value(response, :id, :provider_response_id)
186
183
  end
187
184
 
188
- ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
189
- private_constant :ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS
190
-
191
185
  def pricing_mode(provider:, response:)
192
- raw = object_value(response, :pricing_mode, :service_tier) ||
193
- object_dig(response, :raw, :pricing_mode) ||
194
- object_dig(response, :raw, :service_tier)
195
- return nil if provider == "anthropic" && ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS.include?(raw.to_s)
186
+ raw = object_value(response, :pricing_mode, :service_tier)
187
+ if provider == "anthropic" && LlmCostTracker::Providers::Anthropic::TierClassification.standard_equivalent_tier?(raw)
188
+ return nil
189
+ end
196
190
 
197
191
  raw
198
192
  end
@@ -200,77 +194,37 @@ module LlmCostTracker
200
194
 
201
195
  module ProviderPatch
202
196
  def complete(*args, **kwargs, &)
203
- integration = LlmCostTracker::Integrations::RubyLlm
204
- request = integration.request_params(args, kwargs)
205
- integration.enforce_budget!
206
- started_at = LlmCostTracker::Timing.now_monotonic
207
- response = super
208
- integration.record_completion(
209
- self,
210
- response,
211
- request: request,
212
- latency_ms: integration.elapsed_ms(started_at),
213
- stream: integration.streaming_request?(request, has_block: block_given?)
214
- )
215
- response
197
+ measure(args, kwargs, recorder: :record_completion, has_block: block_given?) { super }
216
198
  end
217
199
 
218
200
  def embed(*args, **kwargs)
219
- integration = LlmCostTracker::Integrations::RubyLlm
220
- request = integration.request_params(args, kwargs)
221
- integration.enforce_budget!
222
- started_at = LlmCostTracker::Timing.now_monotonic
223
- response = super
224
- integration.record_embedding(
225
- self,
226
- response,
227
- request: request,
228
- latency_ms: integration.elapsed_ms(started_at)
229
- )
230
- response
201
+ measure(args, kwargs, recorder: :record_embedding) { super }
231
202
  end
232
203
 
233
204
  def transcribe(*args, **kwargs)
234
- integration = LlmCostTracker::Integrations::RubyLlm
235
- request = integration.request_params(args, kwargs)
236
- integration.enforce_budget!
237
- started_at = LlmCostTracker::Timing.now_monotonic
238
- response = super
239
- integration.record_transcription(
240
- self,
241
- response,
242
- request: request,
243
- latency_ms: integration.elapsed_ms(started_at)
244
- )
245
- response
205
+ measure(args, kwargs, recorder: :record_transcription) { super }
246
206
  end
247
207
 
248
208
  def paint(*args, **kwargs)
249
- integration = LlmCostTracker::Integrations::RubyLlm
250
- request = integration.request_params(args, kwargs)
251
- integration.enforce_budget!
252
- started_at = LlmCostTracker::Timing.now_monotonic
253
- response = super
254
- integration.record_image(
255
- self,
256
- response,
257
- request: request,
258
- latency_ms: integration.elapsed_ms(started_at)
259
- )
260
- response
209
+ measure(args, kwargs, recorder: :record_image) { super }
261
210
  end
262
211
 
263
212
  def moderate(*args, **kwargs)
264
- integration = LlmCostTracker::Integrations::RubyLlm
265
- request = integration.request_params(args, kwargs)
266
- integration.enforce_budget!
213
+ measure(args, kwargs, recorder: :record_moderation) { super }
214
+ end
215
+
216
+ private
217
+
218
+ def measure(args, kwargs, recorder:, **extras)
219
+ request = RubyLlm.request_params(args, kwargs)
220
+ RubyLlm.enforce_budget!(request: request)
267
221
  started_at = LlmCostTracker::Timing.now_monotonic
268
- response = super
269
- integration.record_moderation(
270
- self,
271
- response,
222
+ response = yield
223
+ RubyLlm.public_send(
224
+ recorder, self, response,
272
225
  request: request,
273
- latency_ms: integration.elapsed_ms(started_at)
226
+ latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
227
+ **extras
274
228
  )
275
229
  response
276
230
  end
@@ -2,19 +2,17 @@
2
2
 
3
3
  require_relative "errors"
4
4
  require_relative "logging"
5
- require_relative "integrations/openai"
6
- require_relative "integrations/anthropic"
7
- require_relative "integrations/ruby_llm"
8
5
 
9
6
  module LlmCostTracker
10
7
  module Integrations
11
- AVAILABLE = {
12
- openai: Openai,
13
- anthropic: Anthropic,
14
- ruby_llm: RubyLlm
15
- }.freeze
8
+ autoload :Base, "llm_cost_tracker/integrations/base"
9
+ autoload :Openai, "llm_cost_tracker/integrations/openai"
10
+ autoload :Anthropic, "llm_cost_tracker/integrations/anthropic"
11
+ autoload :RubyLlm, "llm_cost_tracker/integrations/ruby_llm"
16
12
 
13
+ INTEGRATION_CONSTANTS = { openai: :Openai, anthropic: :Anthropic, ruby_llm: :RubyLlm }.freeze
17
14
  DOUBLE_INSTRUMENTATION_OVERLAPS = %i[openai anthropic].freeze
15
+ private_constant :DOUBLE_INSTRUMENTATION_OVERLAPS
18
16
 
19
17
  module_function
20
18
 
@@ -25,7 +23,7 @@ module LlmCostTracker
25
23
  end
26
24
 
27
25
  def checks(names = LlmCostTracker.configuration.instrumented_integrations)
28
- return [Base::Result.new(:integrations, :ok, "no SDK integrations enabled")] if names.empty?
26
+ return [Base::Result.new(:ok, "integrations", "no SDK integrations enabled")] if names.empty?
29
27
 
30
28
  normalize(names).map { |name| fetch(name).status }
31
29
  end
@@ -48,14 +46,17 @@ module LlmCostTracker
48
46
  end
49
47
 
50
48
  def fetch(name)
51
- AVAILABLE.fetch(name) do
52
- message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
53
- raise LlmCostTracker::Error, message
49
+ const_name = INTEGRATION_CONSTANTS[name.to_sym]
50
+ unless const_name
51
+ raise LlmCostTracker::Error,
52
+ "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
54
53
  end
54
+
55
+ const_get(const_name)
55
56
  end
56
57
 
57
58
  def names
58
- AVAILABLE.keys
59
+ INTEGRATION_CONSTANTS.keys
59
60
  end
60
61
  end
61
62
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
4
+
3
5
  require_relative "../period"
4
6
 
5
7
  module LlmCostTracker
@@ -26,12 +28,12 @@ module LlmCostTracker
26
28
  attr_reader :periods, :time
27
29
 
28
30
  def snapshot_totals
29
- values = periods.to_h { |period| [period, 0.0] }
31
+ values = periods.to_h { |period| [period, BigDecimal("0")] }
30
32
  period_by_name = periods.to_h { |period| [period.name, period] }
31
33
  sql = periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
32
34
  LlmCostTracker::Call.find_by_sql(sql).each do |row|
33
35
  period = period_by_name.fetch(row.period_key)
34
- values[period] = row.total_cost.to_f
36
+ values[period] = BigDecimal(row.total_cost.to_s)
35
37
  end
36
38
  values
37
39
  end
@@ -39,7 +41,7 @@ module LlmCostTracker
39
41
  def snapshot_select(period)
40
42
  start = Period.range_start(period, time)
41
43
  components = [period_total_sql(period, start)]
42
- components << pending_total_sql(start) if Ingestion.durable?
44
+ components << pending_total_sql(start) if Ingestion.async?
43
45
  "SELECT #{connection.quote(period.name)} AS period_key, " \
44
46
  "(#{components.join(') + (')}) AS total_cost"
45
47
  end