llm_cost_tracker 0.9.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/README.md +2 -1
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
  6. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  7. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  8. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  9. data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
  10. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  11. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  12. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  13. data/lib/llm_cost_tracker/budget.rb +28 -6
  14. data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
  15. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
  16. data/lib/llm_cost_tracker/configuration.rb +31 -28
  17. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  18. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  19. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  20. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  21. data/lib/llm_cost_tracker/doctor.rb +6 -17
  22. data/lib/llm_cost_tracker/engine.rb +1 -2
  23. data/lib/llm_cost_tracker/errors.rb +3 -2
  24. data/lib/llm_cost_tracker/event.rb +47 -0
  25. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  26. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  27. 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
  28. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  29. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  30. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +23 -8
  31. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  32. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  37. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  38. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -24
  39. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  40. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  41. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  42. data/lib/llm_cost_tracker/integrations/anthropic.rb +28 -42
  43. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  44. data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
  45. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
  46. data/lib/llm_cost_tracker/integrations.rb +14 -13
  47. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  48. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  49. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  50. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  51. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  52. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  53. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  54. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  55. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  56. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  57. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  58. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  59. data/lib/llm_cost_tracker/logging.rb +0 -4
  60. data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
  61. data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
  62. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  63. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  64. data/lib/llm_cost_tracker/parsers/gemini.rb +20 -22
  65. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  66. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
  67. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
  68. data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
  69. data/lib/llm_cost_tracker/parsers.rb +31 -4
  70. data/lib/llm_cost_tracker/prices.json +567 -579
  71. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  72. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  73. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  74. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  75. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  76. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  77. data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
  78. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  79. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  80. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  81. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  82. data/lib/llm_cost_tracker/pricing.rb +72 -27
  83. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  84. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  85. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  86. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  87. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  88. data/lib/llm_cost_tracker/railtie.rb +3 -1
  89. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  90. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  91. data/lib/llm_cost_tracker/reconciliation/importer.rb +1 -0
  92. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
  93. data/lib/llm_cost_tracker/report.rb +0 -4
  94. data/lib/llm_cost_tracker/retention.rb +20 -8
  95. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  96. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  97. data/lib/llm_cost_tracker/tracker.rb +33 -74
  98. data/lib/llm_cost_tracker/version.rb +1 -1
  99. data/lib/llm_cost_tracker.rb +11 -15
  100. data/lib/tasks/llm_cost_tracker.rake +16 -2
  101. metadata +18 -7
  102. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  103. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  104. 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
@@ -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,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)
@@ -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,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: {} }
@@ -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
@@ -1,17 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
+ require "bigdecimal"
4
5
  require "time"
5
6
 
6
7
  require_relative "version"
8
+ require_relative "logging"
7
9
  require_relative "token_usage"
8
10
  require_relative "billing/components"
11
+ require_relative "billing/line_item"
9
12
  require_relative "pricing/mode"
10
13
  require_relative "pricing/registry"
11
14
  require_relative "pricing/lookup"
12
15
  require_relative "pricing/effective_prices"
13
16
  require_relative "pricing/explainer"
14
17
  require_relative "pricing/service_charges"
18
+ require_relative "pricing/estimator"
15
19
 
16
20
  module LlmCostTracker
17
21
  module Pricing # rubocop:disable Metrics/ModuleLength
@@ -19,7 +23,7 @@ module LlmCostTracker
19
23
 
20
24
  STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
21
25
  RATE_DENOMINATOR_TOKENS = 1_000_000
22
- private_constant :STANDARD_MODE_VALUES, :RATE_DENOMINATOR_TOKENS
26
+ private_constant :RATE_DENOMINATOR_TOKENS
23
27
 
24
28
  class << self
25
29
  def normalize_mode(value)
@@ -92,8 +96,49 @@ module LlmCostTracker
92
96
  value ? { total_cost: value } : {}
93
97
  end
94
98
 
