llm_cost_tracker 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
@@ -1,23 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
- require "bigdecimal/util"
5
4
  require "yaml"
6
5
 
7
6
  require_relative "../usage/catalog"
8
- require_relative "../pricing/rate"
9
7
  require_relative "../logging"
10
- require_relative "mode"
11
- require_relative "price_key"
12
8
  require_relative "source"
9
+ require_relative "registry/normalizer"
13
10
 
14
11
  module LlmCostTracker
15
12
  module Pricing
16
13
  module Registry
17
14
  DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
18
15
  CONTEXT_THRESHOLD_KEY = "_context_price_threshold_tokens"
16
+ OFF_PEAK_WINDOWS_KEY = "_off_peak_windows"
17
+ MINIMUM_BILLED_SECONDS_KEY = "_minimum_billed_seconds"
19
18
  PRICE_KEYS = Usage::Catalog.all.select(&:token?).map(&:key).freeze
20
- METADATA_KEYS = ["_source", CONTEXT_THRESHOLD_KEY].freeze
19
+ METADATA_KEYS = ["_source", CONTEXT_THRESHOLD_KEY, OFF_PEAK_WINDOWS_KEY, MINIMUM_BILLED_SECONDS_KEY].freeze
21
20
 
22
21
  class << self
23
22
  def reset!
@@ -66,13 +65,7 @@ module LlmCostTracker
66
65
  end
67
66
 
68
67
  def normalize_price_entries(table, context:)
69
- table = {} if table.nil?
70
- raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
71
-
72
- table.each_with_object({}) do |(model, price), normalized|
73
- price = validate_price_entry(price, model: model, context: context)
74
- normalized[model.to_s] = normalize_price_entry(model, price, context)
75
- end
68
+ Normalizer.new(context).prices(table)
76
69
  end
77
70
 
78
71
  def raw_registry
@@ -96,14 +89,7 @@ module LlmCostTracker
96
89
  end
97
90
 
98
91
  def rates_from_registry(registry, context:)
99
- data = registry.fetch("service_charges", {})
100
- raise ArgumentError, "#{context} service_charges must be a hash" unless data.is_a?(Hash)
101
-
102
- currency = upcased_currency(registry.dig("metadata", "currency"))
103
- data.each_with_object({}) do |(provider, entries), rates|
104
- section_context = "#{context} service_charges.#{provider}"
105
- rates[provider] = rates_from_section(entries, currency: currency, context: section_context)
106
- end
92
+ Normalizer.new(context).rates(registry)
107
93
  end
108
94
 
109
95
  def prices_file_mtime_iso
@@ -114,32 +100,7 @@ module LlmCostTracker
114
100
  end
115
101
 
116
102
  def sources
117
- @sources ||= begin
118
- config = LlmCostTracker.configuration
119
- [
120
- Source.new(
121
- name: "pricing_overrides",
122
- prices: config.pricing.overrides,
123
- rates: {},
124
- currency: upcased_currency(nil),
125
- version: "configuration"
126
- ),
127
- Source.new(
128
- name: "prices_file",
129
- prices: file_prices(config.pricing.file),
130
- rates: file_rates(config.pricing.file),
131
- currency: upcased_currency(file_metadata(config.pricing.file)["currency"]),
132
- version: prices_file_mtime_iso
133
- ),
134
- Source.new(
135
- name: "bundled",
136
- prices: builtin_prices,
137
- rates: builtin_rates,
138
- currency: upcased_currency(metadata["currency"]),
139
- version: LlmCostTracker::VERSION
140
- )
141
- ].freeze
142
- end
103
+ @sources ||= [overrides_source, file_source, bundled_source].freeze
143
104
  end
144
105
 
145
106
  def sorted_price_keys(table)
@@ -150,6 +111,37 @@ module LlmCostTracker
150
111
 
151
112
  private
152
113
 
