llm_cost_tracker 0.11.0 → 0.12.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,23 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "../billing/line_item"
4
+ require_relative "../capture/sdk_payload"
5
+ require_relative "../charges/line_item"
5
6
  require_relative "../providers/azure/hosts"
6
7
  require_relative "../providers/openai/model_families"
7
8
  require_relative "../providers/openai/service_charges"
9
+ require_relative "../providers/openai/usage_extractor"
10
+ require_relative "openai/patches"
11
+ require_relative "openai/batch_capture"
8
12
 
9
13
  module LlmCostTracker
10
14
  module Integrations
11
- module Openai # rubocop:disable Metrics/ModuleLength
15
+ module Openai
12
16
  extend Base
13
17
 
14
- class << self
15
- def integration_name
16
- :openai
17
- end
18
+ minimum_version "0.59.0"
18
19
 
20
+ class << self
19
21
  def stream_pricing_mode(request, host: nil)
20
- LlmCostTracker::Parsers::OpenaiUsage.combined_pricing_mode(
22
+ LlmCostTracker::Providers::Openai::ResponseParser.combined_pricing_mode(
21
23
  host: host,
22
24
  model: (request || {})[:model],
23
25
  service_tier: (request || {})[:service_tier]
@@ -33,13 +35,12 @@ module LlmCostTracker
33
35
  )
34
36
  end
35
37
 
36
- def wrap_stream_call(args, kwargs, resource)
37
- request = request_params(args, kwargs)
38
- enforce_budget!(request: request)
38
+ def stream_seam(resource)
39
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)
40
+ {
41
+ provider: provider_for_host(host),
42
+ collector: ->(request) { stream_collector(request, host: host) }
43
+ }
43
44
  end
44
45
 
45
46
  def client_host_for(resource)
@@ -55,46 +56,31 @@ module LlmCostTracker
55
56
  LlmCostTracker::Providers::Azure::Hosts.openai?(host) ? "azure_openai" : "openai"
56
57
  end
57
58
 
58
- def minimum_version
59
- "0.59.0"
60
- end
61
-
62
- def version_constant
63
- "OpenAI::VERSION"
64
- end
65
-
66
59
  def patch_targets
67
60
  [
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]),
61
+ patch_target("OpenAI::Resources::Responses", with: ResponsesPatch),
62
+ patch_target("OpenAI::Resources::Chat::Completions", with: ChatCompletionsPatch),
72
63
  *auxiliary_patch_targets
73
64
  ]
74
65
  end
75
66
 
76
67
  def auxiliary_patch_targets
77
68
  [
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),
69
+ patch_target("OpenAI::Resources::Embeddings", with: EmbeddingsPatch, optional: true),
70
+ patch_target("OpenAI::Resources::Images", with: ImagesPatch, optional: true),
82
71
  patch_target("OpenAI::Resources::Images",
83
72
  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),
73
+ optional: true,
74
+ skip_when_methods_missing: true),
75
+ patch_target("OpenAI::Resources::Audio::Transcriptions", with: TranscriptionsPatch, optional: true),
88
76
  patch_target("OpenAI::Resources::Audio::Transcriptions",
89
77
  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)
78
+ optional: true,
79
+ skip_when_methods_missing: true),
80
+ patch_target("OpenAI::Resources::Audio::Translations", with: TranslationsPatch, optional: true),
81
+ patch_target("OpenAI::Resources::Audio::Speech", with: SpeechPatch, optional: true),
82
+ patch_target("OpenAI::Resources::Moderations", with: ModerationsPatch, optional: true),
83
+ patch_target("OpenAI::Resources::Batches", with: BatchesPatch, optional: true)
98
84
  ]
99
85
  end
100
86
 
@@ -102,48 +88,43 @@ module LlmCostTracker
102
88
  return unless active?
103
89
 
104
90
  record_safely do
105
- usage = object_value(response, :usage)
106
- next unless usage
107
-
108
- input_tokens = object_value(usage, :input_tokens, :prompt_tokens)
109
- output_tokens = object_value(usage, :output_tokens, :completion_tokens)
110
- next if input_tokens.nil? && output_tokens.nil?
91
+ normalized = LlmCostTracker::Capture::SdkPayload.normalize(response)
92
+ usage = normalized["usage"]
93
+ if usage
94
+ input_tokens = usage["input_tokens"] || usage["prompt_tokens"]
95
+ output_tokens = usage["output_tokens"] || usage["completion_tokens"]
96
+ next if input_tokens.nil? && output_tokens.nil?
97
+ end
111
98
 
