llm_cost_tracker 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +6 -2
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
- data/config/routes.rb +1 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +29 -8
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
- data/lib/llm_cost_tracker/configuration.rb +30 -44
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor.rb +80 -25
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
- data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
- data/lib/llm_cost_tracker/parsers.rb +29 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +71 -43
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -5
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +31 -12
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -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
|
|
@@ -50,8 +50,11 @@ module LlmCostTracker
|
|
|
50
50
|
|
|
51
51
|
def explanation(provider:, model:, pricing_mode:, match:, usage:)
|
|
52
52
|
prices = match&.prices
|
|
53
|
-
pricing_mode = Pricing.
|
|
54
|
-
effective =
|
|
53
|
+
pricing_mode = Pricing::Mode.normalize(pricing_mode)
|
|
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)
|
|
@@ -3,10 +3,39 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Pricing
|
|
5
5
|
class Mode
|
|
6
|
-
|
|
6
|
+
STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
|
|
7
|
+
COMPOUND_MODIFIERS = %i[data_residency].freeze
|
|
7
8
|
|
|
8
9
|
attr_reader :modifiers
|
|
9
10
|
|
|
11
|
+
def self.normalize(value)
|
|
12
|
+
return nil if value.nil?
|
|
13
|
+
|
|
14
|
+
symbol = normalize_string(value.to_s)
|
|
15
|
+
return nil unless symbol
|
|
16
|
+
|
|
17
|
+
STANDARD_MODE_VALUES.include?(symbol) ? nil : symbol
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.merge(provider_mode, request_mode)
|
|
21
|
+
return normalize(request_mode) if provider_mode.to_s.strip.empty?
|
|
22
|
+
|
|
23
|
+
provider_tokens = tokenize(provider_mode) - STANDARD_MODE_VALUES
|
|
24
|
+
request_host_tokens = tokenize(request_mode || "") & COMPOUND_MODIFIERS
|
|
25
|
+
combined = provider_tokens | request_host_tokens
|
|
26
|
+
return nil if combined.empty?
|
|
27
|
+
|
|
28
|
+
normalize(combined.join("_"))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.normalize_string(value)
|
|
32
|
+
normalized = value.strip
|
|
33
|
+
return nil if normalized.empty?
|
|
34
|
+
|
|
35
|
+
normalized.downcase.tr("-", "_").to_sym
|
|
36
|
+
end
|
|
37
|
+
private_class_method :normalize_string
|
|
38
|
+
|
|
10
39
|
def self.parse(value)
|
|
11
40
|
return value if value.is_a?(self)
|
|
12
41
|
return new([]) if value.nil?
|
|
@@ -21,11 +50,12 @@ module LlmCostTracker
|
|
|
21
50
|
break if remaining.empty?
|
|
22
51
|
|
|
23
52
|
compound = COMPOUND_MODIFIERS.find do |token|
|
|
24
|
-
|
|
53
|
+
name = token.name
|
|
54
|
+
remaining == name || remaining.start_with?("#{name}_")
|
|
25
55
|
end
|
|
26
56
|
if compound
|
|
27
|
-
tokens << compound
|
|
28
|
-
remaining = remaining.delete_prefix(compound).delete_prefix("_")
|
|
57
|
+
tokens << compound
|
|
58
|
+
remaining = remaining.delete_prefix(compound.name).delete_prefix("_")
|
|
29
59
|
else
|
|
30
60
|
first, _, rest = remaining.partition("_")
|
|
31
61
|
tokens << first.to_sym unless first.empty?
|
|
@@ -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,14 +60,15 @@ 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
|
|
|
69
70
|
def charge_rate(provider:, component:, pricing_mode:)
|
|
70
|
-
pricing_mode = Pricing.
|
|
71
|
+
pricing_mode = Pricing::Mode.normalize(pricing_mode)
|
|
71
72
|
match = charge_rate_match(provider: provider, component: component, pricing_mode: pricing_mode)
|
|
72
73
|
return nil unless match
|
|
73
74
|
|
|
@@ -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
|