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
@@ -5,6 +5,7 @@ module LlmCostTracker
5
5
  RATE_BASIS_QUANTITIES = {
6
6
  "per_million_tokens" => 1_000_000,
7
7
  "per_million_characters" => 1_000_000,
8
+ "per_million_token_hours" => 1_000_000,
8
9
  "per_request" => 1,
9
10
  "per_1k_requests" => 1_000,
10
11
  "per_session" => 1,
@@ -16,7 +16,7 @@ module LlmCostTracker
16
16
  module Registry
17
17
  DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
18
18
  CONTEXT_THRESHOLD_KEY = "_context_price_threshold_tokens"
19
- PRICE_KEYS = Usage::Catalog.token_priced.map(&:key).freeze
19
+ PRICE_KEYS = Usage::Catalog.all.select(&:token?).map(&:key).freeze
20
20
  METADATA_KEYS = ["_source", CONTEXT_THRESHOLD_KEY].freeze
21
21
 
22
22
  class << self
@@ -185,10 +185,9 @@ module LlmCostTracker
185
185
  def normalize_price_entry(model, price, context)
186
186
  unknown = []
187
187
  normalized = price.each_with_object({}) do |(key, value), acc|
188
- registry_key = registry_key_for(key)
189
- if registry_key == CONTEXT_THRESHOLD_KEY
190
- acc[registry_key] = Integer(value)
191
- elsif registry_key
188
+ if key.to_s == CONTEXT_THRESHOLD_KEY
189
+ acc[CONTEXT_THRESHOLD_KEY] = Integer(value)
190
+ elsif (registry_key = PriceKey.price_key_for(key))
192
191
  acc[registry_key] = non_negative_decimal(value, label: "price for #{registry_key.inspect}")
193
192
  elsif !METADATA_KEYS.include?(key)
194
193
  unknown << key
@@ -213,12 +212,6 @@ module LlmCostTracker
213
212
  )
214
213
  end
215
214
 
216
- def registry_key_for(key)
217
- return CONTEXT_THRESHOLD_KEY if key.to_s == CONTEXT_THRESHOLD_KEY
218
-
219
- PriceKey.price_key_for(key)
220
- end
221
-
222
215
  def validate_price_entry(price, model:, context:)
223
216
  return {} if price.nil?
224
217
  return price if price.is_a?(Hash)
@@ -240,7 +233,7 @@ module LlmCostTracker
240
233
 
241
234
  rate = {
242
235
  amount: amount,
243
- quantity: rate_quantity(dimension),
236
+ quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
244
237
  currency: currency,
245
238
  source_key: key
246
239
  }
@@ -258,10 +251,6 @@ module LlmCostTracker
258
251
  [dimension, tier]
259
252
  end
260
253
 
261
- def rate_quantity(dimension)
262
- Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d
263
- end
264
-
265
254
  def upcased_currency(value)
266
255
  (value || LlmCostTracker::DEFAULT_CURRENCY).upcase
267
256
  end
@@ -44,19 +44,12 @@ module LlmCostTracker
44
44
  return rate if rate
45
45
 
46
46
  tier_rates.each do |candidate, candidate_rate|
47
- return candidate_rate if tier_includes?(pricing_mode, candidate)
47
+ return candidate_rate if "_#{pricing_mode}_".include?("_#{candidate}_")
48
48
  end
49
49
  end
50
50
  dimension_rates[:default]
51
51
  end
52
52
 
53
- def tier_includes?(tier_name, candidate_name)
54
- tier_name == candidate_name ||
55
- tier_name.start_with?("#{candidate_name}_") ||
56
- tier_name.end_with?("_#{candidate_name}") ||
57
- tier_name.include?("_#{candidate_name}_")
58
- end
59
-
60
53
  def charge_dimension_key(dimension)
61
54
  billing_dimension = Usage::Catalog[dimension]
62
55
  return billing_dimension.key if billing_dimension && billing_dimension.token_key.nil?
@@ -6,22 +6,26 @@ module LlmCostTracker
6
6
  module RegistryDiff
7
7
  class << self
8
8
  def call(current_models, updated_models)
