llm_cost_tracker 0.9.0 → 0.11.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../pricing"
4
+ require_relative "../billing/cost_status"
5
+ require_relative "../billing/line_item"
6
+ require_relative "../ledger/rollups"
7
+ require_relative "../token_usage"
8
+
9
+ module LlmCostTracker
10
+ module Pricing
11
+ class Backfill
12
+ Result = Data.define(:examined, :recomputed, :still_unknown)
13
+ RollupEvent = Data.define(:provider, :tracked_at, :pricing_snapshot, :total_cost)
14
+
15
+ DEFAULT_BATCH_SIZE = 500
16
+
17
+ class << self
18
+ def call(scope: default_scope, batch_size: DEFAULT_BATCH_SIZE)
19
+ examined = 0
20
+ recomputed = 0
21
+
22
+ scope.includes(:line_items).find_in_batches(batch_size: batch_size) do |batch|
23
+ rollup_events = []
24
+ LlmCostTracker::Call.transaction do
25
+ batch.each do |call|
26
+ examined += 1
27
+ outcome = recompute_for(call)
28
+ next unless outcome
29
+
30
+ persist!(call, outcome)
31
+ rollup_events << rollup_event_for(call, outcome)
32
+ recomputed += 1
33
+ end
34
+ Ledger::Rollups.increment_many!(rollup_events) if rollup_events.any?
35
+ end
36
+ end
37
+
38
+ Result.new(examined: examined, recomputed: recomputed, still_unknown: examined - recomputed)
39
+ end
40
+
41
+ def default_scope
42
+ LlmCostTracker::Call.where(total_cost: nil)
43
+ end
44
+
45
+ private
46
+
47
+ def recompute_for(call)
48
+ token_usage = token_usage_from(call)
49
+ billing_items = billing_line_items_from(call)
50
+ cost_data, snapshot, priced = Pricing.calculate(
51
+ provider: call.provider, model: call.model,
52
+ tokens: token_usage, line_items: billing_items,
53
+ pricing_mode: call.pricing_mode
54
+ )
55
+ return nil unless cost_data
56
+
57
+ full_cost = Pricing.combine_with_service_lines(cost_data, priced)
58
+ total_cost = full_cost[:total_cost]
59
+ return nil if total_cost.nil?
60
+
61
+ {
62
+ snapshot: snapshot,
63
+ priced_line_items: priced,
64
+ total_cost: total_cost,
65
+ cost_status: Billing::CostStatus.call(
66
+ token_usage: token_usage,
67
+ usage_source: call.usage_source&.to_sym,
68
+ token_cost: cost_data,
69
+ token_pricing_partial: Pricing.token_pricing_partial?(token_usage, cost_data),
70
+ service_line_items: priced.reject(&:token?),
71
+ total_cost: total_cost
72
+ )
73
+ }
74
+ end
75
+
76
+ def persist!(call, outcome)
77
+ call.update!(
78
+ total_cost: outcome[:total_cost],
79
+ pricing_snapshot: outcome[:snapshot],
80
+ cost_status: outcome[:cost_status]
81
+ )
82
+ call.line_items.to_a.zip(outcome[:priced_line_items]).each do |record, priced|
83
+ next if priced.nil?
84
+
85
+ record.update!(
86
+ rate_amount: priced.rate_amount,
87
+ rate_quantity: priced.rate_quantity,
88
+ cost: priced.cost,
89
+ currency: priced.currency,
90
+ cost_status: priced.cost_status,
91
+ price_key: priced.price_key,
92
+ price_source: priced.price_source&.to_s,
93
+ price_source_version: priced.price_source_version
94
+ )
95
+ end
96
+ end
97
+
98
+ def rollup_event_for(call, outcome)
99
+ RollupEvent.new(
100
+ provider: call.provider,
101
+ tracked_at: call.tracked_at,
102
+ pricing_snapshot: outcome[:snapshot],
103
+ total_cost: outcome[:total_cost]
104
+ )
105
+ end
106
+
107
+ def token_usage_from(call)
108
+ TokenUsage.build(
109
+ input_tokens: call.input_tokens,
110
+ output_tokens: call.output_tokens,
111
+ cache_read_input_tokens: call.cache_read_input_tokens,
112
+ cache_write_input_tokens: call.cache_write_input_tokens,
113
+ cache_write_extended_input_tokens: call.cache_write_extended_input_tokens,
114
+ audio_input_tokens: call.audio_input_tokens,
115
+ audio_output_tokens: call.audio_output_tokens,
116
+ image_input_tokens: call.image_input_tokens,
117
+ image_output_tokens: call.image_output_tokens,
118
+ hidden_output_tokens: call.hidden_output_tokens,
119
+ total_tokens: call.total_tokens
120
+ )
121
+ end
122
+
123
+ def billing_line_items_from(call)
124
+ call.line_items.map do |record|
125
+ Billing::LineItem.build(
126
+ kind: record.kind, direction: record.direction, modality: record.modality,
127
+ cache_state: record.cache_state, quantity: record.quantity, unit: record.unit,
128
+ rate_amount: record.rate_amount, rate_quantity: record.rate_quantity,
129
+ cost: record.cost, currency: record.currency, cost_status: record.cost_status,
130
+ pricing_basis: record.pricing_basis, price_key: record.price_key,
131
+ price_source: record.price_source, price_source_version: record.price_source_version,
132
+ provider_field: record.provider_field, provider_item_id: record.provider_item_id,
133
+ details: record.details
134
+ )
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -7,13 +7,11 @@ module LlmCostTracker
7
7
  module Pricing
