llm_cost_tracker 0.13.0 → 0.14.1

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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -16
  3. data/README.md +17 -29
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
  5. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  8. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  10. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
  11. data/app/models/llm_cost_tracker/call.rb +10 -4
  12. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  13. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
  14. data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
  15. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  16. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  17. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  18. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  19. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  20. data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
  21. data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
  22. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  23. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  26. data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
  27. data/config/routes.rb +6 -1
  28. data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
  29. data/lib/llm_cost_tracker/budget.rb +120 -32
  30. data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
  31. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  32. data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
  33. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
  35. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  36. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  37. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  38. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  39. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  40. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  41. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  42. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  43. data/lib/llm_cost_tracker/configuration.rb +69 -125
  44. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  45. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  46. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
  47. data/lib/llm_cost_tracker/doctor.rb +4 -4
  48. data/lib/llm_cost_tracker/engine.rb +4 -0
  49. data/lib/llm_cost_tracker/errors.rb +25 -3
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  59. data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
  60. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  61. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  62. data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
  63. data/lib/llm_cost_tracker/integrations/base.rb +17 -1
  64. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  65. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
  66. data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
  67. data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
  68. data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
  69. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  70. data/lib/llm_cost_tracker/ledger/store.rb +21 -13
  71. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  72. data/lib/llm_cost_tracker/ledger.rb +1 -0
  73. data/lib/llm_cost_tracker/logging.rb +5 -5
  74. data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
  75. data/lib/llm_cost_tracker/parsers.rb +5 -1
  76. data/lib/llm_cost_tracker/prices.json +2089 -372
  77. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  78. data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
  79. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  80. data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
  81. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  82. data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
  83. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  84. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  85. data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
  86. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  87. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  88. data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
  89. data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
  90. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  91. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
  92. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
  93. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
  94. data/lib/llm_cost_tracker/railtie.rb +3 -7
  95. data/lib/llm_cost_tracker/redaction.rb +32 -0
  96. data/lib/llm_cost_tracker/report/data.rb +2 -2
  97. data/lib/llm_cost_tracker/retention.rb +22 -10
  98. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  99. data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
  100. data/lib/llm_cost_tracker/tracker.rb +16 -26
  101. data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
  102. data/lib/llm_cost_tracker/version.rb +1 -1
  103. data/lib/llm_cost_tracker.rb +10 -3
  104. data/lib/tasks/llm_cost_tracker.rake +29 -14
  105. metadata +37 -12
@@ -4,6 +4,7 @@ require_relative "../pricing"
4
4
  require_relative "../charges/line_item"
5
5
  require_relative "../ledger/rollups"
6
6
  require_relative "../usage/token_usage"
7
+ require_relative "../budget/per_tag"
7
8
 
8
9
  module LlmCostTracker
9
10
  module Pricing
@@ -30,7 +31,7 @@ module LlmCostTracker
30
31
  rollup_events << rollup_event_for(call, calculation)
31
32
  recomputed += 1
32
33
  end
33
- Ledger::Rollups.increment!(rollup_events) if rollup_events.any?
34
+ Ledger::Rollups.increment!(rollup_events)
34
35
  end
35
36
  end
36
37
 
@@ -61,6 +62,7 @@ module LlmCostTracker
61
62
  pricing_snapshot: calculation.snapshot,
62
63
  cost_status: calculation.cost_status
63
64
  )
65
+ resync_tag_costs(call, calculation.cost.total)
64
66
  token_priced = calculation.priced_line_items.select(&:token?).index_by { |item| dimension_key(item) }
65
67
  service_priced = calculation.priced_line_items.reject(&:token?)
66
68
  token_records, service_records = call.line_items.partition { |record| record.unit == "token" }
@@ -69,6 +71,14 @@ module LlmCostTracker
69
71
  service_records.sort_by(&:position).zip(service_priced).each { |record, priced| apply_rate(record, priced) }
70
72
  end
71
73
 
74
+ def resync_tag_costs(call, total_cost)
75
+ return unless LlmCostTracker::Budget::PerTag.columns?
76
+
77
+ LlmCostTracker::CallTag
78
+ .where(llm_cost_tracker_call_id: call.id)
79
+ .update_all(total_cost: total_cost)
80
+ end
81
+
72
82
  def apply_rate(record, priced)
