llm_cost_tracker 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -1,36 +1,30 @@
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
18
22
  extend ServiceCharges
19
23
 
20
- STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
21
24
  RATE_DENOMINATOR_TOKENS = 1_000_000
22
- private_constant :STANDARD_MODE_VALUES, :RATE_DENOMINATOR_TOKENS
25
+ private_constant :RATE_DENOMINATOR_TOKENS
23
26
 
24
27
  class << self
25
- def normalize_mode(value)
26
- return nil if value.nil?
27
-
28
- mode = normalize_string_mode(value.to_s)
29
- return nil unless mode
30
-
31
- STANDARD_MODE_VALUES.include?(mode) ? nil : mode
32
- end
33
-
34
28
  def cost_for(provider:, model:, tokens:, pricing_mode: nil)
35
29
  calculation = calculation_for(
36
30
  provider: provider,
@@ -92,13 +86,47 @@ module LlmCostTracker
92
86
  value ? { total_cost: value } : {}
93
87
  end
94
88
 
89
+ def combine_with_service_lines(cost_data, line_items)
90
+ priced_services = line_items.reject(&:token?).select(&:priced?)
91
+ return cost_data if priced_services.empty?
92
+
93
+ base_currency = base_currency_for(cost_data, priced_services)
94
+ matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
95
+ warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
96
+
97
+ cost = cost_data ? cost_data.dup : {}
98
+ cost[:currency] ||= base_currency.to_s
99
+ return cost if matching.empty?
100
+
101
+ service_total = matching.sum(BigDecimal("0"), &:cost_value)
102
+ base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
103
+ cost[:total_cost] = (base_total + service_total).round(8)
104
+ cost
105
+ end
106
+
107
+ def token_pricing_partial?(token_usage, cost_data)
108
+ return false unless cost_data
109
+
110
+ token_usage.priced_quantities.any? do |key, quantity|
111
+ next false unless quantity.positive?
112
+
113
+ cost_data[Billing::Components::BY_KEY.fetch(key).cost_key].nil?
114
+ end
115
+ end
116
+
95
117
  private
96
118
 
97
- def normalize_string_mode(value)
98
- normalized = value.strip
99
- return nil if normalized.empty?
119
+ def base_currency_for(cost_data, priced_services)
120
+ (cost_data && cost_data[:currency]) || priced_services.first.currency || Billing::LineItem::USD
121
+ end
100
122
 
101
- normalized.downcase.tr("-", "_").to_sym
123
+ def warn_currency_mismatch(lines, base_currency)
124
+ currencies = lines.map { |line| line.currency.to_s }.uniq.sort
125
+ Logging.warn(
126
+ "Service line currency mismatch: header is #{base_currency}, dropping " \
127
+ "#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
128
+ "Per-line costs are still recorded; header total reflects #{base_currency} only."
129
+ )
102
130
  end
103
131
 
104
132
  def cost_from(calculation)
@@ -108,22 +136,18 @@ module LlmCostTracker
108
136
  result[component.cost_key] = cost.round(8) unless cost.nil?
109
137
  end
110
138
  values[:total_cost] = costs.values.compact.sum(BigDecimal("0")).round(8)
139
+ values[:currency] = calculation[:match].currency
111
140
  values
112
141
  end
113
142
 
114
143
  def snapshot_from(calculation)
115
144
  match = calculation[:match]
116
145
  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]
146
+ rates = calculation[:quantities].each_with_object({}) do |(key, quantity), values|
147
+ price = effective[key]
121
148
  next if quantity.zero? || price.nil?
122
149
 
123
- values[component.key] = {
124
- amount: price,
125
- quantity: RATE_DENOMINATOR_TOKENS
126
- }
150
+ values[key] = { amount: price, quantity: RATE_DENOMINATOR_TOKENS }
127
151
  end
128
152
 
129
153
  {
@@ -132,7 +156,7 @@ module LlmCostTracker
132
156
  source_key: match.key,
133
157
  source_version: source_version_for(match.source),
134
158
  matched_by: match.matched_by,
135
- currency: "USD",
159
+ currency: match.currency,
136
160
  rates: rates
137
161
  }
138
162
  end
@@ -142,23 +166,29 @@ module LlmCostTracker
142
166
  return nil unless match
143
167
 
144
168
  token_usage = TokenUsage.build_from_tokens(tokens)
145
- 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)
148
-
149
- { match: match, effective: effective, token_usage: token_usage, costs: costs_for(token_usage, effective) }
169
+ quantities = token_usage.priced_quantities
170
+ mode = Mode.normalize(pricing_mode)
171
+ effective = EffectivePrices.call(usage: token_usage, quantities: quantities, prices: match.prices,
172
+ pricing_mode: mode)
173
+ return nil unless any_billable_priced?(quantities, effective)
174
+
175
+ { match: match, effective: effective, token_usage: token_usage, quantities: quantities,
176
+ costs: costs_for(quantities, effective) }
150
177
  end