8
8
  module EffectivePrices
9
9
  class << self
10
- def call(usage:, prices:, pricing_mode:)
10
+ def call(usage:, quantities:, prices:, pricing_mode:)
11
11
  context_tier = context_tier?(usage: usage, prices: prices)
12
12
  orderings = pricing_mode && Mode.parse(pricing_mode).permutations
13
13
 
14
- Billing::Components::TOKEN_PRICED.to_h do |component|
15
- price_key = component.key
16
- tokens = usage.public_send(component.token_key)
14
+ quantities.to_h do |price_key, tokens|
17
15
  price = if tokens.positive?
18
16
  price_for(
19
17
  prices: prices,
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module LlmCostTracker
6
+ module Pricing
7
+ module Estimator
8
+ CHARS_PER_TOKEN = 4
9
+
10
+ def self.call(provider:, model:, request:)
11
+ chars = char_count(request)
12
+ return BigDecimal("0") if chars.zero?
13
+
14
+ estimated_tokens = (chars.to_f / CHARS_PER_TOKEN).ceil
15
+ cost_data = Pricing.cost_for(
16
+ provider: provider,
17
+ model: model,
18
+ tokens: { input: estimated_tokens }
19
+ )
20
+ cost_data && BigDecimal(cost_data[:total_cost].to_s)
21
+ end
22
+
23
+ def self.char_count(value)
24
+ case value
25
+ when String then value.length
26
+ when Hash then value.values.sum { |nested| char_count(nested) }
27
+ when Array then value.sum { |nested| char_count(nested) }
28
+ else 0
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -50,8 +50,11 @@ module LlmCostTracker
50
50
 
51
51
  def explanation(provider:, model:, pricing_mode:, match:, usage:)
52
52
  prices = match&.prices
53
- pricing_mode = Pricing.normalize_mode(pricing_mode)
54
- effective = EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode) if prices
53
+ pricing_mode = Pricing::Mode.normalize(pricing_mode)
54
+ effective = if prices
55
+ EffectivePrices.call(usage: usage, quantities: usage.priced_quantities,
56
+ prices: prices, pricing_mode: pricing_mode)
57
+ end
55
58
 