73
83
  return unless priced
74
84
 
@@ -187,16 +187,16 @@ module LlmCostTracker
187
187
  price = effective[dimension.key]
188
188
  return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) if price.nil?
189
189
 
190
- line_item.with_rate(token_rate(dimension, price))
190
+ line_item.with_rate(token_rate(price))
191
191
  end
192
192
 
193
- def token_rate(dimension, price)
193
+ def token_rate(price)
194
194
  Pricing::Rate.new(
195
- amount: price.to_d,
195
+ amount: price.amount.to_d,
196
196
  quantity: RATE_DENOMINATOR_TOKENS.to_d,
197
197
  currency: match.source.currency,
198
198
  source: match.source.name,
199
- source_key: dimension.key,
199
+ source_key: price.key,
200
200
  source_version: match.source.version
201
201
  )
202
202
  end
@@ -8,38 +8,49 @@ require_relative "price_key"
8
8
  module LlmCostTracker
9
9
  module Pricing
10
10
  module EffectivePrices
11
+ Resolved = Data.define(:amount, :key)
12
+
11
13
  class << self
12
14
  def call(usage:, quantities:, prices:, pricing_mode:)
13
15
  context_tier = context_tier?(usage: usage, prices: prices)
14
16
  orderings = pricing_mode && Mode.permutations_for(pricing_mode)
15
17
 
16
18
  quantities.to_h do |price_key, tokens|
17
- price = if tokens.positive?
18
- price_for(
19
- prices: prices,
20
- key: price_key,
21
- orderings: orderings,
22
- context_tier: context_tier
23
- )
24
- else
25
- BigDecimal("0")
26
- end
27
- [price_key, price]
19
+ resolved = if tokens.positive?
20
+ price_for(
21
+ prices: prices,
22
+ key: price_key,
23
+ orderings: orderings,
24
+ context_tier: context_tier
25
+ )
26
+ else
27
+ Resolved.new(amount: BigDecimal("0"), key: price_key)
28
+ end
29
+ [price_key, resolved]
28
30
  end
29
31
  end
30
32
 
31
33
  private
32
34
 
33
35
  def price_for(prices:, key:, orderings:, context_tier:)
34
- return prices[PriceKey.build(key, above_context: context_tier)] unless orderings
36
+ unless orderings
37
+ standard = PriceKey.build(key, above_context: context_tier)
38
+ return resolve(prices[standard], standard)
39
+ end
35
40
 
36
41
  orderings.each do |mode|
37
- direct = prices[PriceKey.build(key, mode: mode, above_context: context_tier)]
38
- return direct if direct
42
+ table_key = PriceKey.build(key, mode: mode, above_context: context_tier)
43
+ direct = prices[table_key]
44
+ return resolve(direct, table_key) if direct
39
45
  end
40
46
  return nil if %w[input output].include?(key)
41
47
 
42
- derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
48
+ derived = derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
49
+ resolve(derived, nil)
50
+ end
51
+
52
+ def resolve(amount, key)
53
+ amount && Resolved.new(amount: amount, key: key)
43
54
  end
44
55
 
45
56
  def derived_mode_price(prices:, key:, modes:, context_tier:)
@@ -27,6 +27,13 @@ module LlmCostTracker
27
27
  @cache[key] = lookup_match(sources, provider_name, model_name)
28
28
  end
29
29
 
30
+ def modifier_priced?(provider:, model:, modifier:)
31
+ prices = lookup(provider: provider, model: model)&.prices
32
+ return false unless prices
33
+
34
+ prices.any? { |key, _| key.to_s.include?(modifier) }
35
+ end
36
+
30
37
  private
31
38
 
32
39
  def reset_cache(sources)
@@ -9,8 +9,7 @@ module LlmCostTracker
9
9
  "per_1k_requests" => 1_000,
10
10
  "per_session" => 1,
11
11
  "per_hour" => 1,
12
- "per_minute" => 1,
13
- "per_image" => 1
12
+ "per_minute" => 1
14
13
  }.freeze
15
14
 
16
15
  Rate = Data.define(:amount, :quantity, :currency, :source, :source_key, :source_version)
