llm_cost_tracker 0.14.0 → 0.14.2

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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/README.md +16 -5
  4. data/app/assets/llm_cost_tracker/application.css +16 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
  7. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
  13. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  14. data/app/models/llm_cost_tracker/call.rb +10 -14
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
  17. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  18. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  19. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  20. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  21. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  22. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  23. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  24. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  26. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  27. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  28. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  32. data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
  33. data/config/routes.rb +6 -1
  34. data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
  35. data/lib/llm_cost_tracker/budget.rb +44 -55
  36. data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
  37. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  38. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
  40. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  41. data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
  42. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  43. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  44. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  45. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  46. data/lib/llm_cost_tracker/configuration.rb +5 -9
  47. data/lib/llm_cost_tracker/doctor/price_check.rb +14 -3
  48. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  49. data/lib/llm_cost_tracker/doctor.rb +19 -2
  50. data/lib/llm_cost_tracker/engine.rb +0 -1
  51. data/lib/llm_cost_tracker/errors.rb +13 -0
  52. data/lib/llm_cost_tracker/event.rb +8 -0
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  70. data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
  71. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  72. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  73. data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
  74. data/lib/llm_cost_tracker/integrations/base.rb +56 -19
  75. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
  76. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  77. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  78. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
  79. data/lib/llm_cost_tracker/integrations.rb +1 -20
  80. data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
  81. data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
  82. data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
  83. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  84. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  85. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  86. data/lib/llm_cost_tracker/ledger/store.rb +14 -13
  87. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  88. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  89. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  90. data/lib/llm_cost_tracker/ledger.rb +1 -0
  91. data/lib/llm_cost_tracker/logging.rb +3 -1
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
  93. data/lib/llm_cost_tracker/parsers.rb +12 -41
  94. data/lib/llm_cost_tracker/prices.json +3071 -249
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  100. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  102. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  105. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  107. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  108. data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
  109. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  110. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  111. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  112. data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
  113. data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
  114. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  115. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  116. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  117. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
  118. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  119. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
  120. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
  121. data/lib/llm_cost_tracker/redaction.rb +32 -0
  122. data/lib/llm_cost_tracker/report/data.rb +1 -1
  123. data/lib/llm_cost_tracker/retention.rb +1 -3
  124. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
  125. data/lib/llm_cost_tracker/tracker.rb +17 -10
  126. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  127. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  128. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  129. data/lib/llm_cost_tracker/version.rb +1 -1
  130. data/lib/llm_cost_tracker.rb +4 -2
  131. data/lib/tasks/llm_cost_tracker.rake +26 -21
  132. data/llm_cost_tracker.gemspec +63 -0
  133. metadata +23 -10
@@ -1,16 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
4
+ require "time"
5
+
3
6
  module LlmCostTracker
4
7
  module Providers
5
8
  module Gemini
6
9
  class Parser < LlmCostTracker::Parsers::Base
7
10
  HOSTS = %w[generativelanguage.googleapis.com].freeze
8
- TRACKED_PATH_PATTERN = %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}
9
- STREAM_PATH_PATTERN = /:streamGenerateContent\z/
11
+ INTERACTIONS_PATH_PATTERN = %r{/interactions(?:/[^/]+)?\z}
12
+ CACHE_PATH_PATTERN = %r{/cachedContents\z}
13
+ TRACKED_PATH_PATTERN = Regexp.union(
14
+ %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}, INTERACTIONS_PATH_PATTERN, CACHE_PATH_PATTERN
15
+ )
16
+ STREAM_PATH_PATTERN = /:streamGenerateContent\z/
17
+ GROUNDING_FIELDS = {
18
+ "grounding_request" => "response.candidates.groundingMetadata.webSearchQueries",
19
+ "maps_grounding_request" => "response.candidates.groundingMetadata.groundingChunks.maps"
20
+ }.freeze
21
+ INTERACTION_GROUNDING_KINDS = {
22
+ "google_search" => "grounding_request",
23
+ "google_maps" => "maps_grounding_request"
24
+ }.freeze
10
25
 
11
26
  class << self
12
27
  def match?(url)
13
- match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
28
+ uri_matches?(url) do |uri|
29
+ HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.match?(TRACKED_PATH_PATTERN)
30
+ end
14
31
  end
