llm_cost_tracker 0.13.0 → 0.14.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 +53 -16
- data/README.md +16 -28
- data/app/controllers/llm_cost_tracker/application_controller.rb +3 -2
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +11 -0
- data/app/models/llm_cost_tracker/call.rb +9 -3
- data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
- data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
- data/lib/llm_cost_tracker/budget/per_tag.rb +159 -0
- data/lib/llm_cost_tracker/budget.rb +120 -32
- data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
- data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
- data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
- data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
- data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
- data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
- data/lib/llm_cost_tracker/configuration/section.rb +59 -0
- data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
- data/lib/llm_cost_tracker/configuration.rb +69 -125
- data/lib/llm_cost_tracker/deprecator.rb +9 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +4 -4
- data/lib/llm_cost_tracker/engine.rb +4 -0
- data/lib/llm_cost_tracker/errors.rb +12 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +3 -1
- data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
- data/lib/llm_cost_tracker/ingestion.rb +3 -7
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +10 -12
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +34 -4
- data/lib/llm_cost_tracker/ledger/store.rb +9 -2
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
- data/lib/llm_cost_tracker/logging.rb +3 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +1291 -313
- data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
- data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
- data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +5 -5
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -1
- data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +5 -2
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +5 -4
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +2 -2
- data/lib/llm_cost_tracker/railtie.rb +3 -7
- data/lib/llm_cost_tracker/report/data.rb +2 -2
- data/lib/llm_cost_tracker/retention.rb +22 -10
- data/lib/llm_cost_tracker/tags/context.rb +3 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +4 -4
- data/lib/llm_cost_tracker/tracker.rb +14 -23
- data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +6 -1
- data/lib/tasks/llm_cost_tracker.rake +23 -10
- metadata +22 -9
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ledger/schema/adapter"
|
|
4
|
+
require_relative "../ledger/tags/encoding"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Budget
|
|
8
|
+
module PerTag
|
|
9
|
+
COST_COLUMN = "total_cost"
|
|
10
|
+
TIME_COLUMN = "tracked_at"
|
|
11
|
+
WINDOW_STARTS = { daily: :beginning_of_day, weekly: :beginning_of_week, monthly: :beginning_of_month }.freeze
|
|
12
|
+
WINDOW_NEXTS = { daily: :next_day, weekly: :next_week, monthly: :next_month }.freeze
|
|
13
|
+
SLOW_READ_SECONDS = 0.1
|
|
14
|
+
DEFAULT_BACKFILL_BATCH = 5_000
|
|
15
|
+
|
|
16
|
+
Rule = Data.define(:key, :value, :windows, :behavior, :on_exceeded)
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def configured
|
|
20
|
+
LlmCostTracker.configuration.budgets.per_tag
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def active?
|
|
24
|
+
return false if configured.empty?
|
|
25
|
+
return true if columns?
|
|
26
|
+
|
|
27
|
+
warn_missing_columns
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def blocking?
|
|
32
|
+
active? && configured.each_value.any? { |entry| behavior_for(entry) == :block_requests }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rules_for(tags, blocking_only: false)
|
|
36
|
+
return [] unless active?
|
|
37
|
+
|
|
38
|
+
normalized = (tags || {}).to_h.transform_keys(&:to_s)
|
|
39
|
+
configured.filter_map do |key, entry|
|
|
40
|
+
raw = normalized[key]
|
|
41
|
+
next if raw.nil?
|
|
42
|
+
next if blocking_only && behavior_for(entry) != :block_requests
|
|
43
|
+
|
|
44
|
+
Rule.new(
|
|
45
|
+
key: key,
|
|
46
|
+
value: Ledger::Tags::Encoding.encode(raw),
|
|
47
|
+
windows: entry[:windows],
|
|
48
|
+
behavior: behavior_for(entry),
|
|
49
|
+
on_exceeded: on_exceeded_for(entry)
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rules_for_events(events)
|
|
55
|
+
events.each_with_object({}) do |event, grouped|
|
|
56
|
+
rules_for(event.tags).each { |rule| (grouped[rule] ||= []) << event }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def spend(key, value, window, time:)
|
|
61
|
+
spend_by_value(key, [value], window, window_start(window, time)).fetch(value, 0).to_d
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def spend_by_value(key, values, window, bucket)
|
|
65
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
+
totals = LlmCostTracker::CallTag
|
|
67
|
+
.where(key: key, value: values, TIME_COLUMN => window_range(window, bucket))
|
|
68
|
+
.group(:value).sum(COST_COLUMN)
|
|
69
|
+
warn_slow_read(key, window, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
|
|
70
|
+
totals
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def window_start(window, time)
|
|
74
|
+
time.to_time.utc.public_send(WINDOW_STARTS.fetch(window))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def backfill(batch_size: DEFAULT_BACKFILL_BATCH)
|
|
78
|
+
return 0 unless columns?
|
|
79
|
+
|
|
80
|
+
filled = 0
|
|
81
|
+
loop do
|
|
82
|
+
copied = copy_next_batch(batch_size)
|
|
83
|
+
break if copied.zero?
|
|
84
|
+
|
|
85
|
+
filled += copied
|
|
86
|
+
end
|
|
87
|
+
filled
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def columns?
|
|
91
|
+
(LlmCostTracker::CallTag.column_names & [COST_COLUMN, TIME_COLUMN]).size == 2
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def window_range(window, bucket)
|
|
97
|
+
bucket...bucket.public_send(WINDOW_NEXTS.fetch(window))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def copy_next_batch(batch_size)
|
|
101
|
+
ids = LlmCostTracker::CallTag.where(TIME_COLUMN => nil).limit(batch_size).pluck(:id)
|
|
102
|
+
return 0 if ids.empty?
|
|
103
|
+
|
|
104
|
+
LlmCostTracker::CallTag.connection.update(copy_sql(ids))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def copy_sql(ids)
|
|
108
|
+
tags = LlmCostTracker::CallTag.quoted_table_name
|
|
109
|
+
calls = LlmCostTracker::Call.quoted_table_name
|
|
110
|
+
list = ids.join(",")
|
|
111
|
+
return <<~SQL.squish unless Ledger::Schema::Adapter.postgresql?(LlmCostTracker::CallTag.connection)
|
|
112
|
+
UPDATE #{tags} t JOIN #{calls} c ON c.id = t.llm_cost_tracker_call_id
|
|
113
|
+
SET t.#{COST_COLUMN} = c.#{COST_COLUMN}, t.#{TIME_COLUMN} = c.#{TIME_COLUMN}
|
|
114
|
+
WHERE t.id IN (#{list})
|
|
115
|
+
SQL
|
|
116
|
+
|
|
117
|
+
<<~SQL.squish
|
|
118
|
+
UPDATE #{tags} AS t
|
|
119
|
+
SET #{COST_COLUMN} = c.#{COST_COLUMN}, #{TIME_COLUMN} = c.#{TIME_COLUMN}
|
|
120
|
+
FROM #{calls} AS c
|
|
121
|
+
WHERE c.id = t.llm_cost_tracker_call_id AND t.id IN (#{list})
|
|
122
|
+
SQL
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def behavior_for(entry)
|
|
126
|
+
entry[:behavior] || LlmCostTracker.configuration.budgets.exceeded_behavior
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def on_exceeded_for(entry)
|
|
130
|
+
entry[:on_exceeded] || LlmCostTracker.configuration.budgets.on_exceeded
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def warn_slow_read(key, window, seconds)
|
|
134
|
+
return if seconds < SLOW_READ_SECONDS
|
|
135
|
+
|
|
136
|
+
@slow_keys ||= Set.new
|
|
137
|
+
return unless @slow_keys.add?(key)
|
|
138
|
+
|
|
139
|
+
Logging.warn(
|
|
140
|
+
"config.budgets.per_tag[#{key.inspect}] #{window} read took #{(seconds * 1000).round} ms. " \
|
|
141
|
+
"A tag with few distinct values covers most of the ledger, so its budget check cannot use " \
|
|
142
|
+
"an index effectively. Budget high-cardinality tags such as a tenant or user id."
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def warn_missing_columns
|
|
147
|
+
return if @missing_columns_warned
|
|
148
|
+
|
|
149
|
+
@missing_columns_warned = true
|
|
150
|
+
Logging.warn(
|
|
151
|
+
"config.budgets.per_tag is set but llm_cost_tracker_call_tags is missing " \
|
|
152
|
+
"#{COST_COLUMN} / #{TIME_COLUMN}; per-tag budgets are not enforced. Run the " \
|
|
153
|
+
"upgrade_per_tag_budgets generator and migrate, or clear config.budgets.per_tag."
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -4,42 +4,94 @@ require "bigdecimal"
|
|
|
4
4
|
|
|
5
5
|
require_relative "ledger"
|
|
6
6
|
require_relative "pricing/estimator"
|
|
7
|
+
require_relative "budget/per_tag"
|
|
8
|
+
require_relative "tags/context"
|
|
7
9
|
|
|
8
10
|
module LlmCostTracker
|
|
9
11
|
module Budget
|
|
10
12
|
BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
|
|
11
13
|
|
|
12
14
|
class << self
|
|
13
|
-
def enforce!(provider: nil, model: nil, request: nil, estimate: nil, force: false)
|
|
15
|
+
def enforce!(provider: nil, model: nil, request: nil, estimate: nil, tags: nil, force: false)
|
|
14
16
|
config = LlmCostTracker.configuration
|
|
15
17
|
return unless config.enabled
|
|
16
|
-
|
|
18
|
+
|
|
19
|
+
globally = force || config.budgets.exceeded_behavior == :block_requests
|
|
20
|
+
per_tag = force || PerTag.blocking?
|
|
21
|
+
return unless globally || per_tag
|
|
17
22
|
|
|
18
23
|
estimate ||= estimate_cost(provider: provider, model: model, request: request)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
now = Time.now.utc
|
|
25
|
+
enforce_globally(config, estimate: estimate, time: now) if globally
|
|
26
|
+
return unless per_tag
|
|
27
|
+
|
|
28
|
+
check_per_tag(tags || Tags::Context.tags,
|
|
29
|
+
time: now,
|
|
30
|
+
estimate: estimate,
|
|
31
|
+
blocking_only: !force) do |rule, window, total, limit|
|
|
32
|
+
raise_pre_send(budget_type: window, total: total, budget: limit, scope: scope_for(rule))
|
|
27
33
|
end
|
|
28
34
|
end
|
|
29
35
|
|
|
30
|
-
def check!(event)
|
|
36
|
+
def check!(event, behavior_override: nil)
|
|
31
37
|
config = LlmCostTracker.configuration
|
|
32
38
|
return unless event.total_cost
|
|
33
39
|
|
|
34
|
-
check_per_call_budget(event, config)
|
|
35
|
-
check_windowed({ daily: config.
|
|
40
|
+
check_per_call_budget(event, config, behavior_override)
|
|
41
|
+
check_windowed({ daily: config.budgets.daily, monthly: config.budgets.monthly }.compact,
|
|
36
42
|
time: event.tracked_at) do |budget_type, total, budget|
|
|
37
|
-
handle_exceeded(budget_type: budget_type,
|
|
43
|
+
handle_exceeded(budget_type: budget_type,
|
|
44
|
+
total: total,
|
|
45
|
+
budget: budget,
|
|
46
|
+
previous_total: total - event.total_cost,
|
|
47
|
+
last_event: event,
|
|
48
|
+
behavior: behavior_override)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def check_persisted!(events, behavior_override: nil)
|
|
53
|
+
by_rule = PerTag.rules_for_events(events.select(&:total_cost))
|
|
54
|
+
by_rule = by_rule.reject { |rule, _| rule.on_exceeded.nil? } if behavior_override == :notify
|
|
55
|
+
window_buckets(by_rule).each do |(key, window, bucket), scored|
|
|
56
|
+
totals = PerTag.spend_by_value(key, scored.keys.map(&:value), window, bucket)
|
|
57
|
+
scored.each do |rule, recorded|
|
|
58
|
+
total = totals.fetch(rule.value, 0).to_d
|
|
59
|
+
limit = rule.windows.fetch(window)
|
|
60
|
+
next if total < limit
|
|
61
|
+
|
|
62
|
+
handle_exceeded(
|
|
63
|
+
budget_type: window,
|
|
64
|
+
total: total,
|
|
65
|
+
budget: limit,
|
|
66
|
+
previous_total: total - recorded.sum(&:total_cost),
|
|
67
|
+
last_event: recorded.last,
|
|
68
|
+
scope: scope_for(rule),
|
|
69
|
+
behavior: behavior_override || rule.behavior,
|
|
70
|
+
on_exceeded: rule.on_exceeded
|
|
71
|
+
)
|
|
72
|
+
end
|
|
38
73
|
end
|
|
39
74
|
end
|
|
40
75
|
|
|
76
|
+
def notify_persisted_safely!(events)
|
|
77
|
+
check_persisted!(events, behavior_override: :notify)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
|
|
80
|
+
end
|
|
81
|
+
|
|
41
82
|
private
|
|
42
83
|
|
|
84
|
+
def window_buckets(by_rule)
|
|
85
|
+
by_rule.each_with_object({}) do |(rule, events), grouped|
|
|
86
|
+
rule.windows.each_key do |window|
|
|
87
|
+
events.group_by { |event| PerTag.window_start(window, event.tracked_at) }
|
|
88
|
+
.each do |bucket, bucket_events|
|
|
89
|
+
(grouped[[rule.key, window, bucket]] ||= {})[rule] = bucket_events
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
43
95
|
def estimate_cost(provider:, model:, request:)
|
|
44
96
|
return BigDecimal("0") unless provider && model && request
|
|
45
97
|
|
|
@@ -54,14 +106,48 @@ estimate: estimate) do |budget_type, total, budget|
|
|
|
54
106
|
))
|
|
55
107
|
end
|
|
56
108
|
|
|
57
|
-
def check_per_call_budget(event, config)
|
|
58
|
-
budget = config.
|
|
109
|
+
def check_per_call_budget(event, config, behavior_override)
|
|
110
|
+
budget = config.budgets.per_call
|
|
59
111
|
return unless budget
|
|
60
112
|
|
|
61
113
|
total = event.total_cost
|
|
62
114
|
return unless total >= budget
|
|
63
115
|
|
|
64
|
-
handle_exceeded(budget_type: :per_call,
|
|
116
|
+
handle_exceeded(budget_type: :per_call,
|
|
117
|
+
total: total,
|
|
118
|
+
budget: budget,
|
|
119
|
+
previous_total: nil,
|
|
120
|
+
last_event: event,
|
|
121
|
+
behavior: behavior_override)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def enforce_globally(config, estimate:, time:)
|
|
125
|
+
raise_per_call_pre_send(estimate, config.budgets.per_call) if config.budgets.per_call && estimate.positive?
|
|
126
|
+
|
|
127
|
+
check_windowed({ monthly: config.budgets.monthly, daily: config.budgets.daily }.compact,
|
|
128
|
+
time: time,
|
|
129
|
+
estimate: estimate) do |budget_type, total, budget|
|
|
130
|
+
raise_pre_send(budget_type: budget_type, total: total, budget: budget)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def raise_pre_send(budget_type:, total:, budget:, scope: nil)
|
|
135
|
+
raise BudgetExceededError.new(**budget_payload(
|
|
136
|
+
budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send, scope: scope
|
|
137
|
+
))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def check_per_tag(tags, time:, estimate: BigDecimal("0"), blocking_only: false)
|
|
141
|
+
PerTag.rules_for(tags, blocking_only: blocking_only).each do |rule|
|
|
142
|
+
rule.windows.each do |window, limit|
|
|
143
|
+
total = PerTag.spend(rule.key, rule.value, window, time: time) + estimate
|
|
144
|
+
yield(rule, window, total, limit) if total >= limit
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def scope_for(rule)
|
|
150
|
+
{ key: rule.key, value: rule.value }
|
|
65
151
|
end
|
|
66
152
|
|
|
67
153
|
def check_windowed(budgets, time:, estimate: BigDecimal("0"))
|
|
@@ -82,38 +168,40 @@ estimate: estimate) do |budget_type, total, budget|
|
|
|
82
168
|
period_for.transform_values { |period| period_totals.fetch(period) }
|
|
83
169
|
end
|
|
84
170
|
|
|
85
|
-
def handle_exceeded(budget_type:,
|
|
171
|
+
def handle_exceeded(budget_type:,
|
|
172
|
+
total:,
|
|
173
|
+
budget:,
|
|
174
|
+
previous_total:,
|
|
175
|
+
last_event: nil,
|
|
176
|
+
scope: nil,
|
|
177
|
+
behavior: nil,
|
|
178
|
+
on_exceeded: nil)
|
|
86
179
|
config = LlmCostTracker.configuration
|
|
180
|
+
behavior ||= config.budgets.exceeded_behavior
|
|
181
|
+
on_exceeded ||= config.budgets.on_exceeded
|
|
87
182
|
payload = budget_payload(
|
|
88
183
|
budget_type: budget_type,
|
|
89
184
|
total: total,
|
|
90
185
|
budget: budget,
|
|
91
186
|
last_event: last_event,
|
|
92
|
-
stage: :post_spend
|
|
187
|
+
stage: :post_spend,
|
|
188
|
+
scope: scope
|
|
93
189
|
)
|
|
94
190
|
|
|
95
|
-
if
|
|
96
|
-
|
|
97
|
-
end
|
|
98
|
-
raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(config.budget_exceeded_behavior)
|
|
191
|
+
on_exceeded.call(payload) if on_exceeded && (previous_total.nil? || previous_total < budget)
|
|
192
|
+
raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
|
|
99
193
|
end
|
|
100
194
|
|
|
101
|
-
def budget_payload(budget_type:, total:, budget:, last_event:, stage:)
|
|
195
|
+
def budget_payload(budget_type:, total:, budget:, last_event:, stage:, scope: nil)
|
|
102
196
|
{
|
|
103
197
|
budget_type: budget_type,
|
|
104
198
|
total: total,
|
|
105
199
|
budget: budget,
|
|
106
200
|
last_event: last_event,
|
|
107
|
-
stage: stage
|
|
201
|
+
stage: stage,
|
|
202
|
+
scope: scope
|
|
108
203
|
}
|
|
109
204
|
end
|
|
110
|
-
|
|
111
|
-
def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
|
|
112
|
-
return false unless config.on_budget_exceeded
|
|
113
|
-
return true if !last_event&.total_cost || budget_type == :per_call
|
|
114
|
-
|
|
115
|
-
total - last_event.total_cost < budget
|
|
116
|
-
end
|
|
117
205
|
end
|
|
118
206
|
end
|
|
119
207
|
end
|
|
@@ -16,7 +16,7 @@ module LlmCostTracker
|
|
|
16
16
|
"#{total_cost} IS NULL OR #{cost_status} IN (#{statuses})"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
19
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
20
20
|
def self.call(token_usage:,
|
|
21
21
|
usage_source:,
|
|
22
22
|
token_cost:,
|
|
@@ -43,9 +43,10 @@ module LlmCostTracker
|
|
|
43
43
|
return UNKNOWN if unpriced && !priced
|
|
44
44
|
return PARTIAL if unpriced
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
return UNKNOWN if total_cost.nil?
|
|
47
|
+
|
|
48
|
+
total_cost.zero? ? FREE : COMPLETE
|
|
47
49
|
end
|
|
48
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
49
50
|
end
|
|
50
51
|
end
|
|
51
52
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "section"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
class Configuration
|
|
9
|
+
class Budgets < Section
|
|
10
|
+
EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
|
|
11
|
+
TOTALS_SOURCES = %i[ledger cache].freeze
|
|
12
|
+
PER_TAG_WINDOWS = %i[daily weekly monthly].freeze
|
|
13
|
+
PER_TAG_OPTIONS = %i[behavior on_exceeded].freeze
|
|
14
|
+
|
|
15
|
+
attributes :monthly, :daily, :per_call, :on_exceeded
|
|
16
|
+
enum_attribute :exceeded_behavior, allowed: EXCEEDED_BEHAVIORS, default: :notify
|
|
17
|
+
enum_attribute :totals_source, allowed: TOTALS_SOURCES, default: :ledger
|
|
18
|
+
|
|
19
|
+
attr_reader :per_tag
|
|
20
|
+
|
|
21
|
+
def initialize(owner)
|
|
22
|
+
super
|
|
23
|
+
@monthly = nil
|
|
24
|
+
@daily = nil
|
|
25
|
+
@per_call = nil
|
|
26
|
+
@on_exceeded = nil
|
|
27
|
+
@per_tag = {}
|
|
28
|
+
self.exceeded_behavior = :notify
|
|
29
|
+
self.totals_source = :ledger
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def per_tag=(value)
|
|
33
|
+
ensure_mutable!
|
|
34
|
+
@per_tag = (value || {}).to_h.to_h { |key, entry| [validated_key(key), validated_entry(key, entry)] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def finalize!
|
|
38
|
+
@per_tag = deep_freeze(@per_tag || {})
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validated_key(key)
|
|
44
|
+
LlmCostTracker::Tags::Key.validate!(key, error_class: Error)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validated_entry(key, entry)
|
|
48
|
+
raise Error, "budgets.per_tag[#{key.inspect}] must be a hash" unless entry.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
normalized = entry.to_h.transform_keys(&:to_sym)
|
|
51
|
+
windows, options = normalized.partition { |name, _| !PER_TAG_OPTIONS.include?(name) }.map(&:to_h)
|
|
52
|
+
if windows.empty?
|
|
53
|
+
raise Error,
|
|
54
|
+
"budgets.per_tag[#{key.inspect}] needs at least one of: #{PER_TAG_WINDOWS.join(', ')}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
windows: windows.to_h do |window, limit|
|
|
59
|
+
[validated_window(key, window), validated_limit(key, window, limit)]
|
|
60
|
+
end,
|
|
61
|
+
behavior: validated_behavior(key, options[:behavior]),
|
|
62
|
+
on_exceeded: options[:on_exceeded]
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validated_behavior(key, behavior)
|
|
67
|
+
return nil if behavior.nil?
|
|
68
|
+
return behavior.to_sym if EXCEEDED_BEHAVIORS.include?(behavior.to_sym)
|
|
69
|
+
|
|
70
|
+
raise Error,
|
|
71
|
+
"Unknown budgets.per_tag[#{key.inspect}] behavior: #{behavior.inspect}. " \
|
|
72
|
+
"Use one of: #{EXCEEDED_BEHAVIORS.join(', ')}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validated_window(key, window)
|
|
76
|
+
return window.to_sym if PER_TAG_WINDOWS.include?(window.to_sym)
|
|
77
|
+
|
|
78
|
+
raise Error,
|
|
79
|
+
"Unknown budgets.per_tag[#{key.inspect}] window: #{window.inspect}. " \
|
|
80
|
+
"Use one of: #{PER_TAG_WINDOWS.join(', ')}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validated_limit(key, window, limit)
|
|
84
|
+
numeric = Float(limit, exception: false)
|
|
85
|
+
return BigDecimal(limit.to_s) if numeric&.positive?
|
|
86
|
+
|
|
87
|
+
raise Error,
|
|
88
|
+
"budgets.per_tag[#{key.inspect}][#{window.inspect}] must be a positive number, " \
|
|
89
|
+
"got #{limit.inspect}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "section"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class Configuration
|
|
7
|
+
class Capture < Section
|
|
8
|
+
OPENAI_COMPATIBLE_PROVIDERS = {
|
|
9
|
+
"openrouter.ai" => "openrouter",
|
|
10
|
+
"api.deepseek.com" => "deepseek",
|
|
11
|
+
"api.groq.com" => "groq"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
attributes :request_stream_usage
|
|
15
|
+
|
|
16
|
+
attr_reader :openai_compatible_providers
|
|
17
|
+
|
|
18
|
+
def initialize(owner)
|
|
19
|
+
super
|
|
20
|
+
@request_stream_usage = true
|
|
21
|
+
self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def openai_compatible_providers=(providers)
|
|
25
|
+
ensure_mutable!
|
|
26
|
+
@openai_compatible_providers = normalize_providers(providers)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def finalize!
|
|
30
|
+
@openai_compatible_providers = deep_freeze(normalize_providers(@openai_compatible_providers))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def normalize_providers(providers)
|
|
36
|
+
(providers || {}).each_with_object({}) do |(host, provider), normalized|
|
|
37
|
+
normalized[host.to_s.downcase] = provider.to_s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "section"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class Configuration
|
|
7
|
+
class Ingestion < Section
|
|
8
|
+
MODES = %i[inline async].freeze
|
|
9
|
+
|
|
10
|
+
attributes :pool_size
|
|
11
|
+
enum_attribute :mode, allowed: MODES, default: :inline
|
|
12
|
+
|
|
13
|
+
def initialize(owner)
|
|
14
|
+
super
|
|
15
|
+
@pool_size = nil
|
|
16
|
+
self.mode = :inline
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class Configuration
|
|
5
|
+
module Mutability
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def ensure_mutable!
|
|
9
|
+
return unless finalized?
|
|
10
|
+
|
|
11
|
+
raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def deep_freeze(value)
|
|
15
|
+
case value
|
|
16
|
+
when Hash
|
|
17
|
+
value.each do |key, nested_value|
|
|
18
|
+
deep_freeze(key)
|
|
19
|
+
deep_freeze(nested_value)
|
|
20
|
+
end
|
|
21
|
+
value.frozen? ? value : value.freeze
|
|
22
|
+
when Array, Set
|
|
23
|
+
value.each { |nested_value| deep_freeze(nested_value) }
|
|
24
|
+
value.frozen? ? value : value.freeze
|
|
25
|
+
when String
|
|
26
|
+
value.frozen? ? value : value.freeze
|
|
27
|
+
else
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "section"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class Configuration
|
|
7
|
+
class Pricing < Section
|
|
8
|
+
UNKNOWN_BEHAVIORS = %i[ignore warn raise].freeze
|
|
9
|
+
|
|
10
|
+
attributes :file
|
|
11
|
+
enum_attribute :unknown_model_behavior, allowed: UNKNOWN_BEHAVIORS, default: :warn
|
|
12
|
+
|
|
13
|
+
attr_reader :overrides
|
|
14
|
+
|
|
15
|
+
def initialize(owner)
|
|
16
|
+
super
|
|
17
|
+
@file = nil
|
|
18
|
+
self.overrides = {}
|
|
19
|
+
self.unknown_model_behavior = :warn
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def overrides=(value)
|
|
23
|
+
ensure_mutable!
|
|
24
|
+
@overrides = LlmCostTracker::Pricing::Registry.normalize_price_entries(
|
|
25
|
+
value || {}, context: "pricing_overrides"
|
|
26
|
+
)
|
|
27
|
+
rescue ArgumentError, TypeError => e
|
|
28
|
+
raise Error, "invalid pricing.overrides: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def finalize!
|
|
32
|
+
@overrides = deep_freeze(@overrides || {})
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/module/delegation"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
require_relative "mutability"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
class Configuration
|
|
10
|
+
class Section
|
|
11
|
+
include Mutability
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def attributes(*names)
|
|
15
|
+
names.each do |name|
|
|
16
|
+
attr_reader name
|
|
17
|
+
|
|
18
|
+
define_method(:"#{name}=") do |value|
|
|
19
|
+
ensure_mutable!
|
|
20
|
+
instance_variable_set(:"@#{name}", value)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enum_attribute(name, allowed:, default:)
|
|
26
|
+
attr_reader name
|
|
27
|
+
|
|
28
|
+
define_method(:"#{name}=") do |value|
|
|
29
|
+
ensure_mutable!
|
|
30
|
+
instance_variable_set(:"@#{name}", normalize_enum(name, value, allowed, default))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private attr_reader :owner
|
|
36
|
+
|
|
37
|
+
delegate :finalized?, to: :owner
|
|
38
|
+
|
|
39
|
+
def initialize(owner)
|
|
40
|
+
@owner = owner
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def finalize!; end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def section_name
|
|
48
|
+
self.class.name.split("::").last.underscore
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def normalize_enum(name, value, allowed, default)
|
|
52
|
+
value = default if value.nil?
|
|
53
|
+
return value if allowed.include?(value)
|
|
54
|
+
|
|
55
|
+
raise Error, "Unknown #{section_name}.#{name}: #{value.inspect}. Use one of: #{allowed.join(', ')}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|