llm_cost_tracker 0.8.0 → 0.10.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +14 -6
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  26. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  27. data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
  28. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  29. data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
  30. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  31. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  32. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  34. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  35. data/config/routes.rb +3 -2
  36. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  37. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  38. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  39. data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
  40. data/lib/llm_cost_tracker/budget.rb +31 -7
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  43. data/lib/llm_cost_tracker/configuration.rb +72 -17
  44. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  45. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  46. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
  47. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  48. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  49. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  51. data/lib/llm_cost_tracker/doctor.rb +72 -14
  52. data/lib/llm_cost_tracker/engine.rb +8 -0
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +48 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  74. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  75. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
  76. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  77. data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
  78. data/lib/llm_cost_tracker/ingestion.rb +48 -11
  79. data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
  80. data/lib/llm_cost_tracker/integrations/base.rb +35 -15
  81. data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
  82. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
  83. data/lib/llm_cost_tracker/integrations.rb +33 -14
  84. data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
  85. data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
  86. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
  87. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
  88. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
  89. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
  90. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
  91. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
  92. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
  93. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
  94. data/lib/llm_cost_tracker/ledger/store.rb +34 -31
  95. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  96. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
  97. data/lib/llm_cost_tracker/ledger.rb +2 -1
  98. data/lib/llm_cost_tracker/logging.rb +0 -4
  99. data/lib/llm_cost_tracker/masking.rb +39 -0
  100. data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
  101. data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
  102. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  103. data/lib/llm_cost_tracker/parsers/base.rb +53 -43
  104. data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
  105. data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
  106. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
  107. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
  108. data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
  109. data/lib/llm_cost_tracker/parsers.rb +31 -4
  110. data/lib/llm_cost_tracker/prices.json +572 -493
  111. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  112. data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
  113. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  114. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  115. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
  116. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  117. data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
  118. data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
  119. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
  121. data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  123. data/lib/llm_cost_tracker/pricing.rb +117 -44
  124. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  125. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  126. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  127. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  128. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  129. data/lib/llm_cost_tracker/railtie.rb +8 -0
  130. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  131. data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
  132. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
  133. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  134. data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
  135. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
  136. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  137. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  138. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  139. data/lib/llm_cost_tracker/report/data.rb +4 -1
  140. data/lib/llm_cost_tracker/report.rb +0 -4
  141. data/lib/llm_cost_tracker/retention.rb +31 -6
  142. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  143. data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
  144. data/lib/llm_cost_tracker/token_usage.rb +14 -2
  145. data/lib/llm_cost_tracker/tracker.rb +41 -55
  146. data/lib/llm_cost_tracker/version.rb +1 -1
  147. data/lib/llm_cost_tracker.rb +19 -14
  148. data/lib/tasks/llm_cost_tracker.rake +41 -4
  149. metadata +49 -3
  150. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -3,10 +3,12 @@
3
3
  require_relative "base"
4
4
  require_relative "../billing/line_item"
5
5
  require_relative "../parsers/openai_service_charges"
6
+ require_relative "../providers/azure/hosts"
7
+ require_relative "../providers/openai/model_families"
6
8
 
7
9
  module LlmCostTracker
8
10
  module Integrations
9
- module Openai
11
+ module Openai # rubocop:disable Metrics/ModuleLength
10
12
  extend Base
11
13
 
12
14
  class << self
@@ -14,8 +16,43 @@ module LlmCostTracker
14
16
  :openai
15
17
  end
16
18
 
