llm_cost_tracker 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
@@ -11,13 +11,18 @@ module LlmCostTracker
11
11
  module Providers
12
12
  module Openai
13
13
  module ResponseParser
14
- include LlmCostTracker::Providers::Openai::ServiceCharges
14
+ PENDING_STATUSES = %w[queued in_progress].freeze
15
+ RETRIEVE_PATH_PATTERN = %r{/(?:responses|agent)/resp_}
16
+ TRANSCRIPTION_PATH_PATTERN = %r{/audio/(?:transcriptions|translations)\z}
15
17
 
16
18
  class << self
17
- def combined_pricing_mode(host:, model:, service_tier:)
19
+ def included(base) = base.include(StreamParser)
20
+
21
+ def combined_pricing_mode(host:, model:, service_tier:, provider: "openai")
18
22
  modes = [Pricing::Mode.normalize(service_tier)]
19
- if Hosts.data_residency?(host) &&
20
- Pricing::Matcher.modifier_priced?(provider: "openai", model: model, modifier: "data_residency")
23
+ owners = %w[gemini/ anthropic/] if Hosts.vertex_non_global?(host)
24
+ if (owners || Hosts.data_residency?(host)) &&
25
+ Pricing::Matcher.modifier_priced?(provider:, model:, modifier: "data_residency", owners:)
21
26
  modes << "data_residency"
22
27
  end
23
28
  Pricing::Mode.compose(modes)
@@ -25,133 +30,130 @@ module LlmCostTracker
25
30
 
26
31
  def event_from_response(response:, request:, provider:, host:, usage_source:, pricing_mode: nil)
27
32
  usage = response["usage"]&.deep_symbolize_keys
28
- return nil unless usage
33
+ return nil if usage.nil? || pending?(response)
29
34
 
30
35
  model = response["model"] || request["model"]
