llm_cost_tracker 0.14.1 → 0.15.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 +198 -0
- data/LICENSE.txt +28 -0
- data/README.md +38 -35
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
- data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
- data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
- data/app/models/llm_cost_tracker/call.rb +15 -17
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- 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 +2 -5
- data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +22 -171
- data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
- data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
- data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- 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/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +42 -22
- data/lib/llm_cost_tracker/engine.rb +8 -1
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +28 -27
- 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 +5 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -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 +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -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/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +23 -36
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +98 -93
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
- data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
- data/lib/llm_cost_tracker/ledger/store.rb +19 -37
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
- 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/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
- data/lib/llm_cost_tracker/parsers.rb +19 -40
- data/lib/llm_cost_tracker/prices.json +3241 -187
- data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
- 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 +61 -30
- data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +45 -31
- 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 +85 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +58 -25
- data/llm_cost_tracker.gemspec +64 -0
- metadata +65 -11
|
@@ -37,19 +37,12 @@ module LlmCostTracker
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def report(checks = call, color: $stdout.tty?)
|
|
40
|
-
name_width = checks.map { |
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
lines << bold(section, color)
|
|
45
|
-
members.each do |check|
|
|
46
|
-
status = paint_status("[#{STATUS_GLYPHS.fetch(check.status, check.status)}]", check.status, color)
|
|
47
|
-
lines << " #{status} #{"#{check.name}:".ljust(name_width + 1)} #{check.message}"
|
|
48
|
-
end
|
|
49
|
-
lines << ""
|
|
40
|
+
name_width = checks.map { |check| check.name.length }.max.to_i
|
|
41
|
+
sections = checks.group_by { |check| SECTION_FOR_CHECK.fetch(check.name, "Setup") }.slice(*SECTIONS)
|
|
42
|
+
blocks = sections.map do |section, members|
|
|
43
|
+
[bold(section, color), *members.map { |check| check_line(check, name_width, color) }].join("\n")
|
|
50
44
|
end
|
|
51
|
-
|
|
52
|
-
lines.join("\n")
|
|
45
|
+
[bold("LLM Cost Tracker doctor", color), *blocks].join("\n\n")
|
|
53
46
|
end
|
|
54
47
|
|
|
55
48
|
def healthy?(checks = call)
|
|
@@ -58,13 +51,9 @@ module LlmCostTracker
|
|
|
58
51
|
|
|
59
52
|
private
|
|
60
53
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
next if members.empty?
|
|
65
|
-
|
|
66
|
-
yield section, members
|
|
67
|
-
end
|
|
54
|
+
def check_line(check, name_width, color)
|
|
55
|
+
status = paint_status("[#{STATUS_GLYPHS.fetch(check.status, check.status)}]", check.status, color)
|
|
56
|
+
" #{status} #{"#{check.name}:".ljust(name_width + 1)} #{check.message}"
|
|
68
57
|
end
|
|
69
58
|
|
|
70
59
|
def paint_status(text, status, color)
|
|
@@ -89,6 +78,7 @@ module LlmCostTracker
|
|
|
89
78
|
table_check,
|
|
90
79
|
column_check,
|
|
91
80
|
*dependent_core_schema_checks,
|
|
81
|
+
per_tag_budget_check,
|
|
92
82
|
call_rollups_check,
|
|
93
83
|
IngestionCheck.new.call,
|
|
94
84
|
PriceCheck.new.call,
|
|
@@ -106,6 +96,19 @@ module LlmCostTracker
|
|
|
106
96
|
end
|
|
107
97
|
end
|
|
108
98
|
|
|
99
|
+
def per_tag_budget_check
|
|
100
|
+
return if LlmCostTracker.configuration.budgets.per_tag.empty?
|
|
101
|
+
return unless Probe.table_exists?("llm_cost_tracker_call_tags")
|
|
102
|
+
return if Budget::PerTag.columns?
|
|
103
|
+
|
|
104
|
+
Check.new(
|
|
105
|
+
:error,
|
|
106
|
+
"per-tag budgets",
|
|
107
|
+
"budgets.per_tag is set but llm_cost_tracker_call_tags has no cost columns, so no per-tag budget is " \
|
|
108
|
+
"enforced; run bin/rails generate llm_cost_tracker:upgrade_per_tag_budgets and migrate"
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
109
112
|
def configuration_check
|
|
110
113
|
config = LlmCostTracker.configuration
|
|
111
114
|
Check.new(:ok, "configuration", "active_record ledger enabled=#{config.enabled}")
|
|
@@ -167,7 +170,7 @@ module LlmCostTracker
|
|
|
167
170
|
return live_rollups_check unless LlmCostTracker.configuration.budgets.totals_source == :cache
|
|
168
171
|
|
|
169
172
|
errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
|
|
170
|
-
return
|
|
173
|
+
return rollups_drift_check if errors.empty?
|
|
171
174
|
|
|
172
175
|
Check.new(
|
|
173
176
|
:error,
|
|
@@ -176,13 +179,30 @@ module LlmCostTracker
|
|
|
176
179
|
)
|
|
177
180
|
end
|
|
178
181
|
|
|
182
|
+
def rollups_drift_check
|
|
183
|
+
month_start = LlmCostTracker::Ledger::Period.range_start(:month, Time.now)
|
|
184
|
+
today = LlmCostTracker::Ledger::Period.range_start(:day, Time.now)
|
|
185
|
+
rolled = LlmCostTracker::CallRollup.where(period: "day", period_start: month_start.to_date...today.to_date)
|
|
186
|
+
.sum(:total_cost)
|
|
187
|
+
recorded = LlmCostTracker::Call.where(tracked_at: month_start...today).sum(:total_cost)
|
|
188
|
+
return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if rolled == recorded
|
|
189
|
+
|
|
190
|
+
Check.new(
|
|
191
|
+
:warn,
|
|
192
|
+
"call rollups",
|
|
193
|
+
"this month's day rollups before today total #{rolled.to_d.to_s('F')} but the calls total " \
|
|
194
|
+
"#{recorded.to_d.to_s('F')}, and monthly budgets read the rollups; " \
|
|
195
|
+
"run bin/rails llm_cost_tracker:rebuild_rollups"
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
179
199
|
def live_rollups_check
|
|
180
200
|
if Probe.table_exists?("llm_cost_tracker_call_rollups")
|
|
181
201
|
Check.new(
|
|
182
202
|
:warn,
|
|
183
203
|
"call rollups",
|
|
184
204
|
"budgets.totals_source=:ledger but llm_cost_tracker_call_rollups exists. " \
|
|
185
|
-
"Set config.budgets.totals_source = :cache to
|
|
205
|
+
"Set config.budgets.totals_source = :cache to use it or drop the table."
|
|
186
206
|
)
|
|
187
207
|
else
|
|
188
208
|
Check.new(
|
|
@@ -207,7 +227,7 @@ module LlmCostTracker
|
|
|
207
227
|
end
|
|
208
228
|
|
|
209
229
|
def active_record_available?
|
|
210
|
-
LlmCostTracker::Call.
|
|
230
|
+
LlmCostTracker::Call.lease_connection
|
|
211
231
|
true
|
|
212
232
|
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
|
|
213
233
|
false
|
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
require "rails"
|
|
4
4
|
require_relative "../llm_cost_tracker"
|
|
5
5
|
require_relative "assets"
|
|
6
|
-
require "rack/files"
|
|
7
6
|
|
|
8
7
|
module LlmCostTracker
|
|
9
8
|
class Engine < ::Rails::Engine
|
|
10
9
|
isolate_namespace LlmCostTracker
|
|
11
10
|
|
|
11
|
+
initializer "llm_cost_tracker.host_inflections" do |app|
|
|
12
|
+
Rails.autoloaders.each { |autoloader| autoloader.inflector.inflect("llm_cost_tracker" => "LlmCostTracker") }
|
|
13
|
+
app.reloader.to_prepare do
|
|
14
|
+
host_name = "llm_cost_tracker".camelize
|
|
15
|
+
Object.const_set(host_name, LlmCostTracker) unless Object.const_defined?(host_name)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
12
19
|
initializer "llm_cost_tracker.deprecator" do |app|
|
|
13
20
|
app.deprecators[:llm_cost_tracker] = LlmCostTracker.deprecator
|
|
14
21
|
end
|
|
@@ -27,10 +27,8 @@ module LlmCostTracker
|
|
|
27
27
|
@stage = stage
|
|
28
28
|
@scope = scope
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"$#{format('%.6f', @total)} / $#{format('%.6f', budget)}"
|
|
33
|
-
)
|
|
30
|
+
amounts = budget_type == :calls ? "#{total} / #{budget}" : format("$%<total>.6f / $%<budget>.6f", total:, budget:)
|
|
31
|
+
super("LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded#{scope_suffix}: #{amounts}")
|
|
34
32
|
end
|
|
35
33
|
|
|
36
34
|
private
|
|
@@ -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
|
|
@@ -22,43 +24,46 @@ module LlmCostTracker
|
|
|
22
24
|
:cost_status,
|
|
23
25
|
:pricing_snapshot,
|
|
24
26
|
:line_items
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
class Event
|
|
30
|
+
UNKNOWN_MODEL = "unknown"
|
|
31
|
+
PROVIDER_IDS = %i[provider_response_id provider_project_id provider_api_key_id provider_workspace_id].freeze
|
|
32
|
+
PASSTHROUGH_FIELDS = %i[event_id pricing_mode cost tags latency_ms tracked_at cost_status pricing_snapshot].freeze
|
|
33
|
+
private_constant :PROVIDER_IDS, :PASSTHROUGH_FIELDS
|
|
29
34
|
|
|
35
|
+
def self.build(**attributes)
|
|
30
36
|
new(
|
|
31
|
-
|
|
37
|
+
token_usage: attributes.fetch(:token_usage),
|
|
38
|
+
line_items: attributes[:line_items] || resolve_line_items(attributes[:service_line_items]),
|
|
32
39
|
provider: attributes.fetch(:provider).to_s,
|
|
33
|
-
model: attributes.fetch(:model)
|
|
34
|
-
token_usage: token_usage,
|
|
35
|
-
pricing_mode: attributes[:pricing_mode],
|
|
36
|
-
cost: attributes[:cost],
|
|
37
|
-
tags: attributes[:tags],
|
|
38
|
-
latency_ms: attributes[:latency_ms],
|
|
40
|
+
model: identifier(attributes.fetch(:model)) || UNKNOWN_MODEL,
|
|
39
41
|
stream: attributes[:stream] || false,
|
|
40
42
|
usage_source: attributes[:usage_source]&.to_s,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
|
|
44
|
-
provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
|
|
45
|
-
tracked_at: attributes[:tracked_at],
|
|
46
|
-
cost_status: attributes[:cost_status],
|
|
47
|
-
pricing_snapshot: attributes[:pricing_snapshot],
|
|
48
|
-
line_items: line_items
|
|
43
|
+
**PASSTHROUGH_FIELDS.to_h { |field| [field, attributes[field]] },
|
|
44
|
+
**PROVIDER_IDS.to_h { |field| [field, identifier(attributes[field])] }
|
|
49
45
|
)
|
|
50
46
|
end
|
|
51
47
|
|
|
52
|
-
def batch?
|
|
53
|
-
Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
|
|
54
|
-
end
|
|
55
|
-
|
|
56
48
|
def self.resolve_line_items(service_items)
|
|
57
49
|
Array(service_items).map do |item|
|
|
58
50
|
item.is_a?(Charges::LineItem) ? item : Charges::LineItem.build(item)
|
|
59
51
|
end
|
|
60
52
|
end
|
|
61
53
|
|
|
54
|
+
def self.identifier(value) = value.to_s.strip.presence
|
|
55
|
+
private_class_method :identifier
|
|
56
|
+
|
|
57
|
+
def batch?
|
|
58
|
+
Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def keyed_by_response_id
|
|
62
|
+
return self unless provider_response_id
|
|
63
|
+
|
|
64
|
+
with(event_id: Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, "#{provider}/#{provider_response_id}"))
|
|
65
|
+
end
|
|
66
|
+
|
|
62
67
|
def total_cost
|
|
63
68
|
cost&.total
|
|
64
69
|
end
|
|
@@ -72,8 +77,4 @@ module LlmCostTracker
|
|
|
72
77
|
)
|
|
73
78
|
end
|
|
74
79
|
end
|
|
75
|
-
|
|
76
|
-
class Event
|
|
77
|
-
UNKNOWN_MODEL = "unknown"
|
|
78
|
-
end
|
|
79
80
|
end
|
|
@@ -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
|
|
@@ -84,8 +84,10 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
84
84
|
add_index :llm_cost_tracker_calls, %i[model tracked_at]
|
|
85
85
|
add_index :llm_cost_tracker_calls, :cost_status
|
|
86
86
|
add_index :llm_cost_tracker_calls, :provider_response_id
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
if postgresql?
|
|
88
|
+
add_index :llm_cost_tracker_calls, :id, name: :index_llm_cost_tracker_calls_on_unpriced,
|
|
89
|
+
where: "total_cost IS NULL"
|
|
90
|
+
end
|
|
89
91
|
add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
|
|
90
92
|
add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
|
|
91
93
|
if postgresql?
|
|
@@ -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 = {
|
|
@@ -59,13 +60,14 @@ LlmCostTracker.configure do |config|
|
|
|
59
60
|
# user_id: { daily: 25.00, behavior: :notify }
|
|
60
61
|
# }
|
|
61
62
|
# config.budgets.on_exceeded = ->(data) {
|
|
62
|
-
#
|
|
63
|
+
# unit = data[:budget_type] == :calls ? "" : "$"
|
|
64
|
+
# Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: #{unit}#{data[:total]} / #{unit}#{data[:budget]}")
|
|
63
65
|
# }
|
|
64
66
|
|
|
65
67
|
# Where budget checks read period spend from. :ledger sums llm_cost_tracker_calls on
|
|
66
|
-
# every check; :cache
|
|
67
|
-
#
|
|
68
|
-
#
|
|
68
|
+
# every check; :cache reads earlier days from llm_cost_tracker_call_rollups and sums
|
|
69
|
+
# only today's calls. Run `bin/rails generate llm_cost_tracker:call_rollups`, deploy
|
|
70
|
+
# this setting, then run `bin/rails llm_cost_tracker:rebuild_rollups`.
|
|
69
71
|
# config.budgets.totals_source = :cache
|
|
70
72
|
|
|
71
73
|
# Ingestion path — :inline (default) writes events synchronously from the request
|
|
@@ -74,13 +76,15 @@ LlmCostTracker.configure do |config|
|
|
|
74
76
|
# inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
|
|
75
77
|
# Synchronous inbox writes use a dedicated ActiveRecord pool (defaults to 2 connections)
|
|
76
78
|
# so they don't compete with request threads for the default pool when a tracked call
|
|
77
|
-
# happens inside an open caller transaction.
|
|
78
|
-
#
|
|
79
|
+
# happens inside an open caller transaction. The pool is per process: bump ingestion.pool_size
|
|
80
|
+
# if concurrent tracked calls in one process (e.g. Puma threads) outgrow that.
|
|
79
81
|
# config.ingestion.mode = :async
|
|
80
82
|
# config.ingestion.pool_size = 5
|
|
81
83
|
|
|
82
|
-
# Register OpenAI-compatible gateway hosts
|
|
83
|
-
#
|
|
84
|
+
# Register OpenAI-compatible gateway hosts beyond the built-in OpenRouter, DeepSeek, Groq,
|
|
85
|
+
# xAI, Mistral and Perplexity; the Faraday middleware captures only listed hosts. Its calls,
|
|
86
|
+
# and openai gem calls whose base_url is a listed host, are recorded under the provider name
|
|
87
|
+
# given here.
|
|
84
88
|
# config.capture.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
|
|
85
89
|
|
|
86
90
|
# 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
|
|
@@ -27,6 +27,7 @@ class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migr
|
|
|
27
27
|
|
|
28
28
|
def add_unique_index(columns)
|
|
29
29
|
if postgresql?
|
|
30
|
+
LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, connection.index_name(TABLE, columns))
|
|
30
31
|
add_index TABLE, columns, unique: true, algorithm: :concurrently, if_not_exists: true
|
|
31
32
|
else
|
|
32
33
|
add_index TABLE, columns, unique: true, if_not_exists: true
|
|
@@ -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
|
|
@@ -8,6 +8,7 @@ class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= mi
|
|
|
8
8
|
|
|
9
9
|
def up
|
|
10
10
|
if postgresql?
|
|
11
|
+
LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, connection.index_name(TABLE, INDEX_COLUMNS))
|
|
11
12
|
add_index TABLE, INDEX_COLUMNS, algorithm: :concurrently, if_not_exists: true
|
|
12
13
|
elsif mysql?
|
|
13
14
|
add_index TABLE, INDEX_COLUMNS, length: { value: 191 }, if_not_exists: true
|
|
@@ -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
|
|
@@ -9,7 +9,8 @@ class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration<%= migration_versio
|
|
|
9
9
|
UNPRICED_INDEX = :index_llm_cost_tracker_calls_on_unpriced
|
|
10
10
|
|
|
11
11
|
def up
|
|
12
|
-
|
|
12
|
+
LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, UNPRICED_INDEX)
|
|
13
|
+
add_index CALLS, :id, name: UNPRICED_INDEX, where: "total_cost IS NULL", **add_index_options if postgresql?
|
|
13
14
|
return unless table_exists?(INBOX)
|
|
14
15
|
|
|
15
16
|
remove_index INBOX, column: UNUSED_INBOX_INDEX, **remove_index_options
|
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,6 +10,7 @@ class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration<%= migration_
|
|
|
10
10
|
def up
|
|
11
11
|
add_column TABLE, :total_cost, :decimal, precision: 20, scale: 8 unless column_exists?(TABLE, :total_cost)
|
|
12
12
|
add_column TABLE, :tracked_at, :datetime unless column_exists?(TABLE, :tracked_at)
|
|
13
|
+
LlmCostTracker::Ledger::Schema::Adapter.drop_invalid_index(connection, connection.index_name(TABLE, INDEX))
|
|
13
14
|
add_index TABLE, INDEX, **add_index_options
|
|
14
15
|
remove_index TABLE, column: REPLACED_INDEX, **remove_index_options
|
|
15
16
|
end
|
|
@@ -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
|
|
@@ -12,7 +12,7 @@ module LlmCostTracker
|
|
|
12
12
|
TRANSIENT_PERSIST_ERRORS = [
|
|
13
13
|
ActiveRecord::Deadlocked,
|
|
14
14
|
ActiveRecord::LockWaitTimeout,
|
|
15
|
-
ActiveRecord::
|
|
15
|
+
ActiveRecord::QueryAborted,
|
|
16
16
|
ActiveRecord::ConnectionNotEstablished,
|
|
17
17
|
LlmCostTracker::TransactionAbortedError
|
|
18
18
|
].freeze
|
|
@@ -29,20 +29,16 @@ module LlmCostTracker
|
|
|
29
29
|
persist_batch(valid_rows, events) if events.any?
|
|
30
30
|
rows.size
|
|
31
31
|
rescue StandardError => e
|
|
32
|
-
|
|
33
|
-
if rows_to_mark&.any?
|
|
34
|
-
transient = valid_rows&.any? && TRANSIENT_PERSIST_ERRORS.any? { |klass| e.is_a?(klass) }
|
|
35
|
-
mark_failed_with_message(rows_to_mark, error_message_for(e), decrement_attempts: transient)
|
|
36
|
-
end
|
|
32
|
+
release_after_failure(rows, valid_rows, e)
|
|
37
33
|
raise
|
|
38
34
|
end
|
|
39
35
|
|
|
40
36
|
def pending?
|
|
41
|
-
Ingestion::InboxEntry.pending.exists?
|
|
37
|
+
Ingestion::InboxEntry.uncached { Ingestion::InboxEntry.pending.exists? }
|
|
42
38
|
end
|
|
43
39
|
|
|
44
40
|
def claimable?
|
|
45
|
-
claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
|
|
41
|
+
Ingestion::InboxEntry.uncached { claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists? }
|
|
46
42
|
end
|
|
47
43
|
|
|
48
44
|
def mark_failed_with_message(rows, message, decrement_attempts: false)
|
|
@@ -67,6 +63,10 @@ module LlmCostTracker
|
|
|
67
63
|
nil
|
|
68
64
|
end
|
|
69
65
|
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
attr_reader :identity
|
|
69
|
+
|
|
70
70
|
def error_message_for(error)
|
|
71
71
|
"#{error.class}: #{Redaction.text(error.message)}".byteslice(0, 1_000).scrub("")
|
|
72
72
|
end
|
|
@@ -83,10 +83,6 @@ module LlmCostTracker
|
|
|
83
83
|
)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
private
|
|
87
|
-
|
|
88
|
-
attr_reader :identity
|
|
89
|
-
|
|
90
86
|
def id_sample(rows)
|
|
91
87
|
sample = rows.first(10).map(&:id).join(", ")
|
|
92
88
|
rows.size > 10 ? "#{sample}..." : sample
|
|
@@ -121,6 +117,18 @@ module LlmCostTracker
|
|
|
121
117
|
[valid_rows, events]
|
|
122
118
|
end
|
|
123
119
|
|
|
120
|
+
def release_after_failure(rows, valid_rows, error)
|
|
121
|
+
if valid_rows&.any?
|
|
122
|
+
mark_failed_with_message(valid_rows, error_message_for(error), decrement_attempts: transient?(error))
|
|
123
|
+
elsif rows&.any?
|
|
124
|
+
mark_failed_with_message(rows, error_message_for(error))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def transient?(error)
|
|
129
|
+
TRANSIENT_PERSIST_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
130
|
+
end
|
|
131
|
+
|
|
124
132
|
def persist_batch(rows, events)
|
|
125
133
|
landed = []
|
|
126
134
|
failed = Hash.new { |hash, message| hash[message] = [] }
|
|
@@ -128,14 +136,20 @@ module LlmCostTracker
|
|
|
128
136
|
rescue *TRANSIENT_PERSIST_ERRORS
|
|
129
137
|
raise
|
|
130
138
|
rescue StandardError
|
|
131
|
-
rows.zip(events)
|
|
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
|
|
139
|
+
rows.zip(events) { |row, event| persist_alone(row, event, landed, failed) }
|
|
138
140
|
ensure
|
|
141
|
+
settle(landed, failed)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def persist_alone(row, event, landed, failed)
|
|
145
|
+
landed.concat(persist([row], [event]))
|
|
146
|
+
rescue *TRANSIENT_PERSIST_ERRORS
|
|
147
|
+
raise
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
failed[error_message_for(e)] << row
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def settle(landed, failed)
|
|
139
153
|
failed.each do |message, failed_rows|
|
|
140
154
|
LlmCostTracker::Logging.warn(
|
|
141
155
|
"Ingestion::Batch: #{failed_rows.size} inbox row(s) could not be stored " \
|