llm_cost_tracker 0.14.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 +46 -0
- data/README.md +1 -1
- data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
- 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 +5 -0
- data/app/models/llm_cost_tracker/call.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- 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 +8 -4
- 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/doctor/price_check.rb +1 -0
- data/lib/llm_cost_tracker/errors.rb +13 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -6
- data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
- 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 +6 -1
- 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 +2 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +12 -4
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +12 -11
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +3 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +32 -43
- data/lib/llm_cost_tracker/parsers.rb +5 -1
- data/lib/llm_cost_tracker/prices.json +878 -139
- data/lib/llm_cost_tracker/pricing/registry.rb +12 -1
- 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 +30 -13
- 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/response_parser.rb +5 -1
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +10 -5
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +4 -0
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
- data/lib/llm_cost_tracker/tracker.rb +3 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +4 -2
- data/lib/tasks/llm_cost_tracker.rake +6 -4
- metadata +16 -4
|
@@ -11,52 +11,83 @@ module LlmCostTracker
|
|
|
11
11
|
|
|
12
12
|
class << self
|
|
13
13
|
def parse(body)
|
|
14
|
-
|
|
14
|
+
events = []
|
|
15
|
+
reader = Reader.new { |event| events << event }
|
|
16
|
+
reader << body
|
|
17
|
+
reader.finish
|
|
18
|
+
events
|
|
19
|
+
end
|
|
20
|
+
end
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
class Reader
|
|
23
|
+
def initialize(&on_event)
|
|
24
|
+
@on_event = on_event
|
|
25
|
+
@pending = String.new(encoding: Encoding::BINARY)
|
|
26
|
+
@mode = nil
|
|
27
|
+
@event_name = nil
|
|
28
|
+
@data_lines = []
|
|
29
|
+
@scan_pos = 0
|
|
30
|
+
@depth = 0
|
|
31
|
+
@in_string = false
|
|
32
|
+
@object_start = nil
|
|
33
|
+
end
|
|
17
34
|
|
|
18
|
-
|
|
35
|
+
def <<(chunk)
|
|
36
|
+
@pending << chunk.to_s.b
|
|
37
|
+
@mode ||= detect_mode
|
|
38
|
+
case @mode
|
|
39
|
+
when :sse then consume_lines
|
|
40
|
+
when :array then consume_array
|
|
41
|
+
end
|
|
19
42
|
end
|
|
20
43
|
|
|
21
|
-
|
|
44
|
+
def pending_bytesize
|
|
45
|
+
@pending.bytesize
|
|
46
|
+
end
|
|
22
47
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
current_event = nil
|
|
26
|
-
data_lines = []
|
|
48
|
+
def finish
|
|
49
|
+
return unless @mode == :sse
|
|
27
50
|
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
consume_line(@pending) unless @pending.empty?
|
|
52
|
+
@pending = String.new(encoding: Encoding::BINARY)
|
|
53
|
+
dispatch
|
|
54
|
+
end
|
|
30
55
|
|
|
31
|
-
|
|
32
|
-
events << finalize_event(current_event, data_lines) if data_lines.any?
|
|
33
|
-
current_event = nil
|
|
34
|
-
data_lines = []
|
|
35
|
-
next
|
|
36
|
-
end
|
|
56
|
+
private
|
|
37
57
|
|
|
38
|
-
|
|
58
|
+
def detect_mode
|
|
59
|
+
first = @pending[/\S/]
|
|
60
|
+
return nil unless first
|
|
39
61
|
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
first == "[" ? :array : :sse
|
|
63
|
+
end
|
|
42
64
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
65
|
+
def consume_lines
|
|
66
|
+
start = 0
|
|
67
|
+
while (newline = @pending.index("\n", start))
|
|
68
|
+
consume_line(@pending.byteslice(start, newline - start))
|
|
69
|
+
start = newline + 1
|
|
47
70
|
end
|
|
48
|
-
|
|
49
|
-
events << finalize_event(current_event, data_lines) if data_lines.any?
|
|
50
|
-
events.compact
|
|
71
|
+
@pending = @pending.byteslice(start..)
|
|
51
72
|
end
|
|
52
73
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
return
|
|
74
|
+
def consume_line(raw)
|
|
75
|
+
line = raw.chomp("\r").force_encoding(Encoding::UTF_8)
|
|
76
|
+
return dispatch if line.empty?
|
|
77
|
+
return if line.start_with?(":")
|
|
56
78
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
79
|
+
field, _, value = line.partition(":")
|
|
80
|
+
value = value[1..] if value.start_with?(" ")
|
|
81
|
+
case field
|
|
82
|
+
when "event" then @event_name = value
|
|
83
|
+
when "data" then @data_lines << value
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dispatch
|
|
88
|
+
emit(finalize_event(@event_name, @data_lines)) if @data_lines.any?
|
|
89
|
+
@event_name = nil
|
|
90
|
+
@data_lines = []
|
|
60
91
|
end
|
|
61
92
|
|
|
62
93
|
def finalize_event(event_name, data_lines)
|
|
@@ -73,6 +104,51 @@ module LlmCostTracker
|
|
|
73
104
|
rescue JSON::ParserError
|
|
74
105
|
payload
|
|
75
106
|
end
|
|
107
|
+
|
|
108
|
+
def consume_array
|
|
109
|
+
pos = @scan_pos
|
|
110
|
+
while (index = @pending.index(@in_string ? /["\\]/ : /[{}"]/, pos))
|
|
111
|
+
pos = index + 1
|
|
112
|
+
pos = advance_array(@pending.getbyte(index).chr, index, pos)
|
|
113
|
+
end
|
|
114
|
+
trim_array_buffer(pos)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def advance_array(char, index, pos)
|
|
118
|
+
if @in_string
|
|
119
|
+
return pos + 1 if char == "\\"
|
|
120
|
+
|
|
121
|
+
@in_string = false
|
|
122
|
+
elsif char == '"'
|
|
123
|
+
@in_string = true
|
|
124
|
+
elsif char == "{"
|
|
125
|
+
@object_start = index if @depth.zero?
|
|
126
|
+
@depth += 1
|
|
127
|
+
elsif @depth.positive?
|
|
128
|
+
@depth -= 1
|
|
129
|
+
emit_object(index) if @depth.zero?
|
|
130
|
+
end
|
|
131
|
+
pos
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def emit_object(end_index)
|
|
135
|
+
text = @pending.byteslice(@object_start, end_index - @object_start + 1)
|
|
136
|
+
@object_start = nil
|
|
137
|
+
emit({ event: nil, data: JSON.parse(text.force_encoding(Encoding::UTF_8)) })
|
|
138
|
+
rescue JSON::ParserError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def trim_array_buffer(pos)
|
|
143
|
+
keep_from = @object_start || [pos, @pending.bytesize].min
|
|
144
|
+
@pending = @pending.byteslice(keep_from..)
|
|
145
|
+
@scan_pos = pos - keep_from
|
|
146
|
+
@object_start &&= 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def emit(event)
|
|
150
|
+
@on_event.call(event) if event
|
|
151
|
+
end
|
|
76
152
|
end
|
|
77
153
|
end
|
|
78
154
|
end
|
|
@@ -4,7 +4,7 @@ require "active_support/core_ext/object/blank"
|
|
|
4
4
|
require "active_support/core_ext/object/deep_dup"
|
|
5
5
|
require "json"
|
|
6
6
|
|
|
7
|
-
require_relative "
|
|
7
|
+
require_relative "event_window"
|
|
8
8
|
require_relative "../timing"
|
|
9
9
|
|
|
10
10
|
module LlmCostTracker
|
|
@@ -34,9 +34,7 @@ module LlmCostTracker
|
|
|
34
34
|
@metadata = (metadata || {}).deep_dup
|
|
35
35
|
@context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
|
|
36
36
|
@request = request
|
|
37
|
-
@
|
|
38
|
-
@captured_bytes = 0
|
|
39
|
-
@overflowed = false
|
|
37
|
+
@window = EventWindow.new(notable: Parsers.find_for_provider(@provider)&.method(:retain_stream_event?))
|
|
40
38
|
@explicit_usage = nil
|
|
41
39
|
@started_at = LlmCostTracker::Timing.now_monotonic
|
|
42
40
|
@finished = false
|
|
@@ -61,7 +59,7 @@ module LlmCostTracker
|
|
|
61
59
|
def event(data, type: nil)
|
|
62
60
|
@mutex.synchronize do
|
|
63
61
|
ensure_open!
|
|
64
|
-
|
|
62
|
+
@window.push(data, type: type) unless data.nil?
|
|
65
63
|
end
|
|
66
64
|
end
|
|
67
65
|
|
|
@@ -91,6 +89,12 @@ module LlmCostTracker
|
|
|
91
89
|
return if snapshot.nil?
|
|
92
90
|
|
|
93
91
|
record_snapshot(snapshot, errored: errored)
|
|
92
|
+
rescue TransactionAbortedError
|
|
93
|
+
raise
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
raise unless errored
|
|
96
|
+
|
|
97
|
+
Logging.warn("Recording an errored stream raised #{e.class}: #{e.message}; kept the stream's own exception")
|
|
94
98
|
end
|
|
95
99
|
|
|
96
100
|
private
|
|
@@ -102,8 +106,8 @@ module LlmCostTracker
|
|
|
102
106
|
@recording = true
|
|
103
107
|
pricing_mode = Pricing::Mode.normalize(@pricing_mode)
|
|
104
108
|
{
|
|
105
|
-
events: @events
|
|
106
|
-
overflowed: @overflowed
|
|
109
|
+
events: @window.events,
|
|
110
|
+
overflowed: @window.overflowed?,
|
|
107
111
|
explicit_usage: @explicit_usage,
|
|
108
112
|
model: @model,
|
|
109
113
|
latency_ms: @latency_ms,
|
|
@@ -136,10 +140,16 @@ module LlmCostTracker
|
|
|
136
140
|
@mutex.synchronize do
|
|
137
141
|
@finished = save_succeeded
|
|
138
142
|
@recording = false
|
|
143
|
+
release_buffers if save_succeeded
|
|
139
144
|
end
|
|
140
145
|
end
|
|
141
146
|
end
|
|
142
147
|
|
|
148
|
+
def release_buffers
|
|
149
|
+
@window = EventWindow.new
|
|
150
|
+
@request = nil
|
|
151
|
+
end
|
|
152
|
+
|
|
143
153
|
def capture_dimensions
|
|
144
154
|
{
|
|
145
155
|
provider_project_id: @provider_project_id.to_s.strip.presence,
|
|
@@ -207,55 +217,6 @@ module LlmCostTracker
|
|
|
207
217
|
**snapshot.fetch(:capture_dimensions)
|
|
208
218
|
)
|
|
209
219
|
end
|
|
210
|
-
|
|
211
|
-
IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
|
|
212
|
-
private_constant :IGNORED_PAYLOAD_KEYS
|
|
213
|
-
|
|
214
|
-
HEAVY_STRING_BYTES = 8 * 1024
|
|
215
|
-
private_constant :HEAVY_STRING_BYTES
|
|
216
|
-
|
|
217
|
-
def capture_event(data, type:)
|
|
218
|
-
event = { event: type, data: strip_heavy_payload(data) }
|
|
219
|
-
size = approximate_bytesize(event)
|
|
220
|
-
if @captured_bytes + size <= Capture::SSE::LIMIT_BYTES
|
|
221
|
-
@events << event
|
|
222
|
-
@captured_bytes += size
|
|
223
|
-
else
|
|
224
|
-
@overflowed = true
|
|
225
|
-
end
|
|
226
|
-
rescue TypeError, SystemStackError
|
|
227
|
-
@overflowed = true
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def strip_heavy_payload(value)
|
|
231
|
-
case value
|
|
232
|
-
when Hash
|
|
233
|
-
value.each_with_object({}) do |(key, nested), out|
|
|
234
|
-
next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
|
|
235
|
-
|
|
236
|
-
out[key] = strip_heavy_payload(nested)
|
|
237
|
-
end
|
|
238
|
-
when Array
|
|
239
|
-
value.map { |nested| strip_heavy_payload(nested) }
|
|
240
|
-
when String
|
|
241
|
-
value.bytesize > HEAVY_STRING_BYTES ? "" : value
|
|
242
|
-
else
|
|
243
|
-
value
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
def approximate_bytesize(value)
|
|
248
|
-
case value
|
|
249
|
-
when Hash
|
|
250
|
-
value.sum { |key, nested| approximate_bytesize(key) + approximate_bytesize(nested) + 4 }
|
|
251
|
-
when Array
|
|
252
|
-
value.sum { |nested| approximate_bytesize(nested) + 2 }
|
|
253
|
-
when Numeric, true, false, nil
|
|
254
|
-
8
|
|
255
|
-
else
|
|
256
|
-
value.to_s.bytesize + 2
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
220
|
end
|
|
260
221
|
end
|
|
261
222
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "event_window"
|
|
4
|
+
require_relative "sse"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Capture
|
|
8
|
+
class StreamTap
|
|
9
|
+
MAX_PENDING_BYTES = 16 * 1_048_576
|
|
10
|
+
|
|
11
|
+
def initialize(notable: nil)
|
|
12
|
+
@window = EventWindow.new(notable: notable)
|
|
13
|
+
@reader = SSE::Reader.new { |event| @window.push(event[:data], type: event[:event]) }
|
|
14
|
+
@received = false
|
|
15
|
+
@failed = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def <<(chunk)
|
|
19
|
+
return if @failed
|
|
20
|
+
|
|
21
|
+
@received = true
|
|
22
|
+
@reader << chunk
|
|
23
|
+
fail! if @reader.pending_bytesize > MAX_PENDING_BYTES
|
|
24
|
+
rescue StandardError
|
|
25
|
+
fail!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def received?
|
|
29
|
+
@received
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def failed?
|
|
33
|
+
@failed
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def overflowed?
|
|
37
|
+
@window.overflowed?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def events
|
|
41
|
+
@reader&.finish
|
|
42
|
+
@failed ? [] : @window.events
|
|
43
|
+
rescue StandardError
|
|
44
|
+
fail!
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def fail!
|
|
51
|
+
@failed = true
|
|
52
|
+
@reader = nil
|
|
53
|
+
@window = EventWindow.new
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -13,8 +13,7 @@ module LlmCostTracker
|
|
|
13
13
|
@collector = collector
|
|
14
14
|
@active = active
|
|
15
15
|
@finish = finish || proc { |errored| collector.finish!(errored: errored) }
|
|
16
|
-
@
|
|
17
|
-
@attempted_ref = [false]
|
|
16
|
+
@finished = false
|
|
18
17
|
@capture_failed = false
|
|
19
18
|
@mutex = Mutex.new
|
|
20
19
|
end
|
|
@@ -45,7 +44,6 @@ module LlmCostTracker
|
|
|
45
44
|
)
|
|
46
45
|
end
|
|
47
46
|
|
|
48
|
-
register_orphan_finalizer
|
|
49
47
|
@stream
|
|
50
48
|
rescue StandardError => e
|
|
51
49
|
Logging.warn("stream integration failed to install wrapper: #{e.class}: #{e.message}")
|
|
@@ -77,7 +75,7 @@ module LlmCostTracker
|
|
|
77
75
|
yield event
|
|
78
76
|
end
|
|
79
77
|
end
|
|
80
|
-
rescue
|
|
78
|
+
rescue Exception # rubocop:disable Lint/RescueException -- mark Interrupt/shutdown too, so it is not masked
|
|
81
79
|
errored = true
|
|
82
80
|
raise
|
|
83
81
|
ensure
|
|
@@ -107,10 +105,9 @@ module LlmCostTracker
|
|
|
107
105
|
|
|
108
106
|
def finish!(errored:)
|
|
109
107
|
should_finish = @mutex.synchronize do
|
|
110
|
-
|
|
111
|
-
next false if @finished_ref[0]
|
|
108
|
+
next false if @finished
|
|
112
109
|
|
|
113
|
-
@
|
|
110
|
+
@finished = true
|
|
114
111
|
true
|
|
115
112
|
end
|
|
116
113
|
return unless should_finish && @active.call
|
|
@@ -118,37 +115,10 @@ module LlmCostTracker
|
|
|
118
115
|
begin
|
|
119
116
|
@finish.call(errored)
|
|
120
117
|
rescue StandardError
|
|
121
|
-
@mutex.synchronize { @
|
|
118
|
+
@mutex.synchronize { @finished = false }
|
|
122
119
|
raise
|
|
123
120
|
end
|
|
124
121
|
end
|
|
125
|
-
|
|
126
|
-
def register_orphan_finalizer
|
|
127
|
-
finished_ref = @finished_ref
|
|
128
|
-
attempted_ref = @attempted_ref
|
|
129
|
-
finish_proc = @finish
|
|
130
|
-
active_proc = @active
|
|
131
|
-
mutex = @mutex
|
|
132
|
-
finalizer = lambda do |_object_id|
|
|
133
|
-
should_finish = mutex.synchronize do
|
|
134
|
-
next false if finished_ref[0] || attempted_ref[0]
|
|
135
|
-
|
|
136
|
-
finished_ref[0] = true
|
|
137
|
-
attempted_ref[0] = true
|
|
138
|
-
true
|
|
139
|
-
end
|
|
140
|
-
next unless should_finish && active_proc.call
|
|
141
|
-
|
|
142
|
-
begin
|
|
143
|
-
Rails.application.executor.wrap { finish_proc.call(false) }
|
|
144
|
-
rescue StandardError
|
|
145
|
-
nil
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
ObjectSpace.define_finalizer(@stream, finalizer)
|
|
149
|
-
rescue TypeError, ArgumentError
|
|
150
|
-
nil
|
|
151
|
-
end
|
|
152
122
|
end
|
|
153
123
|
end
|
|
154
124
|
end
|
|
@@ -13,6 +13,7 @@ module LlmCostTracker
|
|
|
13
13
|
def call
|
|
14
14
|
path = LlmCostTracker.configuration.pricing.file
|
|
15
15
|
return bundled_check unless path
|
|
16
|
+
return Check.new(:error, "prices", "#{path} does not exist; #{REFRESH_COMMAND}") unless File.exist?(path)
|
|
16
17
|
|
|
17
18
|
count = LlmCostTracker::Pricing::Registry.file_prices(path).size
|
|
18
19
|
metadata = LlmCostTracker::Pricing::Registry.file_metadata(path)
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "redaction"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
class Error < StandardError; end
|
|
5
7
|
|
|
6
8
|
class InvalidFilterError < Error; end
|
|
7
9
|
|
|
10
|
+
class TransactionAbortedError < Error
|
|
11
|
+
def initialize(error)
|
|
12
|
+
super(
|
|
13
|
+
"The database rolled back the whole surrounding transaction while recording LLM usage " \
|
|
14
|
+
"(#{error.class}: #{Redaction.text(error.message)}); the caller's transaction no longer exists"
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
8
19
|
class BudgetExceededError < Error
|
|
9
20
|
attr_reader :total, :budget, :budget_type, :last_event, :stage, :scope
|
|
10
21
|
|
|
@@ -40,4 +51,6 @@ module LlmCostTracker
|
|
|
40
51
|
super("No pricing configured for LLM model: #{model.inspect}")
|
|
41
52
|
end
|
|
42
53
|
end
|
|
54
|
+
|
|
55
|
+
CALLER_ERRORS = [BudgetExceededError, UnknownPricingError, TransactionAbortedError].freeze
|
|
43
56
|
end
|
|
@@ -34,8 +34,8 @@ LlmCostTracker.configure do |config|
|
|
|
34
34
|
# "my-custom-model" => { input: 1.00, output: 2.00 }
|
|
35
35
|
# }
|
|
36
36
|
# :warn (default) records token usage with nil cost when a model has no rate.
|
|
37
|
-
# Use :raise to
|
|
38
|
-
# component rate missing lands as partial and does not trigger it.
|
|
37
|
+
# Use :raise to record and then raise for models that have no rate at all; a model
|
|
38
|
+
# priced with one component rate missing lands as partial and does not trigger it.
|
|
39
39
|
config.pricing.unknown_model_behavior = :warn
|
|
40
40
|
|
|
41
41
|
# Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
|
|
@@ -63,9 +63,9 @@ LlmCostTracker.configure do |config|
|
|
|
63
63
|
# }
|
|
64
64
|
|
|
65
65
|
# Where budget checks read period spend from. :ledger sums llm_cost_tracker_calls on
|
|
66
|
-
# every check; :cache keeps running totals in llm_cost_tracker_call_rollups
|
|
67
|
-
#
|
|
68
|
-
# first with `bin/rails generate llm_cost_tracker:call_rollups`.
|
|
66
|
+
# every check; :cache also keeps running totals in llm_cost_tracker_call_rollups and
|
|
67
|
+
# takes the greater of the two, so it guards against drift but does not make checks
|
|
68
|
+
# faster. Generate the table first with `bin/rails generate llm_cost_tracker:call_rollups`.
|
|
69
69
|
# config.budgets.totals_source = :cache
|
|
70
70
|
|
|
71
71
|
# Ingestion path — :inline (default) writes events synchronously from the request
|
|
@@ -85,7 +85,7 @@ LlmCostTracker.configure do |config|
|
|
|
85
85
|
|
|
86
86
|
# Streaming calls only report token usage when the request asks for it. Leave this
|
|
87
87
|
# on and the Faraday middleware adds stream_options: { include_usage: true } to
|
|
88
|
-
#
|
|
88
|
+
# streaming chat-completions requests on hosts known to accept it.
|
|
89
89
|
# config.capture.request_stream_usage = false
|
|
90
90
|
|
|
91
91
|
end
|
|
@@ -13,7 +13,8 @@ module LlmCostTracker
|
|
|
13
13
|
ActiveRecord::Deadlocked,
|
|
14
14
|
ActiveRecord::LockWaitTimeout,
|
|
15
15
|
ActiveRecord::StatementTimeout,
|
|
16
|
-
ActiveRecord::ConnectionNotEstablished
|
|
16
|
+
ActiveRecord::ConnectionNotEstablished,
|
|
17
|
+
LlmCostTracker::TransactionAbortedError
|
|
17
18
|
].freeze
|
|
18
19
|
|
|
19
20
|
def initialize(identity:)
|
|
@@ -25,7 +26,7 @@ module LlmCostTracker
|
|
|
25
26
|
return 0 if rows.empty?
|
|
26
27
|
|
|
27
28
|
valid_rows, events = decode(rows)
|
|
28
|
-
|
|
29
|
+
persist_batch(valid_rows, events) if events.any?
|
|
29
30
|
rows.size
|
|
30
31
|
rescue StandardError => e
|
|
31
32
|
rows_to_mark = valid_rows&.any? ? valid_rows : rows
|
|
@@ -67,7 +68,7 @@ module LlmCostTracker
|
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def error_message_for(error)
|
|
70
|
-
"#{error.class}: #{error.message}".byteslice(0, 1_000)
|
|
71
|
+
"#{error.class}: #{Redaction.text(error.message)}".byteslice(0, 1_000).scrub("")
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
def warn_on_quarantine(rows)
|
|
@@ -75,12 +76,10 @@ module LlmCostTracker
|
|
|
75
76
|
quarantined = rows.select { |row| row.attempts.to_i + 1 >= threshold }
|
|
76
77
|
return if quarantined.empty?
|
|
77
78
|
|
|
78
|
-
sample = quarantined.first(10).map(&:id).join(", ")
|
|
79
|
-
sample += "..." if quarantined.size > 10
|
|
80
79
|
LlmCostTracker::Logging.warn(
|
|
81
80
|
"Ingestion::Batch: #{quarantined.size} inbox row(s) reached " \
|
|
82
81
|
"MAX_ATTEMPTS_BEFORE_QUARANTINE=#{threshold} and will be skipped " \
|
|
83
|
-
"on the next claim cycle (ids: #{
|
|
82
|
+
"on the next claim cycle (ids: #{id_sample(quarantined)})"
|
|
84
83
|
)
|
|
85
84
|
end
|
|
86
85
|
|
|
@@ -88,6 +87,11 @@ module LlmCostTracker
|
|
|
88
87
|
|
|
89
88
|
attr_reader :identity
|
|
90
89
|
|
|
90
|
+
def id_sample(rows)
|
|
91
|
+
sample = rows.first(10).map(&:id).join(", ")
|
|
92
|
+
rows.size > 10 ? "#{sample}..." : sample
|
|
93
|
+
end
|
|
94
|
+
|
|
91
95
|
def claim
|
|
92
96
|
now = Time.now.utc
|
|
93
97
|
cutoff = now - LOCK_TIMEOUT_SECONDS
|
|
@@ -117,13 +121,38 @@ module LlmCostTracker
|
|
|
117
121
|
[valid_rows, events]
|
|
118
122
|
end
|
|
119
123
|
|
|
124
|
+
def persist_batch(rows, events)
|
|
125
|
+
landed = []
|
|
126
|
+
failed = Hash.new { |hash, message| hash[message] = [] }
|
|
127
|
+
landed.concat(persist(rows, events))
|
|
128
|
+
rescue *TRANSIENT_PERSIST_ERRORS
|
|
129
|
+
raise
|
|
130
|
+
rescue StandardError
|
|
131
|
+
rows.zip(events) do |row, event|
|
|
132
|
+
landed.concat(persist([row], [event]))
|
|
133
|
+
rescue *TRANSIENT_PERSIST_ERRORS
|
|
134
|
+
raise
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
failed[error_message_for(e)] << row
|
|
137
|
+
end
|
|
138
|
+
ensure
|
|
139
|
+
failed.each do |message, failed_rows|
|
|
140
|
+
LlmCostTracker::Logging.warn(
|
|
141
|
+
"Ingestion::Batch: #{failed_rows.size} inbox row(s) could not be stored " \
|
|
142
|
+
"(ids: #{id_sample(failed_rows)}): #{message}"
|
|
143
|
+
)
|
|
144
|
+
mark_failed_with_message(failed_rows, message)
|
|
145
|
+
end
|
|
146
|
+
Ledger::Rollups.increment_safely!(landed)
|
|
147
|
+
Budget.notify_persisted_safely!(landed)
|
|
148
|
+
end
|
|
149
|
+
|
|
120
150
|
def persist(rows, events, retry_on_conflict: true)
|
|
121
151
|
LlmCostTracker::Call.transaction do
|
|
122
152
|
Ledger::Store.persist_records(events)
|
|
123
153
|
Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
|
|
124
154
|
end
|
|
125
|
-
|
|
126
|
-
Budget.notify_persisted_safely!(events)
|
|
155
|
+
events
|
|
127
156
|
rescue ActiveRecord::RecordNotUnique
|
|
128
157
|
raise unless retry_on_conflict
|
|
129
158
|
|
|
@@ -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)
|
|
@@ -21,8 +21,9 @@ module LlmCostTracker
|
|
|
21
21
|
host = Openai.client_host_for(resource)
|
|
22
22
|
Openai.record_safely do
|
|
23
23
|
io = client.files.content(batch.output_file_id)
|
|
24
|
-
capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
|
|
24
|
+
deferred = capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
|
|
25
25
|
mark_captured(batch.id)
|
|
26
|
+
raise deferred if deferred
|
|
26
27
|
end
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -41,6 +42,7 @@ module LlmCostTracker
|
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
def capture_jsonl(jsonl, host:)
|
|
45
|
+
deferred = nil
|
|
44
46
|
jsonl.each_line do |line|
|
|
45
47
|
line = line.strip
|
|
46
48
|
next if line.empty?
|
|
@@ -52,7 +54,10 @@ module LlmCostTracker
|
|
|
52
54
|
next unless response.is_a?(Hash) && response["usage"]
|
|
53
55
|
|
|
54
56
|
record_result(response, host: host)
|
|
57
|
+
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
|
|
58
|
+
deferred ||= e
|
|
55
59
|
end
|
|
60
|
+
deferred
|
|
56
61
|
end
|
|
57
62
|
|
|
58
63
|
def parse_line(line)
|