llm_cost_tracker 0.7.3 → 0.8.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/.ruby-version +1 -0
  3. data/CHANGELOG.md +66 -1
  4. data/README.md +58 -225
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +121 -30
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +96 -13
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +4 -10
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "yaml"
5
4
 
6
- require_relative "components"
5
+ require_relative "../billing/components"
7
6
  require_relative "../logging"
8
7
 
9
8
  module LlmCostTracker
@@ -11,42 +10,58 @@ module LlmCostTracker
11
10
  module Registry
12
11
  DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
13
12
  EMPTY_PRICES = {}.freeze
14
- PRICE_KEYS = Pricing::COMPONENTS.map { |component| component.price_key.to_s }.freeze
15
- METADATA_KEYS = %w[
16
- _source _source_version _fetched_at _updated _notes _validator_override
17
- _context_price_threshold_tokens
13
+ CONTEXT_THRESHOLD_KEY = :_context_price_threshold_tokens
14
+ PRICE_KEYS = Billing::Components::TOKEN_PRICED.map { |component| component.key.name }.freeze
15
+ METADATA_KEYS = [
16
+ "_source", "_source_version", "_fetched_at", "_updated", "_notes", "_validator_override",
17
+ CONTEXT_THRESHOLD_KEY.name
18
18
  ].freeze
19
- MAX_FILE_BYTES = 2_097_152
20
19
  MUTEX = Mutex.new
21
20
 
22
21
  class << self
22
+ def reset!
23
+ MUTEX.synchronize do
24
+ @builtin_prices = nil
25
+ @metadata = nil
26
+ @raw_registry = nil
27
+ @file_prices_cache = nil
28
+ end
29
+ end
30
+
23
31
  def builtin_prices
24
32
  cached = @builtin_prices
25
33
  return cached if cached
26
34
 
27
- value = normalize_price_table(raw_registry.fetch("models", {})).freeze
28
- MUTEX.synchronize { @builtin_prices ||= value }
35
+ MUTEX.synchronize do
36
+ @builtin_prices ||= begin
37
+ registry = @raw_registry ||= load_raw_registry
38
+ normalize_price_table(registry.fetch("models", {})).freeze
39
+ end
40
+ end
29
41
  end
30
42
 
31
43
  def metadata
32
44
  cached = @metadata
33
45
  return cached if cached
34
46
 
35
- value = raw_registry.fetch("metadata", {}).freeze
36
- MUTEX.synchronize { @metadata ||= value }
47
+ MUTEX.synchronize do
48
+ @metadata ||= begin
49
+ registry = @raw_registry ||= load_raw_registry
50
+ registry.fetch("metadata", {}).freeze
51
+ end
52
+ end
37
53
  end
38
54
 
39
55
  def file_metadata(path)
40
56
  return {} unless path
41
57
 
42
- registry = load_price_file(path.to_s)
43
- raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
58
+ registry = YAML.safe_load_file(path, aliases: false) || {}
44
59
 
45
60
  metadata = registry.fetch("metadata", {})
46
61
  raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
47
62
 
48
63
  metadata
49
- rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
64
+ rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
50
65
  raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
51
66
  end
52
67
 
@@ -57,8 +72,7 @@ module LlmCostTracker
57
72
  def file_prices(path)
58
73
  return EMPTY_PRICES unless path
59
74
 
60
- path = path.to_s
61
- cache_key = [path, File.mtime(path).to_f]
75
+ cache_key = [path, File.mtime(path)]
62
76
  cached = @file_prices_cache
63
77
  return cached[:value] if cached && cached[:key] == cache_key
64
78
 
@@ -66,11 +80,12 @@ module LlmCostTracker
66
80
  cached = @file_prices_cache
67
81
  return cached[:value] if cached && cached[:key] == cache_key
68
82
 
69
- value = normalize_price_entries(price_file_models(load_price_file(path)), context: path).freeze
83
+ registry = YAML.safe_load_file(path, aliases: false) || {}
84
+ value = normalize_price_entries(registry.fetch("models", registry), context: path).freeze
70
85
  @file_prices_cache = { key: cache_key, value: value }.freeze
71
86
  value
72
87
  end
73
- rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
88
+ rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
74
89
  raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
75
90
  end
76
91
 
@@ -80,20 +95,31 @@ module LlmCostTracker
80
95
  cached = @raw_registry
81
96
  return cached if cached
82
97
 
83
- MUTEX.synchronize { @raw_registry ||= JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze }
98
+ MUTEX.synchronize { @raw_registry ||= load_raw_registry }
99
+ end
100
+
101
+ def load_raw_registry
102
+ YAML.safe_load_file(DEFAULT_PRICES_PATH, aliases: false).freeze
84
103
  end
85
104
 
86
105
  def normalize_price_entry(price)
87
106
  price.each_with_object({}) do |(key, value), normalized|
88
- key = key.to_s
89
- if price_key?(key)
90
- normalized[key.to_sym] = Float(value)
91
- elsif key == "_context_price_threshold_tokens"
92
- normalized[key.to_sym] = Integer(value)
107
+ key = registry_key_for(key)
108
+ if key == CONTEXT_THRESHOLD_KEY
109
+ normalized[key] = Integer(value)
110
+ elsif key
111
+ normalized[key] = non_negative_float(key, value)
93
112
  end
94
113
  end
95
114
  end
96
115
 
116
+ def non_negative_float(key, value)
117
+ rate = Float(value)
118
+ raise ArgumentError, "price for #{key.inspect} must be non-negative (got #{rate})" if rate.negative?
119
+
120
+ rate
121
+ end
122
+
97
123
  def normalize_price_entries(table, context:)
98
124
  table = {} if table.nil?
99
125
  raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
@@ -106,8 +132,8 @@ module LlmCostTracker
106
132
  end
107
133
 
108
134
  def warn_unknown_keys(model, price, path)
109
- unknown_keys = price.keys.map(&:to_s).reject do |key|
110
- price_key?(key) || METADATA_KEYS.include?(key)
135
+ unknown_keys = price.keys.reject do |key|
136
+ registry_key_for(key) || METADATA_KEYS.include?(key)
111
137
  end
112
138
  return if unknown_keys.empty?
113
139
 
@@ -117,31 +143,25 @@ module LlmCostTracker
117
143
  )