112
- cache_read = cache_read_input_tokens(usage)
113
- model = object_value(response, :model) || request[:model]
114
- LlmCostTracker::Tracker.record(
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),
124
- usage_source: :sdk_response,
125
- provider_response_id: object_value(response, :id),
126
- service_line_items: service_line_items_from(response, request: request)
127
- ),
128
- latency_ms: latency_ms
99
+ event = LlmCostTracker::Providers::Openai::ResponseParser.event_from_response(
100
+ response: normalized,
101
+ request: request,
102
+ provider: provider_for_host(host),
103
+ host: host,
104
+ usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE
129
105
  )
106
+ LlmCostTracker::Tracker.record(event: event, latency_ms: latency_ms) if event
130
107
  end
131
108
  end
132
109
 
133
110
  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)
111
+ usage = usage_hash_from(response) || {}
112
+ raw_input = usage[:input_tokens].to_i
113
+ image_input = LlmCostTracker::Providers::Openai::UsageExtractor.image_input_tokens(usage)
114
+ cache_read = LlmCostTracker::Providers::Openai::UsageExtractor.cache_read_input_tokens(usage)
115
+ image_output, text_output = LlmCostTracker::Providers::Openai::UsageExtractor.split_output(
116
+ output_tokens: usage[:output_tokens].to_i,
117
+ image_output_details: LlmCostTracker::Providers::Openai::UsageExtractor.image_output_tokens(usage),
118
+ text_output_details: LlmCostTracker::Providers::Openai::UsageExtractor.text_output_tokens(usage),
119
+ audio_output: 0,
120
+ default_to_image: true
121
+ )
141
122
  record_passthrough(
142
123
  model: request[:model],
143
124
  response: response,
144
125
  latency_ms: latency_ms,
145
126
  host: host,
146
- input_tokens: text_input,
127
+ input_tokens: [raw_input - image_input - cache_read, 0].max,
147
128
  image_input_tokens: image_input,
148
129
  output_tokens: text_output,
149
130
  image_output_tokens: image_output,
@@ -151,34 +132,27 @@ module LlmCostTracker
151
132
  )
152
133
  end
153
134
 
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
135
  def record_transcription(response, request:, latency_ms:, host: nil)
136
+ usage = usage_hash_from(response)
164
137
  record_passthrough(
165
138
  model: request[:model],
166
139
  response: response,
167
140
  latency_ms: latency_ms,
168
141
  host: host,
169
- **transcription_token_attributes(object_value(response, :usage))
142
+ service_line_items: LlmCostTracker::Providers::Openai::ServiceCharges.transcription_line_items(usage),
143
+ **transcription_token_attributes(usage)
170
144
  )
171
145
  end
172
146
 
173
147
  def transcription_token_attributes(usage)
174
- return { input_tokens: 0, output_tokens: 0 } unless usage && object_value(usage, :type).to_s == "tokens"
148
+ return { input_tokens: 0, output_tokens: 0 } unless usage && usage[:type].to_s == "tokens"
175
149
 
176
- raw_input = object_value(usage, :input_tokens).to_i
177
- audio_input = object_dig(usage, :input_token_details, :audio_tokens).to_i
150
+ raw_input = usage[:input_tokens].to_i
151
+ audio_input = LlmCostTracker::Providers::Openai::UsageExtractor.audio_input_tokens(usage)
178
152
  {
179
153
  input_tokens: [raw_input - audio_input, 0].max,
180
154
  audio_input_tokens: audio_input,
181
- output_tokens: object_value(usage, :output_tokens).to_i
155
+ output_tokens: usage[:output_tokens].to_i
182
156
  }
183
157
  end
184
158
 
@@ -199,18 +173,18 @@ module LlmCostTracker
199
173
  return [] unless input.is_a?(String)
200
174
  return [] unless LlmCostTracker::Providers::Openai::ModelFamilies.character_billed_tts?(request[:model])
201
175
 