17
- def stream_pricing_mode(request)
18
- Pricing.normalize_mode((request || {})[:service_tier])
19
+ def stream_pricing_mode(request, host: nil)
20
+ LlmCostTracker::Parsers::OpenaiUsage.combined_pricing_mode(
21
+ host: host,
22
+ model: (request || {})[:model],
23
+ service_tier: (request || {})[:service_tier]
24
+ )
25
+ end
26
+
27
+ def stream_collector(request, host: nil)
28
+ LlmCostTracker::Capture::StreamCollector.new(
29
+ provider: provider_for_host(host),
30
+ model: request[:model],
31
+ pricing_mode: stream_pricing_mode(request, host: host),
32
+ request: request
33
+ )
34
+ end
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
+
45
+ def client_host_for(resource)
46
+ client = resource.instance_variable_get(:@client)
47
+ return nil unless client
48
+
49
+ URI.parse(client.base_url.to_s).host
50
+ rescue URI::InvalidURIError
51
+ nil
52
+ end
53
+
54
+ def provider_for_host(host)
55
+ LlmCostTracker::Providers::Azure::Hosts.openai?(host) ? "azure_openai" : "openai"
19
56
  end
20
57
 
21
58
  def minimum_version
@@ -28,20 +65,40 @@ module LlmCostTracker
28
65
 
29
66
  def patch_targets
30
67
  [
31
- patch_target(
32
- "OpenAI::Resources::Responses",
33
- with: ResponsesPatch,
34
- methods: %i[create stream stream_raw retrieve_streaming]
35
- ),
36
- patch_target(
37
- "OpenAI::Resources::Chat::Completions",
38
- with: ChatCompletionsPatch,
39
- methods: %i[create stream_raw]
40
- )
68
+ patch_target("OpenAI::Resources::Responses",
69
+ with: ResponsesPatch, methods: %i[create stream stream_raw retrieve_streaming]),
70
+ patch_target("OpenAI::Resources::Chat::Completions",
71
+ with: ChatCompletionsPatch, methods: %i[create stream stream_raw]),
72
+ *auxiliary_patch_targets
73
+ ]
74
+ end
75
+
76
+ def auxiliary_patch_targets
77
+ [
78
+ patch_target("OpenAI::Resources::Embeddings",
79
+ with: EmbeddingsPatch, methods: %i[create], optional: true),
80
+ patch_target("OpenAI::Resources::Images",
81
+ with: ImagesPatch, methods: %i[generate edit create_variation], optional: true),
82
+ patch_target("OpenAI::Resources::Images",
83
+ with: StreamingImagesPatch,
84
+ methods: %i[generate_stream_raw edit_stream_raw],
85
+ optional: true, skip_when_methods_missing: true),
86
+ patch_target("OpenAI::Resources::Audio::Transcriptions",
87
+ with: TranscriptionsPatch, methods: %i[create], optional: true),
88
+ patch_target("OpenAI::Resources::Audio::Transcriptions",
89
+ with: StreamingTranscriptionsPatch,
90
+ methods: %i[create_streaming],
91
+ optional: true, skip_when_methods_missing: true),
92
+ patch_target("OpenAI::Resources::Audio::Translations",
93
+ with: TranslationsPatch, methods: %i[create], optional: true),
94
+ patch_target("OpenAI::Resources::Audio::Speech",
95
+ with: SpeechPatch, methods: %i[create], optional: true),
96
+ patch_target("OpenAI::Resources::Moderations",
97
+ with: ModerationsPatch, methods: %i[create], optional: true)
41
98
  ]
42
99
  end
43
100
 
44
- def record_response(response, request:, latency_ms:)
101
+ def record_response(response, request:, latency_ms:, host: nil)
45
102
  return unless active?
46
103
 
47
104
  record_safely do
@@ -53,27 +110,167 @@ module LlmCostTracker
53
110
  next if input_tokens.nil? && output_tokens.nil?
54
111
 
55
112
  cache_read = cache_read_input_tokens(usage)
