llm_cost_tracker 0.7.3 → 0.9.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +173 -0
  4. data/README.md +60 -220
  5. data/app/assets/llm_cost_tracker/application.css +282 -45
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  14. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  15. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  16. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  17. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  18. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
  19. data/app/models/llm_cost_tracker/call.rb +166 -0
  20. data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
  21. data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
  22. data/app/models/llm_cost_tracker/call_tag.rb +12 -0
  23. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
  24. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  25. data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
  26. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  27. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
  28. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
  30. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  31. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  32. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  33. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  35. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  36. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  37. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  38. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  39. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  40. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  41. data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
  42. data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
  43. data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
  44. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  45. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  46. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  47. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  48. data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
  49. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  50. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  51. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  52. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  53. data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
  54. data/config/routes.rb +3 -2
  55. data/lib/llm_cost_tracker/billing/components.rb +95 -0
  56. data/lib/llm_cost_tracker/billing/components.yml +188 -0
  57. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  58. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  59. data/lib/llm_cost_tracker/budget.rb +26 -36
  60. data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
  61. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  62. data/lib/llm_cost_tracker/configuration.rb +86 -17
  63. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  64. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
  65. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
  66. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  67. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  68. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  69. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  70. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  71. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  72. data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
  73. data/lib/llm_cost_tracker/doctor.rb +111 -44
  74. data/lib/llm_cost_tracker/engine.rb +9 -0
  75. data/lib/llm_cost_tracker/errors.rb +5 -19
  76. data/lib/llm_cost_tracker/event.rb +11 -3
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
  92. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
  93. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  94. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
  95. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  96. data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
  97. data/lib/llm_cost_tracker/ingestion.rb +66 -22
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
  99. data/lib/llm_cost_tracker/integrations/base.rb +56 -32
  100. data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
  101. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
  102. data/lib/llm_cost_tracker/integrations.rb +21 -3
  103. data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
  104. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  105. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  106. data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
  107. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  108. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
  109. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  110. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
  111. data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
  112. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  113. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  114. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  115. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  116. data/lib/llm_cost_tracker/ledger/store.rb +103 -20
  117. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  118. data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
  119. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  120. data/lib/llm_cost_tracker/ledger.rb +5 -2
  121. data/lib/llm_cost_tracker/logging.rb +2 -5
  122. data/lib/llm_cost_tracker/masking.rb +39 -0
  123. data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
  124. data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
  125. data/lib/llm_cost_tracker/parsers/base.rb +13 -4
  126. data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
  127. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  128. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
  129. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
  130. data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
  131. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  132. data/lib/llm_cost_tracker/parsers.rb +1 -1
  133. data/lib/llm_cost_tracker/prices.json +198 -22
  134. data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
  135. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  136. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
  137. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  138. data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
  139. data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
  140. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  141. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  142. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  143. data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
  144. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  145. data/lib/llm_cost_tracker/pricing.rb +220 -28
  146. data/lib/llm_cost_tracker/railtie.rb +6 -8
  147. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  148. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  149. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  150. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  151. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  152. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  153. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  154. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  155. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  156. data/lib/llm_cost_tracker/report/data.rb +19 -8
  157. data/lib/llm_cost_tracker/report.rb +0 -4
  158. data/lib/llm_cost_tracker/retention.rb +22 -9
  159. data/lib/llm_cost_tracker/tags/context.rb +2 -5
  160. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  161. data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
  162. data/lib/llm_cost_tracker/timing.rb +15 -0
  163. data/lib/llm_cost_tracker/token_usage.rb +64 -42
  164. data/lib/llm_cost_tracker/tracker.rb +97 -27
  165. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  166. data/lib/llm_cost_tracker/version.rb +1 -1
  167. data/lib/llm_cost_tracker.rb +45 -35
  168. data/lib/tasks/llm_cost_tracker.rake +45 -17
  169. metadata +71 -41
  170. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  171. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  172. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  173. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  174. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  175. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  176. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  182. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  183. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  184. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  185. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  186. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  187. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  188. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  189. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  190. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  191. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  192. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  193. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  194. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  195. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ class Mode