56
59
  Explanation.new(
57
60
  provider: provider.to_s,
@@ -3,7 +3,8 @@
3
3
  module LlmCostTracker
4
4
  module Pricing
5
5
  module Lookup
6
- Match = Data.define(:source, :key, :prices, :matched_by)
6
+ Match = Data.define(:source, :key, :prices, :matched_by, :currency)
7
+ DEFAULT_CURRENCY = "USD"
7
8
  MUTEX = Mutex.new
8
9
  CACHE_MISS = Object.new.freeze
9
10
  NO_MATCH = Object.new.freeze
@@ -35,6 +36,24 @@ module LlmCostTracker
35
36
  end
36
37
  end
37
38
 
39
+ def prices_file_mtime_iso
40
+ invalidate_cache_if_prices_file_changed!
41
+ signature = @prices_file_signature
42
+ return nil unless signature
43
+
44
+ cached = @prices_file_iso_cache
45
+ return cached[:value] if cached && cached[:mtime] == signature
46
+
47
+ MUTEX.synchronize do
48
+ cached = @prices_file_iso_cache
49
+ return cached[:value] if cached && cached[:mtime] == signature
50
+
51
+ iso = signature.utc.iso8601
52
+ @prices_file_iso_cache = { mtime: signature, value: iso }.freeze
53
+ iso
54
+ end
55
+ end
56
+
38
57
  private
39
58
 
40
59
  def invalidate_cache_if_prices_file_changed!
@@ -62,6 +81,7 @@ module LlmCostTracker
62
81
  @prices_cache = nil
63
82
  @lookup_cache = nil
64
83
  @sorted_price_keys_cache = nil
84
+ @prices_file_iso_cache = nil
65
85
  @prices_file_signature = signature
66
86
  end
67
87
 
@@ -168,7 +188,22 @@ module LlmCostTracker
168
188
  end
169
189
 
170
190
  def match(table:, source:, key:, matched_by:)
171
- Match.new(source: source, key: key, prices: table[key], matched_by: matched_by)
191
+ Match.new(
192
+ source: source,
193
+ key: key,
194
+ prices: table[key],
195
+ matched_by: matched_by,
196
+ currency: source_currency(source)
197
+ )
198
+ end
199
+
200
+ def source_currency(source)
201
+ case source
202
+ when :bundled then Registry.metadata["currency"] || DEFAULT_CURRENCY
203
+ when :prices_file
204
+ Registry.file_metadata(LlmCostTracker.configuration.prices_file)["currency"] || DEFAULT_CURRENCY
205
+ else DEFAULT_CURRENCY
206
+ end
172
207
  end
173
208
 
174
209
  def snapshot_variant?(model, key)
@@ -3,10 +3,39 @@
3
3
  module LlmCostTracker
4
4
  module Pricing
5
5
  class Mode
6
- COMPOUND_MODIFIERS = %w[data_residency].freeze
6
+ STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
7
+ COMPOUND_MODIFIERS = %i[data_residency].freeze
7
8
 
8
9
  attr_reader :modifiers
9
10
 
11
+ def self.normalize(value)
12
+ return nil if value.nil?
13
+
14
+ symbol = normalize_string(value.to_s)
15
+ return nil unless symbol
16
+
17
+ STANDARD_MODE_VALUES.include?(symbol) ? nil : symbol
18
+ end
19
+
20
+ def self.merge(provider_mode, request_mode)
21
+ return normalize(request_mode) if provider_mode.to_s.strip.empty?
22
+
23
+ provider_tokens = tokenize(provider_mode) - STANDARD_MODE_VALUES
24
+ request_host_tokens = tokenize(request_mode || "") & COMPOUND_MODIFIERS
25
+ combined = provider_tokens | request_host_tokens
26
+ return nil if combined.empty?
27
+
28
+ normalize(combined.join("_"))
29
+ end
30
+
31
+ def self.normalize_string(value)
32
+ normalized = value.strip
33
+ return nil if normalized.empty?
34
+
35
+ normalized.downcase.tr("-", "_").to_sym
36
+ end
37
+ private_class_method :normalize_string
38
+
10
39
  def self.parse(value)
11
40
  return value if value.is_a?(self)
12
41
  return new([]) if value.nil?
@@ -21,11 +50,12 @@ module LlmCostTracker
21
50
  break if remaining.empty?
22
51
 
23
52
  compound = COMPOUND_MODIFIERS.find do |token|
24
- remaining == token || remaining.start_with?("#{token}_")
53
+ name = token.name
54
+ remaining == name || remaining.start_with?("#{name}_")
25
55
  end
26
56
  if compound
27
- tokens << compound.to_sym
28
- remaining = remaining.delete_prefix(compound).delete_prefix("_")
57
+ tokens << compound
58
+ remaining = remaining.delete_prefix(compound.name).delete_prefix("_")
29
59
  else
30
60
  first, _, rest = remaining.partition("_")
31
61
  tokens << first.to_sym unless first.empty?
@@ -91,13 +91,6 @@ module LlmCostTracker
91
91
 
92
92
  private
93
93
 
94
- def raw_registry
95
- cached = @raw_registry
96
- return cached if cached
97
-
98
- MUTEX.synchronize { @raw_registry ||= load_raw_registry }
99
- end
100
-
101
94
  def load_raw_registry
102
95
  YAML.safe_load_file(DEFAULT_PRICES_PATH, aliases: false).freeze
103
96
  end
@@ -60,14 +60,15 @@ module LlmCostTracker
60
60
  data = registry.fetch("service_charges", EMPTY_RATES)
61
61
  raise ArgumentError, "#{context} service_charges must be a hash" unless data.is_a?(Hash)
62
62
 
63
+ currency = registry.dig("metadata", "currency") || DEFAULT_CURRENCY
63
64
  data.each_with_object({}) do |(provider, entries), rates|
64
65
  section_context = "#{context} service_charges.#{provider}"
65
- rates[provider] = rates_from_section(entries, context: section_context)
66
+ rates[provider] = rates_from_section(entries, currency: currency, context: section_context)
66
67
  end
67
68
  end
68
69
 
69
70
  def charge_rate(provider:, component:, pricing_mode:)
70
- pricing_mode = Pricing.normalize_mode(pricing_mode)
71
+ pricing_mode = Pricing::Mode.normalize(pricing_mode)
71
72
  match = charge_rate_match(provider: provider, component: component, pricing_mode: pricing_mode)
72
73
  return nil unless match
73
74
 
@@ -84,7 +85,7 @@ module LlmCostTracker
84
85
 
85
86
  private
86
87
 
87
- def rates_from_section(entries, context:)
88
+ def rates_from_section(entries, currency:, context:)
88
89
  raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
89
90
 
90
91
  entries.each_with_object({}) do |(key, amount), rates|
@@ -95,7 +96,7 @@ module LlmCostTracker
95
96
  rate = {
96
97
  amount: amount,
97
98
  quantity: rate_quantity(component),
98
- currency: DEFAULT_CURRENCY,
99
+ currency: currency,
99
100
  source_key: key
100
101
  }
101
102
  component_rates = rates[component.key] ||= { tiers: {} }
@@ -198,12 +199,7 @@ module LlmCostTracker
198
199
  def rate_source_version_for(source)
199
200
  return LlmCostTracker::VERSION if source == :bundled
200
201
 
201
- path = LlmCostTracker.configuration.prices_file
202
- return nil unless path
203
-
204
- File.mtime(path).utc.iso8601
205
- rescue Errno::ENOENT
206
- nil
202
+ Lookup.prices_file_mtime_iso
207
203
  end
208
204
  end
209
205
  end
@@ -2,9 +2,9 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module Pricing
5
- module SyncChangePrinter
6
- class << self
7
- def call(changes, output: $stdout)
5
+ module Sync
6
+ module ChangePrinter
7
+ def self.call(changes, output: $stdout)
8
8
  service_changes = changes["service_charges"]
9
9
  model_changes = changes.except("service_charges")
10
10
 
@@ -13,7 +13,7 @@ module LlmCostTracker
13
13
 
14
14
  def call(path:, registry:)
15
15
  FileUtils.mkdir_p(File.dirname(path))
16
- merged = merge_with_existing(path: path, registry: registry)
16
+ merged = canonicalize(merge_with_existing(path: path, registry: registry))
17
17
  payload = yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
18
18
  temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
19
19
  File.write(temp_path, payload)
@@ -24,6 +24,17 @@ module LlmCostTracker
24
24
 
25
25
  private
26
26
 
27
+ def canonicalize(value)
28
+ case value
29
+ when Hash
30
+ value.sort_by { |key, _| key.to_s }.to_h { |key, nested| [key, canonicalize(nested)] }
31
+ when Array
32
+ value.map { |element| canonicalize(element) }
33
+ else
34
+ value
35
+ end
36
+ end
37
+
27
38
  def merge_with_existing(path:, registry:)
28
39
  existing = read_existing(path)
29
40
  return registry unless existing.is_a?(Hash)
@@ -51,8 +62,9 @@ module LlmCostTracker
51
62
  remote = registry.fetch("service_charges", {})
52
63
  existing.fetch("service_charges", {}).each_with_object(remote.dup) do |(provider, charges), merged|
53
64
  next unless charges.is_a?(Hash)
65
+ next if merged.key?(provider)
54
66
 
55
- merged[provider] = charges.merge(merged.fetch(provider, {}))
67
+ merged[provider] = charges
56
68
  end
57
69
  end
58
70
 
@@ -29,7 +29,7 @@ module LlmCostTracker
29
29
  prices_file = config.prices_file
30
30
  return prices_file.to_s if prices_file
31
31
 
32
- default_output_path
32
+ Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
33
33
  end
34
34
 
35
35
  def configured_remote_url(env: ENV)
@@ -103,14 +103,6 @@ module LlmCostTracker
103
103
 
104
104
  private
105
105
 
106
- def default_output_path
107
- if Rails.root
108
- Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
109
- else
110
- DEFAULT_OUTPUT_PATH
111
- end
112
- end
113
-
114
106
  def normalize_remote_registry(body, url:, response:, today:)
115
107
  registry = parse_registry(body)
116
108
  metadata = registry.fetch("metadata", {})
@@ -6,10 +6,11 @@ module LlmCostTracker
6
6
  module Pricing
7
7
  class Unknown
8
8
  MUTEX = Mutex.new
9
+ WARN_CACHE_LIMIT = 1024
9
10
 
10
11
  class << self
11
- def handle!(model)
12
- model = model.to_s.presence || "unknown"
12
+ def process(model)
13
+ model = model.to_s.presence || Event::UNKNOWN_MODEL
13
14
 
14
15
  case LlmCostTracker.configuration.unknown_pricing_behavior
15
16
  when :ignore
@@ -30,6 +31,8 @@ module LlmCostTracker
30
31
  def warn_missing(model)
31
32
  should_warn = MUTEX.synchronize do
32
33
  @warned_models ||= Set.new
34
+ next false if @warned_models.size >= WARN_CACHE_LIMIT && !@warned_models.include?(model)
35
+
33
36
  @warned_models.add?(model)
34
37
  end
35
38
  return unless should_warn