llm_cost_tracker 0.14.2 → 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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
@@ -11,11 +11,18 @@ module LlmCostTracker
11
11
  module Providers
12
12
  module Openai
13
13
  module ResponseParser
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}
17
+
14
18
  class << self
19
+ def included(base) = base.include(StreamParser)
20
+
15
21
  def combined_pricing_mode(host:, model:, service_tier:, provider: "openai")
16
22
  modes = [Pricing::Mode.normalize(service_tier)]
17
- if Hosts.data_residency?(host) &&
18
- Pricing::Matcher.modifier_priced?(provider: provider, 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:)
19
26
  modes << "data_residency"
20
27
  end
21
28
  Pricing::Mode.compose(modes)
@@ -23,33 +30,23 @@ module LlmCostTracker
23
30
 
24
31
  def event_from_response(response:, request:, provider:, host:, usage_source:, pricing_mode: nil)
25
32
  usage = response["usage"]&.deep_symbolize_keys
26
- return nil unless usage
33
+ return nil if usage.nil? || pending?(response)
27
34
 
28
35
  model = response["model"] || request["model"]
29
- service_line_items =
30
- ServiceCharges.service_line_items_for(response, request: request, model: model) +
31
- ServiceCharges.transcription_line_items(usage) +
32
- ServiceCharges.billed_line_items(usage) +
33
- UsageExtractor.cache_read_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
- provider: provider,
39
- host: host,
40
- model: model,
41
- service_tier: response["service_tier"] || usage[:service_tier] || request["service_tier"]
42
- ),
43
41
  model: model,
44
- token_usage: UsageExtractor.token_usage(usage, model: model),
45
42
  usage_source: usage_source,
46
- 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:)
47
45
  )
48
46
  end
49
47
 
50
48
  def retrieved_event(response:, provider:, host:, usage_source:)
51
- finished = !%w[queued in_progress].include?(response["status"].to_s)
52
- return nil unless finished && response["background"] && response["usage"]
49
+ return nil unless response["background"] && response["usage"] && !pending?(response)
53
50
  return nil if Call.already_recorded?(provider: provider, provider_response_id: response["id"])
54
51
 
55
52
  event_from_response(
@@ -60,168 +57,103 @@ module LlmCostTracker
60
57
  usage_source: usage_source
61
58
  )&.keyed_by_response_id
62
59
  end
60
+
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
70
+
71
+ private
72
+
73
+ def pending?(response)
74
+ PENDING_STATUSES.include?(response["status"].to_s)
75
+ end
63
76
  end
64
77
 
65
78
  def parse(request_url:, request_body:, response_status:, response_body:, **)
66
79
  return nil unless response_status == 200
67
80
 
68
81
  response = safe_json_parse(response_body)
69
- host = parsed_uri(request_url)&.host
70
- if parsed_uri(request_url)&.path.to_s.include?("/responses/resp_")
71
- return ResponseParser.retrieved_event(
72
- response: response,
73
- provider: provider_for(request_url),
74
- host: host,
75
- usage_source: Usage::Source::RESPONSE
76
- )
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)
77
86
  end
78
87
 
79
88
  request = safe_json_parse(request_body)
80
- ResponseParser.event_from_response(
81
- response: response,
82
- request: request,
83
- provider: provider_for(request_url),
84
- host: host,
85
- usage_source: Usage::Source::RESPONSE
86
- ) || speech_event(request_url, request) || transcription_without_usage_event(request_url, request)
89
+ ResponseParser.event_from_response(response: response, request: request, **source) ||
90
+ usage_free_event(request_url, request, response)
87
91
  end
88
92
 
89
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
90
- return nil unless response_status == 200
91
-
92
- request = safe_json_parse(request_body)
93
- usage = detect_stream_usage(events)
94
- context = stream_capture_context(events: events, request: request, request_url: request_url, usage: usage)
93
+ private
95
94
 
96
- background = find_event_value(events) { |data| data.dig("response", "background") }
97
- if usage
98
- event = build_known_stream_usage(usage: usage, **context)
99
- return background ? event.keyed_by_response_id : event
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"))
100
104
  end
101
-
102
- warn_missing_stream_usage(request_url: request_url, request: request)
103
- build_unknown_stream_usage(**context, service_line_items: background ? [] : context[:service_line_items])
104
105
  end
105
106
 