9
- current_models = Registry.normalize_price_entries(current_models, context: "current price table")
10
- updated_models = Registry.normalize_price_entries(updated_models, context: "updated price table")
11
-
12
- (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
13
- fields = price_field_changes(current_models[model], updated_models[model])
14
- changes[model] = fields if fields.any?
15
- end
9
+ nested(
10
+ Registry.normalize_price_entries(current_models, context: "current price table"),
11
+ Registry.normalize_price_entries(updated_models, context: "updated price table")
12
+ )
16
13
  rescue ArgumentError, TypeError => e
17
14
  raise Error, e.message
18
15
  end
19
16
 
17
+ def nested(current, updated)
18
+ (current.keys | updated.keys).sort.each_with_object({}) do |key, changes|
19
+ fields = price_field_changes(current[key], updated[key])
20
+ changes[key] = fields if fields.any?
21
+ end
22
+ end
23
+
20
24
  private
21
25
 
22
26
  def price_field_changes(current_entry, updated_entry)
23
- current_price = current_entry || {}
24
- updated_price = updated_entry || {}
27
+ current_price = (current_entry || {}).transform_keys(&:to_s)
28
+ updated_price = (updated_entry || {}).transform_keys(&:to_s)
25
29
 
26
30
  (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
27
31
  from = current_price[field]
@@ -43,59 +43,26 @@ module LlmCostTracker
43
43
  force: false,
44
44
  fetcher: Fetcher.new,
45
45
  today: Date.today)
46
- current = load_registry(path)
47
- response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
48
-
49
- if response.not_modified
50
- return refresh_result(
51
- path: path,
52
- url: url,
53
- response: response,
54
- changes: registry_changes(current, current),
55
- suspicious: [],
56
- written: false,
57
- not_modified: true
58
- )
59
- end
60
-
61
- remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
62
- changes = registry_changes(current, remote)
63
- suspicious = SnapshotGuard.call(current: current, remote: remote, changes: changes)
64
- unless preview
46
+ response, remote, changes, suspicious = compare(path, url, fetcher, today)
47
+ written = !preview && !response.not_modified
48
+ if written
65
49
  refuse_suspicious_snapshot!(path, suspicious) unless force || suspicious.empty?
66
50
  RegistryWriter.new.call(path: path, registry: remote)
67
51
  Pricing::Registry.reset!
68
52
  end
69
- refresh_result(
53
+ RefreshResult.new(
70
54
  path: path,
71
- url: url,
72
- response: response,
55
+ source_url: Redaction.text(url),
56
+ source_version: response.source_version,
73
57
  changes: changes,
74
58
  suspicious: suspicious,
75
- written: !preview,
76
- not_modified: false
59
+ written: written,
60
+ not_modified: response.not_modified
77
61
  )
78
62
  end
79
63
 
80
64
  def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
81
- current = load_registry(path)
82
- response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
83
-
84
- if response.not_modified
85
- return CheckResult.new(
86
- path: path,
87
- source_url: Redaction.text(url),
88
- source_version: response.source_version,
89
- changes: {},
90
- suspicious: [],
91
- up_to_date: true
92
- )
93
- end
94
-
95
- remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
96
- changes = registry_changes(current, remote)
97
- suspicious = SnapshotGuard.call(current: current, remote: remote, changes: changes)
98
-
65
+ response, _remote, changes, suspicious = compare(path, url, fetcher, today)
99
66
  CheckResult.new(
100
67
  path: path,
101
68
  source_url: Redaction.text(url),
@@ -147,6 +114,16 @@ module LlmCostTracker
147
114
  raise Error, "Unable to load remote pricing snapshot: #{e.message}"
148
115
  end
149
116
 
117
+ def compare(path, url, fetcher, today)
118
+ current = load_registry(path)
119
+ response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
120
+ return [response, nil, {}, []] if response.not_modified
121
+
122
+ remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
123
+ changes = registry_changes(current, remote)
124
+ [response, remote, changes, SnapshotGuard.call(current: current, remote: remote, changes: changes)]
125
+ end
126
+
150
127
  def load_registry(path)
151
128
  return {} unless File.exist?(path)
152
129
 
@@ -164,29 +141,18 @@ module LlmCostTracker
164
141
  raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
165
142
  end
166
143
 
167
- def refresh_result(path:, url:, response:, changes:, suspicious:, written:, not_modified:)
168
- RefreshResult.new(
169
- path: path,
170
- source_url: Redaction.text(url),
171
- source_version: response.source_version,
172
- changes: changes,
173
- suspicious: suspicious,
174
- written: written,
175
- not_modified: not_modified
176
- )
177
- end
178
-
179
144
  def refuse_suspicious_snapshot!(path, suspicious)
180
145
  listed = suspicious.first(20).map { |finding| "\n - #{finding}" }.join
181
146
  raise Error,
182
147
  "Refusing to write pricing file #{path}: the remote snapshot has #{suspicious.size} " \
183
148
  "suspicious price change(s):#{listed}\n" \
184
- "Review them with PREVIEW=1, then refresh with FORCE=1 (force: true) to accept them."
149
+ "Review them with llm_cost_tracker:prices:check, " \
150
+ "then refresh with FORCE=1 (force: true) to accept them."
185
151
  end
186
152
 
187
153
  def registry_changes(current, remote)
188
154
  model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
189
- charge_changes = service_charges_diff(
155
+ charge_changes = RegistryDiff.nested(
190
156
  current.fetch("service_charges", {}),
191
157
  remote.fetch("service_charges", {})
192
158
  )
@@ -194,21 +160,6 @@ module LlmCostTracker
194
160
 
195
161
  model_changes.merge("service_charges" => charge_changes)
196
162
  end
197
-
198
- def service_charges_diff(current, remote)
199
- (current.keys | remote.keys).sort.each_with_object({}) do |provider, changes|
200
- current_rates = (current[provider] || {}).transform_keys(&:to_s)
201
- remote_rates = (remote[provider] || {}).transform_keys(&:to_s)
202
- (current_rates.keys | remote_rates.keys).sort.each_with_object(changes) do |component, _|
203
- from = current_rates[component]
204
- to = remote_rates[component]
205
- next if from == to
206
-
207
- changes[provider] ||= {}
208
- changes[provider][component] = { "from" => from, "to" => to }
209
- end
210
- end
211
- end
212
163
  end
213
164
  end
214
165
  end
@@ -10,7 +10,9 @@ module LlmCostTracker
10
10
 
11
11
  class << self
12
12
  def match?(url)
13
- match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
13
+ uri_matches?(url) do |uri|
14
+ HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.include?("/v1/messages")
15
+ end
14
16
  end
15
17
 
16
18
  def provider_names
@@ -32,7 +34,9 @@ module LlmCostTracker
32
34
  model: response["model"] || request[:model],
33
35
  provider_response_id: response["id"],
34
36
  usage_source: Usage::Source::RESPONSE,
35
- request: request
37
+ request: request,
38
+ content: Array(response["content"]).grep(Hash).map(&:deep_symbolize_keys),
39
+ **stop_fields(response)
36
40
  )
37
41
  end
38
42
 
@@ -51,7 +55,9 @@ module LlmCostTracker
51
55
  provider_response_id: response_id,
52
56
  usage_source: Usage::Source::STREAM_FINAL,
53
57
  request: request,
54
- stream: true
58
+ stream: true,
59
+ content: content_blocks(events),
60
+ **stop_fields(final_delta(events))
55
61
  )
56
62
  else
57
63
  build_unknown_stream_usage(
@@ -67,12 +73,36 @@ module LlmCostTracker
67
73
  "anthropic"
68
74
  end
69
75
 
76
+ def retain_stream_event?(data)
77
+ data.is_a?(Hash) && data.dig("content_block", "type") == "fallback"
78
+ end
79
+
70
80
  private
71
81
 
72
82
  def symbolize_request(request_body)
73
83
  safe_json_parse(request_body).deep_symbolize_keys
74
84
  end
75
85
 
86
+ def final_delta(events)
87
+ find_event_value(events, reverse: true) { |data| data["delta"] if data["type"] == "message_delta" }
88
+ end
89
+
90
+ def content_blocks(events)
91
+ blocks = []
92
+ each_event_data(events) do |data|
93
+ block = data["content_block"] if data["type"] == "content_block_start"
94
+ blocks << block.deep_symbolize_keys if block.is_a?(Hash)
95
+ end
96
+ blocks
97
+ end
98
+
99
+ def stop_fields(source)
100
+ return {} unless source.is_a?(Hash)
101
+
102
+ details = source["stop_details"]
103
+ { stop_reason: source["stop_reason"], refusal_category: (details["category"] if details.is_a?(Hash)) }
104
+ end
105
+
76
106
  def stream_usage(events)
77
107
  latest_delta = find_event_value(events, reverse: true) do |data|
78
108
  data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
@@ -11,17 +11,23 @@ module LlmCostTracker
11
11
  provider_response_id:,
12
12
  usage_source:,
13
13
  request: nil,
14
- pricing_mode: nil,
15
- stream: false)
14
+ stream: false,
15
+ stop_reason: nil,
16
+ refusal_category: nil,
17
+ content: nil)
18
+ pricing_mode = UsageExtractor.pricing_mode(request: request, usage: usage)
19
+ line_items = UsageExtractor.service_line_items(usage) +
20
+ UsageExtractor.iteration_line_items(usage, content: content) +
21
+ UsageExtractor.refusal_line_items(usage, stop_reason:, refusal_category:)
16
22
  Event.build(
17
23
  provider: "anthropic",
18
24
  provider_response_id: provider_response_id,
19
- pricing_mode: pricing_mode || UsageExtractor.pricing_mode(request: request, usage: usage),
20
- model: model,
25
+ pricing_mode: pricing_mode,
26
+ model: UsageExtractor.served_model(usage, content) || model,
21
27
  token_usage: UsageExtractor.token_usage(usage),
22
28
  stream: stream,
23
29
  usage_source: usage_source,
24
- service_line_items: UsageExtractor.service_line_items(usage)
30
+ service_line_items: line_items
25
31
  )
26
32
  end
27
33
  end
@@ -9,34 +9,100 @@ module LlmCostTracker
9
9
  "web_fetch_request" => :web_fetch_requests
10
10
  }.freeze
