llm_cost_tracker 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -1
- data/README.md +2 -1
- data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- 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 +28 -6
- data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +31 -28
- 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 +6 -17
- 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 +23 -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 +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 +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 +3 -24
- 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 +28 -42
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
- 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/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
- data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
- 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 +20 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
- data/lib/llm_cost_tracker/parsers.rb +31 -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 +4 -1
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
- 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 +72 -27
- 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 +3 -1
- 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 +1 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
- 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 +18 -7
- 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/usage_capture.rb +0 -58
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../pricing"
|
|
4
|
+
require_relative "../billing/cost_status"
|
|
5
|
+
require_relative "../billing/line_item"
|
|
6
|
+
require_relative "../ledger/rollups"
|
|
7
|
+
require_relative "../token_usage"
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
module Pricing
|
|
11
|
+
class Backfill
|
|
12
|
+
Result = Data.define(:examined, :recomputed, :still_unknown)
|
|
13
|
+
RollupEvent = Data.define(:provider, :tracked_at, :pricing_snapshot, :total_cost)
|
|
14
|
+
|
|
15
|
+
DEFAULT_BATCH_SIZE = 500
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def call(scope: default_scope, batch_size: DEFAULT_BATCH_SIZE)
|
|
19
|
+
examined = 0
|
|
20
|
+
recomputed = 0
|
|
21
|
+
|
|
22
|
+
scope.includes(:line_items).find_in_batches(batch_size: batch_size) do |batch|
|
|
23
|
+
rollup_events = []
|
|
24
|
+
LlmCostTracker::Call.transaction do
|
|
25
|
+
batch.each do |call|
|
|
26
|
+
examined += 1
|
|
27
|
+
outcome = recompute_for(call)
|
|
28
|
+
next unless outcome
|
|
29
|
+
|
|
30
|
+
persist!(call, outcome)
|
|
31
|
+
rollup_events << rollup_event_for(call, outcome)
|
|
32
|
+
recomputed += 1
|
|
33
|
+
end
|
|
34
|
+
Ledger::Rollups.increment_many!(rollup_events) if rollup_events.any?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Result.new(examined: examined, recomputed: recomputed, still_unknown: examined - recomputed)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def default_scope
|
|
42
|
+
LlmCostTracker::Call.where(total_cost: nil)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def recompute_for(call)
|
|
48
|
+
token_usage = token_usage_from(call)
|
|
49
|
+
billing_items = billing_line_items_from(call)
|
|
50
|
+
cost_data, snapshot, priced = Pricing.calculate(
|
|
51
|
+
provider: call.provider, model: call.model,
|
|
52
|
+
tokens: token_usage, line_items: billing_items,
|
|
53
|
+
pricing_mode: call.pricing_mode
|
|
54
|
+
)
|
|
55
|
+
return nil unless cost_data
|
|
56
|
+
|
|
57
|
+
full_cost = Pricing.combine_with_service_lines(cost_data, priced)
|
|
58
|
+
total_cost = full_cost[:total_cost]
|
|
59
|
+
return nil if total_cost.nil?
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
snapshot: snapshot,
|
|
63
|
+
priced_line_items: priced,
|
|
64
|
+
total_cost: total_cost,
|
|
65
|
+
cost_status: Billing::CostStatus.call(
|
|
66
|
+
token_usage: token_usage,
|
|
67
|
+
usage_source: call.usage_source&.to_sym,
|
|
68
|
+
token_cost: cost_data,
|
|
69
|
+
token_pricing_partial: Pricing.token_pricing_partial?(token_usage, cost_data),
|
|
70
|
+
service_line_items: priced.reject(&:token?),
|
|
71
|
+
total_cost: total_cost
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def persist!(call, outcome)
|
|
77
|
+
call.update!(
|
|
78
|
+
total_cost: outcome[:total_cost],
|
|
79
|
+
pricing_snapshot: outcome[:snapshot],
|
|
80
|
+
cost_status: outcome[:cost_status]
|
|
81
|
+
)
|
|
82
|
+
call.line_items.to_a.zip(outcome[:priced_line_items]).each do |record, priced|
|
|
83
|
+
next if priced.nil?
|
|
84
|
+
|
|
85
|
+
record.update!(
|
|
86
|
+
rate_amount: priced.rate_amount,
|
|
87
|
+
rate_quantity: priced.rate_quantity,
|
|
88
|
+
cost: priced.cost,
|
|
89
|
+
currency: priced.currency,
|
|
90
|
+
cost_status: priced.cost_status,
|
|
91
|
+
price_key: priced.price_key,
|
|
92
|
+
price_source: priced.price_source&.to_s,
|
|
93
|
+
price_source_version: priced.price_source_version
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def rollup_event_for(call, outcome)
|
|
99
|
+
RollupEvent.new(
|
|
100
|
+
provider: call.provider,
|
|
101
|
+
tracked_at: call.tracked_at,
|
|
102
|
+
pricing_snapshot: outcome[:snapshot],
|
|
103
|
+
total_cost: outcome[:total_cost]
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def token_usage_from(call)
|
|
108
|
+
TokenUsage.build(
|
|
109
|
+
input_tokens: call.input_tokens,
|
|
110
|
+
output_tokens: call.output_tokens,
|
|
111
|
+
cache_read_input_tokens: call.cache_read_input_tokens,
|
|
112
|
+
cache_write_input_tokens: call.cache_write_input_tokens,
|
|
113
|
+
cache_write_extended_input_tokens: call.cache_write_extended_input_tokens,
|
|
114
|
+
audio_input_tokens: call.audio_input_tokens,
|
|
115
|
+
audio_output_tokens: call.audio_output_tokens,
|
|
116
|
+
image_input_tokens: call.image_input_tokens,
|
|
117
|
+
image_output_tokens: call.image_output_tokens,
|
|
118
|
+
hidden_output_tokens: call.hidden_output_tokens,
|
|
119
|
+
total_tokens: call.total_tokens
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def billing_line_items_from(call)
|
|
124
|
+
call.line_items.map do |record|
|
|
125
|
+
Billing::LineItem.build(
|
|
126
|
+
kind: record.kind, direction: record.direction, modality: record.modality,
|
|
127
|
+
cache_state: record.cache_state, quantity: record.quantity, unit: record.unit,
|
|
128
|
+
rate_amount: record.rate_amount, rate_quantity: record.rate_quantity,
|
|
129
|
+
cost: record.cost, currency: record.currency, cost_status: record.cost_status,
|
|
130
|
+
pricing_basis: record.pricing_basis, price_key: record.price_key,
|
|
131
|
+
price_source: record.price_source, price_source_version: record.price_source_version,
|
|
132
|
+
provider_field: record.provider_field, provider_item_id: record.provider_item_id,
|
|
133
|
+
details: record.details
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -7,13 +7,11 @@ module LlmCostTracker
|
|
|
7
7
|
module Pricing
|
|
8
8
|
module EffectivePrices
|
|
9
9
|
class << self
|
|
10
|
-
def call(usage:, prices:, pricing_mode:)
|
|
10
|
+
def call(usage:, quantities:, prices:, pricing_mode:)
|
|
11
11
|
context_tier = context_tier?(usage: usage, prices: prices)
|
|
12
12
|
orderings = pricing_mode && Mode.parse(pricing_mode).permutations
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
price_key = component.key
|
|
16
|
-
tokens = usage.public_send(component.token_key)
|
|
14
|
+
quantities.to_h do |price_key, tokens|
|
|
17
15
|
price = if tokens.positive?
|
|
18
16
|
price_for(
|
|
19
17
|
prices: prices,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Pricing
|
|
7
|
+
module Estimator
|
|
8
|
+
CHARS_PER_TOKEN = 4
|
|
9
|
+
|
|
10
|
+
def self.call(provider:, model:, request:)
|
|
11
|
+
chars = char_count(request)
|
|
12
|
+
return BigDecimal("0") if chars.zero?
|
|
13
|
+
|
|
14
|
+
estimated_tokens = (chars.to_f / CHARS_PER_TOKEN).ceil
|
|
15
|
+
cost_data = Pricing.cost_for(
|
|
16
|
+
provider: provider,
|
|
17
|
+
model: model,
|
|
18
|
+
tokens: { input: estimated_tokens }
|
|
19
|
+
)
|
|
20
|
+
cost_data && BigDecimal(cost_data[:total_cost].to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.char_count(value)
|
|
24
|
+
case value
|
|
25
|
+
when String then value.length
|
|
26
|
+
when Hash then value.values.sum { |nested| char_count(nested) }
|
|
27
|
+
when Array then value.sum { |nested| char_count(nested) }
|
|
28
|
+
else 0
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -51,7 +51,10 @@ module LlmCostTracker
|
|
|
51
51
|
def explanation(provider:, model:, pricing_mode:, match:, usage:)
|
|
52
52
|
prices = match&.prices
|
|
53
53
|
pricing_mode = Pricing.normalize_mode(pricing_mode)
|
|
54
|
-
effective =
|
|
54
|
+
effective = if prices
|
|
55
|
+
EffectivePrices.call(usage: usage, quantities: usage.priced_quantities,
|
|
56
|
+
prices: prices, pricing_mode: pricing_mode)
|
|
57
|
+
end
|
|
55
58
|
|
|
56
59
|
Explanation.new(
|
|
57
60
|
provider: provider.to_s,
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Pricing
|
|
5
5
|
module Lookup
|
|
6
|
-
Match = Data.define(:source, :key, :prices, :matched_by)
|
|
6
|
+
Match = Data.define(:source, :key, :prices, :matched_by, :currency)
|
|
7
|
+
DEFAULT_CURRENCY = "USD"
|
|
7
8
|
MUTEX = Mutex.new
|
|
8
9
|
CACHE_MISS = Object.new.freeze
|
|
9
10
|
NO_MATCH = Object.new.freeze
|
|
@@ -35,6 +36,24 @@ module LlmCostTracker
|
|
|
35
36
|
end
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
def prices_file_mtime_iso
|
|
40
|
+
invalidate_cache_if_prices_file_changed!
|
|
41
|
+
signature = @prices_file_signature
|
|
42
|
+
return nil unless signature
|
|
43
|
+
|
|
44
|
+
cached = @prices_file_iso_cache
|
|
45
|
+
return cached[:value] if cached && cached[:mtime] == signature
|
|
46
|
+
|
|
47
|
+
MUTEX.synchronize do
|
|
48
|
+
cached = @prices_file_iso_cache
|
|
49
|
+
return cached[:value] if cached && cached[:mtime] == signature
|
|
50
|
+
|
|
51
|
+
iso = signature.utc.iso8601
|
|
52
|
+
@prices_file_iso_cache = { mtime: signature, value: iso }.freeze
|
|
53
|
+
iso
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
38
57
|
private
|
|
39
58
|
|
|
40
59
|
def invalidate_cache_if_prices_file_changed!
|
|
@@ -62,6 +81,7 @@ module LlmCostTracker
|
|
|
62
81
|
@prices_cache = nil
|
|
63
82
|
@lookup_cache = nil
|
|
64
83
|
@sorted_price_keys_cache = nil
|
|
84
|
+
@prices_file_iso_cache = nil
|
|
65
85
|
@prices_file_signature = signature
|
|
66
86
|
end
|
|
67
87
|
|
|
@@ -168,7 +188,22 @@ module LlmCostTracker
|
|
|
168
188
|
end
|
|
169
189
|
|
|
170
190
|
def match(table:, source:, key:, matched_by:)
|
|
171
|
-
Match.new(
|
|
191
|
+
Match.new(
|
|
192
|
+
source: source,
|
|
193
|
+
key: key,
|
|
194
|
+
prices: table[key],
|
|
195
|
+
matched_by: matched_by,
|
|
196
|
+
currency: source_currency(source)
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def source_currency(source)
|
|
201
|
+
case source
|
|
202
|
+
when :bundled then Registry.metadata["currency"] || DEFAULT_CURRENCY
|
|
203
|
+
when :prices_file
|
|
204
|
+
Registry.file_metadata(LlmCostTracker.configuration.prices_file)["currency"] || DEFAULT_CURRENCY
|
|
205
|
+
else DEFAULT_CURRENCY
|
|
206
|
+
end
|
|
172
207
|
end
|
|
173
208
|
|
|
174
209
|
def snapshot_variant?(model, key)
|
|
@@ -91,13 +91,6 @@ module LlmCostTracker
|
|
|
91
91
|
|
|
92
92
|
private
|
|
93
93
|
|
|
94
|
-
def raw_registry
|
|
95
|
-
cached = @raw_registry
|
|
96
|
-
return cached if cached
|
|
97
|
-
|
|
98
|
-
MUTEX.synchronize { @raw_registry ||= load_raw_registry }
|
|
99
|
-
end
|
|
100
|
-
|
|
101
94
|
def load_raw_registry
|
|
102
95
|
YAML.safe_load_file(DEFAULT_PRICES_PATH, aliases: false).freeze
|
|
103
96
|
end
|
|
@@ -60,9 +60,10 @@ module LlmCostTracker
|
|
|
60
60
|
data = registry.fetch("service_charges", EMPTY_RATES)
|
|
61
61
|
raise ArgumentError, "#{context} service_charges must be a hash" unless data.is_a?(Hash)
|
|
62
62
|
|
|
63
|
+
currency = registry.dig("metadata", "currency") || DEFAULT_CURRENCY
|
|
63
64
|
data.each_with_object({}) do |(provider, entries), rates|
|
|
64
65
|
section_context = "#{context} service_charges.#{provider}"
|
|
65
|
-
rates[provider] = rates_from_section(entries, context: section_context)
|
|
66
|
+
rates[provider] = rates_from_section(entries, currency: currency, context: section_context)
|
|
66
67
|
end
|
|
67
68
|
end
|
|
68
69
|
|
|
@@ -84,7 +85,7 @@ module LlmCostTracker
|
|
|
84
85
|
|
|
85
86
|
private
|
|
86
87
|
|
|
87
|
-
def rates_from_section(entries, context:)
|
|
88
|
+
def rates_from_section(entries, currency:, context:)
|
|
88
89
|
raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
|
|
89
90
|
|
|
90
91
|
entries.each_with_object({}) do |(key, amount), rates|
|
|
@@ -95,7 +96,7 @@ module LlmCostTracker
|
|
|
95
96
|
rate = {
|
|
96
97
|
amount: amount,
|
|
97
98
|
quantity: rate_quantity(component),
|
|
98
|
-
currency:
|
|
99
|
+
currency: currency,
|
|
99
100
|
source_key: key
|
|
100
101
|
}
|
|
101
102
|
component_rates = rates[component.key] ||= { tiers: {} }
|
|
@@ -198,12 +199,7 @@ module LlmCostTracker
|
|
|
198
199
|
def rate_source_version_for(source)
|
|
199
200
|
return LlmCostTracker::VERSION if source == :bundled
|
|
200
201
|
|
|
201
|
-
|
|
202
|
-
return nil unless path
|
|
203
|
-
|
|
204
|
-
File.mtime(path).utc.iso8601
|
|
205
|
-
rescue Errno::ENOENT
|
|
206
|
-
nil
|
|
202
|
+
Lookup.prices_file_mtime_iso
|
|
207
203
|
end
|
|
208
204
|
end
|
|
209
205
|
end
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Pricing
|
|
5
|
-
module
|
|
6
|
-
|
|
7
|
-
def call(changes, output: $stdout)
|
|
5
|
+
module Sync
|
|
6
|
+
module ChangePrinter
|
|
7
|
+
def self.call(changes, output: $stdout)
|
|
8
8
|
service_changes = changes["service_charges"]
|
|
9
9
|
model_changes = changes.except("service_charges")
|
|
10
10
|
|
|
@@ -13,7 +13,7 @@ module LlmCostTracker
|
|
|
13
13
|
|
|
14
14
|
def call(path:, registry:)
|
|
15
15
|
FileUtils.mkdir_p(File.dirname(path))
|
|
16
|
-
merged = merge_with_existing(path: path, registry: registry)
|
|
16
|
+
merged = canonicalize(merge_with_existing(path: path, registry: registry))
|
|
17
17
|
payload = yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
|
|
18
18
|
temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
19
19
|
File.write(temp_path, payload)
|
|
@@ -24,6 +24,17 @@ module LlmCostTracker
|
|
|
24
24
|
|
|
25
25
|
private
|
|
26
26
|
|
|
27
|
+
def canonicalize(value)
|
|
28
|
+
case value
|
|
29
|
+
when Hash
|
|
30
|
+
value.sort_by { |key, _| key.to_s }.to_h { |key, nested| [key, canonicalize(nested)] }
|
|
31
|
+
when Array
|
|
32
|
+
value.map { |element| canonicalize(element) }
|
|
33
|
+
else
|
|
34
|
+
value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
27
38
|
def merge_with_existing(path:, registry:)
|
|
28
39
|
existing = read_existing(path)
|
|
29
40
|
return registry unless existing.is_a?(Hash)
|
|
@@ -51,8 +62,9 @@ module LlmCostTracker
|
|
|
51
62
|
remote = registry.fetch("service_charges", {})
|
|
52
63
|
existing.fetch("service_charges", {}).each_with_object(remote.dup) do |(provider, charges), merged|
|
|
53
64
|
next unless charges.is_a?(Hash)
|
|
65
|
+
next if merged.key?(provider)
|
|
54
66
|
|
|
55
|
-
merged[provider] = charges
|
|
67
|
+
merged[provider] = charges
|
|
56
68
|
end
|
|
57
69
|
end
|
|
58
70
|
|
|
@@ -29,7 +29,7 @@ module LlmCostTracker
|
|
|
29
29
|
prices_file = config.prices_file
|
|
30
30
|
return prices_file.to_s if prices_file
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def configured_remote_url(env: ENV)
|
|
@@ -103,14 +103,6 @@ module LlmCostTracker
|
|
|
103
103
|
|
|
104
104
|
private
|
|
105
105
|
|
|
106
|
-
def default_output_path
|
|
107
|
-
if Rails.root
|
|
108
|
-
Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
|
|
109
|
-
else
|
|
110
|
-
DEFAULT_OUTPUT_PATH
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
106
|
def normalize_remote_registry(body, url:, response:, today:)
|
|
115
107
|
registry = parse_registry(body)
|
|
116
108
|
metadata = registry.fetch("metadata", {})
|
|
@@ -6,10 +6,11 @@ module LlmCostTracker
|
|
|
6
6
|
module Pricing
|
|
7
7
|
class Unknown
|
|
8
8
|
MUTEX = Mutex.new
|
|
9
|
+
WARN_CACHE_LIMIT = 1024
|
|
9
10
|
|
|
10
11
|
class << self
|
|
11
|
-
def
|
|
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,17 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "bigdecimal"
|
|
4
5
|
require "time"
|
|
5
6
|
|
|
6
7
|
require_relative "version"
|
|
8
|
+
require_relative "logging"
|
|
7
9
|
require_relative "token_usage"
|
|
8
10
|
require_relative "billing/components"
|
|
11
|
+
require_relative "billing/line_item"
|
|
9
12
|
require_relative "pricing/mode"
|
|
10
13
|
require_relative "pricing/registry"
|
|
11
14
|
require_relative "pricing/lookup"
|
|
12
15
|
require_relative "pricing/effective_prices"
|
|
13
16
|
require_relative "pricing/explainer"
|
|
14
17
|
require_relative "pricing/service_charges"
|
|
18
|
+
require_relative "pricing/estimator"
|
|
15
19
|
|
|
16
20
|
module LlmCostTracker
|
|
17
21
|
module Pricing # rubocop:disable Metrics/ModuleLength
|
|
@@ -19,7 +23,7 @@ module LlmCostTracker
|
|
|
19
23
|
|
|
20
24
|
STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
|
|
21
25
|
RATE_DENOMINATOR_TOKENS = 1_000_000
|
|
22
|
-
private_constant :
|
|
26
|
+
private_constant :RATE_DENOMINATOR_TOKENS
|
|
23
27
|
|
|
24
28
|
class << self
|
|
25
29
|
def normalize_mode(value)
|
|
@@ -92,8 +96,49 @@ module LlmCostTracker
|
|
|
92
96
|
value ? { total_cost: value } : {}
|
|
93
97
|
end
|
|
94
98
|
|
|
99
|
+
def combine_with_service_lines(cost_data, line_items)
|
|
100
|
+
priced_services = line_items.reject(&:token?).select(&:priced?)
|
|
101
|
+
return cost_data if priced_services.empty?
|
|
102
|
+
|
|
103
|
+
base_currency = base_currency_for(cost_data, priced_services)
|
|
104
|
+
matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
|
|
105
|
+
warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
|
|
106
|
+
|
|
107
|
+
cost = cost_data ? cost_data.dup : {}
|
|
108
|
+
cost[:currency] ||= base_currency.to_s
|
|
109
|
+
return cost if matching.empty?
|
|
110
|
+
|
|
111
|
+
service_total = matching.sum(BigDecimal("0"), &:cost_value)
|
|
112
|
+
base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
|
|
113
|
+
cost[:total_cost] = (base_total + service_total).round(8)
|
|
114
|
+
cost
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def token_pricing_partial?(token_usage, cost_data)
|
|
118
|
+
return false unless cost_data
|
|
119
|
+
|
|
120
|
+
token_usage.priced_quantities.any? do |key, quantity|
|
|
121
|
+
next false unless quantity.positive?
|
|
122
|
+
|
|
123
|
+
cost_data[Billing::Components::BY_KEY.fetch(key).cost_key].nil?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
95
127
|
private
|
|
96
128
|
|
|
129
|
+
def base_currency_for(cost_data, priced_services)
|
|
130
|
+
(cost_data && cost_data[:currency]) || priced_services.first.currency || Billing::LineItem::USD
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def warn_currency_mismatch(lines, base_currency)
|
|
134
|
+
currencies = lines.map { |line| line.currency.to_s }.uniq.sort
|
|
135
|
+
Logging.warn(
|
|
136
|
+
"Service line currency mismatch: header is #{base_currency}, dropping " \
|
|
137
|
+
"#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
|
|
138
|
+
"Per-line costs are still recorded; header total reflects #{base_currency} only."
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
97
142
|
def normalize_string_mode(value)
|
|
98
143
|
normalized = value.strip
|
|
99
144
|
return nil if normalized.empty?
|
|
@@ -108,22 +153,18 @@ module LlmCostTracker
|
|
|
108
153
|
result[component.cost_key] = cost.round(8) unless cost.nil?
|
|
109
154
|
end
|
|
110
155
|
values[:total_cost] = costs.values.compact.sum(BigDecimal("0")).round(8)
|
|
156
|
+
values[:currency] = calculation[:match].currency
|
|
111
157
|
values
|
|
112
158
|
end
|
|
113
159
|
|
|
114
160
|
def snapshot_from(calculation)
|
|
115
161
|
match = calculation[:match]
|
|
116
162
|
effective = calculation[:effective]
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
quantity = token_usage.public_send(component.token_key)
|
|
120
|
-
price = effective[component.key]
|
|
163
|
+
rates = calculation[:quantities].each_with_object({}) do |(key, quantity), values|
|
|
164
|
+
price = effective[key]
|
|
121
165
|
next if quantity.zero? || price.nil?
|
|
122
166
|
|
|
123
|
-
values[
|
|
124
|
-
amount: price,
|
|
125
|
-
quantity: RATE_DENOMINATOR_TOKENS
|
|
126
|
-
}
|
|
167
|
+
values[key] = { amount: price, quantity: RATE_DENOMINATOR_TOKENS }
|
|
127
168
|
end
|
|
128
169
|
|
|
129
170
|
{
|
|
@@ -132,7 +173,7 @@ module LlmCostTracker
|
|
|
132
173
|
source_key: match.key,
|
|
133
174
|
source_version: source_version_for(match.source),
|
|
134
175
|
matched_by: match.matched_by,
|
|
135
|
-
currency:
|
|
176
|
+
currency: match.currency,
|
|
136
177
|
rates: rates
|
|
137
178
|
}
|
|
138
179
|
end
|
|
@@ -142,23 +183,29 @@ module LlmCostTracker
|
|
|
142
183
|
return nil unless match
|
|
143
184
|
|
|
144
185
|
token_usage = TokenUsage.build_from_tokens(tokens)
|
|
186
|
+
quantities = token_usage.priced_quantities
|
|
145
187
|
mode = normalize_mode(pricing_mode)
|
|
146
|
-
effective = EffectivePrices.call(usage: token_usage, prices: match.prices,
|
|
147
|
-
|
|
188
|
+
effective = EffectivePrices.call(usage: token_usage, quantities: quantities, prices: match.prices,
|
|
189
|
+
pricing_mode: mode)
|
|
190
|
+
return nil unless any_billable_priced?(quantities, effective)
|
|
148
191
|
|
|
149
|
-
{ match: match, effective: effective, token_usage: token_usage,
|
|
192
|
+
{ match: match, effective: effective, token_usage: token_usage, quantities: quantities,
|
|
193
|
+
costs: costs_for(quantities, effective) }
|
|
150
194
|
end
|
|
151
195
|
|
|
152
|
-
def any_billable_priced?(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
196
|
+
def any_billable_priced?(quantities, effective)
|
|
197
|
+
any_billable = false
|
|
198
|
+
quantities.each_pair do |key, quantity|
|
|
199
|
+
next unless quantity.positive?
|
|
200
|
+
return true if effective[key]
|
|
156
201
|
|
|
157
|
-
|
|
158
|
-
Billing::Components::TOKEN_PRICED.to_h do |component|
|
|
159
|
-
tokens = usage.public_send(component.token_key)
|
|
160
|
-
[component.key, token_cost(tokens, effective[component.key])]
|
|
202
|
+
any_billable = true
|
|
161
203
|
end
|
|
204
|
+
!any_billable
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def costs_for(quantities, effective)
|
|
208
|
+
quantities.to_h { |key, tokens| [key, token_cost(tokens, effective[key])] }
|
|
162
209
|
end
|
|
163
210
|
|
|
164
211
|
def apply_calculation_to_line_items(line_items, calculation, provider:, pricing_mode:)
|
|
@@ -197,6 +244,7 @@ module LlmCostTracker
|
|
|
197
244
|
rate_amount: BigDecimal(effective_price.to_s),
|
|
198
245
|
rate_quantity: BigDecimal(RATE_DENOMINATOR_TOKENS),
|
|
199
246
|
cost: cost,
|
|
247
|
+
currency: match.currency,
|
|
200
248
|
cost_status: cost.zero? ? Billing::CostStatus::FREE : Billing::CostStatus::COMPLETE,
|
|
201
249
|
price_key: component.key,
|
|
202
250
|
price_source: match.source,
|
|
@@ -212,7 +260,7 @@ module LlmCostTracker
|
|
|
212
260
|
charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
|
|
213
261
|
return line_item unless rate
|
|
214
262
|
|
|
215
|
-
line_item.
|
|
263
|
+
line_item.with_rate(rate)
|
|
216
264
|
end
|
|
217
265
|
|
|
218
266
|
def model_rate_for(line_item, calculation)
|
|
@@ -226,7 +274,7 @@ module LlmCostTracker
|
|
|
226
274
|
{
|
|
227
275
|
amount: BigDecimal(amount.to_s),
|
|
228
276
|
quantity: BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis).to_s),
|
|
229
|
-
currency:
|
|
277
|
+
currency: match.currency,
|
|
230
278
|
source: match.source,
|
|
231
279
|
source_key: "#{match.key}.#{line_item.kind}",
|
|
232
280
|
source_version: source_version_for(match.source)
|
|
@@ -248,13 +296,10 @@ module LlmCostTracker
|
|
|
248
296
|
when :bundled
|
|
249
297
|
LlmCostTracker::VERSION
|
|
250
298
|
when :prices_file
|
|
251
|
-
|
|
252
|
-
path ? File.mtime(path).utc.iso8601 : nil
|
|
299
|
+
Lookup.prices_file_mtime_iso
|
|
253
300
|
when :pricing_overrides
|
|
254
301
|
"configuration"
|
|
255
302
|
end
|
|
256
|
-
rescue Errno::ENOENT
|
|
257
|
-
nil
|
|
258
303
|
end
|
|
259
304
|
|
|
260
305
|
def token_cost(tokens, per_million_price)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Providers
|
|
5
|
+
module Anthropic
|
|
6
|
+
module TierClassification
|
|
7
|
+
DATA_RESIDENCY_GEOS = %w[us].freeze
|
|
8
|
+
STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def data_residency_geo?(geo)
|
|
13
|
+
DATA_RESIDENCY_GEOS.include?(geo.to_s.downcase)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def standard_equivalent_tier?(service_tier)
|
|
17
|
+
STANDARD_EQUIVALENT_SERVICE_TIERS.include?(service_tier.to_s)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Providers
|
|
5
|
+
module Azure
|
|
6
|
+
module Hosts
|
|
7
|
+
OPENAI_HOST_PATTERN = /\A[a-z0-9][a-z0-9-]*\.(?:openai\.azure\.com|services\.ai\.azure\.com)\z/i
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def openai?(host)
|
|
12
|
+
host.to_s.match?(OPENAI_HOST_PATTERN)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Providers
|
|
5
|
+
module Gemini
|
|
6
|
+
module ModelFamilies
|
|
7
|
+
PER_QUERY_GROUNDING_MODEL_PATTERN = /\bgemini-(?:[3-9]|[1-9]\d)\b/i
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def per_query_grounding?(model)
|
|
12
|
+
model.to_s.match?(PER_QUERY_GROUNDING_MODEL_PATTERN)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|