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
@@ -9,18 +9,19 @@ module LlmCostTracker
9
9
  class Parser < LlmCostTracker::Parsers::Base
10
10
  HOSTS = %w[generativelanguage.googleapis.com].freeze
11
11
  INTERACTIONS_PATH_PATTERN = %r{/interactions(?:/[^/]+)?\z}
12
+ INTERACTION_RETRIEVE_PATH_PATTERN = %r{/interactions/[^/]+\z}
12
13
  CACHE_PATH_PATTERN = %r{/cachedContents\z}
13
14
  TRACKED_PATH_PATTERN = Regexp.union(
14
15
  %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}, INTERACTIONS_PATH_PATTERN, CACHE_PATH_PATTERN
15
16
  )
16
17
  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"
18
+ MODEL_PATH_PATTERN = %r{/models/([^/:]+)}
19
+ PENDING_STATUSES = %w[queued in_progress].freeze
20
+ TRAFFIC_TYPE_MODES = {
21
+ "ON_DEMAND" => nil,
22
+ "TRAFFIC_TYPE_UNSPECIFIED" => nil,
23
+ "ON_DEMAND_PRIORITY" => "priority",
24
+ "ON_DEMAND_FLEX" => "flex"
24
25
  }.freeze
25
26
 
26
27
  class << self
@@ -36,9 +37,7 @@ module LlmCostTracker
36
37
  end
37
38
 
38
39
  def streaming_request?(request_url, request_parsed)
39
- return true if path_matches?(request_url, STREAM_PATH_PATTERN)
40
-
41
- super
40
+ path_matches?(request_url, STREAM_PATH_PATTERN) || super
42
41
  end
43
42
 
44
43
  def parse(request_url:, request_body:, response_status:, response_body:, response_headers: nil)
@@ -47,26 +46,11 @@ module LlmCostTracker
47
46
  response = safe_json_parse(response_body)
48
47
  request = safe_json_parse(request_body)
49
48
  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
49
+ return interaction_response_event(response, request_url:, request:, response_headers:)
55
50
  end
56
51
  return cache_storage_event(response) if path_matches?(request_url, CACHE_PATH_PATTERN)
57
52
 
58
- usage = response["usageMetadata"]
59
- return nil unless usage
60
-
61
- model = response["modelVersion"].presence || extract_model_from_url(request_url)
62
- build_event(
63
- model: model,
64
- usage: usage,
65
- usage_source: Usage::Source::RESPONSE,
66
- provider_response_id: response["responseId"],
67
- pricing_mode: pricing_mode(request: request, usage: usage, response_headers: response_headers),
68
- service_line_items: service_line_items_for(response, model: model)
69
- )
53
+ content_event(response, request_url:, request:, response_headers:)
70
54
  end
71
55
 
72
56
  def parse_stream(response_status:,
@@ -79,45 +63,18 @@ module LlmCostTracker
79
63
 
80
64
  request = safe_json_parse(request_body)
81
65
  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
66
+ return interaction_event(interaction, request:, response_headers:, stream: true) if interaction
85
67
 
86
- usage = merged_stream_usage(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"] }
90
- mode = pricing_mode(request: request, usage: usage, response_headers: response_headers)
91
- service_line_items = grounding_line_items_for_stream(events, model: model)
92
-
93
- if usage
94
- build_event(
95
- model: model,
96
- usage: usage,
97
- stream: true,
98
- usage_source: Usage::Source::STREAM_FINAL,
99
- provider_response_id: response_id,
100
- pricing_mode: mode,
101
- service_line_items: service_line_items
102
- )
103
- else
104
- build_unknown_stream_usage(
105
- provider: "gemini",
106
- model: model,
107
- provider_response_id: response_id,
108
- pricing_mode: mode,
109
- service_line_items: service_line_items
110
- )
111
- end
68
+ content_stream_event(events, request:, request_url:, response_headers:, fallback_model: model)
112
69
  end
113
70
 
114
71
  def model_for(request_url, request_parsed)
115
- extract_model_from_url(request_url) ||
72
+ model_from_url(request_url) ||
116
73
  (request_parsed["model"] if path_matches?(request_url, INTERACTIONS_PATH_PATTERN))
117
74
  end
118
75
 
119
76
  def retain_stream_event?(data)
120
- data.is_a?(Hash) && grounding_counts(data["candidates"]).values.any?(&:positive?)
77
+ data.is_a?(Hash) && Grounding.from_candidates(data["candidates"]).any?
121
78
  end
122
79
 
123
80
  def provider_for(_request_url)
@@ -125,29 +82,7 @@ module LlmCostTracker
125
82
  end
126
83
 
127
84
  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
- }
85
+ Grounding.from_response(response).line_items(model: model)
151
86
  end
152
87
 
153
88
  def cache_storage_event(response)
@@ -156,92 +91,37 @@ module LlmCostTracker
156
91
  to = response["expireTime"]
157
92
  return nil unless tokens && from && to
158
93
 
159
- seconds = Time.iso8601(to) - Time.iso8601(from)
160
94
  Event.build(
161
95
  provider: "gemini",
162
- model: response["model"].to_s.delete_prefix("models/"),
96
+ model: response["model"].to_s.split("/").last,
163
97
  token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
164
98
  usage_source: Usage::Source::RESPONSE,
165
99
  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
- ]
100
+ service_line_items: [cache_storage_line_item(tokens, from, to)]
176
101
  )