118
144
  end
119
145
 
120
- def price_key?(key)
121
- return true if PRICE_KEYS.include?(key)
122
-
123
- PRICE_KEYS.any? do |base_key|
124
- key.end_with?("_#{base_key}") && key.delete_suffix("_#{base_key}") != ""
125
- end
126
- end
127
-
128
- def load_price_file(path)
129
- raise ArgumentError, "prices_file exceeds #{MAX_FILE_BYTES} bytes" if File.size(path) > MAX_FILE_BYTES
146
+ def price_key_for(key)
147
+ name = key.is_a?(Symbol) ? key.name : key
148
+ Billing::Components::TOKEN_PRICED.each do |candidate|
149
+ return candidate.key if candidate.key.name == name
130
150
 
131
- contents = File.read(path)
132
- return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
151
+ suffix = "_#{candidate.key.name}"
152
+ next unless name.end_with?(suffix)
133
153
 
134
- JSON.parse(contents)
135
- end
154
+ prefix = name.delete_suffix(suffix)
155
+ return :"#{prefix}_#{candidate.key.name}" unless prefix.empty?
156
+ end
136
157
 
137
- def yaml_file?(path)
138
- %w[.yaml .yml].include?(File.extname(path).downcase)
158
+ nil
139
159
  end
140
160
 
141
- def price_file_models(registry)
142
- raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
161
+ def registry_key_for(key)
162
+ return CONTEXT_THRESHOLD_KEY if key == CONTEXT_THRESHOLD_KEY || key == CONTEXT_THRESHOLD_KEY.name
143
163
 
144
- registry.fetch("models", registry)
164
+ price_key_for(key)
145
165
  end
146
166
 