15
32
 
16
33
  def provider_names
@@ -19,7 +36,7 @@ module LlmCostTracker
19
36
  end
20
37
 
21
38
  def streaming_request?(request_url, request_parsed)
22
- return true if match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
39
+ return true if path_matches?(request_url, STREAM_PATH_PATTERN)
23
40
 
24
41
  super
25
42
  end
@@ -28,34 +45,54 @@ module LlmCostTracker
28
45
  return nil unless response_status == 200
29
46
 
30
47
  response = safe_json_parse(response_body)
31
- usage = response["usageMetadata"]
48
+ request = safe_json_parse(request_body)
49
+ if path_matches?(request_url, INTERACTIONS_PATH_PATTERN)
50
+ event = interaction_event(response, request: request, response_headers: response_headers)
51
+ return nil if event && path_matches?(request_url, %r{/interactions/[^/]+\z}) &&
52
+ Call.already_recorded?(provider: "gemini", provider_response_id: event.provider_response_id)
53
+
54
+ return event
55
+ end
56
+ return cache_storage_event(response) if path_matches?(request_url, CACHE_PATH_PATTERN)
57
+
58
+ usage = response["usageMetadata"]
32
59
  return nil unless usage
33
60
 
34
- request = safe_json_parse(request_body)
35
- model = extract_model_from_url(request_url)
61
+ model = response["modelVersion"].presence || extract_model_from_url(request_url)
36
62
  build_event(
37
- request_url: request_url,
63
+ model: model,
38
64
  usage: usage,
39
65
  usage_source: Usage::Source::RESPONSE,
40
66
  provider_response_id: response["responseId"],
41
67
  pricing_mode: pricing_mode(request: request, usage: usage, response_headers: response_headers),
42
- service_line_items: grounding_line_items(grounding_request_count(response["candidates"]), model: model)
68
+ service_line_items: service_line_items_for(response, model: model)
43
69
  )
44
70
  end
45
71
 
46
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], response_headers: nil)
72
+ def parse_stream(response_status:,
73
+ request_url: nil,
74
+ request_body: nil,
75
+ events: [],
76
+ response_headers: nil,
77
+ model: nil)
47
78
  return nil unless response_status == 200
48
79
 
49
80
  request = safe_json_parse(request_body)
81
+ interaction = find_event_value(events, reverse: true) { |data| completed_interaction(data) }
82
+ if interaction
83
+ return interaction_event(interaction, request: request, response_headers: response_headers, stream: true)
84
+ end
85
+
50
86
  usage = merged_stream_usage(events)
51
- model = extract_model_from_url(request_url)
52
- response_id = stream_response_id(events)
87
+ model = find_event_value(events, reverse: true) { |data| data["modelVersion"] } ||
88
+ extract_model_from_url(request_url) || model || request["model"]
89
+ response_id = find_event_value(events) { |data| data["responseId"] }
53
90
  mode = pricing_mode(request: request, usage: usage, response_headers: response_headers)
54
91
  service_line_items = grounding_line_items_for_stream(events, model: model)
55
92
 
56
93
  if usage