202
- [LlmCostTracker::Billing::LineItem.build(
203
- component_key: :text_to_speech_character,
176
+ [LlmCostTracker::Charges::LineItem.build(
177
+ dimension_key: "text_to_speech_character",
204
178
  quantity: input.length,
205
- cost_status: LlmCostTracker::Billing::CostStatus::UNKNOWN,
206
- pricing_basis: :provider_usage,
179
+ cost_status: LlmCostTracker::Charges::CostStatus::UNKNOWN,
180
+ pricing_basis: "provider_usage",
207
181
  provider_field: "request.input"
208
182
  )]
209
183
  end
210
184
 
211
185
  def record_moderation(response, request:, latency_ms:, host: nil)
212
186
  record_passthrough(
213
- model: object_value(response, :model) || request[:model],
187
+ model: response.model || request[:model],
214
188
  response: response,
215
189
  latency_ms: latency_ms,
216
190
  host: host,
@@ -227,9 +201,9 @@ module LlmCostTracker
227
201
  event: Event.build(
228
202
  provider: provider_for_host(host),
229
203
  model: model,
230
- token_usage: TokenUsage.build(**token_attributes),
231
- usage_source: :sdk_response,
232
- provider_response_id: response && object_value(response, :id),
204
+ token_usage: Usage::TokenUsage.build(**token_attributes),
205
+ usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
206
+ provider_response_id: response&.try(:id),
233
207
  service_line_items: service_line_items
234
208
  ),
235
209
  latency_ms: latency_ms
@@ -237,190 +211,10 @@ module LlmCostTracker
237
211
  end
238
212
  end
239
213
 
240
- def service_line_items_from(response, request: nil)
241
- model = object_value(response, :model) || request&.dig(:model)
242
- output = object_value(response, :output)
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::Providers::Openai::ServiceCharges.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?)
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
-
276
- def normalize_output_item(item)
277
- return item if item.is_a?(Hash)
278
- return nil if item.nil?
279
-
280
- {
281
- "type" => object_value(item, :type)&.to_s,
282
- "id" => object_value(item, :id),
283
- "status" => object_value(item, :status),
284
- "container_id" => object_value(item, :container_id),
285
- "action" => normalize_output_action(object_value(item, :action))
286
- }
287
- end
288
-
289
- def normalize_output_action(action)
290
- return nil if action.nil?
291
- return action if action.is_a?(Hash)
292
-
293
- { "type" => object_value(action, :type)&.to_s }
294
- end
295
-
296
- def token_usage(usage:, input_tokens:, output_tokens:, cache_read:, model: nil)
297
- audio_input = audio_input_tokens(usage)
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
- )
309
-
310
- TokenUsage.build(
311
- input_tokens: regular_input_tokens(input_tokens, cache_read, audio_input, image_input),
312
- output_tokens: regular_output,
313
- cache_read_input_tokens: cache_read,
314
- audio_input_tokens: audio_input,
315
- audio_output_tokens: audio_output,
316
- image_input_tokens: image_input,
317
- image_output_tokens: image_output,
318
- hidden_output_tokens: hidden_output_tokens(usage)
319
- )
320
- end
321
-
322
- INPUT_DETAIL_KEYS = %i[input_tokens_details input_token_details prompt_tokens_details].freeze
323
- OUTPUT_DETAIL_KEYS = %i[output_tokens_details output_token_details completion_tokens_details].freeze
324
-
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)
332
-
333
- def detail(usage, containers, key)
334
- containers.each do |container|
335
- value = object_dig(usage, container, key)
336
- return value.to_i if value
337
- end
338
- 0
339
- end
340
-
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
343
- end
344
-
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
351
-
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]
355
- end
356
- end
357
-
358
- module PatchBuilder
359
- module_function
360
-
361
- def build(record_method:, methods:)
362
- Module.new.tap do |mod|
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) }
370
- end
371
- end
372
-
373
- def define_blocking_method(mod, method_name, record_method)
374
- mod.define_method(method_name) do |*args, **kwargs, &block|
375
- integration = LlmCostTracker::Integrations::Openai
376
- request = integration.request_params(args, kwargs)
377
- integration.enforce_budget!(request: request)
378
- started_at = LlmCostTracker::Timing.now_monotonic
379
- response = super(*integration.normalize_sdk_args(args, kwargs), &block)
380
- integration.public_send(
381
- record_method, response,
382
- request: request,
383
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
384
- host: integration.client_host_for(self)
385
- )
386
- response
387
- end
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
397
- end
398
-
399
- module ResponsesPatch
400
- include PatchBuilder.build(record_method: :record_response, methods: %i[create])
401
- include PatchBuilder.build_stream(methods: %i[stream stream_raw])
402
-
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)
407
- end
214
+ def usage_hash_from(response)
215
+ response.try(:usage)&.deep_to_h
408
216
  end
409
217
  end
410
-
411
- module ChatCompletionsPatch
412
- include PatchBuilder.build(record_method: :record_response, methods: %i[create])
413
- include PatchBuilder.build_stream(methods: %i[stream stream_raw])
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])
424
218
  end
425
219
  end
426
220
  end