llm_cost_tracker 0.14.1 → 0.14.2
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 +82 -0
- data/README.md +16 -5
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +4 -25
- data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
- data/app/models/llm_cost_tracker/call.rb +10 -14
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -9
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
- data/app/views/llm_cost_tracker/tags/show.html.erb +5 -5
- data/lib/llm_cost_tracker/budget/per_tag.rb +15 -9
- data/lib/llm_cost_tracker/budget.rb +44 -55
- data/lib/llm_cost_tracker/capture/event_window.rb +3 -2
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +25 -26
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +9 -23
- data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
- data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
- data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
- data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/price_check.rb +13 -3
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +19 -2
- data/lib/llm_cost_tracker/engine.rb +0 -1
- data/lib/llm_cost_tracker/event.rb +8 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +11 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
- data/lib/llm_cost_tracker/ingestion.rb +3 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +63 -10
- data/lib/llm_cost_tracker/integrations/base.rb +39 -18
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +241 -76
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +0 -7
- data/lib/llm_cost_tracker/ledger/period/totals.rb +14 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +14 -14
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
- data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
- data/lib/llm_cost_tracker/ledger/store.rb +2 -2
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/middleware/faraday.rb +36 -19
- data/lib/llm_cost_tracker/parsers.rb +7 -40
- data/lib/llm_cost_tracker/prices.json +2237 -154
- data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
- data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
- data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +5 -16
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync.rb +22 -71
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -7
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +172 -48
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +96 -26
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -1
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +3 -1
- data/lib/llm_cost_tracker/report/data.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tracker.rb +17 -9
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/tasks/llm_cost_tracker.rake +24 -21
- data/llm_cost_tracker.gemspec +63 -0
- metadata +8 -7
|
@@ -58,7 +58,11 @@ module LlmCostTracker
|
|
|
58
58
|
def self.from_token_usage(token_usage)
|
|
59
59
|
return [] unless token_usage
|
|
60
60
|
|
|
61
|
-
token_usage.priced_quantities
|
|
61
|
+
from_quantities(token_usage.priced_quantities)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.from_quantities(quantities)
|
|
65
|
+
quantities.filter_map do |key, quantity|
|
|
62
66
|
next unless quantity.positive?
|
|
63
67
|
|
|
64
68
|
dimension = Usage::Catalog.fetch(key)
|
|
@@ -18,8 +18,6 @@ module LlmCostTracker
|
|
|
18
18
|
budgets: Budgets, capture: Capture, ingestion: Ingestion, pricing: Pricing, tags: Tags
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
|
-
SCALAR_ATTRIBUTES = %i[enabled].freeze
|
|
22
|
-
|
|
23
21
|
DEPRECATED_OPTIONS = {
|
|
24
22
|
monthly_budget: { to: %i[budgets monthly] },
|
|
25
23
|
daily_budget: { to: %i[budgets daily] },
|
|
@@ -46,7 +44,7 @@ module LlmCostTracker
|
|
|
46
44
|
log_level: { to: nil, note: "LlmCostTracker logs through Rails.logger, which owns the level" }
|
|
47
45
|
}.freeze
|
|
48
46
|
|
|
49
|
-
attr_reader(
|
|
47
|
+
attr_reader(:enabled, *SECTIONS.keys, :instrumented_integrations)
|
|
50
48
|
|
|
51
49
|
def initialize
|
|
52
50
|
SECTIONS.each { |name, klass| instance_variable_set(:"@#{name}", klass.new(self)) }
|
|
@@ -67,11 +65,9 @@ module LlmCostTracker
|
|
|
67
65
|
@instrumented_integrations.include?(name)
|
|
68
66
|
end
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
instance_variable_set(:"@#{name}", value)
|
|
74
|
-
end
|
|
68
|
+
def enabled=(value)
|
|
69
|
+
ensure_mutable!
|
|
70
|
+
@enabled = value
|
|
75
71
|
end
|
|
76
72
|
|
|
77
73
|
DEPRECATED_OPTIONS.each do |old_name, spec|
|
|
@@ -101,7 +97,7 @@ module LlmCostTracker
|
|
|
101
97
|
|
|
102
98
|
def finalize!
|
|
103
99
|
SECTIONS.each_key { |name| public_send(name).finalize! }
|
|
104
|
-
@instrumented_integrations = deep_freeze(@instrumented_integrations
|
|
100
|
+
@instrumented_integrations = deep_freeze(@instrumented_integrations)
|
|
105
101
|
@finalized = true
|
|
106
102
|
end
|
|
107
103
|
|
|
@@ -20,8 +20,8 @@ module LlmCostTracker
|
|
|
20
20
|
updated_at = metadata["updated_at"] || metadata[:updated_at]
|
|
21
21
|
return configured_check(:warn, path, count, "metadata.updated_at missing; #{REFRESH_COMMAND}") unless updated_at
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
if
|
|
23
|
+
file_date = Date.iso8601(updated_at.to_s)
|
|
24
|
+
if (Date.today - file_date).to_i > STALE_AFTER_DAYS
|
|
25
25
|
return configured_check(
|
|
26
26
|
:warn,
|
|
27
27
|
path,
|
|
@@ -29,6 +29,15 @@ module LlmCostTracker
|
|
|
29
29
|
"updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; #{REFRESH_COMMAND}"
|
|
30
30
|
)
|
|
31
31
|
end
|
|
32
|
+
bundled_at = LlmCostTracker::Pricing::Registry.metadata["updated_at"]
|
|
33
|
+
if bundled_at && file_date < Date.iso8601(bundled_at.to_s)
|
|
34
|
+
return configured_check(
|
|
35
|
+
:warn,
|
|
36
|
+
path,
|
|
37
|
+
count,
|
|
38
|
+
"updated_at=#{updated_at} is older than the bundled prices (#{bundled_at}); #{REFRESH_COMMAND}"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
32
41
|
|
|
33
42
|
configured_check(:ok, path, count, "updated_at=#{updated_at}")
|
|
34
43
|
rescue Date::Error
|
|
@@ -49,7 +58,8 @@ module LlmCostTracker
|
|
|
49
58
|
Check.new(
|
|
50
59
|
:warn,
|
|
51
60
|
"prices",
|
|
52
|
-
"using bundled prices updated_at=#{updated_at};
|
|
61
|
+
"using bundled prices updated_at=#{updated_at}; " \
|
|
62
|
+
"commit a prices_file (config.pricing.file) for production releases"
|
|
53
63
|
)
|
|
54
64
|
end
|
|
55
65
|
|
|
@@ -22,8 +22,7 @@ module LlmCostTracker
|
|
|
22
22
|
Check.new(
|
|
23
23
|
:error,
|
|
24
24
|
@name,
|
|
25
|
-
"current schema required; #{errors.join('; ')}; "
|
|
26
|
-
"run bin/rails generate llm_cost_tracker:install && bin/rails db:migrate"
|
|
25
|
+
"current schema required; #{errors.join('; ')}; see docs/upgrading.md"
|
|
27
26
|
)
|
|
28
27
|
end
|
|
29
28
|
end
|
|
@@ -167,7 +167,7 @@ module LlmCostTracker
|
|
|
167
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
|
-
return
|
|
170
|
+
return rollups_drift_check if errors.empty?
|
|
171
171
|
|
|
172
172
|
Check.new(
|
|
173
173
|
:error,
|
|
@@ -176,13 +176,30 @@ module LlmCostTracker
|
|
|
176
176
|
)
|
|
177
177
|
end
|
|
178
178
|
|
|
179
|
+
def rollups_drift_check
|
|
180
|
+
month_start = LlmCostTracker::Ledger::Period.range_start(:month, Time.now)
|
|
181
|
+
today = LlmCostTracker::Ledger::Period.range_start(:day, Time.now)
|
|
182
|
+
rolled = LlmCostTracker::CallRollup.where(period: "day", period_start: month_start.to_date...today.to_date)
|
|
183
|
+
.sum(:total_cost)
|
|
184
|
+
recorded = LlmCostTracker::Call.where(tracked_at: month_start...today).sum(:total_cost)
|
|
185
|
+
return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if rolled == recorded
|
|
186
|
+
|
|
187
|
+
Check.new(
|
|
188
|
+
:warn,
|
|
189
|
+
"call rollups",
|
|
190
|
+
"this month's day rollups before today total #{rolled.to_d.to_s('F')} but the calls total " \
|
|
191
|
+
"#{recorded.to_d.to_s('F')}, and monthly budgets read the rollups; " \
|
|
192
|
+
"run bin/rails llm_cost_tracker:rebuild_rollups"
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
179
196
|
def live_rollups_check
|
|
180
197
|
if Probe.table_exists?("llm_cost_tracker_call_rollups")
|
|
181
198
|
Check.new(
|
|
182
199
|
:warn,
|
|
183
200
|
"call rollups",
|
|
184
201
|
"budgets.totals_source=:ledger but llm_cost_tracker_call_rollups exists. " \
|
|
185
|
-
"Set config.budgets.totals_source = :cache to
|
|
202
|
+
"Set config.budgets.totals_source = :cache to use it or drop the table."
|
|
186
203
|
)
|
|
187
204
|
else
|
|
188
205
|
Check.new(
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/digest/uuid"
|
|
4
|
+
|
|
3
5
|
require_relative "pricing/mode"
|
|
4
6
|
|
|
5
7
|
module LlmCostTracker
|
|
@@ -53,6 +55,12 @@ module LlmCostTracker
|
|
|
53
55
|
Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
|
|
54
56
|
end
|
|
55
57
|
|
|
58
|
+
def keyed_by_response_id
|
|
59
|
+
return self unless provider_response_id
|
|
60
|
+
|
|
61
|
+
with(event_id: Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, "#{provider}/#{provider_response_id}"))
|
|
62
|
+
end
|
|
63
|
+
|
|
56
64
|
def self.resolve_line_items(service_items)
|
|
57
65
|
Array(service_items).map do |item|
|
|
58
66
|
item.is_a?(Charges::LineItem) ? item : Charges::LineItem.build(item)
|
|
@@ -10,7 +10,7 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "Creates the optional llm_cost_tracker_call_rollups table
|
|
13
|
+
desc "Creates the optional llm_cost_tracker_call_rollups table of running budget totals. " \
|
|
14
14
|
"Required when config.budgets.totals_source = :cache."
|
|
15
15
|
|
|
16
16
|
def create_migration_file
|
|
@@ -28,16 +28,13 @@ module LlmCostTracker
|
|
|
28
28
|
config.budgets.totals_source = :cache
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Once that is deployed, run bin/rails llm_cost_tracker:rebuild_rollups. Until then
|
|
32
|
+
calls recorded before the switch are missing from monthly budget totals.
|
|
33
|
+
|
|
34
|
+
Without the setting Tracker keeps reading budget totals as live SUM aggregates over
|
|
32
35
|
llm_cost_tracker_calls. The doctor check warns about an unused rollups table.
|
|
33
36
|
MSG
|
|
34
37
|
end
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
def migration_version
|
|
39
|
-
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
40
|
-
end
|
|
41
38
|
end
|
|
42
39
|
end
|
|
43
40
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%=
|
|
1
|
+
class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
2
|
def change
|
|
3
3
|
create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
|
|
4
4
|
t.string :event_id, null: false
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
class CreateLlmCostTrackerCallRollups < ActiveRecord::Migration<%=
|
|
1
|
+
class CreateLlmCostTrackerCallRollups < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
2
|
def change
|
|
3
3
|
create_table :llm_cost_tracker_call_rollups do |t|
|
|
4
4
|
t.string :period, null: false
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "llm_cost_tracker/charges/cost_status"
|
|
2
2
|
require "llm_cost_tracker/ledger/schema/adapter"
|
|
3
3
|
|
|
4
|
-
class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%=
|
|
4
|
+
class CreateLlmCostTrackerCalls < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
5
5
|
def change
|
|
6
6
|
create_table :llm_cost_tracker_calls do |t|
|
|
7
7
|
t.string :event_id, null: false
|
|
@@ -50,8 +50,9 @@ LlmCostTracker.configure do |config|
|
|
|
50
50
|
|
|
51
51
|
# One budget applied to every distinct value of a tag: each tenant gets its own
|
|
52
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.
|
|
54
|
-
# cost columns
|
|
53
|
+
# can set its own behavior and callback instead of following the global ones. Installs created
|
|
54
|
+
# before v0.14 add the cost columns with `bin/rails generate llm_cost_tracker:upgrade_per_tag_budgets`
|
|
55
|
+
# and `bin/rails db:migrate`, then run `bin/rails llm_cost_tracker:backfill_tag_costs` so earlier calls count.
|
|
55
56
|
# Budget high-cardinality tags only — a tag with a handful of values covers most of the
|
|
56
57
|
# ledger, so its check cannot use an index and every call pays for a near-full scan.
|
|
57
58
|
# config.budgets.per_tag = {
|
|
@@ -63,9 +64,9 @@ LlmCostTracker.configure do |config|
|
|
|
63
64
|
# }
|
|
64
65
|
|
|
65
66
|
# Where budget checks read period spend from. :ledger sums llm_cost_tracker_calls on
|
|
66
|
-
# every check; :cache
|
|
67
|
-
#
|
|
68
|
-
#
|
|
67
|
+
# every check; :cache reads earlier days from llm_cost_tracker_call_rollups and sums
|
|
68
|
+
# only today's calls. Run `bin/rails generate llm_cost_tracker:call_rollups`, deploy
|
|
69
|
+
# this setting, then run `bin/rails llm_cost_tracker:rebuild_rollups`.
|
|
69
70
|
# config.budgets.totals_source = :cache
|
|
70
71
|
|
|
71
72
|
# Ingestion path — :inline (default) writes events synchronously from the request
|
|
@@ -74,13 +75,14 @@ LlmCostTracker.configure do |config|
|
|
|
74
75
|
# inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
|
|
75
76
|
# Synchronous inbox writes use a dedicated ActiveRecord pool (defaults to 2 connections)
|
|
76
77
|
# so they don't compete with request threads for the default pool when a tracked call
|
|
77
|
-
# happens inside an open caller transaction.
|
|
78
|
-
#
|
|
78
|
+
# happens inside an open caller transaction. The pool is per process: bump ingestion.pool_size
|
|
79
|
+
# if concurrent tracked calls in one process (e.g. Puma threads) outgrow that.
|
|
79
80
|
# config.ingestion.mode = :async
|
|
80
81
|
# config.ingestion.pool_size = 5
|
|
81
82
|
|
|
82
|
-
# Register OpenAI-compatible gateway hosts
|
|
83
|
-
#
|
|
83
|
+
# Register OpenAI-compatible gateway hosts beyond the built-in OpenRouter, DeepSeek and Groq;
|
|
84
|
+
# the Faraday middleware captures only listed hosts. Its calls, and openai gem calls whose
|
|
85
|
+
# base_url is a listed host, are recorded under the provider name given here.
|
|
84
86
|
# config.capture.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
|
|
85
87
|
|
|
86
88
|
# Streaming calls only report token usage when the request asks for it. Leave this
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
2
|
|
|
3
|
-
class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%=
|
|
3
|
+
class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
4
|
disable_ddl_transaction!
|
|
5
5
|
|
|
6
6
|
TABLE = :llm_cost_tracker_call_rollups
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
2
|
|
|
3
|
-
class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%=
|
|
3
|
+
class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
4
|
disable_ddl_transaction!
|
|
5
5
|
|
|
6
6
|
TABLE = :llm_cost_tracker_call_tags
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
class UpgradeLlmCostTrackerImageTokens < ActiveRecord::Migration<%=
|
|
1
|
+
class UpgradeLlmCostTrackerImageTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
2
|
TABLE = :llm_cost_tracker_calls
|
|
3
3
|
COLUMNS = %i[image_input_tokens image_output_tokens].freeze
|
|
4
4
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
2
|
|
|
3
|
-
class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration<%=
|
|
3
|
+
class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
4
|
disable_ddl_transaction!
|
|
5
5
|
|
|
6
6
|
CALLS = :llm_cost_tracker_calls
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
2
|
|
|
3
|
-
class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration<%=
|
|
3
|
+
class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
4
|
disable_ddl_transaction!
|
|
5
5
|
|
|
6
6
|
TABLE = :llm_cost_tracker_call_tags
|
|
@@ -10,8 +10,9 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "Adds
|
|
14
|
-
"
|
|
13
|
+
desc "Adds the unpriced-calls index (partial on PostgreSQL, so backfill_unknown_pricing stops scanning " \
|
|
14
|
+
"the whole ledger; a plain id index on MySQL, which has no partial indexes) and drops the unused " \
|
|
15
|
+
"ingestion inbox lock index."
|
|
15
16
|
|
|
16
17
|
def create_migration_file
|
|
17
18
|
migration_template(
|
|
@@ -19,12 +20,6 @@ module LlmCostTracker
|
|
|
19
20
|
"db/migrate/upgrade_llm_cost_tracker_indexes.rb"
|
|
20
21
|
)
|
|
21
22
|
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
def migration_version
|
|
26
|
-
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
27
|
-
end
|
|
28
23
|
end
|
|
29
24
|
end
|
|
30
25
|
end
|
|
@@ -10,8 +10,9 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "
|
|
14
|
-
"
|
|
13
|
+
desc "Adds cost and time columns to llm_cost_tracker_call_tags so per-tag budgets read it " \
|
|
14
|
+
"without a join; run llm_cost_tracker:backfill_tag_costs afterwards so earlier calls count. " \
|
|
15
|
+
"Required for config.budgets.per_tag on installs created before v0.14."
|
|
15
16
|
|
|
16
17
|
def create_migration_file
|
|
17
18
|
migration_template(
|
|
@@ -19,12 +20,6 @@ module LlmCostTracker
|
|
|
19
20
|
"db/migrate/upgrade_llm_cost_tracker_per_tag_budgets.rb"
|
|
20
21
|
)
|
|
21
22
|
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
def migration_version
|
|
26
|
-
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
27
|
-
end
|
|
28
23
|
end
|
|
29
24
|
end
|
|
30
25
|
end
|
|
@@ -18,7 +18,7 @@ module LlmCostTracker
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def event_from_row(row)
|
|
21
|
-
payload = JSON.parse(row.payload, symbolize_names: true)
|
|
21
|
+
payload = JSON.parse(row.payload, symbolize_names: true, allow_duplicate_key: true)
|
|
22
22
|
schema_version = payload[:schema_version]
|
|
23
23
|
unless schema_version == PAYLOAD_SCHEMA_VERSION
|
|
24
24
|
raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
|
|
@@ -69,13 +69,7 @@ module LlmCostTracker
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def payload_for(event)
|
|
72
|
-
event.to_h.merge(
|
|
73
|
-
schema_version: PAYLOAD_SCHEMA_VERSION,
|
|
74
|
-
event_id: event.event_id,
|
|
75
|
-
provider: event.provider,
|
|
76
|
-
model: event.model,
|
|
77
|
-
tracked_at: event.tracked_at.iso8601(6)
|
|
78
|
-
)
|
|
72
|
+
event.to_h.merge(schema_version: PAYLOAD_SCHEMA_VERSION, tracked_at: event.tracked_at.iso8601(6))
|
|
79
73
|
end
|
|
80
74
|
|
|
81
75
|
def insert_row(row)
|
|
@@ -28,6 +28,8 @@ module LlmCostTracker
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
guards_for_current_config.each do |schema_module, table_name|
|
|
31
|
+
next if schema_module == Ledger::Schema::CallRollups && !Ledger::Rollups.cache_active?
|
|
32
|
+
|
|
31
33
|
errors = schema_module.current_schema_errors
|
|
32
34
|
next if errors.empty?
|
|
33
35
|
|
|
@@ -124,7 +126,7 @@ module LlmCostTracker
|
|
|
124
126
|
|
|
125
127
|
def cleanup_verification_call(response_id)
|
|
126
128
|
relation = LlmCostTracker::Call.where(provider_response_id: response_id)
|
|
127
|
-
records = relation.select(:id,
|
|
129
|
+
records = relation.select(:id, *LlmCostTracker::Ledger::Rollups::SOURCE_COLUMNS).to_a
|
|
128
130
|
return if records.empty?
|
|
129
131
|
|
|
130
132
|
relation.delete_all
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
-
require_relative "../providers/anthropic/usage_extractor"
|
|
5
4
|
require_relative "../providers/anthropic/response_parser"
|
|
6
5
|
|
|
7
6
|
module LlmCostTracker
|
|
@@ -17,7 +16,8 @@ module LlmCostTracker
|
|
|
17
16
|
patch_target("Anthropic::Resources::Messages", with: MessagesPatch),
|
|
18
17
|
patch_target("Anthropic::Resources::Beta::Messages", with: MessagesPatch, optional: true),
|
|
19
18
|
patch_target("Anthropic::Resources::Messages::Batches", with: BatchesPatch, optional: true),
|
|
20
|
-
patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true)
|
|
19
|
+
patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true),
|
|
20
|
+
patch_target("Anthropic::BetaRefusalFallbackMiddleware", with: FallbackMiddlewarePatch, optional: true)
|
|
21
21
|
]
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -37,7 +37,8 @@ module LlmCostTracker
|
|
|
37
37
|
model: message.model || request[:model],
|
|
38
38
|
provider_response_id: message.id,
|
|
39
39
|
usage_source: Usage::Source::SDK_RESPONSE,
|
|
40
|
-
request: request
|
|
40
|
+
request: request,
|
|
41
|
+
**response_fields(message)
|
|
41
42
|
),
|
|
42
43
|
latency_ms: latency_ms
|
|
43
44
|
)
|
|
@@ -61,21 +62,52 @@ module LlmCostTracker
|
|
|
61
62
|
next unless usage
|
|
62
63
|
next if usage.input_tokens.nil? && usage.output_tokens.nil?
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
usage: usage_hash,
|
|
65
|
+
record_once(
|
|
66
|
+
Providers::Anthropic::ResponseParser.event_from_usage(
|
|
67
|
+
usage: usage.deep_to_h.merge(service_tier: "batch"),
|
|
68
68
|
model: message.model,
|
|
69
69
|
provider_response_id: message.id,
|
|
70
70
|
usage_source: Usage::Source::SDK_BATCH_RESULT,
|
|
71
|
-
|
|
71
|
+
**response_fields(message)
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def record_refused_hop(request, response)
|
|
78
|
+
record_safely { record_message(response.parse, request: request.body, latency_ms: nil) }
|
|
79
|
+
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def record_refused_stream_hop(hop)
|
|
84
|
+
return unless active?
|
|
85
|
+
|
|
86
|
+
usage = hop.dig(:refused, :usage).to_h.deep_symbolize_keys
|
|
87
|
+
output = usage[:output_tokens].to_i.positive?
|
|
88
|
+
usage = usage.slice(:server_tool_use) if output
|
|
89
|
+
return if output && Providers::Anthropic::UsageExtractor.service_line_items(usage).empty?
|
|
90
|
+
|
|
91
|
+
record_safely do
|
|
92
|
+
LlmCostTracker::Tracker.record(
|
|
93
|
+
event: Providers::Anthropic::ResponseParser.event_from_usage(
|
|
94
|
+
usage: usage,
|
|
95
|
+
model: hop[:model],
|
|
96
|
+
provider_response_id: nil,
|
|
97
|
+
usage_source: Usage::Source::STREAM_FINAL,
|
|
98
|
+
stream: true,
|
|
99
|
+
stop_reason: ("refusal" unless output),
|
|
100
|
+
refusal_category: hop.dig(:refused, :stop_details, "category")
|
|
72
101
|
)
|
|
73
102
|
)
|
|
74
103
|
end
|
|
104
|
+
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
|
|
105
|
+
nil
|
|
75
106
|
end
|
|
76
107
|
|
|
77
|
-
def
|
|
78
|
-
|
|
108
|
+
def response_fields(message)
|
|
109
|
+
{ stop_reason: message.stop_reason, refusal_category: message.stop_details&.category,
|
|
110
|
+
content: message.deep_to_h[:content] }
|
|
79
111
|
end
|
|
80
112
|
end
|
|
81
113
|
|
|
@@ -109,6 +141,27 @@ module LlmCostTracker
|
|
|
109
141
|
end
|
|
110
142
|
end
|
|
111
143
|
|
|
144
|
+
module FallbackMiddlewarePatch
|
|
145
|
+
def call(req, nxt)
|
|
146
|
+
return super if req.streaming? || !LlmCostTracker::Integrations::Anthropic.active?
|
|
147
|
+
|
|
148
|
+
hops = []
|
|
149
|
+
response = super(req, ->(hop_req) { nxt.call(hop_req).tap { |hop| hops << [hop_req, hop] } })
|
|
150
|
+
answered = hops.select { |_hop_req, hop| hop.status < 300 }
|
|
151
|
+
answered[...-1].each { |hop| LlmCostTracker::Integrations::Anthropic.record_refused_hop(*hop) }
|
|
152
|
+
response
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def consume_hop(*args, **kwargs)
|
|
158
|
+
refused = Thread.current[:llm_cost_tracker_refused_hop]
|
|
159
|
+
Thread.current[:llm_cost_tracker_refused_hop] = nil
|
|
160
|
+
LlmCostTracker::Integrations::Anthropic.record_refused_stream_hop(refused) if refused && kwargs[:splice]
|
|
161
|
+
super.tap { |hop| Thread.current[:llm_cost_tracker_refused_hop] = hop if hop[:refused] }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
112
165
|
module BatchesPatch
|
|
113
166
|
def results_streaming(*args, **kwargs)
|
|
114
167
|
raw = super
|