114
+ def overrides_source
115
+ Source.new(
116
+ name: "pricing_overrides",
117
+ prices: LlmCostTracker.configuration.pricing.overrides,
118
+ rates: {},
119
+ currency: upcased_currency(nil),
120
+ version: "configuration"
121
+ )
122
+ end
123
+
124
+ def file_source
125
+ path = LlmCostTracker.configuration.pricing.file
126
+ Source.new(
127
+ name: "prices_file",
128
+ prices: file_prices(path),
129
+ rates: file_rates(path),
130
+ currency: upcased_currency(file_metadata(path)["currency"]),
131
+ version: prices_file_mtime_iso
132
+ )
133
+ end
134
+
135
+ def bundled_source
136
+ Source.new(
137
+ name: "bundled",
138
+ prices: builtin_prices,
139
+ rates: builtin_rates,
140
+ currency: upcased_currency(metadata["currency"]),
141
+ version: LlmCostTracker::VERSION
142
+ )
143
+ end
144
+
153
145
  def memoize_in(cache, key, identity: false)
154
146
  existing = cache && cache[key]
155
147
  return [existing, cache] if existing
@@ -182,75 +174,10 @@ module LlmCostTracker
182
174
  end
183
175
  end
184
176
 
185
- def normalize_price_entry(model, price, context)
186
- unknown = []
187
- normalized = price.each_with_object({}) do |(key, value), acc|
188
- if key.to_s == CONTEXT_THRESHOLD_KEY
189
- acc[CONTEXT_THRESHOLD_KEY] = Integer(value)
190
- elsif (registry_key = PriceKey.price_key_for(key))
191
- acc[registry_key] = non_negative_decimal(value, label: "price for #{registry_key.inspect}")
192
- elsif !METADATA_KEYS.include?(key)
193
- unknown << key
194
- end
195
- end
196
- warn_unknown_keys(model, unknown, context) unless unknown.empty?
197
- normalized
198
- end
199
-
200
- def non_negative_decimal(value, label:)
201
- decimal = BigDecimal(value.to_s)
202
- raise ArgumentError, "#{label} must be finite (got #{value})" unless decimal.finite?
203
- raise ArgumentError, "#{label} must be non-negative (got #{value})" if decimal.negative?
204
-
205
- decimal
206
- end
207
-
208
- def warn_unknown_keys(model, unknown_keys, path)
209
- Logging.warn(
210
- "Unknown price keys #{unknown_keys.inspect} for #{model.inspect} in #{path}; " \
211
- "ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
212
- )
213
- end
214
-
215
- def validate_price_entry(price, model:, context:)
216
- return {} if price.nil?
217
- return price if price.is_a?(Hash)
218
-
219
- raise ArgumentError, "price entry for #{model.inspect} in #{context} must be a hash"
220
- end
221
-
222
177
  def load_file_rates(path)
223
178
  loading(path) { rates_from_registry(raw_file_registry(path), context: path).freeze }
224
179
  end
225
180
 
226
- def rates_from_section(entries, currency:, context:)
227
- raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
228
-
229
- entries.each_with_object({}) do |(key, amount), rates|
230
- key = key.to_s
231
- dimension, tier = dimension_and_tier_for(key, context: context)
232
- amount = non_negative_decimal(amount, label: "service charge price amount for #{key.inspect} in #{context}")
233
-
234
- rate = {
235
- amount: amount,
236
- quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
237
- currency: currency,
238
- source_key: key
239
- }
240
- dimension_rates = rates[dimension.key] ||= { tiers: {} }
241
- (tier ? dimension_rates[:tiers] : dimension_rates)[tier || :default] = rate
242
- end
243
- end
244
-
245
- def dimension_and_tier_for(key, context:)
246
- dimension, tier = PriceKey.parse_dimension_key(key)
247
- unless dimension && dimension.token_key.nil?
248
- raise ArgumentError, "service charge price key #{key.inspect} in #{context} uses unknown billing dimension"
249
- end
250
-
251
- [dimension, tier]
252
- end
253
-
254
181
  def upcased_currency(value)
255
182
  (value || LlmCostTracker::DEFAULT_CURRENCY).upcase
256
183
  end
@@ -3,34 +3,54 @@
3
3
  module LlmCostTracker
4
4
  module Pricing
5
5
  module Sync
6
- module ChangePrinter
6
+ class ChangePrinter
7
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
8
+ new(output).call(changes, suspicious)
9
+ end
10
+
11
+ def initialize(output)
12
+ @output = output
13
+ end
14
+
15
+ def call(changes, suspicious)
16
+ print_suspicious(suspicious)
17
+ print_models(changes.except("service_charges"))
18
+ print_service_charges(changes["service_charges"])
19
+ end
20
+
21
+ private
12
22
 