99
+ def combine_with_service_lines(cost_data, line_items)
100
+ priced_services = line_items.reject(&:token?).select(&:priced?)
101
+ return cost_data if priced_services.empty?
102
+
103
+ base_currency = base_currency_for(cost_data, priced_services)
104
+ matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
105
+ warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
106
+
107
+ cost = cost_data ? cost_data.dup : {}
108
+ cost[:currency] ||= base_currency.to_s
109
+ return cost if matching.empty?
110
+
111
+ service_total = matching.sum(BigDecimal("0"), &:cost_value)
112
+ base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
113
+ cost[:total_cost] = (base_total + service_total).round(8)
114
+ cost
115
+ end
116
+
117
+ def token_pricing_partial?(token_usage, cost_data)
118
+ return false unless cost_data
119
+
120
+ token_usage.priced_quantities.any? do |key, quantity|
121
+ next false unless quantity.positive?
122
+
123
+ cost_data[Billing::Components::BY_KEY.fetch(key).cost_key].nil?
124
+ end
125
+ end
126
+
95
127
  private
96
128
 
129
+ def base_currency_for(cost_data, priced_services)
130
+ (cost_data && cost_data[:currency]) || priced_services.first.currency || Billing::LineItem::USD
131
+ end
132
+
133
+ def warn_currency_mismatch(lines, base_currency)
134
+ currencies = lines.map { |line| line.currency.to_s }.uniq.sort
135
+ Logging.warn(
136
+ "Service line currency mismatch: header is #{base_currency}, dropping " \
137
+ "#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
138
+ "Per-line costs are still recorded; header total reflects #{base_currency} only."
139
+ )
140
+ end
141
+
97
142
  def normalize_string_mode(value)
98
143
  normalized = value.strip
99
144
  return nil if normalized.empty?
@@ -108,22 +153,18 @@ module LlmCostTracker
108
153
  result[component.cost_key] = cost.round(8) unless cost.nil?
109
154
  end
110
155
  values[:total_cost] = costs.values.compact.sum(BigDecimal("0")).round(8)
156
+ values[:currency] = calculation[:match].currency
111
157
  values
112
158
  end
113
159
 
114
160
  def snapshot_from(calculation)
115
161
  match = calculation[:match]
116
162
  effective = calculation[:effective]
117
- token_usage = calculation[:token_usage]
118
- rates = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, values|
119
- quantity = token_usage.public_send(component.token_key)
120
- price = effective[component.key]
163
+ rates = calculation[:quantities].each_with_object({}) do |(key, quantity), values|
164
+ price = effective[key]
121
165
  next if quantity.zero? || price.nil?
122
166
 
123
- values[component.key] = {
124
- amount: price,
125
- quantity: RATE_DENOMINATOR_TOKENS
126
- }
167
+ values[key] = { amount: price, quantity: RATE_DENOMINATOR_TOKENS }
127
168
  end
128
169
 
129
170
  {
@@ -132,7 +173,7 @@ module LlmCostTracker
132
173
  source_key: match.key,
133
174
  source_version: source_version_for(match.source),
134
175
  matched_by: match.matched_by,
135
- currency: "USD",
176
+ currency: match.currency,
136
177
  rates: rates
137
178
  }
138
179
  end
@@ -142,23 +183,29 @@ module LlmCostTracker
142
183
  return nil unless match
143
184
 
144
185
  token_usage = TokenUsage.build_from_tokens(tokens)
186
+ quantities = token_usage.priced_quantities
145
187
  mode = normalize_mode(pricing_mode)
146
- effective = EffectivePrices.call(usage: token_usage, prices: match.prices, pricing_mode: mode)
147
- return nil unless any_billable_priced?(token_usage, effective)
188
+ effective = EffectivePrices.call(usage: token_usage, quantities: quantities, prices: match.prices,
189
+ pricing_mode: mode)
190
+ return nil unless any_billable_priced?(quantities, effective)
148
191
 
149
- { match: match, effective: effective, token_usage: token_usage, costs: costs_for(token_usage, effective) }
192
+ { match: match, effective: effective, token_usage: token_usage, quantities: quantities,
193
+ costs: costs_for(quantities, effective) }
150
194
  end
151
195
 