57
94
  build_event(
58
- request_url: request_url,
95
+ model: model,
59
96
  usage: usage,
60
97
  stream: true,
61
98
  usage_source: Usage::Source::STREAM_FINAL,
@@ -74,35 +111,96 @@ module LlmCostTracker
74
111
  end
75
112
  end
76
113
 
77
- def model_for(request_url, _request_parsed)
78
- extract_model_from_url(request_url)
114
+ def model_for(request_url, request_parsed)
115
+ extract_model_from_url(request_url) ||
116
+ (request_parsed["model"] if path_matches?(request_url, INTERACTIONS_PATH_PATTERN))
117
+ end
118
+
119
+ def retain_stream_event?(data)
120
+ data.is_a?(Hash) && grounding_counts(data["candidates"]).values.any?(&:positive?)
79
121
  end
80
122
 
81
123
  def provider_for(_request_url)
82
124
  "gemini"
83
125
  end
84
126
 
127
+ def service_line_items_for(response, model:)
128
+ usage = response["usage"]
129
+ return grounding_line_items(grounding_counts(response["candidates"]), model: model) unless usage.is_a?(Hash)
130
+
131
+ counts = Array(usage["grounding_tool_count"]).each_with_object(Hash.new(0)) do |entry, acc|
132
+ kind = INTERACTION_GROUNDING_KINDS[entry["type"]]
133
+ acc[kind] += entry["count"].to_i if kind
134
+ end
135
+ grounding_line_items(counts, model: model, provider_field: "response.usage.grounding_tool_count")
136
+ end
137
+
138
+ def interaction_usage_metadata(usage, service_tier)
139
+ {
140
+ "promptTokenCount" => usage["total_input_tokens"],
141
+ "cachedContentTokenCount" => usage["total_cached_tokens"],
142
+ "toolUsePromptTokenCount" => usage["total_tool_use_tokens"],
143
+ "candidatesTokenCount" => usage["total_output_tokens"],
144
+ "thoughtsTokenCount" => usage["total_thought_tokens"],
145
+ "totalTokenCount" => usage["total_tokens"],
146
+ "promptTokensDetails" => modality_details(usage["input_tokens_by_modality"]),
147
+ "cacheTokensDetails" => modality_details(usage["cached_tokens_by_modality"]),
148
+ "candidatesTokensDetails" => modality_details(usage["output_tokens_by_modality"]),
149
+ "serviceTier" => service_tier
150
+ }
151
+ end
152
+
153
+ def cache_storage_event(response)
154
+ tokens = response.dig("usageMetadata", "totalTokenCount")
155
+ from = response["createTime"]
156
+ to = response["expireTime"]
157
+ return nil unless tokens && from && to
158
+
159
+ seconds = Time.iso8601(to) - Time.iso8601(from)
160
+ Event.build(
161
+ provider: "gemini",
162
+ model: response["model"].to_s.delete_prefix("models/"),
163
+ token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
164
+ usage_source: Usage::Source::RESPONSE,
165
+ provider_response_id: response["name"],
166
+ service_line_items: [
167
+ Charges::LineItem.build(
168
+ dimension_key: "cache_storage_token_hour",
169
+ quantity: BigDecimal(tokens.to_s) * BigDecimal(seconds.to_s) / 3600,
170
+ cost_status: Charges::CostStatus::UNKNOWN,
171
+ pricing_basis: "provider_usage",
172
+ provider_field: "response.usageMetadata.totalTokenCount",
173
+ details: { cached_tokens: tokens, expire_time: to }
174
+ )
175
+ ]
176
+ )
177
+ end
178
+
85
179
  private
86
180
 
87
- def build_event(request_url:,
181
+ def build_event(model:,
88
182
  usage:,
89
183
  usage_source:,
90
- stream: false,
91
- provider_response_id: nil,
92
- pricing_mode: nil,
93
- service_line_items: nil)
184
+ provider_response_id:,
185
+ pricing_mode:,
186
+ service_line_items:,
187
+ stream: false)
94
188
  Event.build(
95
189
  provider: "gemini",
96
- model: extract_model_from_url(request_url),
190
+ model: model,
97
191
  pricing_mode: pricing_mode,
98
192
  token_usage: UsageExtractor.token_usage(usage),
99
193
  stream: stream,
100
194
  usage_source: usage_source,
101
195
  provider_response_id: provider_response_id,
102
- service_line_items: service_line_items
196
+ service_line_items: service_line_items + UsageExtractor.cache_read_line_items(usage)
103
197
  )
104
198
  end
105
199
 
200
+ def path_matches?(url, pattern)
201
+ uri_matches?(url) { |uri| uri.path.to_s.match?(pattern) }
202
+ end
203
+
106
204
  def merged_stream_usage(events)
107
205
  find_event_value(events, reverse: true) do |data|
108
206
  meta = data["usageMetadata"]
@@ -110,10 +208,6 @@ module LlmCostTracker
110
208
  end
111
209
  end
112
210
 
113
- def stream_response_id(events)
114
- find_event_value(events) { |data| data["responseId"] }
115
- end
116
-
117
211
  def extract_model_from_url(url)
118
212
  uri = parsed_uri(url)
119
213
  return nil unless uri
@@ -137,39 +231,73 @@ module LlmCostTracker
137
231
  headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
138
232
  end
139
233
 
234
+ def completed_interaction(data)
235
+ interaction = data["interaction"]
236
+ interaction if interaction.is_a?(Hash) && interaction["usage"].is_a?(Hash)
237
+ end
238
+
239
+ def interaction_event(interaction, request:, response_headers:, stream: false)
240
+ usage = interaction["usage"]
241
+ return nil unless usage.is_a?(Hash) && !%w[queued in_progress].include?(interaction["status"])
242
+
243
+ model = interaction["model"] || request["model"]
244
+ metadata = interaction_usage_metadata(usage, interaction["service_tier"])
245
+ build_event(
246
+ model: model,
247
+ usage: metadata,
248
+ stream: stream,
249
+ usage_source: stream ? Usage::Source::STREAM_FINAL : Usage::Source::RESPONSE,
250
+ provider_response_id: interaction["id"],
251
+ pricing_mode: pricing_mode(request: request, usage: metadata, response_headers: response_headers),
252
+ service_line_items: service_line_items_for(interaction, model: model)
253
+ ).keyed_by_response_id
254
+ end
255
+
256
+ def modality_details(entries)
257
+ Array(entries).map do |entry|
258
+ { "modality" => entry["modality"].to_s.upcase, "tokenCount" => entry["tokens"] }
259
+ end
260
+ end
261
+
140
262
  def grounding_line_items_for_stream(events, model:)
141
- quantity = find_event_value(events, reverse: true) do |data|
142
- count = grounding_request_count(data["candidates"])
143
- count if count.positive?
263
+ counts = find_event_value(events, reverse: true) do |data|
264
+ candidate_counts = grounding_counts(data["candidates"])
265
+ candidate_counts if candidate_counts.values.any?(&:positive?)
144
266
  end
145
- grounding_line_items(quantity || 0, model: model)
267
+ grounding_line_items(counts || {}, model: model)
146
268
  end
147
269
 
148
- def grounding_request_count(candidates)
149
- Array(candidates).sum do |candidate|
150
- queries = candidate.dig("groundingMetadata", "webSearchQueries") || []
151
- Array(queries).size
270
+ def grounding_counts(candidates)
271
+ Array(candidates).each_with_object(Hash.new(0)) do |candidate, counts|
272
+ meta = candidate["groundingMetadata"]
273
+ next unless meta.is_a?(Hash)
274
+
275
+ queries = unique_query_count(meta["webSearchQueries"])
276
+ if Array(meta["groundingChunks"]).any? { |chunk| chunk.is_a?(Hash) && chunk.key?("maps") }
277
+ counts["maps_grounding_request"] += [queries, 1].max
278
+ else
279
+ counts["grounding_request"] += queries + unique_query_count(meta["imageSearchQueries"])
280
+ end
152
281
  end
153
282
  end
154
283
 
155
- def grounding_line_items(query_count, model:)
156
- return [] unless query_count.positive?
284
+ def unique_query_count(queries)
285
+ Array(queries).map { |query| query.to_s.strip }.reject(&:empty?).uniq.size
286
+ end
287
+
288
+ def grounding_line_items(counts, model:, provider_field: nil)
289
+ counts.filter_map do |kind, count|
290
+ next unless count.positive?
157
291
 
158
- billed_quantity = grounding_billed_quantity(query_count, model: model)
159
- [
160
292
  Charges::LineItem.build(
161
- dimension_key: "grounding_request",
162
- quantity: billed_quantity,
293
+ dimension_key: kind,
294
+ quantity: ModelFamilies.per_query_grounding?(model) ? count : 1,
163
295
  cost_status: Charges::CostStatus::UNKNOWN,
164
296
  pricing_basis: "provider_usage",
165
- provider_field: "response.candidates.groundingMetadata.webSearchQueries",
166
- details: { web_search_queries: query_count }
297
+ provider_field: provider_field || GROUNDING_FIELDS.fetch(kind),
298
+ details: { web_search_queries: count }
167
299
  )
168
- ]
169
- end
170
-
171
- def grounding_billed_quantity(query_count, model:)
172
- ModelFamilies.per_query_grounding?(model) ? query_count : 1
300
+ end
173
301
  end
174
302
  end
175
303
  end
@@ -7,10 +7,10 @@ module LlmCostTracker
7
7
  def self.token_usage(usage)
8
8
  cache_read = usage["cachedContentTokenCount"].to_i
9
9
  tool_use_prompt = usage["toolUsePromptTokenCount"].to_i
10
- audio_input = audio_input_tokens(usage)
11
- audio_output = audio_output_tokens(usage)
12
- image_input = image_input_tokens(usage)
13
- image_output = image_output_tokens(usage)
10
+ audio_input = uncached_prompt_tokens(usage, "AUDIO")
11
+ audio_output = modality_tokens(usage["candidatesTokensDetails"], "AUDIO")
12
+ image_input = uncached_prompt_tokens(usage, "IMAGE") + uncached_prompt_tokens(usage, "DOCUMENT")
13
+ image_output = modality_tokens(usage["candidatesTokensDetails"], "IMAGE")
14
14
 
15
15
  Usage::TokenUsage.build(
16
16
  input_tokens: regular_input_tokens(usage: usage,
@@ -31,6 +31,11 @@ module LlmCostTracker
31
31
  )
32
32
  end
33
33
 
34
+ def self.cache_read_line_items(usage)
35
+ audio = modality_tokens(usage["cacheTokensDetails"], "AUDIO")
36
+ audio.positive? ? [Charges::LineItem.build(dimension_key: "audio_cache_read_input", quantity: audio)] : []
37
+ end
38
+
34
39
  def self.gross_output_tokens(usage)
35
40
  usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
36
41
  end
@@ -43,24 +48,10 @@ module LlmCostTracker
43
48
  [gross_output_tokens(usage) - audio_output - image_output, 0].max
44
49
  end
45
50
 
46
- def self.audio_input_tokens(usage)
47
- prompt_audio = modality_tokens(usage["promptTokensDetails"], "AUDIO")
48
- cache_audio = modality_tokens(usage["cacheTokensDetails"], "AUDIO")
49
- [prompt_audio - cache_audio, 0].max
50
- end
51
-
52
- def self.audio_output_tokens(usage)
53
- modality_tokens(usage["candidatesTokensDetails"], "AUDIO")
54
- end
55
-
56
- def self.image_input_tokens(usage)
57
- prompt_image = modality_tokens(usage["promptTokensDetails"], "IMAGE")
58
- cache_image = modality_tokens(usage["cacheTokensDetails"], "IMAGE")
59
- [prompt_image - cache_image, 0].max
60
- end
61
-
62
- def self.image_output_tokens(usage)
63
- modality_tokens(usage["candidatesTokensDetails"], "IMAGE")
51
+ def self.uncached_prompt_tokens(usage, modality)
52
+ prompt = modality_tokens(usage["promptTokensDetails"], modality)
53
+ cached = modality_tokens(usage["cacheTokensDetails"], modality)
54
+ [prompt - cached, 0].max
64
55
  end
65
56
 
66
57
  def self.modality_tokens(details, modality)
@@ -18,7 +18,7 @@ module LlmCostTracker
18
18
  ae.api.openai.com
19
19
  ].freeze
20
20
 
21
- DATA_RESIDENCY_HOST_PATTERN = /\A[a-z]{2,3}\.api\.openai\.com\z/
21
+ DATA_RESIDENCY_HOST_PATTERN = /\A(?:[a-z]{2,3}\.api\.openai\.com|us\.api\.x\.ai|api\.(?:eu|us)\.mistral\.ai)\z/
22
22
 
23
23
  def self.data_residency?(host)
24
24
  host.to_s.downcase.match?(DATA_RESIDENCY_HOST_PATTERN)
@@ -6,23 +6,21 @@ module LlmCostTracker
6
6
  class Parser < LlmCostTracker::Parsers::Base
7
7
  include ResponseParser
8
8
 
9
- TRACKED_PATHS = %w[
10
- /v1/chat/completions
11
- /v1/completions
12
- /v1/embeddings
13
- /v1/responses
14
- /v1/images/generations
15
- /v1/images/edits
16
- /v1/images/variations
17
- /v1/audio/transcriptions
18
- /v1/audio/translations
19
- /v1/audio/speech
20
- /v1/moderations
9
+ TRACKED_ENDPOINTS = %w[
10
+ chat/completions completions embeddings moderations responses
11
+ audio/transcriptions audio/translations audio/speech
12
+ images/generations images/edits images/variations
21
13
  ].freeze
14
+ TRACKED_PATHS = TRACKED_ENDPOINTS.map { |endpoint| "/v1/#{endpoint}" }.freeze
15
+ RETRIEVE_PATH = %r{\A/v1/responses/resp_[^/]+\z}
22
16
 
23
17
  class << self
24
18
  def match?(url)
25
- match_uri?(url, hosts: Hosts::API_HOSTS, exact_paths: TRACKED_PATHS)
19
+ uri_matches?(url) do |uri|
20
+ path = uri.path.to_s
21
+ Hosts::API_HOSTS.include?(uri.host.to_s.downcase) &&
22
+ (TRACKED_PATHS.include?(path) || path.match?(RETRIEVE_PATH))
23
+ end
26
24
  end
27
25
 
28
26
  def provider_names
@@ -11,13 +11,11 @@ module LlmCostTracker
11
11
  module Providers
12
12
  module Openai
13
13
  module ResponseParser
14
- include LlmCostTracker::Providers::Openai::ServiceCharges
15
-
16
14
  class << self
17
- def combined_pricing_mode(host:, model:, service_tier:)
15
+ def combined_pricing_mode(host:, model:, service_tier:, provider: "openai")
18
16
  modes = [Pricing::Mode.normalize(service_tier)]
19
17
  if Hosts.data_residency?(host) &&
20
- Pricing::Matcher.modifier_priced?(provider: "openai", model: model, modifier: "data_residency")
18
+ Pricing::Matcher.modifier_priced?(provider: provider, model: model, modifier: "data_residency")
21
19
  modes << "data_residency"
22
20
  end
23
21
  Pricing::Mode.compose(modes)
@@ -30,12 +28,17 @@ module LlmCostTracker
30
28
  model = response["model"] || request["model"]
31
29
  service_line_items =
32
30
  ServiceCharges.service_line_items_for(response, request: request, model: model) +
33
- ServiceCharges.transcription_line_items(usage)
31
+ ServiceCharges.transcription_line_items(usage) +
32
+ ServiceCharges.billed_line_items(usage) +
33
+ UsageExtractor.cache_read_line_items(usage)
34
34
  Event.build(
35
35
  provider: provider,
36
36
  provider_response_id: response["id"],
37
37
  pricing_mode: pricing_mode || combined_pricing_mode(
38
- host: host, model: model, service_tier: response["service_tier"] || request["service_tier"]
38
+ provider: provider,
39
+ host: host,
40
+ model: model,
41
+ service_tier: response["service_tier"] || usage[:service_tier] || request["service_tier"]
39
42
  ),
40
43
  model: model,
41
44
  token_usage: UsageExtractor.token_usage(usage, model: model),
@@ -43,18 +46,44 @@ module LlmCostTracker
43
46
  service_line_items: service_line_items
44
47
  )
45
48
  end
49
+
50
+ 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"]
53
+ return nil if Call.already_recorded?(provider: provider, provider_response_id: response["id"])
54
+
55
+ event_from_response(
56
+ response: response,
57
+ request: { "tools" => response["tools"] },
58
+ provider: provider,
59
+ host: host,
60
+ usage_source: usage_source
61
+ )&.keyed_by_response_id
62
+ end
46
63
  end
47
64
 
48
65
  def parse(request_url:, request_body:, response_status:, response_body:, **)
49
66
  return nil unless response_status == 200
50
67
 
68
+ 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
+ )
77
+ end
78
+
79
+ request = safe_json_parse(request_body)
51
80
  ResponseParser.event_from_response(
52
- response: safe_json_parse(response_body),
53
- request: safe_json_parse(request_body),
81
+ response: response,
82
+ request: request,
54
83
  provider: provider_for(request_url),
55
- host: parsed_uri(request_url)&.host,
84
+ host: host,
56
85
  usage_source: Usage::Source::RESPONSE
57
- )
86
+ ) || speech_event(request_url, request) || transcription_without_usage_event(request_url, request)
58
87
  end