@@ -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"
@@ -101,7 +107,7 @@ module LlmCostTracker
101
107
  end
102
108
 
103
109
  def prices_file_mtime_iso
104
- path = LlmCostTracker.configuration.prices_file
110
+ path = LlmCostTracker.configuration.pricing.file
105
111
  return nil unless path && File.exist?(path)
106
112
 
107
113
  @prices_file_mtime_iso ||= File.mtime(path).utc.iso8601
@@ -113,16 +119,16 @@ module LlmCostTracker
113
119
  [
114
120
  Source.new(
115
121
  name: "pricing_overrides",
116
- prices: config.pricing_overrides,
122
+ prices: config.pricing.overrides,
117
123
  rates: {},
118
124
  currency: upcased_currency(nil),
119
125
  version: "configuration"
120
126
  ),
121
127
  Source.new(
122
128
  name: "prices_file",
123
- prices: file_prices(config.prices_file),
124
- rates: file_rates(config.prices_file),
125
- currency: upcased_currency(file_metadata(config.prices_file)["currency"]),
129
+ prices: file_prices(config.pricing.file),
130
+ rates: file_rates(config.pricing.file),
131
+ currency: upcased_currency(file_metadata(config.pricing.file)["currency"]),
126
132
  version: prices_file_mtime_iso
127
133
  ),
128
134
  Source.new(
@@ -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
 
@@ -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
 
@@ -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,15 +19,15 @@ 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)
26
27
  output = env["OUTPUT"].to_s.strip.presence
27
28
  return output if output
28
29
 
29
- prices_file = config.prices_file
30
+ prices_file = config.pricing.file
30
31
  return prices_file.to_s if prices_file
31
32
 
32
33
  Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
@@ -39,6 +40,7 @@ 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
46
  current = load_registry(path)
@@ -49,15 +51,18 @@ module LlmCostTracker
49
51
  path: path,
50
52
  url: url,
51
53
  response: response,
52
- current: current,
53
- remote: current,
54
+ changes: registry_changes(current, current),
55
+ suspicious: [],
54
56
  written: false,
55
57
  not_modified: true
56
58
  )
57
59
  end
58
60
 
59
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)
60
64
  unless preview
65
+ refuse_suspicious_snapshot!(path, suspicious) unless force || suspicious.empty?
61
66
  RegistryWriter.new.call(path: path, registry: remote)
62
67
  Pricing::Registry.reset!
63
68
  end
@@ -65,8 +70,8 @@ module LlmCostTracker
65
70
  path: path,
66
71
  url: url,
67
72
  response: response,
68
- current: current,
69
- remote: remote,
73
+ changes: changes,
74
+ suspicious: suspicious,
70
75
  written: !preview,
71
76
  not_modified: false
72
77
  )
@@ -79,22 +84,25 @@ module LlmCostTracker
79
84
  if response.not_modified
80
85
  return CheckResult.new(
81
86
  path: path,
82
- source_url: url,
87
+ source_url: Redaction.text(url),
83
88
  source_version: response.source_version,
84
89
  changes: {},
90
+ suspicious: [],
85
91
  up_to_date: true
86
92
  )
87
93
  end
88
94
 
89
95
  remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
90
96
  changes = registry_changes(current, remote)
97
+ suspicious = SnapshotGuard.call(current: current, remote: remote, changes: changes)
91
98
 
92
99
  CheckResult.new(
93
100
  path: path,
94
- source_url: url,
101
+ source_url: Redaction.text(url),
95
102
  source_version: response.source_version,
96
103
  changes: changes,
97
- up_to_date: changes.empty?
104
+ suspicious: suspicious,
105
+ up_to_date: changes.empty? && suspicious.empty?
98
106
  )
99
107
  end
100
108
 
@@ -128,7 +136,7 @@ module LlmCostTracker
128
136
  "metadata" => metadata.merge(
129
137
  "schema_version" => schema_version,
130
138
  "updated_at" => metadata["updated_at"] || today.iso8601,
131
- "source_url" => url,
139
+ "source_url" => Redaction.text(url),
132
140
  "source_version" => response.source_version
133
141
  ),
134
142
  "models" => models
@@ -156,17 +164,26 @@ module LlmCostTracker
156
164
  raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
157
165
  end
158
166
 
