llm_cost_tracker 0.5.2 → 0.5.3
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 +16 -0
- data/README.md +8 -3
- data/docs/architecture.md +28 -0
- data/docs/budgets.md +45 -0
- data/docs/configuration.md +65 -0
- data/docs/cookbook.md +185 -0
- data/docs/dashboard-overview.png +0 -0
- data/docs/dashboard.md +38 -0
- data/docs/extending.md +32 -0
- data/docs/operations.md +44 -0
- data/docs/pricing.md +94 -0
- data/docs/querying.md +36 -0
- data/docs/streaming.md +70 -0
- data/docs/technical/README.md +10 -0
- data/docs/technical/data-flow.md +67 -0
- data/docs/technical/extension-points.md +111 -0
- data/docs/technical/module-map.md +197 -0
- data/docs/technical/operational-notes.md +77 -0
- data/docs/upgrading.md +46 -0
- data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
- data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
- data/lib/llm_cost_tracker/configuration.rb +2 -1
- data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
- data/lib/llm_cost_tracker/doctor.rb +6 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
- data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
- data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
- data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
- data/lib/llm_cost_tracker/pricing/lookup.rb +110 -0
- data/lib/llm_cost_tracker/pricing.rb +25 -108
- data/lib/llm_cost_tracker/retention.rb +3 -9
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +115 -0
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +42 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +26 -0
- data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
- data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
- data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
- data/lib/llm_cost_tracker/storage/registry.rb +63 -0
- data/lib/llm_cost_tracker/tag_sql.rb +34 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +3 -0
- data/lib/tasks/llm_cost_tracker.rake +49 -0
- metadata +32 -2
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Pricing
|
|
5
|
+
EffectivePriceSet = Data.define(:input, :cache_read_input, :cache_write_input, :output) do
|
|
6
|
+
def to_h
|
|
7
|
+
{
|
|
8
|
+
input: input,
|
|
9
|
+
cache_read_input: cache_read_input,
|
|
10
|
+
cache_write_input: cache_write_input,
|
|
11
|
+
output: output
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def complete?
|
|
16
|
+
missing_keys.empty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def missing_keys
|
|
20
|
+
to_h.filter_map { |key, value| key if value.nil? }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module EffectivePrices
|
|
25
|
+
class << self
|
|
26
|
+
def call(usage:, prices:, pricing_mode:)
|
|
27
|
+
EffectivePriceSet.new(
|
|
28
|
+
input: price_for_usage(usage.input_tokens, prices, :input, pricing_mode),
|
|
29
|
+
cache_read_input: price_for_cache_usage(
|
|
30
|
+
usage.cache_read_input_tokens,
|
|
31
|
+
prices,
|
|
32
|
+
:cache_read_input,
|
|
33
|
+
pricing_mode
|
|
34
|
+
),
|
|
35
|
+
cache_write_input: price_for_cache_usage(
|
|
36
|
+
usage.cache_write_input_tokens,
|
|
37
|
+
prices,
|
|
38
|
+
:cache_write_input,
|
|
39
|
+
pricing_mode
|
|
40
|
+
),
|
|
41
|
+
output: price_for_usage(usage.output_tokens, prices, :output, pricing_mode)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def price_for_cache_usage(tokens, prices, key, pricing_mode)
|
|
48
|
+
return 0.0 unless tokens.positive?
|
|
49
|
+
|
|
50
|
+
price_for(prices, key, pricing_mode) || price_for(prices, :input, pricing_mode)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def price_for_usage(tokens, prices, key, pricing_mode)
|
|
54
|
+
tokens.positive? ? price_for(prices, key, pricing_mode) : 0.0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def price_for(prices, key, pricing_mode)
|
|
58
|
+
mode = normalized_pricing_mode(pricing_mode)
|
|
59
|
+
return prices[key] unless mode
|
|
60
|
+
|
|
61
|
+
prices[:"#{mode}_#{key}"] || prices[key]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalized_pricing_mode(value)
|
|
65
|
+
return nil if value.nil?
|
|
66
|
+
|
|
67
|
+
mode = value.to_s.strip
|
|
68
|
+
return nil if mode.empty? || mode == "standard"
|
|
69
|
+
|
|
70
|
+
mode
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effective_prices"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Pricing
|
|
7
|
+
Explanation = Data.define(
|
|
8
|
+
:provider,
|
|
9
|
+
:model,
|
|
10
|
+
:pricing_mode,
|
|
11
|
+
:source,
|
|
12
|
+
:matched_key,
|
|
13
|
+
:matched_by,
|
|
14
|
+
:prices,
|
|
15
|
+
:effective_prices,
|
|
16
|
+
:missing_price_keys
|
|
17
|
+
) do
|
|
18
|
+
def matched? = !prices.nil?
|
|
19
|
+
|
|
20
|
+
def complete? = matched? && missing_price_keys.empty?
|
|
21
|
+
|
|
22
|
+
def message
|
|
23
|
+
return "No price entry matched #{provider}/#{model}" unless matched?
|
|
24
|
+
return "Matched #{matched_key} from #{source} via #{matched_by}" if complete?
|
|
25
|
+
|
|
26
|
+
"Matched #{matched_key} from #{source} via #{matched_by}, but missing #{missing_price_keys.join(', ')}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module Explainer
|
|
31
|
+
class << self
|
|
32
|
+
def call(provider:, model:, input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0,
|
|
33
|
+
cache_write_input_tokens: 0, pricing_mode: nil)
|
|
34
|
+
match = Lookup.call(provider: provider, model: model)
|
|
35
|
+
usage = match && UsageBreakdown.build(
|
|
36
|
+
input_tokens: input_tokens,
|
|
37
|
+
output_tokens: output_tokens,
|
|
38
|
+
cache_read_input_tokens: cache_read_input_tokens,
|
|
39
|
+
cache_write_input_tokens: cache_write_input_tokens
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
explanation(provider, model, pricing_mode, match, usage)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def explanation(provider, model, pricing_mode, match, usage)
|
|
48
|
+
prices = match&.prices
|
|
49
|
+
effective = if prices && usage
|
|
50
|
+
EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Explanation.new(
|
|
54
|
+
provider.to_s,
|
|
55
|
+
model.to_s,
|
|
56
|
+
normalized_pricing_mode(pricing_mode),
|
|
57
|
+
match&.source,
|
|
58
|
+
match&.key,
|
|
59
|
+
match&.matched_by,
|
|
60
|
+
prices,
|
|
61
|
+
effective ? effective.to_h : {},
|
|
62
|
+
effective ? effective.missing_keys : []
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def normalized_pricing_mode(value)
|
|
67
|
+
return nil if value.nil?
|
|
68
|
+
|
|
69
|
+
mode = value.to_s.strip
|
|
70
|
+
return nil if mode.empty? || mode == "standard"
|
|
71
|
+
|
|
72
|
+
mode
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Pricing
|
|
7
|
+
module Lookup
|
|
8
|
+
Match = Data.define(:source, :key, :prices, :matched_by)
|
|
9
|
+
MUTEX = Monitor.new
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def call(provider:, model:)
|
|
13
|
+
provider_name = provider.to_s
|
|
14
|
+
model_name = model.to_s
|
|
15
|
+
provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
|
|
16
|
+
normalized_model = normalize_model_name(model_name)
|
|
17
|
+
current = current_price_tables
|
|
18
|
+
|
|
19
|
+
explain_table(current.fetch(:pricing_overrides), :pricing_overrides, provider_model, model_name,
|
|
20
|
+
normalized_model) ||
|
|
21
|
+
explain_table(current.fetch(:file_prices), :prices_file, provider_model, model_name, normalized_model) ||
|
|
22
|
+
explain_table(Pricing::PRICES, :bundled, provider_model, model_name, normalized_model)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def current_price_tables
|
|
28
|
+
file_prices = PriceRegistry.file_prices(LlmCostTracker.configuration.prices_file)
|
|
29
|
+
overrides = PriceRegistry.normalize_price_table(LlmCostTracker.configuration.pricing_overrides)
|
|
30
|
+
cache_key = [file_prices.object_id, LlmCostTracker.configuration.pricing_overrides.hash]
|
|
31
|
+
|
|
32
|
+
cached = @prices_cache
|
|
33
|
+
return cached[:value] if cached && cached[:key] == cache_key
|
|
34
|
+
|
|
35
|
+
MUTEX.synchronize do
|
|
36
|
+
cached = @prices_cache
|
|
37
|
+
return cached[:value] if cached && cached[:key] == cache_key
|
|
38
|
+
|
|
39
|
+
value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
|
|
40
|
+
@prices_cache = { key: cache_key, value: value }.freeze
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def explain_table(table, source, provider_model, model_name, normalized_model)
|
|
46
|
+
return nil if table.empty?
|
|
47
|
+
|
|
48
|
+
direct_match(table, source, provider_model, :provider_model) ||
|
|
49
|
+
direct_match(table, source, model_name, :model) ||
|
|
50
|
+
direct_match(table, source, normalized_model, :normalized_model) ||
|
|
51
|
+
unique_providerless_lookup(normalized_model, table, source) ||
|
|
52
|
+
fuzzy_match(provider_model, normalized_model, table, source) ||
|
|
53
|
+
unique_providerless_fuzzy_match(normalized_model, table, source)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def normalize_model_name(model)
|
|
57
|
+
model.to_s.split("/").last
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def unique_providerless_lookup(model, table, source)
|
|
61
|
+
matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
|
|
62
|
+
match(table, source, matches.first, :unique_providerless_model) if matches.one?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fuzzy_match(model, normalized_model, table, source)
|
|
66
|
+
sorted_price_keys(table).each do |key|
|
|
67
|
+
return match(table, source, key, :dated_snapshot) if snapshot_variant?(model, key) ||
|
|
68
|
+
snapshot_variant?(normalized_model, key)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def unique_providerless_fuzzy_match(model, table, source)
|
|
75
|
+
matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
|
|
76
|
+
match(table, source, matches.first, :unique_providerless_dated_snapshot) if matches.one?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def direct_match(table, source, key, matched_by)
|
|
80
|
+
match(table, source, key, matched_by) if table.key?(key)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def match(table, source, key, matched_by)
|
|
84
|
+
Match.new(source.to_s, key, table[key], matched_by.to_s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def snapshot_variant?(model, key)
|
|
88
|
+
suffix = model.delete_prefix("#{key}-")
|
|
89
|
+
return false if suffix == model
|
|
90
|
+
|
|
91
|
+
suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8})\z/)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def sorted_price_keys(table)
|
|
95
|
+
cached = @sorted_price_keys_cache
|
|
96
|
+
return cached[:keys] if cached && cached[:table].equal?(table)
|
|
97
|
+
|
|
98
|
+
MUTEX.synchronize do
|
|
99
|
+
cached = @sorted_price_keys_cache
|
|
100
|
+
return cached[:keys] if cached && cached[:table].equal?(table)
|
|
101
|
+
|
|
102
|
+
keys = table.keys.sort_by { |key| -key.length }
|
|
103
|
+
@sorted_price_keys_cache = { table: table, keys: keys }.freeze
|
|
104
|
+
keys
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "pricing/lookup"
|
|
4
|
+
require_relative "pricing/effective_prices"
|
|
5
|
+
require_relative "pricing/explainer"
|
|
4
6
|
|
|
5
7
|
module LlmCostTracker
|
|
6
8
|
module Pricing
|
|
7
9
|
PRICES = PriceRegistry.builtin_prices
|
|
8
|
-
MUTEX = Monitor.new
|
|
9
10
|
|
|
10
11
|
class << self
|
|
11
12
|
def cost_for(provider:, model:, input_tokens:, output_tokens:, cache_read_input_tokens: 0,
|
|
@@ -20,6 +21,7 @@ module LlmCostTracker
|
|
|
20
21
|
cache_write_input_tokens: cache_write_input_tokens
|
|
21
22
|
)
|
|
22
23
|
costs = calculate_costs(usage, prices, pricing_mode: pricing_mode)
|
|
24
|
+
return nil unless costs
|
|
23
25
|
|
|
24
26
|
Cost.new(
|
|
25
27
|
input_cost: costs[:input].round(8),
|
|
@@ -32,127 +34,42 @@ module LlmCostTracker
|
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def lookup(provider:, model:)
|
|
35
|
-
|
|
36
|
-
model_name = model.to_s
|
|
37
|
-
provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
|
|
38
|
-
normalized_model = normalize_model_name(model_name)
|
|
39
|
-
current = current_price_tables
|
|
40
|
-
|
|
41
|
-
lookup_in_table(current.fetch(:pricing_overrides), provider_model, model_name, normalized_model) ||
|
|
42
|
-
lookup_in_table(current.fetch(:file_prices), provider_model, model_name, normalized_model) ||
|
|
43
|
-
lookup_in_table(PRICES, provider_model, model_name, normalized_model)
|
|
37
|
+
Lookup.call(provider: provider, model: model)&.prices
|
|
44
38
|
end
|
|
45
39
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
cached = @prices_cache
|
|
58
|
-
return cached[:value] if cached && cached[:key] == cache_key
|
|
59
|
-
|
|
60
|
-
value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
|
|
61
|
-
@prices_cache = { key: cache_key, value: value }.freeze
|
|
62
|
-
value
|
|
63
|
-
end
|
|
40
|
+
def explain(provider:, model:, input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0,
|
|
41
|
+
cache_write_input_tokens: 0, pricing_mode: nil)
|
|
42
|
+
Explainer.call(
|
|
43
|
+
provider: provider,
|
|
44
|
+
model: model,
|
|
45
|
+
input_tokens: input_tokens,
|
|
46
|
+
output_tokens: output_tokens,
|
|
47
|
+
cache_read_input_tokens: cache_read_input_tokens,
|
|
48
|
+
cache_write_input_tokens: cache_write_input_tokens,
|
|
49
|
+
pricing_mode: pricing_mode
|
|
50
|
+
)
|
|
64
51
|
end
|
|
65
52
|
|
|
66
|
-
|
|
67
|
-
return nil if table.empty?
|
|
68
|
-
|
|
69
|
-
table[provider_model] ||
|
|
70
|
-
table[model_name] ||
|
|
71
|
-
table[normalized_model] ||
|
|
72
|
-
unique_providerless_lookup(normalized_model, table) ||
|
|
73
|
-
fuzzy_match(provider_model, normalized_model, table) ||
|
|
74
|
-
unique_providerless_fuzzy_match(normalized_model, table)
|
|
75
|
-
end
|
|
53
|
+
private
|
|
76
54
|
|
|
77
55
|
def calculate_costs(usage, prices, pricing_mode:)
|
|
56
|
+
effective = EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
|
|
57
|
+
return nil unless effective.complete?
|
|
58
|
+
|
|
78
59
|
{
|
|
79
|
-
input: token_cost(usage.input_tokens,
|
|
80
|
-
cache_read_input: token_cost(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
),
|
|
84
|
-
cache_write_input: token_cost(
|
|
85
|
-
usage.cache_write_input_tokens,
|
|
86
|
-
price_for(prices, :cache_write_input, pricing_mode) || price_for(prices, :input, pricing_mode)
|
|
87
|
-
),
|
|
88
|
-
output: token_cost(usage.output_tokens, price_for(prices, :output, pricing_mode))
|
|
60
|
+
input: token_cost(usage.input_tokens, effective.input),
|
|
61
|
+
cache_read_input: token_cost(usage.cache_read_input_tokens, effective.cache_read_input),
|
|
62
|
+
cache_write_input: token_cost(usage.cache_write_input_tokens, effective.cache_write_input),
|
|
63
|
+
output: token_cost(usage.output_tokens, effective.output)
|
|
89
64
|
}
|
|
90
65
|
end
|
|
91
66
|
|
|
92
|
-
def price_for(prices, key, pricing_mode)
|
|
93
|
-
mode = normalized_pricing_mode(pricing_mode)
|
|
94
|
-
return prices[key] unless mode
|
|
95
|
-
|
|
96
|
-
prices[:"#{mode}_#{key}"] || prices[key]
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def normalized_pricing_mode(value)
|
|
100
|
-
return nil if value.nil?
|
|
101
|
-
|
|
102
|
-
mode = value.to_s.strip
|
|
103
|
-
return nil if mode.empty? || mode == "standard"
|
|
104
|
-
|
|
105
|
-
mode
|
|
106
|
-
end
|
|
107
|
-
|
|
108
67
|
def token_cost(tokens, per_million_price)
|
|
109
68
|
return 0.0 if tokens.to_i.zero?
|
|
69
|
+
return nil if per_million_price.nil?
|
|
110
70
|
|
|
111
71
|
(tokens.to_f / 1_000_000) * per_million_price
|
|
112
72
|
end
|
|
113
|
-
|
|
114
|
-
def normalize_model_name(model)
|
|
115
|
-
model.to_s.split("/").last
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def unique_providerless_lookup(model, table)
|
|
119
|
-
matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
|
|
120
|
-
table[matches.first] if matches.one?
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def fuzzy_match(model, normalized_model, table)
|
|
124
|
-
sorted_price_keys(table).each do |key|
|
|
125
|
-
return table[key] if snapshot_variant?(model, key) || snapshot_variant?(normalized_model, key)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
nil
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def unique_providerless_fuzzy_match(model, table)
|
|
132
|
-
matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
|
|
133
|
-
table[matches.first] if matches.one?
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def snapshot_variant?(model, key)
|
|
137
|
-
suffix = model.delete_prefix("#{key}-")
|
|
138
|
-
return false if suffix == model
|
|
139
|
-
|
|
140
|
-
suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8})\z/)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def sorted_price_keys(table)
|
|
144
|
-
cached = @sorted_price_keys_cache
|
|
145
|
-
return cached[:keys] if cached && cached[:table].equal?(table)
|
|
146
|
-
|
|
147
|
-
MUTEX.synchronize do
|
|
148
|
-
cached = @sorted_price_keys_cache
|
|
149
|
-
return cached[:keys] if cached && cached[:table].equal?(table)
|
|
150
|
-
|
|
151
|
-
keys = table.keys.sort_by { |key| -key.length }
|
|
152
|
-
@sorted_price_keys_cache = { table: table, keys: keys }.freeze
|
|
153
|
-
keys
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
73
|
end
|
|
157
74
|
end
|
|
158
75
|
end
|
|
@@ -8,15 +8,9 @@ module LlmCostTracker
|
|
|
8
8
|
def prune(older_than:, batch_size: DEFAULT_BATCH_SIZE, now: Time.now.utc)
|
|
9
9
|
batch_size = normalized_batch_size(batch_size)
|
|
10
10
|
cutoff = resolve_cutoff(older_than, now)
|
|
11
|
-
require_relative "
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
loop do
|
|
15
|
-
batch = LlmCostTracker::LlmApiCall.where(tracked_at: ...cutoff).limit(batch_size).delete_all
|
|
16
|
-
deleted += batch
|
|
17
|
-
break if batch < batch_size
|
|
18
|
-
end
|
|
19
|
-
deleted
|
|
11
|
+
require_relative "storage/active_record_backend"
|
|
12
|
+
|
|
13
|
+
Storage::ActiveRecordBackend.prune(cutoff: cutoff, batch_size: batch_size)
|
|
20
14
|
end
|
|
21
15
|
|
|
22
16
|
private
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "registry"
|
|
6
|
+
require_relative "active_record_store"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Storage
|
|
10
|
+
class ActiveRecordBackend
|
|
11
|
+
VERIFY_TAG = "llm_cost_tracker_verify"
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def save(event)
|
|
15
|
+
require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
16
|
+
|
|
17
|
+
ActiveRecordStore.save(event)
|
|
18
|
+
event
|
|
19
|
+
rescue LoadError => e
|
|
20
|
+
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verify
|
|
24
|
+
require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
25
|
+
|
|
26
|
+
unless LlmCostTracker::LlmApiCall.table_exists?
|
|
27
|
+
return [
|
|
28
|
+
VerificationResult.new(
|
|
29
|
+
:error,
|
|
30
|
+
"active_record",
|
|
31
|
+
"llm_api_calls table is missing; run install generator and migrate"
|
|
32
|
+
)
|
|
33
|
+
]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
[active_record_capture_check]
|
|
37
|
+
rescue LoadError => e
|
|
38
|
+
[VerificationResult.new(:error, "active_record", "unavailable: #{e.message}")]
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
[VerificationResult.new(:error, "active_record", "#{e.class}: #{e.message}")]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def prune(cutoff:, batch_size:)
|
|
44
|
+
require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
45
|
+
|
|
46
|
+
ActiveRecordStore.prune(cutoff: cutoff, batch_size: batch_size)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def active_record_capture_check
|
|
52
|
+
provider, model = sample_priced_identity
|
|
53
|
+
response_id = "lct_verify_#{SecureRandom.hex(8)}"
|
|
54
|
+
notifications = []
|
|
55
|
+
persisted = false
|
|
56
|
+
subscription = subscribe_to_verification(response_id, notifications)
|
|
57
|
+
|
|
58
|
+
LlmCostTracker::LlmApiCall.transaction do
|
|
59
|
+
LlmCostTracker.track(
|
|
60
|
+
provider: provider,
|
|
61
|
+
model: model,
|
|
62
|
+
input_tokens: 1,
|
|
63
|
+
output_tokens: 1,
|
|
64
|
+
provider_response_id: response_id,
|
|
65
|
+
feature: VERIFY_TAG
|
|
66
|
+
)
|
|
67
|
+
persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
|
|
68
|
+
raise ActiveRecord::Rollback
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return active_record_capture_success if persisted && notifications.any?
|
|
72
|
+
|
|
73
|
+
VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
|
|
74
|
+
rescue LlmCostTracker::BudgetExceededError => e
|
|
75
|
+
VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
|
|
76
|
+
rescue LlmCostTracker::Error => e
|
|
77
|
+
VerificationResult.new(:error, "active_record capture", e.message)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
|
|
80
|
+
ensure
|
|
81
|
+
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def subscribe_to_verification(response_id, notifications)
|
|
85
|
+
ActiveSupport::Notifications.subscribe(LlmCostTracker::Tracker::EVENT_NAME) do |*, payload|
|
|
86
|
+
notifications << payload if payload[:provider_response_id] == response_id
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def active_record_capture_success
|
|
91
|
+
VerificationResult.new(
|
|
92
|
+
:ok,
|
|
93
|
+
"active_record capture",
|
|
94
|
+
"manual event emitted and persisted inside rollback"
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def capture_failure_message(persisted, notifications)
|
|
99
|
+
missing = []
|
|
100
|
+
missing << "notification" if notifications.empty?
|
|
101
|
+
missing << "persisted row" unless persisted
|
|
102
|
+
"missing #{missing.join(' and ')} for synthetic manual event"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def sample_priced_identity
|
|
106
|
+
key = LlmCostTracker::PriceRegistry.builtin_prices.find do |model_id, prices|
|
|
107
|
+
model_id.include?("/") && prices[:input] && prices[:output]
|
|
108
|
+
end&.first
|
|
109
|
+
provider, model = key.to_s.split("/", 2)
|
|
110
|
+
[provider || "openai", model || "gpt-4o-mini"]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Storage
|
|
5
7
|
class ActiveRecordRollups
|
|
@@ -26,6 +28,15 @@ module LlmCostTracker
|
|
|
26
28
|
)
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
def decrement!(call_rows)
|
|
32
|
+
return unless period_totals_enabled?
|
|
33
|
+
|
|
34
|
+
totals = period_decrement_totals(call_rows)
|
|
35
|
+
return if totals.empty?
|
|
36
|
+
|
|
37
|
+
apply_decrements(totals)
|
|
38
|
+
end
|
|
39
|
+
|
|
29
40
|
def monthly_total(time: Time.now.utc)
|
|
30
41
|
period_totals(%i[monthly], time: time).fetch(:monthly)
|
|
31
42
|
end
|
|
@@ -57,6 +68,37 @@ module LlmCostTracker
|
|
|
57
68
|
end
|
|
58
69
|
end
|
|
59
70
|
|
|
71
|
+
def period_decrement_totals(call_rows)
|
|
72
|
+
call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
|
|
73
|
+
_id, tracked_at, total_cost = row
|
|
74
|
+
next unless total_cost
|
|
75
|
+
|
|
76
|
+
PERIODS.each_key do |period|
|
|
77
|
+
totals[[period, bucket_for(period, tracked_at)]] += decimal(total_cost)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def apply_decrements(totals)
|
|
83
|
+
model = period_total_model
|
|
84
|
+
now = Time.now.utc
|
|
85
|
+
|
|
86
|
+
totals.each do |(period, period_start), amount|
|
|
87
|
+
row = model.lock.find_by(period: PERIODS.fetch(period), period_start: period_start)
|
|
88
|
+
next unless row
|
|
89
|
+
|
|
90
|
+
row.update_columns(total_cost: decremented_total(row.total_cost, amount), updated_at: now)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def decremented_total(current, amount)
|
|
95
|
+
[decimal(current) - amount, BigDecimal("0")].max
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def decimal(value)
|
|
99
|
+
BigDecimal(value.to_s)
|
|
100
|
+
end
|
|
101
|
+
|
|
60
102
|
def rollup_period_totals(periods, time)
|
|
61
103
|
buckets = periods.to_h { |period| [period, bucket_for(period, time)] }
|
|
62
104
|
index = buckets.to_h { |period, bucket| [[PERIODS.fetch(period), bucket], period] }
|
|
@@ -54,8 +54,34 @@ module LlmCostTracker
|
|
|
54
54
|
ActiveRecordRollups.period_totals(periods, time: time)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
def prune(cutoff:, batch_size:)
|
|
58
|
+
deleted = 0
|
|
59
|
+
loop do
|
|
60
|
+
batch = prune_batch(cutoff, batch_size)
|
|
61
|
+
deleted += batch
|
|
62
|
+
break if batch < batch_size
|
|
63
|
+
end
|
|
64
|
+
deleted
|
|
65
|
+
end
|
|
66
|
+
|
|
57
67
|
private
|
|
58
68
|
|
|
69
|
+
def prune_batch(cutoff, batch_size)
|
|
70
|
+
LlmCostTracker::LlmApiCall.transaction do
|
|
71
|
+
rows = LlmCostTracker::LlmApiCall
|
|
72
|
+
.where(tracked_at: ...cutoff)
|
|
73
|
+
.order(:id)
|
|
74
|
+
.limit(batch_size)
|
|
75
|
+
.lock
|
|
76
|
+
.pluck(:id, :tracked_at, :total_cost)
|
|
77
|
+
next 0 if rows.empty?
|
|
78
|
+
|
|
79
|
+
deleted = LlmCostTracker::LlmApiCall.where(id: rows.map(&:first)).delete_all
|
|
80
|
+
ActiveRecordRollups.decrement!(rows) if deleted.positive?
|
|
81
|
+
deleted
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
59
85
|
def stringify_tags(tags)
|
|
60
86
|
tags.transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
|
|
61
87
|
end
|