59
88
 
60
89
  def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
@@ -62,34 +91,69 @@ module LlmCostTracker
62
91
 
63
92
  request = safe_json_parse(request_body)
64
93
  usage = detect_stream_usage(events)
65
- context = stream_capture_context(events: events, request: request, request_url: request_url)
94
+ context = stream_capture_context(events: events, request: request, request_url: request_url, usage: usage)
66
95
 
67
- return build_known_stream_usage(usage: usage, **context) if usage
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
100
+ end
68
101
 
69
102
  warn_missing_stream_usage(request_url: request_url, request: request)
70
- build_unknown_stream_usage(**context)
103
+ build_unknown_stream_usage(**context, service_line_items: background ? [] : context[:service_line_items])
71
104
  end
72
105
 
73
- def auto_enable_stream_usage?(request_url)
106
+ def auto_enable_stream_usage?(request_url, _request_parsed)
74
107
  openai_chat_completions_url?(request_url)
75
108
  end
76
109
 
110
+ def retain_stream_event?(data)
111
+ data.is_a?(Hash) && (data["item"].is_a?(Hash) || data["response"].is_a?(Hash))
112
+ end
113
+
77
114
  private
78
115
 
79
- def stream_capture_context(events:, request:, request_url:)
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)
126
+ )
127
+ end
128
+
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
140
+
141
+ def stream_capture_context(events:, request:, request_url:, usage:)
80
142
  model = find_event_value(events) do |data|
