llm_cost_tracker 0.7.2 → 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 +72 -1
  4. data/README.md +58 -221
  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 +125 -34
  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 +4 -10
  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 +110 -18
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
  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
@@ -7,42 +7,18 @@ module LlmCostTracker
7
7
  MUTEX = Mutex.new
8
8
  CACHE_MISS = Object.new.freeze
9
9
  NO_MATCH = Object.new.freeze
10
- MAX_LOOKUP_CACHE_ENTRIES = 512
11
10
 
12
11
  class << self
13
12
  def call(provider:, model:)
14
13
  provider_name = provider.to_s.presence
15
14
  model_name = model.to_s
15
+ return nil if model_name.empty?
16
+
16
17
  cache_key = [provider_name, model_name]
17
18
  cached = cached_lookup(cache_key)
18
19
  return cached unless cached.equal?(CACHE_MISS)
19
20
 
20
- provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
21
- normalized_model = normalize_model_name(model_name)
22
- current = current_price_tables
23
-
24
- match =
25
- explain_table(
26
- table: current.fetch(:pricing_overrides),
27
- source: :pricing_overrides,
28
- provider_model: provider_model,
29
- model_name: model_name,
30
- normalized_model: normalized_model
31
- ) ||
32
- explain_table(
33
- table: current.fetch(:file_prices),
34
- source: :prices_file,
35
- provider_model: provider_model,
36
- model_name: model_name,
37
- normalized_model: normalized_model
38
- ) ||
39
- explain_table(
40
- table: Registry.builtin_prices,
41
- source: :bundled,
42
- provider_model: provider_model,
43
- model_name: model_name,
44
- normalized_model: normalized_model
45
- )
21
+ match = lookup_match(provider_name: provider_name, model_name: model_name)
46
22
  cache_lookup(cache_key, match)
47
23
  match
48
24
  end
@@ -57,6 +33,32 @@ module LlmCostTracker
57
33
 
58
34
  private
59
35
 
36
+ def lookup_match(provider_name:, model_name:)
37
+ provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
38
+ normalized_model = normalize_model_name(model_name)
39
+ current = current_price_tables
40
+
41
+ ordered_table_lookups(current).each do |source, table|
42
+ match = explain_table(
43
+ table: table,
44
+ source: source,
45
+ provider_model: provider_model,
46
+ model_name: model_name,
47
+ normalized_model: normalized_model
48
+ )
49
+ return match if match
50
+ end
51
+ nil
52
+ end
53
+
54
+ def ordered_table_lookups(current)
55
+ [
56
+ [:pricing_overrides, current.fetch(:pricing_overrides)],
57
+ [:prices_file, current.fetch(:file_prices)],
58
+ [:bundled, Registry.builtin_prices]
59
+ ]
60
+ end
61
+
60
62
  def current_price_tables
61
63
  cached = @prices_cache
62
64
  return cached if cached
@@ -67,8 +69,7 @@ module LlmCostTracker
67
69
 
68
70
  config = LlmCostTracker.configuration
69
71
  file_prices = Registry.file_prices(config.prices_file)
70
- overrides = Registry.normalize_price_table(config.pricing_overrides)
71
- value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
72
+ value = { pricing_overrides: config.pricing_overrides, file_prices: file_prices }.freeze
72
73
  @prices_cache = value
73
74
  value
74
75
  end
@@ -85,7 +86,6 @@ module LlmCostTracker
85
86
  def cache_lookup(cache_key, match)
86
87
  MUTEX.synchronize do
87
88
  values = (@lookup_cache || {}).dup
88
- values.clear if values.size >= MAX_LOOKUP_CACHE_ENTRIES
89
89
  values[cache_key] = match || NO_MATCH
90
90
  @lookup_cache = values.freeze
91
91
  end
@@ -135,7 +135,7 @@ module LlmCostTracker
135
135
  end
136
136
 
137
137
  def match(table:, source:, key:, matched_by:)
138
- Match.new(source: source.to_s, key: key, prices: table[key], matched_by: matched_by.to_s)
138
+ Match.new(source: source, key: key, prices: table[key], matched_by: matched_by)
139
139
  end
140
140
 
141
141
  def snapshot_variant?(model, key)
@@ -147,14 +147,18 @@ module LlmCostTracker
147
147
 
148
148
  def sorted_price_keys(table)
149
149
  cached = @sorted_price_keys_cache
150
- return cached[:keys] if cached && cached[:table].equal?(table)
150
+ existing = cached && cached[table]
151
+ return existing if existing
151
152
 
152
153
  MUTEX.synchronize do
153
154
  cached = @sorted_price_keys_cache
154
- return cached[:keys] if cached && cached[:table].equal?(table)
155
+ existing = cached && cached[table]
156
+ return existing if existing
155
157
 
156
158
  keys = table.keys.sort_by { |key| -key.length }
157
- @sorted_price_keys_cache = { table: table, keys: keys }.freeze
159
+ next_cache = cached ? cached.dup : {}.compare_by_identity
160
+ next_cache[table] = keys
161
+ @sorted_price_keys_cache = next_cache.freeze
158
162
  keys
159
163
  end
160
164
  end
@@ -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