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
@@ -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,30 +1,35 @@
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"
12
+ require_relative "pricing/mode"
9
13
  require_relative "pricing/registry"
10
14
  require_relative "pricing/lookup"
11
15
  require_relative "pricing/effective_prices"
12
16
  require_relative "pricing/explainer"
13
17
  require_relative "pricing/service_charges"
18
+ require_relative "pricing/estimator"
14
19
 
15
20
  module LlmCostTracker
16
- module Pricing
21
+ module Pricing # rubocop:disable Metrics/ModuleLength
17
22
  extend ServiceCharges
18
23
 
19
24
  STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
20
25
  RATE_DENOMINATOR_TOKENS = 1_000_000
21
- private_constant :STANDARD_MODE_VALUES, :RATE_DENOMINATOR_TOKENS
26
+ private_constant :RATE_DENOMINATOR_TOKENS
22
27
 
23
28
  class << self
24
29
  def normalize_mode(value)
25
30
  return nil if value.nil?
26
31
 
27
- mode = value.is_a?(Symbol) ? value.downcase : normalize_string_mode(value)
32
+ mode = normalize_string_mode(value.to_s)
28
33
  return nil unless mode
29
34
 
30
35
  STANDARD_MODE_VALUES.include?(mode) ? nil : mode
@@ -42,29 +47,26 @@ module LlmCostTracker
42
47
  cost_from(calculation)
43
48
  end
44
49
 
45
- def cost_and_snapshot_for(provider:, model:, tokens:, pricing_mode: nil)
50
+ def calculate(provider:, model:, tokens:, line_items:, pricing_mode: nil)
46
51
  calculation = calculation_for(
47
52
  provider: provider,
48
53
  model: model,
49
54
  tokens: tokens,
50
55
  pricing_mode: pricing_mode
51
56
  )
52
- return [nil, nil] unless calculation
53
-
54
- [cost_from(calculation), snapshot_from(calculation)]
57
+ cost_data = calculation && cost_from(calculation)
58
+ snapshot = calculation && snapshot_from(calculation)
59
+ priced = apply_calculation_to_line_items(line_items, calculation,
60
+ provider: provider, pricing_mode: pricing_mode)
61
+ [cost_data, snapshot, priced]
55
62
  end
56
63
 
57
64
  def price_line_items(provider:, model:, line_items:, pricing_mode: nil)
58
65
  token_usage = TokenUsage.build_from_tokens(token_attributes_from(line_items))
59
66
  calculation = calculation_for(provider: provider, model: model, tokens: token_usage, pricing_mode: pricing_mode)
60
67
  snapshot = calculation && snapshot_from(calculation)
61
-
62
- priced = line_items.map do |line_item|
63
- next price_token_line_item(line_item, calculation) if line_item.unit == :token
64
-
65
- price_service_charge_line_item(line_item, provider: provider, pricing_mode: pricing_mode)
66
- end
67
-
68
+ priced = apply_calculation_to_line_items(line_items, calculation,
69
+ provider: provider, pricing_mode: pricing_mode)
68
70
  [priced, snapshot]
69
71
  end
70
72
 
@@ -91,11 +93,52 @@ module LlmCostTracker
91
93
 
92
94
  def stored_cost_attributes(attributes)
93
95
  value = attributes.to_h[:total_cost]
94
- value.nil? ? {} : { total_cost: value }
96
+ value ? { total_cost: value } : {}
97
+ end
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
95
125
  end
96
126
 
97
127
  private
98
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
+
99
142
  def normalize_string_mode(value)
100
143
  normalized = value.strip
101
144
  return nil if normalized.empty?
@@ -109,23 +152,19 @@ module LlmCostTracker
109
152
  cost = costs[component.key]
110
153
  result[component.cost_key] = cost.round(8) unless cost.nil?
111
154
  end
112
- values[:total_cost] = costs.values.compact.sum.round(8)
155
+ values[:total_cost] = costs.values.compact.sum(BigDecimal("0")).round(8)
156
+ values[:currency] = calculation[:match].currency
113
157
  values