6
+ COMPOUND_MODIFIERS = %w[data_residency].freeze
7
+
8
+ attr_reader :modifiers
9
+
10
+ def self.parse(value)
11
+ return value if value.is_a?(self)
12
+ return new([]) if value.nil?
13
+
14
+ new(tokenize(value.to_s))
15
+ end
16
+
17
+ def self.tokenize(value)
18
+ remaining = value.to_s.downcase.tr("-", "_")
19
+ tokens = []
20
+ loop do
21
+ break if remaining.empty?
22
+
23
+ compound = COMPOUND_MODIFIERS.find do |token|
24
+ remaining == token || remaining.start_with?("#{token}_")
25
+ end
26
+ if compound
27
+ tokens << compound.to_sym
28
+ remaining = remaining.delete_prefix(compound).delete_prefix("_")
29
+ else
30
+ first, _, rest = remaining.partition("_")
31
+ tokens << first.to_sym unless first.empty?
32
+ remaining = rest
33
+ end
34
+ end
35
+ tokens
36
+ end
37
+
38
+ def initialize(modifiers)
39
+ @modifiers = Array(modifiers).map(&:to_sym).uniq.sort
40
+ freeze
41
+ end
42
+
43
+ def empty?
44
+ modifiers.empty?
45
+ end
46
+
47
+ def include?(modifier)
48
+ modifiers.include?(modifier.to_sym)
49
+ end
50
+
51
+ def canonical
52
+ modifiers.join("_")
53
+ end
54
+ alias to_s canonical
55
+
56
+ def to_sym
57
+ empty? ? nil : canonical.to_sym
58
+ end
59
+
60
+ def permutations
61
+ return [canonical] if modifiers.size <= 1
62
+
63
+ modifiers.permutation.map { |permutation| permutation.join("_") }.uniq
64
+ end
65
+
66
+ def ==(other)
67
+ other.is_a?(self.class) && modifiers == other.modifiers
68
+ end
69
+ alias eql? ==
70
+
71
+ def hash
72
+ modifiers.hash
73
+ end
74
+ end
75
+ end
76
+ 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,32 @@ 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 finite (got #{rate})" unless rate.finite?
119
+ raise ArgumentError, "price for #{key.inspect} must be non-negative (got #{rate})" if rate.negative?
120
+
121
+ rate
122
+ end
123
+
97
124
  def normalize_price_entries(table, context:)
98
125
  table = {} if table.nil?
99
126
  raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
@@ -106,8 +133,8 @@ module LlmCostTracker
106
133
  end
107
134
 
108
135
  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)
136
+ unknown_keys = price.keys.reject do |key|
137
+ registry_key_for(key) || METADATA_KEYS.include?(key)
111
138
  end
112
139
  return if unknown_keys.empty?
113
140
 
@@ -117,31 +144,26 @@ module LlmCostTracker
117
144
  )
118
145
  end
119
146
 
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
147
+ def price_key_for(key)
148
+ name = key.is_a?(Symbol) ? key.name : key
149
+ Billing::Components::REGISTRY.each do |candidate|
150
+ return candidate.key if candidate.key.name == name
151
+ next unless candidate.token_key
130
152
 
131
- contents = File.read(path)
132
- return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
153
+ suffix = "_#{candidate.key.name}"
154
+ next unless name.end_with?(suffix)
133
155
 
134
- JSON.parse(contents)
135
- end
156
+ prefix = name.delete_suffix(suffix)
157
+ return :"#{prefix}_#{candidate.key.name}" unless prefix.empty?
158
+ end
136
159
 
137
- def yaml_file?(path)
138
- %w[.yaml .yml].include?(File.extname(path).downcase)
160
+ nil
139
161
  end
140
162
 
141
- def price_file_models(registry)
142
- raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
163
+ def registry_key_for(key)
164
+ return CONTEXT_THRESHOLD_KEY if key == CONTEXT_THRESHOLD_KEY || key == CONTEXT_THRESHOLD_KEY.name
143
165
 
144
- registry.fetch("models", registry)
166
+ price_key_for(key)
145
167
  end
146
168
 
147
169
  def validate_price_entry(price, model:, context:)
