llm_cost_tracker 0.8.0 → 0.10.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +14 -6
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  26. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  27. data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
  28. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  29. data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
  30. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  31. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  32. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  34. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  35. data/config/routes.rb +3 -2
  36. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  37. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  38. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  39. data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
  40. data/lib/llm_cost_tracker/budget.rb +31 -7
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  43. data/lib/llm_cost_tracker/configuration.rb +72 -17
  44. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  45. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  46. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
  47. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  48. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  49. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  51. data/lib/llm_cost_tracker/doctor.rb +72 -14
  52. data/lib/llm_cost_tracker/engine.rb +8 -0
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +48 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  74. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  75. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
  76. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  77. data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
  78. data/lib/llm_cost_tracker/ingestion.rb +48 -11
  79. data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
  80. data/lib/llm_cost_tracker/integrations/base.rb +35 -15
  81. data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
  82. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
  83. data/lib/llm_cost_tracker/integrations.rb +33 -14
  84. data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
  85. data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
  86. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
  87. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
  88. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
  89. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
  90. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
  91. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
  92. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
  93. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
  94. data/lib/llm_cost_tracker/ledger/store.rb +34 -31
  95. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  96. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
  97. data/lib/llm_cost_tracker/ledger.rb +2 -1
  98. data/lib/llm_cost_tracker/logging.rb +0 -4
  99. data/lib/llm_cost_tracker/masking.rb +39 -0
  100. data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
  101. data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
  102. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  103. data/lib/llm_cost_tracker/parsers/base.rb +53 -43
  104. data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
  105. data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
  106. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
  107. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
  108. data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
  109. data/lib/llm_cost_tracker/parsers.rb +31 -4
  110. data/lib/llm_cost_tracker/prices.json +572 -493
  111. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  112. data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
  113. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  114. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  115. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
  116. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  117. data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
  118. data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
  119. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
  121. data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  123. data/lib/llm_cost_tracker/pricing.rb +117 -44
  124. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  125. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  126. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  127. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  128. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  129. data/lib/llm_cost_tracker/railtie.rb +8 -0
  130. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  131. data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
  132. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
  133. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  134. data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
  135. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
  136. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  137. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  138. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  139. data/lib/llm_cost_tracker/report/data.rb +4 -1
  140. data/lib/llm_cost_tracker/report.rb +0 -4
  141. data/lib/llm_cost_tracker/retention.rb +31 -6
  142. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  143. data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
  144. data/lib/llm_cost_tracker/token_usage.rb +14 -2
  145. data/lib/llm_cost_tracker/tracker.rb +41 -55
  146. data/lib/llm_cost_tracker/version.rb +1 -1
  147. data/lib/llm_cost_tracker.rb +19 -14
  148. data/lib/tasks/llm_cost_tracker.rake +41 -4
  149. metadata +49 -3
  150. 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
@@ -1,22 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../billing/components"
4
+ require_relative "mode"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Pricing
7
8
  module EffectivePrices
8
9
  class << self
9
- def call(usage:, prices:, pricing_mode:)
10
+ def call(usage:, quantities:, prices:, pricing_mode:)
10
11
  context_tier = context_tier?(usage: usage, prices: prices)
12
+ orderings = pricing_mode && Mode.parse(pricing_mode).permutations
11
13
 
12
- Billing::Components::TOKEN_PRICED.to_h do |component|
13
- price_key = component.key
14
- tokens = usage.public_send(component.token_key)
14
+ quantities.to_h do |price_key, tokens|
15
15
  price = if tokens.positive?
16
16
  price_for(
17
17
  prices: prices,
18
18
  key: price_key,
19
- pricing_mode: pricing_mode,
19
+ orderings: orderings,
20
20
  context_tier: context_tier
21
21
  )
22
22
  else
@@ -28,10 +28,9 @@ module LlmCostTracker
28
28
 
29
29
  private
30
30
 
31
- def price_for(prices:, key:, pricing_mode:, context_tier:)
32
- return contextual_price(prices: prices, key: key, context_tier: context_tier) unless pricing_mode
31
+ def price_for(prices:, key:, orderings:, context_tier:)
32
+ return contextual_price(prices: prices, key: key, context_tier: context_tier) unless orderings
33
33
 
34
- orderings = mode_orderings_for(pricing_mode)
35
34
  orderings.each do |mode|
36
35
  direct = contextual_price(prices: prices, key: :"#{mode}_#{key}", context_tier: context_tier)
37
36
  return direct if direct
@@ -41,38 +40,6 @@ module LlmCostTracker
41
40
  derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
42
41
  end
43
42
 