114
158
  end
115
159
 
116
160
  def snapshot_from(calculation)
117
161
  match = calculation[:match]
118
162
  effective = calculation[:effective]
119
- token_usage = calculation[:token_usage]
120
- rates = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, values|
121
- quantity = token_usage.public_send(component.token_key)
122
- price = effective[component.key]
163
+ rates = calculation[:quantities].each_with_object({}) do |(key, quantity), values|
164
+ price = effective[key]
123
165
  next if quantity.zero? || price.nil?
124
166
 
125
- values[component.key] = {
126
- amount: price,
127
- quantity: RATE_DENOMINATOR_TOKENS
128
- }
167
+ values[key] = { amount: price, quantity: RATE_DENOMINATOR_TOKENS }
129
168
  end
130
169
 
131
170
  {
@@ -134,7 +173,7 @@ module LlmCostTracker
134
173
  source_key: match.key,
135
174
  source_version: source_version_for(match.source),
136
175
  matched_by: match.matched_by,
137
- currency: "USD",
176
+ currency: match.currency,
138
177
  rates: rates
139
178
  }
140
179
  end
@@ -144,22 +183,39 @@ module LlmCostTracker
144
183
  return nil unless match
145
184
 
146
185
  token_usage = TokenUsage.build_from_tokens(tokens)
186
+ quantities = token_usage.priced_quantities
147
187
  mode = normalize_mode(pricing_mode)
148
- effective = EffectivePrices.call(usage: token_usage, prices: match.prices, pricing_mode: mode)
149
- 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)
150
191
 
151
- { 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) }
152
194
  end
153
195
 
154
- def any_billable_priced?(token_usage, effective)
155
- billable = Billing::Components::TOKEN_PRICED.select { |c| token_usage.public_send(c.token_key).positive? }
156
- billable.empty? || billable.any? { |c| effective[c.key] }
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]
201
+
202
+ any_billable = true
203
+ end
204
+ !any_billable
157
205
  end
158
206
 
159
- def costs_for(usage, effective)
160
- Billing::Components::TOKEN_PRICED.to_h do |component|
161
- tokens = usage.public_send(component.token_key)
162
- [component.key, token_cost(tokens, effective[component.key])]
207
+ def costs_for(quantities, effective)
208
+ quantities.to_h { |key, tokens| [key, token_cost(tokens, effective[key])] }
209
+ end
210
+
211
+ def apply_calculation_to_line_items(line_items, calculation, provider:, pricing_mode:)
212
+ line_items.map do |line_item|
213
+ next price_token_line_item(line_item, calculation) if line_item.unit == :token
214
+
215
+ price_service_charge_line_item(line_item,
216
+ provider: provider,
217
+ calculation: calculation,
218
+ pricing_mode: pricing_mode)
163
219
  end
164
220
  end
165
221
 
@@ -188,6 +244,7 @@ module LlmCostTracker
188
244
  rate_amount: BigDecimal(effective_price.to_s),
189
245
  rate_quantity: BigDecimal(RATE_DENOMINATOR_TOKENS),
190
246
  cost: cost,
247
+ currency: match.currency,
191
248
  cost_status: cost.zero? ? Billing::CostStatus::FREE : Billing::CostStatus::COMPLETE,
192
249
  price_key: component.key,
193
250
  price_source: match.source,
@@ -195,14 +252,33 @@ module LlmCostTracker
195
252
  )
196
253
  end
197
254
 
198
- def price_service_charge_line_item(line_item, provider:, pricing_mode:)
255
+ def price_service_charge_line_item(line_item, provider:, calculation:, pricing_mode:)
199
256
  return line_item if line_item.priced?
200
257
  return line_item unless line_item.billable?
201
258
 
202
- rate = charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
259
+ rate = model_rate_for(line_item, calculation) ||
260
+ charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
203
261
  return line_item unless rate
