llm_cost_tracker 0.13.0 → 0.14.1
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 +99 -16
- data/README.md +17 -29
- data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
- 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/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
- data/app/models/llm_cost_tracker/call.rb +10 -4
- 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/filter.rb +24 -21
- 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/params.rb +10 -0
- 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/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
- data/lib/llm_cost_tracker/budget.rb +120 -32
- data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
- 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 +2 -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 +25 -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 +38 -7
- 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/anthropic.rb +7 -1
- data/lib/llm_cost_tracker/integrations/base.rb +17 -1
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
- data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +21 -13
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +5 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
- data/lib/llm_cost_tracker/parsers.rb +5 -1
- data/lib/llm_cost_tracker/prices.json +2089 -372
- 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 +17 -6
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
- 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/azure/parser.rb +19 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
- data/lib/llm_cost_tracker/railtie.rb +3 -7
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- 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 +14 -40
- data/lib/llm_cost_tracker/tracker.rb +16 -26
- 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 +10 -3
- data/lib/tasks/llm_cost_tracker.rake +29 -14
- metadata +37 -12
|
@@ -11,20 +11,20 @@ module LlmCostTracker
|
|
|
11
11
|
return unless Probe.table_exists?("llm_cost_tracker_calls")
|
|
12
12
|
return inline_check unless LlmCostTracker::Ingestion.async?
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
return async_ok if
|
|
14
|
+
problems = missing_parts + drifted_parts
|
|
15
|
+
return async_ok if problems.empty?
|
|
16
16
|
|
|
17
17
|
Check.new(
|
|
18
18
|
:error,
|
|
19
19
|
"async ingestion",
|
|
20
|
-
"
|
|
20
|
+
"#{problems.join('; ')}; see docs/upgrading.md for the recovery steps"
|
|
21
21
|
)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
26
|
def async_ok
|
|
27
|
-
Check.new(:ok, "async ingestion", "inbox and ingestion lease tables
|
|
27
|
+
Check.new(:ok, "async ingestion", "inbox and ingestion lease tables are on the current schema")
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def inline_check
|
|
@@ -32,14 +32,14 @@ module LlmCostTracker
|
|
|
32
32
|
if leftovers.empty?
|
|
33
33
|
return Check.new(:ok,
|
|
34
34
|
"inline ingestion",
|
|
35
|
-
"config.ingestion = :inline; events write directly to the ledger")
|
|
35
|
+
"config.ingestion.mode = :inline; events write directly to the ledger")
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
Check.new(
|
|
39
39
|
:warn,
|
|
40
40
|
"inline ingestion",
|
|
41
|
-
"config.ingestion = :inline but found unused async ingestion tables: #{leftovers.join(', ')}. " \
|
|
42
|
-
"Set config.ingestion = :async to keep the inbox path or drop the tables."
|
|
41
|
+
"config.ingestion.mode = :inline but found unused async ingestion tables: #{leftovers.join(', ')}. " \
|
|
42
|
+
"Set config.ingestion.mode = :async to keep the inbox path or drop the tables."
|
|
43
43
|
)
|
|
44
44
|
end
|
|
45
45
|
|
|
@@ -48,7 +48,16 @@ module LlmCostTracker
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def missing_parts
|
|
51
|
-
async_tables.reject { |table| Probe.table_exists?(table) }
|
|
51
|
+
async_tables.reject { |table| Probe.table_exists?(table) }.map { |table| "missing #{table}" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def drifted_parts
|
|
55
|
+
LlmCostTracker::Ledger::Schema::ASYNC_SCHEMAS.filter_map do |schema, table|
|
|
56
|
+
next unless Probe.table_exists?(table)
|
|
57
|
+
|
|
58
|
+
errors = schema.current_schema_errors
|
|
59
|
+
"#{table} #{errors.join(', ')}" unless errors.empty?
|
|
60
|
+
end
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
def async_tables
|
|
@@ -11,8 +11,9 @@ module LlmCostTracker
|
|
|
11
11
|
REFRESH_COMMAND = "refresh the source-controlled prices file with bin/rails llm_cost_tracker:prices:refresh"
|
|
12
12
|
|
|
13
13
|
def call
|
|
14
|
-
path = LlmCostTracker.configuration.
|
|
14
|
+
path = LlmCostTracker.configuration.pricing.file
|
|
15
15
|
return bundled_check unless path
|
|
16
|
+
return Check.new(:error, "prices", "#{path} does not exist; #{REFRESH_COMMAND}") unless File.exist?(path)
|
|
16
17
|
|
|
17
18
|
count = LlmCostTracker::Pricing::Registry.file_prices(path).size
|
|
18
19
|
metadata = LlmCostTracker::Pricing::Registry.file_metadata(path)
|
|
@@ -164,7 +164,7 @@ module LlmCostTracker
|
|
|
164
164
|
|
|
165
165
|
def call_rollups_check
|
|
166
166
|
return unless llm_cost_tracker_calls_table?
|
|
167
|
-
return live_rollups_check unless LlmCostTracker.configuration.
|
|
167
|
+
return live_rollups_check unless LlmCostTracker.configuration.budgets.totals_source == :cache
|
|
168
168
|
|
|
169
169
|
errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
|
|
170
170
|
return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if errors.empty?
|
|
@@ -181,14 +181,14 @@ module LlmCostTracker
|
|
|
181
181
|
Check.new(
|
|
182
182
|
:warn,
|
|
183
183
|
"call rollups",
|
|
184
|
-
"
|
|
185
|
-
"Set config.
|
|
184
|
+
"budgets.totals_source=:ledger but llm_cost_tracker_call_rollups exists. " \
|
|
185
|
+
"Set config.budgets.totals_source = :cache to keep budget reads on the rollups fast path or drop the table."
|
|
186
186
|
)
|
|
187
187
|
else
|
|
188
188
|
Check.new(
|
|
189
189
|
:ok,
|
|
190
190
|
"call rollups",
|
|
191
|
-
"
|
|
191
|
+
"budgets.totals_source=:ledger; budget reads aggregate from llm_cost_tracker_calls directly"
|
|
192
192
|
)
|
|
193
193
|
end
|
|
194
194
|
end
|
|
@@ -9,6 +9,10 @@ module LlmCostTracker
|
|
|
9
9
|
class Engine < ::Rails::Engine
|
|
10
10
|
isolate_namespace LlmCostTracker
|
|
11
11
|
|
|
12
|
+
initializer "llm_cost_tracker.deprecator" do |app|
|
|
13
|
+
app.deprecators[:llm_cost_tracker] = LlmCostTracker.deprecator
|
|
14
|
+
end
|
|
15
|
+
|
|
12
16
|
initializer "llm_cost_tracker.dashboard_setup_state" do |app|
|
|
13
17
|
app.reloader.to_prepare { LlmCostTracker::Dashboard::SetupState.reset! }
|
|
14
18
|
end
|
|
@@ -1,25 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "redaction"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
class Error < StandardError; end
|
|
5
7
|
|
|
6
8
|
class InvalidFilterError < Error; end
|
|
7
9
|
|
|
10
|
+
class TransactionAbortedError < Error
|
|
11
|
+
def initialize(error)
|
|
12
|
+
super(
|
|
13
|
+
"The database rolled back the whole surrounding transaction while recording LLM usage " \
|
|
14
|
+
"(#{error.class}: #{Redaction.text(error.message)}); the caller's transaction no longer exists"
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
8
19
|
class BudgetExceededError < Error
|
|
9
|
-
attr_reader :total, :budget, :budget_type, :last_event, :stage
|
|
20
|
+
attr_reader :total, :budget, :budget_type, :last_event, :stage, :scope
|
|
10
21
|
|
|
11
|
-
def initialize(budget:, budget_type:, total:, last_event: nil, stage: :post_spend)
|
|
22
|
+
def initialize(budget:, budget_type:, total:, last_event: nil, stage: :post_spend, scope: nil)
|
|
12
23
|
@total = total
|
|
13
24
|
@budget = budget
|
|
14
25
|
@budget_type = budget_type
|
|
15
26
|
@last_event = last_event
|
|
16
27
|
@stage = stage
|
|
28
|
+
@scope = scope
|
|
17
29
|
|
|
18
30
|
super(
|
|
19
|
-
"LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded: " \
|
|
31
|
+
"LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded#{scope_suffix}: " \
|
|
20
32
|
"$#{format('%.6f', @total)} / $#{format('%.6f', budget)}"
|
|
21
33
|
)
|
|
22
34
|
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def scope_suffix
|
|
39
|
+
return "" unless @scope
|
|
40
|
+
|
|
41
|
+
" for #{@scope[:key]}=#{@scope[:value]}"
|
|
42
|
+
end
|
|
23
43
|
end
|
|
24
44
|
|
|
25
45
|
class UnknownPricingError < Error
|
|
@@ -31,4 +51,6 @@ module LlmCostTracker
|
|
|
31
51
|
super("No pricing configured for LLM model: #{model.inspect}")
|
|
32
52
|
end
|
|
33
53
|
end
|
|
54
|
+
|
|
55
|
+
CALLER_ERRORS = [BudgetExceededError, UnknownPricingError, TransactionAbortedError].freeze
|
|
34
56
|
end
|
|
@@ -11,7 +11,7 @@ module LlmCostTracker
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
13
|
desc "Creates the async ingestion tables (llm_cost_tracker_ingestion_inbox_entries + _leases). " \
|
|
14
|
-
"Required when config.ingestion = :async."
|
|
14
|
+
"Required when config.ingestion.mode = :async."
|
|
15
15
|
|
|
16
16
|
def create_migration_file
|
|
17
17
|
migration_template(
|
|
@@ -25,7 +25,7 @@ module LlmCostTracker
|
|
|
25
25
|
After migrating, set the following in config/initializers/llm_cost_tracker.rb:
|
|
26
26
|
|
|
27
27
|
LlmCostTracker.configure do |config|
|
|
28
|
-
config.ingestion = :async
|
|
28
|
+
config.ingestion.mode = :async
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
Without it the async inbox tables stay unused and Tracker keeps writing
|
|
@@ -11,7 +11,7 @@ module LlmCostTracker
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
13
|
desc "Creates the optional llm_cost_tracker_call_rollups table for fast budget reads. " \
|
|
14
|
-
"Required when config.
|
|
14
|
+
"Required when config.budgets.totals_source = :cache."
|
|
15
15
|
|
|
16
16
|
def create_migration_file
|
|
17
17
|
migration_template(
|
|
@@ -25,7 +25,7 @@ module LlmCostTracker
|
|
|
25
25
|
After migrating, set the following in config/initializers/llm_cost_tracker.rb:
|
|
26
26
|
|
|
27
27
|
LlmCostTracker.configure do |config|
|
|
28
|
-
config.
|
|
28
|
+
config.budgets.totals_source = :cache
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
Without it Tracker keeps reading budget totals as live SUM aggregates over
|
|
@@ -23,7 +23,6 @@ class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_
|
|
|
23
23
|
|
|
24
24
|
add_index :llm_cost_tracker_ingestion_inbox_entries, :event_id, unique: true
|
|
25
25
|
add_index :llm_cost_tracker_ingestion_inbox_entries, [:tracked_at, :attempts]
|
|
26
|
-
add_index :llm_cost_tracker_ingestion_inbox_entries, [:locked_at, :id]
|
|
27
26
|
add_index :llm_cost_tracker_ingestion_leases, :name, unique: true
|
|
28
27
|
end
|
|
29
28
|
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb
CHANGED
|
@@ -74,20 +74,24 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
74
74
|
foreign_key: { to_table: :llm_cost_tracker_calls, on_delete: :cascade }
|
|
75
75
|
t.string :key, null: false
|
|
76
76
|
t.text :value, null: false
|
|
77
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
78
|
+
t.datetime :tracked_at
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
add_index :llm_cost_tracker_calls, :event_id, unique: true
|
|
80
82
|
add_index :llm_cost_tracker_calls, :tracked_at
|
|
81
|
-
add_index :llm_cost_tracker_calls, [
|
|
82
|
-
add_index :llm_cost_tracker_calls, [
|
|
83
|
+
add_index :llm_cost_tracker_calls, %i[provider tracked_at]
|
|
84
|
+
add_index :llm_cost_tracker_calls, %i[model tracked_at]
|
|
83
85
|
add_index :llm_cost_tracker_calls, :cost_status
|
|
84
86
|
add_index :llm_cost_tracker_calls, :provider_response_id
|
|
87
|
+
add_index :llm_cost_tracker_calls, :id, name: :index_llm_cost_tracker_calls_on_unpriced,
|
|
88
|
+
where: "total_cost IS NULL"
|
|
85
89
|
add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
|
|
86
90
|
add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
|
|
87
91
|
if postgresql?
|
|
88
|
-
add_index :llm_cost_tracker_call_tags, [:key, :value]
|
|
92
|
+
add_index :llm_cost_tracker_call_tags, [:key, :value, :tracked_at]
|
|
89
93
|
elsif mysql?
|
|
90
|
-
add_index :llm_cost_tracker_call_tags, [:key, :value], length: { value: 191 }
|
|
94
|
+
add_index :llm_cost_tracker_call_tags, [:key, :value, :tracked_at], length: { value: 191 }
|
|
91
95
|
end
|
|
92
96
|
end
|
|
93
97
|
|
|
@@ -4,16 +4,16 @@ LlmCostTracker.configure do |config|
|
|
|
4
4
|
# Set to false to temporarily disable tracking without removing middleware.
|
|
5
5
|
config.enabled = true
|
|
6
6
|
|
|
7
|
-
# LLM Cost Tracker logs warnings through Rails.logger when available.
|
|
8
|
-
config.log_level = :info
|
|
9
|
-
|
|
10
7
|
# Tags merged into every event. Use a callable for request/job-time context.
|
|
11
|
-
config.
|
|
8
|
+
config.tags.default = -> { { environment: Rails.env } }
|
|
12
9
|
|
|
13
10
|
# Tag guardrails keep accidental high-cardinality or sensitive values out of the ledger.
|
|
14
|
-
# config.
|
|
15
|
-
# config.
|
|
16
|
-
# config.
|
|
11
|
+
# config.tags.max_count = 50
|
|
12
|
+
# config.tags.max_value_bytesize = 1024
|
|
13
|
+
# config.tags.redacted_keys = <%= LlmCostTracker::Configuration::Tags::DEFAULT_REDACTED_KEYS.inspect %>
|
|
14
|
+
|
|
15
|
+
# Tag keys that get their own cost breakdown in bin/rails llm_cost_tracker:report.
|
|
16
|
+
# config.tags.report_breakdown_keys = %w[feature user_id]
|
|
17
17
|
|
|
18
18
|
# Optional SDK integrations. Provider SDK gems are not installed by LLM Cost Tracker.
|
|
19
19
|
# Enabled integrations are checked at boot, so enable only clients your app loads.
|
|
@@ -23,35 +23,50 @@ LlmCostTracker.configure do |config|
|
|
|
23
23
|
|
|
24
24
|
# Pricing — local file refreshed via bin/rails llm_cost_tracker:prices:refresh
|
|
25
25
|
# plus inline overrides. Rates are per 1M tokens; the snapshot's currency
|
|
26
|
-
# is read from your
|
|
26
|
+
# is read from your pricing file's `metadata.currency` (USD in the bundled
|
|
27
27
|
# snapshot — set a different code per file if you maintain non-USD prices).
|
|
28
28
|
<% if options[:prices] -%>
|
|
29
|
-
config.
|
|
29
|
+
config.pricing.file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
30
30
|
<% else -%>
|
|
31
|
-
# config.
|
|
31
|
+
# config.pricing.file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
32
32
|
<% end -%>
|
|
33
|
-
# config.
|
|
33
|
+
# config.pricing.overrides = {
|
|
34
34
|
# "my-custom-model" => { input: 1.00, output: 2.00 }
|
|
35
35
|
# }
|
|
36
36
|
# :warn (default) records token usage with nil cost when a model has no rate.
|
|
37
|
-
# Use :raise to
|
|
38
|
-
|
|
37
|
+
# Use :raise to record and then raise for models that have no rate at all; a model
|
|
38
|
+
# priced with one component rate missing lands as partial and does not trigger it.
|
|
39
|
+
config.pricing.unknown_model_behavior = :warn
|
|
39
40
|
|
|
40
41
|
# Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
|
|
41
|
-
# plus behavior on crossing (:notify default fires
|
|
42
|
+
# plus behavior on crossing (:notify default fires budgets.on_exceeded; :raise
|
|
42
43
|
# raises after recording; :block_requests preflights supported requests, also
|
|
43
44
|
# estimating the current call's input cost via chars/4 so it can block before
|
|
44
|
-
# send) and an optional callback.
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
# config.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
#
|
|
45
|
+
# send) and an optional callback.
|
|
46
|
+
# config.budgets.monthly = 100.00
|
|
47
|
+
# config.budgets.daily = 10.00
|
|
48
|
+
# config.budgets.per_call = 1.00
|
|
49
|
+
config.budgets.exceeded_behavior = :notify
|
|
50
|
+
|
|
51
|
+
# One budget applied to every distinct value of a tag: each tenant gets its own
|
|
52
|
+
# 1000 a month, not 1000 shared between them. Declare as many tags as you need; a rule
|
|
53
|
+
# can set its own behavior and callback instead of following the global ones. Needs the
|
|
54
|
+
# cost columns from `bin/rails generate llm_cost_tracker:upgrade_per_tag_budgets`.
|
|
55
|
+
# Budget high-cardinality tags only — a tag with a handful of values covers most of the
|
|
56
|
+
# ledger, so its check cannot use an index and every call pays for a near-full scan.
|
|
57
|
+
# config.budgets.per_tag = {
|
|
58
|
+
# tenant_id: { monthly: 1000.00, weekly: 300.00 },
|
|
59
|
+
# user_id: { daily: 25.00, behavior: :notify }
|
|
60
|
+
# }
|
|
61
|
+
# config.budgets.on_exceeded = ->(data) {
|
|
52
62
|
# Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}")
|
|
53
63
|
# }
|
|
54
|
-
|
|
64
|
+
|
|
65
|
+
# Where budget checks read period spend from. :ledger sums llm_cost_tracker_calls on
|
|
66
|
+
# every check; :cache also keeps running totals in llm_cost_tracker_call_rollups and
|
|
67
|
+
# takes the greater of the two, so it guards against drift but does not make checks
|
|
68
|
+
# faster. Generate the table first with `bin/rails generate llm_cost_tracker:call_rollups`.
|
|
69
|
+
# config.budgets.totals_source = :cache
|
|
55
70
|
|
|
56
71
|
# Ingestion path — :inline (default) writes events synchronously from the request
|
|
57
72
|
# thread. Set to :async for a write-ahead inbox + background worker that batches
|
|
@@ -59,13 +74,18 @@ LlmCostTracker.configure do |config|
|
|
|
59
74
|
# inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
|
|
60
75
|
# Synchronous inbox writes use a dedicated ActiveRecord pool (defaults to 2 connections)
|
|
61
76
|
# so they don't compete with request threads for the default pool when a tracked call
|
|
62
|
-
# happens inside an open caller transaction. Bump
|
|
77
|
+
# happens inside an open caller transaction. Bump ingestion.pool_size if your Puma
|
|
63
78
|
# worker count outgrows that.
|
|
64
|
-
# config.ingestion
|
|
65
|
-
# config.
|
|
79
|
+
# config.ingestion.mode = :async
|
|
80
|
+
# config.ingestion.pool_size = 5
|
|
81
|
+
|
|
82
|
+
# Register OpenAI-compatible gateway hosts so their calls are attributed to a
|
|
83
|
+
# provider name instead of the generic openai_compatible bucket.
|
|
84
|
+
# config.capture.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
|
|
85
|
+
|
|
86
|
+
# Streaming calls only report token usage when the request asks for it. Leave this
|
|
87
|
+
# on and the Faraday middleware adds stream_options: { include_usage: true } to
|
|
88
|
+
# streaming chat-completions requests on hosts known to accept it.
|
|
89
|
+
# config.capture.request_stream_usage = false
|
|
66
90
|
|
|
67
|
-
# Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
|
|
68
|
-
# for bin/rails llm_cost_tracker:report.
|
|
69
|
-
# config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
|
|
70
|
-
# config.report_tag_breakdowns = %w[feature user_id]
|
|
71
91
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
6
|
+
CALLS = :llm_cost_tracker_calls
|
|
7
|
+
INBOX = :llm_cost_tracker_ingestion_inbox_entries
|
|
8
|
+
UNUSED_INBOX_INDEX = %i[locked_at id].freeze
|
|
9
|
+
UNPRICED_INDEX = :index_llm_cost_tracker_calls_on_unpriced
|
|
10
|
+
|
|
11
|
+
def up
|
|
12
|
+
add_index CALLS, :id, name: UNPRICED_INDEX, where: "total_cost IS NULL", **add_index_options
|
|
13
|
+
return unless table_exists?(INBOX)
|
|
14
|
+
|
|
15
|
+
remove_index INBOX, column: UNUSED_INBOX_INDEX, **remove_index_options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def down
|
|
19
|
+
add_index INBOX, UNUSED_INBOX_INDEX, **add_index_options if table_exists?(INBOX)
|
|
20
|
+
remove_index CALLS, name: UNPRICED_INDEX, **remove_index_options
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def add_index_options
|
|
26
|
+
return { if_not_exists: true } unless postgresql?
|
|
27
|
+
|
|
28
|
+
{ if_not_exists: true, algorithm: :concurrently }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def remove_index_options
|
|
32
|
+
return { if_exists: true } unless postgresql?
|
|
33
|
+
|
|
34
|
+
{ if_exists: true, algorithm: :concurrently }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def postgresql?
|
|
38
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
6
|
+
TABLE = :llm_cost_tracker_call_tags
|
|
7
|
+
INDEX = %i[key value tracked_at].freeze
|
|
8
|
+
REPLACED_INDEX = %i[key value].freeze
|
|
9
|
+
|
|
10
|
+
def up
|
|
11
|
+
add_column TABLE, :total_cost, :decimal, precision: 20, scale: 8 unless column_exists?(TABLE, :total_cost)
|
|
12
|
+
add_column TABLE, :tracked_at, :datetime unless column_exists?(TABLE, :tracked_at)
|
|
13
|
+
add_index TABLE, INDEX, **add_index_options
|
|
14
|
+
remove_index TABLE, column: REPLACED_INDEX, **remove_index_options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def down
|
|
18
|
+
add_index TABLE, REPLACED_INDEX, **add_index_options
|
|
19
|
+
remove_index TABLE, column: INDEX, **remove_index_options
|
|
20
|
+
remove_column TABLE, :tracked_at, if_exists: true
|
|
21
|
+
remove_column TABLE, :total_cost, if_exists: true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def add_index_options
|
|
27
|
+
return { if_not_exists: true, length: { value: 191 } } unless postgresql?
|
|
28
|
+
|
|
29
|
+
{ if_not_exists: true, algorithm: :concurrently }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def remove_index_options
|
|
33
|
+
return { if_exists: true } unless postgresql?
|
|
34
|
+
|
|
35
|
+
{ if_exists: true, algorithm: :concurrently }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def postgresql?
|
|
39
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class UpgradeIndexesGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Adds a partial index over unpriced calls and drops the unused ingestion inbox lock index" \
|
|
14
|
+
"scanning the whole ledger."
|
|
15
|
+
|
|
16
|
+
def create_migration_file
|
|
17
|
+
migration_template(
|
|
18
|
+
"upgrade_indexes.rb.erb",
|
|
19
|
+
"db/migrate/upgrade_llm_cost_tracker_indexes.rb"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def migration_version
|
|
26
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class UpgradePerTagBudgetsGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Carries each call's cost and time onto its tag rows so per-tag budgets read " \
|
|
14
|
+
"llm_cost_tracker_call_tags without a join. Required when config.budgets.per_tag is set."
|
|
15
|
+
|
|
16
|
+
def create_migration_file
|
|
17
|
+
migration_template(
|
|
18
|
+
"upgrade_per_tag_budgets.rb.erb",
|
|
19
|
+
"db/migrate/upgrade_llm_cost_tracker_per_tag_budgets.rb"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def migration_version
|
|
26
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "inbox"
|
|
4
|
+
require_relative "../budget"
|
|
4
5
|
require_relative "../ledger/store"
|
|
5
6
|
|
|
6
7
|
module LlmCostTracker
|
|
@@ -12,7 +13,8 @@ module LlmCostTracker
|
|
|
12
13
|
ActiveRecord::Deadlocked,
|
|
13
14
|
ActiveRecord::LockWaitTimeout,
|
|
14
15
|
ActiveRecord::StatementTimeout,
|
|
15
|
-
ActiveRecord::ConnectionNotEstablished
|
|
16
|
+
ActiveRecord::ConnectionNotEstablished,
|
|
17
|
+
LlmCostTracker::TransactionAbortedError
|
|
16
18
|
].freeze
|
|
17
19
|
|
|
18
20
|
def initialize(identity:)
|
|
@@ -24,7 +26,7 @@ module LlmCostTracker
|
|
|
24
26
|
return 0 if rows.empty?
|
|
25
27
|
|
|
26
28
|
valid_rows, events = decode(rows)
|
|
27
|
-
|
|
29
|
+
persist_batch(valid_rows, events) if events.any?
|
|
28
30
|
rows.size
|
|
29
31
|
rescue StandardError => e
|
|
30
32
|
rows_to_mark = valid_rows&.any? ? valid_rows : rows
|
|
@@ -66,7 +68,7 @@ module LlmCostTracker
|
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
def error_message_for(error)
|
|
69
|
-
"#{error.class}: #{error.message}".byteslice(0, 1_000)
|
|
71
|
+
"#{error.class}: #{Redaction.text(error.message)}".byteslice(0, 1_000).scrub("")
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
def warn_on_quarantine(rows)
|
|
@@ -74,12 +76,10 @@ module LlmCostTracker
|
|
|
74
76
|
quarantined = rows.select { |row| row.attempts.to_i + 1 >= threshold }
|
|
75
77
|
return if quarantined.empty?
|
|
76
78
|
|
|
77
|
-
sample = quarantined.first(10).map(&:id).join(", ")
|
|
78
|
-
sample += "..." if quarantined.size > 10
|
|
79
79
|
LlmCostTracker::Logging.warn(
|
|
80
80
|
"Ingestion::Batch: #{quarantined.size} inbox row(s) reached " \
|
|
81
81
|
"MAX_ATTEMPTS_BEFORE_QUARANTINE=#{threshold} and will be skipped " \
|
|
82
|
-
"on the next claim cycle (ids: #{
|
|
82
|
+
"on the next claim cycle (ids: #{id_sample(quarantined)})"
|
|
83
83
|
)
|
|
84
84
|
end
|
|
85
85
|
|
|
@@ -87,6 +87,11 @@ module LlmCostTracker
|
|
|
87
87
|
|
|
88
88
|
attr_reader :identity
|
|
89
89
|
|
|
90
|
+
def id_sample(rows)
|
|
91
|
+
sample = rows.first(10).map(&:id).join(", ")
|
|
92
|
+
rows.size > 10 ? "#{sample}..." : sample
|
|
93
|
+
end
|
|
94
|
+
|
|
90
95
|
def claim
|
|
91
96
|
now = Time.now.utc
|
|
92
97
|
cutoff = now - LOCK_TIMEOUT_SECONDS
|
|
@@ -116,12 +121,38 @@ module LlmCostTracker
|
|
|
116
121
|
[valid_rows, events]
|
|
117
122
|
end
|
|
118
123
|
|
|
124
|
+
def persist_batch(rows, events)
|
|
125
|
+
landed = []
|
|
126
|
+
failed = Hash.new { |hash, message| hash[message] = [] }
|
|
127
|
+
landed.concat(persist(rows, events))
|
|
128
|
+
rescue *TRANSIENT_PERSIST_ERRORS
|
|
129
|
+
raise
|
|
130
|
+
rescue StandardError
|
|
131
|
+
rows.zip(events) do |row, event|
|
|
132
|
+
landed.concat(persist([row], [event]))
|
|
133
|
+
rescue *TRANSIENT_PERSIST_ERRORS
|
|
134
|
+
raise
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
failed[error_message_for(e)] << row
|
|
137
|
+
end
|
|
138
|
+
ensure
|
|
139
|
+
failed.each do |message, failed_rows|
|
|
140
|
+
LlmCostTracker::Logging.warn(
|
|
141
|
+
"Ingestion::Batch: #{failed_rows.size} inbox row(s) could not be stored " \
|
|
142
|
+
"(ids: #{id_sample(failed_rows)}): #{message}"
|
|
143
|
+
)
|
|
144
|
+
mark_failed_with_message(failed_rows, message)
|
|
145
|
+
end
|
|
146
|
+
Ledger::Rollups.increment_safely!(landed)
|
|
147
|
+
Budget.notify_persisted_safely!(landed)
|
|
148
|
+
end
|
|
149
|
+
|
|
119
150
|
def persist(rows, events, retry_on_conflict: true)
|
|
120
151
|
LlmCostTracker::Call.transaction do
|
|
121
152
|
Ledger::Store.persist_records(events)
|
|
122
153
|
Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
|
|
123
154
|
end
|
|
124
|
-
|
|
155
|
+
events
|
|
125
156
|
rescue ActiveRecord::RecordNotUnique
|
|
126
157
|
raise unless retry_on_conflict
|
|
127
158
|
|
|
@@ -12,7 +12,14 @@ module LlmCostTracker
|
|
|
12
12
|
delegate :with_connection, to: :pool
|
|
13
13
|
|
|
14
14
|
def pool
|
|
15
|
-
@pool
|
|
15
|
+
return @pool if @pool && @pid == Process.pid
|
|
16
|
+
|
|
17
|
+
MUTEX.synchronize do
|
|
18
|
+
next @pool if @pool && @pid == Process.pid
|
|
19
|
+
|
|
20
|
+
@pid = Process.pid
|
|
21
|
+
@pool = connect!
|
|
22
|
+
end
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
private
|
|
@@ -27,7 +34,7 @@ module LlmCostTracker
|
|
|
27
34
|
end
|
|
28
35
|
|
|
29
36
|
def pool_size
|
|
30
|
-
configured = LlmCostTracker.configuration.
|
|
37
|
+
configured = LlmCostTracker.configuration.ingestion.pool_size.to_i
|
|
31
38
|
configured.positive? ? configured : DEFAULT_POOL_SIZE
|
|
32
39
|
end
|
|
33
40
|
end
|
|
@@ -37,16 +37,12 @@ module LlmCostTracker
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def async?
|
|
40
|
-
LlmCostTracker.configuration.ingestion == :async
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def cache_rollups?
|
|
44
|
-
LlmCostTracker.configuration.cache_rollups
|
|
40
|
+
LlmCostTracker.configuration.ingestion.mode == :async
|
|
45
41
|
end
|
|
46
42
|
|
|
47
43
|
def guards_for_current_config
|
|
48
44
|
guards = Ledger::Schema::CORE_SCHEMAS.dup
|
|
49
|
-
guards << Ledger::Schema::CACHE_ROLLUPS_SCHEMA if
|
|
45
|
+
guards << Ledger::Schema::CACHE_ROLLUPS_SCHEMA if LlmCostTracker.configuration.budgets.totals_source == :cache
|
|
50
46
|
guards += Ledger::Schema::ASYNC_SCHEMAS if async?
|
|
51
47
|
guards
|
|
52
48
|
end
|
|
@@ -132,7 +128,7 @@ module LlmCostTracker
|
|
|
132
128
|
return if records.empty?
|
|
133
129
|
|
|
134
130
|
relation.delete_all
|
|
135
|
-
LlmCostTracker::Ledger::Rollups.decrement!(records)
|
|
131
|
+
LlmCostTracker::Ledger::Rollups.decrement!(records)
|
|
136
132
|
end
|
|
137
133
|
|
|
138
134
|
def cleanup_verification_inbox(event:, response_id:)
|