113
+ model = object_value(response, :model) || request[:model]
56
114
  LlmCostTracker::Tracker.record(
57
- capture: UsageCapture.build(
58
- provider: "openai",
59
- model: object_value(response, :model) || request[:model],
60
- pricing_mode: object_value(response, :service_tier) || request[:service_tier],
61
- token_usage: token_usage(usage:, input_tokens:, output_tokens:, cache_read:),
115
+ event: Event.build(
116
+ provider: provider_for_host(host),
117
+ model: model,
118
+ pricing_mode: LlmCostTracker::Parsers::OpenaiUsage.combined_pricing_mode(
119
+ host: host,
120
+ model: model,
121
+ service_tier: object_value(response, :service_tier) || request[:service_tier]
122
+ ),
123
+ token_usage: token_usage(usage:, input_tokens:, output_tokens:, cache_read:, model: model),
62
124
  usage_source: :sdk_response,
63
125
  provider_response_id: object_value(response, :id),
64
- service_line_items: service_line_items_from(response)
126
+ service_line_items: service_line_items_from(response, request: request)
127
+ ),
128
+ latency_ms: latency_ms
129
+ )
130
+ end
131
+ end
132
+
133
+ def record_image(response, request:, latency_ms:, host: nil)
134
+ usage = object_value(response, :usage)
135
+ raw_input = usage ? object_value(usage, :input_tokens).to_i : 0
136
+ raw_output = usage ? object_value(usage, :output_tokens).to_i : 0
137
+ image_input = image_input_tokens(usage).to_i
138
+ cache_read = cache_read_input_tokens(usage).to_i
139
+ text_input = [raw_input - image_input - cache_read, 0].max
140
+ image_output, text_output = split_image_output(usage, raw_output)
141
+ record_passthrough(
142
+ model: request[:model],
143
+ response: response,
144
+ latency_ms: latency_ms,
145
+ host: host,
146
+ input_tokens: text_input,
147
+ image_input_tokens: image_input,
148
+ output_tokens: text_output,
149
+ image_output_tokens: image_output,
150
+ cache_read_input_tokens: cache_read
151
+ )
152
+ end
153
+
154
+ def split_image_output(usage, raw_output)
155
+ image_tokens = image_output_tokens(usage).to_i
156
+ text_tokens = text_output_tokens(usage).to_i
157
+ return [raw_output, 0] if image_tokens.zero? && text_tokens.zero?
158
+
159
+ text_tokens = [raw_output - image_tokens, 0].max if text_tokens.zero?
160
+ [image_tokens, text_tokens]
161
+ end
162
+
163
+ def record_transcription(response, request:, latency_ms:, host: nil)
164
+ record_passthrough(
165
+ model: request[:model],
166
+ response: response,
167
+ latency_ms: latency_ms,
168
+ host: host,
169
+ **transcription_token_attributes(object_value(response, :usage))
170
+ )
171
+ end
172
+
173
+ def transcription_token_attributes(usage)
174
+ return { input_tokens: 0, output_tokens: 0 } unless usage && object_value(usage, :type).to_s == "tokens"
175
+
176
+ raw_input = object_value(usage, :input_tokens).to_i
177
+ audio_input = object_dig(usage, :input_token_details, :audio_tokens).to_i
178
+ {
179
+ input_tokens: [raw_input - audio_input, 0].max,
180
+ audio_input_tokens: audio_input,
181
+ output_tokens: object_value(usage, :output_tokens).to_i
182
+ }
183
+ end
184
+
185
+ def record_speech(_response, request:, latency_ms:, host: nil)
186
+ record_passthrough(
187
+ model: request[:model],
188
+ response: nil,
189
+ latency_ms: latency_ms,
190
+ host: host,
191
+ input_tokens: 0,
192
+ output_tokens: 0,
193
+ service_line_items: speech_line_items(request)
194
+ )
195
+ end
196
+
197
+ def speech_line_items(request)
198
+ input = request[:input]
199
+ return [] unless input.is_a?(String)
200
+ return [] unless LlmCostTracker::Providers::Openai::ModelFamilies.character_billed_tts?(request[:model])
201
+
202
+ [LlmCostTracker::Billing::LineItem.build(
203
+ component_key: :text_to_speech_character,
204
+ quantity: input.length,
205
+ cost_status: LlmCostTracker::Billing::CostStatus::UNKNOWN,
206
+ pricing_basis: :provider_usage,
207
+ provider_field: "request.input"
208
+ )]
209
+ end
210
+
211
+ def record_moderation(response, request:, latency_ms:, host: nil)
212
+ record_passthrough(
213
+ model: object_value(response, :model) || request[:model],
214
+ response: response,
215
+ latency_ms: latency_ms,
216
+ host: host,
217
+ input_tokens: 0,
218
+ output_tokens: 0
219
+ )
220
+ end
221
+
222
+ def record_passthrough(model:, response:, latency_ms:, host: nil, service_line_items: [], **token_attributes)
223
+ return unless active?
224
+
225
+ record_safely do
226
+ LlmCostTracker::Tracker.record(
227
+ event: Event.build(
228
+ provider: provider_for_host(host),
229
+ model: model,
230
+ token_usage: TokenUsage.build(**token_attributes),
231
+ usage_source: :sdk_response,
232
+ provider_response_id: response && object_value(response, :id),
233
+ service_line_items: service_line_items
65
234
  ),
66
235
  latency_ms: latency_ms
67
236
  )
68
237
  end
69
238
  end
70
239
 
71
- def service_line_items_from(response)
240
+ def service_line_items_from(response, request: nil)
241
+ model = object_value(response, :model) || request&.dig(:model)
72
242
  output = object_value(response, :output)
73
- 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?
247
+
248
+ LlmCostTracker::Parsers::OpenaiServiceCharges.line_items_from_output(
249
+ output_items, request: request, model: model
250
+ )
251
+ end
252
+
253
+ def chat_completions_search_item(response, model: nil)
254
+ choices = object_value(response, :choices)
255
+ return nil unless choices.respond_to?(:any?)
74
256
 
75
- LlmCostTracker::Parsers::OpenaiServiceCharges
76
- .line_items_from_output(output.map { |item| normalize_output_item(item) })
257
+ provider_field = if choices.any? { |choice| choice_used_url_citation?(choice) }
258
+ LlmCostTracker::Parsers::OpenaiServiceCharges::CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD
259
+ elsif LlmCostTracker::Providers::Openai::ModelFamilies.chat_completions_search?(model)
260
+ LlmCostTracker::Parsers::OpenaiServiceCharges::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" }
77
274
  end
78
275
 
79
276
  def normalize_output_item(item)
@@ -81,7 +278,7 @@ module LlmCostTracker
81
278
  return nil if item.nil?
82
279
 
83
280
  {
84
- "type" => object_value(item, :type),
281
+ "type" => object_value(item, :type)&.to_s,
85
282
  "id" => object_value(item, :id),
86
283
  "status" => object_value(item, :status),
87
284
  "container_id" => object_value(item, :container_id),
@@ -93,19 +290,31 @@ module LlmCostTracker
93
290
  return nil if action.nil?
94
291
  return action if action.is_a?(Hash)
95
292
 
96
- { "type" => object_value(action, :type) }
293
+ { "type" => object_value(action, :type)&.to_s }
97
294
  end
98
295
 
99
- def token_usage(usage:, input_tokens:, output_tokens:, cache_read:)
296
+ def token_usage(usage:, input_tokens:, output_tokens:, cache_read:, model: nil)
100
297
  audio_input = audio_input_tokens(usage)
101
298
  audio_output = audio_output_tokens(usage)
299
+ image_input = image_input_tokens(usage)
300
+ image_output_details = image_output_tokens(usage)
301
+ text_output_details = text_output_tokens(usage)
302
+ image_output, regular_output = split_responses_image_output(
303
+ output_tokens: output_tokens.to_i,
304
+ image_output_details: image_output_details,
305
+ text_output_details: text_output_details,
306
+ audio_output: audio_output,
307
+ default_to_image: LlmCostTracker::Providers::Openai::ModelFamilies.image_output?(model)
308
+ )
102
309
 
103
310
  TokenUsage.build(
104
- input_tokens: regular_input_tokens(input_tokens, cache_read, audio_input),
105
- output_tokens: regular_output_tokens(output_tokens, audio_output),
311
+ input_tokens: regular_input_tokens(input_tokens, cache_read, audio_input, image_input),
312
+ output_tokens: regular_output,
106
313
  cache_read_input_tokens: cache_read,
107
314
  audio_input_tokens: audio_input,
108
315
  audio_output_tokens: audio_output,
316
+ image_input_tokens: image_input,
317
+ image_output_tokens: image_output,
109
318
  hidden_output_tokens: hidden_output_tokens(usage)
110
319
  )
111
320
  end
@@ -113,104 +322,156 @@ module LlmCostTracker
113
322
  INPUT_DETAIL_KEYS = %i[input_tokens_details input_token_details prompt_tokens_details].freeze
114
323
  OUTPUT_DETAIL_KEYS = %i[output_tokens_details output_token_details completion_tokens_details].freeze
115
324
 
116
- def cache_read_input_tokens(usage)
117
- input_detail(usage, :cached_tokens)
118
- end
119
-
120
- def hidden_output_tokens(usage)
121
- output_detail(usage, :reasoning_tokens)
122
- end
123
-
124
- def audio_input_tokens(usage)
125
- input_detail(usage, :audio_tokens)
126
- end
127
-
128
- def audio_output_tokens(usage)
129
- output_detail(usage, :audio_tokens)
130
- end
325
+ def cache_read_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens)
326
+ def hidden_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :reasoning_tokens)
327
+ def audio_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :audio_tokens)
328
+ def audio_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :audio_tokens)
329
+ def image_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :image_tokens)
330
+ def image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
331
+ def text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
131
332
 