31
- service_line_items =
32
- ServiceCharges.service_line_items_for(response, request: request, model: model) +
33
- ServiceCharges.transcription_line_items(usage)
34
- Event.build(
36
+ service_tier = response["service_tier"] || usage[:service_tier] || request["service_tier"]
37
+ usage_event(
38
+ usage,
35
39
  provider: provider,
36
40
  provider_response_id: response["id"],
37
- pricing_mode: pricing_mode || combined_pricing_mode(
38
- host: host, model: model, service_tier: response["service_tier"] || request["service_tier"]
39
- ),
40
41
  model: model,
41
- token_usage: UsageExtractor.token_usage(usage, model: model),
42
42
  usage_source: usage_source,
43
- service_line_items: service_line_items
43
+ service_line_items: ServiceCharges.service_line_items_for(response, request: request, model: model),
44
+ pricing_mode: pricing_mode || combined_pricing_mode(provider:, host:, model:, service_tier:)
44
45
  )
45
46
  end
46
- end
47
47
 
48
- def parse(request_url:, request_body:, response_status:, response_body:, **)
49
- return nil unless response_status == 200
48
+ def retrieved_event(response:, provider:, host:, usage_source:)
49
+ return nil unless response["background"] && response["usage"] && !pending?(response)
50
+ return nil if Call.already_recorded?(provider: provider, provider_response_id: response["id"])
50
51
 
51
- ResponseParser.event_from_response(
52
- response: safe_json_parse(response_body),
53
- request: safe_json_parse(request_body),
54
- provider: provider_for(request_url),
55
- host: parsed_uri(request_url)&.host,
56
- usage_source: Usage::Source::RESPONSE
57
- )
58
- end
59
-
60
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
61
- return nil unless response_status == 200
52
+ event_from_response(
53
+ response: response,
54
+ request: { "tools" => response["tools"] },
55
+ provider: provider,
56
+ host: host,
57
+ usage_source: usage_source
58
+ )&.keyed_by_response_id
59
+ end
62
60
 
63
- request = safe_json_parse(request_body)
64
- usage = detect_stream_usage(events)
65
- context = stream_capture_context(events: events, request: request, request_url: request_url)
61
+ def usage_event(usage, model:, service_line_items:, **attributes)
62
+ Event.build(
63
+ model: model,
64
+ token_usage: UsageExtractor.token_usage(usage, model: model),
65
+ service_line_items: service_line_items + ServiceCharges.transcription_line_items(usage) +
66
+ ServiceCharges.billed_line_items(usage) + UsageExtractor.cache_read_line_items(usage),
67
+ **attributes
68
+ )
69
+ end
66
70
 
67
- return build_known_stream_usage(usage: usage, **context) if usage
71
+ private
68
72
 
69
- warn_missing_stream_usage(request_url: request_url, request: request)
70
- build_unknown_stream_usage(**context)
73
+ def pending?(response)
74
+ PENDING_STATUSES.include?(response["status"].to_s)
75
+ end
71
76
  end
72
77
 
73
- def auto_enable_stream_usage?(request_url, _request_parsed)
74
- openai_chat_completions_url?(request_url)
75
- end
78
+ def parse(request_url:, request_body:, response_status:, response_body:, **)
79
+ return nil unless response_status == 200
80
+
81
+ response = safe_json_parse(response_body)
82
+ uri = parsed_uri(request_url)
83
+ source = { provider: provider_for(request_url), host: uri&.host, usage_source: Usage::Source::RESPONSE }
84
+ if uri&.path.to_s.match?(RETRIEVE_PATH_PATTERN)
85
+ return ResponseParser.retrieved_event(response: response, **source)
86
+ end
76
87
 
77
- def retain_stream_event?(data)
78
- data.is_a?(Hash) && (data["item"].is_a?(Hash) || data["response"].is_a?(Hash))
88
+ request = safe_json_parse(request_body)
89
+ ResponseParser.event_from_response(response: response, request: request, **source) ||
90
+ usage_free_event(request_url, request, response)
79
91
  end
80
92
 
81
93
  private
82
94
 
83
- def stream_capture_context(events:, request:, request_url:)
84
- model = find_event_value(events) do |data|
85
- data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
86
- end || request["model"]
87
- {
88
- provider: provider_for(request_url),
89
- model: model,
90
- provider_response_id: find_event_value(events) do |data|
91
- data["id"] || data.dig("response", "id") || data.dig("chunk", "id")
92
- end,
93
- pricing_mode: pricing_mode(
94
- request_url: request_url,
95
- model: model,
96
- service_tier: stream_pricing_mode(events) || request["service_tier"]
97
- ),
98
- service_line_items: openai_stream_service_line_items(events, request: request, model: model)
99
- }
95
+ def usage_free_event(request_url, request, response)
96
+ path = parsed_uri(request_url)&.path.to_s
97
+ if path.end_with?("/audio/speech")
98
+ speech_event(request_url, request)
99
+ elsif path.match?(TRANSCRIPTION_PATH_PATTERN)
100
+ transcription_event(request_url, request, response)
101
+ else
102
+ ocr_event(request_url, request, response) ||
103
+ (moderation_event(request_url, request, response) if path.end_with?("/moderations"))
104
+ end
100
105
  end
101
106
 
102
- def build_known_stream_usage(usage:,
103
- provider:,
104
- model:,
105
- provider_response_id:,
106
- pricing_mode:,
107
- service_line_items:)
108
- Event.build(
109
- provider: provider,
110
- provider_response_id: provider_response_id,
111
- pricing_mode: pricing_mode,
112
- model: model,
113
- token_usage: UsageExtractor.token_usage(usage, model: model),
114
- stream: true,
115
- usage_source: Usage::Source::STREAM_FINAL,
116
- service_line_items: service_line_items
117
- )
107
+ def speech_event(request_url, request)
108
+ line_item_event(request_url, model_for(request_url, request), ServiceCharges.speech_line_items(request))
118
109
  end
119
110
 
120
- def warn_missing_stream_usage(request_url:, request:)
121
- return unless request["stream"]
122
- return unless openai_chat_completions_url?(request_url)
123
- return if request.dig("stream_options", "include_usage")
111
+ def transcription_event(request_url, request, response)
112
+ seconds = response["duration"].to_f.ceil
113
+ line_items = ServiceCharges.transcription_line_items(type: "duration", seconds: seconds)
114
+ line_item_event(request_url, model_for(request_url, request) || Event::UNKNOWN_MODEL, line_items)
115
+ end
124
116
 
125
- Logging.warn(
126
- "OpenAI-compatible chat-completions stream finished without a final usage chunk. " \
127
- "Set `stream_options: { include_usage: true }` in your request body so the gem can " \
128
- "record token counts. This call was stored with usage_source=#{Usage::Source::UNKNOWN}."
117
+ def line_item_event(request_url, model, line_items)
118
+ zero_token_event(
119
+ request_url,
120
+ model: model,
121
+ usage_source: line_items.empty? ? Usage::Source::UNKNOWN : Usage::Source::RESPONSE,
122
+ service_line_items: line_items
129
123
  )
130
124
  end
131
125
 
132
- def openai_chat_completions_url?(request_url)
133
- uri = parsed_uri(request_url)
134
- uri && uri.path.to_s.end_with?("/chat/completions")
135
- end
126
+ def ocr_event(request_url, request, response)
127
+ line_items = ServiceCharges.ocr_line_items(response)
128
+ return nil if line_items.empty?
136
129
 
137
- def detect_stream_usage(events)
138
- usage = find_event_value(events, reverse: true) do |data|
139
- candidate = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage")
140
- candidate if candidate.is_a?(Hash)
141
- end
142
- usage&.deep_symbolize_keys
130
+ model = response["model"] || model_for(request_url, request)
131
+ zero_token_event(
132
+ request_url,
133
+ model: model,
134
+ pricing_mode: ResponseParser.combined_pricing_mode(
135
+ provider: provider_for(request_url), host: parsed_uri(request_url)&.host, model: model, service_tier: nil
136
+ ),
137
+ usage_source: Usage::Source::RESPONSE,
138
+ service_line_items: line_items
139
+ )
143
140
  end
144
141
 
145
- def stream_pricing_mode(events)
146
- find_event_value(events, reverse: true) do |data|
147
- data["service_tier"] || data.dig("response", "service_tier") || data.dig("chunk", "service_tier")
148
- end
142
+ def moderation_event(request_url, request, response)
143
+ zero_token_event(
144
+ request_url,
145
+ model: response["model"] || model_for(request_url, request),
146
+ provider_response_id: response["id"],
147
+ usage_source: Usage::Source::RESPONSE
148
+ )
149
149
  end
150
150
 
151
- def pricing_mode(request_url:, model:, service_tier:)
152
- ResponseParser.combined_pricing_mode(host: parsed_uri(request_url)&.host,
153
- model: model,
154
- service_tier: service_tier)
151
+ def zero_token_event(request_url, **attributes)
152
+ Event.build(
153
+ provider: provider_for(request_url),
154
+ token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0),
155
+ **attributes
156
+ )
155
157
  end
156
158
  end
157
159
  end
@@ -1,179 +1,173 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "model_families"
4
+ require_relative "output_item"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Providers
7
8
  module Openai
8
9
  module ServiceCharges
9
- RESPONSE_OUTPUT_RENAMES = {
10
- "web_search_call" => "web_search_request",
11
- "code_interpreter_call" => "container_session"
12
- }.freeze
13
-
14
- module_function
15
-
16
- def line_items_from_output(output_items, request: nil, model: nil)
17
- deduped = {}
18
- Array(output_items).each { |item| store_output_item(deduped, item) }
19
- deduped.values
20
- .select { |item| billable?(item) }
21
- .filter_map { |item| build_line_item(item, request: request, model: model) }
22
- end
23
-
24
- def service_line_items_for(response, request: nil, model: nil)
25
- output_items = Array(response["output"])
26
- output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
27
- line_items_from_output(output_items, request: request, model: model)
28
- end
10
+ TICKS_PER_USD = 10_000_000_000
29
11
 
30
- CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
31
- CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
12
+ class << self
13
+ def service_line_items_for(response, request: nil, model: nil)
14
+ output_items = Array(response["output"])
15
+ output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
16
+ line_items_from_output(output_items, request: request, model: model)
17
+ end
32
18
 
33
- def chat_completions_web_search_items(response, model: nil)
34
- return [] unless response["choices"]
19
+ def line_items_from_output(output_items, request: nil, model: nil)
20
+ unique = {}
21
+ Array(output_items).each do |data|
22
+ item = OutputItem.wrap(data)
23
+ unique[item.dedup_key(unique.length)] = item if item
24
+ end
25
+ unique.values.select(&:billable?).map { |item| priced_line_item(item, request: request, model: model) }
26
+ end
35
27
 
36
- provider_field = chat_completions_search_provider_field(response["choices"], model)
37
- return [] unless provider_field
28
+ def billable?(item)
29
+ OutputItem.wrap(item)&.billable? || false
30
+ end
38
31
 
39
- [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
40
- "provider_field" => provider_field }]
41
- end
32
+ def build_line_item(item, request: nil, model: nil)
33
+ output_item = OutputItem.wrap(item)
34
+ priced_line_item(output_item, request: request, model: model) if output_item
35
+ end
42
36
 