177
102
  end
178
103
 
179
- private
180
-
181
- def build_event(model:,
182
- usage:,
183
- usage_source:,
184
- provider_response_id:,
185
- pricing_mode:,
186
- service_line_items:,
187
- stream: false)
188
- Event.build(
189
- provider: "gemini",
190
- model: model,
191
- pricing_mode: pricing_mode,
192
- token_usage: UsageExtractor.token_usage(usage),
193
- stream: stream,
194
- usage_source: usage_source,
195
- provider_response_id: provider_response_id,
196
- service_line_items: service_line_items + UsageExtractor.cache_read_line_items(usage)
197
- )
104
+ def pricing_mode(request:, usage:, response_headers:, host: nil, model: nil)
105
+ regional = Openai::Hosts.vertex_non_global?(host) &&
106
+ Pricing::Matcher.modifier_priced?(provider: "gemini", model: model, modifier: "data_residency")
107
+ Pricing::Mode.compose([service_tier(request, usage, response_headers), ("data_residency" if regional)])
198
108
  end
199
109
 
200
- def path_matches?(url, pattern)
201
- uri_matches?(url) { |uri| uri.path.to_s.match?(pattern) }
202
- end
203
-
204
- def merged_stream_usage(events)
205
- find_event_value(events, reverse: true) do |data|
206
- meta = data["usageMetadata"]
207
- meta if meta.is_a?(Hash)
208
- end
209
- end
210
-
211
- def extract_model_from_url(url)
212
- uri = parsed_uri(url)
213
- return nil unless uri
214
-
215
- match = uri.path.match(%r{/models/([^/:]+)})
216
- match && match[1]
217
- end
218
-
219
- def pricing_mode(request:, usage:, response_headers:)
220
- body_mode = Pricing::Mode.normalize(usage && usage["serviceTier"])
221
- return body_mode if body_mode
222
-
223
- header_mode = Pricing::Mode.normalize(response_header(response_headers, "x-gemini-service-tier"))
224
- return header_mode if header_mode
110
+ private
225
111
 
226
- request_mode = Pricing::Mode.normalize(request["service_tier"] || request["serviceTier"])
227
- request_mode == "flex" ? request_mode : nil
228
- end
112
+ def interaction_response_event(response, request_url:, request:, response_headers:)
113
+ event = interaction_event(response, request:, response_headers:)
114
+ return event unless event && path_matches?(request_url, INTERACTION_RETRIEVE_PATH_PATTERN)
229
115
 
230
- def response_header(headers, name)
231
- headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
232
- end
233
-
234
- def completed_interaction(data)
235
- interaction = data["interaction"]
236
- interaction if interaction.is_a?(Hash) && interaction["usage"].is_a?(Hash)
116
+ event unless Call.already_recorded?(provider: "gemini", provider_response_id: event.provider_response_id)
237
117
  end
238
118
 
239
119
  def interaction_event(interaction, request:, response_headers:, stream: false)
240
120
  usage = interaction["usage"]