132
- def input_detail(usage, key)
133
- INPUT_DETAIL_KEYS.each do |container|
333
+ def detail(usage, containers, key)
334
+ containers.each do |container|
134
335
  value = object_dig(usage, container, key)
135
336
  return value.to_i if value
136
337
  end
137
338
  0
138
339
  end
139
340
 
140
- def output_detail(usage, key)
141
- OUTPUT_DETAIL_KEYS.each do |container|
142
- value = object_dig(usage, container, key)
143
- return value.to_i if value
144
- end
145
- 0
341
+ def regular_input_tokens(input_tokens, cache_read, audio_input, image_input)
342
+ [input_tokens.to_i - cache_read - audio_input - image_input, 0].max
146
343
  end
147
344
 
148
- def regular_input_tokens(input_tokens, cache_read, audio_input)
149
- [input_tokens.to_i - cache_read - audio_input, 0].max
150
- end
345
+ def split_responses_image_output(output_tokens:, image_output_details:, text_output_details:, audio_output:,
346
+ default_to_image: false)
347
+ if image_output_details.zero? && text_output_details.zero?
348
+ remainder = [output_tokens - audio_output, 0].max
349
+ return default_to_image ? [remainder, 0] : [0, remainder]
350
+ end
151
351
 
152
- def regular_output_tokens(output_tokens, audio_output)
153
- [output_tokens.to_i - audio_output, 0].max
352
+ text_output = text_output_details
353
+ text_output = [output_tokens - image_output_details - audio_output, 0].max if text_output.zero?
354
+ [image_output_details, text_output]
154
355
  end