106
- def auto_enable_stream_usage?(request_url, _request_parsed)
107
- openai_chat_completions_url?(request_url)
107
+ def speech_event(request_url, request)
108
+ line_item_event(request_url, model_for(request_url, request), ServiceCharges.speech_line_items(request))
108
109
  end
109
110
 
110
- def retain_stream_event?(data)
111
- data.is_a?(Hash) && (data["item"].is_a?(Hash) || data["response"].is_a?(Hash))
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)
112
115
  end
113
116
 
114
- private
115
-
116
- def speech_event(request_url, request)
117
- uri = parsed_uri(request_url)
118
- return nil unless uri && uri.path.to_s.end_with?("/audio/speech")
119
-
120
- Event.build(
121
- provider: provider_for(request_url),
122
- model: model_for(request_url, request),
123
- token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0),
124
- usage_source: Usage::Source::RESPONSE,
125
- service_line_items: ServiceCharges.speech_line_items(request)
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
126
123
  )
127
124
  end
128
125
 
129
- def transcription_without_usage_event(request_url, request)
130
- uri = parsed_uri(request_url)
131
- return nil unless uri && uri.path.to_s.match?(%r{/audio/(?:transcriptions|translations)\z})
132
-
133
- Event.build(
134
- provider: provider_for(request_url),
135
- model: model_for(request_url, request) || Event::UNKNOWN_MODEL,
136
- token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0),
137
- usage_source: Usage::Source::UNKNOWN
138
- )
139
- end
126
+ def ocr_event(request_url, request, response)
127
+ line_items = ServiceCharges.ocr_line_items(response)
128
+ return nil if line_items.empty?
140
129
 
141
- def stream_capture_context(events:, request:, request_url:, usage:)
142
- model = find_event_value(events) do |data|
143
- data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
144
- end || request["model"]
145
- provider = provider_for(request_url)
146
- {
147
- provider: provider,
130
+ model = response["model"] || model_for(request_url, request)
131
+ zero_token_event(
132
+ request_url,
148
133
  model: model,
149
- provider_response_id: find_event_value(events) do |data|
150
- data["id"] || data.dig("response", "id") || data.dig("chunk", "id")
151
- end,
152
134
  pricing_mode: ResponseParser.combined_pricing_mode(
153
- provider: provider,
154
- host: parsed_uri(request_url)&.host,
155
- model: model,
156
- service_tier: stream_pricing_mode(events) || usage&.dig(:service_tier) || request["service_tier"]
135
+ provider: provider_for(request_url), host: parsed_uri(request_url)&.host, model: model, service_tier: nil
157
136
  ),
158
- service_line_items: openai_stream_service_line_items(events, request: request, model: model)
159
- }
160
- end
161
-
162
- def build_known_stream_usage(usage:,
163
- provider:,
164
- model:,
165
- provider_response_id:,
166
- pricing_mode:,
167
- service_line_items:)
168
- Event.build(
169
- provider: provider,
170
- provider_response_id: provider_response_id,
171
- pricing_mode: pricing_mode,
172
- model: model,
173
- token_usage: UsageExtractor.token_usage(usage, model: model),
174
- stream: true,
175
- usage_source: Usage::Source::STREAM_FINAL,
176
- service_line_items: service_line_items + ServiceCharges.transcription_line_items(usage) +
177
- ServiceCharges.billed_line_items(usage) + UsageExtractor.cache_read_line_items(usage)
137
+ usage_source: Usage::Source::RESPONSE,
138
+ service_line_items: line_items
178
139
  )
179
140
  end
180
141
 
181
- def warn_missing_stream_usage(request_url:, request:)
182
- return unless request_url.nil? || request["stream"]
183
- return unless request_url ? openai_chat_completions_url?(request_url) : request["messages"]
184
- return if request.dig("stream_options", "include_usage")
185
-
186
- Logging.warn(
187
- "OpenAI-compatible chat-completions stream finished without a final usage chunk. " \
188
- "Set `stream_options: { include_usage: true }` in your request body so the gem can " \
189
- "record token counts. This call was stored with usage_source=#{Usage::Source::UNKNOWN}."
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
190
148
  )
191
149
  end
192
150
 
193
- def openai_chat_completions_url?(request_url)
194
- uri = parsed_uri(request_url)
195
- uri && uri.path.to_s.end_with?("/chat/completions")
196
- end
197
-
198
- def detect_stream_usage(events)
199
- usage = find_event_value(events, reverse: true) do |data|
200
- candidate = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage") ||
201
- data.dig("x_groq", "usage") || data.dig("chunk", "x_groq", "usage")
202
- candidate if candidate.is_a?(Hash)
203
- end
204
- usage&.deep_symbolize_keys
205
- end
206
-
207
- def stream_pricing_mode(events)
208
- find_event_value(events, reverse: true) do |data|
209
- data["service_tier"] || data.dig("response", "service_tier") || data.dig("chunk", "service_tier")
210
- end
211
- end
212
-
213
- def openai_stream_service_line_items(events, request: nil, model: nil)
214
- response = { "output" => [] }
215
- each_event_data(events) do |data|
216
- response["output"].concat(Array(data.dig("response", "output")))
217
- response["output"] << data["item"] if data["item"]
218
- chunk = data["chunk"] || data
219
- next unless chunk["choices"].is_a?(Array)
220
-
221
- response["id"] ||= chunk["id"]
222
- response["choices"] ||= chunk["choices"]
223
- end
224
- ServiceCharges.service_line_items_for(response, request: request, model: model)
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
+ )
225
157
  end
