llm_cost_tracker 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +198 -0
- data/LICENSE.txt +28 -0
- data/README.md +38 -35
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
- data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
- data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
- data/app/models/llm_cost_tracker/call.rb +15 -17
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
- data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +22 -171
- data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
- data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
- data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +42 -22
- data/lib/llm_cost_tracker/engine.rb +8 -1
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +28 -27
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +23 -36
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +98 -93
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
- data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
- data/lib/llm_cost_tracker/ledger/store.rb +19 -37
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
- data/lib/llm_cost_tracker/parsers.rb +19 -40
- data/lib/llm_cost_tracker/prices.json +3241 -187
- data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
- data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +45 -31
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +58 -25
- data/llm_cost_tracker.gemspec +64 -0
- metadata +65 -11
|
@@ -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)
|
|
@@ -18,7 +21,7 @@ module LlmCostTracker
|
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
def event_from_row(row)
|
|
21
|
-
payload = JSON.parse(row.payload, symbolize_names: true)
|
|
24
|
+
payload = JSON.parse(row.payload, symbolize_names: true, allow_duplicate_key: true)
|
|
22
25
|
schema_version = payload[:schema_version]
|
|
23
26
|
unless schema_version == PAYLOAD_SCHEMA_VERSION
|
|
24
27
|
raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
|
|
@@ -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
|
{
|
|
@@ -69,17 +78,11 @@ module LlmCostTracker
|
|
|
69
78
|
end
|
|
70
79
|
|
|
71
80
|
def payload_for(event)
|
|
72
|
-
event.to_h.merge(
|
|
73
|
-
schema_version: PAYLOAD_SCHEMA_VERSION,
|
|
74
|
-
event_id: event.event_id,
|
|
75
|
-
provider: event.provider,
|
|
76
|
-
model: event.model,
|
|
77
|
-
tracked_at: event.tracked_at.iso8601(6)
|
|
78
|
-
)
|
|
81
|
+
event.to_h.merge(schema_version: PAYLOAD_SCHEMA_VERSION, tracked_at: event.tracked_at.iso8601(6))
|
|
79
82
|
end
|
|
80
83
|
|
|
81
84
|
def insert_row(row)
|
|
82
|
-
Pool.with_connection { |connection| execute_insert(connection, row) }
|
|
85
|
+
Ledger::Isolation.writing { Pool.with_connection { |connection| execute_insert(connection, row) } }
|
|
83
86
|
rescue ActiveRecord::ConnectionTimeoutError => e
|
|
84
87
|
raise LlmCostTracker::Error,
|
|
85
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,11 +25,11 @@ 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
|
+
next if schema_module == Ledger::Schema::CallRollups && !Ledger::Rollups.cache_active?
|
|
32
|
+
|
|
31
33
|
errors = schema_module.current_schema_errors
|
|
32
34
|
next if errors.empty?
|
|
33
35
|
|
|
@@ -49,13 +51,7 @@ module LlmCostTracker
|
|
|
49
51
|
|
|
50
52
|
def verify
|
|
51
53
|
unless LlmCostTracker::Call.table_exists?
|
|
52
|
-
return [
|
|
53
|
-
LlmCostTracker::Check.new(
|
|
54
|
-
:error,
|
|
55
|
-
"active_record",
|
|
56
|
-
"llm_cost_tracker_calls table is missing; run install generator and migrate"
|
|
57
|
-
)
|
|
58
|
-
]
|
|
54
|
+
return [LlmCostTracker::Check.new(:error, "active_record", MISSING_CALLS_TABLE)]
|
|
59
55
|
end
|
|
60
56
|
|
|
61
57
|
[capture_check]
|
|
@@ -70,7 +66,6 @@ module LlmCostTracker
|
|
|
70
66
|
response_id = "lct_verify_#{SecureRandom.hex(8)}"
|
|
71
67
|
notifications = []
|
|
72
68
|
subscription = subscribe_to_verification(response_id, notifications)
|
|
73
|
-
|
|
74
69
|
event = LlmCostTracker.track(
|
|
75
70
|
provider: provider,
|
|
76
71
|
model: model,
|
|
@@ -79,21 +74,9 @@ module LlmCostTracker
|
|
|
79
74
|
tags: { feature: VERIFY_TAG }
|
|
80
75
|
)
|
|
81
76
|
LlmCostTracker::Ingestion::Worker.flush! if async?
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return capture_success if persisted && notifications.any?
|
|
85
|
-
|
|
86
|
-
LlmCostTracker::Check.new(
|
|
87
|
-
:error,
|
|
88
|
-
"active_record capture",
|
|
89
|
-
capture_failure_message(persisted, notifications)
|
|
90
|
-
)
|
|
91
|
-
rescue LlmCostTracker::BudgetExceededError => e
|
|
92
|
-
LlmCostTracker::Check.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
|
|
93
|
-
rescue LlmCostTracker::Error => e
|
|
94
|
-
LlmCostTracker::Check.new(:error, "active_record capture", e.message)
|
|
77
|
+
capture_result(LlmCostTracker::Call.where(provider_response_id: response_id).exists?, notifications)
|
|
95
78
|
rescue StandardError => e
|
|
96
|
-
LlmCostTracker::Check.new(:error,
|
|
79
|
+
LlmCostTracker::Check.new(:error, CAPTURE_CHECK, capture_error_message(e))
|
|
97
80
|
ensure
|
|
98
81
|
cleanup_verification_call(response_id) if response_id
|
|
99
82
|
cleanup_verification_inbox(event: event, response_id: response_id)
|
|
@@ -106,25 +89,29 @@ module LlmCostTracker
|
|
|
106
89
|
end
|
|
107
90
|
end
|
|
108
91
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
:ok,
|
|
113
|
-
|
|
114
|
-
"manual event emitted and persisted through #{path}"
|
|
115
|
-
)
|
|
116
|
-
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
|
|
117
97
|
|
|
118
|
-
def capture_failure_message(persisted, notifications)
|
|
119
98
|
missing = []
|
|
120
99
|
missing << "notification" if notifications.empty?
|
|
121
100
|
missing << "persisted row" unless persisted
|
|
122
|
-
"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
|
|
123
110
|
end
|
|
124
111
|
|
|
125
112
|
def cleanup_verification_call(response_id)
|
|
126
113
|
relation = LlmCostTracker::Call.where(provider_response_id: response_id)
|
|
127
|
-
records = relation.select(:id,
|
|
114
|
+
records = relation.select(:id, *LlmCostTracker::Ledger::Rollups::SOURCE_COLUMNS).to_a
|
|
128
115
|
return if records.empty?
|
|
129
116
|
|
|
130
117
|
relation.delete_all
|
|
@@ -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
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
-
require_relative "../providers/anthropic/usage_extractor"
|
|
5
4
|
require_relative "../providers/anthropic/response_parser"
|
|
5
|
+
require_relative "anthropic/patches"
|
|
6
|
+
require_relative "anthropic/batch_results_capture"
|
|
6
7
|
|
|
7
8
|
module LlmCostTracker
|
|
8
9
|
module Integrations
|
|
@@ -17,27 +18,48 @@ module LlmCostTracker
|
|
|
17
18
|
patch_target("Anthropic::Resources::Messages", with: MessagesPatch),
|
|
18
19
|
patch_target("Anthropic::Resources::Beta::Messages", with: MessagesPatch, optional: true),
|
|
19
20
|
patch_target("Anthropic::Resources::Messages::Batches", with: BatchesPatch, optional: true),
|
|
20
|
-
patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true)
|
|
21
|
+
patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true),
|
|
22
|
+
patch_target("Anthropic::BetaRefusalFallbackMiddleware",
|
|
23
|
+
with: FallbackMiddlewarePatch,
|
|
24
|
+
optional: true,
|
|
25
|
+
since: "1.54.0")
|
|
21
26
|
]
|
|
22
27
|
end
|
|
23
28
|
|
|
24
|
-
def
|
|
29
|
+
def blocking_seam(client)
|
|
30
|
+
host = client_host(client)
|
|
31
|
+
{
|
|
32
|
+
record: lambda do |message, request, latency_ms|
|
|
33
|
+
record_message(message, request: request, latency_ms: latency_ms, host: host)
|
|
34
|
+
end
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def stream_seam(client)
|
|
39
|
+
host = client_host(client)
|
|
40
|
+
{ collector: ->(request) { stream_collector(request, host: host) } }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stream_collector(request, host: nil)
|
|
44
|
+
regional = Providers::Anthropic::UsageExtractor.regional_host?(host, request[:model])
|
|
45
|
+
super(request, pricing_mode: ("data_residency" if regional))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def record_message(message, request:, latency_ms:, host: nil)
|
|
25
49
|
return unless active?
|
|
26
50
|
|
|
27
51
|
record_safely do
|
|
28
52
|
usage = message.usage
|
|
29
|
-
next unless usage
|
|
30
|
-
next if usage.input_tokens.nil? && usage.output_tokens.nil?
|
|
31
|
-
|
|
32
|
-
usage_hash = usage.deep_to_h
|
|
53
|
+
next unless tokens_reported?(usage)
|
|
33
54
|
|
|
34
|
-
|
|
35
|
-
event:
|
|
36
|
-
|
|
55
|
+
Tracker.record(
|
|
56
|
+
event: message_event(
|
|
57
|
+
message,
|
|
58
|
+
usage: usage.deep_to_h,
|
|
37
59
|
model: message.model || request[:model],
|
|
38
|
-
provider_response_id: message.id,
|
|
39
60
|
usage_source: Usage::Source::SDK_RESPONSE,
|
|
40
|
-
request: request
|
|
61
|
+
request: request,
|
|
62
|
+
host: host
|
|
41
63
|
),
|
|
42
64
|
latency_ms: latency_ms
|
|
43
65
|
)
|
|
@@ -48,106 +70,76 @@ module LlmCostTracker
|
|
|
48
70
|
return unless active?
|
|
49
71
|
|
|
50
72
|
record_safely do
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
result = response.result
|
|
54
|
-
next unless result.respond_to?(:type) && result.type.to_s == "succeeded"
|
|
55
|
-
|
|
56
|
-
message = result.respond_to?(:message) ? result.message : nil
|
|
73
|
+
message = succeeded_message(response)
|
|
57
74
|
next unless message
|
|
58
|
-
next if
|
|
75
|
+
next if Call.already_recorded?(provider: "anthropic", provider_response_id: message.id)
|
|
59
76
|
|
|
60
77
|
usage = message.usage
|
|
61
|
-
next unless usage
|
|
62
|
-
next if usage.input_tokens.nil? && usage.output_tokens.nil?
|
|
78
|
+
next unless tokens_reported?(usage)
|
|
63
79
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
usage:
|
|
80
|
+
record_once(
|
|
81
|
+
message_event(
|
|
82
|
+
message,
|
|
83
|
+
usage: usage.deep_to_h.merge(service_tier: "batch"),
|
|
68
84
|
model: message.model,
|
|
69
|
-
|
|
70
|
-
usage_source: Usage::Source::SDK_BATCH_RESULT,
|
|
71
|
-
pricing_mode: "batch"
|
|
85
|
+
usage_source: Usage::Source::SDK_BATCH_RESULT
|
|
72
86
|
)
|
|
73
87
|
)
|
|
74
88
|
end
|
|
75
89
|
end
|
|
76
90
|
|
|
77
|
-
def
|
|
78
|
-
|
|
91
|
+
def record_refused_hop(request, response)
|
|
92
|
+
record_safely { record_message(response.parse, request: request.body, latency_ms: nil) }
|
|
93
|
+
rescue BudgetExceededError, UnknownPricingError
|
|
94
|
+
nil
|
|
79
95
|
end
|
|
80
|
-
end
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
LlmCostTracker::Integrations::Anthropic.wrap_blocking(
|
|
85
|
-
args,
|
|
86
|
-
kwargs,
|
|
87
|
-
record: lambda do |message, request, latency_ms|
|
|
88
|
-
LlmCostTracker::Integrations::Anthropic.record_message(
|
|
89
|
-
message, request: request, latency_ms: latency_ms
|
|
90
|
-
)
|
|
91
|
-
end
|
|
92
|
-
) { super }
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def stream(*args, **kwargs)
|
|
96
|
-
LlmCostTracker::Integrations::Anthropic.wrap_stream(
|
|
97
|
-
args,
|
|
98
|
-
kwargs,
|
|
99
|
-
collector: ->(request) { LlmCostTracker::Integrations::Anthropic.stream_collector(request) }
|
|
100
|
-
) { super }
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def stream_raw(*args, **kwargs)
|
|
104
|
-
LlmCostTracker::Integrations::Anthropic.wrap_stream(
|
|
105
|
-
args,
|
|
106
|
-
kwargs,
|
|
107
|
-
collector: ->(request) { LlmCostTracker::Integrations::Anthropic.stream_collector(request) }
|
|
108
|
-
) { super }
|
|
109
|
-
end
|
|
110
|
-
end
|
|
97
|
+
def record_refused_stream_hop(hop)
|
|
98
|
+
return unless active?
|
|
111
99
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return
|
|
100
|
+
usage = hop.dig(:refused, :usage).to_h.deep_symbolize_keys
|
|
101
|
+
produced_output = usage[:output_tokens].to_i.positive?
|
|
102
|
+
usage = usage.slice(:server_tool_use) if produced_output
|
|
103
|
+
return if produced_output && Providers::Anthropic::UsageExtractor.service_line_items(usage).empty?
|
|
116
104
|
|
|
117
|
-
|
|
105
|
+
record_safely do
|
|
106
|
+
Tracker.record(
|
|
107
|
+
event: Providers::Anthropic::ResponseParser.event_from_usage(
|
|
108
|
+
usage: usage,
|
|
109
|
+
model: hop[:model],
|
|
110
|
+
provider_response_id: nil,
|
|
111
|
+
usage_source: Usage::Source::STREAM_FINAL,
|
|
112
|
+
stream: true,
|
|
113
|
+
stop_reason: ("refusal" unless produced_output),
|
|
114
|
+
refusal_category: hop.dig(:refused, :stop_details, "category")
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
rescue BudgetExceededError, UnknownPricingError
|
|
119
|
+
nil
|
|
118
120
|
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
class BatchResultsCapture
|
|
122
|
-
include Enumerable
|
|
123
121
|
|
|
124
|
-
|
|
125
|
-
@raw_stream = raw_stream
|
|
126
|
-
end
|
|
122
|
+
private
|
|
127
123
|
|
|
128
|
-
def
|
|
129
|
-
|
|
124
|
+
def succeeded_message(response)
|
|
125
|
+
result = response.result if response.respond_to?(:result)
|
|
126
|
+
return unless result.respond_to?(:type) && result.type.to_s == "succeeded"
|
|
130
127
|
|
|
131
|
-
|
|
132
|
-
@raw_stream.each do |response|
|
|
133
|
-
begin
|
|
134
|
-
LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
|
|
135
|
-
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
|
|
136
|
-
deferred ||= e
|
|
137
|
-
end
|
|
138
|
-
block.call(response)
|
|
139
|
-
end
|
|
140
|
-
raise deferred if deferred
|
|
128
|
+
result.message if result.respond_to?(:message)
|
|
141
129
|
end
|
|
142
130
|
|
|
143
|
-
def
|
|
144
|
-
|
|
131
|
+
def tokens_reported?(usage)
|
|
132
|
+
usage && !(usage.input_tokens.nil? && usage.output_tokens.nil?)
|
|
145
133
|
end
|
|
146
134
|
|
|
147
|
-
def
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
135
|
+
def message_event(message, **attributes)
|
|
136
|
+
Providers::Anthropic::ResponseParser.event_from_usage(
|
|
137
|
+
provider_response_id: message.id,
|
|
138
|
+
stop_reason: message.stop_reason,
|
|
139
|
+
refusal_category: message.stop_details&.category,
|
|
140
|
+
content: message.deep_to_h[:content],
|
|
141
|
+
**attributes
|
|
142
|
+
)
|
|
151
143
|
end
|
|
152
144
|
end
|
|
153
145
|
end
|