155
356
  end
156
357
 
157
358
  module ResponsesPatch
158
359
  def create(*args, **kwargs)
159
- LlmCostTracker::Integrations::Openai.enforce_budget!
360
+ request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
361
+ LlmCostTracker::Integrations::Openai.enforce_budget!(request: request)
160
362
  started_at = LlmCostTracker::Timing.now_monotonic
161
- response = super
363
+ response = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
162
364
  LlmCostTracker::Integrations::Openai.record_response(
163
365
  response,
164
- request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
165
- latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at)
366
+ request: request,
367
+ latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
368
+ host: LlmCostTracker::Integrations::Openai.client_host_for(self)
166
369
  )
167
370
  response
168
371
  end
169
372
 
170
373
  def stream(*args, **kwargs)
171
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
172
- LlmCostTracker::Integrations::Openai.enforce_budget!
173
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
174
- stream = super
175
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
374
+ LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
375
+ super(*normalized)
376
+ end
176
377
  end
177
378
 
178
379
  def stream_raw(*args, **kwargs)
179
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
180
- LlmCostTracker::Integrations::Openai.enforce_budget!
181
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
182
- stream = super
183
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
380
+ LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
381
+ super(*normalized)
382
+ end
184
383
  end
185
384
 
186
385
  def retrieve_streaming(response_id, *args, **kwargs)