43
- def chat_completions_search_provider_field(choices, model)
44
- return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
45
- return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
37
+ def billing_fields(item)
38
+ OutputItem.wrap(item)&.billing_fields
39
+ end
46
40
 
47
- nil
48
- end
41
+ def transcription_line_items(usage)
42
+ return [] unless usage
43
+
44
+ field = (usage[:type] || usage["type"]).to_s == "duration" ? "seconds" : "prompt_audio_seconds"
45
+ seconds = (usage[field.to_sym] || usage[field]).to_f
46
+ return [] unless seconds.positive?
47
+
48
+ [Charges::LineItem.build(
49
+ dimension_key: "transcription_minute",
50
+ quantity: BigDecimal(seconds.to_s) / 60,
51
+ cost_status: Charges::CostStatus::UNKNOWN,
52
+ pricing_basis: "provider_usage",
53
+ provider_field: "usage.#{field}",
54
+ details: { seconds: seconds }
55
+ )]
56
+ end
49
57
 
50
- def chat_completions_used_web_search?(choices)
51
- Array(choices).any? do |choice|
52
- Array(choice.dig("message", "annotations")).any? do |annotation|
53
- annotation.is_a?(Hash) && annotation["type"].to_s == "url_citation"
54
- end
58
+ def speech_line_items(request)
59
+ input = request["input"]
60
+ return [] unless input.is_a?(String) && ModelFamilies.character_billed_tts?(request["model"])
61
+
62
+ [Charges::LineItem.build(
63
+ dimension_key: "text_to_speech_character",
64
+ quantity: input.length,
65
+ cost_status: Charges::CostStatus::UNKNOWN,
66
+ pricing_basis: "provider_usage",
67
+ provider_field: "request.input"
68
+ )]
55
69
  end