241
- return nil unless usage.is_a?(Hash) && !%w[queued in_progress].include?(interaction["status"])
121
+ return nil unless usage.is_a?(Hash) && !PENDING_STATUSES.include?(interaction["status"])
242
122
 
243
123
  model = interaction["model"] || request["model"]
244
- metadata = interaction_usage_metadata(usage, interaction["service_tier"])
124
+ metadata = UsageExtractor.from_interaction(usage, interaction["service_tier"])
245
125
  build_event(
246
126
  model: model,
247
127
  usage: metadata,
@@ -253,51 +133,108 @@ module LlmCostTracker
253
133
  ).keyed_by_response_id
254
134
  end
255
135
 
256
- def modality_details(entries)
257
- Array(entries).map do |entry|
258
- { "modality" => entry["modality"].to_s.upcase, "tokenCount" => entry["tokens"] }
259
- end
136
+ def content_event(response, request_url:, request:, response_headers:)
137
+ usage = response["usageMetadata"]
138
+ return nil unless usage
139
+
140
+ model = response["modelVersion"].presence || model_from_url(request_url)
141
+ host = parsed_uri(request_url)&.host
142
+ build_event(
143
+ model: model,
144
+ usage: usage,
145
+ usage_source: Usage::Source::RESPONSE,
146
+ provider_response_id: response["responseId"],
147
+ pricing_mode: pricing_mode(request:, usage:, response_headers:, host:, model:),
148
+ service_line_items: service_line_items_for(response, model: model)
149
+ )
260
150
  end
261
151
 
262
- def grounding_line_items_for_stream(events, model:)
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?)
266
- end
267
- grounding_line_items(counts || {}, model: model)
152
+ def content_stream_event(events, request:, request_url:, response_headers:, fallback_model:)
153
+ usage = last_usage_metadata(events)
154
+ model = find_event_value(events, reverse: true) { |data| data["modelVersion"] } ||
155
+ model_from_url(request_url) || fallback_model || request["model"]
156
+ host = parsed_uri(request_url)&.host
157
+ attributes = {
158
+ model: model,
159
+ provider_response_id: find_event_value(events) { |data| data["responseId"] },
160
+ pricing_mode: pricing_mode(request:, usage:, response_headers:, host:, model:),
161
+ service_line_items: stream_grounding_line_items(events, model: model)
162
+ }
163
+ return build_unknown_stream_usage(provider: "gemini", **attributes) unless usage
164
+
165
+ build_event(usage: usage, stream: true, usage_source: Usage::Source::STREAM_FINAL, **attributes)
268
166
  end
269
167
 
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)
168
+ def build_event(usage:, service_line_items:, **attributes)
169
+ Event.build(
170
+ provider: "gemini",
171
+ token_usage: UsageExtractor.token_usage(usage),
172
+ service_line_items: service_line_items + UsageExtractor.line_items(usage),
173
+ **attributes
174
+ )
175
+ end
274
176
 
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
177
+ def cache_storage_line_item(tokens, from, to)
178
+ seconds = Time.iso8601(to) - Time.iso8601(from)
179
+ Charges::LineItem.build(
180
+ dimension_key: "cache_storage_token_hour",
181
+ quantity: BigDecimal(tokens.to_s) * BigDecimal(seconds.to_s) / 3600,
182
+ cost_status: Charges::CostStatus::UNKNOWN,
183
+ pricing_basis: "provider_usage",
184
+ provider_field: "response.usageMetadata.totalTokenCount",
185
+ details: { cached_tokens: tokens, expire_time: to }
186
+ )
187
+ end
188
+
189
+ def service_tier(request, usage, response_headers)
190
+ reported_service_tier(usage) ||
191
+ Pricing::Mode.normalize(response_header(response_headers, "x-gemini-service-tier")) ||
192
+ requested_flex_tier(request)
193
+ end
194
+
195
+ def reported_service_tier(usage)
196
+ return nil unless usage
197
+
198
+ traffic = usage["trafficType"]
199
+ Pricing::Mode.normalize(usage["serviceTier"]) ||
200
+ TRAFFIC_TYPE_MODES.fetch(traffic.to_s) { Pricing::Mode.normalize(traffic) }
201
+ end
202
+
203
+ def requested_flex_tier(request)
204
+ "flex" if Pricing::Mode.normalize(request["service_tier"] || request["serviceTier"]) == "flex"
205
+ end
206
+
207
+ def response_header(headers, name)
208
+ headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
209
+ end
210
+
211
+ def path_matches?(url, pattern)
212
+ uri_matches?(url) { |uri| uri.path.to_s.match?(pattern) }
213
+ end
214
+
215
+ def model_from_url(url)
216
+ uri = parsed_uri(url)
217
+ uri.path[MODEL_PATH_PATTERN, 1] if uri
218
+ end
219
+
220
+ def last_usage_metadata(events)
221
+ find_event_value(events, reverse: true) do |data|
222
+ metadata = data["usageMetadata"]
223
+ metadata if metadata.is_a?(Hash)
281
224
  end
