llm_cost_tracker 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/README.md +16 -5
  4. data/app/assets/llm_cost_tracker/application.css +16 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
  7. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
  13. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  14. data/app/models/llm_cost_tracker/call.rb +10 -14
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
  17. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  18. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  19. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  20. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  21. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  22. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  23. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  24. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  26. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  27. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  28. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  32. data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
  33. data/config/routes.rb +6 -1
  34. data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
  35. data/lib/llm_cost_tracker/budget.rb +44 -55
  36. data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
  37. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  38. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
  40. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  41. data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
  42. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  43. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  44. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  45. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  46. data/lib/llm_cost_tracker/configuration.rb +5 -9
  47. data/lib/llm_cost_tracker/doctor/price_check.rb +14 -3
  48. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  49. data/lib/llm_cost_tracker/doctor.rb +19 -2
  50. data/lib/llm_cost_tracker/engine.rb +0 -1
  51. data/lib/llm_cost_tracker/errors.rb +13 -0
  52. data/lib/llm_cost_tracker/event.rb +8 -0
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  70. data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
  71. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  72. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  73. data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
  74. data/lib/llm_cost_tracker/integrations/base.rb +56 -19
  75. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
  76. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  77. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  78. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
  79. data/lib/llm_cost_tracker/integrations.rb +1 -20
  80. data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
  81. data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
  82. data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
  83. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  84. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  85. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  86. data/lib/llm_cost_tracker/ledger/store.rb +14 -13
  87. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  88. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  89. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  90. data/lib/llm_cost_tracker/ledger.rb +1 -0
  91. data/lib/llm_cost_tracker/logging.rb +3 -1
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
  93. data/lib/llm_cost_tracker/parsers.rb +12 -41
  94. data/lib/llm_cost_tracker/prices.json +3071 -249
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  100. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  102. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  105. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  107. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  108. data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
  109. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  110. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  111. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  112. data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
  113. data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
  114. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  115. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  116. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  117. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
  118. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  119. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
  120. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
  121. data/lib/llm_cost_tracker/redaction.rb +32 -0
  122. data/lib/llm_cost_tracker/report/data.rb +1 -1
  123. data/lib/llm_cost_tracker/retention.rb +1 -3
  124. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
  125. data/lib/llm_cost_tracker/tracker.rb +17 -10
  126. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  127. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  128. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  129. data/lib/llm_cost_tracker/version.rb +1 -1
  130. data/lib/llm_cost_tracker.rb +4 -2
  131. data/lib/tasks/llm_cost_tracker.rake +26 -21
  132. data/llm_cost_tracker.gemspec +63 -0
  133. metadata +23 -10
@@ -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
@@ -33,6 +33,12 @@ module LlmCostTracker
33
33
  @prices_file_mtime_iso = nil
34
34
  end
35
35
 
36
+ def validate_file!(path)
37
+ file_prices(path)
38
+ file_rates(path)
39
+ file_metadata(path)
40
+ end
41
+
36
42
  def builtin_prices
37
43
  @builtin_prices ||= normalize_price_entries(
38
44
  raw_registry.fetch("models", {}), context: "bundled prices"
@@ -157,10 +163,15 @@ module LlmCostTracker
157
163
  def loading(path)
158
164
  yield
159
165
  rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
160
- raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
166
+ raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}; fix or delete it"
161
167
  end
162
168
 
163
169
  def load_raw_file_registry(path)
170
+ unless File.exist?(path)
171
+ Logging.warn("pricing.file #{path} does not exist; using bundled prices until prices:refresh creates it")
172
+ return {}.freeze
173
+ end
174
+
164
175
  loading(path) { (YAML.safe_load_file(path, aliases: false) || {}).freeze }
165
176
  end
166
177
 
@@ -174,10 +185,9 @@ module LlmCostTracker
174
185
  def normalize_price_entry(model, price, context)
175
186
  unknown = []
176
187
  normalized = price.each_with_object({}) do |(key, value), acc|
177
- registry_key = registry_key_for(key)
178
- if registry_key == CONTEXT_THRESHOLD_KEY
179
- acc[registry_key] = Integer(value)
180
- 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))
181
191
  acc[registry_key] = non_negative_decimal(value, label: "price for #{registry_key.inspect}")
182
192
  elsif !METADATA_KEYS.include?(key)
183
193
  unknown << key
@@ -202,12 +212,6 @@ module LlmCostTracker
202
212
  )
203
213
  end
204
214
 
205
- def registry_key_for(key)
206
- return CONTEXT_THRESHOLD_KEY if key.to_s == CONTEXT_THRESHOLD_KEY
207
-
208
- PriceKey.price_key_for(key)
209
- end
210
-
211
215
  def validate_price_entry(price, model:, context:)
212
216
  return {} if price.nil?
213
217
  return price if price.is_a?(Hash)
@@ -229,7 +233,7 @@ module LlmCostTracker
229
233
 
