llm_cost_tracker 0.14.0 → 0.14.2
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 +128 -0
- data/README.md +16 -5
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
- data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
- data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
- data/app/models/llm_cost_tracker/call.rb +10 -14
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
- data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- 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 +1 -4
- data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
- data/lib/llm_cost_tracker/budget.rb +44 -55
- data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
- data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
- data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
- data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
- 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/price_check.rb +14 -3
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +19 -2
- data/lib/llm_cost_tracker/engine.rb +0 -1
- data/lib/llm_cost_tracker/errors.rb +13 -0
- data/lib/llm_cost_tracker/event.rb +8 -0
- 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 +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -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 +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -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 +37 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
- data/lib/llm_cost_tracker/ingestion.rb +3 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
- data/lib/llm_cost_tracker/integrations/base.rb +56 -19
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
- data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +14 -13
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
- 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/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +3 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
- data/lib/llm_cost_tracker/parsers.rb +12 -41
- data/lib/llm_cost_tracker/prices.json +3071 -249
- data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
- data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
- 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 +36 -22
- data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
- data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/report/data.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
- data/lib/llm_cost_tracker/tracker.rb +17 -10
- 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 +61 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +4 -2
- data/lib/tasks/llm_cost_tracker.rake +26 -21
- data/llm_cost_tracker.gemspec +63 -0
- metadata +23 -10
|
@@ -10,8 +10,9 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "Adds
|
|
14
|
-
"
|
|
13
|
+
desc "Adds the unpriced-calls index (partial on PostgreSQL, so backfill_unknown_pricing stops scanning " \
|
|
14
|
+
"the whole ledger; a plain id index on MySQL, which has no partial indexes) and drops the unused " \
|
|
15
|
+
"ingestion inbox lock index."
|
|
15
16
|
|
|
16
17
|
def create_migration_file
|
|
17
18
|
migration_template(
|
|
@@ -19,12 +20,6 @@ module LlmCostTracker
|
|
|
19
20
|
"db/migrate/upgrade_llm_cost_tracker_indexes.rb"
|
|
20
21
|
)
|
|
21
22
|
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
def migration_version
|
|
26
|
-
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
27
|
-
end
|
|
28
23
|
end
|
|
29
24
|
end
|
|
30
25
|
end
|
|
@@ -10,8 +10,9 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "
|
|
14
|
-
"
|
|
13
|
+
desc "Adds cost and time columns to llm_cost_tracker_call_tags so per-tag budgets read it " \
|
|
14
|
+
"without a join; run llm_cost_tracker:backfill_tag_costs afterwards so earlier calls count. " \
|
|
15
|
+
"Required for config.budgets.per_tag on installs created before v0.14."
|
|
15
16
|
|
|
16
17
|
def create_migration_file
|
|
17
18
|
migration_template(
|
|
@@ -19,12 +20,6 @@ module LlmCostTracker
|
|
|
19
20
|
"db/migrate/upgrade_llm_cost_tracker_per_tag_budgets.rb"
|
|
20
21
|
)
|
|
21
22
|
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
def migration_version
|
|
26
|
-
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
27
|
-
end
|
|
28
23
|
end
|
|
29
24
|
end
|
|
30
25
|
end
|
|
@@ -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
|
|
|
@@ -18,7 +18,7 @@ module LlmCostTracker
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def event_from_row(row)
|
|
21
|
-
payload = JSON.parse(row.payload, symbolize_names: true)
|
|
21
|
+
payload = JSON.parse(row.payload, symbolize_names: true, allow_duplicate_key: true)
|
|
22
22
|
schema_version = payload[:schema_version]
|
|
23
23
|
unless schema_version == PAYLOAD_SCHEMA_VERSION
|
|
24
24
|
raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
|
|
@@ -69,13 +69,7 @@ module LlmCostTracker
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
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
|
-
)
|
|
72
|
+
event.to_h.merge(schema_version: PAYLOAD_SCHEMA_VERSION, tracked_at: event.tracked_at.iso8601(6))
|
|
79
73
|
end
|
|
80
74
|
|
|
81
75
|
def insert_row(row)
|
|
@@ -28,6 +28,8 @@ module LlmCostTracker
|
|
|
28
28
|
end
|
|
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
|
|
|
@@ -124,7 +126,7 @@ module LlmCostTracker
|
|
|
124
126
|
|
|
125
127
|
def cleanup_verification_call(response_id)
|
|
126
128
|
relation = LlmCostTracker::Call.where(provider_response_id: response_id)
|
|
127
|
-
records = relation.select(:id,
|
|
129
|
+
records = relation.select(:id, *LlmCostTracker::Ledger::Rollups::SOURCE_COLUMNS).to_a
|
|
128
130
|
return if records.empty?
|
|
129
131
|
|
|
130
132
|
relation.delete_all
|
|
@@ -1,7 +1,6 @@
|
|
|
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"
|
|
6
5
|
|
|
7
6
|
module LlmCostTracker
|
|
@@ -17,7 +16,8 @@ module LlmCostTracker
|
|
|
17
16
|
patch_target("Anthropic::Resources::Messages", with: MessagesPatch),
|
|
18
17
|
patch_target("Anthropic::Resources::Beta::Messages", with: MessagesPatch, optional: true),
|
|
19
18
|
patch_target("Anthropic::Resources::Messages::Batches", with: BatchesPatch, optional: true),
|
|
20
|
-
patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true)
|
|
19
|
+
patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true),
|
|
20
|
+
patch_target("Anthropic::BetaRefusalFallbackMiddleware", with: FallbackMiddlewarePatch, optional: true)
|
|
21
21
|
]
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -37,7 +37,8 @@ module LlmCostTracker
|
|
|
37
37
|
model: message.model || request[:model],
|
|
38
38
|
provider_response_id: message.id,
|
|
39
39
|
usage_source: Usage::Source::SDK_RESPONSE,
|
|
40
|
-
request: request
|
|
40
|
+
request: request,
|
|
41
|
+
**response_fields(message)
|
|
41
42
|
),
|
|
42
43
|
latency_ms: latency_ms
|
|
43
44
|
)
|
|
@@ -61,21 +62,52 @@ module LlmCostTracker
|
|
|
61
62
|
next unless usage
|
|
62
63
|
next if usage.input_tokens.nil? && usage.output_tokens.nil?
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
usage: usage_hash,
|
|
65
|
+
record_once(
|
|
66
|
+
Providers::Anthropic::ResponseParser.event_from_usage(
|
|
67
|
+
usage: usage.deep_to_h.merge(service_tier: "batch"),
|
|
68
68
|
model: message.model,
|
|
69
69
|
provider_response_id: message.id,
|
|
70
70
|
usage_source: Usage::Source::SDK_BATCH_RESULT,
|
|
71
|
-
|
|
71
|
+
**response_fields(message)
|
|
72
72
|
)
|
|
73
73
|
)
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
def
|
|
78
|
-
|
|
77
|
+
def record_refused_hop(request, response)
|
|
78
|
+
record_safely { record_message(response.parse, request: request.body, latency_ms: nil) }
|
|
79
|
+
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def record_refused_stream_hop(hop)
|
|
84
|
+
return unless active?
|
|
85
|
+
|
|
86
|
+
usage = hop.dig(:refused, :usage).to_h.deep_symbolize_keys
|
|
87
|
+
output = usage[:output_tokens].to_i.positive?
|
|
88
|
+
usage = usage.slice(:server_tool_use) if output
|
|
89
|
+
return if output && Providers::Anthropic::UsageExtractor.service_line_items(usage).empty?
|
|
90
|
+
|
|
91
|
+
record_safely do
|
|
92
|
+
LlmCostTracker::Tracker.record(
|
|
93
|
+
event: Providers::Anthropic::ResponseParser.event_from_usage(
|
|
94
|
+
usage: usage,
|
|
95
|
+
model: hop[:model],
|
|
96
|
+
provider_response_id: nil,
|
|
97
|
+
usage_source: Usage::Source::STREAM_FINAL,
|
|
98
|
+
stream: true,
|
|
99
|
+
stop_reason: ("refusal" unless output),
|
|
100
|
+
refusal_category: hop.dig(:refused, :stop_details, "category")
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def response_fields(message)
|
|
109
|
+
{ stop_reason: message.stop_reason, refusal_category: message.stop_details&.category,
|
|
110
|
+
content: message.deep_to_h[:content] }
|
|
79
111
|
end
|
|
80
112
|
end
|
|
81
113
|
|
|
@@ -109,6 +141,27 @@ module LlmCostTracker
|
|
|
109
141
|
end
|
|
110
142
|
end
|
|
111
143
|
|
|
144
|
+
module FallbackMiddlewarePatch
|
|
145
|
+
def call(req, nxt)
|
|
146
|
+
return super if req.streaming? || !LlmCostTracker::Integrations::Anthropic.active?
|
|
147
|
+
|
|
148
|
+
hops = []
|
|
149
|
+
response = super(req, ->(hop_req) { nxt.call(hop_req).tap { |hop| hops << [hop_req, hop] } })
|
|
150
|
+
answered = hops.select { |_hop_req, hop| hop.status < 300 }
|
|
151
|
+
answered[...-1].each { |hop| LlmCostTracker::Integrations::Anthropic.record_refused_hop(*hop) }
|
|
152
|
+
response
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def consume_hop(*args, **kwargs)
|
|
158
|
+
refused = Thread.current[:llm_cost_tracker_refused_hop]
|
|
159
|
+
Thread.current[:llm_cost_tracker_refused_hop] = nil
|
|
160
|
+
LlmCostTracker::Integrations::Anthropic.record_refused_stream_hop(refused) if refused && kwargs[:splice]
|
|
161
|
+
super.tap { |hop| Thread.current[:llm_cost_tracker_refused_hop] = hop if hop[:refused] }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
112
165
|
module BatchesPatch
|
|
113
166
|
def results_streaming(*args, **kwargs)
|
|
114
167
|
raw = super
|
|
@@ -128,10 +181,16 @@ module LlmCostTracker
|
|
|
128
181
|
def each(&block)
|
|
129
182
|
return enum_for(:each) unless block
|
|
130
183
|
|
|
184
|
+
deferred = nil
|
|
131
185
|
@raw_stream.each do |response|
|
|
132
|
-
|
|
186
|
+
begin
|
|
187
|
+
LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
|
|
188
|
+
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
|
|
189
|
+
deferred ||= e
|
|
190
|
+
end
|
|
133
191
|
block.call(response)
|
|
134
192
|
end
|
|
193
|
+
raise deferred if deferred
|
|
135
194
|
end
|
|
136
195
|
|
|
137
196
|
def respond_to_missing?(name, include_private = false)
|
|
@@ -16,20 +16,19 @@ module LlmCostTracker
|
|
|
16
16
|
@integration_name ||= name.demodulize.underscore.to_sym
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def provider
|
|
20
|
-
@provider = value.to_s if value
|
|
21
|
-
@provider ||= integration_name.to_s
|
|
22
|
-
end
|
|
19
|
+
def provider = integration_name.to_s
|
|
23
20
|
|
|
24
21
|
def active?
|
|
25
|
-
LlmCostTracker.configuration.instrumented?(integration_name)
|
|
22
|
+
LlmCostTracker.configuration.enabled && LlmCostTracker.configuration.instrumented?(integration_name)
|
|
26
23
|
end
|
|
27
24
|
|
|
28
25
|
def install
|
|
29
26
|
validate_contract!
|
|
27
|
+
Logging.warn(untested_version_message) if untested_version?
|
|
30
28
|
patch_targets.each do |target|
|
|
31
29
|
target_class = target.fetch(:constant_name).to_s.safe_constantize
|
|
32
|
-
|
|
30
|
+
patch = target.fetch(:patch)
|
|
31
|
+
target_class.prepend(patch) if target_class && !target_class.ancestors.include?(patch)
|
|
33
32
|
end
|
|
34
33
|
end
|
|
35
34
|
|
|
@@ -39,6 +38,7 @@ module LlmCostTracker
|
|
|
39
38
|
if problems.any?
|
|
40
39
|
return Check.new(:warn, name, "#{name} integration cannot be installed: #{problems.join('; ')}")
|
|
41
40
|
end
|
|
41
|
+
return Check.new(:warn, name, untested_version_message) if untested_version?
|
|
42
42
|
|
|
43
43
|
installed = patch_targets.reject { |target| target.fetch(:optional) }.all? do |target|
|
|
44
44
|
target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
|
|
@@ -60,12 +60,46 @@ module LlmCostTracker
|
|
|
60
60
|
|
|
61
61
|
def record_safely
|
|
62
62
|
yield
|
|
63
|
-
rescue LlmCostTracker::
|
|
63
|
+
rescue *LlmCostTracker::CALLER_ERRORS
|
|
64
64
|
raise
|
|
65
65
|
rescue StandardError => e
|
|
66
66
|
Logging.warn("#{integration_name} integration failed to record usage: #{e.class}: #{e.message}")
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
def record_passthrough(provider:,
|
|
70
|
+
model:,
|
|
71
|
+
response:,
|
|
72
|
+
latency_ms:,
|
|
73
|
+
service_line_items: [],
|
|
74
|
+
usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
|
|
75
|
+
pricing_mode: nil,
|
|
76
|
+
**token_attributes)
|
|
77
|
+
return unless active?
|
|
78
|
+
|
|
79
|
+
record_safely do
|
|
80
|
+
LlmCostTracker::Tracker.record(
|
|
81
|
+
event: LlmCostTracker::Event.build(
|
|
82
|
+
provider: provider,
|
|
83
|
+
model: model,
|
|
84
|
+
token_usage: LlmCostTracker::Usage::TokenUsage.build(**token_attributes),
|
|
85
|
+
usage_source: usage_source,
|
|
86
|
+
pricing_mode: pricing_mode,
|
|
87
|
+
provider_response_id: provider_response_id_for(response),
|
|
88
|
+
service_line_items: service_line_items
|
|
89
|
+
),
|
|
90
|
+
latency_ms: latency_ms
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def record_once(event)
|
|
96
|
+
LlmCostTracker::Tracker.record(event: event.keyed_by_response_id)
|
|
97
|
+
rescue ActiveRecord::RecordNotUnique
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def provider_response_id_for(response) = response&.try(:id)
|
|
102
|
+
|
|
69
103
|
def request_params(args, kwargs)
|
|
70
104
|
params =
|
|
71
105
|
case args.first
|
|
@@ -106,24 +140,24 @@ module LlmCostTracker
|
|
|
106
140
|
).wrap
|
|
107
141
|
end
|
|
108
142
|
|
|
109
|
-
def stream_collector(request
|
|
143
|
+
def stream_collector(request)
|
|
110
144
|
LlmCostTracker::Capture::StreamCollector.new(
|
|
111
145
|
provider: provider,
|
|
112
146
|
model: request[:model],
|
|
113
|
-
pricing_mode: stream_pricing_mode(request),
|
|
114
147
|
request: request
|
|
115
148
|
)
|
|
116
149
|
end
|
|
117
150
|
|
|
118
|
-
def stream_pricing_mode(_request)
|
|
119
|
-
nil
|
|
120
|
-
end
|
|
121
|
-
|
|
122
151
|
def minimum_version(value = nil)
|
|
123
152
|
@minimum_version = value if value
|
|
124
153
|
@minimum_version
|
|
125
154
|
end
|
|
126
155
|
|
|
156
|
+
def maximum_version(value = nil)
|
|
157
|
+
@maximum_version = value if value
|
|
158
|
+
@maximum_version
|
|
159
|
+
end
|
|
160
|
+
|
|
127
161
|
def gem_version
|
|
128
162
|
Gem.loaded_specs[integration_name.to_s]&.version
|
|
129
163
|
end
|
|
@@ -160,6 +194,15 @@ module LlmCostTracker
|
|
|
160
194
|
["#{name} >= #{minimum_version} is required, detected #{version}"]
|
|
161
195
|
end
|
|
162
196
|
|
|
197
|
+
def untested_version?
|
|
198
|
+
maximum_version && gem_version && gem_version >= Gem::Version.new(maximum_version)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def untested_version_message
|
|
202
|
+
"#{integration_name} #{gem_version} is newer than the tested range (< #{maximum_version}); " \
|
|
203
|
+
"its calls may not be recorded"
|
|
204
|
+
end
|
|
205
|
+
|
|
163
206
|
def target_problems
|
|
164
207
|
patch_targets.flat_map do |target|
|
|
165
208
|
constant_name = target.fetch(:constant_name)
|
|
@@ -180,12 +223,6 @@ module LlmCostTracker
|
|
|
180
223
|
"#{target.fetch(:constant_name)}##{method_name} is not available"
|
|
181
224
|
end
|
|
182
225
|
end
|
|
183
|
-
|
|
184
|
-
def install_patch(target, patch)
|
|
185
|
-
return if target&.ancestors&.include?(patch)
|
|
186
|
-
|
|
187
|
-
target.prepend(patch)
|
|
188
|
-
end
|
|
189
226
|
end
|
|
190
227
|
end
|
|
191
228
|
end
|
|
@@ -13,16 +13,17 @@ module LlmCostTracker
|
|
|
13
13
|
class << self
|
|
14
14
|
def maybe_capture(batch, resource:)
|
|
15
15
|
return unless Openai.active?
|
|
16
|
-
return unless batch.status.to_s
|
|
16
|
+
return unless %w[completed expired cancelled].include?(batch.status.to_s)
|
|
17
17
|
return unless batch.output_file_id && batch.id
|
|
18
18
|
return if captured?(batch.id)
|
|
19
19
|
|
|
20
20
|
client = resource.instance_variable_get(:@client)
|
|
21
21
|
host = Openai.client_host_for(resource)
|
|
22
22
|
Openai.record_safely do
|
|
23
|
-
|
|
24
|
-
capture_jsonl(
|
|
23
|
+
jsonl = client.files.content(batch.output_file_id).read
|
|
24
|
+
deferred = capture_jsonl(jsonl, host: host, model: batch.model)
|
|
25
25
|
mark_captured(batch.id)
|
|
26
|
+
raise deferred if deferred
|
|
26
27
|
end
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -40,19 +41,20 @@ module LlmCostTracker
|
|
|
40
41
|
end
|
|
41
42
|
end
|
|
42
43
|
|
|
43
|
-
def capture_jsonl(jsonl, host:)
|
|
44
|
+
def capture_jsonl(jsonl, host:, model:)
|
|
45
|
+
deferred = nil
|
|
44
46
|
jsonl.each_line do |line|
|
|
45
|
-
line = line.strip
|
|
46
|
-
next if line.empty?
|
|
47
|
-
|
|
48
47
|
entry = parse_line(line)
|
|
49
48
|
next unless entry
|
|
50
49
|
|
|
51
50
|
response = entry.dig("response", "body")
|
|
52
51
|
next unless response.is_a?(Hash) && response["usage"]
|
|
53
52
|
|
|
54
|
-
record_result(response, host: host)
|
|
53
|
+
record_result({ "id" => entry["id"] }.merge(response), host: host, model: model)
|
|
54
|
+
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
|
|
55
|
+
deferred ||= e
|
|
55
56
|
end
|
|
57
|
+
deferred
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
def parse_line(line)
|
|
@@ -61,19 +63,25 @@ module LlmCostTracker
|
|
|
61
63
|
nil
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
def record_result(response, host:)
|
|
66
|
+
def record_result(response, host:, model:)
|
|
65
67
|
provider = Openai.provider_for_host(host)
|
|
66
68
|
return if LlmCostTracker::Call.already_recorded?(provider: provider, provider_response_id: response["id"])
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
parser = LlmCostTracker::Providers::Openai::ResponseParser
|
|
71
|
+
event = parser.event_from_response(
|
|
69
72
|
response: response,
|
|
70
|
-
request: {},
|
|
73
|
+
request: { "model" => model },
|
|
71
74
|
provider: provider,
|
|
72
75
|
host: host,
|
|
73
76
|
usage_source: LlmCostTracker::Usage::Source::SDK_BATCH_RESULT,
|
|
74
|
-
|
|
77
|
+
# /v1/batches runs regional processing only on the us and eu hosts.
|
|
78
|
+
pricing_mode: parser.combined_pricing_mode(
|
|
79
|
+
host: (host if host.to_s.match?(/\A(?:us|eu)\./i)),
|
|
80
|
+
model: response["model"] || model,
|
|
81
|
+
service_tier: "batch"
|
|
82
|
+
)
|
|
75
83
|
)
|
|
76
|
-
|
|
84
|
+
Openai.record_once(event)
|
|
77
85
|
end
|
|
78
86
|
end
|
|
79
87
|
end
|
|
@@ -45,6 +45,14 @@ module LlmCostTracker
|
|
|
45
45
|
include PatchBuilder.build(record_method: :record_response, methods: %i[create])
|
|
46
46
|
include PatchBuilder.build_stream(methods: %i[stream stream_raw])
|
|
47
47
|
|
|
48
|
+
def retrieve(response_id, *args, **kwargs)
|
|
49
|
+
response = super
|
|
50
|
+
LlmCostTracker::Integrations::Openai.record_retrieved_response(
|
|
51
|
+
response, host: LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
52
|
+
)
|
|
53
|
+
response
|
|
54
|
+
end
|
|
55
|
+
|
|
48
56
|
def retrieve_streaming(response_id, *args, **kwargs)
|
|
49
57
|
LlmCostTracker::Integrations::Openai.wrap_stream(
|
|
50
58
|
args, kwargs, **LlmCostTracker::Integrations::Openai.stream_seam(self)
|