151
178
 
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
179
+ def any_billable_priced?(quantities, effective)
180
+ any_billable = false
181
+ quantities.each_pair do |key, quantity|
182
+ next unless quantity.positive?
183
+ return true if effective[key]
156
184
 
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])]
185
+ any_billable = true
161
186
  end
187
+ !any_billable
188
+ end
189
+
190
+ def costs_for(quantities, effective)
191
+ quantities.to_h { |key, tokens| [key, token_cost(tokens, effective[key])] }
162
192
  end
163
193
 
164
194
  def apply_calculation_to_line_items(line_items, calculation, provider:, pricing_mode:)
@@ -197,6 +227,7 @@ module LlmCostTracker
197
227
  rate_amount: BigDecimal(effective_price.to_s),
198
228
  rate_quantity: BigDecimal(RATE_DENOMINATOR_TOKENS),
199
229
  cost: cost,
230
+ currency: match.currency,
200
231
  cost_status: cost.zero? ? Billing::CostStatus::FREE : Billing::CostStatus::COMPLETE,
201
232
  price_key: component.key,
202
233
  price_source: match.source,
@@ -212,7 +243,7 @@ module LlmCostTracker
212
243
  charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
213
244
  return line_item unless rate
214
245
 
215
- line_item.apply_rate(rate)
246
+ line_item.with_rate(rate)
216
247
  end
217
248
 
218
249
  def model_rate_for(line_item, calculation)
@@ -226,7 +257,7 @@ module LlmCostTracker
226
257
  {
227
258
  amount: BigDecimal(amount.to_s),
228
259
  quantity: BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis).to_s),
229
- currency: "USD",
260
+ currency: match.currency,
230
261
  source: match.source,
231
262
  source_key: "#{match.key}.#{line_item.kind}",
232
263
  source_version: source_version_for(match.source)
@@ -248,13 +279,10 @@ module LlmCostTracker
248
279
  when :bundled
249
280
  LlmCostTracker::VERSION
250
281
  when :prices_file
251
- path = LlmCostTracker.configuration.prices_file
252
- path ? File.mtime(path).utc.iso8601 : nil
282
+ Lookup.prices_file_mtime_iso
253
283
  when :pricing_overrides
254
284
  "configuration"
255
285
  end
256
- rescue Errno::ENOENT
257
- nil
258
286
  end
259
287
 
260
288
  def token_cost(tokens, per_million_price)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Anthropic