@@ -0,0 +1,210 @@
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
+ if value.infinite? || value.nan?
125
+ raise ArgumentError,
126
+ "service charge price amount for #{key.inspect} in #{context} must be finite"
127
+ end
128
+ if value.negative?
129
+ raise ArgumentError,
130
+ "service charge price amount for #{key.inspect} in #{context} must be non-negative"
131
+ end
132
+
133
+ value
134
+ end
135
+
136
+ def rate_quantity(component)
137
+ BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis, 1).to_s)
138
+ end
139
+
140
+ def charge_rate_match(provider:, component:, pricing_mode:)
141
+ provider_name = provider.is_a?(Symbol) ? provider.name : provider.presence
142
+ return nil unless provider_name
143
+
144
+ component_key = charge_component_key(component)
145
+
146
+ table = ServiceCharges.file_rates(LlmCostTracker.configuration.prices_file)
147
+ provider_table = table.fetch(provider_name, EMPTY_RATES)
148
+ rate = rate_for(provider_table, component_key: component_key, pricing_mode: pricing_mode)
149
+ if rate
150
+ return {
151
+ source: :prices_file,
152
+ key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
153
+ rate: rate
154
+ }
155
+ end
156
+
157
+ table = ServiceCharges.builtin_rates
158
+ provider_table = table.fetch(provider_name, EMPTY_RATES)
159
+ rate = rate_for(provider_table, component_key: component_key, pricing_mode: pricing_mode)
160
+ return unless rate
161
+
162
+ {
163
+ source: :bundled,
164
+ key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
165
+ rate: rate
166
+ }
167
+ end
168
+
169
+ def rate_for(provider_table, component_key:, pricing_mode:)
170
+ component_rates = provider_table.fetch(component_key, EMPTY_RATES)
171
+ tier_rates = component_rates.fetch(:tiers, EMPTY_RATES)
172
+ if pricing_mode
173
+ rate = tier_rates[pricing_mode]
174
+ return rate if rate
175
+
176
+ name = pricing_mode.name
177
+ tier_rates.each do |candidate, candidate_rate|
178
+ return candidate_rate if tier_includes?(name, candidate.name)
179
+ end
180
+ end
181
+ component_rates[:default]
182
+ end
183
+
184
+ def tier_includes?(tier_name, candidate_name)
185
+ tier_name == candidate_name ||
186
+ tier_name.start_with?("#{candidate_name}_") ||
187
+ tier_name.end_with?("_#{candidate_name}") ||
188
+ tier_name.include?("_#{candidate_name}_")
189
+ end
190
+
191
+ def charge_component_key(component)
192
+ billing_component = Billing::Components::BY_KEY[component]
193
+ return billing_component.key if billing_component && billing_component.token_key.nil?
194
+
195
+ raise Error, "Unknown billing component: #{component.inspect}"
196
+ end
197
+
198
+ def rate_source_version_for(source)
199
+ return LlmCostTracker::VERSION if source == :bundled
200
+
201
+ path = LlmCostTracker.configuration.prices_file
202
+ return nil unless path
203
+
204
+ File.mtime(path).utc.iso8601
205
+ rescue Errno::ENOENT
206
+ nil
207
+ end
208
+ end
209
+ end
210
+ 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
@@ -9,10 +9,12 @@ module LlmCostTracker
9
9
  module Sync
10
10
  class RegistryWriter
11
11
  YAML_EXTENSIONS = %w[.yml .yaml].freeze
12
+ MANUAL_SOURCE = "manual"
12
13
 
13
14
  def call(path:, registry:)
14
15
  FileUtils.mkdir_p(File.dirname(path))
15
- payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
16
+ merged = merge_with_existing(path: path, registry: registry)
17
+ payload = yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
16
18
  temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
17
19
  File.write(temp_path, payload)
18
20
  File.rename(temp_path, path)
@@ -22,6 +24,53 @@ module LlmCostTracker
22
24
 
23
25
  private
24
26
 
27
+ def merge_with_existing(path:, registry:)
28
+ existing = read_existing(path)
29
+ return registry unless existing.is_a?(Hash)
30
+
31
+ merged = registry.dup
32
+ merged["models"] = merged_models(registry, existing) if existing["models"].is_a?(Hash)
33
+ if existing["service_charges"].is_a?(Hash)
34
+ merged["service_charges"] = merged_service_charges(registry, existing)
35
+ end
36
+ merged
37
+ end
38
+
39
+ def merged_models(registry, existing)
40
+ merged = registry.fetch("models", {}).dup
41
+ existing.fetch("models", {}).each do |model, attrs|
42
+ next unless attrs.is_a?(Hash) && attrs["_source"].to_s == MANUAL_SOURCE
43
+ next if merged.key?(model)
44
+
45
+ merged[model] = attrs
46
+ end
47
+ merged
48
+ end
49
+
50
+ def merged_service_charges(registry, existing)
51
+ remote = registry.fetch("service_charges", {})
52
+ existing.fetch("service_charges", {}).each_with_object(remote.dup) do |(provider, charges), merged|
53
+ next unless charges.is_a?(Hash)
54
+
55
+ merged[provider] = charges.merge(merged.fetch(provider, {}))
56
+ end
57
+ end
58
+
59
+ def read_existing(path)
60
+ return nil unless File.exist?(path)
61
+
62
+ contents = File.read(path)
63
+ return nil if contents.strip.empty?
64
+
65
+ if yaml_file?(path)
66
+ YAML.safe_load(contents, permitted_classes: [Symbol, Date, Time])
67
+ else
68
+ JSON.parse(contents)
69
+ end
70
+ rescue StandardError
71
+ nil
72
+ end
73
+
25
74
  def yaml_file?(path)
26
75
  YAML_EXTENSIONS.include?(File.extname(path).downcase)
27
76
  end