187
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
188
- LlmCostTracker::Integrations::Openai.enforce_budget!
189
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
190
- collector.provider_response_id = response_id
191
- stream = super
192
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
386
+ LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, collector|
387
+ collector.provider_response_id = response_id
388
+ super(response_id, *normalized)
389
+ end
193
390
  end
194
391
  end
195
392
 
196
393
  module ChatCompletionsPatch
197
394
  def create(*args, **kwargs)
198
- LlmCostTracker::Integrations::Openai.enforce_budget!
395
+ request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
396
+ LlmCostTracker::Integrations::Openai.enforce_budget!(request: request)
199
397
  started_at = LlmCostTracker::Timing.now_monotonic
200
- response = super
398
+ response = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
201
399
  LlmCostTracker::Integrations::Openai.record_response(
202
400
  response,
203
- request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
204
- latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at)
401
+ request: request,
402
+ latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
403
+ host: LlmCostTracker::Integrations::Openai.client_host_for(self)
205
404
  )
206
405
  response
207
406
  end
208
407
 
408
+ def stream(*args, **kwargs)
409
+ LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
410
+ super(*normalized)
411
+ end
412
+ end
413
+
209
414
  def stream_raw(*args, **kwargs)
415
+ LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
416
+ super(*normalized)
417
+ end
418
+ end
419
+ end
420
+
421
+ module PatchBuilder
422
+ module_function
423
+
424
+ def build(record_method:, methods:)
425
+ Module.new.tap do |mod|
426
+ methods.each { |method_name| define_wrapped_method(mod, method_name, record_method) }
427
+ end
428
+ end
429
+
430
+ def define_wrapped_method(mod, method_name, record_method)
431
+ mod.define_method(method_name) do |*args, **kwargs, &block|
432
+ integration = LlmCostTracker::Integrations::Openai
433
+ request = integration.request_params(args, kwargs)
434
+ integration.enforce_budget!(request: request)
435
+ started_at = LlmCostTracker::Timing.now_monotonic
436
+ response = super(*integration.normalize_sdk_args(args, kwargs), &block)
437
+ integration.public_send(
438
+ record_method, response,
439
+ request: request,
440
+ latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
441
+ host: integration.client_host_for(self)
442
+ )
443
+ response
444
+ end
445
+ end
446
+ end
447
+
448
+ EmbeddingsPatch = PatchBuilder.build(record_method: :record_response, methods: %i[create])
449
+ ImagesPatch = PatchBuilder.build(record_method: :record_image, methods: %i[generate edit create_variation])
450
+ TranscriptionsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
451
+ TranslationsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
452
+ SpeechPatch = PatchBuilder.build(record_method: :record_speech, methods: %i[create])
453
+ ModerationsPatch = PatchBuilder.build(record_method: :record_moderation, methods: %i[create])
454
+
455
+ module StreamingImagesPatch
456
+ %i[generate_stream_raw edit_stream_raw].each do |method_name|
457
+ define_method(method_name) do |*args, **kwargs|
458
+ request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
459
+ LlmCostTracker::Integrations::Openai.enforce_budget!(request: request)
460
+ host = LlmCostTracker::Integrations::Openai.client_host_for(self)
461
+ collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
462
+ stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
463
+ LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
464
+ end
465
+ end
466
+ end
467
+
468
+ module StreamingTranscriptionsPatch
469
+ def create_streaming(*args, **kwargs)
210
470
  request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
211
- LlmCostTracker::Integrations::Openai.enforce_budget!
212
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
213
- stream = super
471
+ LlmCostTracker::Integrations::Openai.enforce_budget!(request: request)
472
+ host = LlmCostTracker::Integrations::Openai.client_host_for(self)
473
+ collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
474
+ stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
214
475
  LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
215
476
  end
216
477
  end