282
225
  end
283
226
 
284
- def unique_query_count(queries)
285
- Array(queries).map { |query| query.to_s.strip }.reject(&:empty?).uniq.size
227
+ def completed_interaction(data)
228
+ interaction = data["interaction"]
229
+ interaction if interaction.is_a?(Hash) && interaction["usage"].is_a?(Hash)
286
230
  end
287
231
 
288
- def grounding_line_items(counts, model:, provider_field: nil)
289
- counts.filter_map do |kind, count|
290
- next unless count.positive?
291
-
292
- Charges::LineItem.build(
293
- dimension_key: kind,
294
- quantity: ModelFamilies.per_query_grounding?(model) ? count : 1,
295
- cost_status: Charges::CostStatus::UNKNOWN,
296
- pricing_basis: "provider_usage",
297
- provider_field: provider_field || GROUNDING_FIELDS.fetch(kind),
298
- details: { web_search_queries: count }
299
- )
232
+ def stream_grounding_line_items(events, model:)
233
+ grounding = find_event_value(events, reverse: true) do |data|
234
+ candidates = Grounding.from_candidates(data["candidates"])
235
+ candidates if candidates.any?
300
236
  end
237
+ grounding ? grounding.line_items(model: model) : []
301
238
  end
302
239
  end
303
240
  end
@@ -4,61 +4,80 @@ module LlmCostTracker
4
4
  module Providers
5
5
  module Gemini
6
6
  module UsageExtractor
7
- def self.token_usage(usage)
8
- cache_read = usage["cachedContentTokenCount"].to_i
9
- tool_use_prompt = usage["toolUsePromptTokenCount"].to_i
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")
7
+ class << self
8
+ def token_usage(usage)
9
+ Usage::TokenUsage.build(
10
+ **input_counts(usage),
11
+ **output_counts(usage),
12
+ total_tokens: usage["totalTokenCount"],
13
+ hidden_output_tokens: usage["thoughtsTokenCount"]
14
+ )
15
+ end
14
16
 
15
- Usage::TokenUsage.build(
16
- input_tokens: regular_input_tokens(usage: usage,
17
- cache_read: cache_read,
18
- audio_input: audio_input,
19
- image_input: image_input) +
20
- tool_use_prompt,
21
- output_tokens: regular_output_tokens(usage: usage,
22
- audio_output: audio_output,
23
- image_output: image_output),
24
- total_tokens: usage["totalTokenCount"],
25
- cache_read_input_tokens: cache_read,
26
- audio_input_tokens: audio_input,
27
- audio_output_tokens: audio_output,
28
- image_input_tokens: image_input,
29
- image_output_tokens: image_output,
30
- hidden_output_tokens: usage["thoughtsTokenCount"]
31
- )
32
- end
17
+ def line_items(usage)
18
+ {
19
+ "audio_cache_read_input" => modality_tokens(usage["cacheTokensDetails"], "AUDIO"),
20
+ "video_output" => modality_tokens(usage["candidatesTokensDetails"], "VIDEO")
21
+ }.filter_map do |dimension_key, quantity|
22
+ Charges::LineItem.build(dimension_key: dimension_key, quantity: quantity) if quantity.positive?
23
+ end
24
+ end
33
25
 
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
26
+ def from_interaction(usage, service_tier)
27
+ {
28
+ "promptTokenCount" => usage["total_input_tokens"],
29
+ "cachedContentTokenCount" => usage["total_cached_tokens"],
30
+ "toolUsePromptTokenCount" => usage["total_tool_use_tokens"],
31
+ "candidatesTokenCount" => usage["total_output_tokens"],
32
+ "thoughtsTokenCount" => usage["total_thought_tokens"],
33
+ "totalTokenCount" => usage["total_tokens"],
34
+ "promptTokensDetails" => interaction_modalities(usage["input_tokens_by_modality"]),
35
+ "cacheTokensDetails" => interaction_modalities(usage["cached_tokens_by_modality"]),
36
+ "candidatesTokensDetails" => interaction_modalities(usage["output_tokens_by_modality"]),
37
+ "serviceTier" => service_tier
38
+ }
39
+ end
38
40
 