230
234
  rate = {
231
235
  amount: amount,
232
- quantity: rate_quantity(dimension),
236
+ quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
233
237
  currency: currency,
234
238
  source_key: key
235
239
  }
@@ -247,10 +251,6 @@ module LlmCostTracker
247
251
  [dimension, tier]
248
252
  end
249
253
 
250
- def rate_quantity(dimension)
251
- Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d
252
- end
253
-
254
254
  def upcased_currency(value)
255
255
  (value || LlmCostTracker::DEFAULT_CURRENCY).upcase
256
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?
@@ -4,7 +4,12 @@ module LlmCostTracker
4
4
  module Pricing
5
5
  module Sync
6
6
  module ChangePrinter
7
- def self.call(changes, output: $stdout)
7
+ def self.call(changes, suspicious: [], output: $stdout)
8
+ if suspicious.any?
9
+ output.puts " suspicious changes (refresh writes them only with FORCE=1): #{suspicious.size}"
10
+ suspicious.each { |finding| output.puts " - #{finding}" }
11
+ end
12
+
8
13
  service_changes = changes["service_charges"]
9
14
  model_changes = changes.except("service_charges")
10
15
 
@@ -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]
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../currency"
4
+
5
+ module LlmCostTracker
6
+ module Pricing
7
+ module Sync
8
+ module SnapshotGuard
9
+ class << self
10
+ def call(current:, remote:, changes:)
11
+ findings = changes.except("service_charges").flat_map do |model, fields|
12
+ next [] unless remote["models"].key?(model)
13
+
14
+ fields.map { |field, values| finding("#{model} #{field}", field, values) }
15
+ end
16
+ changes.fetch("service_charges", {}).each do |provider, components|
17
+ components.each { |component, values| findings << finding("#{provider}.#{component}", component, values) }
18
+ end
19
+ (findings << currency_finding(current, remote)).compact
20
+ end
21
+
22
+ private
23
+
24
+ def finding(label, field, values)
25
+ from, to = values.values_at("from", "to").map { |value| value&.to_f }
26
+ "#{label}: #{from.inspect} -> #{to.inspect}" if suspicious?(field, from, to)
27
+ end
28
+
29
+ def suspicious?(field, from, to)
30
+ return %w[input output].include?(field) && from.positive? if to.nil?
31
+ return false if from.nil?
32
+ return true if from.zero? || to.zero?
33
+
34
+ [to / from, from / to].max >= 100
35
+ end
36
+
37
+ def currency_finding(current, remote)
38
+ return if current.empty?
39
+
40
+ from, to = [current, remote].map { |registry| registry.dig("metadata", "currency") || DEFAULT_CURRENCY }
41
+ "currency: #{from} -> #{to}" unless from.upcase == to.upcase
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -9,6 +9,7 @@ require_relative "registry"
9
9
  require_relative "sync/fetcher"
10
10
  require_relative "sync/registry_diff"
11
11
  require_relative "sync/registry_writer"
12
+ require_relative "sync/snapshot_guard"
12
13
 
13
14
  module LlmCostTracker
14
15
  module Pricing
@@ -18,8 +19,8 @@ module LlmCostTracker
18
19
  "https://raw.githubusercontent.com/sergey-homenko/llm_cost_tracker/main/lib/llm_cost_tracker/prices.json"
19
20
  SUPPORTED_SCHEMA_VERSION = 1
20
21
 
21
- RefreshResult = Data.define(:path, :source_url, :source_version, :changes, :written, :not_modified)
22
- CheckResult = Data.define(:path, :source_url, :source_version, :changes, :up_to_date)
22
+ RefreshResult = Data.define(:path, :source_url, :source_version, :changes, :suspicious, :written, :not_modified)
23
+ CheckResult = Data.define(:path, :source_url, :source_version, :changes, :suspicious, :up_to_date)
23
24
 
24
25
  class << self
25
26
  def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
@@ -39,62 +40,36 @@ module LlmCostTracker
39
40
  def refresh(path: DEFAULT_OUTPUT_PATH,
40
41
  url: DEFAULT_REMOTE_URL,
41
42
  preview: false,
43
+ force: false,
42
44
  fetcher: Fetcher.new,
43
45
  today: Date.today)
44
- current = load_registry(path)
45
- response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
46
-
47
- if response.not_modified
48
- return refresh_result(
49
- path: path,
50
- url: url,
51
- response: response,
52
- current: current,
53
- remote: current,
54
- written: false,
55
- not_modified: true
56
- )
57
- end
58
-
59
- remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
60
- unless preview
46
+ response, remote, changes, suspicious = compare(path, url, fetcher, today)
47
+ written = !preview && !response.not_modified
48
+ if written
49
+ refuse_suspicious_snapshot!(path, suspicious) unless force || suspicious.empty?
61
50
  RegistryWriter.new.call(path: path, registry: remote)
62
51
  Pricing::Registry.reset!
63
52
  end