81
143
  data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
82
144
  end || request["model"]
145
+ provider = provider_for(request_url)
83
146
  {
84
- provider: provider_for(request_url),
147
+ provider: provider,
85
148
  model: model,
86
149
  provider_response_id: find_event_value(events) do |data|
87
150
  data["id"] || data.dig("response", "id") || data.dig("chunk", "id")
88
151
  end,
89
- pricing_mode: pricing_mode(
90
- request_url: request_url,
152
+ pricing_mode: ResponseParser.combined_pricing_mode(
153
+ provider: provider,
154
+ host: parsed_uri(request_url)&.host,
91
155
  model: model,
92
- service_tier: stream_pricing_mode(events) || request["service_tier"]
156
+ service_tier: stream_pricing_mode(events) || usage&.dig(:service_tier) || request["service_tier"]
93
157
  ),
94
158
  service_line_items: openai_stream_service_line_items(events, request: request, model: model)
95
159
  }
@@ -109,13 +173,14 @@ module LlmCostTracker
109
173
  token_usage: UsageExtractor.token_usage(usage, model: model),
110
174
  stream: true,
111
175
  usage_source: Usage::Source::STREAM_FINAL,
112
- service_line_items: service_line_items
176
+ service_line_items: service_line_items + ServiceCharges.transcription_line_items(usage) +
177
+ ServiceCharges.billed_line_items(usage) + UsageExtractor.cache_read_line_items(usage)
113
178
  )