11
11
  DATA_RESIDENCY_GEOS = %w[us].freeze
12
- private_constant :SERVER_TOOL_LINE_ITEMS, :DATA_RESIDENCY_GEOS
12
+ BILLED_REFUSAL_CATEGORIES = %w[bio frontier_llm reasoning_extraction].freeze
13
+ BEDROCK_REGIONAL_PROFILE = /\A(?!global\.)[a-z]+\.anthropic\./
14
+ private_constant :SERVER_TOOL_LINE_ITEMS,
15
+ :DATA_RESIDENCY_GEOS,
16
+ :BILLED_REFUSAL_CATEGORIES,
17
+ :BEDROCK_REGIONAL_PROFILE
13
18
 
14
19
  def self.token_usage(usage)
15
- input = usage[:input_tokens].to_i
16
- output = usage[:output_tokens].to_i
17
- cache_read = usage[:cache_read_input_tokens].to_i
18
- cache_write, cache_write_extended = cache_writes(usage)
20
+ entries = [usage, *iterations(usage, "compaction")]
21
+ writes = entries.map { |entry| cache_writes(entry) }
19
22
 
20
23
  Usage::TokenUsage.build(
21
- input_tokens: input,
22
- output_tokens: output,
23
- cache_read_input_tokens: cache_read,
24
- cache_write_input_tokens: cache_write,
25
- cache_write_extended_input_tokens: cache_write_extended,
24
+ input_tokens: entries.sum { |entry| entry[:input_tokens].to_i },
25
+ output_tokens: entries.sum { |entry| entry[:output_tokens].to_i },
26
+ cache_read_input_tokens: entries.sum { |entry| entry[:cache_read_input_tokens].to_i },
27
+ cache_write_input_tokens: writes.sum(&:first),
28
+ cache_write_extended_input_tokens: writes.sum(&:last),
26
29
  hidden_output_tokens: usage.dig(:output_tokens_details, :thinking_tokens).to_i
27
30
  )
