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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +136 -0
- data/README.md +14 -6
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
- data/lib/llm_cost_tracker/budget.rb +31 -7
- data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +72 -17
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- 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/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -14
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +48 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- 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 +3 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
- data/lib/llm_cost_tracker/ingestion.rb +48 -11
- data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
- data/lib/llm_cost_tracker/integrations/base.rb +35 -15
- data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
- data/lib/llm_cost_tracker/integrations.rb +33 -14
- data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
- data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
- data/lib/llm_cost_tracker/ledger/store.rb +34 -31
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
- data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -43
- data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +572 -493
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
- data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
- 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 +62 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +117 -44
- 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/railtie.rb +8 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +4 -1
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +31 -6
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
- data/lib/llm_cost_tracker/token_usage.rb +14 -2
- data/lib/llm_cost_tracker/tracker.rb +41 -55
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +19 -14
- data/lib/tasks/llm_cost_tracker.rake +41 -4
- metadata +49 -3
- 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
|
|
12
|
-
model = model.to_s.presence ||
|
|
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 :
|
|
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 =
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
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[
|
|
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:
|
|
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,
|
|
149
|
-
|
|
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,
|
|
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?(
|
|
155
|
-
|
|
156
|
-
|
|
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(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|