llm_cost_tracker 0.14.1 → 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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -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/calls_controller.rb +1 -3
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  7. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +4 -25
  9. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  10. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  11. data/app/models/llm_cost_tracker/call.rb +10 -14
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -9
  14. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  16. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  17. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  18. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  19. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  21. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  22. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  23. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  26. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  27. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  28. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -5
  29. data/lib/llm_cost_tracker/budget/per_tag.rb +15 -9
  30. data/lib/llm_cost_tracker/budget.rb +44 -55
  31. data/lib/llm_cost_tracker/capture/event_window.rb +3 -2
  32. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  33. data/lib/llm_cost_tracker/capture/stream_collector.rb +25 -26
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +9 -23
  35. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  36. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  37. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  38. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  39. data/lib/llm_cost_tracker/configuration.rb +5 -9
  40. data/lib/llm_cost_tracker/doctor/price_check.rb +13 -3
  41. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  42. data/lib/llm_cost_tracker/doctor.rb +19 -2
  43. data/lib/llm_cost_tracker/engine.rb +0 -1
  44. data/lib/llm_cost_tracker/event.rb +8 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  49. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +11 -9
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  62. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  63. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  64. data/lib/llm_cost_tracker/integrations/anthropic.rb +63 -10
  65. data/lib/llm_cost_tracker/integrations/base.rb +39 -18
  66. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  67. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  68. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  69. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +241 -76
  70. data/lib/llm_cost_tracker/integrations.rb +1 -20
  71. data/lib/llm_cost_tracker/ledger/isolation.rb +0 -7
  72. data/lib/llm_cost_tracker/ledger/period/totals.rb +14 -10
  73. data/lib/llm_cost_tracker/ledger/rollups.rb +14 -14
  74. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  75. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  76. data/lib/llm_cost_tracker/ledger/store.rb +2 -2
  77. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  78. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  79. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  80. data/lib/llm_cost_tracker/middleware/faraday.rb +36 -19
  81. data/lib/llm_cost_tracker/parsers.rb +7 -40
  82. data/lib/llm_cost_tracker/prices.json +2237 -154
  83. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  84. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  85. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  86. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  87. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  88. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  89. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  90. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  91. data/lib/llm_cost_tracker/pricing/registry.rb +5 -16
  92. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  93. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  94. data/lib/llm_cost_tracker/pricing/sync.rb +22 -71
  95. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  96. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  97. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  98. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -7
  99. data/lib/llm_cost_tracker/providers/gemini/parser.rb +172 -48
  100. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  101. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  102. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  103. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +96 -26
  104. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  105. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -1
  106. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +3 -1
  107. data/lib/llm_cost_tracker/report/data.rb +1 -1
  108. data/lib/llm_cost_tracker/retention.rb +1 -3
  109. data/lib/llm_cost_tracker/tracker.rb +17 -9
  110. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  111. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  112. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  113. data/lib/llm_cost_tracker/version.rb +1 -1
  114. data/lib/tasks/llm_cost_tracker.rake +24 -21
  115. data/llm_cost_tracker.gemspec +63 -0
  116. metadata +8 -7
@@ -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,39 +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))
79
117
  end
80
118
 
81
119
  def retain_stream_event?(data)
82
- data.is_a?(Hash) && grounding_request_count(data["candidates"]).positive?
120
+ data.is_a?(Hash) && grounding_counts(data["candidates"]).values.any?(&:positive?)
83
121
  end
84
122
 
85
123
  def provider_for(_request_url)
86
124
  "gemini"
87
125
  end
88
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
+
89
179
  private
90
180
 
91
- def build_event(request_url:,
181
+ def build_event(model:,
92
182
  usage:,
93
183
  usage_source:,
94
- stream: false,
95
- provider_response_id: nil,
96
- pricing_mode: nil,
97
- service_line_items: nil)
184
+ provider_response_id:,
185
+ pricing_mode:,
186
+ service_line_items:,
187
+ stream: false)
98
188
  Event.build(
99
189
  provider: "gemini",
100
- model: extract_model_from_url(request_url),
190
+ model: model,
101
191
  pricing_mode: pricing_mode,
102
192
  token_usage: UsageExtractor.token_usage(usage),
103
193
  stream: stream,
104
194
  usage_source: usage_source,
105
195
  provider_response_id: provider_response_id,
106
- service_line_items: service_line_items
196
+ service_line_items: service_line_items + UsageExtractor.cache_read_line_items(usage)
107
197
  )
108
198
  end
109
199
 
200
+ def path_matches?(url, pattern)
201
+ uri_matches?(url) { |uri| uri.path.to_s.match?(pattern) }
202
+ end
203
+
110
204
  def merged_stream_usage(events)