39
- def self.gross_output_tokens(usage)
40
- usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
41
- end
41
+ def modality_tokens(details, modality)
42
+ Array(details).sum do |detail|
43
+ next 0 unless detail["modality"] == modality
42
44
 
43
- def self.regular_input_tokens(usage:, cache_read:, audio_input:, image_input:)
44
- [usage["promptTokenCount"].to_i - cache_read - audio_input - image_input, 0].max
45
- end
45
+ detail["tokenCount"].to_i
46
+ end
47
+ end
46
48
 
47
- def self.regular_output_tokens(usage:, audio_output:, image_output:)
48
- [gross_output_tokens(usage) - audio_output - image_output, 0].max
49
- end
49
+ private
50
50
 
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
55
- end
51
+ def input_counts(usage)
52
+ cache_read = usage["cachedContentTokenCount"].to_i
53
+ audio = uncached_prompt_tokens(usage, "AUDIO")
54
+ image = uncached_prompt_tokens(usage, "IMAGE") + uncached_prompt_tokens(usage, "DOCUMENT")
55
+ text = [usage["promptTokenCount"].to_i - cache_read - audio - image, 0].max
56
+ {
57
+ input_tokens: text + usage["toolUsePromptTokenCount"].to_i,
58
+ cache_read_input_tokens: cache_read,
59
+ audio_input_tokens: audio,
60
+ image_input_tokens: image
61
+ }
62
+ end
56
63
 
57
- def self.modality_tokens(details, modality)
58
- Array(details).sum do |detail|
59
- next 0 unless detail["modality"] == modality
64
+ def output_counts(usage)
65
+ audio = modality_tokens(usage["candidatesTokensDetails"], "AUDIO")
66
+ image = modality_tokens(usage["candidatesTokensDetails"], "IMAGE")
67
+ gross = usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
68
+ { output_tokens: [gross - audio - image, 0].max, audio_output_tokens: audio, image_output_tokens: image }
69
+ end
70
+
71
+ def uncached_prompt_tokens(usage, modality)
72
+ prompt = modality_tokens(usage["promptTokensDetails"] || usage["promptTokenDetails"], modality)
73
+ cached = modality_tokens(usage["cacheTokensDetails"], modality)
74
+ [prompt - cached, 0].max
75
+ end
60
76
 
61
- detail["tokenCount"].to_i
77
+ def interaction_modalities(entries)
78
+ Array(entries).map do |entry|
79
+ { "modality" => entry["modality"].to_s.upcase, "tokenCount" => entry["tokens"] }
80
+ end
62
81
  end
63
82
  end
64
83
  end
@@ -19,10 +19,21 @@ module LlmCostTracker
19
19
  ].freeze
20
20
 
21
21
  DATA_RESIDENCY_HOST_PATTERN = /\A(?:[a-z]{2,3}\.api\.openai\.com|us\.api\.x\.ai|api\.(?:eu|us)\.mistral\.ai)\z/
22
+ VERTEX_NON_GLOBAL_HOST_PATTERN = /\A(?:[a-z0-9-]+-aiplatform|aiplatform\.[a-z0-9-]+\.rep)\.googleapis\.com\z/
23
+
24
+ BEDROCK_HOST_PATTERN = /\Abedrock-(?:mantle|runtime(?:-fips)?)\./
22
25
 
23
26
  def self.data_residency?(host)
24
27
  host.to_s.downcase.match?(DATA_RESIDENCY_HOST_PATTERN)
25
28
  end
