llm_cost_tracker 0.12.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 +71 -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 +2 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +6 -4
- 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/models/llm_cost_tracker/ingestion/inbox_entry.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +25 -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 +8 -10
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +22 -0
- data/app/views/llm_cost_tracker/pricing/index.html.erb +1 -1
- 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/capture/stream_collector.rb +5 -4
- data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
- data/lib/llm_cost_tracker/charges/line_item.rb +3 -2
- 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/event.rb +3 -1
- 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 +27 -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 +8 -7
- data/lib/llm_cost_tracker/integrations/base.rb +1 -1
- 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 +16 -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 +1384 -304
- data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
- data/lib/llm_cost_tracker/pricing/calculation.rb +44 -25
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
- data/lib/llm_cost_tracker/pricing/matcher.rb +24 -3
- data/lib/llm_cost_tracker/pricing/mode.rb +7 -7
- 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 +16 -26
- data/lib/llm_cost_tracker/usage/catalog.rb +12 -2
- data/lib/llm_cost_tracker/usage/dimensions.yml +0 -24
- data/lib/llm_cost_tracker/usage/token_usage.rb +20 -75
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +7 -2
- data/lib/tasks/llm_cost_tracker.rake +23 -10
- metadata +22 -9
|
@@ -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 reject models that have no rate at all; a model priced with one
|
|
38
|
+
# 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 keeps running totals in llm_cost_tracker_call_rollups so checks
|
|
67
|
+
# read one row instead — switch once the live SUM gets slow, and generate the table
|
|
68
|
+
# 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
|
+
# OpenAI-compatible streaming request bodies that don't already set 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
|
|
@@ -8,6 +9,12 @@ module LlmCostTracker
|
|
|
8
9
|
class Batch
|
|
9
10
|
BATCH_SIZE = 100
|
|
10
11
|
LOCK_TIMEOUT_SECONDS = 30
|
|
12
|
+
TRANSIENT_PERSIST_ERRORS = [
|
|
13
|
+
ActiveRecord::Deadlocked,
|
|
14
|
+
ActiveRecord::LockWaitTimeout,
|
|
15
|
+
ActiveRecord::StatementTimeout,
|
|
16
|
+
ActiveRecord::ConnectionNotEstablished
|
|
17
|
+
].freeze
|
|
11
18
|
|
|
12
19
|
def initialize(identity:)
|
|
13
20
|
@identity = identity
|
|
@@ -22,7 +29,10 @@ module LlmCostTracker
|
|
|
22
29
|
rows.size
|
|
23
30
|
rescue StandardError => e
|
|
24
31
|
rows_to_mark = valid_rows&.any? ? valid_rows : rows
|
|
25
|
-
|
|
32
|
+
if rows_to_mark&.any?
|
|
33
|
+
transient = valid_rows&.any? && TRANSIENT_PERSIST_ERRORS.any? { |klass| e.is_a?(klass) }
|
|
34
|
+
mark_failed_with_message(rows_to_mark, error_message_for(e), decrement_attempts: transient)
|
|
35
|
+
end
|
|
26
36
|
raise
|
|
27
37
|
end
|
|
28
38
|
|
|
@@ -34,12 +44,20 @@ module LlmCostTracker
|
|
|
34
44
|
claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
|
|
35
45
|
end
|
|
36
46
|
|
|
37
|
-
def mark_failed_with_message(rows, message)
|
|
47
|
+
def mark_failed_with_message(rows, message, decrement_attempts: false)
|
|
38
48
|
now = Time.now.utc
|
|
39
|
-
Ingestion::InboxEntry
|
|
40
|
-
|
|
41
|
-
.update_all(
|
|
42
|
-
|
|
49
|
+
scope = Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity)
|
|
50
|
+
if decrement_attempts
|
|
51
|
+
scope.update_all(
|
|
52
|
+
Ingestion::InboxEntry.sanitize_sql_array(
|
|
53
|
+
["last_error = ?, locked_at = ?, locked_by = NULL, " \
|
|
54
|
+
"attempts = GREATEST(attempts - 1, 0), updated_at = ?", message, now, now]
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
else
|
|
58
|
+
scope.update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
|
|
59
|
+
warn_on_quarantine(rows)
|
|
60
|
+
end
|
|
43
61
|
rescue StandardError => e
|
|
44
62
|
LlmCostTracker::Logging.warn(
|
|
45
63
|
"Inbox mark_failed_with_message failed for #{rows.size} rows: #{e.class}: #{e.message} " \
|
|
@@ -101,9 +119,11 @@ module LlmCostTracker
|
|
|
101
119
|
|
|
102
120
|
def persist(rows, events, retry_on_conflict: true)
|
|
103
121
|
LlmCostTracker::Call.transaction do
|
|
104
|
-
Ledger::Store.
|
|
122
|
+
Ledger::Store.persist_records(events)
|
|
105
123
|
Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
|
|
106
124
|
end
|
|
125
|
+
Ledger::Rollups.increment_safely!(events)
|
|
126
|
+
Budget.notify_persisted_safely!(events)
|
|
107
127
|
rescue ActiveRecord::RecordNotUnique
|
|
108
128
|
raise unless retry_on_conflict
|
|
109
129
|
|
|
@@ -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:)
|
|
@@ -46,16 +46,17 @@ module LlmCostTracker
|
|
|
46
46
|
|
|
47
47
|
def record_batch_result(response)
|
|
48
48
|
return unless active?
|
|
49
|
-
return unless response.respond_to?(:result) && response.result
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
record_safely do
|
|
51
|
+
next unless response.respond_to?(:result) && response.result
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
result = response.result
|
|
54
|
+
next unless result.respond_to?(:type) && result.type.to_s == "succeeded"
|
|
55
|
+
|
|
56
|
+
message = result.respond_to?(:message) ? result.message : nil
|
|
57
|
+
next unless message
|
|
58
|
+
next if LlmCostTracker::Call.already_recorded?(provider: "anthropic", provider_response_id: message.id)
|
|
57
59
|
|
|
58
|
-
record_safely do
|
|
59
60
|
usage = message.usage
|
|
60
61
|
next unless usage
|
|
61
62
|
next if usage.input_tokens.nil? && usage.output_tokens.nil?
|
|
@@ -83,7 +83,7 @@ module LlmCostTracker
|
|
|
83
83
|
enforce_budget!(request: request, provider: provider)
|
|
84
84
|
started_at = LlmCostTracker::Timing.now_monotonic
|
|
85
85
|
response = yield
|
|
86
|
-
record.call(response, request, LlmCostTracker::Timing.elapsed_ms(started_at))
|
|
86
|
+
record_safely { record.call(response, request, LlmCostTracker::Timing.elapsed_ms(started_at)) }
|
|
87
87
|
response
|
|
88
88
|
end
|
|
89
89
|
|
|
@@ -13,32 +13,30 @@ module LlmCostTracker
|
|
|
13
13
|
class << self
|
|
14
14
|
def maybe_capture(batch, resource:)
|
|
15
15
|
return unless Openai.active?
|
|
16
|
-
return unless batch.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return unless output_file_id
|
|
20
|
-
|
|
21
|
-
batch_id = batch.respond_to?(:id) ? batch.id : nil
|
|
22
|
-
return unless batch_id && claim(batch_id)
|
|
16
|
+
return unless batch.status.to_s == "completed"
|
|
17
|
+
return unless batch.output_file_id && batch.id
|
|
18
|
+
return if captured?(batch.id)
|
|
23
19
|
|
|
24
20
|
client = resource.instance_variable_get(:@client)
|
|
25
21
|
host = Openai.client_host_for(resource)
|
|
26
22
|
Openai.record_safely do
|
|
27
|
-
io = client.files.content(output_file_id)
|
|
23
|
+
io = client.files.content(batch.output_file_id)
|
|
28
24
|
capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
|
|
25
|
+
mark_captured(batch.id)
|
|
29
26
|
end
|
|
30
27
|
end
|
|
31
28
|
|
|
32
29
|
private
|
|
33
30
|
|
|
34
|
-
def
|
|
31
|
+
def captured?(batch_id)
|
|
32
|
+
MUTEX.synchronize { @dedup&.include?(batch_id) || false }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def mark_captured(batch_id)
|
|
35
36
|
MUTEX.synchronize do
|
|
36
37
|
@dedup ||= Set.new
|
|
37
|
-
next false if @dedup.include?(batch_id)
|
|
38
|
-
|
|
39
38
|
@dedup.clear if @dedup.size >= DEDUP_LIMIT
|
|
40
39
|
@dedup.add(batch_id)
|
|
41
|
-
true
|
|
42
40
|
end
|
|
43
41
|
end
|
|
44
42
|
|
|
@@ -45,7 +45,7 @@ module LlmCostTracker
|
|
|
45
45
|
|
|
46
46
|
def recorded_sql(period, start)
|
|
47
47
|
calls = "COALESCE(#{sum_sql(LlmCostTracker::Call.between(start, time))}, 0)"
|
|
48
|
-
return calls unless
|
|
48
|
+
return calls unless Rollups.cache_active?
|
|
49
49
|
|
|
50
50
|
rollup = "COALESCE(#{sum_sql(rollup_scope(period))}, 0)"
|
|
51
51
|
"GREATEST(#{rollup}, #{calls})"
|
|
@@ -8,7 +8,17 @@ module LlmCostTracker
|
|
|
8
8
|
module Ledger
|
|
9
9
|
module Rollups
|
|
10
10
|
class << self
|
|
11
|
+
def cache_active?
|
|
12
|
+
return false unless LlmCostTracker.configuration.budgets.totals_source == :cache
|
|
13
|
+
return true if LlmCostTracker::CallRollup.table_exists?
|
|
14
|
+
|
|
15
|
+
warn_missing_table
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
11
19
|
def increment!(events)
|
|
20
|
+
return unless cache_active?
|
|
21
|
+
|
|
12
22
|
events = Array(events).select(&:total_cost)
|
|
13
23
|
return if events.empty?
|
|
14
24
|
|
|
@@ -18,7 +28,9 @@ module LlmCostTracker
|
|
|
18
28
|
ROLLUP_INCREMENT_ATTEMPTS = 3
|
|
19
29
|
ROLLUP_INCREMENT_BASE_DELAY_SECONDS = 0.05
|
|
20
30
|
REBUILD_INSERT_SLICE = 1_000
|
|
21
|
-
private_constant :ROLLUP_INCREMENT_ATTEMPTS,
|
|
31
|
+
private_constant :ROLLUP_INCREMENT_ATTEMPTS,
|
|
32
|
+
:ROLLUP_INCREMENT_BASE_DELAY_SECONDS,
|
|
33
|
+
:REBUILD_INSERT_SLICE
|
|
22
34
|
|
|
23
35
|
def increment_safely!(events)
|
|
24
36
|
attempt = 0
|
|
@@ -28,19 +40,26 @@ module LlmCostTracker
|
|
|
28
40
|
rescue StandardError => e
|
|
29
41
|
raise if LlmCostTracker::Call.connection.open_transactions.positive?
|
|
30
42
|
|
|
31
|
-
if attempt < ROLLUP_INCREMENT_ATTEMPTS
|
|
43
|
+
if attempt < ROLLUP_INCREMENT_ATTEMPTS && rolled_back?(e)
|
|
32
44
|
sleep(ROLLUP_INCREMENT_BASE_DELAY_SECONDS * (2**(attempt - 1)))
|
|
33
45
|
retry
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
LlmCostTracker::Logging.warn(
|
|
37
|
-
"Rollup increment failed for #{events.size} events after #{attempt}
|
|
38
|
-
"#{e.class}: #{e.message}"
|
|
49
|
+
"Rollup increment failed for #{events.size} events after #{attempt} attempt(s): " \
|
|
50
|
+
"#{e.class}: #{e.message}. Budget reads fall back to the calls ledger; " \
|
|
51
|
+
"run bin/rails llm_cost_tracker:rebuild_rollups to resync the cache."
|
|
39
52
|
)
|
|
40
53
|
end
|
|
41
54
|
end
|
|
42
55
|
|
|
56
|
+
def rolled_back?(error)
|
|
57
|
+
error.is_a?(ActiveRecord::Deadlocked) || error.is_a?(ActiveRecord::LockWaitTimeout)
|
|
58
|
+
end
|
|
59
|
+
|
|
43
60
|
def decrement!(records)
|
|
61
|
+
return unless cache_active?
|
|
62
|
+
|
|
44
63
|
buckets = bucket_totals(records)
|
|
45
64
|
return if buckets.empty?
|
|
46
65
|
|
|
@@ -60,6 +79,17 @@ module LlmCostTracker
|
|
|
60
79
|
|
|
61
80
|
private
|
|
62
81
|
|
|
82
|
+
def warn_missing_table
|
|
83
|
+
return if @missing_table_warned
|
|
84
|
+
|
|
85
|
+
@missing_table_warned = true
|
|
86
|
+
Logging.warn(
|
|
87
|
+
"config.budgets.totals_source = :cache is set but llm_cost_tracker_call_rollups is missing; " \
|
|
88
|
+
"budget and dashboard totals fall back to aggregating llm_cost_tracker_calls. " \
|
|
89
|
+
"Run the call_rollups generator and migrate, or set config.budgets.totals_source = :ledger."
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
63
93
|
def accumulate_priced_calls
|
|
64
94
|
accumulated = Hash.new { |totals, key| totals[key] = BigDecimal("0") }
|
|
65
95
|
priced_calls.in_batches do |batch|
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../pricing"
|
|
6
6
|
require_relative "rollups"
|
|
7
|
+
require_relative "../budget/per_tag"
|
|
7
8
|
require_relative "tags/encoding"
|
|
8
9
|
|
|
9
10
|
module LlmCostTracker
|
|
@@ -14,13 +15,20 @@ module LlmCostTracker
|
|
|
14
15
|
events = Array(events)
|
|
15
16
|
return if events.empty?
|
|
16
17
|
|
|
18
|
+
persist_records(events)
|
|
19
|
+
Ledger::Rollups.increment_safely!(events)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def persist_records(events)
|
|
23
|
+
events = Array(events)
|
|
24
|
+
return if events.empty?
|
|
25
|
+
|
|
17
26
|
LlmCostTracker::Call.transaction do
|
|
18
27
|
rows = events.map { |event| attributes_for(event) }
|
|
19
28
|
call_ids = insert_calls_returning_ids(rows, events)
|
|
20
29
|
insert_line_items(events, call_ids)
|
|
21
30
|
insert_call_tags(events, call_ids)
|
|
22
31
|
end
|
|
23
|
-
Ledger::Rollups.increment_safely!(events) if LlmCostTracker.configuration.cache_rollups
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
private
|
|
@@ -114,7 +122,7 @@ module LlmCostTracker
|
|
|
114
122
|
llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
|
|
115
123
|
key: key.to_s,
|
|
116
124
|
value: Tags::Encoding.encode(value)
|
|
117
|
-
}
|
|
125
|
+
}.merge(budget_columns_for(event))
|
|
118
126
|
end
|
|
119
127
|
end
|
|
120
128
|
return if rows.empty?
|
|
@@ -122,6 +130,12 @@ module LlmCostTracker
|
|
|
122
130
|
LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
|
|
123
131
|
end
|
|
124
132
|
|
|
133
|
+
def budget_columns_for(event)
|
|
134
|
+
return {} unless LlmCostTracker::Budget::PerTag.columns?
|
|
135
|
+
|
|
136
|
+
{ total_cost: event.cost&.total, tracked_at: event.tracked_at }
|
|
137
|
+
end
|
|
138
|
+
|
|
125
139
|
def stored_details(details)
|
|
126
140
|
(details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
|
|
127
141
|
end
|
|
@@ -6,12 +6,22 @@ module LlmCostTracker
|
|
|
6
6
|
module Ledger
|
|
7
7
|
module Tags
|
|
8
8
|
module Encoding
|
|
9
|
+
INDEXABLE_BYTES = 2_048
|
|
10
|
+
|
|
9
11
|
def self.encode(value)
|
|
10
|
-
case value
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
encoded = case value
|
|
13
|
+
when Hash then JSON.generate(normalize_hash(value))
|
|
14
|
+
when Array then JSON.generate(normalize_array(value))
|
|
15
|
+
else value.to_s
|
|
16
|
+
end
|
|
17
|
+
truncate(encoded)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.truncate(string)
|
|
21
|
+
limit = [LlmCostTracker.configuration.tags.max_value_bytesize.to_i, INDEXABLE_BYTES].min
|
|
22
|
+
return string if string.bytesize <= limit
|
|
23
|
+
|
|
24
|
+
string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
|
|
15
25
|
end
|
|
16
26
|
|
|
17
27
|
def self.normalize_hash(hash)
|
|
@@ -3,15 +3,13 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Logging
|
|
5
5
|
class << self
|
|
6
|
-
def debug(message) =
|
|
6
|
+
def debug(message) = Rails.logger.debug(prefixed(message))
|
|
7
7
|
|
|
8
|
-
def warn(message) =
|
|
8
|
+
def warn(message) = Rails.logger.warn(prefixed(message))
|
|
9
9
|
|
|
10
10
|
private
|
|
11
11
|
|
|
12
|
-
def
|
|
13
|
-
Rails.logger.tagged(LlmCostTracker.name)
|
|
14
|
-
end
|
|
12
|
+
def prefixed(message) = "[#{LlmCostTracker.name}] #{message}"
|
|
15
13
|
end
|
|
16
14
|
end
|
|
17
15
|
end
|
|
@@ -94,7 +94,7 @@ module LlmCostTracker
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
|
|
97
|
-
return nil unless LlmCostTracker.configuration.
|
|
97
|
+
return nil unless LlmCostTracker.configuration.capture.request_stream_usage
|
|
98
98
|
return nil unless parser&.auto_enable_stream_usage?(request_url)
|
|
99
99
|
|
|
100
100
|
stream_options = request_parsed["stream_options"]
|