llm_cost_tracker 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -16
- data/README.md +17 -29
- data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
- data/app/models/llm_cost_tracker/call.rb +10 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
- data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
- data/lib/llm_cost_tracker/budget.rb +120 -32
- data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
- data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
- data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
- data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
- data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
- data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
- data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
- data/lib/llm_cost_tracker/configuration/section.rb +59 -0
- data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
- data/lib/llm_cost_tracker/configuration.rb +69 -125
- data/lib/llm_cost_tracker/deprecator.rb +9 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
- data/lib/llm_cost_tracker/doctor.rb +4 -4
- data/lib/llm_cost_tracker/engine.rb +4 -0
- data/lib/llm_cost_tracker/errors.rb +25 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
- data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
- data/lib/llm_cost_tracker/ingestion.rb +3 -7
- data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
- data/lib/llm_cost_tracker/integrations/base.rb +17 -1
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
- data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +21 -13
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +5 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
- data/lib/llm_cost_tracker/parsers.rb +5 -1
- data/lib/llm_cost_tracker/prices.json +2089 -372
- data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
- data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
- data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
- data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
- data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
- data/lib/llm_cost_tracker/railtie.rb +3 -7
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/report/data.rb +2 -2
- data/lib/llm_cost_tracker/retention.rb +22 -10
- data/lib/llm_cost_tracker/tags/context.rb +3 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
- data/lib/llm_cost_tracker/tracker.rb +16 -26
- data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +10 -3
- data/lib/tasks/llm_cost_tracker.rake +29 -14
- metadata +37 -12
|
@@ -128,10 +128,16 @@ module LlmCostTracker
|
|
|
128
128
|
def each(&block)
|
|
129
129
|
return enum_for(:each) unless block
|
|
130
130
|
|
|
131
|
+
deferred = nil
|
|
131
132
|
@raw_stream.each do |response|
|
|
132
|
-
|
|
133
|
+
begin
|
|
134
|
+
LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
|
|
135
|
+
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
|
|
136
|
+
deferred ||= e
|
|
137
|
+
end
|
|
133
138
|
block.call(response)
|
|
134
139
|
end
|
|
140
|
+
raise deferred if deferred
|
|
135
141
|
end
|
|
136
142
|
|
|
137
143
|
def respond_to_missing?(name, include_private = false)
|
|
@@ -27,6 +27,7 @@ module LlmCostTracker
|
|
|
27
27
|
|
|
28
28
|
def install
|
|
29
29
|
validate_contract!
|
|
30
|
+
Logging.warn(untested_version_message) if untested_version?
|
|
30
31
|
patch_targets.each do |target|
|
|
31
32
|
target_class = target.fetch(:constant_name).to_s.safe_constantize
|
|
32
33
|
install_patch(target_class, target.fetch(:patch)) if target_class
|
|
@@ -39,6 +40,7 @@ module LlmCostTracker
|
|
|
39
40
|
if problems.any?
|
|
40
41
|
return Check.new(:warn, name, "#{name} integration cannot be installed: #{problems.join('; ')}")
|
|
41
42
|
end
|
|
43
|
+
return Check.new(:warn, name, untested_version_message) if untested_version?
|
|
42
44
|
|
|
43
45
|
installed = patch_targets.reject { |target| target.fetch(:optional) }.all? do |target|
|
|
44
46
|
target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
|
|
@@ -60,7 +62,7 @@ module LlmCostTracker
|
|
|
60
62
|
|
|
61
63
|
def record_safely
|
|
62
64
|
yield
|
|
63
|
-
rescue LlmCostTracker::
|
|
65
|
+
rescue *LlmCostTracker::CALLER_ERRORS
|
|
64
66
|
raise
|
|
65
67
|
rescue StandardError => e
|
|
66
68
|
Logging.warn("#{integration_name} integration failed to record usage: #{e.class}: #{e.message}")
|
|
@@ -124,6 +126,11 @@ module LlmCostTracker
|
|
|
124
126
|
@minimum_version
|
|
125
127
|
end
|
|
126
128
|
|
|
129
|
+
def maximum_version(value = nil)
|
|
130
|
+
@maximum_version = value if value
|
|
131
|
+
@maximum_version
|
|
132
|
+
end
|
|
133
|
+
|
|
127
134
|
def gem_version
|
|
128
135
|
Gem.loaded_specs[integration_name.to_s]&.version
|
|
129
136
|
end
|
|
@@ -160,6 +167,15 @@ module LlmCostTracker
|
|
|
160
167
|
["#{name} >= #{minimum_version} is required, detected #{version}"]
|
|
161
168
|
end
|
|
162
169
|
|
|
170
|
+
def untested_version?
|
|
171
|
+
maximum_version && gem_version && gem_version >= Gem::Version.new(maximum_version)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def untested_version_message
|
|
175
|
+
"#{integration_name} #{gem_version} is newer than the tested range (< #{maximum_version}); " \
|
|
176
|
+
"its calls may not be recorded"
|
|
177
|
+
end
|
|
178
|
+
|
|
163
179
|
def target_problems
|
|
164
180
|
patch_targets.flat_map do |target|
|
|
165
181
|
constant_name = target.fetch(:constant_name)
|
|
@@ -13,36 +13,36 @@ 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)
|
|
28
|
-
capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
|
|
23
|
+
io = client.files.content(batch.output_file_id)
|
|
24
|
+
deferred = capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
|
|
25
|
+
mark_captured(batch.id)
|
|
26
|
+
raise deferred if deferred
|
|
29
27
|
end
|
|
30
28
|
end
|
|
31
29
|
|
|
32
30
|
private
|
|
33
31
|
|
|
34
|
-
def
|
|
32
|
+
def captured?(batch_id)
|
|
33
|
+
MUTEX.synchronize { @dedup&.include?(batch_id) || false }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def mark_captured(batch_id)
|
|
35
37
|
MUTEX.synchronize do
|
|
36
38
|
@dedup ||= Set.new
|
|
37
|
-
next false if @dedup.include?(batch_id)
|
|
38
|
-
|
|
39
39
|
@dedup.clear if @dedup.size >= DEDUP_LIMIT
|
|
40
40
|
@dedup.add(batch_id)
|
|
41
|
-
true
|
|
42
41
|
end
|
|
43
42
|
end
|
|
44
43
|
|
|
45
44
|
def capture_jsonl(jsonl, host:)
|
|
45
|
+
deferred = nil
|
|
46
46
|
jsonl.each_line do |line|
|
|
47
47
|
line = line.strip
|
|
48
48
|
next if line.empty?
|
|
@@ -54,7 +54,10 @@ module LlmCostTracker
|
|
|
54
54
|
next unless response.is_a?(Hash) && response["usage"]
|
|
55
55
|
|
|
56
56
|
record_result(response, host: host)
|
|
57
|
+
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
|
|
58
|
+
deferred ||= e
|
|
57
59
|
end
|
|
60
|
+
deferred
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def parse_line(line)
|
|
@@ -8,10 +8,14 @@ module LlmCostTracker
|
|
|
8
8
|
extend Base
|
|
9
9
|
|
|
10
10
|
minimum_version "1.15.0"
|
|
11
|
+
maximum_version "3.0.0"
|
|
11
12
|
|
|
12
13
|
class << self
|
|
13
14
|
def patch_targets
|
|
14
|
-
[
|
|
15
|
+
[
|
|
16
|
+
patch_target("RubyLLM::Provider", with: ProviderPatch),
|
|
17
|
+
patch_target("RubyLLM::Providers::Gemini::Transcription", with: GeminiTranscriptionPatch, optional: true)
|
|
18
|
+
]
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
def record_completion(provider, response, request:, latency_ms:, has_block:)
|
|
@@ -36,25 +40,29 @@ module LlmCostTracker
|
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
def record_transcription(provider, response, request:, latency_ms:)
|
|
43
|
+
model = response_model_id(response) || model_id_from_request(request[:model])
|
|
44
|
+
match = LlmCostTracker::Pricing::Matcher.lookup(provider: provider.slug.to_s, model: model)
|
|
39
45
|
record_usage(
|
|
40
46
|
provider: provider.slug.to_s,
|
|
41
|
-
model:
|
|
47
|
+
model: model,
|
|
42
48
|
response: response,
|
|
43
49
|
latency_ms: latency_ms,
|
|
44
|
-
stream: false
|
|
50
|
+
stream: false,
|
|
51
|
+
audio_input: match&.prices&.key?("audio_input")
|
|
45
52
|
)
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
def record_image(provider, response, request:, latency_ms:)
|
|
49
|
-
|
|
56
|
+
image = response.is_a?(Array) ? response.first : response
|
|
57
|
+
usage = image_usage(image)
|
|
50
58
|
raw_input = usage[:input_tokens].to_i
|
|
51
59
|
raw_output = usage[:output_tokens].to_i
|
|
52
60
|
image_input = image_token_detail(usage, :input)
|
|
53
61
|
image_output = image_token_detail(usage, :output)
|
|
54
62
|
record_passthrough(
|
|
55
63
|
provider: provider.slug.to_s,
|
|
56
|
-
model: response_model_id(
|
|
57
|
-
response:
|
|
64
|
+
model: response_model_id(image) || model_id_from_request(request[:model]),
|
|
65
|
+
response: image,
|
|
58
66
|
latency_ms: latency_ms,
|
|
59
67
|
input_tokens: [raw_input - image_input, 0].max,
|
|
60
68
|
image_input_tokens: image_input,
|
|
@@ -74,6 +82,12 @@ module LlmCostTracker
|
|
|
74
82
|
)
|
|
75
83
|
end
|
|
76
84
|
|
|
85
|
+
def image_usage(image)
|
|
86
|
+
usage = image.try(:usage)
|
|
87
|
+
usage = image.send(:raw_usage) if !usage.is_a?(Hash) && image.respond_to?(:raw_usage, true)
|
|
88
|
+
(usage.is_a?(Hash) ? usage : {}).with_indifferent_access
|
|
89
|
+
end
|
|
90
|
+
|
|
77
91
|
def image_token_detail(usage, direction)
|
|
78
92
|
container_key = direction == :input ? :input_tokens_details : :output_tokens_details
|
|
79
93
|
details = usage[container_key]
|
|
@@ -111,27 +125,29 @@ module LlmCostTracker
|
|
|
111
125
|
end
|
|
112
126
|
end
|
|
113
127
|
|
|
114
|
-
def record_usage(provider:, model:, response:, latency_ms:, stream:, output_tokens: nil)
|
|
128
|
+
def record_usage(provider:, model:, response:, latency_ms:, stream:, output_tokens: nil, audio_input: false)
|
|
115
129
|
return unless active?
|
|
116
130
|
|
|
117
131
|
record_safely do
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
counts = token_counts(response)
|
|
133
|
+
input_tokens = counts[:input]
|
|
134
|
+
output_tokens = counts[:output] if output_tokens.nil?
|
|
120
135
|
next if input_tokens.nil? && output_tokens.nil?
|
|
121
136
|
|
|
122
|
-
cache_write_5m, cache_write_1h =
|
|
137
|
+
cache_write_5m, cache_write_1h = cache_write_split(provider, response, counts[:cache_write])
|
|
123
138
|
LlmCostTracker::Tracker.record(
|
|
124
139
|
event: Event.build(
|
|
125
140
|
provider: provider,
|
|
126
141
|
model: model,
|
|
127
142
|
pricing_mode: pricing_mode_for(provider: provider, response: response),
|
|
128
143
|
token_usage: Usage::TokenUsage.build(
|
|
129
|
-
input_tokens: input_tokens.to_i,
|
|
144
|
+
input_tokens: audio_input ? 0 : input_tokens.to_i,
|
|
145
|
+
audio_input_tokens: audio_input ? input_tokens.to_i : 0,
|
|
130
146
|
output_tokens: output_tokens.to_i,
|
|
131
|
-
cache_read_input_tokens:
|
|
147
|
+
cache_read_input_tokens: counts[:cache_read].to_i,
|
|
132
148
|
cache_write_input_tokens: cache_write_5m,
|
|
133
149
|
cache_write_extended_input_tokens: cache_write_1h,
|
|
134
|
-
hidden_output_tokens:
|
|
150
|
+
hidden_output_tokens: counts[:thinking].to_i
|
|
135
151
|
),
|
|
136
152
|
stream: stream,
|
|
137
153
|
usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
|
|
@@ -142,11 +158,22 @@ module LlmCostTracker
|
|
|
142
158
|
end
|
|
143
159
|
end
|
|
144
160
|
|
|
145
|
-
def
|
|
146
|
-
|
|
161
|
+
def token_counts(response)
|
|
162
|
+
tokens = response.try(:tokens)
|
|
163
|
+
return { input: response.try(:input_tokens), output: response.try(:output_tokens) } unless tokens
|
|
164
|
+
|
|
165
|
+
{
|
|
166
|
+
input: tokens.input,
|
|
167
|
+
output: tokens.output,
|
|
168
|
+
cache_read: tokens.cache_read,
|
|
169
|
+
cache_write: tokens.cache_write,
|
|
170
|
+
thinking: tokens.thinking
|
|
171
|
+
}
|
|
172
|
+
end
|
|
147
173
|
|
|
148
|
-
|
|
149
|
-
|
|
174
|
+
def cache_write_split(provider, response, cache_write)
|
|
175
|
+
cache = raw_body(response).dig("usage", "cache_creation") if provider == "anthropic"
|
|
176
|
+
return [cache_write.to_i, 0] unless cache.is_a?(Hash)
|
|
150
177
|
|
|
151
178
|
[cache["ephemeral_5m_input_tokens"].to_i, cache["ephemeral_1h_input_tokens"].to_i]
|
|
152
179
|
end
|
|
@@ -164,7 +191,8 @@ module LlmCostTracker
|
|
|
164
191
|
end
|
|
165
192
|
|
|
166
193
|
def raw_body(response)
|
|
167
|
-
|
|
194
|
+
raw = response.try(:raw)
|
|
195
|
+
body = raw.respond_to?(:body) ? raw.body : raw
|
|
168
196
|
body.is_a?(Hash) ? body : {}
|
|
169
197
|
end
|
|
170
198
|
|
|
@@ -217,6 +245,13 @@ module LlmCostTracker
|
|
|
217
245
|
LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
|
|
218
246
|
end
|
|
219
247
|
end
|
|
248
|
+
|
|
249
|
+
module GeminiTranscriptionPatch
|
|
250
|
+
def transcribe(*args, **kwargs)
|
|
251
|
+
seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_transcription)
|
|
252
|
+
LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
|
|
253
|
+
end
|
|
254
|
+
end
|
|
220
255
|
end
|
|
221
256
|
end
|
|
222
257
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Isolation
|
|
8
|
+
class << self
|
|
9
|
+
def guard(model = LlmCostTracker::Call, &)
|
|
10
|
+
connection = model.connection
|
|
11
|
+
nested = connection.transaction_open?
|
|
12
|
+
return yield unless nested
|
|
13
|
+
|
|
14
|
+
model.transaction(requires_new: true, &)
|
|
15
|
+
rescue ActiveRecord::TransactionRollbackError => e
|
|
16
|
+
raise unless nested && connection.savepoint_errors_invalidate_transactions?
|
|
17
|
+
|
|
18
|
+
raise TransactionAbortedError, e
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def after_commit(&)
|
|
22
|
+
current = LlmCostTracker::Call.connection.current_transaction
|
|
23
|
+
return yield unless current.open? && current.joinable? && current.respond_to?(:after_commit)
|
|
24
|
+
|
|
25
|
+
current.after_commit(&)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal/util"
|
|
4
4
|
|
|
5
|
+
require_relative "../isolation"
|
|
5
6
|
require_relative "../period"
|
|
6
7
|
|
|
7
8
|
module LlmCostTracker
|
|
@@ -22,7 +23,7 @@ module LlmCostTracker
|
|
|
22
23
|
|
|
23
24
|
values = periods.to_h { |period| [period, BigDecimal("0")] }
|
|
24
25
|
period_by_name = periods.to_h { |period| [period.to_s, period] }
|
|
25
|
-
LlmCostTracker::Call.find_by_sql(union_sql).each do |row|
|
|
26
|
+
Isolation.guard { LlmCostTracker::Call.find_by_sql(union_sql) }.each do |row|
|
|
26
27
|
values[period_by_name.fetch(row.period_key)] = row.total_cost.to_d
|
|
27
28
|
end
|
|
28
29
|
values
|
|
@@ -45,7 +46,7 @@ module LlmCostTracker
|
|
|
45
46
|
|
|
46
47
|
def recorded_sql(period, start)
|
|
47
48
|
calls = "COALESCE(#{sum_sql(LlmCostTracker::Call.between(start, time))}, 0)"
|
|
48
|
-
return calls unless
|
|
49
|
+
return calls unless Rollups.cache_active?
|
|
49
50
|
|
|
50
51
|
rollup = "COALESCE(#{sum_sql(rollup_scope(period))}, 0)"
|
|
51
52
|
"GREATEST(#{rollup}, #{calls})"
|
|
@@ -2,13 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal/util"
|
|
4
4
|
|
|
5
|
+
require_relative "isolation"
|
|
5
6
|
require_relative "period"
|
|
6
7
|
|
|
7
8
|
module LlmCostTracker
|
|
8
9
|
module Ledger
|
|
9
10
|
module Rollups
|
|
10
11
|
class << self
|
|
12
|
+
def cache_active?
|
|
13
|
+
return false unless LlmCostTracker.configuration.budgets.totals_source == :cache
|
|
14
|
+
return true if LlmCostTracker::CallRollup.table_exists?
|
|
15
|
+
|
|
16
|
+
warn_missing_table
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
|
|
11
20
|
def increment!(events)
|
|
21
|
+
return unless cache_active?
|
|
22
|
+
|
|
12
23
|
events = Array(events).select(&:total_cost)
|
|
13
24
|
return if events.empty?
|
|
14
25
|
|
|
@@ -18,29 +29,45 @@ module LlmCostTracker
|
|
|
18
29
|
ROLLUP_INCREMENT_ATTEMPTS = 3
|
|
19
30
|
ROLLUP_INCREMENT_BASE_DELAY_SECONDS = 0.05
|
|
20
31
|
REBUILD_INSERT_SLICE = 1_000
|
|
21
|
-
private_constant :ROLLUP_INCREMENT_ATTEMPTS,
|
|
32
|
+
private_constant :ROLLUP_INCREMENT_ATTEMPTS,
|
|
33
|
+
:ROLLUP_INCREMENT_BASE_DELAY_SECONDS,
|
|
34
|
+
:REBUILD_INSERT_SLICE
|
|
22
35
|
|
|
23
36
|
def increment_safely!(events)
|
|
37
|
+
return unless LlmCostTracker.configuration.budgets.totals_source == :cache
|
|
38
|
+
|
|
39
|
+
Isolation.after_commit { increment_with_retries(events) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def increment_with_retries(events)
|
|
43
|
+
retryable = !LlmCostTracker::Call.connection.transaction_open?
|
|
24
44
|
attempt = 0
|
|
25
45
|
begin
|
|
26
46
|
attempt += 1
|
|
27
|
-
increment!(events)
|
|
47
|
+
Isolation.guard { increment!(events) }
|
|
48
|
+
rescue LlmCostTracker::Error
|
|
49
|
+
raise
|
|
28
50
|
rescue StandardError => e
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if attempt < ROLLUP_INCREMENT_ATTEMPTS
|
|
51
|
+
if retryable && attempt < ROLLUP_INCREMENT_ATTEMPTS && rolled_back?(e)
|
|
32
52
|
sleep(ROLLUP_INCREMENT_BASE_DELAY_SECONDS * (2**(attempt - 1)))
|
|
33
53
|
retry
|
|
34
54
|
end
|
|
35
55
|
|
|
36
56
|
LlmCostTracker::Logging.warn(
|
|
37
|
-
"Rollup increment failed for #{events.size} events after #{attempt}
|
|
38
|
-
"#{e.class}: #{e.message}"
|
|
57
|
+
"Rollup increment failed for #{events.size} events after #{attempt} attempt(s): " \
|
|
58
|
+
"#{e.class}: #{e.message}. Budget reads fall back to the calls ledger; " \
|
|
59
|
+
"run bin/rails llm_cost_tracker:rebuild_rollups to resync the cache."
|
|
39
60
|
)
|
|
40
61
|
end
|
|
41
62
|
end
|
|
42
63
|
|
|
64
|
+
def rolled_back?(error)
|
|
65
|
+
error.is_a?(ActiveRecord::Deadlocked) || error.is_a?(ActiveRecord::LockWaitTimeout)
|
|
66
|
+
end
|
|
67
|
+
|
|
43
68
|
def decrement!(records)
|
|
69
|
+
return unless cache_active?
|
|
70
|
+
|
|
44
71
|
buckets = bucket_totals(records)
|
|
45
72
|
return if buckets.empty?
|
|
46
73
|
|
|
@@ -60,6 +87,17 @@ module LlmCostTracker
|
|
|
60
87
|
|
|
61
88
|
private
|
|
62
89
|
|
|
90
|
+
def warn_missing_table
|
|
91
|
+
return if @missing_table_warned
|
|
92
|
+
|
|
93
|
+
@missing_table_warned = true
|
|
94
|
+
Logging.warn(
|
|
95
|
+
"config.budgets.totals_source = :cache is set but llm_cost_tracker_call_rollups is missing; " \
|
|
96
|
+
"budget and dashboard totals fall back to aggregating llm_cost_tracker_calls. " \
|
|
97
|
+
"Run the call_rollups generator and migrate, or set config.budgets.totals_source = :ledger."
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
63
101
|
def accumulate_priced_calls
|
|
64
102
|
accumulated = Hash.new { |totals, key| totals[key] = BigDecimal("0") }
|
|
65
103
|
priced_calls.in_batches do |batch|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Ledger
|
|
5
|
+
module Storable
|
|
6
|
+
def self.clean(value)
|
|
7
|
+
case value
|
|
8
|
+
when Hash then value.to_h { |key, nested| [clean(key), clean(nested)] }
|
|
9
|
+
when Array then value.map { |nested| clean(nested) }
|
|
10
|
+
when String then value.dup.force_encoding(Encoding::UTF_8).scrub.delete("\u0000")
|
|
11
|
+
else value
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -4,7 +4,9 @@ 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"
|
|
9
|
+
require_relative "storable"
|
|
8
10
|
|
|
9
11
|
module LlmCostTracker
|
|
10
12
|
module Ledger
|
|
@@ -15,18 +17,20 @@ module LlmCostTracker
|
|
|
15
17
|
return if events.empty?
|
|
16
18
|
|
|
17
19
|
persist_records(events)
|
|
18
|
-
Ledger::Rollups.increment_safely!(events)
|
|
20
|
+
Ledger::Rollups.increment_safely!(events)
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def persist_records(events)
|
|
22
24
|
events = Array(events)
|
|
23
25
|
return if events.empty?
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
Isolation.guard do
|
|
28
|
+
LlmCostTracker::Call.transaction do
|
|
29
|
+
rows = events.map { |event| attributes_for(event) }
|
|
30
|
+
call_ids = insert_calls_returning_ids(rows, events)
|
|
31
|
+
insert_line_items(events, call_ids)
|
|
32
|
+
insert_call_tags(events, call_ids)
|
|
33
|
+
end
|
|
30
34
|
end
|
|
31
35
|
end
|
|
32
36
|
|
|
@@ -61,9 +65,7 @@ module LlmCostTracker
|
|
|
61
65
|
pricing_snapshot: event.pricing_snapshot
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
attributes
|
|
65
|
-
.merge(event.token_usage.to_h)
|
|
66
|
-
.merge(total_cost: event.cost&.total)
|
|
68
|
+
Storable.clean(attributes.merge(event.token_usage.to_h).merge(total_cost: event.cost&.total))
|
|
67
69
|
end
|
|
68
70
|
|
|
69
71
|
def call_ids_for(events)
|
|
@@ -89,7 +91,7 @@ module LlmCostTracker
|
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
def line_item_attributes(call_id:, line_item:, position:)
|
|
92
|
-
|
|
94
|
+
Storable.clean(
|
|
93
95
|
llm_cost_tracker_call_id: call_id,
|
|
94
96
|
position: position,
|
|
95
97
|
kind: line_item.kind,
|
|
@@ -111,7 +113,7 @@ module LlmCostTracker
|
|
|
111
113
|
provider_item_id: line_item.provider_item_id,
|
|
112
114
|
details: stored_details(line_item.details),
|
|
113
115
|
created_at: Time.now.utc
|
|
114
|
-
|
|
116
|
+
)
|
|
115
117
|
end
|
|
116
118
|
|
|
117
119
|
def insert_call_tags(events, call_ids)
|
|
@@ -120,8 +122,8 @@ module LlmCostTracker
|
|
|
120
122
|
{
|
|
121
123
|
llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
|
|
122
124
|
key: key.to_s,
|
|
123
|
-
value: Tags::Encoding.encode(value)
|
|
124
|
-
}
|
|
125
|
+
value: Tags::Encoding.encode(Storable.clean(value))
|
|
126
|
+
}.merge(budget_columns_for(event))
|
|
125
127
|
end
|
|
126
128
|
end
|
|
127
129
|
return if rows.empty?
|
|
@@ -129,6 +131,12 @@ module LlmCostTracker
|
|
|
129
131
|
LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
|
|
130
132
|
end
|
|
131
133
|
|
|
134
|
+
def budget_columns_for(event)
|
|
135
|
+
return {} unless LlmCostTracker::Budget::PerTag.columns?
|
|
136
|
+
|
|
137
|
+
{ total_cost: event.cost&.total, tracked_at: event.tracked_at }
|
|
138
|
+
end
|
|
139
|
+
|
|
132
140
|
def stored_details(details)
|
|
133
141
|
(details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
|
|
134
142
|
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)
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "redaction"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Logging
|
|
5
7
|
class << self
|
|
6
|
-
def debug(message) =
|
|
8
|
+
def debug(message) = Rails.logger.debug(prefixed(message))
|
|
7
9
|
|
|
8
|
-
def warn(message) =
|
|
10
|
+
def warn(message) = Rails.logger.warn(prefixed(message))
|
|
9
11
|
|
|
10
12
|
private
|
|
11
13
|
|
|
12
|
-
def
|
|
13
|
-
Rails.logger.tagged(LlmCostTracker.name)
|
|
14
|
-
end
|
|
14
|
+
def prefixed(message) = "[#{LlmCostTracker.name}] #{Redaction.text(message)}"
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
end
|