204
262
 
205
- line_item.apply_rate(rate)
263
+ line_item.with_rate(rate)
264
+ end
265
+
266
+ def model_rate_for(line_item, calculation)
267
+ return nil unless calculation
268
+
269
+ match = calculation[:match]
270
+ amount = match.prices[line_item.kind] || match.prices[line_item.kind.to_s]
271
+ return nil unless amount.is_a?(Numeric)
272
+
273
+ component = Billing::Components::BY_KEY[line_item.kind]
274
+ {
275
+ amount: BigDecimal(amount.to_s),
276
+ quantity: BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis).to_s),
277
+ currency: match.currency,
278
+ source: match.source,
279
+ source_key: "#{match.key}.#{line_item.kind}",
280
+ source_version: source_version_for(match.source)
281
+ }
206
282
  end
207
283
 
208
284
  def component_for_line_item(line_item)
@@ -220,20 +296,17 @@ module LlmCostTracker
220
296
  when :bundled
221
297
  LlmCostTracker::VERSION
222
298
  when :prices_file
223
- path = LlmCostTracker.configuration.prices_file
224
- path ? File.mtime(path).utc.iso8601 : nil
299
+ Lookup.prices_file_mtime_iso
225
300
  when :pricing_overrides
226
301
  "configuration"
227
302
  end
228
- rescue Errno::ENOENT
229
- nil
230
303
  end
231
304
 
232
305
  def token_cost(tokens, per_million_price)
233
- return 0.0 if tokens.zero?
306
+ return BigDecimal("0") if tokens.zero?
234
307
  return nil if per_million_price.nil?
235
308
 
236
- (tokens * per_million_price) / RATE_DENOMINATOR_TOKENS
309
+ (BigDecimal(tokens.to_s) * BigDecimal(per_million_price.to_s)) / RATE_DENOMINATOR_TOKENS
237
310
  end
238
311
  end
239
312
  end
@@ -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
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Openai
6
+ module Hosts
7
+ API_HOSTS = %w[
8
+ api.openai.com
9
+ us.api.openai.com
10
+ eu.api.openai.com
11
+ au.api.openai.com
12
+ ca.api.openai.com
13
+ jp.api.openai.com
14
+ in.api.openai.com
15
+ sg.api.openai.com
16
+ kr.api.openai.com
17
+ gb.api.openai.com
18
+ ae.api.openai.com
19
+ ].freeze
20
+
21
+ DATA_RESIDENCY_HOST_PATTERN = /\A[a-z]{2,3}\.api\.openai\.com\z/
22
+
23
+ module_function
24
+
25
+ def api?(host)
26
+ API_HOSTS.include?(host.to_s.downcase)
27
+ end
28
+
29
+ def data_residency?(host)
30
+ host.to_s.downcase.match?(DATA_RESIDENCY_HOST_PATTERN)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Openai
6
+ module ModelFamilies
7
+ DATA_RESIDENCY_MODEL_PATTERN =
8
+ /\Agpt-5\.(?:4|5)(?:-(?:mini|nano|pro|codex(?:-mini|-max)?))?(?:-\d{4}-\d{2}-\d{2})?\z/
9
+
10
+ IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
11
+
12
+ CHARACTER_BILLED_TTS_MODEL_PATTERN = /\Atts-1(-hd)?\z/
13
+
14
+ REASONING_MODEL_PATTERNS = [
15
+ /\Agpt-5(\b|[\d.-])/i,
16
+ /\Ao\d+(\b|[\d.-])/i
17
+ ].freeze
18
+
19
+ NON_REASONING_GPT5_PATTERN = /\Agpt-5(?:\.\d+)?-chat\b/i
20
+
21
+ CHAT_COMPLETIONS_SEARCH_MODEL_PATTERN = /-search-(?:preview|api)\b/i
22
+
23
+ module_function
24
+
25
+ def data_residency?(model)
26
+ model.to_s.match?(DATA_RESIDENCY_MODEL_PATTERN)
27
+ end
28
+
29
+ def image_output?(model)
30
+ model.to_s.match?(IMAGE_OUTPUT_MODEL_PATTERN)
31
+ end
32
+
33
+ def character_billed_tts?(model)
34
+ model.to_s.match?(CHARACTER_BILLED_TTS_MODEL_PATTERN)
35
+ end
36
+
37
+ def chat_completions_search?(model)
38
+ model.to_s.match?(CHAT_COMPLETIONS_SEARCH_MODEL_PATTERN)
39
+ end
40
+
41
+ def reasoning?(model)
42
+ name = model.to_s
43
+ return false if name.empty?
44
+ return false if NON_REASONING_GPT5_PATTERN.match?(name)
45
+
46
+ REASONING_MODEL_PATTERNS.any? { |pattern| pattern.match?(name) }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -11,6 +11,14 @@ module LlmCostTracker
11
11
  generators do