13
- service_changes = changes["service_charges"]
14
- model_changes = changes.except("service_charges")
23
+ def print_suspicious(findings)
24
+ return unless findings.any?
15
25
 
16
- output.puts " changed models: #{model_changes.size}"
17
- model_changes.each do |model, fields|
18
- output.puts " - #{model}"
19
- fields.each do |field, values|
20
- output.puts " #{field}: #{values['from'].inspect} -> #{values['to'].inspect}"
21
- end
26
+ @output.puts " suspicious changes (refresh writes them only with FORCE=1): #{findings.size}"
27
+ findings.each { |finding| @output.puts " - #{finding}" }
28
+ end
29
+
30
+ def print_models(changes)
31
+ @output.puts " changed models: #{changes.size}"
32
+ changes.each do |model, fields|
33
+ @output.puts " - #{model}"
34
+ fields.each { |field, values| @output.puts " #{field}: #{transition(values)}" }
22
35
  end
36
+ end
23
37
 
24
- return if service_changes.nil? || service_changes.empty?
38
+ def print_service_charges(changes)
39
+ return if changes.nil? || changes.empty?
25
40
 
26
- output.puts " changed service charges: #{service_changes.values.sum(&:size)}"
27
- service_changes.each do |provider, components|
28
- components.each do |component, values|
29
- output.puts " - #{provider}.#{component}: " \
30
- "#{values['from'].inspect} -> #{values['to'].inspect}"
31
- end
41
+ @output.puts " changed service charges: #{changes.values.sum(&:size)}"
42
+ changes.each do |provider, components|
43
+ components.each { |component, values| @output.puts " - #{provider}.#{component}: #{transition(values)}" }
32
44
  end
33
45
  end
46
+
47
+ def transition(values)
48
+ "#{shown(values['from'])} -> #{shown(values['to'])}"
49
+ end
50
+
51
+ def shown(value)
52
+ value.is_a?(BigDecimal) ? value.to_s("F") : value.inspect
53
+ end
34
54
  end
35
55
  end
36
56
  end
@@ -26,55 +26,57 @@ module LlmCostTracker
26
26
  READ_TIMEOUT = 10
27
27
  WRITE_TIMEOUT = 10
28
28
 
29
+ def self.scrub_url(url)
30
+ uri = URI.parse(url.to_s)
31
+ uri.user = nil
32
+ uri.password = nil
33
+ uri.query = nil
34
+ uri.fragment = nil
35
+ uri.to_s
36
+ rescue URI::InvalidURIError
37
+ "[invalid url]"
38
+ end
39
+
29
40
  def get(url, etag: nil, redirects: 0)
30
- safe_url = scrub_url(url)
41
+ safe_url = self.class.scrub_url(url)
31
42
  raise Error, "Too many redirects while fetching #{safe_url}" if redirects > MAX_REDIRECTS
32
43
 
33
- uri = URI.parse(url)
34
- raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
35
-
36
- request = Net::HTTP::Get.new(uri)
37
- request["User-Agent"] = USER_AGENT
38
- request["If-None-Match"] = etag if etag
39
-
40
- response, body = fetch_response(uri, request)
41
-
44
+ response, body = fetch_response(https_uri(url), etag)
42
45
  case response
43
- when Net::HTTPSuccess
44
- build_response(response, body: body, not_modified: false)
45
- when Net::HTTPNotModified
46
- build_response(response, body: nil, not_modified: true)
47
- when Net::HTTPRedirection
48
- location = response["location"]
49
- raise Error, "Redirect without location while fetching #{safe_url}" if location.blank?
50
-
51
- get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
52
- else
53
- raise Error, "Unable to fetch #{safe_url}: HTTP #{response.code}"
46
+ when Net::HTTPSuccess then build_response(response, body: body, not_modified: false)
47
+ when Net::HTTPNotModified then build_response(response, body: nil, not_modified: true)
48
+ when Net::HTTPRedirection then get(redirect_url(url, response), etag: etag, redirects: redirects + 1)
49
+ else raise Error, "Unable to fetch #{safe_url}: HTTP #{response.code}"
54
50
  end
55
51
  rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