64
- refresh_result(
53
+ RefreshResult.new(
65
54
  path: path,
66
- url: url,
67
- response: response,
68
- current: current,
69
- remote: remote,
70
- written: !preview,
71
- not_modified: false
55
+ source_url: Redaction.text(url),
56
+ source_version: response.source_version,
57
+ changes: changes,
58
+ suspicious: suspicious,
59
+ written: written,
60
+ not_modified: response.not_modified
72
61
  )
73
62
  end
74
63
 
75
64
  def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
76
- current = load_registry(path)
77
- response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
78
-
79
- if response.not_modified
80
- return CheckResult.new(
81
- path: path,
82
- source_url: url,
83
- source_version: response.source_version,
84
- changes: {},
85
- up_to_date: true
86
- )
87
- end
88
-
89
- remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
90
- changes = registry_changes(current, remote)
91
-
65
+ response, _remote, changes, suspicious = compare(path, url, fetcher, today)
92
66
  CheckResult.new(
93
67
  path: path,
94
- source_url: url,
68
+ source_url: Redaction.text(url),
95
69
  source_version: response.source_version,
96
70
  changes: changes,
97
- up_to_date: changes.empty?
71
+ suspicious: suspicious,
72
+ up_to_date: changes.empty? && suspicious.empty?
98
73
  )
99
74
  end
100
75
 
@@ -128,7 +103,7 @@ module LlmCostTracker
128
103
  "metadata" => metadata.merge(
129
104
  "schema_version" => schema_version,
130
105
  "updated_at" => metadata["updated_at"] || today.iso8601,
131
- "source_url" => url,
106
+ "source_url" => Redaction.text(url),
132
107
  "source_version" => response.source_version
133
108
  ),
134
109
  "models" => models
@@ -139,6 +114,16 @@ module LlmCostTracker
139
114
  raise Error, "Unable to load remote pricing snapshot: #{e.message}"
140
115
  end
141
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
+
142
127
  def load_registry(path)
143
128
  return {} unless File.exist?(path)
144
129
 
@@ -156,20 +141,18 @@ module LlmCostTracker
156
141
  raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
157
142
  end
158
143
 
159
- def refresh_result(path:, url:, response:, current:, remote:, written:, not_modified:)
160
- RefreshResult.new(
161
- path: path,
162
- source_url: url,
163
- source_version: response.source_version,
164
- changes: registry_changes(current, remote),
165
- written: written,
166
- not_modified: not_modified
167
- )
144
+ def refuse_suspicious_snapshot!(path, suspicious)
145
+ listed = suspicious.first(20).map { |finding| "\n - #{finding}" }.join
146
+ raise Error,
147
+ "Refusing to write pricing file #{path}: the remote snapshot has #{suspicious.size} " \
148
+ "suspicious price change(s):#{listed}\n" \
149
+ "Review them with llm_cost_tracker:prices:check, " \
150
+ "then refresh with FORCE=1 (force: true) to accept them."
168
151
  end
169
152
 
170
153
  def registry_changes(current, remote)
171
154
  model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
172
- charge_changes = service_charges_diff(
155
+ charge_changes = RegistryDiff.nested(
173
156
  current.fetch("service_charges", {}),
174
157
  remote.fetch("service_charges", {})
175
158
  )
@@ -177,21 +160,6 @@ module LlmCostTracker
177
160
 
178
161
  model_changes.merge("service_charges" => charge_changes)
179
162
  end
180
-
181
- def service_charges_diff(current, remote)
182
- (current.keys | remote.keys).sort.each_with_object({}) do |provider, changes|
183
- current_rates = (current[provider] || {}).transform_keys(&:to_s)
184
- remote_rates = (remote[provider] || {}).transform_keys(&:to_s)
185
- (current_rates.keys | remote_rates.keys).sort.each_with_object(changes) do |component, _|
186
- from = current_rates[component]
187
- to = remote_rates[component]
188
- next if from == to
189
-
190
- changes[provider] ||= {}
191
- changes[provider][component] = { "from" => from, "to" => to }
192
- end
193
- end
194
- end
195
163
  end
196
164
  end
197
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)
@@ -38,6 +34,25 @@ module LlmCostTracker
38
34
  match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
39
35
  match && match[1]
40
36
  end
37
+
38
+ def auto_enable_stream_usage?(request_url, request_parsed)
39
+ super && stream_options_api?(parsed_uri(request_url)) &&
40
+ !request_parsed.key?("data_sources") && !image_input?(request_parsed)
41
+ end
42
+
43
+ private
44
+
45
+ def stream_options_api?(uri)
46
+ uri.path.start_with?("/openai/v1/") ||
47
+ uri.query.to_s[/api-version=(\d{4}-\d{2}-\d{2})/, 1].to_s >= "2024-06-01"
48
+ end
49
+
50
+ def image_input?(request)
51
+ Array(request["messages"]).any? do |message|
52
+ message.is_a?(Hash) &&
53
+ Array(message["content"]).any? { |part| part.is_a?(Hash) && part["type"] == "image_url" }
54
+ end
55
+ end
41
56
  end
42
57
  end
43
58
  end