114
179
  end
115
180
 
116
181
  def warn_missing_stream_usage(request_url:, request:)
117
- return unless request["stream"]
118
- return unless openai_chat_completions_url?(request_url)
182
+ return unless request_url.nil? || request["stream"]
183
+ return unless request_url ? openai_chat_completions_url?(request_url) : request["messages"]
119
184
  return if request.dig("stream_options", "include_usage")
120
185
 
121
186
  Logging.warn(
@@ -132,7 +197,8 @@ module LlmCostTracker
132
197
 
133
198
  def detect_stream_usage(events)
134
199
  usage = find_event_value(events, reverse: true) do |data|
135
- candidate = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage")
200
+ candidate = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage") ||
201
+ data.dig("x_groq", "usage") || data.dig("chunk", "x_groq", "usage")
136
202
  candidate if candidate.is_a?(Hash)
137
203
  end
138
204
  usage&.deep_symbolize_keys
@@ -144,10 +210,18 @@ module LlmCostTracker
144
210
  end
145
211
  end
146
212
 
147
- def pricing_mode(request_url:, model:, service_tier:)
148
- ResponseParser.combined_pricing_mode(host: parsed_uri(request_url)&.host,
149
- model: model,
150
- service_tier: service_tier)
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
225
  end
152
226
  end
153
227
  end