12
12
  require_relative "generators/llm_cost_tracker/install_generator"
13
13
  require_relative "generators/llm_cost_tracker/prices_generator"
14
+ require_relative "generators/llm_cost_tracker/call_rollups_generator"
15
+ require_relative "generators/llm_cost_tracker/async_ingestion_generator"
16
+ require_relative "generators/llm_cost_tracker/reconciliation_generator"
17
+ require_relative "generators/llm_cost_tracker/upgrade_call_rollups_provider_generator"
18
+ require_relative "generators/llm_cost_tracker/upgrade_image_tokens_generator"
19
+ require_relative "generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator"
20
+ require_relative "generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator"
21
+ require_relative "generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator"
14
22
  end
15
23
 
16
24
  rake_tasks do
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "reconciliation"
6
+
7
+ module LlmCostTracker
8
+ module ReconcileTasks
9
+ SOURCE_PARSERS = {
10
+ "openai" => Reconciliation::Sources::OpenaiUsage,
11
+ "anthropic" => Reconciliation::Sources::AnthropicUsage
12
+ }.freeze
13
+ GENERIC_SOURCES = %w[csv].freeze
14
+
15
+ module_function
16
+
17
+ def run_import(env: ENV, output: $stdout, error_output: $stderr)
18
+ result = import_from_env(env: env)
19
+ output.puts "llm_cost_tracker: imported #{result.total_imported} rows " \
20
+ "(inserted=#{result.inserted}, updated=#{result.updated}, skipped=#{result.skipped})"
21
+ result.errors.each { |error| error_output.puts " error: #{error}" }
22
+ raise "llm_cost_tracker: reconcile import had errors" unless result.success?
23
+
24
+ result
25
+ end
26
+
27
+ def run_diff(env: ENV, output: $stdout)
28
+ diff = diff_from_env(env: env)
29
+ print_diff(diff, output: output)
30
+ diff
31
+ end
32
+
33
+ def import_from_env(env: ENV)
34
+ source = required_env(env, "SOURCE")
35
+ input_path = required_env(env, "INPUT")
36
+ raise ArgumentError, "INPUT file not found: #{input_path}" unless File.exist?(input_path)
37
+
38
+ payload = JSON.parse(File.read(input_path))
39
+ rows = parse_rows(source: source, payload: payload)
40
+ Reconciliation.import(source: source.to_sym, rows: rows, provider: env["PROVIDER"])
41
+ end
42
+
43
+ def diff_from_env(env: ENV)
44
+ source = required_env(env, "SOURCE")
45
+ period_start = Date.parse(required_env(env, "PERIOD_START"))
46
+ period_end = Date.parse(required_env(env, "PERIOD_END"))
47
+ Reconciliation.diff(source: source.to_sym, period_start: period_start, period_end: period_end,
48
+ provider: env["PROVIDER"],
49
+ drilldown_limit: parse_drilldown_limit(env["DRILLDOWN_LIMIT"]))
50
+ end
51
+
52
+ def parse_drilldown_limit(value)
53
+ return Reconciliation::Diff::DEFAULT_DRILLDOWN_LIMIT if value.nil? || value.to_s.empty?
54
+ return nil if value.to_s.downcase == "all"
55
+
56
+ Integer(value)
57
+ end
58
+
59
+ def print_diff(diff, output: $stdout)
60
+ output.puts "llm_cost_tracker: reconciliation diff for #{diff.source} " \
61
+ "#{diff.period_start}..#{diff.period_end}"
62
+ output.puts " provider_total: #{diff.provider_total.to_s('F')} #{diff.currency}"
63
+ output.puts " local_total: #{diff.local_total.to_s('F')} #{diff.currency} " \
64
+ "(from #{diff.local_total_source})"
65
+ output.puts " delta: #{diff.delta_amount.to_s('F')} (#{diff.delta_percent || 'n/a'}%)"
66
+ print_unmatched_provider_rows(diff, output)
67
+ print_unmatched_local_calls(diff, output)
68
+ print_non_cost_rows(diff, output)
69
+ end
70
+
71
+ def parse_rows(source:, payload:)
72
+ parser = SOURCE_PARSERS[source.to_s]
73
+ return parser.parse(payload) if parser
74
+ return Array(payload["rows"]) if GENERIC_SOURCES.include?(source.to_s)
75
+
76
+ known = (SOURCE_PARSERS.keys + GENERIC_SOURCES).join(", ")
77
+ raise ArgumentError, "unknown SOURCE #{source.inspect}; known sources: #{known}"
78
+ end
79
+
80
+ def required_env(env, key)
81
+ value = env[key].to_s.strip
82
+ raise ArgumentError, "missing #{key}" if value.empty?
83
+
84
+ value
85
+ end
86
+
87
+ def print_unmatched_provider_rows(diff, output)
88
+ return if diff.unmatched_provider_rows.empty?
89
+
90
+ output.puts " unmatched provider rows#{truncation_suffix(diff.unmatched_provider_rows.size,
91
+ diff.unmatched_provider_rows_total)}:"
92
+ diff.unmatched_provider_rows.each do |row|
93
+ output.puts " #{row[:external_id]} (#{row[:match_basis]}): " \
94
+ "#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
95
+ end
96
+ end
97
+
98
+ def print_unmatched_local_calls(diff, output)
99
+ return if diff.unmatched_local_calls.empty?
100
+
101
+ output.puts " unmatched local calls#{truncation_suffix(diff.unmatched_local_calls.size,
102
+ diff.unmatched_local_calls_total)}:"
103
+ diff.unmatched_local_calls.each do |row|
104
+ output.puts " #{row[:count]} calls / #{row[:total_cost].to_s('F')} " \
105
+ "#{format_attribution(row[:attribution])}"
106
+ end
107
+ end
108
+
109
+ def print_non_cost_rows(diff, output)
110
+ return if diff.non_cost_rows.empty?
111
+
112
+ output.puts " non-cost evidence#{truncation_suffix(diff.non_cost_rows.size,
113
+ diff.non_cost_rows_total)}:"
114
+ diff.non_cost_rows.each do |row|
115
+ output.puts " [#{row[:row_type]}/#{row[:meter]}] #{format_amount(row[:billed_amount])} " \
116
+ "#{format_attribution(row[:attribution])}"
117
+ end
118
+ end
119
+
120
+ def truncation_suffix(shown, total)
121
+ return "" if shown >= total
122
+
123
+ " (showing #{shown} of #{total} — pass DRILLDOWN_LIMIT=all to see every row)"
124
+ end
125
+
126
+ def format_amount(value)
127
+ value.nil? ? "n/a" : value.to_s("F")
128
+ end
129
+
130
+ def format_attribution(attribution)
131
+ LlmCostTracker::Masking.format_attribution(attribution, separator: ",")
132
+ end
133
+ end
134
+ end