56
- raise Error, "Unable to fetch #{scrub_url(url)}: #{e.class}: #{e.message}"
52
+ raise Error, "Unable to fetch #{self.class.scrub_url(url)}: #{e.class}: #{e.message}"
57
53
  end
58
54
 
59
55
  private
60
56
 
61
- def scrub_url(url)
62
- uri = URI.parse(url.to_s)
63
- uri.user = nil
64
- uri.password = nil
65
- uri.query = nil
66
- uri.fragment = nil
67
- uri.to_s
68
- rescue URI::InvalidURIError
69
- "[invalid url]"
57
+ def https_uri(url)
58
+ uri = URI.parse(url)
59
+ raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
60
+
61
+ uri
62
+ end
63
+
64
+ def redirect_url(url, response)
65
+ location = response["location"]
66
+ raise Error, "Redirect without location while fetching #{self.class.scrub_url(url)}" if location.blank?
67
+
68
+ URI.join(url, location).to_s
70
69
  end
71
70
 
72
- def fetch_response(uri, request)
71
+ def fetch_response(uri, etag)
72
+ request = Net::HTTP::Get.new(uri)
73
+ request["User-Agent"] = USER_AGENT
74
+ request["If-None-Match"] = etag if etag
73
75
  body = nil
74
76
  response = Net::HTTP.start(
75
77
  uri.host,
76
78
  uri.port,
77
- use_ssl: uri.scheme == "https",
79
+ use_ssl: true,
78
80
  open_timeout: OPEN_TIMEOUT,
79
81
  read_timeout: READ_TIMEOUT,
80
82
  write_timeout: WRITE_TIMEOUT
@@ -83,7 +85,6 @@ module LlmCostTracker
83
85
  body = limited_body(streamed_response) if streamed_response.is_a?(Net::HTTPSuccess)
84
86
  end
85
87
  end
86
-
87
88
  [response, body]
88
89
  end
89
90
 
@@ -5,6 +5,8 @@ require "fileutils"
5
5
  require "json"
6
6
  require "yaml"
7
7
 
8
+ require_relative "../registry"
9
+
8
10
  module LlmCostTracker
9
11
  module Pricing
10
12
  module Sync
@@ -12,6 +14,10 @@ module LlmCostTracker
12
14
  YAML_EXTENSIONS = %w[.yml .yaml].freeze
13
15
  MANUAL_SOURCE = "manual"
14
16
 
17
+ def initialize(keep_unlisted: true)
18
+ @keep_unlisted = keep_unlisted
19
+ end
20
+
15
21
  def call(path:, registry:)
16
22
  payload = render(path: path, registry: registry)
17
23
  FileUtils.mkdir_p(File.dirname(path))
@@ -23,23 +29,9 @@ module LlmCostTracker
23
29
  end
24
30
 
25
31
  def render(path:, registry:)
26
- merged = canonicalize(merge_with_existing(path: path, registry: registry))
27
- yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
28
- end
29
-
30
- private
31
-
32
- def canonicalize(value)
33
- case value
34
- when Hash
35
- value.sort_by { |key, _| key.to_s }.to_h { |key, nested| [key, canonicalize(nested)] }
36
- when Array
37
- value.map { |element| canonicalize(element) }
38
- when BigDecimal
39
- value.to_f
40
- else
41
- value
42
- end
32
+ json = !yaml_file?(path)
33
+ merged = canonicalize(merge_with_existing(path: path, registry: registry), json: json)
34
+ json ? "#{JSON.pretty_generate(merged)}\n" : YAML.dump(merged)
43
35
  end
44
36
 
45
37
  def merge_with_existing(path:, registry:)
@@ -54,10 +46,25 @@ module LlmCostTracker
54
46
  merged
55
47
  end
56
48
 
49
+ private
50
+
51
+ def canonicalize(value, json:)
52
+ case value
53
+ when Hash then value.sort_by { |key, _| key.to_s }.to_h { |key, item| [key, canonicalize(item, json: json)] }
54
+ when Array then value.map { |item| canonicalize(item, json: json) }
55
+ when BigDecimal, Float then canonical_number(value, json: json)
56
+ else value
57
+ end
58
+ end
59
+
60
+ def canonical_number(value, json:)
61
+ json && defined?(JSON::Fragment) ? JSON::Fragment.new(value.to_f.to_s) : value.to_f
62
+ end
63
+
57
64
  def merged_models(registry, existing)
58
65
  merged = registry.fetch("models", {}).dup
59
66
  existing.fetch("models", {}).each do |model, attrs|
60
- next unless attrs.is_a?(Hash) && attrs["_source"].to_s == MANUAL_SOURCE
67
+ next unless attrs.is_a?(Hash) && own_entry?(model, attrs)
61
68
  next if merged.key?(model)
62
69
 
63
70
  merged[model] = attrs
@@ -65,6 +72,10 @@ module LlmCostTracker
65
72
  merged
66
73
  end
67
74
 
75
+ def own_entry?(model, attrs)
76
+ attrs["_source"].to_s == MANUAL_SOURCE || (@keep_unlisted && !Registry.builtin_prices.key?(model))
77
+ end
78
+
68
79
  def merged_service_charges(registry, existing)
69
80
  remote = registry.fetch("service_charges", {})
70
81
  existing.fetch("service_charges", {}).each_with_object(remote.dup) do |(provider, charges), merged|
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "json"
5
+ require "rubygems"
6
+
7
+ require_relative "fetcher"
8
+
9
+ module LlmCostTracker
10
+ module Pricing
11
+ module Sync
12
+ class RemoteSnapshot
13
+ CONTEXT = "remote pricing snapshot"
14
+
15
+ def initialize(body)
16
+ @registry = parse(body)
17
+ @metadata = @registry.fetch("metadata", {})
18
+ raise Error, "remote pricing metadata must be a hash" unless @metadata.is_a?(Hash)
19
+ end
20
+
21
+ def registry(url:, source_version:, today:)
22
+ check_compatibility!
23
+ models = models_with_metadata
24
+ Registry.rates_from_registry(@registry, context: CONTEXT) if service_charges
25
+ {
26
+ "metadata" => @metadata.merge(
27
+ "schema_version" => schema_version,
28
+ "updated_at" => @metadata["updated_at"] || today.iso8601,
29
+ "source_url" => Fetcher.scrub_url(url),
30
+ "source_version" => source_version
31
+ ),
32
+ "models" => models,
33
+ "service_charges" => service_charges.presence
34
+ }.compact
35
+ rescue ArgumentError, TypeError => e
36
+ raise Error, "Unable to load remote pricing snapshot: #{e.message}"
37
+ end
38
+
39
+ private
40
+
41
+ def parse(body)
42
+ registry = JSON.parse(body.to_s)
43
+ raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
44
+
45
+ registry
46
+ rescue JSON::ParserError => e
47
+ raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
48
+ end
49
+
50
+ def check_compatibility!
51
+ if schema_version > SUPPORTED_SCHEMA_VERSION
52
+ raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
53
+ end
54
+
55
+ min_gem_version = @metadata["min_gem_version"]
56
+ return unless min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
57
+
58
+ raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
59
+ end
60
+
61
+ def schema_version
62
+ @schema_version ||= Integer(@metadata.fetch("schema_version", 1))
63
+ end
64
+
65
+ def models_with_metadata
66
+ raw_models = @registry.fetch("models", {})
67
+ Registry.normalize_price_entries(raw_models, context: CONTEXT).to_h do |model, prices|
68
+ [model, (raw_models[model] || {}).slice(*Registry::METADATA_KEYS).merge(prices)]
69
+ end
70
+ end
71
+
72
+ def service_charges
73
+ @registry["service_charges"]
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -22,6 +22,8 @@ module LlmCostTracker
22
22
  private
23
23
 
24
24
  def finding(label, field, values)
25
+ return if field == Registry::OFF_PEAK_WINDOWS_KEY
26
+
25
27
  from, to = values.values_at("from", "to").map { |value| value&.to_f }
26
28
  "#{label}: #{from.inspect} -> #{to.inspect}" if suspicious?(field, from, to)
27
29
  end
@@ -2,13 +2,12 @@
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
4
  require "date"
5
- require "json"
6
- require "rubygems"
7
5
 
8
6
  require_relative "registry"
9
7
  require_relative "sync/fetcher"
10
8
  require_relative "sync/registry_diff"
11
9
  require_relative "sync/registry_writer"
10
+ require_relative "sync/remote_snapshot"
12
11
  require_relative "sync/snapshot_guard"
13
12
 
14
13
  module LlmCostTracker
@@ -52,7 +51,7 @@ module LlmCostTracker
52
51
  end
53
52
  RefreshResult.new(
54
53
  path: path,
55
- source_url: Redaction.text(url),
54
+ source_url: Fetcher.scrub_url(url),
56
55
  source_version: response.source_version,
57
56
  changes: changes,
58
57
  suspicious: suspicious,
@@ -65,7 +64,7 @@ module LlmCostTracker
65
64
  response, _remote, changes, suspicious = compare(path, url, fetcher, today)
66
65
  CheckResult.new(
67
66
  path: path,
68
- source_url: Redaction.text(url),
67
+ source_url: Fetcher.scrub_url(url),
69
68
  source_version: response.source_version,
70
69
  changes: changes,
71
70
  suspicious: suspicious,
@@ -75,51 +74,15 @@ module LlmCostTracker
75
74
 
76
75
  private
77
76
 
78
- def normalize_remote_registry(body, url:, response:, today:)
79
- registry = parse_registry(body)
80
- metadata = registry.fetch("metadata", {})
81
- raise Error, "remote pricing metadata must be a hash" unless metadata.is_a?(Hash)
82
-
83
- schema_version = Integer(metadata.fetch("schema_version", 1))
84
- if schema_version > SUPPORTED_SCHEMA_VERSION
85
- raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
86
- end
87
-
88
- min_gem_version = metadata["min_gem_version"]
89
- if min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
90
- raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
91
- end
92
-
93
- raw_models = registry.fetch("models", {})
94
- models = Registry.normalize_price_entries(raw_models, context: "remote pricing snapshot")
95
- .each_with_object({}) do |(model, prices), normalized|
96
- model_metadata = (raw_models[model] || {}).slice(*Registry::METADATA_KEYS)
97
- normalized[model] = model_metadata.merge(prices)
98
- end
99
- service_charges = registry["service_charges"]
100
- Registry.rates_from_registry(registry, context: "remote pricing snapshot") if service_charges
101
-
102
- normalized = {
103
- "metadata" => metadata.merge(
104
- "schema_version" => schema_version,
105
- "updated_at" => metadata["updated_at"] || today.iso8601,
106
- "source_url" => Redaction.text(url),
107
- "source_version" => response.source_version
108
- ),
109
- "models" => models
110
- }
111
- normalized["service_charges"] = service_charges if service_charges.present?
112
- normalized
113
- rescue ArgumentError, TypeError => e
114
- raise Error, "Unable to load remote pricing snapshot: #{e.message}"
115
- end
116
-
117
77
  def compare(path, url, fetcher, today)
118
78
  current = load_registry(path)
119
79
  response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
120
80
  return [response, nil, {}, []] if response.not_modified
121
81
 
122
- remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
82
+ snapshot = RemoteSnapshot.new(response.body)
83
+ remote = RegistryWriter.new.merge_with_existing(
84
+ path: path, registry: snapshot.registry(url: url, source_version: response.source_version, today: today)
85
+ )
123
86
  changes = registry_changes(current, remote)
124
87
  [response, remote, changes, SnapshotGuard.call(current: current, remote: remote, changes: changes)]
125
88
  end
@@ -132,15 +95,6 @@ module LlmCostTracker
132
95
  raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
133
96
  end
134
97
 
135
- def parse_registry(body)
136
- registry = JSON.parse(body.to_s)
137
- raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
138
-
139
- registry
140
- rescue JSON::ParserError => e
141
- raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
142
- end
143
-
144
98
  def refuse_suspicious_snapshot!(path, suspicious)
145
99
  listed = suspicious.first(20).map { |finding| "\n - #{finding}" }.join
146
100
  raise Error,
@@ -38,7 +38,7 @@ module LlmCostTracker
38
38
  subject += " at pricing_mode #{pricing_mode.inspect}" if pricing_mode
39
39
  Logging.warn(
40
40
  "No pricing configured for #{subject}. " \
41
- "Cost and budget guardrails will be skipped for this event. " \
41
+ "Cost and spend budgets will be skipped for this event. " \
42
42
  "Add a pricing.overrides entry or set pricing.unknown_model_behavior."
43
43
  )
44
44
  end