147
167
  def validate_price_entry(price, model:, context:)
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "bigdecimal"
5
+ require "time"
6
+ require "yaml"
7
+
8
+ require_relative "../billing/components"
9
+ require_relative "registry"
10
+
11
+ module LlmCostTracker
12
+ module Pricing
13
+ module ServiceCharges
14
+ extend self
15
+
16
+ DEFAULT_CURRENCY = "USD"
17
+ EMPTY_RATES = {}.freeze
18
+ MUTEX = Mutex.new
19
+
20
+ def reset!
21
+ MUTEX.synchronize do
22
+ @builtin_rates = nil
23
+ @file_rates_cache = nil
24
+ end
25
+ end
26
+
27
+ def builtin_rates
28
+ cached = @builtin_rates
29
+ return cached if cached
30
+
31
+ MUTEX.synchronize do
32
+ @builtin_rates ||= begin
33
+ registry = YAML.safe_load_file(Registry::DEFAULT_PRICES_PATH, aliases: false) || {}
34
+ rates_from_registry(registry).freeze
35
+ end
36
+ end
37
+ end
38
+
39
+ def file_rates(path)
40
+ return EMPTY_RATES unless path
41
+
42
+ cache_key = [path, File.mtime(path)]
43
+ cached = @file_rates_cache
44
+ return cached[:value] if cached && cached[:key] == cache_key
45
+
46
+ MUTEX.synchronize do
47
+ cached = @file_rates_cache
48
+ return cached[:value] if cached && cached[:key] == cache_key
49
+
50
+ registry = YAML.safe_load_file(path, aliases: false) || {}
51
+ value = rates_from_registry(registry, context: path).freeze
52
+ @file_rates_cache = { key: cache_key, value: value }.freeze
53
+ value
54
+ end
55
+ rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
56
+ raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
57
+ end
58
+
59
+ def rates_from_registry(registry, context: "price registry")
60
+ data = registry.fetch("service_charges", EMPTY_RATES)
61
+ raise ArgumentError, "#{context} service_charges must be a hash" unless data.is_a?(Hash)
62
+
63
+ data.each_with_object({}) do |(provider, entries), rates|
64
+ section_context = "#{context} service_charges.#{provider}"
65
+ rates[provider] = rates_from_section(entries, context: section_context)
66
+ end
67
+ end
68
+
69
+ def charge_rate(provider:, component:, pricing_mode:)
70
+ pricing_mode = Pricing.normalize_mode(pricing_mode)
71
+ match = charge_rate_match(provider: provider, component: component, pricing_mode: pricing_mode)
72
+ return nil unless match
73
+
74
+ rate = match.fetch(:rate)
75
+ {
76
+ amount: rate.fetch(:amount),
77
+ quantity: rate.fetch(:quantity),
78
+ currency: rate.fetch(:currency),
79
+ source: match.fetch(:source),
80
+ source_key: match.fetch(:key),
81
+ source_version: rate_source_version_for(match.fetch(:source))
82
+ }
83
+ end
84
+
85
+ private
86
+
87
+ def rates_from_section(entries, context:)
88
+ raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
89
+
90
+ entries.each_with_object({}) do |(key, amount), rates|
91
+ key = key.name if key.is_a?(Symbol)
92
+ component, tier = component_and_tier_for(key, context: context)
93
+ amount = amount_for(key, amount, context: context)
94
+
95
+ rate = {
96
+ amount: amount,
97
+ quantity: rate_quantity(component),
98
+ currency: DEFAULT_CURRENCY,
99
+ source_key: key
100
+ }
101
+ component_rates = rates[component.key] ||= { tiers: {} }
102
+ (tier ? component_rates[:tiers] : component_rates)[tier || :default] = rate
103
+ end
104
+ end
105
+
106
+ def component_and_tier_for(key, context:)
107
+ Billing::Components::REGISTRY.each do |component|
108
+ next if component.token_key
109
+
110
+ return [component, nil] if key == component.key.name
111
+
112
+ suffix = "_#{component.key.name}"
113
+ next unless key.end_with?(suffix)
114
+
115
+ tier = key.delete_suffix(suffix)
116
+ return [component, :"#{tier}"] unless tier.empty?
117
+ end
118
+
119
+ raise ArgumentError, "service charge price key #{key.inspect} in #{context} uses unknown billing component"
120
+ end
121
+
122
+ def amount_for(key, amount, context:)
123
+ value = BigDecimal(amount.to_s)
124
+ message = "service charge price amount for #{key.inspect} in #{context} must be non-negative"
125
+ raise ArgumentError, message if value.negative?
126
+
127
+ value
128
+ end
129
+
130
+ def rate_quantity(component)
131
+ component.unit == :request ? BigDecimal("1000") : BigDecimal("1")
132
+ end
133
+
134
+ def charge_rate_match(provider:, component:, pricing_mode:)
135
+ provider_name = provider.is_a?(Symbol) ? provider.name : provider.presence
136
+ return nil unless provider_name
137
+
138
+ component_key = charge_component_key(component)
139
+
140
+ table = ServiceCharges.file_rates(LlmCostTracker.configuration.prices_file)
141
+ provider_table = table.fetch(provider_name, EMPTY_RATES)
142
+ rate = rate_for(provider_table, component_key: component_key, pricing_mode: pricing_mode)
143
+ if rate
144
+ return {
145
+ source: :prices_file,
146
+ key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
147
+ rate: rate
148
+ }
149
+ end
150
+
151
+ table = ServiceCharges.builtin_rates
152
+ provider_table = table.fetch(provider_name, EMPTY_RATES)
153
+ rate = rate_for(provider_table, component_key: component_key, pricing_mode: pricing_mode)
154
+ return unless rate
155
+
156
+ {
157
+ source: :bundled,
158
+ key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
159
+ rate: rate
160
+ }
161
+ end
162
+
163
+ def rate_for(provider_table, component_key:, pricing_mode:)
164
+ component_rates = provider_table.fetch(component_key, EMPTY_RATES)
165
+ tier_rates = component_rates.fetch(:tiers, EMPTY_RATES)
166
+ if pricing_mode
167
+ rate = tier_rates[pricing_mode]
168
+ return rate if rate
169
+
170
+ name = pricing_mode.name
171
+ tier_rates.each do |candidate, candidate_rate|
172
+ return candidate_rate if tier_includes?(name, candidate.name)
173
+ end
174
+ end
175
+ component_rates[:default]
176
+ end
177
+
178
+ def tier_includes?(tier_name, candidate_name)
179
+ tier_name == candidate_name ||
180
+ tier_name.start_with?("#{candidate_name}_") ||
181
+ tier_name.end_with?("_#{candidate_name}") ||
182
+ tier_name.include?("_#{candidate_name}_")
183
+ end
184
+
185
+ def charge_component_key(component)
186
+ billing_component = Billing::Components::BY_KEY[component]
187
+ return billing_component.key if billing_component && billing_component.token_key.nil?
188
+
189
+ raise Error, "Unknown billing component: #{component.inspect}"
190
+ end
191
+
192
+ def rate_source_version_for(source)
193
+ return LlmCostTracker::VERSION if source == :bundled
194
+
195
+ path = LlmCostTracker.configuration.prices_file
196
+ return nil unless path
197
+
198
+ File.mtime(path).utc.iso8601
199
+ rescue Errno::ENOENT
200
+ nil
201
+ end
202
+ end
203
+ end
204
+ end
@@ -7,6 +7,8 @@ require "openssl"
7
7
  require "time"