226
158
  end
227
159
  end
@@ -1,196 +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
- def chat_completions_web_search_items(response, model: nil)
31
- return [] unless response["choices"] && chat_completions_search_model?(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
- [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
34
- "provider_field" => "request.model" }]
35
- end
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
36
27
 
37
- def billable?(item)
38
- return false unless item.is_a?(Hash)
28
+ def billable?(item)
29
+ OutputItem.wrap(item)&.billable? || false
30
+ end
39
31
 
40
- dimension = item_dimension(item)
41
- return false unless dimension
42
- return item["status"] == "completed" if dimension == "image_generation_call"
43
- return true unless dimension == "web_search_request"
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
44
36
 
45
- action_type = item.dig("action", "type")
46
- action_type.nil? || action_type == "search"
47
- end
37
+ def billing_fields(item)
38
+ OutputItem.wrap(item)&.billing_fields
39
+ end
48
40
 
49
- def store_output_item(output_items, item)
50
- return unless item.is_a?(Hash)
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
51
57
 
52
- dimension = item_dimension(item)
53
- return unless dimension
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
+ )]
69
+ end
54
70
 
55
- key = if dimension == "container_session" && container_id(item)
56
- "#{dimension}:#{container_id(item)}"
57
- else
58
- item["id"] || "#{item['type']}:#{output_items.length}"
59
- end
60
- output_items[key] = item
61
- end
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
62
74
 
63
- def build_line_item(item, request: nil, model: nil)
64
- return nil unless item.is_a?(Hash)
65
-
66
- dimension_key = dimension_key_for(item, request: request, model: model)
67
- return nil unless dimension_key
68
-
69
- provider_item_id = if dimension_key == "container_session"
70
- container_id(item) || item["id"]
71
- else
72
- item["id"]
73
- end
74
- Charges::LineItem.build(
75
- dimension_key: dimension_key,
76
- quantity: 1,
77
- cost_status: Charges::CostStatus::UNKNOWN,
78
- pricing_basis: "provider_usage",
79
- provider_field: item["provider_field"] || "response.output.#{item['type']}",
80
- provider_item_id: provider_item_id,
81
- details: line_item_details(item)
82
- )
83
- end
75
+ unit_line_items("ocr_page", response.dig("meta", "billed_units", "pages"), "meta.billed_units.pages")
76
+ end
84
77
 
85
- def dimension_key_for(item, request:, model:)
86
- dimension = item_dimension(item)
87
- return dimension unless dimension == "web_search_request"
88
- return dimension unless web_search_preview_used?(request) || chat_completions_search_model?(model)
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
89
82
 
90
- reasoning_model?(model) ? "web_search_preview_request_reasoning" : "web_search_preview_request_non_reasoning"
91
- end
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
92
97
 
93
- def item_dimension(item)
94
- return "container_session" if item["type"] == "shell_call" && shell_container_id(item)
98
+ private
95
99
 
96
- output_dimension(item["type"])
97
- end
100
+ def chat_completions_web_search_items(response, model: nil)
101
+ return [] unless response["choices"] && chat_completions_search_model?(model)
98
102
 