44
- def mode_orderings_for(pricing_mode)
45
- mode_string = pricing_mode.to_s
46
- return [mode_string] unless mode_string.include?("_")
47
-
48
- tokens = tokenize_mode(mode_string)
49
- return [mode_string] if tokens.size <= 1
50
-
51
- [mode_string, *tokens.permutation.map { |permutation| permutation.join("_") }].uniq
52
- end
53
-
54
- def tokenize_mode(mode_string)
55
- remaining = mode_string.dup
56
- tokens = []
57
- loop do
58
- break if remaining.empty?
59
-
60
- compound = COMPOUND_MODE_TOKENS.find { |token| remaining == token || remaining.start_with?("#{token}_") }
61
- if compound
62
- tokens << compound
63
- remaining = remaining.delete_prefix(compound).delete_prefix("_")
64
- else
65
- first, _, rest = remaining.partition("_")
66
- tokens << first
67
- remaining = rest
68
- end
69
- end
70
- tokens
71
- end
72
-
73
- COMPOUND_MODE_TOKENS = %w[data_residency].freeze
74
- private_constant :COMPOUND_MODE_TOKENS
75
-
76
43
  def contextual_price(prices:, key:, context_tier:)
77
44
  return prices[key] unless context_tier
78
45
 
@@ -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
@@ -51,7 +51,10 @@ module LlmCostTracker
51
51
  def explanation(provider:, model:, pricing_mode:, match:, usage:)
52
52
  prices = match&.prices
53
53
  pricing_mode = Pricing.normalize_mode(pricing_mode)
54
- effective = EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode) if prices
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,10 +3,14 @@
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
11
+ LOOKUP_CACHE_LIMIT = 2_048
12
+ PRICE_FILE_RECHECK_INTERVAL = 1.0
13
+ private_constant :PRICE_FILE_RECHECK_INTERVAL
10
14
 
11
15
  class << self
12
16
  def call(provider:, model:)
@@ -14,6 +18,8 @@ module LlmCostTracker
14
18
  model_name = model.to_s
15
19
  return nil if model_name.empty?
16
20
 
21
+ invalidate_cache_if_prices_file_changed!
22
+
17
23
  cache_key = [provider_name, model_name]
18
24
  cached = cached_lookup(cache_key)
19
25
  return cached unless cached.equal?(CACHE_MISS)
@@ -25,14 +31,60 @@ module LlmCostTracker
25
31
 
26
32
  def reset!
27
33
  MUTEX.synchronize do
28
- @prices_cache = nil
29
- @lookup_cache = nil
30
- @sorted_price_keys_cache = nil
34
+ reset_prices_caches!(signature: nil)
35
+ @prices_file_last_check_at = nil
36
+ end
37
+ end
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
31
54
  end
32
55
  end
33
56
 
34
57
  private
35
58
 
59
+ def invalidate_cache_if_prices_file_changed!
60
+ path = LlmCostTracker.configuration.prices_file
61
+
62
+ unless path
63
+ return if @prices_file_signature.nil?
64
+
65
+ MUTEX.synchronize { reset_prices_caches!(signature: nil) unless @prices_file_signature.nil? }
66
+ return
67
+ end
68
+
69
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
+ last_check = @prices_file_last_check_at
71
+ return if last_check && (now - last_check) < PRICE_FILE_RECHECK_INTERVAL
72
+
73
+ signature = File.exist?(path) ? File.mtime(path) : nil
74
+ MUTEX.synchronize do
75
+ @prices_file_last_check_at = now
76
+ reset_prices_caches!(signature: signature) if @prices_file_signature != signature
77
+ end
78
+ end
79
+
80
+ def reset_prices_caches!(signature:)
81
+ @prices_cache = nil
82
+ @lookup_cache = nil
83
+ @sorted_price_keys_cache = nil
84
+ @prices_file_iso_cache = nil
85
+ @prices_file_signature = signature
86
+ end
87
+
36
88
  def lookup_match(provider_name:, model_name:)
37
89
  provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
38
90
  normalized_model = normalize_model_name(model_name)
@@ -86,6 +138,7 @@ module LlmCostTracker
86
138
  def cache_lookup(cache_key, match)
87
139
  MUTEX.synchronize do
88
140
  values = (@lookup_cache || {}).dup
141
+ values.shift while values.size >= LOOKUP_CACHE_LIMIT
89
142
  values[cache_key] = match || NO_MATCH
90
143
  @lookup_cache = values.freeze
91
144
  end
@@ -135,7 +188,22 @@ module LlmCostTracker
135
188
  end
136
189
 
137
190
  def match(table:, source:, key:, matched_by:)
138
- 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
139
207
  end
140
208
 
141
209
  def snapshot_variant?(model, key)
