llm_cost_tracker 0.10.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  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/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. 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"
5
- require_relative "../parsers/openai_service_charges"
4
+ require_relative "../capture/sdk_payload"
5
+ require_relative "../charges/line_item"
6
6
  require_relative "../providers/azure/hosts"
7
7
  require_relative "../providers/openai/model_families"
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?
111
-
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
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
98
+
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,242 +211,8 @@ 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::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?)
256
-
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" }
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 ResponsesPatch
359
- def create(*args, **kwargs)
360
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
361
- LlmCostTracker::Integrations::Openai.enforce_budget!(request: request)
362
- started_at = LlmCostTracker::Timing.now_monotonic
363
- response = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
364
- LlmCostTracker::Integrations::Openai.record_response(
365
- response,
366
- request: request,
367
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
368
- host: LlmCostTracker::Integrations::Openai.client_host_for(self)
369
- )
370
- response
371
- end
372
-
373
- def stream(*args, **kwargs)
374
- LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
375
- super(*normalized)
376
- end
377
- end
378
-
379
- def stream_raw(*args, **kwargs)
380
- LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
381
- super(*normalized)
382
- end
383
- end
384
-
385
- def retrieve_streaming(response_id, *args, **kwargs)
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
390
- end
391
- end
392
-
393
- module ChatCompletionsPatch
394
- def create(*args, **kwargs)
395
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
396
- LlmCostTracker::Integrations::Openai.enforce_budget!(request: request)
397
- started_at = LlmCostTracker::Timing.now_monotonic
398
- response = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
399
- LlmCostTracker::Integrations::Openai.record_response(
400
- response,
401
- request: request,
402
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
403
- host: LlmCostTracker::Integrations::Openai.client_host_for(self)
404
- )
405
- response
406
- end
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
-
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)
470
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
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))
475
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
214
+ def usage_hash_from(response)
215
+ response.try(:usage)&.deep_to_h
476
216
  end
477
217
  end
478
218
  end