28
31
  end
29
32
 
33
+ def self.served_model(usage, content = nil)
34
+ served = iterations(usage, "fallback_message").last&.dig(:model)
35
+ served ||= fallback_blocks(content).last&.dig(:to, :model)
36
+ served&.to_s.presence
37
+ end
38
+
39
+ def self.iteration_line_items(usage, content: nil)
40
+ other_model_iterations(usage, content).map do |iteration|
41
+ tokens = token_usage(iteration)
42
+ Charges::LineItem.build(
43
+ dimension_key: "model_iteration",
44
+ quantity: 1,
45
+ pricing_basis: "provider_usage",
46
+ provider_field: "usage.iterations.#{iteration[:type]}",
47
+ details: tokens.to_h.select { |_key, count| count.positive? }.merge(model: iteration[:model].to_s)
48
+ )
49
+ end
50
+ end
51
+
52
+ def self.refusal_line_items(usage, stop_reason:, refusal_category:)
53
+ return [] unless stop_reason.to_s == "refusal" && token_usage(usage).output_tokens.zero?
54
+ return [] if BILLED_REFUSAL_CATEGORIES.include?(refusal_category.to_s)
55
+
56
+ [Charges::LineItem.build(
57
+ dimension_key: "billed_request",
58
+ quantity: 1,
59
+ rate_amount: 0,
60
+ cost: 0,
61
+ pricing_basis: "provider_usage",
62
+ price_source: "provider_response",
63
+ provider_field: "stop_details.category",
64
+ details: { category: refusal_category&.to_s }.compact
65
+ )]
66
+ end
67
+
68
+ def self.other_model_iterations(usage, content)
69
+ advisors = iterations(usage, "advisor_message")
70
+ served = served_model(usage, content)
71
+ return advisors unless served
72
+
73
+ categories = fallback_blocks(content).map { |block| block.dig(:trigger, :category).to_s }
74
+ declined = iterations(usage, "message").reject { |entry| ["", served].include?(entry[:model].to_s) }
75
+ hops = declined.group_by { |entry| entry[:model].to_s }.values
76
+ billed = hops.select.with_index do |hop, index|
77
+ hop.any? { |entry| entry[:output_tokens].to_i.positive? } ||
78
+ BILLED_REFUSAL_CATEGORIES.include?(categories[index])
79
+ end
80
+ advisors + billed.flatten
81
+ end
82
+
83
+ def self.fallback_blocks(content)
84
+ Array(content).select { |block| block.is_a?(Hash) && block[:type].to_s == "fallback" }
85
+ end
86
+
87
+ def self.iterations(usage, type)
88
+ Array(usage[:iterations]).select { |entry| entry.is_a?(Hash) && entry[:type].to_s == type }
89
+ end
90
+
30
91
  def self.pricing_mode(request:, usage:)
