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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +6 -2
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
- data/config/routes.rb +1 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +29 -8
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
- data/lib/llm_cost_tracker/configuration.rb +30 -44
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor.rb +80 -25
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- 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
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
- data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
- data/lib/llm_cost_tracker/parsers.rb +29 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +71 -43
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -5
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +31 -12
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
- 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 :
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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[
|
|
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:
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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?(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|