111
205
  find_event_value(events, reverse: true) do |data|
112
206
  meta = data["usageMetadata"]
@@ -114,10 +208,6 @@ module LlmCostTracker
114
208
  end
115
209
  end
116
210
 
117
- def stream_response_id(events)
118
- find_event_value(events) { |data| data["responseId"] }
119
- end
120
-
121
211
  def extract_model_from_url(url)
122
212
  uri = parsed_uri(url)
123
213
  return nil unless uri
@@ -141,39 +231,73 @@ module LlmCostTracker
141
231
  headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
142
232
  end
143
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
+
144
262
  def grounding_line_items_for_stream(events, model:)
145
- quantity = find_event_value(events, reverse: true) do |data|
146
- count = grounding_request_count(data["candidates"])
147
- 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?)
148
266
  end
149
- grounding_line_items(quantity || 0, model: model)
267
+ grounding_line_items(counts || {}, model: model)
150
268
  end
151
269
 
152
- def grounding_request_count(candidates)
153
- Array(candidates).sum do |candidate|
154
- queries = candidate.dig("groundingMetadata", "webSearchQueries") || []
155
- 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
156
281
  end
157
282
  end
158
283
 
159
- def grounding_line_items(query_count, model:)
160
- 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?
161
291
 
162
- billed_quantity = grounding_billed_quantity(query_count, model: model)
163
- [
164
292
  Charges::LineItem.build(
165
- dimension_key: "grounding_request",
166
- quantity: billed_quantity,
293
+ dimension_key: kind,
294
+ quantity: ModelFamilies.per_query_grounding?(model) ? count : 1,
167
295
  cost_status: Charges::CostStatus::UNKNOWN,
168
296
  pricing_basis: "provider_usage",
169
- provider_field: "response.candidates.groundingMetadata.webSearchQueries",
170
- details: { web_search_queries: query_count }
297
+ provider_field: provider_field || GROUNDING_FIELDS.fetch(kind),
298
+ details: { web_search_queries: count }
171
299
  )
172
- ]
173
- end
174
-
175
- def grounding_billed_quantity(query_count, model:)
176
- ModelFamilies.per_query_grounding?(model) ? query_count : 1
300
+ end
177
301
  end
178
302
  end
179
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,12 +91,16 @@ 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
106
  def auto_enable_stream_usage?(request_url, _request_parsed)
@@ -80,20 +113,47 @@ module LlmCostTracker
80
113
 
81
114
  private
82
115
 
83
- 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:)
84
142
  model = find_event_value(events) do |data|
85
143
  data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
86
144
  end || request["model"]
145
+ provider = provider_for(request_url)
87
146
  {
88
- provider: provider_for(request_url),
147
+ provider: provider,
89
148
  model: model,
90
149
  provider_response_id: find_event_value(events) do |data|
91
150
  data["id"] || data.dig("response", "id") || data.dig("chunk", "id")
92
151
  end,
93
- pricing_mode: pricing_mode(
94
- request_url: request_url,
152
+ pricing_mode: ResponseParser.combined_pricing_mode(
153
+ provider: provider,
154
+ host: parsed_uri(request_url)&.host,
95
155
  model: model,
96
- service_tier: stream_pricing_mode(events) || request["service_tier"]
156
+ service_tier: stream_pricing_mode(events) || usage&.dig(:service_tier) || request["service_tier"]
97
157
  ),
98
158
  service_line_items: openai_stream_service_line_items(events, request: request, model: model)
99
159
  }
@@ -113,13 +173,14 @@ module LlmCostTracker
113
173
  token_usage: UsageExtractor.token_usage(usage, model: model),
114
174
  stream: true,
115
175
  usage_source: Usage::Source::STREAM_FINAL,
116
- 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)
117
178
  )
118
179
  end
119
180
 
120
181
  def warn_missing_stream_usage(request_url:, request:)
121
- return unless request["stream"]
122
- 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"]
123
184
  return if request.dig("stream_options", "include_usage")
124
185
 
125
186
  Logging.warn(
@@ -136,7 +197,8 @@ module LlmCostTracker
136
197
 
137
198
  def detect_stream_usage(events)
138
199
  usage = find_event_value(events, reverse: true) do |data|
139
- 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")
140
202
  candidate if candidate.is_a?(Hash)
141
203
  end
142
204
  usage&.deep_symbolize_keys
@@ -148,10 +210,18 @@ module LlmCostTracker
148
210
  end
149
211
  end
150
212
 
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)
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)
155
225
  end
156
226
  end
157
227
  end