159
- def refresh_result(path:, url:, response:, current:, remote:, written:, not_modified:)
167
+ def refresh_result(path:, url:, response:, changes:, suspicious:, written:, not_modified:)
160
168
  RefreshResult.new(
161
169
  path: path,
162
- source_url: url,
170
+ source_url: Redaction.text(url),
163
171
  source_version: response.source_version,
164
- changes: registry_changes(current, remote),
172
+ changes: changes,
173
+ suspicious: suspicious,
165
174
  written: written,
166
175
  not_modified: not_modified
167
176
  )
168
177
  end
169
178
 
179
+ def refuse_suspicious_snapshot!(path, suspicious)
180
+ listed = suspicious.first(20).map { |finding| "\n - #{finding}" }.join
181
+ raise Error,
182
+ "Refusing to write pricing file #{path}: the remote snapshot has #{suspicious.size} " \
183
+ "suspicious price change(s):#{listed}\n" \
184
+ "Review them with PREVIEW=1, then refresh with FORCE=1 (force: true) to accept them."
185
+ end
186
+
170
187
  def registry_changes(current, remote)
171
188
  model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
172
189
  charge_changes = service_charges_diff(
@@ -9,14 +9,14 @@ module LlmCostTracker
9
9
  WARN_CACHE_LIMIT = 1024
10
10
 
11
11
  class << self
12
- def process(model)
12
+ def process(model, pricing_mode: nil)
13
13
  model = model.to_s.presence || Event::UNKNOWN_MODEL
14
14
 
15
- case LlmCostTracker.configuration.unknown_pricing_behavior
15
+ case LlmCostTracker.configuration.pricing.unknown_model_behavior
16
16
  when :ignore
17
17
  nil
18
18
  when :warn
19
- warn_missing(model)
19
+ warn_missing(model, pricing_mode.to_s.presence)
20
20
  when :raise
21
21
  raise UnknownPricingError.new(model: model)
22
22
  end
@@ -24,19 +24,22 @@ module LlmCostTracker
24
24
 
25
25
  private
26
26
 
27
- def warn_missing(model)
27
+ def warn_missing(model, pricing_mode)
28
+ key = [model, pricing_mode].freeze
28
29
  should_warn = MUTEX.synchronize do
29
30
  @warned_models ||= Set.new
30
- next false if @warned_models.size >= WARN_CACHE_LIMIT && !@warned_models.include?(model)
31
+ next false if @warned_models.size >= WARN_CACHE_LIMIT && !@warned_models.include?(key)
31
32
 
32
- @warned_models.add?(model)
33
+ @warned_models.add?(key)
33
34
  end
34
35
  return unless should_warn
35
36
 
37
+ subject = "model #{model.inspect}"
38
+ subject += " at pricing_mode #{pricing_mode.inspect}" if pricing_mode
36
39
  Logging.warn(
37
- "No pricing configured for model #{model.inspect}. " \
40
+ "No pricing configured for #{subject}. " \
38
41
  "Cost and budget guardrails will be skipped for this event. " \
39
- "Add a pricing_overrides entry or set unknown_pricing_behavior."
42
+ "Add a pricing.overrides entry or set pricing.unknown_model_behavior."
40
43
  )
41
44
  end
42
45
  end
@@ -22,12 +22,13 @@ module LlmCostTracker
22
22
  output_tokens: output,
23
23
  cache_read_input_tokens: cache_read,
24
24
  cache_write_input_tokens: cache_write,
25
- cache_write_extended_input_tokens: cache_write_extended
25
+ cache_write_extended_input_tokens: cache_write_extended,
26
+ hidden_output_tokens: usage.dig(:output_tokens_details, :thinking_tokens).to_i
26
27
  )
27
28
  end
28
29
 
29
30
  def self.pricing_mode(request:, usage:)
30
- speed = request&.dig(:speed)
31
+ speed = usage&.dig(:speed) || request&.dig(:speed)
31
32
  service_tier = usage&.dig(:service_tier) || request&.dig(:service_tier)
32
33
  geo = (usage&.dig(:inference_geo) || request&.dig(:inference_geo)).to_s.downcase
33
34
 
@@ -38,6 +38,25 @@ module LlmCostTracker
38
38
  match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
39
39
  match && match[1]