8
8
  require "uri"
9
9
 
10
+ require_relative "../../version"
11
+
10
12
  module LlmCostTracker
11
13
  module Pricing
12
14
  module Sync
@@ -17,7 +19,7 @@ module LlmCostTracker
17
19
  end
18
20
  end
19
21
 
20
- USER_AGENT = "llm_cost_tracker price refresh"
22
+ USER_AGENT = "llm_cost_tracker/#{LlmCostTracker::VERSION} price refresh".freeze
21
23
  MAX_REDIRECTS = 5
22
24
  MAX_BODY_BYTES = 2_097_152
23
25
  OPEN_TIMEOUT = 5
@@ -25,7 +27,8 @@ module LlmCostTracker
25
27
  WRITE_TIMEOUT = 10
26
28
 
27
29
  def get(url, etag: nil, redirects: 0)
28
- raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
30
+ safe_url = scrub_url(url)
31
+ raise Error, "Too many redirects while fetching #{safe_url}" if redirects > MAX_REDIRECTS
29
32
 
30
33
  uri = URI.parse(url)
31
34
  raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
@@ -38,23 +41,34 @@ module LlmCostTracker
38
41
 
39
42
  case response
40
43
  when Net::HTTPSuccess
41
- build_response(response, body: body || limited_body(response), not_modified: false)
44
+ build_response(response, body: body, not_modified: false)
42
45
  when Net::HTTPNotModified