56
- end
57
70
 
58
- def billable?(item)
59
- return false unless item.is_a?(Hash)
71
+ def ocr_line_items(response)
72
+ pages = response.dig("usage_info", "pages_processed")
73
+ return unit_line_items("ocr_page", pages, "usage_info.pages_processed") if pages
60
74
 
61
- dimension = output_dimension(item["type"])
62
- return false unless dimension
63
- return true unless dimension == "web_search_request"
75
+ unit_line_items("ocr_page", response.dig("meta", "billed_units", "pages"), "meta.billed_units.pages")
76
+ end
64
77
 
65
- action_type = item.dig("action", "type")
66
- action_type.nil? || action_type == "search"
67
- end
78
+ def rerank_line_items(response)
79
+ units = response.dig("meta", "billed_units", "search_units")
80
+ unit_line_items("rerank_search_unit", units, "meta.billed_units.search_units")
81
+ end
68
82
 
69
- def store_output_item(output_items, item)
70
- return unless item.is_a?(Hash)
83
+ def billed_line_items(usage)
84
+ amount, field = billed_amount(usage)
85
+ return [] unless amount
86
+
87
+ [Charges::LineItem.build(
88
+ dimension_key: "billed_request",
89
+ quantity: 1,
90
+ rate_amount: amount,
91
+ cost: amount,
92
+ pricing_basis: "provider_usage",
93
+ price_source: "provider_response",
94
+ provider_field: field
95
+ )]
96
+ end
71
97
 
72
- dimension = output_dimension(item["type"])
73
- return unless dimension
98
+ private
74
99
 
75
- key = if dimension == "container_session" && item["container_id"]
76
- "#{dimension}:#{item['container_id']}"
77
- else
78
- item["id"] || "#{item['type']}:#{output_items.length}"
79
- end
80
- output_items[key] = item
81
- end
100
+ def chat_completions_web_search_items(response, model: nil)
101
+ return [] unless response["choices"] && chat_completions_search_model?(model)
82
102
 
83
- def build_line_item(item, request: nil, model: nil)
84
- return nil unless item.is_a?(Hash)
85
-
86
- dimension_key = dimension_key_for(item, request: request, model: model)
87
- return nil unless dimension_key
88
-
89
- provider_item_id = if dimension_key == "container_session"
90
- item["container_id"] || item["id"]
91
- else
92
- item["id"]
93
- end
94
- Charges::LineItem.build(
95
- dimension_key: dimension_key,
96
- quantity: 1,
97
- cost_status: Charges::CostStatus::UNKNOWN,
98
- pricing_basis: "provider_usage",
99
- provider_field: item["provider_field"] || "response.output.#{item['type']}",
100
- provider_item_id: provider_item_id,
101
- details: line_item_details(item)
102
- )
103
- end
103
+ [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
104
+ "provider_field" => "request.model" }]
105
+ end
104
106
 
105
- def dimension_key_for(item, request:, model:)
106
- dimension = output_dimension(item["type"])
107
- return dimension unless dimension == "web_search_request"
108
- return dimension unless web_search_preview_used?(request) || chat_completions_search_model?(model)
107
+ def priced_line_item(item, request:, model:)
108
+ item.line_item(priced_dimension(item.dimension, request: request, model: model))
109
+ end
109
110
 
