llm_cost_tracker 0.10.0 → 0.12.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 +82 -0
- data/README.md +11 -5
- data/app/assets/llm_cost_tracker/application.css +784 -802
- data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
- 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/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
- data/app/models/llm_cost_tracker/call.rb +28 -63
- data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
- data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
- data/app/models/llm_cost_tracker/call_tag.rb +0 -2
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
- data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
- 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/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 +85 -104
- data/config/routes.rb +3 -3
- data/lib/llm_cost_tracker/budget.rb +25 -28
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
- data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
- data/lib/llm_cost_tracker/charges/cost.rb +27 -0
- data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
- data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
- data/lib/llm_cost_tracker/check.rb +5 -0
- data/lib/llm_cost_tracker/configuration.rb +13 -61
- data/lib/llm_cost_tracker/currency.rb +5 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
- data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
- data/lib/llm_cost_tracker/doctor.rb +66 -64
- data/lib/llm_cost_tracker/engine.rb +4 -4
- data/lib/llm_cost_tracker/event.rb +12 -20
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
- data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
- data/lib/llm_cost_tracker/ingestion.rb +24 -36
- data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
- data/lib/llm_cost_tracker/integrations/base.rb +39 -57
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
- data/lib/llm_cost_tracker/integrations.rb +32 -25
- data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
- data/lib/llm_cost_tracker/ledger/period.rb +5 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
- data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
- data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
- data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
- data/lib/llm_cost_tracker/ledger/store.rb +18 -42
- data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
- data/lib/llm_cost_tracker/ledger.rb +14 -11
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
- data/lib/llm_cost_tracker/parsers.rb +140 -29
- data/lib/llm_cost_tracker/prices.json +1707 -1
- data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
- data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
- data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
- data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
- data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
- data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
- data/lib/llm_cost_tracker/pricing/source.rb +7 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
- data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
- data/lib/llm_cost_tracker/pricing.rb +10 -295
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
- data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
- data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
- data/lib/llm_cost_tracker/providers.rb +35 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -7
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +33 -20
- data/lib/llm_cost_tracker/report.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +6 -19
- data/lib/llm_cost_tracker/tags/context.rb +9 -6
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
- data/lib/llm_cost_tracker/timing.rb +2 -4
- data/lib/llm_cost_tracker/tracker.rb +24 -36
- data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
- data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
- data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
- data/lib/llm_cost_tracker/usage/source.rb +14 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +43 -52
- data/lib/tasks/llm_cost_tracker.rake +14 -73
- metadata +92 -58
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
- 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/billing/components.rb +0 -95
- data/lib/llm_cost_tracker/capture/stream.rb +0 -9
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
- data/lib/llm_cost_tracker/doctor/check.rb +0 -7
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
- data/lib/llm_cost_tracker/masking.rb +0 -39
- data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
- data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
- data/lib/llm_cost_tracker/parsers/base.rb +0 -131
- data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
- data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
- data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
- data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
- data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
- data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
|
@@ -2,16 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Pricing
|
|
5
|
-
|
|
5
|
+
module Mode
|
|
6
|
+
STANDARD_MODE_VALUES = %w[auto default standard standard_only unspecified].freeze
|
|
6
7
|
COMPOUND_MODIFIERS = %w[data_residency].freeze
|
|
8
|
+
KNOWN_MODIFIERS = %w[batch flex priority scale fast on_demand data_residency].freeze
|
|
9
|
+
MAX_PERMUTED_MODIFIERS = 6
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
def self.normalize(value)
|
|
12
|
+
return nil if value.nil?
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
return
|
|
12
|
-
return
|
|
14
|
+
mode = normalize_string(value.to_s)
|
|
15
|
+
return nil unless mode
|
|
16
|
+
return nil if STANDARD_MODE_VALUES.include?(mode)
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
warn_unknown_tokens(mode)
|
|
19
|
+
mode
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.merge(provider_mode, request_mode)
|
|
23
|
+
return normalize(request_mode) if provider_mode.to_s.strip.empty?
|
|
24
|
+
|
|
25
|
+
provider_tokens = tokenize(provider_mode) - STANDARD_MODE_VALUES
|
|
26
|
+
request_host_tokens = tokenize(request_mode || "") & COMPOUND_MODIFIERS
|
|
27
|
+
combined = provider_tokens | request_host_tokens
|
|
28
|
+
return nil if combined.empty?
|
|
29
|
+
|
|
30
|
+
normalize(combined.join("_"))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.compose(tokens)
|
|
34
|
+
tokens = Array(tokens).compact.uniq
|
|
35
|
+
tokens.empty? ? nil : tokens.join("_")
|
|
15
36
|
end
|
|
16
37
|
|
|
17
38
|
def self.tokenize(value)
|
|
@@ -24,53 +45,50 @@ module LlmCostTracker
|
|
|
24
45
|
remaining == token || remaining.start_with?("#{token}_")
|
|
25
46
|
end
|
|
26
47
|
if compound
|
|
27
|
-
tokens << compound
|
|
48
|
+
tokens << compound
|
|
28
49
|
remaining = remaining.delete_prefix(compound).delete_prefix("_")
|
|
29
50
|
else
|
|
30
51
|
first, _, rest = remaining.partition("_")
|
|
31
|
-
tokens << first
|
|
52
|
+
tokens << first unless first.empty?
|
|
32
53
|
remaining = rest
|
|
33
54
|
end
|
|
34
55
|
end
|
|
35
56
|
tokens
|
|
36
57
|
end
|
|
37
58
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
def self.permutations_for(value)
|
|
60
|
+
modifiers = tokenize(value).uniq.sort
|
|
61
|
+
return [""] if modifiers.empty?
|
|
62
|
+
return [modifiers.first] if modifiers.size == 1
|
|
63
|
+
return [modifiers.join("_")] if modifiers.size > MAX_PERMUTED_MODIFIERS
|
|
42
64
|
|
|
43
|
-
|
|
44
|
-
modifiers.empty?
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def include?(modifier)
|
|
48
|
-
modifiers.include?(modifier.to_sym)
|
|
65
|
+
modifiers.permutation.map { |permutation| permutation.join("_") }.uniq
|
|
49
66
|
end
|
|
50
67
|
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
alias to_s canonical
|
|
68
|
+
def self.normalize_string(value)
|
|
69
|
+
normalized = value.strip
|
|
70
|
+
return nil if normalized.empty?
|
|
55
71
|
|
|
56
|
-
|
|
57
|
-
empty? ? nil : canonical.to_sym
|
|
72
|
+
normalized.downcase.tr("-", "_")
|
|
58
73
|
end
|
|
74
|
+
private_class_method :normalize_string
|
|
59
75
|
|
|
60
|
-
def
|
|
61
|
-
|
|
76
|
+
def self.warn_unknown_tokens(mode)
|
|
77
|
+
unknown = tokenize(mode) - KNOWN_MODIFIERS - STANDARD_MODE_VALUES
|
|
78
|
+
return if unknown.empty?
|
|
62
79
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def ==(other)
|
|
67
|
-
other.is_a?(self.class) && modifiers == other.modifiers
|
|
68
|
-
end
|
|
69
|
-
alias eql? ==
|
|
80
|
+
@warned_tokens ||= Set.new
|
|
81
|
+
fresh = unknown.uniq.reject { |token| @warned_tokens.include?(token) }
|
|
82
|
+
return if fresh.empty?
|
|
70
83
|
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
@warned_tokens.merge(fresh)
|
|
85
|
+
Logging.warn(
|
|
86
|
+
"Unrecognized pricing_mode token(s) #{fresh.inspect} in #{mode.inspect}; " \
|
|
87
|
+
"the call will land with cost_status: unknown. " \
|
|
88
|
+
"Known pricing_mode tokens: #{KNOWN_MODIFIERS.inspect}"
|
|
89
|
+
)
|
|
73
90
|
end
|
|
91
|
+
private_class_method :warn_unknown_tokens
|
|
74
92
|
end
|
|
75
93
|
end
|
|
76
94
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../usage/catalog"
|
|
4
|
+
require_relative "mode"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Pricing
|
|
8
|
+
module PriceKey
|
|
9
|
+
ABOVE_CONTEXT_PREFIX = "above_context_"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def build(dimension_key, mode: nil, above_context: false)
|
|
13
|
+
key = mode ? "#{mode}_#{dimension_key}" : dimension_key.to_s
|
|
14
|
+
above_context ? "#{ABOVE_CONTEXT_PREFIX}#{key}" : key
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def price_key_for(key)
|
|
18
|
+
key = key.to_s
|
|
19
|
+
dimension_key = strip_mode_prefix(key.delete_prefix(ABOVE_CONTEXT_PREFIX))
|
|
20
|
+
dimension = Usage::Catalog[dimension_key]
|
|
21
|
+
return nil unless dimension
|
|
22
|
+
return key if key == dimension_key
|
|
23
|
+
|
|
24
|
+
dimension.token_key ? key : nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse_dimension_key(key)
|
|
28
|
+
name = key.to_s
|
|
29
|
+
exact = Usage::Catalog.all.find { |dimension| dimension.key == name }
|
|
30
|
+
return [exact, nil] if exact
|
|
31
|
+
|
|
32
|
+
Usage::Catalog.all.sort_by { |dimension| -dimension.key.length }.each do |dimension|
|
|
33
|
+
suffix = "_#{dimension.key}"
|
|
34
|
+
next unless name.end_with?(suffix)
|
|
35
|
+
|
|
36
|
+
tier = name.delete_suffix(suffix)
|
|
37
|
+
return [dimension, tier] unless tier.empty?
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def strip_mode_prefix(key)
|
|
45
|
+
loop do
|
|
46
|
+
modifier = Mode::KNOWN_MODIFIERS.find { |m| key.start_with?("#{m}_") }
|
|
47
|
+
break unless modifier
|
|
48
|
+
|
|
49
|
+
key = key.delete_prefix("#{modifier}_")
|
|
50
|
+
end
|
|
51
|
+
key
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Pricing
|
|
5
|
+
RATE_BASIS_QUANTITIES = {
|
|
6
|
+
"per_million_tokens" => 1_000_000,
|
|
7
|
+
"per_million_characters" => 1_000_000,
|
|
8
|
+
"per_request" => 1,
|
|
9
|
+
"per_1k_requests" => 1_000,
|
|
10
|
+
"per_session" => 1,
|
|
11
|
+
"per_hour" => 1,
|
|
12
|
+
"per_minute" => 1,
|
|
13
|
+
"per_image" => 1
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
Rate = Data.define(:amount, :quantity, :currency, :source, :source_key, :source_version)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -1,162 +1,211 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "bigdecimal/util"
|
|
3
5
|
require "yaml"
|
|
4
6
|
|
|
5
|
-
require_relative "../
|
|
7
|
+
require_relative "../usage/catalog"
|
|
8
|
+
require_relative "../pricing/rate"
|
|
6
9
|
require_relative "../logging"
|
|
10
|
+
require_relative "mode"
|
|
11
|
+
require_relative "price_key"
|
|
12
|
+
require_relative "source"
|
|
7
13
|
|
|
8
14
|
module LlmCostTracker
|
|
9
15
|
module Pricing
|
|
10
16
|
module Registry
|
|
11
17
|
DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
METADATA_KEYS = [
|
|
16
|
-
"_source", "_source_version", "_fetched_at", "_updated", "_notes", "_validator_override",
|
|
17
|
-
CONTEXT_THRESHOLD_KEY.name
|
|
18
|
-
].freeze
|
|
19
|
-
MUTEX = Mutex.new
|
|
18
|
+
CONTEXT_THRESHOLD_KEY = "_context_price_threshold_tokens"
|
|
19
|
+
PRICE_KEYS = Usage::Catalog.token_priced.map(&:key).freeze
|
|
20
|
+
METADATA_KEYS = ["_source", CONTEXT_THRESHOLD_KEY].freeze
|
|
20
21
|
|
|
21
22
|
class << self
|
|
22
23
|
def reset!
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
@builtin_prices = nil
|
|
25
|
+
@metadata = nil
|
|
26
|
+
@raw_registry = nil
|
|
27
|
+
@raw_file_registries = nil
|
|
28
|
+
@file_prices = nil
|
|
29
|
+
@builtin_rates = nil
|
|
30
|
+
@file_rates = nil
|
|
31
|
+
@sources = nil
|
|
32
|
+
@sorted_price_keys_cache = nil
|
|
33
|
+
@prices_file_mtime_iso = nil
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
def builtin_prices
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
MUTEX.synchronize do
|
|
36
|
-
@builtin_prices ||= begin
|
|
37
|
-
registry = @raw_registry ||= load_raw_registry
|
|
38
|
-
normalize_price_table(registry.fetch("models", {})).freeze
|
|
39
|
-
end
|
|
40
|
-
end
|
|
37
|
+
@builtin_prices ||= normalize_price_entries(
|
|
38
|
+
raw_registry.fetch("models", {}), context: "bundled prices"
|
|
39
|
+
).freeze
|
|
41
40
|
end
|
|
42
41
|
|
|
43
42
|
def metadata
|
|
44
|
-
|
|
45
|
-
return cached if cached
|
|
46
|
-
|
|
47
|
-
MUTEX.synchronize do
|
|
48
|
-
@metadata ||= begin
|
|
49
|
-
registry = @raw_registry ||= load_raw_registry
|
|
50
|
-
registry.fetch("metadata", {}).freeze
|
|
51
|
-
end
|
|
52
|
-
end
|
|
43
|
+
@metadata ||= raw_registry.fetch("metadata", {}).freeze
|
|
53
44
|
end
|
|
54
45
|
|
|
55
46
|
def file_metadata(path)
|
|
56
47
|
return {} unless path
|
|
57
48
|
|
|
58
|
-
|
|
49
|
+
meta = raw_file_registry(path).fetch("metadata", {})
|
|
50
|
+
return meta if meta.is_a?(Hash)
|
|
59
51
|
|
|
60
|
-
|
|
61
|
-
|
|
52
|
+
raise Error, "Unable to load prices_file #{path.inspect}: prices_file metadata must be a hash"
|
|
53
|
+
end
|
|
62
54
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
55
|
+
def file_prices(path)
|
|
56
|
+
return {} unless path
|
|
57
|
+
|
|
58
|
+
prices, @file_prices = memoize_in(@file_prices, path) { load_file_prices(path) }
|
|
59
|
+
prices
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize_price_entries(table, context:)
|
|
63
|
+
table = {} if table.nil?
|
|
64
|
+
raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
table.each_with_object({}) do |(model, price), normalized|
|
|
67
|
+
price = validate_price_entry(price, model: model, context: context)
|
|
68
|
+
normalized[model.to_s] = normalize_price_entry(model, price, context)
|
|
69
|
+
end
|
|
66
70
|
end
|
|
67
71
|
|
|
68
|
-
def
|
|
69
|
-
|
|
72
|
+
def raw_registry
|
|
73
|
+
@raw_registry ||= YAML.safe_load_file(DEFAULT_PRICES_PATH, aliases: false).freeze
|
|
70
74
|
end
|
|
71
75
|
|
|
72
|
-
def
|
|
73
|
-
|
|
76
|
+
def raw_file_registry(path)
|
|
77
|
+
registry, @raw_file_registries = memoize_in(@raw_file_registries, path) { load_raw_file_registry(path) }
|
|
78
|
+
registry
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def builtin_rates
|
|
82
|
+
@builtin_rates ||= rates_from_registry(raw_registry, context: DEFAULT_PRICES_PATH).freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def file_rates(path)
|
|
86
|
+
return {} unless path
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
rates, @file_rates = memoize_in(@file_rates, path) { load_file_rates(path) }
|
|
89
|
+
rates
|
|
90
|
+
end
|
|
78
91
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
def rates_from_registry(registry, context:)
|
|
93
|
+
data = registry.fetch("service_charges", {})
|
|
94
|
+
raise ArgumentError, "#{context} service_charges must be a hash" unless data.is_a?(Hash)
|
|
82
95
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
currency = upcased_currency(registry.dig("metadata", "currency"))
|
|
97
|
+
data.each_with_object({}) do |(provider, entries), rates|
|
|
98
|
+
section_context = "#{context} service_charges.#{provider}"
|
|
99
|
+
rates[provider] = rates_from_section(entries, currency: currency, context: section_context)
|
|
87
100
|
end
|
|
88
|
-
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
89
|
-
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
90
101
|
end
|
|
91
102
|
|
|
92
|
-
|
|
103
|
+
def prices_file_mtime_iso
|
|
104
|
+
path = LlmCostTracker.configuration.prices_file
|
|
105
|
+
return nil unless path && File.exist?(path)
|
|
93
106
|
|
|
94
|
-
|
|
95
|
-
YAML.safe_load_file(DEFAULT_PRICES_PATH, aliases: false).freeze
|
|
107
|
+
@prices_file_mtime_iso ||= File.mtime(path).utc.iso8601
|
|
96
108
|
end
|
|
97
109
|
|
|
98
|
-
def
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
def sources
|
|
111
|
+
@sources ||= begin
|
|
112
|
+
config = LlmCostTracker.configuration
|
|
113
|
+
[
|
|
114
|
+
Source.new(
|
|
115
|
+
name: "pricing_overrides",
|
|
116
|
+
prices: config.pricing_overrides,
|
|
117
|
+
rates: {},
|
|
118
|
+
currency: upcased_currency(nil),
|
|
119
|
+
version: "configuration"
|
|
120
|
+
),
|
|
121
|
+
Source.new(
|
|
122
|
+
name: "prices_file",
|
|
123
|
+
prices: file_prices(config.prices_file),
|
|
124
|
+
rates: file_rates(config.prices_file),
|
|
125
|
+
currency: upcased_currency(file_metadata(config.prices_file)["currency"]),
|
|
126
|
+
version: prices_file_mtime_iso
|
|
127
|
+
),
|
|
128
|
+
Source.new(
|
|
129
|
+
name: "bundled",
|
|
130
|
+
prices: builtin_prices,
|
|
131
|
+
rates: builtin_rates,
|
|
132
|
+
currency: upcased_currency(metadata["currency"]),
|
|
133
|
+
version: LlmCostTracker::VERSION
|
|
134
|
+
)
|
|
135
|
+
].freeze
|
|
106
136
|
end
|
|
107
137
|
end
|
|
108
138
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
139
|
+
def sorted_price_keys(table)
|
|
140
|
+
keys, @sorted_price_keys_cache =
|
|
141
|
+
memoize_in(@sorted_price_keys_cache, table, identity: true) { table.keys.sort_by { |key| -key.length } }
|
|
142
|
+
keys
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
113
146
|
|
|
114
|
-
|
|
147
|
+
def memoize_in(cache, key, identity: false)
|
|
148
|
+
existing = cache && cache[key]
|
|
149
|
+
return [existing, cache] if existing
|
|
150
|
+
|
|
151
|
+
value = yield
|
|
152
|
+
next_cache = cache&.dup || (identity ? {}.compare_by_identity : {})
|
|
153
|
+
next_cache[key] = value
|
|
154
|
+
[value, next_cache.freeze]
|
|
115
155
|
end
|
|
116
156
|
|
|
117
|
-
def
|
|
118
|
-
|
|
119
|
-
|
|
157
|
+
def loading(path)
|
|
158
|
+
yield
|
|
159
|
+
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
160
|
+
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
161
|
+
end
|
|
120
162
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
163
|
+
def load_raw_file_registry(path)
|
|
164
|
+
loading(path) { (YAML.safe_load_file(path, aliases: false) || {}).freeze }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def load_file_prices(path)
|
|
168
|
+
loading(path) do
|
|
169
|
+
doc = raw_file_registry(path)
|
|
170
|
+
normalize_price_entries(doc.fetch("models", doc), context: path).freeze
|
|
125
171
|
end
|
|
126
172
|
end
|
|
127
173
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
174
|
+
def normalize_price_entry(model, price, context)
|
|
175
|
+
unknown = []
|
|
176
|
+
normalized = price.each_with_object({}) do |(key, value), acc|
|
|
177
|
+
registry_key = registry_key_for(key)
|
|
178
|
+
if registry_key == CONTEXT_THRESHOLD_KEY
|
|
179
|
+
acc[registry_key] = Integer(value)
|
|
180
|
+
elsif registry_key
|
|
181
|
+
acc[registry_key] = non_negative_decimal(value, label: "price for #{registry_key.inspect}")
|
|
182
|
+
elsif !METADATA_KEYS.include?(key)
|
|
183
|
+
unknown << key
|
|
184
|
+
end
|
|
131
185
|
end
|
|
132
|
-
|
|
186
|
+
warn_unknown_keys(model, unknown, context) unless unknown.empty?
|
|
187
|
+
normalized
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def non_negative_decimal(value, label:)
|
|
191
|
+
decimal = BigDecimal(value.to_s)
|
|
192
|
+
raise ArgumentError, "#{label} must be finite (got #{value})" unless decimal.finite?
|
|
193
|
+
raise ArgumentError, "#{label} must be non-negative (got #{value})" if decimal.negative?
|
|
133
194
|
|
|
195
|
+
decimal
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def warn_unknown_keys(model, unknown_keys, path)
|
|
134
199
|
Logging.warn(
|
|
135
200
|
"Unknown price keys #{unknown_keys.inspect} for #{model.inspect} in #{path}; " \
|
|
136
201
|
"ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
|
|
137
202
|
)
|
|
138
203
|
end
|
|
139
204
|
|
|
140
|
-
def price_key_for(key)
|
|
141
|
-
name = key.is_a?(Symbol) ? key.name : key
|
|
142
|
-
Billing::Components::REGISTRY.each do |candidate|
|
|
143
|
-
return candidate.key if candidate.key.name == name
|
|
144
|
-
next unless candidate.token_key
|
|
145
|
-
|
|
146
|
-
suffix = "_#{candidate.key.name}"
|
|
147
|
-
next unless name.end_with?(suffix)
|
|
148
|
-
|
|
149
|
-
prefix = name.delete_suffix(suffix)
|
|
150
|
-
return :"#{prefix}_#{candidate.key.name}" unless prefix.empty?
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
nil
|
|
154
|
-
end
|
|
155
|
-
|
|
156
205
|
def registry_key_for(key)
|
|
157
|
-
return CONTEXT_THRESHOLD_KEY if key == CONTEXT_THRESHOLD_KEY
|
|
206
|
+
return CONTEXT_THRESHOLD_KEY if key.to_s == CONTEXT_THRESHOLD_KEY
|
|
158
207
|
|
|
159
|
-
price_key_for(key)
|
|
208
|
+
PriceKey.price_key_for(key)
|
|
160
209
|
end
|
|
161
210
|
|
|
162
211
|
def validate_price_entry(price, model:, context:)
|
|
@@ -165,6 +214,46 @@ module LlmCostTracker
|
|
|
165
214
|
|
|
166
215
|
raise ArgumentError, "price entry for #{model.inspect} in #{context} must be a hash"
|
|
167
216
|
end
|
|
217
|
+
|
|
218
|
+
def load_file_rates(path)
|
|
219
|
+
loading(path) { rates_from_registry(raw_file_registry(path), context: path).freeze }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def rates_from_section(entries, currency:, context:)
|
|
223
|
+
raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
|
|
224
|
+
|
|
225
|
+
entries.each_with_object({}) do |(key, amount), rates|
|
|
226
|
+
key = key.to_s
|
|
227
|
+
dimension, tier = dimension_and_tier_for(key, context: context)
|
|
228
|
+
amount = non_negative_decimal(amount, label: "service charge price amount for #{key.inspect} in #{context}")
|
|
229
|
+
|
|
230
|
+
rate = {
|
|
231
|
+
amount: amount,
|
|
232
|
+
quantity: rate_quantity(dimension),
|
|
233
|
+
currency: currency,
|
|
234
|
+
source_key: key
|
|
235
|
+
}
|
|
236
|
+
dimension_rates = rates[dimension.key] ||= { tiers: {} }
|
|
237
|
+
(tier ? dimension_rates[:tiers] : dimension_rates)[tier || :default] = rate
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def dimension_and_tier_for(key, context:)
|
|
242
|
+
dimension, tier = PriceKey.parse_dimension_key(key)
|
|
243
|
+
unless dimension && dimension.token_key.nil?
|
|
244
|
+
raise ArgumentError, "service charge price key #{key.inspect} in #{context} uses unknown billing dimension"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
[dimension, tier]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def rate_quantity(dimension)
|
|
251
|
+
Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def upcased_currency(value)
|
|
255
|
+
(value || LlmCostTracker::DEFAULT_CURRENCY).upcase
|
|
256
|
+
end
|
|
168
257
|
end
|
|
169
258
|
end
|
|
170
259
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
|
|
5
|
+
require_relative "../usage/catalog"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
require_relative "rate"
|
|
8
|
+
require_relative "mode"
|
|
9
|
+
|
|
10
|
+
module LlmCostTracker
|
|
11
|
+
module Pricing
|
|
12
|
+
module ServiceRates
|
|
13
|
+
class << self
|
|
14
|
+
def charge_rate(provider:, dimension:, pricing_mode:)
|
|
15
|
+
pricing_mode = Mode.normalize(pricing_mode)
|
|
16
|
+
provider_name = provider.to_s.presence
|
|
17
|
+
return nil unless provider_name
|
|
18
|
+
|
|
19
|
+
dimension_key = charge_dimension_key(dimension)
|
|
20
|
+
Registry.sources.each do |source|
|
|
21
|
+
provider_rates = source.rates.fetch(provider_name, {})
|
|
22
|
+
rate = rate_for(provider_rates, dimension_key: dimension_key, pricing_mode: pricing_mode)
|
|
23
|
+
next unless rate
|
|
24
|
+
|
|
25
|
+
return Pricing::Rate.new(
|
|
26
|
+
amount: rate.fetch(:amount),
|
|
27
|
+
quantity: rate.fetch(:quantity),
|
|
28
|
+
currency: rate.fetch(:currency),
|
|
29
|
+
source: source.name,
|
|
30
|
+
source_key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
|
|
31
|
+
source_version: source.version
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def rate_for(provider_table, dimension_key:, pricing_mode:)
|
|
40
|
+
dimension_rates = provider_table.fetch(dimension_key, {})
|
|
41
|
+
tier_rates = dimension_rates.fetch(:tiers, {})
|
|
42
|
+
if pricing_mode
|
|
43
|
+
rate = tier_rates[pricing_mode]
|
|
44
|
+
return rate if rate
|
|
45
|
+
|
|
46
|
+
tier_rates.each do |candidate, candidate_rate|
|
|
47
|
+
return candidate_rate if tier_includes?(pricing_mode, candidate)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
dimension_rates[:default]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def tier_includes?(tier_name, candidate_name)
|
|
54
|
+
tier_name == candidate_name ||
|
|
55
|
+
tier_name.start_with?("#{candidate_name}_") ||
|
|
56
|
+
tier_name.end_with?("_#{candidate_name}") ||
|
|
57
|
+
tier_name.include?("_#{candidate_name}_")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def charge_dimension_key(dimension)
|
|
61
|
+
billing_dimension = Usage::Catalog[dimension]
|
|
62
|
+
return billing_dimension.key if billing_dimension && billing_dimension.token_key.nil?
|
|
63
|
+
|
|
64
|
+
raise Error, "Unknown billing dimension: #{dimension.inspect}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -13,7 +13,7 @@ module LlmCostTracker
|
|
|
13
13
|
module Pricing
|
|
14
14
|
module Sync
|
|
15
15
|
class Fetcher
|
|
16
|
-
Response = Data.define(:body, :etag, :last_modified, :not_modified
|
|
16
|
+
Response = Data.define(:body, :etag, :last_modified, :not_modified) do
|
|
17
17
|
def source_version
|
|
18
18
|
etag || last_modified || Digest::SHA256.hexdigest(body.to_s)
|
|
19
19
|
end
|
|
@@ -106,8 +106,7 @@ module LlmCostTracker
|
|
|
106
106
|
body: body,
|
|
107
107
|
etag: response["etag"],
|
|
108
108
|
last_modified: response["last-modified"],
|
|
109
|
-
not_modified: not_modified
|
|
110
|
-
fetched_at: Time.now.utc.iso8601
|
|
109
|
+
not_modified: not_modified
|
|
111
110
|
)
|
|
112
111
|
end
|
|
113
112
|
end
|