40
40
  end
41
+
42
+ def auto_enable_stream_usage?(request_url, request_parsed)
43
+ super && stream_options_api?(parsed_uri(request_url)) &&
44
+ !request_parsed.key?("data_sources") && !image_input?(request_parsed)
45
+ end
46
+
47
+ private
48
+
49
+ def stream_options_api?(uri)
50
+ uri.path.start_with?("/openai/v1/") ||
51
+ uri.query.to_s[/api-version=(\d{4}-\d{2}-\d{2})/, 1].to_s >= "2024-06-01"
52
+ end
53
+
54
+ def image_input?(request)
55
+ Array(request["messages"]).any? do |message|
56
+ message.is_a?(Hash) &&
57
+ Array(message["content"]).any? { |part| part.is_a?(Hash) && part["type"] == "image_url" }
58
+ end
59
+ end
41
60
  end
42
61
  end
43
62
  end
@@ -78,6 +78,10 @@ module LlmCostTracker
78
78
  extract_model_from_url(request_url)
79
79
  end
80
80
 
81
+ def retain_stream_event?(data)
82
+ data.is_a?(Hash) && grounding_request_count(data["candidates"]).positive?
83
+ end
84
+
81
85
  def provider_for(_request_url)
82
86
  "gemini"
83
87
  end
@@ -4,9 +4,6 @@ module LlmCostTracker
4
4
  module Providers
5
5
  module Openai
6
6
  module ModelFamilies
7
- DATA_RESIDENCY_MODEL_PATTERN =
8
- /\Agpt-5\.(?:4|5)(?:-(?:mini|nano|pro|codex(?:-mini|-max)?))?(?:-\d{4}-\d{2}-\d{2})?\z/
9
-
10
7
  IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
11
8
 
12
9
  CHARACTER_BILLED_TTS_MODEL_PATTERN = /\Atts-1(-hd)?\z/
@@ -19,10 +16,6 @@ module LlmCostTracker
19
16
  NON_REASONING_GPT5_PATTERN = /\Agpt-5(?:\.\d+)?-chat\b/i
20
17
 
21
18
  CHAT_COMPLETIONS_SEARCH_MODEL_PATTERN = /-search-(?:preview|api)\b/i
22
- def self.data_residency?(model)
23
- model.to_s.match?(DATA_RESIDENCY_MODEL_PATTERN)
24
- end
25
-
26
19
  def self.image_output?(model)
27
20
  model.to_s.match?(IMAGE_OUTPUT_MODEL_PATTERN)
28
21
  end
@@ -16,7 +16,10 @@ module LlmCostTracker
16
16
  class << self
17
17
  def combined_pricing_mode(host:, model:, service_tier:)
18
18
  modes = [Pricing::Mode.normalize(service_tier)]
19
- modes << "data_residency" if Hosts.data_residency?(host) && ModelFamilies.data_residency?(model)
19
+ if Hosts.data_residency?(host) &&
20
+ Pricing::Matcher.modifier_priced?(provider: "openai", model: model, modifier: "data_residency")
21
+ modes << "data_residency"
22
+ end
20
23
  Pricing::Mode.compose(modes)
21
24
  end
22
25
 
@@ -26,7 +29,7 @@ module LlmCostTracker
26
29
 
27
30
  model = response["model"] || request["model"]
28
31
  service_line_items =
29
- ServiceCharges.service_line_items_for(response, request: request, model: response["model"]) +
32
+ ServiceCharges.service_line_items_for(response, request: request, model: model) +
30
33
  ServiceCharges.transcription_line_items(usage)