110
- reasoning_model?(model) ? "web_search_preview_request_reasoning" : "web_search_preview_request_non_reasoning"
111
- end
111
+ def priced_dimension(dimension, request:, model:)
112
+ return dimension unless dimension == "web_search_request"
113
+ return dimension unless web_search_preview_used?(request) || chat_completions_search_model?(model)
114
+ return "web_search_preview_request_reasoning" if reasoning_model?(model)
112
115
 
113
- def output_dimension(type)
114
- key = RESPONSE_OUTPUT_RENAMES[type] || type
115
- dimension = Usage::Catalog[key]
116
- key if dimension && dimension.token_key.nil?
117
- end
116
+ "web_search_preview_request_non_reasoning"
117
+ end
118
118
 
119
- def web_search_preview_used?(request)
120
- tools = request && (request[:tools] || request["tools"])
121
- Array(tools).any? do |tool|
122
- type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
123
- type.to_s.include?("web_search_preview")
119
+ def web_search_preview_used?(request)
120
+ tools = request && (request[:tools] || request["tools"])
121
+ Array(tools).any? do |tool|
122
+ type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
123
+ type.to_s.include?("web_search_preview")
124
+ end
124
125
  end
125
- end
126
126
 
127
- def chat_completions_search_model?(model)
128
- name = local_model_name(model)
129
- name && ModelFamilies.chat_completions_search?(name)
130
- end
127
+ def chat_completions_search_model?(model)
128
+ name = local_model_name(model)
129
+ name && ModelFamilies.chat_completions_search?(name)
130
+ end
131
131
 
132
- def reasoning_model?(model)
133
- name = local_model_name(model)
134
- name && ModelFamilies.reasoning?(name)
135
- end
132
+ def reasoning_model?(model)
133
+ name = local_model_name(model)
134
+ name && ModelFamilies.reasoning?(name)
135
+ end
136
136
 
137
- def local_model_name(model)
138
- return nil unless model
137
+ def local_model_name(model)
138
+ return nil unless model
139
139
 
140
- model.to_s.split("/", 2).last
141
- end
140
+ model.to_s.split("/", 2).last
141
+ end
142
142
 
143
- def line_item_details(item)
144
- {
145
- status: item["status"],
146
- action_type: item.dig("action", "type"),
147
- container_id: item["container_id"]
148
- }.compact
149
- end
143
+ def unit_line_items(dimension_key, quantity, provider_field)
144
+ return [] unless quantity.is_a?(Numeric) && quantity.positive?
150
145
 
151
- def openai_stream_service_line_items(events, request: nil, model: nil)
152
- output_items = []
153
- each_event_data(events) do |data|
154
- output_items.concat(Array(data.dig("response", "output")))
155
- output_items << data["item"] if data["item"]
146
+ [Charges::LineItem.build(
147
+ dimension_key: dimension_key,
148
+ quantity: quantity,
149
+ cost_status: Charges::CostStatus::UNKNOWN,
150
+ pricing_basis: "provider_usage",
151
+ provider_field: provider_field
152
+ )]
156
153
  end
157
- line_items_from_output(output_items, request: request, model: model)
158
- end
159
154
 
160
- def transcription_line_items(usage)
161
- return [] unless usage
155
+ def billed_amount(usage)
156
+ cost = usage[:cost]
157
+ return reported_cost(usage, cost) if cost.is_a?(Numeric)
162
158
 
163
- type = (usage[:type] || usage["type"]).to_s
164
- return [] unless type == "duration"
159
+ ticks = usage[:cost_in_usd_ticks]
160
+ return [BigDecimal(ticks.to_s) / TICKS_PER_USD, "usage.cost_in_usd_ticks"] if ticks.is_a?(Numeric)
165
161
 
166
- seconds = (usage[:seconds] || usage["seconds"]).to_f
167
- return [] unless seconds.positive?
162
+ total = cost[:total_cost] if cost.is_a?(Hash)
163
+ [BigDecimal(total.to_s), "usage.cost.total_cost"] if total.is_a?(Numeric) && total.positive?
164
+ end
168
165
 
169
- [Charges::LineItem.build(
170
- dimension_key: "transcription_minute",
171
- quantity: (seconds / 60.0).ceil,
172
- cost_status: Charges::CostStatus::UNKNOWN,
173
- pricing_basis: "provider_usage",
174
- provider_field: "usage.seconds",
175
- details: { seconds: seconds }
176
- )]
166
+ def reported_cost(usage, cost)
167
+ amounts = [cost]
168
+ amounts << usage.dig(:cost_details, :upstream_inference_cost) if usage[:is_byok]
169
+ [amounts.sum { |value| BigDecimal(value.to_s) }, "usage.cost"] if amounts.all?(Numeric)
170
+ end
177
171
  end
178
172
  end
179
173
  end