29
+
30
+ def self.vertex_non_global?(host)
31
+ host.to_s.downcase.match?(VERTEX_NON_GLOBAL_HOST_PATTERN)
32
+ end
33
+
34
+ def self.bedrock?(host)
35
+ host.to_s.downcase.match?(BEDROCK_HOST_PATTERN)
36
+ end
26
37
  end
27
38
  end
28
39
  end
@@ -6,7 +6,7 @@ module LlmCostTracker
6
6
  module ModelFamilies
7
7
  IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
8
8
 
9
- CHARACTER_BILLED_TTS_MODEL_PATTERN = /\Atts-1(-hd)?\z/
9
+ CHARACTER_BILLED_TTS_MODEL_PATTERN = %r{\A(?:tts-1(?:-hd)?|voxtral-mini-tts-[\w-]+|canopylabs/orpheus-[\w-]+)\z}
10
10
 
11
11
  REASONING_MODEL_PATTERNS = [
12
12
  /\Agpt-5(\b|[\d.-])/i,
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Openai
6
+ class OutputItem
7
+ RENAMED_TYPES = {
8
+ "web_search_call" => "web_search_request",
9
+ "code_interpreter_call" => "container_session"
10
+ }.freeze
11
+
12
+ attr_reader :dimension
13
+
14
+ def self.wrap(data)
15
+ item = new(data) if data.is_a?(Hash)
16
+ item if item&.dimension
17
+ end
18
+
19
+ def initialize(data)
20
+ @data = data
21
+ @dimension = shell_session? ? "container_session" : catalog_dimension
22
+ end
23
+
24
+ def billable?
25
+ case dimension
26
+ when "image_generation_call" then @data["status"] == "completed"
27
+ when "web_search_request" then [nil, "search"].include?(@data.dig("action", "type"))
28
+ else true
29
+ end
30
+ end
31
+
32
+ def dedup_key(position)
33
+ return "#{dimension}:#{container_id}" if dimension == "container_session" && container_id
34
+
35
+ @data["id"] || "#{@data['type']}:#{position}"
36
+ end
37
+
38
+ def line_item(dimension_key)
39
+ Charges::LineItem.build(
40
+ dimension_key: dimension_key,
41
+ quantity: 1,
42
+ cost_status: Charges::CostStatus::UNKNOWN,
43
+ pricing_basis: "provider_usage",
44
+ provider_field: @data["provider_field"] || "response.output.#{@data['type']}",
45
+ provider_item_id: dimension_key == "container_session" ? container_id || @data["id"] : @data["id"],
46
+ details: details
47
+ )
48
+ end
49
+
50
+ def billing_fields
51
+ @data.slice("type", "id", "status", "container_id").merge(
52
+ "action" => @data["action"]&.slice("type"),
53
+ "environment" => @data["environment"]&.slice("type", "container_id")
54
+ ).compact
55
+ end
56
+
57
+ private
58
+
59
+ def shell_session?
60
+ @data["type"] == "shell_call" && shell_container_id
61
+ end
62
+
63
+ def catalog_dimension
64
+ key = RENAMED_TYPES[@data["type"]] || @data["type"]
65
+ dimension = Usage::Catalog[key]
66
+ key if dimension && dimension.token_key.nil?
67
+ end
68
+
69
+ def container_id
70
+ @data["container_id"] || shell_container_id
71
+ end
72
+
73
+ def shell_container_id
74
+ environment = @data["environment"]
75
+ environment["container_id"] if environment.is_a?(Hash) && environment["type"] == "container_reference"
76
+ end
77
+
78
+ def details
79
+ { status: @data["status"], action_type: @data.dig("action", "type"), container_id: container_id }.compact
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -5,9 +5,10 @@ module LlmCostTracker
5
5
  module Openai
6
6
  class Parser < LlmCostTracker::Parsers::Base
7
7
  include ResponseParser
8
+ include StreamParser
8
9
 
9
10
  TRACKED_ENDPOINTS = %w[
10
- chat/completions completions embeddings moderations responses
11
+ chat/completions completions embeddings moderations responses responses/compact
11
12
  audio/transcriptions audio/translations audio/speech
12
13
  images/generations images/edits images/variations
13
14
  ].freeze