152
- def any_billable_priced?(token_usage, effective)
153
- billable = Billing::Components::TOKEN_PRICED.select { |c| token_usage.public_send(c.token_key).positive? }
154
- billable.empty? || billable.any? { |c| effective[c.key] }
155
- end
196
+ def any_billable_priced?(quantities, effective)
197
+ any_billable = false
198
+ quantities.each_pair do |key, quantity|
199
+ next unless quantity.positive?
200
+ return true if effective[key]
156
201
 
157
- def costs_for(usage, effective)
158
- Billing::Components::TOKEN_PRICED.to_h do |component|
159
- tokens = usage.public_send(component.token_key)
160
- [component.key, token_cost(tokens, effective[component.key])]
202
+ any_billable = true
161
203
  end
204
+ !any_billable
205
+ end
206
+
207
+ def costs_for(quantities, effective)
208
+ quantities.to_h { |key, tokens| [key, token_cost(tokens, effective[key])] }
162
209
  end
163
210
 
164
211
  def apply_calculation_to_line_items(line_items, calculation, provider:, pricing_mode:)
@@ -197,6 +244,7 @@ module LlmCostTracker
197
244
  rate_amount: BigDecimal(effective_price.to_s),
198
245
  rate_quantity: BigDecimal(RATE_DENOMINATOR_TOKENS),
199
246
  cost: cost,
247
+ currency: match.currency,
200
248
  cost_status: cost.zero? ? Billing::CostStatus::FREE : Billing::CostStatus::COMPLETE,
201
249
  price_key: component.key,
202
250
  price_source: match.source,
@@ -212,7 +260,7 @@ module LlmCostTracker
212
260
  charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
213
261
  return line_item unless rate
214
262
 
215
- line_item.apply_rate(rate)
263
+ line_item.with_rate(rate)
216
264
  end
217
265
 
218
266
  def model_rate_for(line_item, calculation)
@@ -226,7 +274,7 @@ module LlmCostTracker
226
274
  {
227
275
  amount: BigDecimal(amount.to_s),
228
276
  quantity: BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis).to_s),
229
- currency: "USD",
277
+ currency: match.currency,
230
278
  source: match.source,
231
279
  source_key: "#{match.key}.#{line_item.kind}",
232
280
  source_version: source_version_for(match.source)
@@ -248,13 +296,10 @@ module LlmCostTracker
248
296
  when :bundled
249
297
  LlmCostTracker::VERSION
250
298
  when :prices_file
251
- path = LlmCostTracker.configuration.prices_file
252
- path ? File.mtime(path).utc.iso8601 : nil
299
+ Lookup.prices_file_mtime_iso
253
300
  when :pricing_overrides
254
301
  "configuration"
255
302
  end
256
- rescue Errno::ENOENT
257
- nil
258
303
  end
259
304
 
260
305
  def token_cost(tokens, per_million_price)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Anthropic
6
+ module TierClassification
7
+ DATA_RESIDENCY_GEOS = %w[us].freeze
8
+ STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
9
+
10
+ module_function
11
+
12
+ def data_residency_geo?(geo)
13
+ DATA_RESIDENCY_GEOS.include?(geo.to_s.downcase)
14
+ end
15
+
16
+ def standard_equivalent_tier?(service_tier)
17
+ STANDARD_EQUIVALENT_SERVICE_TIERS.include?(service_tier.to_s)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Azure
6
+ module Hosts
7
+ OPENAI_HOST_PATTERN = /\A[a-z0-9][a-z0-9-]*\.(?:openai\.azure\.com|services\.ai\.azure\.com)\z/i
8
+
9
+ module_function
10
+
11
+ def openai?(host)
12
+ host.to_s.match?(OPENAI_HOST_PATTERN)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Gemini
6
+ module ModelFamilies
7
+ PER_QUERY_GROUNDING_MODEL_PATTERN = /\bgemini-(?:[3-9]|[1-9]\d)\b/i
8
+
9
+ module_function
10
+
11
+ def per_query_grounding?(model)
12
+ model.to_s.match?(PER_QUERY_GROUNDING_MODEL_PATTERN)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end