6
+ module ServerTools
7
+ LINE_ITEMS = {
8
+ web_search_request: :web_search_requests,
9
+ web_fetch_request: :web_fetch_requests,
10
+ code_execution_request: :code_execution_requests
11
+ }.freeze
12
+ end
13
+ end
14
+ end
15
+ 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
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../billing/line_item"
4
+ require_relative "model_families"
5
+
6
+ module LlmCostTracker
7
+ module Providers
8
+ module Openai
9
+ module ServiceCharges
10
+ RESPONSE_OUTPUT_COMPONENTS = {
11
+ "web_search_call" => :web_search_request,
12
+ "file_search_call" => :file_search_call,
13
+ "code_interpreter_call" => :container_session,
14
+ "mcp_call" => :mcp_call
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def line_items_from_output(output_items, request: nil, model: nil)
20
+ deduped = {}
21
+ Array(output_items).each { |item| store_output_item(deduped, item) }
22
+ deduped.values
23
+ .select { |item| billable?(item) }
24
+ .filter_map { |item| build_line_item(item, request: request, model: model) }
25
+ end
26
+
27
+ def service_line_items_for(response, request: nil, model: nil)
28
+ output_items = Array(response["output"])
29
+ output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
30
+ line_items_from_output(output_items, request: request, model: model)
31
+ end
32
+
33
+ CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
34
+ CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
35
+
36
+ def chat_completions_web_search_items(response, model: nil)
37
+ return [] unless response["choices"]
38
+
39
+ provider_field = chat_completions_search_provider_field(response["choices"], model)
40
+ return [] unless provider_field
41
+
42
+ [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
43
+ "provider_field" => provider_field }]
44
+ end
45
+
46
+ def chat_completions_search_provider_field(choices, model)
47
+ return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
48
+ return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
49
+
50
+ nil
51
+ end
52
+
53
+ def chat_completions_used_web_search?(choices)
54
+ Array(choices).any? do |choice|
55
+ Array(choice.dig("message", "annotations")).any? do |annotation|
56
+ annotation.is_a?(Hash) && annotation["type"] == "url_citation"
57
+ end
58
+ end
59
+ end
60
+
61
+ def billable?(item)
62
+ return false unless item.is_a?(Hash)
63
+
64
+ component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
65
+ return false unless component
66
+ return true unless component == :web_search_request
67
+
68
+ action_type = item.dig("action", "type")
69
+ action_type.nil? || action_type == "search"
70
+ end
71
+
72
+ def store_output_item(output_items, item)
73
+ return unless item.is_a?(Hash) && RESPONSE_OUTPUT_COMPONENTS.key?(item["type"])
74
+
75
+ component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
76
+ key = if component == :container_session && item["container_id"]
77
+ "#{component}:#{item['container_id']}"
78
+ else
79
+ item["id"] || "#{item['type']}:#{output_items.length}"
80
+ end
81
+ output_items[key] = item
82
+ end
83
+
84
+ def build_line_item(item, request: nil, model: nil)
85
+ return nil unless item.is_a?(Hash)
86
+
87
+ component_key = component_key_for(item, request: request, model: model)
88
+ return nil unless component_key
89
+
90
+ provider_item_id = if component_key == :container_session
91
+ item["container_id"] || item["id"]
92
+ else
93
+ item["id"]
94
+ end
95
+ Billing::LineItem.build(
96
+ component_key: component_key,
97
+ quantity: 1,
98
+ cost_status: Billing::CostStatus::UNKNOWN,
99
+ pricing_basis: :provider_usage,
100
+ provider_field: item["provider_field"] || "response.output.#{item['type']}",
101
+ provider_item_id: provider_item_id,
102
+ details: line_item_details(item)
103
+ )
104
+ end
105
+
106
+ def component_key_for(item, request:, model:)
107
+ component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
108
+ return component unless component == :web_search_request
109
+ return component unless web_search_preview_used?(request) || chat_completions_search_model?(model)
110
+
111
+ reasoning_model?(model) ? :web_search_preview_request_reasoning : :web_search_preview_request_non_reasoning
112
+ end
113
+
114
+ def web_search_preview_used?(request)
115
+ tools = request && (request[:tools] || request["tools"])
116
+ return false unless tools.respond_to?(:each)
117
+
118
+ tools.any? do |tool|
119
+ type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
120
+ type.to_s.include?("web_search_preview")
121
+ end
122
+ end
123
+
124
+ def chat_completions_search_model?(model)
125
+ return false unless model
126
+
127
+ name = model.to_s.split("/", 2).last
128
+ ModelFamilies.chat_completions_search?(name)
129
+ end
130
+
131
+ def reasoning_model?(model)
132
+ return false unless model
133
+
134
+ name = model.to_s.split("/", 2).last
135
+ ModelFamilies.reasoning?(name)
136
+ end
137
+
138
+ def line_item_details(item)
139
+ {
140
+ "status" => item["status"],
141
+ "action_type" => item.dig("action", "type"),
142
+ "container_id" => item["container_id"]
143
+ }.compact
144
+ end
145
+
146
+ def openai_stream_service_line_items(events, request: nil, model: nil)
147
+ output_items = []
148
+ each_event_data(events) do |data|
149
+ output_items.concat(Array(data.dig("response", "output")))
150
+ output_items << data["item"] if data["item"]
151
+ end
152
+ line_items_from_output(output_items, request: request, model: model)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -12,15 +12,13 @@ module LlmCostTracker
12
12
  require_relative "generators/llm_cost_tracker/install_generator"
13
13
  require_relative "generators/llm_cost_tracker/prices_generator"
14
14
  require_relative "generators/llm_cost_tracker/call_rollups_generator"
15
- require_relative "generators/llm_cost_tracker/durable_ingestion_generator"
15
+ require_relative "generators/llm_cost_tracker/async_ingestion_generator"
16
16
  require_relative "generators/llm_cost_tracker/reconciliation_generator"
17
17
  require_relative "generators/llm_cost_tracker/upgrade_call_rollups_provider_generator"
18
18
  require_relative "generators/llm_cost_tracker/upgrade_image_tokens_generator"
19
19
  require_relative "generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator"
20
- end
21
-
22
- rake_tasks do
23
- load File.expand_path("../tasks/llm_cost_tracker.rake", __dir__)
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"
24
22
  end
25
23
  end
26
24
  end
@@ -85,38 +85,35 @@ module LlmCostTracker
85
85
  end
86
86
 
87
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])}"
88
+ print_section(output, "unmatched provider rows",
89
+ diff.unmatched_provider_rows, diff.unmatched_provider_rows_total) do |row|
90
+ "#{row[:external_id]} (#{row[:match_basis]}): " \
91
+ "#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
95
92
  end
96
93
  end
97
94
 
98
95
  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])}"
96
+ print_section(output, "unmatched local calls",
97
+ diff.unmatched_local_calls, diff.unmatched_local_calls_total) do |row|
98
+ "#{row[:count]} calls / #{row[:total_cost].to_s('F')} #{format_attribution(row[:attribution])}"
106
99
  end
107
100
  end
108
101
 
109
102
  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])}"
103
+ print_section(output, "non-cost evidence",
104
+ diff.non_cost_rows, diff.non_cost_rows_total) do |row|
105
+ "[#{row[:row_type]}/#{row[:meter]}] " \
106
+ "#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
117
107
  end
118
108
  end
119
109
 
110
+ def print_section(output, label, rows, total)
111
+ return if rows.empty?
112
+
113
+ output.puts " #{label}#{truncation_suffix(rows.size, total)}:"
114
+ rows.each { |row| output.puts " #{yield(row)}" }
115
+ end
116
+
120
117
  def truncation_suffix(shown, total)
121
118
  return "" if shown >= total
122
119