llm_cost_tracker 0.7.3 → 0.9.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/.ruby-version +1 -0
- data/CHANGELOG.md +173 -0
- data/README.md +60 -220
- data/app/assets/llm_cost_tracker/application.css +282 -45
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
- 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 +18 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -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 +24 -7
- data/app/models/llm_cost_tracker/call.rb +166 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
- data/app/models/llm_cost_tracker/call_tag.rb +12 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
- data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
- data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
- 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 +66 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
- data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +95 -0
- data/lib/llm_cost_tracker/billing/components.yml +188 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
- data/lib/llm_cost_tracker/budget.rb +26 -36
- data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +86 -17
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
- data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
- data/lib/llm_cost_tracker/doctor.rb +111 -44
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +11 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- 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_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -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/upgrade_call_rollups_provider_generator.rb +38 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
- data/lib/llm_cost_tracker/ingestion.rb +66 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
- data/lib/llm_cost_tracker/integrations/base.rb +56 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
- data/lib/llm_cost_tracker/integrations.rb +21 -3
- data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +103 -20
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
- data/lib/llm_cost_tracker/ledger.rb +5 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
- data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
- data/lib/llm_cost_tracker/parsers/base.rb +13 -4
- data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
- data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
- data/lib/llm_cost_tracker/parsers.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +198 -22
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +220 -28
- data/lib/llm_cost_tracker/railtie.rb +6 -8
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -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 +19 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +22 -9
- data/lib/llm_cost_tracker/tags/context.rb +2 -5
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +64 -42
- data/lib/llm_cost_tracker/tracker.rb +97 -27
- data/lib/llm_cost_tracker/usage_capture.rb +29 -8
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +45 -35
- data/lib/tasks/llm_cost_tracker.rake +45 -17
- metadata +71 -41
- data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
- data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
- data/lib/llm_cost_tracker/pricing/components.rb +0 -37
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
|
@@ -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
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require "yaml"
|
|
5
4
|
|
|
6
|
-
require_relative "components"
|
|
5
|
+
require_relative "../billing/components"
|
|
7
6
|
require_relative "../logging"
|
|
8
7
|
|
|
9
8
|
module LlmCostTracker
|
|
@@ -11,42 +10,58 @@ module LlmCostTracker
|
|
|
11
10
|
module Registry
|
|
12
11
|
DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
|
|
13
12
|
EMPTY_PRICES = {}.freeze
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
CONTEXT_THRESHOLD_KEY = :_context_price_threshold_tokens
|
|
14
|
+
PRICE_KEYS = Billing::Components::TOKEN_PRICED.map { |component| component.key.name }.freeze
|
|
15
|
+
METADATA_KEYS = [
|
|
16
|
+
"_source", "_source_version", "_fetched_at", "_updated", "_notes", "_validator_override",
|
|
17
|
+
CONTEXT_THRESHOLD_KEY.name
|
|
18
18
|
].freeze
|
|
19
|
-
MAX_FILE_BYTES = 2_097_152
|
|
20
19
|
MUTEX = Mutex.new
|
|
21
20
|
|
|
22
21
|
class << self
|
|
22
|
+
def reset!
|
|
23
|
+
MUTEX.synchronize do
|
|
24
|
+
@builtin_prices = nil
|
|
25
|
+
@metadata = nil
|
|
26
|
+
@raw_registry = nil
|
|
27
|
+
@file_prices_cache = nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
def builtin_prices
|
|
24
32
|
cached = @builtin_prices
|
|
25
33
|
return cached if cached
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
29
41
|
end
|
|
30
42
|
|
|
31
43
|
def metadata
|
|
32
44
|
cached = @metadata
|
|
33
45
|
return cached if cached
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
MUTEX.synchronize do
|
|
48
|
+
@metadata ||= begin
|
|
49
|
+
registry = @raw_registry ||= load_raw_registry
|
|
50
|
+
registry.fetch("metadata", {}).freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
def file_metadata(path)
|
|
40
56
|
return {} unless path
|
|
41
57
|
|
|
42
|
-
registry =
|
|
43
|
-
raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
|
|
58
|
+
registry = YAML.safe_load_file(path, aliases: false) || {}
|
|
44
59
|
|
|
45
60
|
metadata = registry.fetch("metadata", {})
|
|
46
61
|
raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
|
|
47
62
|
|
|
48
63
|
metadata
|
|
49
|
-
rescue Errno::ENOENT,
|
|
64
|
+
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
50
65
|
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
51
66
|
end
|
|
52
67
|
|
|
@@ -57,8 +72,7 @@ module LlmCostTracker
|
|
|
57
72
|
def file_prices(path)
|
|
58
73
|
return EMPTY_PRICES unless path
|
|
59
74
|
|
|
60
|
-
|
|
61
|
-
cache_key = [path, File.mtime(path).to_f]
|
|
75
|
+
cache_key = [path, File.mtime(path)]
|
|
62
76
|
cached = @file_prices_cache
|
|
63
77
|
return cached[:value] if cached && cached[:key] == cache_key
|
|
64
78
|
|
|
@@ -66,11 +80,12 @@ module LlmCostTracker
|
|
|
66
80
|
cached = @file_prices_cache
|
|
67
81
|
return cached[:value] if cached && cached[:key] == cache_key
|
|
68
82
|
|
|
69
|
-
|
|
83
|
+
registry = YAML.safe_load_file(path, aliases: false) || {}
|
|
84
|
+
value = normalize_price_entries(registry.fetch("models", registry), context: path).freeze
|
|
70
85
|
@file_prices_cache = { key: cache_key, value: value }.freeze
|
|
71
86
|
value
|
|
72
87
|
end
|
|
73
|
-
rescue Errno::ENOENT,
|
|
88
|
+
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
74
89
|
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
75
90
|
end
|
|
76
91
|
|
|
@@ -80,20 +95,32 @@ module LlmCostTracker
|
|
|
80
95
|
cached = @raw_registry
|
|
81
96
|
return cached if cached
|
|
82
97
|
|
|
83
|
-
MUTEX.synchronize { @raw_registry ||=
|
|
98
|
+
MUTEX.synchronize { @raw_registry ||= load_raw_registry }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def load_raw_registry
|
|
102
|
+
YAML.safe_load_file(DEFAULT_PRICES_PATH, aliases: false).freeze
|
|
84
103
|
end
|
|
85
104
|
|
|
86
105
|
def normalize_price_entry(price)
|
|
87
106
|
price.each_with_object({}) do |(key, value), normalized|
|
|
88
|
-
key = key
|
|
89
|
-
if
|
|
90
|
-
normalized[key
|
|
91
|
-
elsif key
|
|
92
|
-
normalized[key
|
|
107
|
+
key = registry_key_for(key)
|
|
108
|
+
if key == CONTEXT_THRESHOLD_KEY
|
|
109
|
+
normalized[key] = Integer(value)
|
|
110
|
+
elsif key
|
|
111
|
+
normalized[key] = non_negative_float(key, value)
|
|
93
112
|
end
|
|
94
113
|
end
|
|
95
114
|
end
|
|
96
115
|
|
|
116
|
+
def non_negative_float(key, value)
|
|
117
|
+
rate = Float(value)
|
|
118
|
+
raise ArgumentError, "price for #{key.inspect} must be finite (got #{rate})" unless rate.finite?
|
|
119
|
+
raise ArgumentError, "price for #{key.inspect} must be non-negative (got #{rate})" if rate.negative?
|
|
120
|
+
|
|
121
|
+
rate
|
|
122
|
+
end
|
|
123
|
+
|
|
97
124
|
def normalize_price_entries(table, context:)
|
|
98
125
|
table = {} if table.nil?
|
|
99
126
|
raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
|
|
@@ -106,8 +133,8 @@ module LlmCostTracker
|
|
|
106
133
|
end
|
|
107
134
|
|
|
108
135
|
def warn_unknown_keys(model, price, path)
|
|
109
|
-
unknown_keys = price.keys.
|
|
110
|
-
|
|
136
|
+
unknown_keys = price.keys.reject do |key|
|
|
137
|
+
registry_key_for(key) || METADATA_KEYS.include?(key)
|
|
111
138
|
end
|
|
112
139
|
return if unknown_keys.empty?
|
|
113
140
|
|
|
@@ -117,31 +144,26 @@ module LlmCostTracker
|
|
|
117
144
|
)
|
|
118
145
|
end
|
|
119
146
|
|
|
120
|
-
def
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def load_price_file(path)
|
|
129
|
-
raise ArgumentError, "prices_file exceeds #{MAX_FILE_BYTES} bytes" if File.size(path) > MAX_FILE_BYTES
|
|
147
|
+
def price_key_for(key)
|
|
148
|
+
name = key.is_a?(Symbol) ? key.name : key
|
|
149
|
+
Billing::Components::REGISTRY.each do |candidate|
|
|
150
|
+
return candidate.key if candidate.key.name == name
|
|
151
|
+
next unless candidate.token_key
|
|
130
152
|
|
|
131
|
-
|
|
132
|
-
|
|
153
|
+
suffix = "_#{candidate.key.name}"
|
|
154
|
+
next unless name.end_with?(suffix)
|
|
133
155
|
|
|
134
|
-
|
|
135
|
-
|
|
156
|
+
prefix = name.delete_suffix(suffix)
|
|
157
|
+
return :"#{prefix}_#{candidate.key.name}" unless prefix.empty?
|
|
158
|
+
end
|
|
136
159
|
|
|
137
|
-
|
|
138
|
-
%w[.yaml .yml].include?(File.extname(path).downcase)
|
|
160
|
+
nil
|
|
139
161
|
end
|
|
140
162
|
|
|
141
|
-
def
|
|
142
|
-
|
|
163
|
+
def registry_key_for(key)
|
|
164
|
+
return CONTEXT_THRESHOLD_KEY if key == CONTEXT_THRESHOLD_KEY || key == CONTEXT_THRESHOLD_KEY.name
|
|
143
165
|
|
|
144
|
-
|
|
166
|
+
price_key_for(key)
|
|
145
167
|
end
|
|
146
168
|
|
|
147
169
|
def validate_price_entry(price, model:, context:)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "bigdecimal"
|
|
5
|
+
require "time"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
require_relative "../billing/components"
|
|
9
|
+
require_relative "registry"
|
|
10
|
+
|
|
11
|
+
module LlmCostTracker
|
|
12
|
+
module Pricing
|
|
13
|
+
module ServiceCharges
|
|
14
|
+
extend self
|
|
15
|
+
|
|
16
|
+
DEFAULT_CURRENCY = "USD"
|
|
17
|
+
EMPTY_RATES = {}.freeze
|
|
18
|
+
MUTEX = Mutex.new
|
|
19
|
+
|
|
20
|
+
def reset!
|
|
21
|
+
MUTEX.synchronize do
|
|
22
|
+
@builtin_rates = nil
|
|
23
|
+
@file_rates_cache = nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def builtin_rates
|
|
28
|
+
cached = @builtin_rates
|
|
29
|
+
return cached if cached
|
|
30
|
+
|
|
31
|
+
MUTEX.synchronize do
|
|
32
|
+
@builtin_rates ||= begin
|
|
33
|
+
registry = YAML.safe_load_file(Registry::DEFAULT_PRICES_PATH, aliases: false) || {}
|
|
34
|
+
rates_from_registry(registry).freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def file_rates(path)
|
|
40
|
+
return EMPTY_RATES unless path
|
|
41
|
+
|
|
42
|
+
cache_key = [path, File.mtime(path)]
|
|
43
|
+
cached = @file_rates_cache
|
|
44
|
+
return cached[:value] if cached && cached[:key] == cache_key
|
|
45
|
+
|
|
46
|
+
MUTEX.synchronize do
|
|
47
|
+
cached = @file_rates_cache
|
|
48
|
+
return cached[:value] if cached && cached[:key] == cache_key
|
|
49
|
+
|
|
50
|
+
registry = YAML.safe_load_file(path, aliases: false) || {}
|
|
51
|
+
value = rates_from_registry(registry, context: path).freeze
|
|
52
|
+
@file_rates_cache = { key: cache_key, value: value }.freeze
|
|
53
|
+
value
|
|
54
|
+
end
|
|
55
|
+
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
56
|
+
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def rates_from_registry(registry, context: "price registry")
|
|
60
|
+
data = registry.fetch("service_charges", EMPTY_RATES)
|
|
61
|
+
raise ArgumentError, "#{context} service_charges must be a hash" unless data.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
data.each_with_object({}) do |(provider, entries), rates|
|
|
64
|
+
section_context = "#{context} service_charges.#{provider}"
|
|
65
|
+
rates[provider] = rates_from_section(entries, context: section_context)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def charge_rate(provider:, component:, pricing_mode:)
|
|
70
|
+
pricing_mode = Pricing.normalize_mode(pricing_mode)
|
|
71
|
+
match = charge_rate_match(provider: provider, component: component, pricing_mode: pricing_mode)
|
|
72
|
+
return nil unless match
|
|
73
|
+
|
|
74
|
+
rate = match.fetch(:rate)
|
|
75
|
+
{
|
|
76
|
+
amount: rate.fetch(:amount),
|
|
77
|
+
quantity: rate.fetch(:quantity),
|
|
78
|
+
currency: rate.fetch(:currency),
|
|
79
|
+
source: match.fetch(:source),
|
|
80
|
+
source_key: match.fetch(:key),
|
|
81
|
+
source_version: rate_source_version_for(match.fetch(:source))
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def rates_from_section(entries, context:)
|
|
88
|
+
raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
entries.each_with_object({}) do |(key, amount), rates|
|
|
91
|
+
key = key.name if key.is_a?(Symbol)
|
|
92
|
+
component, tier = component_and_tier_for(key, context: context)
|
|
93
|
+
amount = amount_for(key, amount, context: context)
|
|
94
|
+
|
|
95
|
+
rate = {
|
|
96
|
+
amount: amount,
|
|
97
|
+
quantity: rate_quantity(component),
|
|
98
|
+
currency: DEFAULT_CURRENCY,
|
|
99
|
+
source_key: key
|
|
100
|
+
}
|
|
101
|
+
component_rates = rates[component.key] ||= { tiers: {} }
|
|
102
|
+
(tier ? component_rates[:tiers] : component_rates)[tier || :default] = rate
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def component_and_tier_for(key, context:)
|
|
107
|
+
Billing::Components::REGISTRY.each do |component|
|
|
108
|
+
next if component.token_key
|
|
109
|
+
|
|
110
|
+
return [component, nil] if key == component.key.name
|
|
111
|
+
|
|
112
|
+
suffix = "_#{component.key.name}"
|
|
113
|
+
next unless key.end_with?(suffix)
|
|
114
|
+
|
|
115
|
+
tier = key.delete_suffix(suffix)
|
|
116
|
+
return [component, :"#{tier}"] unless tier.empty?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
raise ArgumentError, "service charge price key #{key.inspect} in #{context} uses unknown billing component"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def amount_for(key, amount, context:)
|
|
123
|
+
value = BigDecimal(amount.to_s)
|
|
124
|
+
if value.infinite? || value.nan?
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"service charge price amount for #{key.inspect} in #{context} must be finite"
|
|
127
|
+
end
|
|
128
|
+
if value.negative?
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"service charge price amount for #{key.inspect} in #{context} must be non-negative"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
value
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def rate_quantity(component)
|
|
137
|
+
BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis, 1).to_s)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def charge_rate_match(provider:, component:, pricing_mode:)
|
|
141
|
+
provider_name = provider.is_a?(Symbol) ? provider.name : provider.presence
|
|
142
|
+
return nil unless provider_name
|
|
143
|
+
|
|
144
|
+
component_key = charge_component_key(component)
|
|
145
|
+
|
|
146
|
+
table = ServiceCharges.file_rates(LlmCostTracker.configuration.prices_file)
|
|
147
|
+
provider_table = table.fetch(provider_name, EMPTY_RATES)
|
|
148
|
+
rate = rate_for(provider_table, component_key: component_key, pricing_mode: pricing_mode)
|
|
149
|
+
if rate
|
|
150
|
+
return {
|
|
151
|
+
source: :prices_file,
|
|
152
|
+
key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
|
|
153
|
+
rate: rate
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
table = ServiceCharges.builtin_rates
|
|
158
|
+
provider_table = table.fetch(provider_name, EMPTY_RATES)
|
|
159
|
+
rate = rate_for(provider_table, component_key: component_key, pricing_mode: pricing_mode)
|
|
160
|
+
return unless rate
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
source: :bundled,
|
|
164
|
+
key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
|
|
165
|
+
rate: rate
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def rate_for(provider_table, component_key:, pricing_mode:)
|
|
170
|
+
component_rates = provider_table.fetch(component_key, EMPTY_RATES)
|
|
171
|
+
tier_rates = component_rates.fetch(:tiers, EMPTY_RATES)
|
|
172
|
+
if pricing_mode
|
|
173
|
+
rate = tier_rates[pricing_mode]
|
|
174
|
+
return rate if rate
|
|
175
|
+
|
|
176
|
+
name = pricing_mode.name
|
|
177
|
+
tier_rates.each do |candidate, candidate_rate|
|
|
178
|
+
return candidate_rate if tier_includes?(name, candidate.name)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
component_rates[:default]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def tier_includes?(tier_name, candidate_name)
|
|
185
|
+
tier_name == candidate_name ||
|
|
186
|
+
tier_name.start_with?("#{candidate_name}_") ||
|
|
187
|
+
tier_name.end_with?("_#{candidate_name}") ||
|
|
188
|
+
tier_name.include?("_#{candidate_name}_")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def charge_component_key(component)
|
|
192
|
+
billing_component = Billing::Components::BY_KEY[component]
|
|
193
|
+
return billing_component.key if billing_component && billing_component.token_key.nil?
|
|
194
|
+
|
|
195
|
+
raise Error, "Unknown billing component: #{component.inspect}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def rate_source_version_for(source)
|
|
199
|
+
return LlmCostTracker::VERSION if source == :bundled
|
|
200
|
+
|
|
201
|
+
path = LlmCostTracker.configuration.prices_file
|
|
202
|
+
return nil unless path
|
|
203
|
+
|
|
204
|
+
File.mtime(path).utc.iso8601
|
|
205
|
+
rescue Errno::ENOENT
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -7,6 +7,8 @@ require "openssl"
|
|
|
7
7
|
require "time"
|
|
8
8
|
require "uri"
|
|
9
9
|
|
|
10
|
+
require_relative "../../version"
|
|
11
|
+
|
|
10
12
|
module LlmCostTracker
|
|
11
13
|
module Pricing
|
|
12
14
|
module Sync
|
|
@@ -17,7 +19,7 @@ module LlmCostTracker
|
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
USER_AGENT = "llm_cost_tracker price refresh"
|
|
22
|
+
USER_AGENT = "llm_cost_tracker/#{LlmCostTracker::VERSION} price refresh".freeze
|
|
21
23
|
MAX_REDIRECTS = 5
|
|
22
24
|
MAX_BODY_BYTES = 2_097_152
|
|
23
25
|
OPEN_TIMEOUT = 5
|
|
@@ -25,7 +27,8 @@ module LlmCostTracker
|
|
|
25
27
|
WRITE_TIMEOUT = 10
|
|
26
28
|
|
|
27
29
|
def get(url, etag: nil, redirects: 0)
|
|
28
|
-
|
|
30
|
+
safe_url = scrub_url(url)
|
|
31
|
+
raise Error, "Too many redirects while fetching #{safe_url}" if redirects > MAX_REDIRECTS
|
|
29
32
|
|
|
30
33
|
uri = URI.parse(url)
|
|
31
34
|
raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
|
|
@@ -38,23 +41,34 @@ module LlmCostTracker
|
|
|
38
41
|
|
|
39
42
|
case response
|
|
40
43
|
when Net::HTTPSuccess
|
|
41
|
-
build_response(response, body: body
|
|
44
|
+
build_response(response, body: body, not_modified: false)
|
|
42
45
|
when Net::HTTPNotModified
|
|
43
46
|
build_response(response, body: nil, not_modified: true)
|
|
44
47
|
when Net::HTTPRedirection
|
|
45
48
|
location = response["location"]
|
|
46
|
-
raise Error, "Redirect without location while fetching #{
|
|
49
|
+
raise Error, "Redirect without location while fetching #{safe_url}" if location.blank?
|
|
47
50
|
|
|
48
51
|
get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
|
|
49
52
|
else
|
|
50
|
-
raise Error, "Unable to fetch #{
|
|
53
|
+
raise Error, "Unable to fetch #{safe_url}: HTTP #{response.code}"
|
|
51
54
|
end
|
|
52
55
|
rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
|
|
53
|
-
raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
|
|
56
|
+
raise Error, "Unable to fetch #{scrub_url(url)}: #{e.class}: #{e.message}"
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
private
|
|
57
60
|
|
|
61
|
+
def scrub_url(url)
|
|
62
|
+
uri = URI.parse(url.to_s)
|
|
63
|
+
uri.user = nil
|
|
64
|
+
uri.password = nil
|
|
65
|
+
uri.query = nil
|
|
66
|
+
uri.fragment = nil
|
|
67
|
+
uri.to_s
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
"[invalid url]"
|
|
70
|
+
end
|
|
71
|
+
|
|
58
72
|
def fetch_response(uri, request)
|
|
59
73
|
body = nil
|
|
60
74
|
response = Net::HTTP.start(
|
|
@@ -75,19 +89,14 @@ module LlmCostTracker
|
|
|
75
89
|
|
|
76
90
|
def limited_body(response)
|
|
77
91
|
body = +""
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes"
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
body << chunk
|
|
92
|
+
response.read_body do |chunk|
|
|
93
|
+
chunk = chunk.to_s
|
|
94
|
+
if body.bytesize + chunk.bytesize > MAX_BODY_BYTES
|
|
95
|
+
raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes"
|
|
86
96
|
end
|
|
87
|
-
|
|
88
|
-
body
|
|
97
|
+
|
|
98
|
+
body << chunk
|
|
89
99
|
end
|
|
90
|
-
raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes" if body.bytesize > MAX_BODY_BYTES
|
|
91
100
|
|
|
92
101
|
body
|
|
93
102
|
end
|
|
@@ -18,8 +18,8 @@ module LlmCostTracker
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
20
|
def price_field_changes(current_entry, updated_entry)
|
|
21
|
-
current_price =
|
|
22
|
-
updated_price =
|
|
21
|
+
current_price = current_entry || {}
|
|
22
|
+
updated_price = updated_entry || {}
|
|
23
23
|
|
|
24
24
|
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
25
25
|
from = current_price[field]
|
|
@@ -30,21 +30,12 @@ module LlmCostTracker
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def comparable_price(entry)
|
|
34
|
-
normalize_hash(entry).slice(*Registry::PRICE_KEYS)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
33
|
def normalize_models(models)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def normalize_hash(hash)
|
|
42
|
-
return {} if hash.nil?
|
|
43
|
-
raise Error, "pricing entries must be hashes" unless hash.is_a?(Hash)
|
|
44
|
-
|
|
45
|
-
hash.each_with_object({}) do |(key, value), normalized|
|
|
46
|
-
normalized[key.to_s] = value
|
|
34
|
+
Registry.normalize_price_table(models).transform_values do |price|
|
|
35
|
+
price.to_h { |key, value| [key.name, value] }
|
|
47
36
|
end
|
|
37
|
+
rescue ArgumentError, TypeError => e
|
|
38
|
+
raise Error, e.message
|
|
48
39
|
end
|
|
49
40
|
end
|
|
50
41
|
end
|
|
@@ -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 = 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,53 @@ module LlmCostTracker
|
|
|
22
24
|
|
|
23
25
|
private
|
|
24
26
|
|
|
27
|
+
def merge_with_existing(path:, registry:)
|
|
28
|
+
existing = read_existing(path)
|
|
29
|
+
return registry unless existing.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
merged = registry.dup
|
|
32
|
+
merged["models"] = merged_models(registry, existing) if existing["models"].is_a?(Hash)
|
|
33
|
+
if existing["service_charges"].is_a?(Hash)
|
|
34
|
+
merged["service_charges"] = merged_service_charges(registry, existing)
|
|
35
|
+
end
|
|
36
|
+
merged
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def merged_models(registry, existing)
|
|
40
|
+
merged = registry.fetch("models", {}).dup
|
|
41
|
+
existing.fetch("models", {}).each do |model, attrs|
|
|
42
|
+
next unless attrs.is_a?(Hash) && attrs["_source"].to_s == MANUAL_SOURCE
|
|
43
|
+
next if merged.key?(model)
|
|
44
|
+
|
|
45
|
+
merged[model] = attrs
|
|
46
|
+
end
|
|
47
|
+
merged
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def merged_service_charges(registry, existing)
|
|
51
|
+
remote = registry.fetch("service_charges", {})
|
|
52
|
+
existing.fetch("service_charges", {}).each_with_object(remote.dup) do |(provider, charges), merged|
|
|
53
|
+
next unless charges.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
merged[provider] = charges.merge(merged.fetch(provider, {}))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def read_existing(path)
|
|
60
|
+
return nil unless File.exist?(path)
|
|
61
|
+
|
|
62
|
+
contents = File.read(path)
|
|
63
|
+
return nil if contents.strip.empty?
|
|
64
|
+
|
|
65
|
+
if yaml_file?(path)
|
|
66
|
+
YAML.safe_load(contents, permitted_classes: [Symbol, Date, Time])
|
|
67
|
+
else
|
|
68
|
+
JSON.parse(contents)
|
|
69
|
+
end
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
25
74
|
def yaml_file?(path)
|
|
26
75
|
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
27
76
|
end
|