@@ -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
@@ -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
@@ -115,6 +108,7 @@ module LlmCostTracker
115
108
 
116
109
  def non_negative_float(key, value)
117
110
  rate = Float(value)
111
+ raise ArgumentError, "price for #{key.inspect} must be finite (got #{rate})" unless rate.finite?
118
112
  raise ArgumentError, "price for #{key.inspect} must be non-negative (got #{rate})" if rate.negative?
119
113
 
120
114
  rate
@@ -145,8 +139,9 @@ module LlmCostTracker
145
139
 
146
140
  def price_key_for(key)
147
141
  name = key.is_a?(Symbol) ? key.name : key
148
- Billing::Components::TOKEN_PRICED.each do |candidate|
142
+ Billing::Components::REGISTRY.each do |candidate|
149
143
  return candidate.key if candidate.key.name == name
144
+ next unless candidate.token_key
150
145
 
151
146
  suffix = "_#{candidate.key.name}"
152
147
  next unless name.end_with?(suffix)
@@ -60,9 +60,10 @@ 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
 
@@ -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: {} }
@@ -121,14 +122,20 @@ module LlmCostTracker
121
122
 
122
123
  def amount_for(key, amount, context:)
123
124
  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?
125
+ if value.infinite? || value.nan?
126
+ raise ArgumentError,
127
+ "service charge price amount for #{key.inspect} in #{context} must be finite"
128
+ end
129
+ if value.negative?
130
+ raise ArgumentError,
131
+ "service charge price amount for #{key.inspect} in #{context} must be non-negative"
132
+ end
126
133
 
127
134
  value
128
135
  end
129
136
 
130
137
  def rate_quantity(component)
131
- component.unit == :request ? BigDecimal("1000") : BigDecimal("1")
138
+ BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis, 1).to_s)
132
139
  end
133
140
 
134
141
  def charge_rate_match(provider:, component:, pricing_mode:)
@@ -192,12 +199,7 @@ module LlmCostTracker
192
199
  def rate_source_version_for(source)
193
200
  return LlmCostTracker::VERSION if source == :bundled
194
201
 
195
- path = LlmCostTracker.configuration.prices_file
196
- return nil unless path
197
-
198
- File.mtime(path).utc.iso8601
199
- rescue Errno::ENOENT
200
- nil
202
+ Lookup.prices_file_mtime_iso
201
203
  end
202
204
  end
203
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
 
@@ -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 = canonicalize(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,65 @@ module LlmCostTracker
22
24
 
23
25
  private
24
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
+
38
+ def merge_with_existing(path:, registry:)
39
+ existing = read_existing(path)
40
+ return registry unless existing.is_a?(Hash)
41
+
42
+ merged = registry.dup
43
+ merged["models"] = merged_models(registry, existing) if existing["models"].is_a?(Hash)
44
+ if existing["service_charges"].is_a?(Hash)
45
+ merged["service_charges"] = merged_service_charges(registry, existing)
46
+ end
47
+ merged
48
+ end
49
+
50
+ def merged_models(registry, existing)
51
+ merged = registry.fetch("models", {}).dup
52
+ existing.fetch("models", {}).each do |model, attrs|
53
+ next unless attrs.is_a?(Hash) && attrs["_source"].to_s == MANUAL_SOURCE
54
+ next if merged.key?(model)
55
+
56
+ merged[model] = attrs
57
+ end
58
+ merged
59
+ end
60
+
61
+ def merged_service_charges(registry, existing)
62
+ remote = registry.fetch("service_charges", {})
63
+ existing.fetch("service_charges", {}).each_with_object(remote.dup) do |(provider, charges), merged|
64
+ next unless charges.is_a?(Hash)
65
+ next if merged.key?(provider)
66
+
67
+ merged[provider] = charges
68
+ end
69
+ end
70
+
71
+ def read_existing(path)
72
+ return nil unless File.exist?(path)
73
+
74
+ contents = File.read(path)
75
+ return nil if contents.strip.empty?
76
+
77
+ if yaml_file?(path)
78
+ YAML.safe_load(contents, permitted_classes: [Symbol, Date, Time])
79
+ else
80
+ JSON.parse(contents)
81
+ end
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
25
86
  def yaml_file?(path)
26
87
  YAML_EXTENSIONS.include?(File.extname(path).downcase)
27
88
  end
@@ -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", {})
@@ -150,8 +142,10 @@ module LlmCostTracker
150
142
  end
151
143
 
152
144
  def load_registry(path)
145
+ return {} unless File.exist?(path)
146
+
153
147
  YAML.safe_load_file(path, aliases: false) || {}
154
- rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
148
+ rescue Psych::Exception, ArgumentError, TypeError => e
155
149
  raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
156
150
  end
157
151