llm_cost_tracker 0.8.0 → 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/CHANGELOG.md +108 -0
- data/README.md +12 -5
- 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 +5 -7
- 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 +10 -0
- 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 +24 -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/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
- 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/line_item.rb +1 -1
- data/lib/llm_cost_tracker/budget.rb +4 -2
- data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +53 -1
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -3
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/event.rb +1 -1
- 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 +13 -3
- 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 +5 -58
- 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/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/ingestion/inbox.rb +0 -1
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
- data/lib/llm_cost_tracker/ingestion.rb +48 -10
- data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
- data/lib/llm_cost_tracker/integrations/base.rb +22 -5
- data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
- data/lib/llm_cost_tracker/integrations.rb +19 -1
- data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
- 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 +2 -2
- data/lib/llm_cost_tracker/ledger/store.rb +14 -14
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
- data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
- data/lib/llm_cost_tracker/parsers/base.rb +5 -1
- data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
- data/lib/llm_cost_tracker/prices.json +110 -19
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
- data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
- data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
- data/lib/llm_cost_tracker/pricing.rb +47 -19
- data/lib/llm_cost_tracker/railtie.rb +6 -0
- 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 +4 -1
- data/lib/llm_cost_tracker/retention.rb +15 -2
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
- data/lib/llm_cost_tracker/token_usage.rb +10 -2
- data/lib/llm_cost_tracker/tracker.rb +45 -18
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +9 -0
- data/lib/tasks/llm_cost_tracker.rake +25 -2
- metadata +36 -1
|
@@ -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
|
|
@@ -21,6 +21,17 @@ 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
|
+
|
|
24
35
|
private
|
|
25
36
|
|
|
26
37
|
def resolve_cutoff(older_than, now)
|
|
@@ -54,14 +65,16 @@ module LlmCostTracker
|
|
|
54
65
|
next 0 if rows.empty?
|
|
55
66
|
|
|
56
67
|
deleted = LlmCostTracker::Call.where(id: rows.map(&:first)).delete_all
|
|
57
|
-
|
|
68
|
+
if deleted.positive? && LlmCostTracker.configuration.cache_rollups
|
|
69
|
+
LlmCostTracker::Ledger::Rollups.decrement!(rows)
|
|
70
|
+
end
|
|
58
71
|
deleted
|
|
59
72
|
end
|
|
60
73
|
end
|
|
61
74
|
|
|
62
75
|
def pluck_prunable(cutoff, batch_size)
|
|
63
76
|
LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
|
|
64
|
-
.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot)
|
|
77
|
+
.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
|
|
65
78
|
end
|
|
66
79
|
end
|
|
67
80
|
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!
|
|
@@ -8,12 +8,26 @@ module LlmCostTracker
|
|
|
8
8
|
module Sanitizer
|
|
9
9
|
REDACTED_VALUE = "[REDACTED]"
|
|
10
10
|
|
|
11
|
+
SECRET_VALUE_PATTERNS = [
|
|
12
|
+
/\Ask-(?:ant-|admin-|proj-|svcacct-|live-|test-)?[A-Za-z0-9_-]{16,}\z/,
|
|
13
|
+
/\AAKIA[0-9A-Z]{16}\z/,
|
|
14
|
+
/\Agh[opsur]_[A-Za-z0-9]{16,}\z/,
|
|
15
|
+
/\Agithub_pat_[A-Za-z0-9_]{20,}\z/,
|
|
16
|
+
/\Aeyj[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\z/i,
|
|
17
|
+
/\Abearer\s+[A-Za-z0-9_.-]{20,}\z/i,
|
|
18
|
+
/\Axox[abprs]-[A-Za-z0-9-]{10,}\z/,
|
|
19
|
+
/\A(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}\z/,
|
|
20
|
+
/\AAIza[0-9A-Za-z_-]{35}\z/
|
|
21
|
+
].freeze
|
|
22
|
+
private_constant :SECRET_VALUE_PATTERNS
|
|
23
|
+
|
|
11
24
|
class << self
|
|
12
25
|
def call(tags, config: LlmCostTracker.configuration)
|
|
13
26
|
tags = (tags || {}).to_h
|
|
14
27
|
redacted = Array(config.redacted_tag_keys).map { |key| normalized_key(key) }
|
|
15
28
|
limit = [config.max_tag_value_bytesize.to_i, 0].max
|
|
16
|
-
|
|
29
|
+
max_count = [config.max_tag_count.to_i, 0].max
|
|
30
|
+
tags.to_a.last(max_count).each_with_object({}) do |(key, value), sanitized|
|
|
17
31
|
sanitized[key] = sanitized_value(key, value, redacted, limit)
|
|
18
32
|
end
|
|
19
33
|
end
|
|
@@ -23,10 +37,52 @@ module LlmCostTracker
|
|
|
23
37
|
def sanitized_value(key, value, redacted, limit)
|
|
24
38
|
return REDACTED_VALUE if redacted_key?(key, redacted)
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
return
|
|
40
|
+
scrubbed = scrub_secrets(value)
|
|
41
|
+
return REDACTED_VALUE if scrubbed.equal?(REDACTED_SENTINEL)
|
|
42
|
+
|
|
43
|
+
scalar_truncate(scrubbed, limit)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
REDACTED_SENTINEL = Object.new.freeze
|
|
47
|
+
private_constant :REDACTED_SENTINEL
|
|
48
|
+
|
|
49
|
+
def scalar_truncate(value, limit)
|
|
50
|
+
case value
|
|
51
|
+
when Hash
|
|
52
|
+
value.transform_values { |nested| scalar_truncate(nested, limit) }
|
|
53
|
+
when Array
|
|
54
|
+
value.map { |nested| scalar_truncate(nested, limit) }
|
|
55
|
+
else
|
|
56
|
+
return value if value == REDACTED_VALUE
|
|
57
|
+
|
|
58
|
+
string = value.to_s
|
|
59
|
+
return value if string.bytesize <= limit
|
|
60
|
+
|
|
61
|
+
string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def scrub_secrets(value)
|
|
66
|
+
case value
|
|
67
|
+
when Hash
|
|
68
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
69
|
+
scrubbed = scrub_secrets(nested)
|
|
70
|
+
out[key] = scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
|
|
71
|
+
end
|
|
72
|
+
when Array
|
|
73
|
+
value.map do |nested|
|
|
74
|
+
scrubbed = scrub_secrets(nested)
|
|
75
|
+
scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
|
|
76
|
+
end
|
|
77
|
+
else
|
|
78
|
+
secret_shaped?(value.to_s) ? REDACTED_SENTINEL : value
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def secret_shaped?(string)
|
|
83
|
+
return false if string.bytesize < 16
|
|
28
84
|
|
|
29
|
-
|
|
85
|
+
SECRET_VALUE_PATTERNS.any? { |pattern| pattern.match?(string) }
|
|
30
86
|
end
|
|
31
87
|
|
|
32
88
|
def redacted_key?(key, redacted)
|
|
@@ -14,8 +14,10 @@ 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
|
|
@@ -53,6 +55,7 @@ module LlmCostTracker
|
|
|
53
55
|
def self.build(input_tokens:, output_tokens:, cache_read_input_tokens: 0,
|
|
54
56
|
cache_write_input_tokens: 0, cache_write_extended_input_tokens: 0,
|
|
55
57
|
audio_input_tokens: 0, audio_output_tokens: 0,
|
|
58
|
+
image_input_tokens: 0, image_output_tokens: 0,
|
|
56
59
|
total_tokens: nil, hidden_output_tokens: 0)
|
|
57
60
|
input = non_negative_int(input_tokens)
|
|
58
61
|
output = non_negative_int(output_tokens)
|
|
@@ -61,9 +64,12 @@ module LlmCostTracker
|
|
|
61
64
|
cache_write_extended = non_negative_int(cache_write_extended_input_tokens)
|
|
62
65
|
audio_input = non_negative_int(audio_input_tokens)
|
|
63
66
|
audio_output = non_negative_int(audio_output_tokens)
|
|
67
|
+
image_input = non_negative_int(image_input_tokens)
|
|
68
|
+
image_output = non_negative_int(image_output_tokens)
|
|
64
69
|
hidden_output = non_negative_int(hidden_output_tokens)
|
|
65
|
-
calculated_total = input + cache_read + cache_write + cache_write_extended +
|
|
66
|
-
|
|
70
|
+
calculated_total = input + cache_read + cache_write + cache_write_extended +
|
|
71
|
+
audio_input + image_input + output + audio_output + image_output
|
|
72
|
+
total = total_tokens ? [non_negative_int(total_tokens), calculated_total].max : calculated_total
|
|
67
73
|
|
|
68
74
|
new(
|
|
69
75
|
input_tokens: input,
|
|
@@ -71,8 +77,10 @@ module LlmCostTracker
|
|
|
71
77
|
cache_write_input_tokens: cache_write,
|
|
72
78
|
cache_write_extended_input_tokens: cache_write_extended,
|
|
73
79
|
audio_input_tokens: audio_input,
|
|
80
|
+
image_input_tokens: image_input,
|
|
74
81
|
output_tokens: output,
|
|
75
82
|
audio_output_tokens: audio_output,
|
|
83
|
+
image_output_tokens: image_output,
|
|
76
84
|
total_tokens: total,
|
|
77
85
|
hidden_output_tokens: hidden_output
|
|
78
86
|
)
|
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
|
|
7
7
|
require_relative "ingestion"
|
|
8
8
|
require_relative "ledger"
|
|
9
|
+
require_relative "logging"
|
|
9
10
|
require_relative "pricing"
|
|
10
11
|
require_relative "billing/cost_status"
|
|
11
12
|
|
|
@@ -24,10 +25,11 @@ module LlmCostTracker
|
|
|
24
25
|
return unless LlmCostTracker.configuration.enabled
|
|
25
26
|
|
|
26
27
|
pricing_mode = Pricing.normalize_mode(pricing_mode) || capture.pricing_mode
|
|
27
|
-
cost_data, pricing_snapshot = Pricing.
|
|
28
|
+
cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
|
|
28
29
|
provider: capture.provider,
|
|
29
30
|
model: capture.model,
|
|
30
31
|
tokens: capture.token_usage,
|
|
32
|
+
line_items: capture.line_items,
|
|
31
33
|
pricing_mode: pricing_mode
|
|
32
34
|
)
|
|
33
35
|
|
|
@@ -38,19 +40,36 @@ module LlmCostTracker
|
|
|
38
40
|
pricing_mode: pricing_mode,
|
|
39
41
|
cost_data: cost_data,
|
|
40
42
|
pricing_snapshot: pricing_snapshot,
|
|
43
|
+
line_items: priced_line_items,
|
|
41
44
|
metadata: metadata,
|
|
42
45
|
latency_ms: latency_ms,
|
|
43
46
|
context_tags: context_tags
|
|
44
47
|
)
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
save_event(event)
|
|
50
|
+
yield :after_save if block_given?
|
|
51
|
+
notify_subscribers(event)
|
|
49
52
|
Budget.check!(event)
|
|
50
53
|
|
|
51
54
|
event
|
|
52
55
|
end
|
|
53
56
|
|
|
57
|
+
def save_event(event)
|
|
58
|
+
if LlmCostTracker.configuration.durable_ingestion
|
|
59
|
+
Ingestion::Inbox.save(event)
|
|
60
|
+
else
|
|
61
|
+
Ingestion::Inline.save(event)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def notify_subscribers(event)
|
|
66
|
+
return unless ActiveSupport::Notifications.notifier.listening?(EVENT_NAME)
|
|
67
|
+
|
|
68
|
+
ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
|
|
71
|
+
end
|
|
72
|
+
|
|
54
73
|
private
|
|
55
74
|
|
|
56
75
|
def token_pricing_partial?(token_usage:, cost_data:)
|
|
@@ -61,15 +80,9 @@ module LlmCostTracker
|
|
|
61
80
|
end
|
|
62
81
|
end
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
def build_event(capture:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
|
|
84
|
+
metadata:, latency_ms:, context_tags:)
|
|
66
85
|
context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
|
|
67
|
-
line_items, = Pricing.price_line_items(
|
|
68
|
-
provider: capture.provider,
|
|
69
|
-
model: capture.model,
|
|
70
|
-
line_items: capture.line_items,
|
|
71
|
-
pricing_mode: pricing_mode
|
|
72
|
-
)
|
|
73
86
|
cost = cost_with_service_lines(cost_data, line_items)
|
|
74
87
|
cost_status = Billing::CostStatus.call(
|
|
75
88
|
token_usage: capture.token_usage,
|
|
@@ -102,7 +115,6 @@ module LlmCostTracker
|
|
|
102
115
|
line_items: line_items
|
|
103
116
|
)
|
|
104
117
|
end
|
|
105
|
-
# rubocop:enable Metrics/MethodLength
|
|
106
118
|
|
|
107
119
|
def finite_latency_ms(latency_ms)
|
|
108
120
|
return nil if latency_ms.nil?
|
|
@@ -113,16 +125,31 @@ module LlmCostTracker
|
|
|
113
125
|
end
|
|
114
126
|
|
|
115
127
|
def cost_with_service_lines(cost_data, line_items)
|
|
116
|
-
|
|
117
|
-
return cost_data if
|
|
118
|
-
|
|
128
|
+
priced_services = line_items.reject(&:token?).select(&:priced?)
|
|
129
|
+
return cost_data if priced_services.empty?
|
|
130
|
+
|
|
131
|
+
base_currency = (cost_data && cost_data[:currency]) || Billing::LineItem::USD
|
|
132
|
+
matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
|
|
133
|
+
warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
|
|
119
134
|
|
|
120
|
-
service_total = service_lines.sum(BigDecimal("0"), &:cost_value)
|
|
121
135
|
cost = cost_data ? cost_data.dup : {}
|
|
136
|
+
cost[:currency] ||= base_currency.to_s
|
|
137
|
+
return cost if matching.empty?
|
|
138
|
+
|
|
139
|
+
service_total = matching.sum(BigDecimal("0"), &:cost_value)
|
|
122
140
|
base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
|
|
123
|
-
cost[:total_cost] = (base_total + service_total).round(8)
|
|
141
|
+
cost[:total_cost] = (base_total + service_total).round(8)
|
|
124
142
|
cost
|
|
125
143
|
end
|
|
144
|
+
|
|
145
|
+
def warn_currency_mismatch(lines, base_currency)
|
|
146
|
+
currencies = lines.map { |line| line.currency.to_s }.uniq.sort
|
|
147
|
+
Logging.warn(
|
|
148
|
+
"Service line currency mismatch: header is #{base_currency}, dropping " \
|
|
149
|
+
"#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
|
|
150
|
+
"Per-line costs are still recorded; header total reflects #{base_currency} only."
|
|
151
|
+
)
|
|
152
|
+
end
|
|
126
153
|
end
|
|
127
154
|
end
|
|
128
155
|
end
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -16,6 +16,7 @@ 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"
|
|
@@ -45,6 +46,9 @@ require_relative "llm_cost_tracker/doctor"
|
|
|
45
46
|
require_relative "llm_cost_tracker/doctor/capture_verifier"
|
|
46
47
|
|
|
47
48
|
module LlmCostTracker
|
|
49
|
+
autoload :Reconciliation, "llm_cost_tracker/reconciliation"
|
|
50
|
+
autoload :ReconcileTasks, "llm_cost_tracker/reconcile_tasks"
|
|
51
|
+
|
|
48
52
|
@configuration = Configuration.new
|
|
49
53
|
|
|
50
54
|
class << self
|
|
@@ -54,6 +58,10 @@ module LlmCostTracker
|
|
|
54
58
|
"llm_cost_tracker_"
|
|
55
59
|
end
|
|
56
60
|
|
|
61
|
+
def reconciliation_enabled?
|
|
62
|
+
configuration.reconciliation_enabled
|
|
63
|
+
end
|
|
64
|
+
|
|
57
65
|
def configure
|
|
58
66
|
config = configuration
|
|
59
67
|
raise Error, "LlmCostTracker is already configured" if config.finalized?
|
|
@@ -76,6 +84,7 @@ module LlmCostTracker
|
|
|
76
84
|
Pricing::Unknown.reset!
|
|
77
85
|
Ingestion::Worker.reset!
|
|
78
86
|
Tags::Context.clear!
|
|
87
|
+
DashboardSetupState.reset! if defined?(DashboardSetupState)
|
|
79
88
|
end
|
|
80
89
|
|
|
81
90
|
def with_tags(tags = nil, **kwargs, &)
|
|
@@ -10,8 +10,15 @@ require_relative "../llm_cost_tracker/pricing/sync_change_printer"
|
|
|
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
|
|
|
@@ -109,9 +116,25 @@ namespace :llm_cost_tracker do
|
|
|
109
116
|
abort("llm_cost_tracker: price is incomplete or unknown") unless explanation.complete?
|
|
110
117
|
end
|
|
111
118
|
end
|
|
119
|
+
|
|
120
|
+
namespace :reconcile do
|
|
121
|
+
desc "Import provider invoice rows from a JSON INPUT file. " \
|
|
122
|
+
"Use SOURCE=openai INPUT=path/to/file.json. Pass PROVIDER=openai for unmapped sources (csv, ...)."
|
|
123
|
+
task(:import) { reconcile_run(:run_import) }
|
|
124
|
+
|
|
125
|
+
desc "Print a reconciliation diff. " \
|
|
126
|
+
"Use SOURCE=openai PERIOD_START=YYYY-MM-DD PERIOD_END=YYYY-MM-DD. PROVIDER=openai for unmapped sources."
|
|
127
|
+
task(:diff) { reconcile_run(:run_diff) }
|
|
128
|
+
end
|
|
112
129
|
end
|
|
113
130
|
# rubocop:enable Metrics/BlockLength
|
|
114
131
|
|
|
132
|
+
def reconcile_run(method)
|
|
133
|
+
Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
|
|
134
|
+
require_relative "../llm_cost_tracker"
|
|
135
|
+
LlmCostTracker::ReconcileTasks.public_send(method, env: ENV)
|
|
136
|
+
end
|
|
137
|
+
|
|
115
138
|
def print_changes(changes)
|
|
116
139
|
LlmCostTracker::Pricing::SyncChangePrinter.call(changes)
|
|
117
140
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llm_cost_tracker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergii Khomenko
|
|
@@ -239,13 +239,16 @@ files:
|
|
|
239
239
|
- app/controllers/llm_cost_tracker/dashboard_controller.rb
|
|
240
240
|
- app/controllers/llm_cost_tracker/data_quality_controller.rb
|
|
241
241
|
- app/controllers/llm_cost_tracker/models_controller.rb
|
|
242
|
+
- app/controllers/llm_cost_tracker/reconciliation_controller.rb
|
|
242
243
|
- app/controllers/llm_cost_tracker/tags_controller.rb
|
|
243
244
|
- app/helpers/llm_cost_tracker/application_helper.rb
|
|
244
245
|
- app/helpers/llm_cost_tracker/chart_helper.rb
|
|
245
246
|
- app/helpers/llm_cost_tracker/dashboard_filter_helper.rb
|
|
246
247
|
- app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb
|
|
247
248
|
- app/helpers/llm_cost_tracker/dashboard_query_helper.rb
|
|
249
|
+
- app/helpers/llm_cost_tracker/inline_style_helper.rb
|
|
248
250
|
- app/helpers/llm_cost_tracker/pagination_helper.rb
|
|
251
|
+
- app/helpers/llm_cost_tracker/reconciliation_helper.rb
|
|
249
252
|
- app/helpers/llm_cost_tracker/token_usage_helper.rb
|
|
250
253
|
- app/models/llm_cost_tracker/call.rb
|
|
251
254
|
- app/models/llm_cost_tracker/call_line_item.rb
|
|
@@ -254,6 +257,7 @@ files:
|
|
|
254
257
|
- app/models/llm_cost_tracker/ingestion/inbox_entry.rb
|
|
255
258
|
- app/models/llm_cost_tracker/ingestion/lease.rb
|
|
256
259
|
- app/models/llm_cost_tracker/provider_invoice.rb
|
|
260
|
+
- app/models/llm_cost_tracker/provider_invoice_import.rb
|
|
257
261
|
- app/services/llm_cost_tracker/dashboard/data_quality.rb
|
|
258
262
|
- app/services/llm_cost_tracker/dashboard/date_range.rb
|
|
259
263
|
- app/services/llm_cost_tracker/dashboard/filter.rb
|
|
@@ -275,6 +279,7 @@ files:
|
|
|
275
279
|
- app/views/llm_cost_tracker/errors/invalid_filter.html.erb
|
|
276
280
|
- app/views/llm_cost_tracker/errors/not_found.html.erb
|
|
277
281
|
- app/views/llm_cost_tracker/models/index.html.erb
|
|
282
|
+
- app/views/llm_cost_tracker/reconciliation/index.html.erb
|
|
278
283
|
- app/views/llm_cost_tracker/shared/_active_filters.html.erb
|
|
279
284
|
- app/views/llm_cost_tracker/shared/_bar.html.erb
|
|
280
285
|
- app/views/llm_cost_tracker/shared/_filters.html.erb
|
|
@@ -297,11 +302,13 @@ files:
|
|
|
297
302
|
- lib/llm_cost_tracker/capture/stream_collector.rb
|
|
298
303
|
- lib/llm_cost_tracker/capture/stream_tracker.rb
|
|
299
304
|
- lib/llm_cost_tracker/configuration.rb
|
|
305
|
+
- lib/llm_cost_tracker/dashboard_setup_state.rb
|
|
300
306
|
- lib/llm_cost_tracker/doctor.rb
|
|
301
307
|
- lib/llm_cost_tracker/doctor/capture_verifier.rb
|
|
302
308
|
- lib/llm_cost_tracker/doctor/check.rb
|
|
303
309
|
- lib/llm_cost_tracker/doctor/cost_drift_check.rb
|
|
304
310
|
- lib/llm_cost_tracker/doctor/ingestion_check.rb
|
|
311
|
+
- lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb
|
|
305
312
|
- lib/llm_cost_tracker/doctor/legacy_audit_check.rb
|
|
306
313
|
- lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb
|
|
307
314
|
- lib/llm_cost_tracker/doctor/price_check.rb
|
|
@@ -311,13 +318,26 @@ files:
|
|
|
311
318
|
- lib/llm_cost_tracker/engine.rb
|
|
312
319
|
- lib/llm_cost_tracker/errors.rb
|
|
313
320
|
- lib/llm_cost_tracker/event.rb
|
|
321
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb
|
|
322
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb
|
|
314
323
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
|
|
315
324
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb
|
|
325
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb
|
|
326
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb
|
|
316
327
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb
|
|
328
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb
|
|
329
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb
|
|
317
330
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb
|
|
331
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb
|
|
332
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb
|
|
333
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb
|
|
334
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb
|
|
335
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb
|
|
336
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb
|
|
318
337
|
- lib/llm_cost_tracker/ingestion.rb
|
|
319
338
|
- lib/llm_cost_tracker/ingestion/batch.rb
|
|
320
339
|
- lib/llm_cost_tracker/ingestion/inbox.rb
|
|
340
|
+
- lib/llm_cost_tracker/ingestion/inline.rb
|
|
321
341
|
- lib/llm_cost_tracker/ingestion/lease_claim.rb
|
|
322
342
|
- lib/llm_cost_tracker/ingestion/worker.rb
|
|
323
343
|
- lib/llm_cost_tracker/integrations.rb
|
|
@@ -335,11 +355,16 @@ files:
|
|
|
335
355
|
- lib/llm_cost_tracker/ledger/schema/call_rollups.rb
|
|
336
356
|
- lib/llm_cost_tracker/ledger/schema/call_tags.rb
|
|
337
357
|
- lib/llm_cost_tracker/ledger/schema/calls.rb
|
|
358
|
+
- lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb
|
|
359
|
+
- lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb
|
|
360
|
+
- lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb
|
|
338
361
|
- lib/llm_cost_tracker/ledger/schema/provider_invoices.rb
|
|
339
362
|
- lib/llm_cost_tracker/ledger/store.rb
|
|
363
|
+
- lib/llm_cost_tracker/ledger/tags/encoding.rb
|
|
340
364
|
- lib/llm_cost_tracker/ledger/tags/query.rb
|
|
341
365
|
- lib/llm_cost_tracker/ledger/tags/sql.rb
|
|
342
366
|
- lib/llm_cost_tracker/logging.rb
|
|
367
|
+
- lib/llm_cost_tracker/masking.rb
|
|
343
368
|
- lib/llm_cost_tracker/middleware/faraday.rb
|
|
344
369
|
- lib/llm_cost_tracker/parsers.rb
|
|
345
370
|
- lib/llm_cost_tracker/parsers/anthropic.rb
|
|
@@ -355,6 +380,7 @@ files:
|
|
|
355
380
|
- lib/llm_cost_tracker/pricing/effective_prices.rb
|
|
356
381
|
- lib/llm_cost_tracker/pricing/explainer.rb
|
|
357
382
|
- lib/llm_cost_tracker/pricing/lookup.rb
|
|
383
|
+
- lib/llm_cost_tracker/pricing/mode.rb
|
|
358
384
|
- lib/llm_cost_tracker/pricing/registry.rb
|
|
359
385
|
- lib/llm_cost_tracker/pricing/service_charges.rb
|
|
360
386
|
- lib/llm_cost_tracker/pricing/sync.rb
|
|
@@ -364,6 +390,15 @@ files:
|
|
|
364
390
|
- lib/llm_cost_tracker/pricing/sync_change_printer.rb
|
|
365
391
|
- lib/llm_cost_tracker/pricing/unknown.rb
|
|
366
392
|
- lib/llm_cost_tracker/railtie.rb
|
|
393
|
+
- lib/llm_cost_tracker/reconcile_tasks.rb
|
|
394
|
+
- lib/llm_cost_tracker/reconciliation.rb
|
|
395
|
+
- lib/llm_cost_tracker/reconciliation/diff.rb
|
|
396
|
+
- lib/llm_cost_tracker/reconciliation/diff_result.rb
|
|
397
|
+
- lib/llm_cost_tracker/reconciliation/import_result.rb
|
|
398
|
+
- lib/llm_cost_tracker/reconciliation/importer.rb
|
|
399
|
+
- lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb
|
|
400
|
+
- lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb
|
|
401
|
+
- lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb
|
|
367
402
|
- lib/llm_cost_tracker/report.rb
|
|
368
403
|
- lib/llm_cost_tracker/report/data.rb
|
|
369
404
|
- lib/llm_cost_tracker/report/formatter.rb
|