31
92
  speed = usage&.dig(:speed) || request&.dig(:speed)
32
93
  service_tier = usage&.dig(:service_tier) || request&.dig(:service_tier)
33
94
  geo = (usage&.dig(:inference_geo) || request&.dig(:inference_geo)).to_s.downcase
34
95
 
35
96
  modes = [Pricing::Mode.normalize(speed), Pricing::Mode.normalize(service_tier)]
36
- modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo)
97
+ modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo) || bedrock_regional?(request&.dig(:model))
37
98
  Pricing::Mode.compose(modes)
38
99
  end
39
100
 
101
+ def self.bedrock_regional?(model)
102
+ model.to_s.match?(BEDROCK_REGIONAL_PROFILE) &&
103
+ Pricing::Matcher.modifier_priced?(provider: "bedrock", model: model.to_s, modifier: "data_residency")
104
+ end
105
+
40
106
  def self.service_line_items(usage)
41
107
  server_tool_use = usage[:server_tool_use]
42
108
  return [] unless server_tool_use.is_a?(Hash)
@@ -58,7 +124,10 @@ module LlmCostTracker
58
124
  def self.cache_writes(usage)
59
125
  cache_creation = usage[:cache_creation]
60
126
  if cache_creation.is_a?(Hash)
61
- [cache_creation[:ephemeral_5m_input_tokens].to_i, cache_creation[:ephemeral_1h_input_tokens].to_i]
127
+ five = cache_creation[:ephemeral_5m_input_tokens].to_i
128
+ one = cache_creation[:ephemeral_1h_input_tokens].to_i
129
+ # A stream's message_delta total also counts later server-tool breakpoints, which are always 5m.
130
+ [five + [usage[:cache_creation_input_tokens].to_i - five - one, 0].max, one]
62
131
  else
63
132
  warn_unexpected_cache_creation(cache_creation, usage)
64
133
  [usage[:cache_creation_input_tokens].to_i, 0]
@@ -6,13 +6,9 @@ module LlmCostTracker
6
6
  class Parser < LlmCostTracker::Parsers::Base
7
7
  include Openai::ResponseParser
8
8
 
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
13
- ].freeze
14
-
15
- PATH_PATTERN = %r{\A/openai/(?:deployments/[^/]+|v1)/(?:#{TRACKED_ENDPOINTS.join('|')})\z}
9
+ PATH_PATTERN = %r{
10
+ \A/openai/(?:deployments/[^/]+|v1)/(?:#{Openai::Parser::TRACKED_ENDPOINTS.join('|')}|responses/resp_[^/]+)\z
11
+ }x
16
12
 
17
13
  class << self
18
14
  def match?(url)