llm_cost_tracker 0.14.2 → 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 +116 -0
- data/LICENSE.txt +28 -0
- data/README.md +33 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
- data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
- data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
- 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 +30 -0
- data/app/models/llm_cost_tracker/call.rb +6 -4
- 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 +66 -210
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
- 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 +3 -14
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- 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 +59 -27
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +19 -157
- data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
- data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
- data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
- 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/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +23 -20
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +22 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +20 -35
- 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 +59 -120
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +65 -81
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
- 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 -408
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
- data/lib/llm_cost_tracker/ledger/store.rb +17 -35
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -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 -301
- data/lib/llm_cost_tracker/parsers.rb +12 -0
- data/lib/llm_cost_tracker/prices.json +1179 -208
- data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
- 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 +82 -173
- data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
- data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
- 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_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 +7 -53
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
- 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 +2 -1
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -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/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +32 -26
- data/lib/llm_cost_tracker/usage/dimensions.yml +24 -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 +35 -5
- data/llm_cost_tracker.gemspec +6 -5
- metadata +58 -5
|
@@ -24,33 +24,36 @@ module LlmCostTracker
|
|
|
24
24
|
:cost_status,
|
|
25
25
|
:pricing_snapshot,
|
|
26
26
|
:line_items
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
31
34
|
|
|
35
|
+
def self.build(**attributes)
|
|
32
36
|
new(
|
|
33
|
-
|
|
37
|
+
token_usage: attributes.fetch(:token_usage),
|
|
38
|
+
line_items: attributes[:line_items] || resolve_line_items(attributes[:service_line_items]),
|
|
34
39
|
provider: attributes.fetch(:provider).to_s,
|
|
35
|
-
model: attributes.fetch(:model)
|
|
36
|
-
token_usage: token_usage,
|
|
37
|
-
pricing_mode: attributes[:pricing_mode],
|
|
38
|
-
cost: attributes[:cost],
|
|
39
|
-
tags: attributes[:tags],
|
|
40
|
-
latency_ms: attributes[:latency_ms],
|
|
40
|
+
model: identifier(attributes.fetch(:model)) || UNKNOWN_MODEL,
|
|
41
41
|
stream: attributes[:stream] || false,
|
|
42
42
|
usage_source: attributes[:usage_source]&.to_s,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
|
|
46
|
-
provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
|
|
47
|
-
tracked_at: attributes[:tracked_at],
|
|
48
|
-
cost_status: attributes[:cost_status],
|
|
49
|
-
pricing_snapshot: attributes[:pricing_snapshot],
|
|
50
|
-
line_items: line_items
|
|
43
|
+
**PASSTHROUGH_FIELDS.to_h { |field| [field, attributes[field]] },
|
|
44
|
+
**PROVIDER_IDS.to_h { |field| [field, identifier(attributes[field])] }
|
|
51
45
|
)
|
|
52
46
|
end
|
|
53
47
|
|
|
48
|
+
def self.resolve_line_items(service_items)
|
|
49
|
+
Array(service_items).map do |item|
|
|
50
|
+
item.is_a?(Charges::LineItem) ? item : Charges::LineItem.build(item)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.identifier(value) = value.to_s.strip.presence
|
|
55
|
+
private_class_method :identifier
|
|
56
|
+
|
|
54
57
|
def batch?
|
|
55
58
|
Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
|
|
56
59
|
end
|
|
@@ -61,12 +64,6 @@ module LlmCostTracker
|
|
|
61
64
|
with(event_id: Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, "#{provider}/#{provider_response_id}"))
|
|
62
65
|
end
|
|
63
66
|
|
|
64
|
-
def self.resolve_line_items(service_items)
|
|
65
|
-
Array(service_items).map do |item|
|
|
66
|
-
item.is_a?(Charges::LineItem) ? item : Charges::LineItem.build(item)
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
67
|
def total_cost
|
|
71
68
|
cost&.total
|
|
72
69
|
end
|
|
@@ -80,8 +77,4 @@ module LlmCostTracker
|
|
|
80
77
|
)
|
|
81
78
|
end
|
|
82
79
|
end
|
|
83
|
-
|
|
84
|
-
class Event
|
|
85
|
-
UNKNOWN_MODEL = "unknown"
|
|
86
|
-
end
|
|
87
80
|
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb
CHANGED
|
@@ -84,8 +84,10 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration[<%= ActiveRecord::Migr
|
|
|
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?
|
|
@@ -60,7 +60,8 @@ LlmCostTracker.configure do |config|
|
|
|
60
60
|
# user_id: { daily: 25.00, behavior: :notify }
|
|
61
61
|
# }
|
|
62
62
|
# config.budgets.on_exceeded = ->(data) {
|
|
63
|
-
#
|
|
63
|
+
# unit = data[:budget_type] == :calls ? "" : "$"
|
|
64
|
+
# Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: #{unit}#{data[:total]} / #{unit}#{data[:budget]}")
|
|
64
65
|
# }
|
|
65
66
|
|
|
66
67
|
# Where budget checks read period spend from. :ledger sums llm_cost_tracker_calls on
|
|
@@ -80,9 +81,10 @@ LlmCostTracker.configure do |config|
|
|
|
80
81
|
# config.ingestion.mode = :async
|
|
81
82
|
# config.ingestion.pool_size = 5
|
|
82
83
|
|
|
83
|
-
# Register OpenAI-compatible gateway hosts beyond the built-in OpenRouter, DeepSeek
|
|
84
|
-
# the Faraday middleware captures only listed hosts. Its calls,
|
|
85
|
-
# base_url is a listed host, are recorded under the provider name
|
|
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.
|
|
86
88
|
# config.capture.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
|
|
87
89
|
|
|
88
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
|
@@ -27,6 +27,7 @@ class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration[<%= Act
|
|
|
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
|
|
@@ -8,6 +8,7 @@ class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration[<%= A
|
|
|
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
|
|
@@ -9,7 +9,8 @@ class UpgradeLlmCostTrackerIndexes < ActiveRecord::Migration[<%= ActiveRecord::M
|
|
|
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
|
@@ -10,6 +10,7 @@ class UpgradeLlmCostTrackerPerTagBudgets < ActiveRecord::Migration[<%= ActiveRec
|
|
|
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
|
|
@@ -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 " \
|
|
@@ -11,6 +11,9 @@ module LlmCostTracker
|
|
|
11
11
|
module Ingestion
|
|
12
12
|
module Inbox
|
|
13
13
|
PAYLOAD_SCHEMA_VERSION = 2
|
|
14
|
+
OPTIONAL_FIELDS = %i[
|
|
15
|
+
latency_ms usage_source provider_response_id provider_project_id provider_api_key_id provider_workspace_id
|
|
16
|
+
].freeze
|
|
14
17
|
|
|
15
18
|
class << self
|
|
16
19
|
def save(event)
|
|
@@ -30,9 +33,8 @@ module LlmCostTracker
|
|
|
30
33
|
private
|
|
31
34
|
|
|
32
35
|
def event_attributes_from(payload)
|
|
33
|
-
cost =
|
|
34
|
-
token_usage =
|
|
35
|
-
|
|
36
|
+
cost = cost_from(payload)
|
|
37
|
+
token_usage = token_usage_from(payload)
|
|
36
38
|
{
|
|
37
39
|
event_id: payload.fetch(:event_id),
|
|
38
40
|
provider: payload.fetch(:provider),
|
|
@@ -41,20 +43,27 @@ module LlmCostTracker
|
|
|
41
43
|
pricing_mode: Pricing::Mode.normalize(payload[:pricing_mode]),
|
|
42
44
|
cost: cost,
|
|
43
45
|
tags: payload.fetch(:tags),
|
|
44
|
-
latency_ms: payload[:latency_ms],
|
|
45
46
|
stream: payload.fetch(:stream),
|
|
46
|
-
|
|
47
|
-
provider_response_id: payload[:provider_response_id],
|
|
48
|
-
provider_project_id: payload[:provider_project_id],
|
|
49
|
-
provider_api_key_id: payload[:provider_api_key_id],
|
|
50
|
-
provider_workspace_id: payload[:provider_workspace_id],
|
|
47
|
+
**OPTIONAL_FIELDS.to_h { |field| [field, payload[field]] },
|
|
51
48
|
tracked_at: Time.iso8601(payload.fetch(:tracked_at)),
|
|
52
49
|
cost_status: payload.fetch(:cost_status),
|
|
53
50
|
pricing_snapshot: payload[:pricing_snapshot]&.deep_stringify_keys,
|
|
54
|
-
line_items: (payload
|
|
51
|
+
line_items: line_items_from(payload)
|
|
55
52
|
}
|
|
56
53
|
end
|
|
57
54
|
|
|
55
|
+
def cost_from(payload)
|
|
56
|
+
payload[:cost] && Charges::Cost.from_h(payload[:cost])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def token_usage_from(payload)
|
|
60
|
+
Usage::TokenUsage.build(**payload.fetch(:token_usage).slice(*Usage::TokenUsage.members))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def line_items_from(payload)
|
|
64
|
+
(payload[:line_items] || []).map { |attrs| Charges::LineItem.build(attrs) }
|
|
65
|
+
end
|
|
66
|
+
|
|
58
67
|
def row_for(event)
|
|
59
68
|
now = Time.now.utc
|
|
60
69
|
{
|
|
@@ -73,7 +82,7 @@ module LlmCostTracker
|
|
|
73
82
|
end
|
|
74
83
|
|
|
75
84
|
def insert_row(row)
|
|
76
|
-
Pool.with_connection { |connection| execute_insert(connection, row) }
|
|
85
|
+
Ledger::Isolation.writing { Pool.with_connection { |connection| execute_insert(connection, row) } }
|
|
77
86
|
rescue ActiveRecord::ConnectionTimeoutError => e
|
|
78
87
|
raise LlmCostTracker::Error,
|
|
79
88
|
"ledger inbox could not checkout a database connection: #{e.message}"
|
|
@@ -38,39 +38,21 @@ module LlmCostTracker
|
|
|
38
38
|
def flush!(timeout: nil, require_lease: false)
|
|
39
39
|
return true unless Ingestion.async?
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return true unless Ingestion::Batch.new(identity: identity).pending?
|
|
47
|
-
return false if Time.now.utc >= deadline
|
|
48
|
-
|
|
49
|
-
processed = ingest_once(require_lease: require_lease)
|
|
50
|
-
next unless processed.zero?
|
|
51
|
-
|
|
52
|
-
duration = [INTERVAL_SECONDS, deadline - Time.now.utc].min
|
|
53
|
-
return false unless duration.positive?
|
|
54
|
-
|
|
55
|
-
sleep(duration)
|
|
41
|
+
prepare!
|
|
42
|
+
drained = drain_until(Time.now.utc + flush_timeout_seconds(timeout), require_lease)
|
|
43
|
+
unless drained
|
|
44
|
+
Logging.warn("Ingestion::Worker.flush! timed out with #{Ingestion::InboxEntry.pending.count} " \
|
|
45
|
+
"inbox row(s) still pending")
|
|
56
46
|
end
|
|
47
|
+
drained && Ingestion::InboxEntry.uncached { Ingestion::InboxEntry.quarantined.none? }
|
|
57
48
|
end
|
|
58
49
|
|
|
59
50
|
def shutdown!(timeout: nil, drain: true)
|
|
60
51
|
return true unless Ingestion.async?
|
|
61
52
|
|
|
62
53
|
timeout ||= FLUSH_TIMEOUT_SECONDS
|
|
63
|
-
thread =
|
|
64
|
-
|
|
65
|
-
@generation = @generation.to_i + 1
|
|
66
|
-
@thread
|
|
67
|
-
end
|
|
68
|
-
begin
|
|
69
|
-
wake_thread(thread)
|
|
70
|
-
thread&.join(timeout)
|
|
71
|
-
rescue StandardError => e
|
|
72
|
-
handle_error(e)
|
|
73
|
-
end
|
|
54
|
+
thread = request_stop
|
|
55
|
+
await_exit(thread, timeout)
|
|
74
56
|
drain ? flush!(timeout: timeout, require_lease: true) : true
|
|
75
57
|
rescue StandardError => e
|
|
76
58
|
handle_error(e)
|
|
@@ -89,8 +71,7 @@ module LlmCostTracker
|
|
|
89
71
|
end
|
|
90
72
|
|
|
91
73
|
def ingest_once(require_lease: true)
|
|
92
|
-
|
|
93
|
-
MUTEX.synchronize { reset_after_fork! }
|
|
74
|
+
prepare!
|
|
94
75
|
batch = Ingestion::Batch.new(identity: identity)
|
|
95
76
|
return 0 unless batch.claimable?
|
|
96
77
|
return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
|
|
@@ -108,14 +89,7 @@ module LlmCostTracker
|
|
|
108
89
|
loop do
|
|
109
90
|
break if MUTEX.synchronize { @stop_requested || generation != @generation }
|
|
110
91
|
|
|
111
|
-
|
|
112
|
-
release_connection!
|
|
113
|
-
if processed.zero?
|
|
114
|
-
sleep(idle_interval)
|
|
115
|
-
idle_interval = [idle_interval * 2, MAX_IDLE_INTERVAL_SECONDS].min
|
|
116
|
-
else
|
|
117
|
-
idle_interval = IDLE_INTERVAL_SECONDS
|
|
118
|
-
end
|
|
92
|
+
idle_interval = ingest_or_back_off(idle_interval)
|
|
119
93
|
rescue StandardError => e
|
|
120
94
|
handle_error(e)
|
|
121
95
|
release_connection!
|
|
@@ -126,6 +100,52 @@ module LlmCostTracker
|
|
|
126
100
|
MUTEX.synchronize { @thread = nil if @thread.equal?(Thread.current) }
|
|
127
101
|
end
|
|
128
102
|
|
|
103
|
+
def ingest_or_back_off(idle_interval)
|
|
104
|
+
processed = Rails.application.executor.wrap { ingest_once }
|
|
105
|
+
release_connection!
|
|
106
|
+
return IDLE_INTERVAL_SECONDS unless processed.zero?
|
|
107
|
+
|
|
108
|
+
sleep(idle_interval)
|
|
109
|
+
[idle_interval * 2, MAX_IDLE_INTERVAL_SECONDS].min
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def prepare!
|
|
113
|
+
Ingestion.ensure_current_schema!
|
|
114
|
+
MUTEX.synchronize { reset_after_fork! }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def drain_until(deadline, require_lease)
|
|
118
|
+
loop do
|
|
119
|
+
return true unless Ingestion::Batch.new(identity: identity).pending?
|
|
120
|
+
return false unless time_left(deadline).positive?
|
|
121
|
+
next unless ingest_once(require_lease: require_lease).zero?
|
|
122
|
+
|
|
123
|
+
duration = [INTERVAL_SECONDS, time_left(deadline)].min
|
|
124
|
+
return false unless duration.positive?
|
|
125
|
+
|
|
126
|
+
sleep(duration)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def time_left(deadline)
|
|
131
|
+
deadline - Time.now.utc
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def request_stop
|
|
135
|
+
MUTEX.synchronize do
|
|
136
|
+
@stop_requested = true
|
|
137
|
+
@generation = @generation.to_i + 1
|
|
138
|
+
@thread
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def await_exit(thread, timeout)
|
|
143
|
+
wake_thread(thread)
|
|
144
|
+
thread&.join(timeout)
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
handle_error(e)
|
|
147
|
+
end
|
|
148
|
+
|
|
129
149
|
def reset_after_fork!
|
|
130
150
|
return if @pid == Process.pid
|
|
131
151
|
|
|
@@ -145,7 +165,8 @@ module LlmCostTracker
|
|
|
145
165
|
end
|
|
146
166
|
|
|
147
167
|
def handle_error(error)
|
|
148
|
-
Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}"
|
|
168
|
+
Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}" \
|
|
169
|
+
"#{" (#{error.backtrace.first})" if error.backtrace}")
|
|
149
170
|
end
|
|
150
171
|
|
|
151
172
|
def release_connection!
|
|
@@ -16,6 +16,8 @@ module LlmCostTracker
|
|
|
16
16
|
autoload :Worker, "llm_cost_tracker/ingestion/worker"
|
|
17
17
|
|
|
18
18
|
VERIFY_TAG = "llm_cost_tracker_verify"
|
|
19
|
+
CAPTURE_CHECK = "active_record capture"
|
|
20
|
+
MISSING_CALLS_TABLE = "llm_cost_tracker_calls table is missing; run install generator and migrate"
|
|
19
21
|
|
|
20
22
|
class << self
|
|
21
23
|
def table_name_prefix
|
|
@@ -23,9 +25,7 @@ module LlmCostTracker
|
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def ensure_current_schema!
|
|
26
|
-
unless LlmCostTracker::Call.table_exists?
|
|
27
|
-
raise Error, "llm_cost_tracker_calls table is missing; run install generator and migrate"
|
|
28
|
-
end
|
|
28
|
+
raise Error, MISSING_CALLS_TABLE unless LlmCostTracker::Call.table_exists?
|
|
29
29
|
|
|
30
30
|
guards_for_current_config.each do |schema_module, table_name|
|
|
31
31
|
next if schema_module == Ledger::Schema::CallRollups && !Ledger::Rollups.cache_active?
|
|
@@ -51,13 +51,7 @@ module LlmCostTracker
|
|
|
51
51
|
|
|
52
52
|
def verify
|
|
53
53
|
unless LlmCostTracker::Call.table_exists?
|
|
54
|
-
return [
|
|
55
|
-
LlmCostTracker::Check.new(
|
|
56
|
-
:error,
|
|
57
|
-
"active_record",
|
|
58
|
-
"llm_cost_tracker_calls table is missing; run install generator and migrate"
|
|
59
|
-
)
|
|
60
|
-
]
|
|
54
|
+
return [LlmCostTracker::Check.new(:error, "active_record", MISSING_CALLS_TABLE)]
|
|
61
55
|
end
|
|
62
56
|
|
|
63
57
|
[capture_check]
|
|
@@ -72,7 +66,6 @@ module LlmCostTracker
|
|
|
72
66
|
response_id = "lct_verify_#{SecureRandom.hex(8)}"
|
|
73
67
|
notifications = []
|
|
74
68
|
subscription = subscribe_to_verification(response_id, notifications)
|
|
75
|
-
|
|
76
69
|
event = LlmCostTracker.track(
|
|
77
70
|
provider: provider,
|
|
78
71
|
model: model,
|
|
@@ -81,21 +74,9 @@ module LlmCostTracker
|
|
|
81
74
|
tags: { feature: VERIFY_TAG }
|
|
82
75
|
)
|
|
83
76
|
LlmCostTracker::Ingestion::Worker.flush! if async?
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return capture_success if persisted && notifications.any?
|
|
87
|
-
|
|
88
|
-
LlmCostTracker::Check.new(
|
|
89
|
-
:error,
|
|
90
|
-
"active_record capture",
|
|
91
|
-
capture_failure_message(persisted, notifications)
|
|
92
|
-
)
|
|
93
|
-
rescue LlmCostTracker::BudgetExceededError => e
|
|
94
|
-
LlmCostTracker::Check.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
|
|
95
|
-
rescue LlmCostTracker::Error => e
|
|
96
|
-
LlmCostTracker::Check.new(:error, "active_record capture", e.message)
|
|
77
|
+
capture_result(LlmCostTracker::Call.where(provider_response_id: response_id).exists?, notifications)
|
|
97
78
|
rescue StandardError => e
|
|
98
|
-
LlmCostTracker::Check.new(:error,
|
|
79
|
+
LlmCostTracker::Check.new(:error, CAPTURE_CHECK, capture_error_message(e))
|
|
99
80
|
ensure
|
|
100
81
|
cleanup_verification_call(response_id) if response_id
|
|
101
82
|
cleanup_verification_inbox(event: event, response_id: response_id)
|
|
@@ -108,20 +89,24 @@ module LlmCostTracker
|
|
|
108
89
|
end
|
|
109
90
|
end
|
|
110
91
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
:ok,
|
|
115
|
-
|
|
116
|
-
"manual event emitted and persisted through #{path}"
|
|
117
|
-
)
|
|
118
|
-
end
|
|
92
|
+
def capture_result(persisted, notifications)
|
|
93
|
+
if persisted && notifications.any?
|
|
94
|
+
path = async? ? "async inbox" : "inline writer"
|
|
95
|
+
return LlmCostTracker::Check.new(:ok, CAPTURE_CHECK, "manual event emitted and persisted through #{path}")
|
|
96
|
+
end
|
|
119
97
|
|
|
120
|
-
def capture_failure_message(persisted, notifications)
|
|
121
98
|
missing = []
|
|
122
99
|
missing << "notification" if notifications.empty?
|
|
123
100
|
missing << "persisted row" unless persisted
|
|
124
|
-
"missing #{missing.join(' and ')} for synthetic manual event"
|
|
101
|
+
LlmCostTracker::Check.new(:error, CAPTURE_CHECK, "missing #{missing.join(' and ')} for synthetic manual event")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def capture_error_message(error)
|
|
105
|
+
case error
|
|
106
|
+
when LlmCostTracker::BudgetExceededError then "blocked by budget guardrail: #{error.message}"
|
|
107
|
+
when LlmCostTracker::Error then error.message
|
|
108
|
+
else "#{error.class}: #{error.message}"
|
|
109
|
+
end
|
|
125
110
|
end
|
|
126
111
|
|
|
127
112
|
def cleanup_verification_call(response_id)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Integrations
|
|
5
|
+
module Anthropic
|
|
6
|
+
class BatchResultsCapture
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
def initialize(raw_stream)
|
|
10
|
+
@raw_stream = raw_stream
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def each(&block)
|
|
14
|
+
return enum_for(:each) unless block
|
|
15
|
+
|
|
16
|
+
deferred = nil
|
|
17
|
+
@raw_stream.each do |response|
|
|
18
|
+
begin
|
|
19
|
+
Integrations::Anthropic.record_batch_result(response)
|
|
20
|
+
rescue BudgetExceededError, UnknownPricingError => e
|
|
21
|
+
deferred ||= e
|
|
22
|
+
end
|
|
23
|
+
block.call(response)
|
|
24
|
+
end
|
|
25
|
+
raise deferred if deferred
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def respond_to_missing?(name, include_private = false)
|
|
29
|
+
@raw_stream.respond_to?(name, include_private) || super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def method_missing(name, ...)
|
|
33
|
+
return super unless @raw_stream.respond_to?(name)
|
|
34
|
+
|
|
35
|
+
@raw_stream.public_send(name, ...)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Integrations
|
|
5
|
+
module Anthropic
|
|
6
|
+
module MessagesPatch
|
|
7
|
+
def create(*args, **kwargs)
|
|
8
|
+
Integrations::Anthropic.wrap_blocking(args, kwargs, **Integrations::Anthropic.blocking_seam(@client)) { super }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def stream(*args, **kwargs)
|
|
12
|
+
Integrations::Anthropic.wrap_stream(args, kwargs, **Integrations::Anthropic.stream_seam(@client)) { super }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def stream_raw(*args, **kwargs)
|
|
16
|
+
Integrations::Anthropic.wrap_stream(args, kwargs, **Integrations::Anthropic.stream_seam(@client)) { super }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module FallbackMiddlewarePatch
|
|
21
|
+
def call(req, nxt)
|
|
22
|
+
return super if req.streaming? || !Integrations::Anthropic.active?
|
|
23
|
+
|
|
24
|
+
hops = []
|
|
25
|
+
response = super(req, ->(hop_req) { nxt.call(hop_req).tap { |hop| hops << [hop_req, hop] } })
|
|
26
|
+
answered = hops.select { |_hop_req, hop| hop.status < 300 }
|
|
27
|
+
answered[...-1].each { |hop| Integrations::Anthropic.record_refused_hop(*hop) }
|
|
28
|
+
response
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def consume_hop(*args, **kwargs)
|
|
34
|
+
refused = Thread.current[:llm_cost_tracker_refused_hop]
|
|
35
|
+
Thread.current[:llm_cost_tracker_refused_hop] = nil
|
|
36
|
+
Integrations::Anthropic.record_refused_stream_hop(refused) if refused && kwargs[:splice]
|
|
37
|
+
super.tap { |hop| Thread.current[:llm_cost_tracker_refused_hop] = hop if hop[:refused] }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module BatchesPatch
|
|
42
|
+
def create(*args, **kwargs)
|
|
43
|
+
Integrations::Anthropic.enforce_budget!(request: Integrations::Anthropic.request_params(args, kwargs))
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def results_streaming(*args, **kwargs)
|
|
48
|
+
raw = super
|
|
49
|
+
return raw unless Integrations::Anthropic.active?
|
|
50
|
+
|
|
51
|
+
BatchResultsCapture.new(raw)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|