31
34
  Event.build(
32
35
  provider: provider,
@@ -67,10 +70,14 @@ module LlmCostTracker
67
70
  build_unknown_stream_usage(**context)
68
71
  end
69
72
 
70
- def auto_enable_stream_usage?(request_url)
73
+ def auto_enable_stream_usage?(request_url, _request_parsed)
71
74
  openai_chat_completions_url?(request_url)
72
75
  end
73
76
 
77
+ def retain_stream_event?(data)
78
+ data.is_a?(Hash) && (data["item"].is_a?(Hash) || data["response"].is_a?(Hash))
79
+ end
80
+
74
81
  private
75
82
 
76
83
  def stream_capture_context(events:, request:, request_url:)
@@ -12,9 +12,11 @@ module LlmCostTracker
12
12
  input_tokens = (usage[:input_tokens] || usage[:prompt_tokens]).to_i
13
13
  output_tokens = (usage[:output_tokens] || usage[:completion_tokens]).to_i
14
14
  cache_read = cache_read_input_tokens(usage)
15
- audio_input = audio_input_tokens(usage)
15
+ cache_write = cache_write_input_tokens(usage)
16
+ uncached_input = [input_tokens - cache_read - cache_write, 0].max
17
+ image_input = uncached_input_tokens(usage, :image_tokens).clamp(0, uncached_input)
18
+ audio_input = uncached_input_tokens(usage, :audio_tokens).clamp(0, uncached_input - image_input)
16
19
  audio_output = audio_output_tokens(usage)
17
- image_input = image_input_tokens(usage)
18
20
  image_output, regular_output = split_output(
19
21
  output_tokens: output_tokens,
20
22
  image_output_details: image_output_tokens(usage),
@@ -24,10 +26,11 @@ module LlmCostTracker
24
26
  )
25
27
 
26
28
  Usage::TokenUsage.build(
27
- input_tokens: [input_tokens - cache_read - audio_input - image_input, 0].max,
29
+ input_tokens: uncached_input - audio_input - image_input,
28
30
  output_tokens: regular_output,
29
31
  total_tokens: usage[:total_tokens],
30
32
  cache_read_input_tokens: cache_read,
33
+ cache_write_input_tokens: cache_write,
31
34
  audio_input_tokens: audio_input,
32
35
  audio_output_tokens: audio_output,
33
36
  image_input_tokens: image_input,
@@ -46,12 +49,11 @@ module LlmCostTracker
46
49
  return default_to_image ? [remainder, 0] : [0, remainder]
47
50
  end
48
51
 
49
- text_output = text_output_details
50
- text_output = [output_tokens - image_output_details - audio_output, 0].max if text_output.zero?
51
- [image_output_details, text_output]
52
+ [image_output_details, [output_tokens - image_output_details - audio_output, 0].max]
52
53
  end
53
54
 
54
55
  def self.cache_read_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens)
56
+ def self.cache_write_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cache_write_tokens)
55
57
  def self.hidden_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :reasoning_tokens)
56
58
  def self.audio_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :audio_tokens)
57
59
  def self.audio_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :audio_tokens)
@@ -59,9 +61,13 @@ module LlmCostTracker
59
61
  def self.image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
60
62
  def self.text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
61
63
 
62
- def self.detail(usage, containers, key)
64
+ def self.uncached_input_tokens(usage, key)
65
+ detail(usage, INPUT_DETAIL_KEYS, key) - detail(usage, INPUT_DETAIL_KEYS, :cached_tokens_details, key)
66
+ end
67
+
68
+ def self.detail(usage, containers, *path)
63
69
  containers.each do |container|
64
- value = usage.dig(container, key)
70
+ value = usage.dig(container, *path)
65
71
  return value.to_i if value
66
72
  end
67
73
  0
@@ -14,7 +14,7 @@ module LlmCostTracker
14
14
  end
15
15
 
16
16
  def provider_names
17
- custom = LlmCostTracker.configuration.openai_compatible_providers.each_value.map do |provider|
17
+ custom = LlmCostTracker.configuration.capture.openai_compatible_providers.each_value.map do |provider|
18
18
  provider.to_s.downcase
19
19
  end
20
20
  ["openai_compatible", *custom].uniq
@@ -23,13 +23,17 @@ module LlmCostTracker
23
23
  def provider_for_uri(uri)
24
24
  return nil unless uri
25
25
 
26
- LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
26
+ LlmCostTracker.configuration.capture.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
27
27
  end
28
28
  end
29
29
 
30
30
  def provider_for(request_url)
31
31
  self.class.provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
32
32
  end
33
+
34
+ def auto_enable_stream_usage?(request_url, request_parsed)
35
+ super && Configuration::Capture::OPENAI_COMPATIBLE_PROVIDERS.key?(parsed_uri(request_url).host.to_s.downcase)
36
+ end
33
37
  end
34
38
  end
35
39
  end