llm_cost_tracker 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +136 -0
- data/README.md +14 -6
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
- data/lib/llm_cost_tracker/budget.rb +31 -7
- data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +72 -17
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -14
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +48 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
- data/lib/llm_cost_tracker/ingestion.rb +48 -11
- data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
- data/lib/llm_cost_tracker/integrations/base.rb +35 -15
- data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
- data/lib/llm_cost_tracker/integrations.rb +33 -14
- data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
- data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
- data/lib/llm_cost_tracker/ledger/store.rb +34 -31
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
- data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -43
- data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +572 -493
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
- data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +117 -44
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/railtie.rb +8 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +4 -1
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +31 -6
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
- data/lib/llm_cost_tracker/token_usage.rb +14 -2
- data/lib/llm_cost_tracker/tracker.rb +41 -55
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +19 -14
- data/lib/tasks/llm_cost_tracker.rake +41 -4
- metadata +49 -3
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/integer/time"
|
|
4
4
|
|
|
5
|
+
require_relative "../billing/cost_status"
|
|
5
6
|
require_relative "../ledger"
|
|
6
7
|
|
|
7
8
|
module LlmCostTracker
|
|
@@ -57,7 +58,9 @@ module LlmCostTracker
|
|
|
57
58
|
"COALESCE(SUM(total_cost), 0) AS total_cost, " \
|
|
58
59
|
"COUNT(*) AS requests_count, " \
|
|
59
60
|
"AVG(latency_ms) AS average_latency_ms, " \
|
|
60
|
-
"COALESCE(SUM(CASE WHEN total_cost IS NULL
|
|
61
|
+
"COALESCE(SUM(CASE WHEN total_cost IS NULL " \
|
|
62
|
+
"OR cost_status IN ('#{Billing::CostStatus::UNKNOWN}', '#{Billing::CostStatus::PARTIAL}') " \
|
|
63
|
+
"THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
|
|
61
64
|
)
|
|
62
65
|
.take
|
|
63
66
|
end
|
|
@@ -15,10 +15,6 @@ module LlmCostTracker
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
Formatter.new(report_data).to_s
|
|
18
|
-
rescue LoadError => e
|
|
19
|
-
"Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
|
|
20
|
-
rescue StandardError => e
|
|
21
|
-
"Unable to build LLM cost report: #{e.class}: #{e.message}"
|
|
22
18
|
end
|
|
23
19
|
end
|
|
24
20
|
end
|
|
@@ -21,6 +21,25 @@ module LlmCostTracker
|
|
|
21
21
|
deleted
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
def prune_invoice_imports(older_than:, now: Time.now.utc)
|
|
25
|
+
cutoff = resolve_cutoff(older_than, now)
|
|
26
|
+
require_relative "ledger"
|
|
27
|
+
return 0 unless LlmCostTracker::ProviderInvoiceImport.table_exists?
|
|
28
|
+
|
|
29
|
+
LlmCostTracker::ProviderInvoiceImport
|
|
30
|
+
.where(state: %w[completed failed])
|
|
31
|
+
.where(finished_at: ...cutoff)
|
|
32
|
+
.delete_all
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def prune_inbox(older_than:, now: Time.now.utc)
|
|
36
|
+
cutoff = resolve_cutoff(older_than, now)
|
|
37
|
+
require_relative "ingestion"
|
|
38
|
+
return 0 unless LlmCostTracker::Ingestion::InboxEntry.table_exists?
|
|
39
|
+
|
|
40
|
+
LlmCostTracker::Ingestion::InboxEntry.where(tracked_at: ...cutoff).delete_all
|
|
41
|
+
end
|
|
42
|
+
|
|
24
43
|
private
|
|
25
44
|
|
|
26
45
|
def resolve_cutoff(older_than, now)
|
|
@@ -50,18 +69,24 @@ module LlmCostTracker
|
|
|
50
69
|
|
|
51
70
|
def prune_batch(cutoff, batch_size)
|
|
52
71
|
LlmCostTracker::Call.transaction do
|
|
53
|
-
|
|
72
|
+
cache_rollups = LlmCostTracker.configuration.cache_rollups
|
|
73
|
+
rows = pluck_prunable(cutoff, batch_size, with_rollup_columns: cache_rollups)
|
|
54
74
|
next 0 if rows.empty?
|
|
55
75
|
|
|
56
|
-
|
|
57
|
-
LlmCostTracker::
|
|
76
|
+
ids = cache_rollups ? rows.map(&:first) : rows
|
|
77
|
+
deleted = LlmCostTracker::Call.where(id: ids).delete_all
|
|
78
|
+
LlmCostTracker::Ledger::Rollups.decrement!(rows) if cache_rollups && deleted.positive?
|
|
58
79
|
deleted
|
|
59
80
|
end
|
|
60
81
|
end
|
|
61
82
|
|
|
62
|
-
def pluck_prunable(cutoff, batch_size)
|
|
63
|
-
LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
|
|
64
|
-
|
|
83
|
+
def pluck_prunable(cutoff, batch_size, with_rollup_columns:)
|
|
84
|
+
relation = LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
|
|
85
|
+
if with_rollup_columns
|
|
86
|
+
relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
|
|
87
|
+
else
|
|
88
|
+
relation.pluck(:id)
|
|
89
|
+
end
|
|
65
90
|
end
|
|
66
91
|
end
|
|
67
92
|
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "active_support/core_ext/object/deep_dup"
|
|
4
3
|
require "active_support/isolated_execution_state"
|
|
5
4
|
|
|
6
5
|
module LlmCostTracker
|
|
@@ -11,7 +10,7 @@ module LlmCostTracker
|
|
|
11
10
|
class << self
|
|
12
11
|
def with(tags)
|
|
13
12
|
stack = ActiveSupport::IsolatedExecutionState[KEY] || []
|
|
14
|
-
ActiveSupport::IsolatedExecutionState[KEY] = stack + [(tags || {}).
|
|
13
|
+
ActiveSupport::IsolatedExecutionState[KEY] = stack + [Sanitizer.call((tags || {}).to_h)]
|
|
15
14
|
yield
|
|
16
15
|
ensure
|
|
17
16
|
ActiveSupport::IsolatedExecutionState[KEY] = stack
|
|
@@ -19,9 +18,9 @@ module LlmCostTracker
|
|
|
19
18
|
|
|
20
19
|
def tags
|
|
21
20
|
default_tags = LlmCostTracker.configuration.default_tags
|
|
22
|
-
default_tags = default_tags.call
|
|
21
|
+
default_tags = default_tags.call if default_tags.respond_to?(:call)
|
|
23
22
|
|
|
24
|
-
default_tags.to_h.merge(*Array(ActiveSupport::IsolatedExecutionState[KEY]))
|
|
23
|
+
Sanitizer.call(default_tags.to_h).merge(*Array(ActiveSupport::IsolatedExecutionState[KEY]))
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
def clear!
|
|
@@ -1,32 +1,99 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/string/inflections"
|
|
4
|
-
require "json"
|
|
5
4
|
|
|
6
5
|
module LlmCostTracker
|
|
7
6
|
module Tags
|
|
8
7
|
module Sanitizer
|
|
9
8
|
REDACTED_VALUE = "[REDACTED]"
|
|
10
9
|
|
|
10
|
+
SECRET_VALUE_PATTERNS = [
|
|
11
|
+
/\Ask-(?:ant-|admin-|proj-|svcacct-|live-|test-)?[A-Za-z0-9_-]{16,}\z/,
|
|
12
|
+
/\AAKIA[0-9A-Z]{16}\z/,
|
|
13
|
+
/\Agh[opsur]_[A-Za-z0-9]{16,}\z/,
|
|
14
|
+
/\Agithub_pat_[A-Za-z0-9_]{20,}\z/,
|
|
15
|
+
/\Aeyj[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\z/i,
|
|
16
|
+
/\Abearer\s+[A-Za-z0-9_.-]{20,}\z/i,
|
|
17
|
+
/\Axox[abprs]-[A-Za-z0-9-]{10,}\z/,
|
|
18
|
+
/\A(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}\z/,
|
|
19
|
+
/\AAIza[0-9A-Za-z_-]{35}\z/
|
|
20
|
+
].freeze
|
|
21
|
+
private_constant :SECRET_VALUE_PATTERNS
|
|
22
|
+
|
|
11
23
|
class << self
|
|
12
24
|
def call(tags, config: LlmCostTracker.configuration)
|
|
13
25
|
tags = (tags || {}).to_h
|
|
14
|
-
redacted =
|
|
26
|
+
redacted = config.normalized_redacted_tag_keys
|
|
15
27
|
limit = [config.max_tag_value_bytesize.to_i, 0].max
|
|
16
|
-
|
|
28
|
+
max_count = [config.max_tag_count.to_i, 0].max
|
|
29
|
+
tags.to_a.last(max_count).each_with_object({}) do |(key, value), sanitized|
|
|
17
30
|
sanitized[key] = sanitized_value(key, value, redacted, limit)
|
|
18
31
|
end
|
|
19
32
|
end
|
|
20
33
|
|
|
34
|
+
def cap(tags, config: LlmCostTracker.configuration)
|
|
35
|
+
tags = (tags || {}).to_h
|
|
36
|
+
max_count = [config.max_tag_count.to_i, 0].max
|
|
37
|
+
return tags if tags.size <= max_count
|
|
38
|
+
|
|
39
|
+
tags.to_a.last(max_count).to_h
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def normalized_key(key)
|
|
43
|
+
key.to_s.underscore.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
|
|
44
|
+
end
|
|
45
|
+
|
|
21
46
|
private
|
|
22
47
|
|
|
23
48
|
def sanitized_value(key, value, redacted, limit)
|
|
24
49
|
return REDACTED_VALUE if redacted_key?(key, redacted)
|
|
25
50
|
|
|
26
|
-
|
|
27
|
-
return
|
|
51
|
+
scrubbed = scrub_secrets(value)
|
|
52
|
+
return REDACTED_VALUE if scrubbed.equal?(REDACTED_SENTINEL)
|
|
53
|
+
|
|
54
|
+
scalar_truncate(scrubbed, limit)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
REDACTED_SENTINEL = Object.new.freeze
|
|
58
|
+
private_constant :REDACTED_SENTINEL
|
|
59
|
+
|
|
60
|
+
def scalar_truncate(value, limit)
|
|
61
|
+
case value
|
|
62
|
+
when Hash
|
|
63
|
+
value.transform_values { |nested| scalar_truncate(nested, limit) }
|
|
64
|
+
when Array
|
|
65
|
+
value.map { |nested| scalar_truncate(nested, limit) }
|
|
66
|
+
else
|
|
67
|
+
return value if value == REDACTED_VALUE
|
|
68
|
+
|
|
69
|
+
string = value.to_s
|
|
70
|
+
return value if string.bytesize <= limit
|
|
71
|
+
|
|
72
|
+
string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
28
75
|
|
|
29
|
-
|
|
76
|
+
def scrub_secrets(value)
|
|
77
|
+
case value
|
|
78
|
+
when Hash
|
|
79
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
80
|
+
scrubbed = scrub_secrets(nested)
|
|
81
|
+
out[key] = scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
|
|
82
|
+
end
|
|
83
|
+
when Array
|
|
84
|
+
value.map do |nested|
|
|
85
|
+
scrubbed = scrub_secrets(nested)
|
|
86
|
+
scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
secret_shaped?(value.to_s) ? REDACTED_SENTINEL : value
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def secret_shaped?(string)
|
|
94
|
+
return false if string.bytesize < 16
|
|
95
|
+
|
|
96
|
+
SECRET_VALUE_PATTERNS.any? { |pattern| pattern.match?(string) }
|
|
30
97
|
end
|
|
31
98
|
|
|
32
99
|
def redacted_key?(key, redacted)
|
|
@@ -36,27 +103,12 @@ module LlmCostTracker
|
|
|
36
103
|
redacted.any? { |candidate| redacted_key_component?(normalized, candidate) }
|
|
37
104
|
end
|
|
38
105
|
|
|
39
|
-
def normalized_key(key)
|
|
40
|
-
key.to_s.underscore.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
|
|
41
|
-
end
|
|
42
|
-
|
|
43
106
|
def redacted_key_component?(key, candidate)
|
|
44
107
|
key == candidate ||
|
|
45
108
|
key.start_with?("#{candidate}_") ||
|
|
46
109
|
key.end_with?("_#{candidate}") ||
|
|
47
110
|
key.include?("_#{candidate}_")
|
|
48
111
|
end
|
|
49
|
-
|
|
50
|
-
def value_string(value)
|
|
51
|
-
case value
|
|
52
|
-
when Hash, Array
|
|
53
|
-
JSON.generate(value)
|
|
54
|
-
else
|
|
55
|
-
value.to_s
|
|
56
|
-
end
|
|
57
|
-
rescue JSON::GeneratorError, TypeError
|
|
58
|
-
value.to_s
|
|
59
|
-
end
|
|
60
112
|
end
|
|
61
113
|
end
|
|
62
114
|
end
|
|
@@ -14,11 +14,17 @@ module LlmCostTracker
|
|
|
14
14
|
:cache_write_input_tokens,
|
|
15
15
|
:cache_write_extended_input_tokens,
|
|
16
16
|
:audio_input_tokens,
|
|
17
|
+
:image_input_tokens,
|
|
17
18
|
:output_tokens,
|
|
18
19
|
:audio_output_tokens,
|
|
20
|
+
:image_output_tokens,
|
|
19
21
|
:total_tokens,
|
|
20
22
|
:hidden_output_tokens
|
|
21
23
|
) do
|
|
24
|
+
def priced_quantities
|
|
25
|
+
Billing::Components::TOKEN_PRICED.to_h { |component| [component.key, public_send(component.token_key)] }
|
|
26
|
+
end
|
|
27
|
+
|
|
22
28
|
def self.build_from_tokens(tokens)
|
|
23
29
|
return tokens if tokens.is_a?(self)
|
|
24
30
|
raise ArgumentError, "tokens must be a Hash, got #{tokens.class}" unless tokens.respond_to?(:to_h)
|
|
@@ -53,6 +59,7 @@ module LlmCostTracker
|
|
|
53
59
|
def self.build(input_tokens:, output_tokens:, cache_read_input_tokens: 0,
|
|
54
60
|
cache_write_input_tokens: 0, cache_write_extended_input_tokens: 0,
|
|
55
61
|
audio_input_tokens: 0, audio_output_tokens: 0,
|
|
62
|
+
image_input_tokens: 0, image_output_tokens: 0,
|
|
56
63
|
total_tokens: nil, hidden_output_tokens: 0)
|
|
57
64
|
input = non_negative_int(input_tokens)
|
|
58
65
|
output = non_negative_int(output_tokens)
|
|
@@ -61,9 +68,12 @@ module LlmCostTracker
|
|
|
61
68
|
cache_write_extended = non_negative_int(cache_write_extended_input_tokens)
|
|
62
69
|
audio_input = non_negative_int(audio_input_tokens)
|
|
63
70
|
audio_output = non_negative_int(audio_output_tokens)
|
|
71
|
+
image_input = non_negative_int(image_input_tokens)
|
|
72
|
+
image_output = non_negative_int(image_output_tokens)
|
|
64
73
|
hidden_output = non_negative_int(hidden_output_tokens)
|
|
65
|
-
calculated_total = input + cache_read + cache_write + cache_write_extended +
|
|
66
|
-
|
|
74
|
+
calculated_total = input + cache_read + cache_write + cache_write_extended +
|
|
75
|
+
audio_input + image_input + output + audio_output + image_output
|
|
76
|
+
total = total_tokens ? [non_negative_int(total_tokens), calculated_total].max : calculated_total
|
|
67
77
|
|
|
68
78
|
new(
|
|
69
79
|
input_tokens: input,
|
|
@@ -71,8 +81,10 @@ module LlmCostTracker
|
|
|
71
81
|
cache_write_input_tokens: cache_write,
|
|
72
82
|
cache_write_extended_input_tokens: cache_write_extended,
|
|
73
83
|
audio_input_tokens: audio_input,
|
|
84
|
+
image_input_tokens: image_input,
|
|
74
85
|
output_tokens: output,
|
|
75
86
|
audio_output_tokens: audio_output,
|
|
87
|
+
image_output_tokens: image_output,
|
|
76
88
|
total_tokens: total,
|
|
77
89
|
hidden_output_tokens: hidden_output
|
|
78
90
|
)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
|
-
require "bigdecimal"
|
|
5
4
|
require "securerandom"
|
|
6
5
|
|
|
7
6
|
require_relative "ingestion"
|
|
8
7
|
require_relative "ledger"
|
|
8
|
+
require_relative "logging"
|
|
9
9
|
require_relative "pricing"
|
|
10
10
|
require_relative "billing/cost_status"
|
|
11
11
|
|
|
@@ -14,38 +14,48 @@ module LlmCostTracker
|
|
|
14
14
|
EVENT_NAME = "llm_request.llm_cost_tracker"
|
|
15
15
|
|
|
16
16
|
class << self
|
|
17
|
-
def enforce_budget!
|
|
17
|
+
def enforce_budget!(provider: nil, model: nil, request: nil)
|
|
18
18
|
return unless LlmCostTracker.configuration.enabled
|
|
19
19
|
|
|
20
|
-
Budget.enforce!
|
|
20
|
+
Budget.enforce!(provider: provider, model: model, request: request)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def record(
|
|
23
|
+
def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
|
|
24
24
|
return unless LlmCostTracker.configuration.enabled
|
|
25
25
|
|
|
26
|
-
pricing_mode = Pricing.normalize_mode(pricing_mode) ||
|
|
27
|
-
cost_data, pricing_snapshot = Pricing.
|
|
28
|
-
provider:
|
|
29
|
-
model:
|
|
30
|
-
tokens:
|
|
26
|
+
pricing_mode = Pricing.normalize_mode(pricing_mode) || event.pricing_mode
|
|
27
|
+
cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
|
|
28
|
+
provider: event.provider,
|
|
29
|
+
model: event.model,
|
|
30
|
+
tokens: event.token_usage,
|
|
31
|
+
line_items: event.line_items,
|
|
31
32
|
pricing_mode: pricing_mode
|
|
32
33
|
)
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
if cost_data.nil? && event.token_usage.total_tokens.positive? && priced_line_items.none?(&:priced?)
|
|
36
|
+
Pricing::Unknown.process(event.model)
|
|
37
|
+
end
|
|
35
38
|
|
|
36
39
|
event = build_event(
|
|
37
|
-
|
|
40
|
+
event: event,
|
|
38
41
|
pricing_mode: pricing_mode,
|
|
39
42
|
cost_data: cost_data,
|
|
40
43
|
pricing_snapshot: pricing_snapshot,
|
|
44
|
+
line_items: priced_line_items,
|
|
41
45
|
metadata: metadata,
|
|
42
46
|
latency_ms: latency_ms,
|
|
43
47
|
context_tags: context_tags
|
|
44
48
|
)
|
|
45
49
|
|
|
46
|
-
|
|
50
|
+
if Ingestion.async?
|
|
51
|
+
Ingestion::Inbox.save(event)
|
|
52
|
+
Ingestion::Worker.ensure_started
|
|
53
|
+
else
|
|
54
|
+
Ledger::Store.insert(event, skip_existence_check: true)
|
|
55
|
+
end
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
yield if block_given?
|
|
58
|
+
notify_subscribers(event)
|
|
49
59
|
Budget.check!(event)
|
|
50
60
|
|
|
51
61
|
event
|
|
@@ -53,56 +63,44 @@ module LlmCostTracker
|
|
|
53
63
|
|
|
54
64
|
private
|
|
55
65
|
|
|
56
|
-
def
|
|
57
|
-
return
|
|
66
|
+
def notify_subscribers(event)
|
|
67
|
+
return unless ActiveSupport::Notifications.notifier.listening?(EVENT_NAME)
|
|
58
68
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
|
|
62
72
|
end
|
|
63
73
|
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
def build_event(event:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
|
|
75
|
+
metadata:, latency_ms:, context_tags:)
|
|
66
76
|
context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
|
|
67
|
-
|
|
68
|
-
provider: capture.provider,
|
|
69
|
-
model: capture.model,
|
|
70
|
-
line_items: capture.line_items,
|
|
71
|
-
pricing_mode: pricing_mode
|
|
72
|
-
)
|
|
73
|
-
cost = cost_with_service_lines(cost_data, line_items)
|
|
77
|
+
cost = Pricing.combine_with_service_lines(cost_data, line_items)
|
|
74
78
|
cost_status = Billing::CostStatus.call(
|
|
75
|
-
token_usage:
|
|
76
|
-
usage_source:
|
|
79
|
+
token_usage: event.token_usage,
|
|
80
|
+
usage_source: event.usage_source,
|
|
77
81
|
token_cost: cost_data,
|
|
78
|
-
token_pricing_partial: token_pricing_partial?(
|
|
82
|
+
token_pricing_partial: Pricing.token_pricing_partial?(event.token_usage, cost_data),
|
|
79
83
|
service_line_items: line_items.reject(&:token?),
|
|
80
84
|
total_cost: cost&.fetch(:total_cost, nil)
|
|
81
85
|
)
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
event.with(
|
|
84
88
|
event_id: SecureRandom.uuid,
|
|
85
|
-
provider: capture.provider,
|
|
86
|
-
model: capture.model,
|
|
87
|
-
token_usage: capture.token_usage,
|
|
88
89
|
pricing_mode: pricing_mode,
|
|
89
90
|
cost: cost,
|
|
90
|
-
tags:
|
|
91
|
+
tags: build_tags(context_tags: context_tags, metadata: metadata),
|
|
91
92
|
latency_ms: finite_latency_ms(latency_ms),
|
|
92
|
-
stream: capture.stream,
|
|
93
|
-
usage_source: capture.usage_source,
|
|
94
|
-
provider_response_id: capture.provider_response_id,
|
|
95
|
-
provider_project_id: capture.provider_project_id,
|
|
96
|
-
provider_api_key_id: capture.provider_api_key_id,
|
|
97
|
-
provider_workspace_id: capture.provider_workspace_id,
|
|
98
|
-
batch: capture.batch,
|
|
99
93
|
tracked_at: Time.now.utc,
|
|
100
94
|
cost_status: cost_status,
|
|
101
95
|
pricing_snapshot: pricing_snapshot,
|
|
102
96
|
line_items: line_items
|
|
103
97
|
)
|
|
104
98
|
end
|
|
105
|
-
|
|
99
|
+
|
|
100
|
+
def build_tags(context_tags:, metadata:)
|
|
101
|
+
sanitized_metadata = LlmCostTracker::Tags::Sanitizer.call(metadata.to_h)
|
|
102
|
+
LlmCostTracker::Tags::Sanitizer.cap(context_tags.merge(sanitized_metadata)).freeze
|
|
103
|
+
end
|
|
106
104
|
|
|
107
105
|
def finite_latency_ms(latency_ms)
|
|
108
106
|
return nil if latency_ms.nil?
|
|
@@ -111,18 +109,6 @@ module LlmCostTracker
|
|
|
111
109
|
rescue ArgumentError, TypeError, FloatDomainError
|
|
112
110
|
nil
|
|
113
111
|
end
|
|
114
|
-
|
|
115
|
-
def cost_with_service_lines(cost_data, line_items)
|
|
116
|
-
service_lines = line_items.reject(&:token?)
|
|
117
|
-
return cost_data if service_lines.empty?
|
|
118
|
-
return cost_data if service_lines.none?(&:priced?)
|
|
119
|
-
|
|
120
|
-
service_total = service_lines.sum(BigDecimal("0"), &:cost_value)
|
|
121
|
-
cost = cost_data ? cost_data.dup : {}
|
|
122
|
-
base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
|
|
123
|
-
cost[:total_cost] = (base_total + service_total).round(8).to_f
|
|
124
|
-
cost
|
|
125
|
-
end
|
|
126
112
|
end
|
|
127
113
|
end
|
|
128
114
|
end
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -16,21 +16,13 @@ require_relative "llm_cost_tracker/logging"
|
|
|
16
16
|
require_relative "llm_cost_tracker/tags/key"
|
|
17
17
|
require_relative "llm_cost_tracker/tags/context"
|
|
18
18
|
require_relative "llm_cost_tracker/tags/sanitizer"
|
|
19
|
+
require_relative "llm_cost_tracker/masking"
|
|
19
20
|
require_relative "llm_cost_tracker/token_usage"
|
|
20
21
|
require_relative "llm_cost_tracker/billing/components"
|
|
21
22
|
require_relative "llm_cost_tracker/billing/line_item"
|
|
22
23
|
require_relative "llm_cost_tracker/billing/cost_status"
|
|
23
24
|
require_relative "llm_cost_tracker/event"
|
|
24
25
|
require_relative "llm_cost_tracker/pricing"
|
|
25
|
-
require_relative "llm_cost_tracker/usage_capture"
|
|
26
|
-
require_relative "llm_cost_tracker/pricing/sync"
|
|
27
|
-
require_relative "llm_cost_tracker/parsers/base"
|
|
28
|
-
require_relative "llm_cost_tracker/parsers/openai_usage"
|
|
29
|
-
require_relative "llm_cost_tracker/parsers/openai"
|
|
30
|
-
require_relative "llm_cost_tracker/parsers/openai_compatible"
|
|
31
|
-
require_relative "llm_cost_tracker/parsers/anthropic"
|
|
32
|
-
require_relative "llm_cost_tracker/parsers/gemini"
|
|
33
|
-
require_relative "llm_cost_tracker/parsers/sse"
|
|
34
26
|
require_relative "llm_cost_tracker/parsers"
|
|
35
27
|
require_relative "llm_cost_tracker/middleware/faraday"
|
|
36
28
|
require_relative "llm_cost_tracker/integrations"
|
|
@@ -39,12 +31,19 @@ require_relative "llm_cost_tracker/pricing/unknown"
|
|
|
39
31
|
require_relative "llm_cost_tracker/ledger"
|
|
40
32
|
require_relative "llm_cost_tracker/ingestion"
|
|
41
33
|
require_relative "llm_cost_tracker/tracker"
|
|
42
|
-
require_relative "llm_cost_tracker/retention"
|
|
43
|
-
require_relative "llm_cost_tracker/report"
|
|
44
|
-
require_relative "llm_cost_tracker/doctor"
|
|
45
|
-
require_relative "llm_cost_tracker/doctor/capture_verifier"
|
|
46
34
|
|
|
47
35
|
module LlmCostTracker
|
|
36
|
+
autoload :Engine, "llm_cost_tracker/engine"
|
|
37
|
+
autoload :Reconciliation, "llm_cost_tracker/reconciliation"
|
|
38
|
+
autoload :ReconcileTasks, "llm_cost_tracker/reconcile_tasks"
|
|
39
|
+
autoload :Doctor, "llm_cost_tracker/doctor"
|
|
40
|
+
autoload :Report, "llm_cost_tracker/report"
|
|
41
|
+
autoload :Retention, "llm_cost_tracker/retention"
|
|
42
|
+
|
|
43
|
+
module Pricing
|
|
44
|
+
autoload :Sync, "llm_cost_tracker/pricing/sync"
|
|
45
|
+
end
|
|
46
|
+
|
|
48
47
|
@configuration = Configuration.new
|
|
49
48
|
|
|
50
49
|
class << self
|
|
@@ -54,6 +53,10 @@ module LlmCostTracker
|
|
|
54
53
|
"llm_cost_tracker_"
|
|
55
54
|
end
|
|
56
55
|
|
|
56
|
+
def reconciliation_enabled?
|
|
57
|
+
configuration.reconciliation_enabled
|
|
58
|
+
end
|
|
59
|
+
|
|
57
60
|
def configure
|
|
58
61
|
config = configuration
|
|
59
62
|
raise Error, "LlmCostTracker is already configured" if config.finalized?
|
|
@@ -69,6 +72,7 @@ module LlmCostTracker
|
|
|
69
72
|
|
|
70
73
|
def reset_configuration!
|
|
71
74
|
Ingestion::Worker.shutdown!(drain: false)
|
|
75
|
+
Ingestion::Pool.reset!
|
|
72
76
|
@configuration = Configuration.new
|
|
73
77
|
Pricing::Lookup.reset!
|
|
74
78
|
Pricing::Registry.reset!
|
|
@@ -76,6 +80,7 @@ module LlmCostTracker
|
|
|
76
80
|
Pricing::Unknown.reset!
|
|
77
81
|
Ingestion::Worker.reset!
|
|
78
82
|
Tags::Context.clear!
|
|
83
|
+
Dashboard::SetupState.reset!
|
|
79
84
|
end
|
|
80
85
|
|
|
81
86
|
def with_tags(tags = nil, **kwargs, &)
|
|
@@ -89,7 +94,7 @@ module LlmCostTracker
|
|
|
89
94
|
Tracker.enforce_budget! if enforce_budget
|
|
90
95
|
|
|
91
96
|
Tracker.record(
|
|
92
|
-
|
|
97
|
+
event: Event.build(
|
|
93
98
|
provider: provider,
|
|
94
99
|
model: model,
|
|
95
100
|
token_usage: TokenUsage.build_from_tokens(tokens),
|
|
@@ -4,14 +4,21 @@ require "fileutils"
|
|
|
4
4
|
require "rails/generators"
|
|
5
5
|
|
|
6
6
|
require_relative "../llm_cost_tracker/generators/llm_cost_tracker/install_generator"
|
|
7
|
-
require_relative "../llm_cost_tracker/pricing/
|
|
7
|
+
require_relative "../llm_cost_tracker/pricing/sync/change_printer"
|
|
8
8
|
|
|
9
9
|
# rubocop:disable Metrics/BlockLength
|
|
10
10
|
namespace :llm_cost_tracker do
|
|
11
11
|
desc "Install LLM Cost Tracker with dashboard and prices, migrate, and run doctor"
|
|
12
12
|
task :setup do
|
|
13
|
-
Rails::Generators.invoke("llm_cost_tracker:install", %w[--dashboard --prices])
|
|
14
|
-
|
|
13
|
+
Rails::Generators.invoke("llm_cost_tracker:install", %w[--dashboard --prices --skip])
|
|
14
|
+
begin
|
|
15
|
+
Rake::Task["db:migrate"].invoke
|
|
16
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished => e
|
|
17
|
+
abort(
|
|
18
|
+
"llm_cost_tracker: database is not reachable (#{e.class}). " \
|
|
19
|
+
"Start your database, run 'rails db:create db:migrate', then re-run 'rails llm_cost_tracker:setup'."
|
|
20
|
+
)
|
|
21
|
+
end
|
|
15
22
|
Rake::Task["llm_cost_tracker:doctor"].invoke
|
|
16
23
|
end
|
|
17
24
|
|
|
@@ -41,12 +48,26 @@ namespace :llm_cost_tracker do
|
|
|
41
48
|
puts LlmCostTracker::Report.generate(days: days)
|
|
42
49
|
end
|
|
43
50
|
|
|
51
|
+
desc "Recompute total_cost for calls with unknown pricing using the current price registry. " \
|
|
52
|
+
"Use BATCH_SIZE=N to tune."
|
|
53
|
+
task backfill_unknown_pricing: :environment do
|
|
54
|
+
require_relative "../llm_cost_tracker/pricing/backfill"
|
|
55
|
+
batch_size = (ENV["BATCH_SIZE"] || LlmCostTracker::Pricing::Backfill::DEFAULT_BATCH_SIZE).to_i
|
|
56
|
+
result = LlmCostTracker::Pricing::Backfill.call(batch_size: batch_size)
|
|
57
|
+
puts "llm_cost_tracker: examined #{result.examined} calls, recomputed #{result.recomputed}, " \
|
|
58
|
+
"still unknown #{result.still_unknown}"
|
|
59
|
+
end
|
|
60
|
+
|
|
44
61
|
desc "Delete llm_cost_tracker_calls older than DAYS (default: 90). Use BATCH_SIZE=N to tune."
|
|
45
62
|
task prune: :environment do
|
|
46
63
|
days = (ENV["DAYS"] || 90).to_i
|
|
47
64
|
batch_size = (ENV["BATCH_SIZE"] || LlmCostTracker::Retention::DEFAULT_BATCH_SIZE).to_i
|
|
48
65
|
deleted = LlmCostTracker::Retention.prune(older_than: days, batch_size: batch_size)
|
|
49
66
|
puts "llm_cost_tracker: pruned #{deleted} calls older than #{days} days"
|
|
67
|
+
inbox_pruned = LlmCostTracker::Retention.prune_inbox(older_than: days)
|
|
68
|
+
puts "llm_cost_tracker: pruned #{inbox_pruned} inbox entries older than #{days} days"
|
|
69
|
+
invoice_imports_pruned = LlmCostTracker::Retention.prune_invoice_imports(older_than: days)
|
|
70
|
+
puts "llm_cost_tracker: pruned #{invoice_imports_pruned} provider invoice imports older than #{days} days"
|
|
50
71
|
end
|
|
51
72
|
|
|
52
73
|
namespace :prices do
|
|
@@ -109,11 +130,27 @@ namespace :llm_cost_tracker do
|
|
|
109
130
|
abort("llm_cost_tracker: price is incomplete or unknown") unless explanation.complete?
|
|
110
131
|
end
|
|
111
132
|
end
|
|
133
|
+
|
|
134
|
+
namespace :reconcile do
|
|
135
|
+
desc "Import provider invoice rows from a JSON INPUT file. " \
|
|
136
|
+
"Use SOURCE=openai INPUT=path/to/file.json. Pass PROVIDER=openai for unmapped sources (csv, ...)."
|
|
137
|
+
task(:import) { reconcile_run(:run_import) }
|
|
138
|
+
|
|
139
|
+
desc "Print a reconciliation diff. " \
|
|
140
|
+
"Use SOURCE=openai PERIOD_START=YYYY-MM-DD PERIOD_END=YYYY-MM-DD. PROVIDER=openai for unmapped sources."
|
|
141
|
+
task(:diff) { reconcile_run(:run_diff) }
|
|
142
|
+
end
|
|
112
143
|
end
|
|
113
144
|
# rubocop:enable Metrics/BlockLength
|
|
114
145
|
|
|
146
|
+
def reconcile_run(method)
|
|
147
|
+
Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
|
|
148
|
+
require_relative "../llm_cost_tracker"
|
|
149
|
+
LlmCostTracker::ReconcileTasks.public_send(method, env: ENV)
|
|
150
|
+
end
|
|
151
|
+
|
|
115
152
|
def print_changes(changes)
|
|
116
|
-
LlmCostTracker::Pricing::
|
|
153
|
+
LlmCostTracker::Pricing::Sync::ChangePrinter.call(changes)
|
|
117
154
|
end
|
|
118
155
|
|
|
119
156
|
def price_refresh_output_path
|