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
|
@@ -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
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../billing/components"
|
|
4
|
+
require_relative "mode"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Pricing
|
|
7
8
|
module EffectivePrices
|
|
8
9
|
class << self
|
|
9
|
-
def call(usage:, prices:, pricing_mode:)
|
|
10
|
+
def call(usage:, quantities:, prices:, pricing_mode:)
|
|
10
11
|
context_tier = context_tier?(usage: usage, prices: prices)
|
|
12
|
+
orderings = pricing_mode && Mode.parse(pricing_mode).permutations
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
price_key = component.key
|
|
14
|
-
tokens = usage.public_send(component.token_key)
|
|
14
|
+
quantities.to_h do |price_key, tokens|
|
|
15
15
|
price = if tokens.positive?
|
|
16
16
|
price_for(
|
|
17
17
|
prices: prices,
|
|
18
18
|
key: price_key,
|
|
19
|
-
|
|
19
|
+
orderings: orderings,
|
|
20
20
|
context_tier: context_tier
|
|
21
21
|
)
|
|
22
22
|
else
|
|
@@ -28,10 +28,9 @@ module LlmCostTracker
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
def price_for(prices:, key:,
|
|
32
|
-
return contextual_price(prices: prices, key: key, context_tier: context_tier) unless
|
|
31
|
+
def price_for(prices:, key:, orderings:, context_tier:)
|
|
32
|
+
return contextual_price(prices: prices, key: key, context_tier: context_tier) unless orderings
|
|
33
33
|
|
|
34
|
-
orderings = mode_orderings_for(pricing_mode)
|
|
35
34
|
orderings.each do |mode|
|
|
36
35
|
direct = contextual_price(prices: prices, key: :"#{mode}_#{key}", context_tier: context_tier)
|
|
37
36
|
return direct if direct
|
|
@@ -41,38 +40,6 @@ module LlmCostTracker
|
|
|
41
40
|
derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
|
|
42
41
|
end
|
|
43
42
|
|
|
44
|
-
def mode_orderings_for(pricing_mode)
|
|
45
|
-
mode_string = pricing_mode.to_s
|
|
46
|
-
return [mode_string] unless mode_string.include?("_")
|
|
47
|
-
|
|
48
|
-
tokens = tokenize_mode(mode_string)
|
|
49
|
-
return [mode_string] if tokens.size <= 1
|
|
50
|
-
|
|
51
|
-
[mode_string, *tokens.permutation.map { |permutation| permutation.join("_") }].uniq
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def tokenize_mode(mode_string)
|
|
55
|
-
remaining = mode_string.dup
|
|
56
|
-
tokens = []
|
|
57
|
-
loop do
|
|
58
|
-
break if remaining.empty?
|
|
59
|
-
|
|
60
|
-
compound = COMPOUND_MODE_TOKENS.find { |token| remaining == token || remaining.start_with?("#{token}_") }
|
|
61
|
-
if compound
|
|
62
|
-
tokens << compound
|
|
63
|
-
remaining = remaining.delete_prefix(compound).delete_prefix("_")
|
|
64
|
-
else
|
|
65
|
-
first, _, rest = remaining.partition("_")
|
|
66
|
-
tokens << first
|
|
67
|
-
remaining = rest
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
tokens
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
COMPOUND_MODE_TOKENS = %w[data_residency].freeze
|
|
74
|
-
private_constant :COMPOUND_MODE_TOKENS
|
|
75
|
-
|
|
76
43
|
def contextual_price(prices:, key:, context_tier:)
|
|
77
44
|
return prices[key] unless context_tier
|
|
78
45
|
|
|
@@ -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,10 +3,14 @@
|
|
|
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
|
|
11
|
+
LOOKUP_CACHE_LIMIT = 2_048
|
|
12
|
+
PRICE_FILE_RECHECK_INTERVAL = 1.0
|
|
13
|
+
private_constant :PRICE_FILE_RECHECK_INTERVAL
|
|
10
14
|
|
|
11
15
|
class << self
|
|
12
16
|
def call(provider:, model:)
|
|
@@ -14,6 +18,8 @@ module LlmCostTracker
|
|
|
14
18
|
model_name = model.to_s
|
|
15
19
|
return nil if model_name.empty?
|
|
16
20
|
|
|
21
|
+
invalidate_cache_if_prices_file_changed!
|
|
22
|
+
|
|
17
23
|
cache_key = [provider_name, model_name]
|
|
18
24
|
cached = cached_lookup(cache_key)
|
|
19
25
|
return cached unless cached.equal?(CACHE_MISS)
|
|
@@ -25,14 +31,60 @@ module LlmCostTracker
|
|
|
25
31
|
|
|
26
32
|
def reset!
|
|
27
33
|
MUTEX.synchronize do
|
|
28
|
-
|
|
29
|
-
@
|
|
30
|
-
|
|
34
|
+
reset_prices_caches!(signature: nil)
|
|
35
|
+
@prices_file_last_check_at = nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
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
|
|
31
54
|
end
|
|
32
55
|
end
|
|
33
56
|
|
|
34
57
|
private
|
|
35
58
|
|
|
59
|
+
def invalidate_cache_if_prices_file_changed!
|
|
60
|
+
path = LlmCostTracker.configuration.prices_file
|
|
61
|
+
|
|
62
|
+
unless path
|
|
63
|
+
return if @prices_file_signature.nil?
|
|
64
|
+
|
|
65
|
+
MUTEX.synchronize { reset_prices_caches!(signature: nil) unless @prices_file_signature.nil? }
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
|
+
last_check = @prices_file_last_check_at
|
|
71
|
+
return if last_check && (now - last_check) < PRICE_FILE_RECHECK_INTERVAL
|
|
72
|
+
|
|
73
|
+
signature = File.exist?(path) ? File.mtime(path) : nil
|
|
74
|
+
MUTEX.synchronize do
|
|
75
|
+
@prices_file_last_check_at = now
|
|
76
|
+
reset_prices_caches!(signature: signature) if @prices_file_signature != signature
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reset_prices_caches!(signature:)
|
|
81
|
+
@prices_cache = nil
|
|
82
|
+
@lookup_cache = nil
|
|
83
|
+
@sorted_price_keys_cache = nil
|
|
84
|
+
@prices_file_iso_cache = nil
|
|
85
|
+
@prices_file_signature = signature
|
|
86
|
+
end
|
|
87
|
+
|
|
36
88
|
def lookup_match(provider_name:, model_name:)
|
|
37
89
|
provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
|
|
38
90
|
normalized_model = normalize_model_name(model_name)
|
|
@@ -86,6 +138,7 @@ module LlmCostTracker
|
|
|
86
138
|
def cache_lookup(cache_key, match)
|
|
87
139
|
MUTEX.synchronize do
|
|
88
140
|
values = (@lookup_cache || {}).dup
|
|
141
|
+
values.shift while values.size >= LOOKUP_CACHE_LIMIT
|
|
89
142
|
values[cache_key] = match || NO_MATCH
|
|
90
143
|
@lookup_cache = values.freeze
|
|
91
144
|
end
|
|
@@ -135,7 +188,22 @@ module LlmCostTracker
|
|
|
135
188
|
end
|
|
136
189
|
|
|
137
190
|
def match(table:, source:, key:, matched_by:)
|
|
138
|
-
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
|
|
139
207
|
end
|
|
140
208
|
|
|
141
209
|
def snapshot_variant?(model, key)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Pricing
|
|
5
|
+
class Mode
|
|
6
|
+
COMPOUND_MODIFIERS = %w[data_residency].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :modifiers
|
|
9
|
+
|
|
10
|
+
def self.parse(value)
|
|
11
|
+
return value if value.is_a?(self)
|
|
12
|
+
return new([]) if value.nil?
|
|
13
|
+
|
|
14
|
+
new(tokenize(value.to_s))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.tokenize(value)
|
|
18
|
+
remaining = value.to_s.downcase.tr("-", "_")
|
|
19
|
+
tokens = []
|
|
20
|
+
loop do
|
|
21
|
+
break if remaining.empty?
|
|
22
|
+
|
|
23
|
+
compound = COMPOUND_MODIFIERS.find do |token|
|
|
24
|
+
remaining == token || remaining.start_with?("#{token}_")
|
|
25
|
+
end
|
|
26
|
+
if compound
|
|
27
|
+
tokens << compound.to_sym
|
|
28
|
+
remaining = remaining.delete_prefix(compound).delete_prefix("_")
|
|
29
|
+
else
|
|
30
|
+
first, _, rest = remaining.partition("_")
|
|
31
|
+
tokens << first.to_sym unless first.empty?
|
|
32
|
+
remaining = rest
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
tokens
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(modifiers)
|
|
39
|
+
@modifiers = Array(modifiers).map(&:to_sym).uniq.sort
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def empty?
|
|
44
|
+
modifiers.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def include?(modifier)
|
|
48
|
+
modifiers.include?(modifier.to_sym)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def canonical
|
|
52
|
+
modifiers.join("_")
|
|
53
|
+
end
|
|
54
|
+
alias to_s canonical
|
|
55
|
+
|
|
56
|
+
def to_sym
|
|
57
|
+
empty? ? nil : canonical.to_sym
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def permutations
|
|
61
|
+
return [canonical] if modifiers.size <= 1
|
|
62
|
+
|
|
63
|
+
modifiers.permutation.map { |permutation| permutation.join("_") }.uniq
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ==(other)
|
|
67
|
+
other.is_a?(self.class) && modifiers == other.modifiers
|
|
68
|
+
end
|
|
69
|
+
alias eql? ==
|
|
70
|
+
|
|
71
|
+
def hash
|
|
72
|
+
modifiers.hash
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -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
|
|
@@ -115,6 +108,7 @@ module LlmCostTracker
|
|
|
115
108
|
|
|
116
109
|
def non_negative_float(key, value)
|
|
117
110
|
rate = Float(value)
|
|
111
|
+
raise ArgumentError, "price for #{key.inspect} must be finite (got #{rate})" unless rate.finite?
|
|
118
112
|
raise ArgumentError, "price for #{key.inspect} must be non-negative (got #{rate})" if rate.negative?
|
|
119
113
|
|
|
120
114
|
rate
|
|
@@ -145,8 +139,9 @@ module LlmCostTracker
|
|
|
145
139
|
|
|
146
140
|
def price_key_for(key)
|
|
147
141
|
name = key.is_a?(Symbol) ? key.name : key
|
|
148
|
-
Billing::Components::
|
|
142
|
+
Billing::Components::REGISTRY.each do |candidate|
|
|
149
143
|
return candidate.key if candidate.key.name == name
|
|
144
|
+
next unless candidate.token_key
|
|
150
145
|
|
|
151
146
|
suffix = "_#{candidate.key.name}"
|
|
152
147
|
next unless name.end_with?(suffix)
|
|
@@ -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: {} }
|
|
@@ -121,14 +122,20 @@ module LlmCostTracker
|
|
|
121
122
|
|
|
122
123
|
def amount_for(key, amount, context:)
|
|
123
124
|
value = BigDecimal(amount.to_s)
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
if value.infinite? || value.nan?
|
|
126
|
+
raise ArgumentError,
|
|
127
|
+
"service charge price amount for #{key.inspect} in #{context} must be finite"
|
|
128
|
+
end
|
|
129
|
+
if value.negative?
|
|
130
|
+
raise ArgumentError,
|
|
131
|
+
"service charge price amount for #{key.inspect} in #{context} must be non-negative"
|
|
132
|
+
end
|
|
126
133
|
|
|
127
134
|
value
|
|
128
135
|
end
|
|
129
136
|
|
|
130
137
|
def rate_quantity(component)
|
|
131
|
-
component.
|
|
138
|
+
BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis, 1).to_s)
|
|
132
139
|
end
|
|
133
140
|
|
|
134
141
|
def charge_rate_match(provider:, component:, pricing_mode:)
|
|
@@ -192,12 +199,7 @@ module LlmCostTracker
|
|
|
192
199
|
def rate_source_version_for(source)
|
|
193
200
|
return LlmCostTracker::VERSION if source == :bundled
|
|
194
201
|
|
|
195
|
-
|
|
196
|
-
return nil unless path
|
|
197
|
-
|
|
198
|
-
File.mtime(path).utc.iso8601
|
|
199
|
-
rescue Errno::ENOENT
|
|
200
|
-
nil
|
|
202
|
+
Lookup.prices_file_mtime_iso
|
|
201
203
|
end
|
|
202
204
|
end
|
|
203
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
|
|
|
@@ -9,10 +9,12 @@ module LlmCostTracker
|
|
|
9
9
|
module Sync
|
|
10
10
|
class RegistryWriter
|
|
11
11
|
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
12
|
+
MANUAL_SOURCE = "manual"
|
|
12
13
|
|
|
13
14
|
def call(path:, registry:)
|
|
14
15
|
FileUtils.mkdir_p(File.dirname(path))
|
|
15
|
-
|
|
16
|
+
merged = canonicalize(merge_with_existing(path: path, registry: registry))
|
|
17
|
+
payload = yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
|
|
16
18
|
temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
17
19
|
File.write(temp_path, payload)
|
|
18
20
|
File.rename(temp_path, path)
|
|
@@ -22,6 +24,65 @@ module LlmCostTracker
|
|
|
22
24
|
|
|
23
25
|
private
|
|
24
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
|
+
|
|
38
|
+
def merge_with_existing(path:, registry:)
|
|
39
|
+
existing = read_existing(path)
|
|
40
|
+
return registry unless existing.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
merged = registry.dup
|
|
43
|
+
merged["models"] = merged_models(registry, existing) if existing["models"].is_a?(Hash)
|
|
44
|
+
if existing["service_charges"].is_a?(Hash)
|
|
45
|
+
merged["service_charges"] = merged_service_charges(registry, existing)
|
|
46
|
+
end
|
|
47
|
+
merged
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def merged_models(registry, existing)
|
|
51
|
+
merged = registry.fetch("models", {}).dup
|
|
52
|
+
existing.fetch("models", {}).each do |model, attrs|
|
|
53
|
+
next unless attrs.is_a?(Hash) && attrs["_source"].to_s == MANUAL_SOURCE
|
|
54
|
+
next if merged.key?(model)
|
|
55
|
+
|
|
56
|
+
merged[model] = attrs
|
|
57
|
+
end
|
|
58
|
+
merged
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def merged_service_charges(registry, existing)
|
|
62
|
+
remote = registry.fetch("service_charges", {})
|
|
63
|
+
existing.fetch("service_charges", {}).each_with_object(remote.dup) do |(provider, charges), merged|
|
|
64
|
+
next unless charges.is_a?(Hash)
|
|
65
|
+
next if merged.key?(provider)
|
|
66
|
+
|
|
67
|
+
merged[provider] = charges
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def read_existing(path)
|
|
72
|
+
return nil unless File.exist?(path)
|
|
73
|
+
|
|
74
|
+
contents = File.read(path)
|
|
75
|
+
return nil if contents.strip.empty?
|
|
76
|
+
|
|
77
|
+
if yaml_file?(path)
|
|
78
|
+
YAML.safe_load(contents, permitted_classes: [Symbol, Date, Time])
|
|
79
|
+
else
|
|
80
|
+
JSON.parse(contents)
|
|
81
|
+
end
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
25
86
|
def yaml_file?(path)
|
|
26
87
|
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
27
88
|
end
|
|
@@ -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", {})
|
|
@@ -150,8 +142,10 @@ module LlmCostTracker
|
|
|
150
142
|
end
|
|
151
143
|
|
|
152
144
|
def load_registry(path)
|
|
145
|
+
return {} unless File.exist?(path)
|
|
146
|
+
|
|
153
147
|
YAML.safe_load_file(path, aliases: false) || {}
|
|
154
|
-
rescue
|
|
148
|
+
rescue Psych::Exception, ArgumentError, TypeError => e
|
|
155
149
|
raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
|
|
156
150
|
end
|
|
157
151
|
|