43
46
  build_response(response, body: nil, not_modified: true)
44
47
  when Net::HTTPRedirection
45
48
  location = response["location"]
46
- raise Error, "Redirect without location while fetching #{url}" if location.blank?
49
+ raise Error, "Redirect without location while fetching #{safe_url}" if location.blank?
47
50
 
48
51
  get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
49
52
  else
50
- raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
53
+ raise Error, "Unable to fetch #{safe_url}: HTTP #{response.code}"
51
54
  end
52
55
  rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
53
- raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
56
+ raise Error, "Unable to fetch #{scrub_url(url)}: #{e.class}: #{e.message}"
54
57
  end
55
58
 
56
59
  private
57
60
 
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]"
70
+ end
71
+
58
72
  def fetch_response(uri, request)
59
73
  body = nil
60
74
  response = Net::HTTP.start(
@@ -75,19 +89,14 @@ module LlmCostTracker
75
89
 
76
90
  def limited_body(response)
77
91
  body = +""
78
- if response.respond_to?(:read_body)
79
- response.read_body do |chunk|
80
- chunk = chunk.to_s
81
- if body.bytesize + chunk.bytesize > MAX_BODY_BYTES
82
- raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes"
83
- end
84
-
85
- body << chunk
92
+ response.read_body do |chunk|
93
+ chunk = chunk.to_s
94
+ if body.bytesize + chunk.bytesize > MAX_BODY_BYTES
95
+ raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes"
86
96
  end
87
- else
88
- body = response.body.to_s
97
+
98
+ body << chunk
89
99
  end
90
- raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes" if body.bytesize > MAX_BODY_BYTES
91
100
 
92
101
  body
93
102
  end
@@ -18,8 +18,8 @@ module LlmCostTracker
18
18
  private
19
19
 
20
20
  def price_field_changes(current_entry, updated_entry)
21
- current_price = comparable_price(current_entry)
22
- updated_price = comparable_price(updated_entry)
21
+ current_price = current_entry || {}
22
+ updated_price = updated_entry || {}
23
23
 
24
24
  (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
25
25
  from = current_price[field]
@@ -30,21 +30,12 @@ module LlmCostTracker
30
30
  end
31
31
  end
32
32
 
33
- def comparable_price(entry)
34
- normalize_hash(entry).slice(*Registry::PRICE_KEYS)
35
- end
36
-
37
33
  def normalize_models(models)
38
- normalize_hash(models).transform_values { |entry| normalize_hash(entry) }
39
- end
40
-
41
- def normalize_hash(hash)
42
- return {} if hash.nil?
43
- raise Error, "pricing entries must be hashes" unless hash.is_a?(Hash)
44
-
45
- hash.each_with_object({}) do |(key, value), normalized|
46
- normalized[key.to_s] = value
34
+ Registry.normalize_price_table(models).transform_values do |price|
35
+ price.to_h { |key, value| [key.name, value] }
47
36
  end
37
+ rescue ArgumentError, TypeError => e
38
+ raise Error, e.message
48
39
  end
49
40
  end
50
41
  end
@@ -8,7 +8,6 @@ require "rubygems"
8
8
  require_relative "registry"
9
9
  require_relative "sync/fetcher"
10
10
  require_relative "sync/registry_diff"
11
- require_relative "sync/registry_loader"
12
11
  require_relative "sync/registry_writer"
13
12
 
14
13
  module LlmCostTracker
@@ -39,7 +38,7 @@ module LlmCostTracker
39
38
 
40
39
  def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
41
40
  today: Date.today)
42
- current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
41
+ current = load_registry(path)
43
42
  response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
44
43
 
45
44
  if response.not_modified
@@ -55,7 +54,10 @@ module LlmCostTracker
55
54
  end
56
55
 
57
56
  remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
58
- RegistryWriter.new.call(path: path, registry: remote) unless preview
57
+ unless preview
58
+ RegistryWriter.new.call(path: path, registry: remote)
59
+ invalidate_pricing_caches!
60
+ end
59
61
  refresh_result(
60
62
  path: path,
61
63
  url: url,
@@ -67,8 +69,14 @@ module LlmCostTracker
67
69
  )
68
70
  end
69
71
 
72
+ def invalidate_pricing_caches!
73
+ Pricing::Lookup.reset!
74
+ Pricing::Registry.reset!
75
+ Pricing::ServiceCharges.reset!
76
+ end
77
+
70
78
  def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
71
- current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
79
+ current = load_registry(path)
72
80
  response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
73
81
 
74
82
  if response.not_modified
@@ -82,7 +90,7 @@ module LlmCostTracker
82
90
  end
83
91
 
84
92
  remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
85
- changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
93
+ changes = registry_changes(current, remote)
86
94
 
87
95
  CheckResult.new(
88
96
  path: path,
@@ -118,10 +126,15 @@ module LlmCostTracker
118
126
  raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
119
127
  end
120
128
 
121
- models = registry.fetch("models", {})
122
- Registry.normalize_price_table(models)
129
+ raw_models = registry.fetch("models", {})
130
+ models = Registry.normalize_price_table(raw_models).each_with_object({}) do |(model, prices), normalized|
131
+ model_metadata = (raw_models[model] || {}).slice(*Registry::METADATA_KEYS)
132
+ normalized[model] = model_metadata.merge(prices.to_h { |key, value| [key.name, value] })
133
+ end
134
+ service_charges = registry["service_charges"]
135
+ ServiceCharges.rates_from_registry(registry, context: "remote pricing snapshot") if service_charges
123
136
 
124
- registry.merge(
137
+ normalized = {
125
138
  "metadata" => metadata.merge(
126
139
  "schema_version" => schema_version,
127
140
  "updated_at" => metadata["updated_at"] || today.iso8601,
@@ -129,11 +142,19 @@ module LlmCostTracker
129
142
  "source_version" => response.source_version
130
143
  ),
131
144
  "models" => models
132
- )
145
+ }
146
+ normalized["service_charges"] = service_charges if service_charges.present?
147
+ normalized
133
148
  rescue ArgumentError, TypeError => e
134
149
  raise Error, "Unable to load remote pricing snapshot: #{e.message}"
135
150
  end
136
151
 
152
+ def load_registry(path)
153
+ YAML.safe_load_file(path, aliases: false) || {}
154
+ rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
155
+ raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
156
+ end
157
+
137
158
  def parse_registry(body)
138
159
  registry = JSON.parse(body.to_s)
139
160
  raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
@@ -148,11 +169,37 @@ module LlmCostTracker
148
169
  path: path,
149
170
  source_url: url,
150
171
  source_version: response.source_version,
151
- changes: RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {})),
172
+ changes: registry_changes(current, remote),
152
173
  written: written,
153
174
  not_modified: not_modified
154
175
  )
155
176
  end
177
+
178
+ def registry_changes(current, remote)
179
+ model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
180
+ charge_changes = service_charges_diff(
181
+ current.fetch("service_charges", {}),
182
+ remote.fetch("service_charges", {})
183
+ )
184
+ return model_changes if charge_changes.empty?
185
+
186
+ model_changes.merge("service_charges" => charge_changes)
187
+ end
188
+
189
+ def service_charges_diff(current, remote)
190
+ (current.keys | remote.keys).sort.each_with_object({}) do |provider, changes|
191
+ current_rates = (current[provider] || {}).transform_keys(&:to_s)
192
+ remote_rates = (remote[provider] || {}).transform_keys(&:to_s)
193
+ (current_rates.keys | remote_rates.keys).sort.each_with_object(changes) do |component, _|
194
+ from = current_rates[component]
195
+ to = remote_rates[component]
196
+ next if from == to
197
+
198
+ changes[provider] ||= {}
199
+ changes[provider][component] = { "from" => from, "to" => to }
200
+ end
201
+ end
202
+ end
156
203
  end
157
204
  end
158
205
  end