99
- def container_id(item)
100
- item["container_id"] || shell_container_id(item)
101
- end
103
+ [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
104
+ "provider_field" => "request.model" }]
105
+ end
102
106
 
103
- def shell_container_id(item)
104
- environment = item["environment"]
105
- environment["container_id"] if environment.is_a?(Hash) && environment["type"] == "container_reference"
106
- end
107
+ def priced_line_item(item, request:, model:)
108
+ item.line_item(priced_dimension(item.dimension, request: request, model: model))
109
+ end
107
110
 
108
- def output_dimension(type)
109
- key = RESPONSE_OUTPUT_RENAMES[type] || type
110
- dimension = Usage::Catalog[key]
111
- key if dimension && dimension.token_key.nil?
112
- 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)
113
115
 
114
- def web_search_preview_used?(request)
115
- tools = request && (request[:tools] || request["tools"])
116
- Array(tools).any? do |tool|
117
- type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
118
- type.to_s.include?("web_search_preview")
116
+ "web_search_preview_request_non_reasoning"
119
117
  end
120
- end
121
118
 
122
- def chat_completions_search_model?(model)
123
- name = local_model_name(model)
124
- name && ModelFamilies.chat_completions_search?(name)
125
- end
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
125
+ end
126
126
 
127
- def reasoning_model?(model)
128
- name = local_model_name(model)
129
- name && ModelFamilies.reasoning?(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 local_model_name(model)
133
- return nil unless model
132
+ def reasoning_model?(model)
133
+ name = local_model_name(model)
134
+ name && ModelFamilies.reasoning?(name)
135
+ end
134
136
 
135
- model.to_s.split("/", 2).last
136
- end
137
+ def local_model_name(model)
138
+ return nil unless model
137
139
 
138
- def line_item_details(item)
139
- {
140
- status: item["status"],
141
- action_type: item.dig("action", "type"),
142
- container_id: container_id(item)
143
- }.compact
144
- end
140
+ model.to_s.split("/", 2).last
141
+ end
145
142
 
146
- def transcription_line_items(usage)
147
- return [] unless usage
143
+ def unit_line_items(dimension_key, quantity, provider_field)
144
+ return [] unless quantity.is_a?(Numeric) && quantity.positive?
148
145
 
149
- type = (usage[:type] || usage["type"]).to_s
150
- return [] unless type == "duration"
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
+ )]
153
+ end
151
154
 
152
- seconds = (usage[:seconds] || usage["seconds"]).to_f
153
- return [] unless seconds.positive?
155
+ def billed_amount(usage)
156
+ cost = usage[:cost]
157
+ return reported_cost(usage, cost) if cost.is_a?(Numeric)
154
158
 
155
- [Charges::LineItem.build(
156
- dimension_key: "transcription_minute",
157
- quantity: BigDecimal(seconds.to_s) / 60,
158
- cost_status: Charges::CostStatus::UNKNOWN,
159
- pricing_basis: "provider_usage",
160
- provider_field: "usage.seconds",
161
- details: { seconds: seconds }
162
- )]
163
- end
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)
164
161
 
165
- def speech_line_items(request)
166
- input = request["input"]
167
- return [] unless input.is_a?(String) && ModelFamilies.character_billed_tts?(request["model"])
168
-
169
- [Charges::LineItem.build(
170
- dimension_key: "text_to_speech_character",
171
- quantity: input.length,
172
- cost_status: Charges::CostStatus::UNKNOWN,
173
- pricing_basis: "provider_usage",
174
- provider_field: "request.input"
175
- )]
176
- end
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
177
165
 
178
- # OpenRouter bills BYOK inference to the user's own provider key and puts only its fee in usage.cost.
179
- def billed_line_items(usage)
180
- amounts = [usage[:cost]]
181
- amounts << usage.dig(:cost_details, :upstream_inference_cost) if usage[:is_byok]
182
- return [] unless amounts.all?(Numeric)
183
-
184
- amount = amounts.sum { |value| BigDecimal(value.to_s) }
185
- [Charges::LineItem.build(
186
- dimension_key: "billed_request",
187
- quantity: 1,
188
- rate_amount: amount,
189
- cost: amount,
190
- pricing_basis: "provider_usage",
191
- price_source: "provider_response",
192
- provider_field: "usage.cost"
193
- )]
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
194
171
  end
195
172
  end
196
173
  end