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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
4
|
require_relative "../providers/anthropic/response_parser"
|
|
5
|
+
require_relative "anthropic/patches"
|
|
6
|
+
require_relative "anthropic/batch_results_capture"
|
|
5
7
|
|
|
6
8
|
module LlmCostTracker
|
|
7
9
|
module Integrations
|
|
@@ -17,28 +19,47 @@ module LlmCostTracker
|
|
|
17
19
|
patch_target("Anthropic::Resources::Beta::Messages", with: MessagesPatch, optional: true),
|
|
18
20
|
patch_target("Anthropic::Resources::Messages::Batches", with: BatchesPatch, optional: true),
|
|
19
21
|
patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true),
|
|
20
|
-
patch_target("Anthropic::BetaRefusalFallbackMiddleware",
|
|
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
61
|
request: request,
|
|
41
|
-
|
|
62
|
+
host: host
|
|
42
63
|
),
|
|
43
64
|
latency_ms: latency_ms
|
|
44
65
|
)
|
|
@@ -49,26 +70,19 @@ module LlmCostTracker
|
|
|
49
70
|
return unless active?
|
|
50
71
|
|
|
51
72
|
record_safely do
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
result = response.result
|
|
55
|
-
next unless result.respond_to?(:type) && result.type.to_s == "succeeded"
|
|
56
|
-
|
|
57
|
-
message = result.respond_to?(:message) ? result.message : nil
|
|
73
|
+
message = succeeded_message(response)
|
|
58
74
|
next unless message
|
|
59
|
-
next if
|
|
75
|
+
next if Call.already_recorded?(provider: "anthropic", provider_response_id: message.id)
|
|
60
76
|
|
|
61
77
|
usage = message.usage
|
|
62
|
-
next unless usage
|
|
63
|
-
next if usage.input_tokens.nil? && usage.output_tokens.nil?
|
|
78
|
+
next unless tokens_reported?(usage)
|
|
64
79
|
|
|
65
80
|
record_once(
|
|
66
|
-
|
|
81
|
+
message_event(
|
|
82
|
+
message,
|
|
67
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
|
-
**response_fields(message)
|
|
85
|
+
usage_source: Usage::Source::SDK_BATCH_RESULT
|
|
72
86
|
)
|
|
73
87
|
)
|
|
74
88
|
end
|
|
@@ -76,7 +90,7 @@ module LlmCostTracker
|
|
|
76
90
|
|
|
77
91
|
def record_refused_hop(request, response)
|
|
78
92
|
record_safely { record_message(response.parse, request: request.body, latency_ms: nil) }
|
|
79
|
-
rescue
|
|
93
|
+
rescue BudgetExceededError, UnknownPricingError
|
|
80
94
|
nil
|
|
81
95
|
end
|
|
82
96
|
|
|
@@ -84,123 +98,48 @@ module LlmCostTracker
|
|
|
84
98
|
return unless active?
|
|
85
99
|
|
|
86
100
|
usage = hop.dig(:refused, :usage).to_h.deep_symbolize_keys
|
|
87
|
-
|
|
88
|
-
usage = usage.slice(:server_tool_use) if
|
|
89
|
-
return if
|
|
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?
|
|
90
104
|
|
|
91
105
|
record_safely do
|
|
92
|
-
|
|
106
|
+
Tracker.record(
|
|
93
107
|
event: Providers::Anthropic::ResponseParser.event_from_usage(
|
|
94
108
|
usage: usage,
|
|
95
109
|
model: hop[:model],
|
|
96
110
|
provider_response_id: nil,
|
|
97
111
|
usage_source: Usage::Source::STREAM_FINAL,
|
|
98
112
|
stream: true,
|
|
99
|
-
stop_reason: ("refusal" unless
|
|
113
|
+
stop_reason: ("refusal" unless produced_output),
|
|
100
114
|
refusal_category: hop.dig(:refused, :stop_details, "category")
|
|
101
115
|
)
|
|
102
116
|
)
|
|
103
117
|
end
|
|
104
|
-
rescue
|
|
118
|
+
rescue BudgetExceededError, UnknownPricingError
|
|
105
119
|
nil
|
|
106
120
|
end
|
|
107
121
|
|
|
108
|
-
def response_fields(message)
|
|
109
|
-
{ stop_reason: message.stop_reason, refusal_category: message.stop_details&.category,
|
|
110
|
-
content: message.deep_to_h[:content] }
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
module MessagesPatch
|
|
115
|
-
def create(*args, **kwargs)
|
|
116
|
-
LlmCostTracker::Integrations::Anthropic.wrap_blocking(
|
|
117
|
-
args,
|
|
118
|
-
kwargs,
|
|
119
|
-
record: lambda do |message, request, latency_ms|
|
|
120
|
-
LlmCostTracker::Integrations::Anthropic.record_message(
|
|
121
|
-
message, request: request, latency_ms: latency_ms
|
|
122
|
-
)
|
|
123
|
-
end
|
|
124
|
-
) { super }
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def stream(*args, **kwargs)
|
|
128
|
-
LlmCostTracker::Integrations::Anthropic.wrap_stream(
|
|
129
|
-
args,
|
|
130
|
-
kwargs,
|
|
131
|
-
collector: ->(request) { LlmCostTracker::Integrations::Anthropic.stream_collector(request) }
|
|
132
|
-
) { super }
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def stream_raw(*args, **kwargs)
|
|
136
|
-
LlmCostTracker::Integrations::Anthropic.wrap_stream(
|
|
137
|
-
args,
|
|
138
|
-
kwargs,
|
|
139
|
-
collector: ->(request) { LlmCostTracker::Integrations::Anthropic.stream_collector(request) }
|
|
140
|
-
) { super }
|
|
141
|
-
end
|
|
142
|
-
end
|
|
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
122
|
private
|
|
156
123
|
|
|
157
|
-
def
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
165
|
-
module BatchesPatch
|
|
166
|
-
def results_streaming(*args, **kwargs)
|
|
167
|
-
raw = super
|
|
168
|
-
return raw unless LlmCostTracker::Integrations::Anthropic.active?
|
|
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"
|
|
169
127
|
|
|
170
|
-
|
|
128
|
+
result.message if result.respond_to?(:message)
|
|
171
129
|
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
class BatchResultsCapture
|
|
175
|
-
include Enumerable
|
|
176
|
-
|
|
177
|
-
def initialize(raw_stream)
|
|
178
|
-
@raw_stream = raw_stream
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def each(&block)
|
|
182
|
-
return enum_for(:each) unless block
|
|
183
130
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
begin
|
|
187
|
-
LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
|
|
188
|
-
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
|
|
189
|
-
deferred ||= e
|
|
190
|
-
end
|
|
191
|
-
block.call(response)
|
|
192
|
-
end
|
|
193
|
-
raise deferred if deferred
|
|
131
|
+
def tokens_reported?(usage)
|
|
132
|
+
usage && !(usage.input_tokens.nil? && usage.output_tokens.nil?)
|
|
194
133
|
end
|
|
195
134
|
|
|
196
|
-
def
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
)
|
|
204
143
|
end
|
|
205
144
|
end
|
|
206
145
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Integrations
|
|
7
|
+
module Base
|
|
8
|
+
PatchTarget = Data.define(:constant_name, :patch, :optional, :skip_when_methods_missing, :since) do
|
|
9
|
+
def target_class = constant_name.to_s.safe_constantize
|
|
10
|
+
|
|
11
|
+
def installed? = target_class&.ancestors&.include?(patch)
|
|
12
|
+
|
|
13
|
+
def missing?(version) = target_class.nil? && (since.nil? || version.nil? || version >= Gem::Version.new(since))
|
|
14
|
+
|
|
15
|
+
def install
|
|
16
|
+
target = target_class
|
|
17
|
+
target.prepend(patch) unless target.nil? || target.ancestors.include?(patch)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def problems
|
|
21
|
+
target = target_class
|
|
22
|
+
return [] if target.nil? && optional
|
|
23
|
+
return ["#{constant_name} is not loaded"] unless target
|
|
24
|
+
return [] if skip_when_methods_missing
|
|
25
|
+
|
|
26
|
+
patch.instance_methods.filter_map do |method_name|
|
|
27
|
+
next if target.method_defined?(method_name) || target.private_method_defined?(method_name)
|
|
28
|
+
|
|
29
|
+
"#{constant_name}##{method_name} is not available"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -8,6 +8,7 @@ require_relative "../logging"
|
|
|
8
8
|
require_relative "../timing"
|
|
9
9
|
require_relative "../capture/stream_collector"
|
|
10
10
|
require_relative "../capture/stream_tracker"
|
|
11
|
+
require_relative "base/patch_target"
|
|
11
12
|
|
|
12
13
|
module LlmCostTracker
|
|
13
14
|
module Integrations
|
|
@@ -22,39 +23,57 @@ module LlmCostTracker
|
|
|
22
23
|
LlmCostTracker.configuration.enabled && LlmCostTracker.configuration.instrumented?(integration_name)
|
|
23
24
|
end
|
|
24
25
|
|
|
26
|
+
def minimum_version(value = nil)
|
|
27
|
+
@minimum_version = value if value
|
|
28
|
+
@minimum_version
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def maximum_version(value = nil)
|
|
32
|
+
@maximum_version = value if value
|
|
33
|
+
@maximum_version
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def gem_version
|
|
37
|
+
Gem.loaded_specs[integration_name.to_s]&.version
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def patch_targets = []
|
|
41
|
+
|
|
42
|
+
def patch_target(constant_name, with:, optional: false, skip_when_methods_missing: false, since: nil)
|
|
43
|
+
PatchTarget.new(constant_name:, patch: with, optional:, skip_when_methods_missing:, since:)
|
|
44
|
+
end
|
|
45
|
+
|
|
25
46
|
def install
|
|
26
47
|
validate_contract!
|
|
27
48
|
Logging.warn(untested_version_message) if untested_version?
|
|
28
|
-
patch_targets.each
|
|
29
|
-
target_class = target.fetch(:constant_name).to_s.safe_constantize
|
|
30
|
-
patch = target.fetch(:patch)
|
|
31
|
-
target_class.prepend(patch) if target_class && !target_class.ancestors.include?(patch)
|
|
32
|
-
end
|
|
49
|
+
patch_targets.each(&:install)
|
|
33
50
|
end
|
|
34
51
|
|
|
35
52
|
def status
|
|
36
53
|
name = integration_name.to_s
|
|
37
|
-
|
|
38
|
-
if
|
|
39
|
-
return Check.new(:warn, name, "#{name} integration cannot be installed: #{problems.join('; ')}")
|
|
40
|
-
end
|
|
54
|
+
violation = contract_violation
|
|
55
|
+
return Check.new(:warn, name, violation) if violation
|
|
41
56
|
return Check.new(:warn, name, untested_version_message) if untested_version?
|
|
57
|
+
return Check.new(:warn, name, "#{name} integration is enabled but not installed") unless installed?
|
|
42
58
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
end
|
|
46
|
-
return Check.new(:ok, name, "#{name} integration installed") if installed
|
|
59
|
+
missing = missing_targets
|
|
60
|
+
return Check.new(:ok, name, "#{name} integration installed") if missing.empty?
|
|
47
61
|
|
|
48
|
-
Check.new(
|
|
62
|
+
Check.new(
|
|
63
|
+
:warn,
|
|
64
|
+
name,
|
|
65
|
+
"#{name} integration installed without #{missing.join(', ')}; their calls are not recorded"
|
|
66
|
+
)
|
|
49
67
|
end
|
|
50
68
|
|
|
51
|
-
def enforce_budget!(request:, provider: self.provider)
|
|
69
|
+
def enforce_budget!(request:, provider: self.provider, tags: nil)
|
|
52
70
|
return unless active?
|
|
53
71
|
|
|
54
72
|
LlmCostTracker::Budget.enforce!(
|
|
55
73
|
provider: provider,
|
|
56
74
|
model: request[:model],
|
|
57
|
-
request: request
|
|
75
|
+
request: request,
|
|
76
|
+
tags: tags
|
|
58
77
|
)
|
|
59
78
|
end
|
|
60
79
|
|
|
@@ -92,21 +111,23 @@ module LlmCostTracker
|
|
|
92
111
|
end
|
|
93
112
|
end
|
|
94
113
|
|
|
95
|
-
def record_once(event)
|
|
96
|
-
LlmCostTracker::Tracker.record(event: event.keyed_by_response_id)
|
|
114
|
+
def record_once(event, **)
|
|
115
|
+
LlmCostTracker::Tracker.record(event: event.keyed_by_response_id, **)
|
|
97
116
|
rescue ActiveRecord::RecordNotUnique
|
|
98
117
|
nil
|
|
99
118
|
end
|
|
100
119
|
|
|
101
|
-
def provider_response_id_for(response) = response
|
|
120
|
+
def provider_response_id_for(response) = (response.id if response.respond_to?(:id))
|
|
121
|
+
|
|
122
|
+
def client_host(client)
|
|
123
|
+
URI.parse(client.base_url.to_s).host if client
|
|
124
|
+
rescue URI::InvalidURIError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
102
127
|
|
|
103
128
|
def request_params(args, kwargs)
|
|
104
|
-
params =
|
|
105
|
-
|
|
106
|
-
when Hash then args.first
|
|
107
|
-
when nil then {}
|
|
108
|
-
else args.first.to_h
|
|
109
|
-
end
|
|
129
|
+
params = args.first
|
|
130
|
+
params = params.to_h unless params.is_a?(Hash)
|
|
110
131
|
params.merge(kwargs).with_indifferent_access
|
|
111
132
|
rescue StandardError
|
|
112
133
|
kwargs.to_h.with_indifferent_access
|
|
@@ -129,58 +150,36 @@ module LlmCostTracker
|
|
|
129
150
|
track_stream(stream, collector: built)
|
|
130
151
|
end
|
|
131
152
|
|
|
132
|
-
def
|
|
133
|
-
return stream unless active?
|
|
134
|
-
|
|
135
|
-
LlmCostTracker::Capture::StreamTracker.new(
|
|
136
|
-
stream: stream,
|
|
137
|
-
collector: collector,
|
|
138
|
-
active: -> { active? },
|
|
139
|
-
finish: ->(errored) { record_safely { collector.finish!(errored: errored) } }
|
|
140
|
-
).wrap
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def stream_collector(request)
|
|
153
|
+
def stream_collector(request, pricing_mode: nil)
|
|
144
154
|
LlmCostTracker::Capture::StreamCollector.new(
|
|
145
155
|
provider: provider,
|
|
146
156
|
model: request[:model],
|
|
157
|
+
pricing_mode: pricing_mode,
|
|
147
158
|
request: request
|
|
148
159
|
)
|
|
149
160
|
end
|
|
150
161
|
|
|
151
|
-
|
|
152
|
-
@minimum_version = value if value
|
|
153
|
-
@minimum_version
|
|
154
|
-
end
|
|
162
|
+
private
|
|
155
163
|
|
|
156
|
-
def
|
|
157
|
-
|
|
158
|
-
@maximum_version
|
|
159
|
-
end
|
|
164
|
+
def track_stream(stream, collector:)
|
|
165
|
+
return stream unless active?
|
|
160
166
|
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
LlmCostTracker::Capture::StreamTracker.new(
|
|
168
|
+
stream: stream,
|
|
169
|
+
collector: collector,
|
|
170
|
+
active: -> { active? },
|
|
171
|
+
finish: ->(errored) { record_safely { collector.finish!(errored: errored) } }
|
|
172
|
+
).wrap
|
|
163
173
|
end
|
|
164
174
|
|
|
165
|
-
def
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
{
|
|
169
|
-
constant_name: constant_name,
|
|
170
|
-
patch: with,
|
|
171
|
-
method_names: with.instance_methods,
|
|
172
|
-
optional: optional,
|
|
173
|
-
skip_when_methods_missing: skip_when_methods_missing
|
|
174
|
-
}
|
|
175
|
+
def validate_contract!
|
|
176
|
+
violation = contract_violation
|
|
177
|
+
raise Error, violation if violation
|
|
175
178
|
end
|
|
176
179
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def validate_contract!
|
|
180
|
+
def contract_violation
|
|
180
181
|
problems = version_problems + target_problems
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
raise Error, "#{integration_name} integration cannot be installed: #{problems.join('; ')}"
|
|
182
|
+
"#{integration_name} integration cannot be installed: #{problems.join('; ')}" if problems.any?
|
|
184
183
|
end
|
|
185
184
|
|
|
186
185
|
def version_problems
|
|
@@ -203,26 +202,11 @@ module LlmCostTracker
|
|
|
203
202
|
"its calls may not be recorded"
|
|
204
203
|
end
|
|
205
204
|
|
|
206
|
-
def target_problems
|
|
207
|
-
patch_targets.flat_map do |target|
|
|
208
|
-
constant_name = target.fetch(:constant_name)
|
|
209
|
-
target_class = constant_name.to_s.safe_constantize
|
|
210
|
-
next [] if target_class.nil? && target.fetch(:optional)
|
|
211
|
-
next ["#{constant_name} is not loaded"] unless target_class
|
|
212
|
-
|
|
213
|
-
missing_methods(target_class, target)
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def missing_methods(target_class, target)
|
|
218
|
-
return [] if target[:skip_when_methods_missing]
|
|
205
|
+
def target_problems = patch_targets.flat_map(&:problems)
|
|
219
206
|
|
|
220
|
-
|
|
221
|
-
next if target_class.method_defined?(method_name) || target_class.private_method_defined?(method_name)
|
|
207
|
+
def missing_targets = patch_targets.select { |target| target.missing?(gem_version) }.map(&:constant_name).uniq
|
|
222
208
|
|
|
223
|
-
|
|
224
|
-
end
|
|
225
|
-
end
|
|
209
|
+
def installed? = patch_targets.reject(&:optional).all?(&:installed?)
|
|
226
210
|
end
|
|
227
211
|
end
|
|
228
212
|
end
|
|
@@ -6,19 +6,16 @@ module LlmCostTracker
|
|
|
6
6
|
module Integrations
|
|
7
7
|
module Openai
|
|
8
8
|
module BatchCapture
|
|
9
|
+
FINISHED_STATUSES = %w[completed expired cancelled].freeze
|
|
9
10
|
DEDUP_LIMIT = 1024
|
|
10
11
|
MUTEX = Mutex.new
|
|
11
|
-
private_constant :DEDUP_LIMIT, :MUTEX
|
|
12
|
+
private_constant :FINISHED_STATUSES, :DEDUP_LIMIT, :MUTEX
|
|
12
13
|
|
|
13
14
|
class << self
|
|
14
|
-
def
|
|
15
|
-
return unless Openai.active?
|
|
16
|
-
return unless %w[completed expired cancelled].include?(batch.status.to_s)
|
|
17
|
-
return unless batch.output_file_id && batch.id
|
|
18
|
-
return if captured?(batch.id)
|
|
15
|
+
def capture(batch, client:)
|
|
16
|
+
return unless Openai.active? && capturable?(batch)
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
host = Openai.client_host_for(resource)
|
|
18
|
+
host = Openai.client_host(client)
|
|
22
19
|
Openai.record_safely do
|
|
23
20
|
jsonl = client.files.content(batch.output_file_id).read
|
|
24
21
|
deferred = capture_jsonl(jsonl, host: host, model: batch.model)
|
|
@@ -29,6 +26,10 @@ module LlmCostTracker
|
|
|
29
26
|
|
|
30
27
|
private
|
|
31
28
|
|
|
29
|
+
def capturable?(batch)
|
|
30
|
+
FINISHED_STATUSES.include?(batch.status.to_s) && batch.output_file_id && batch.id && !captured?(batch.id)
|
|
31
|
+
end
|
|
32
|
+
|
|
32
33
|
def captured?(batch_id)
|
|
33
34
|
MUTEX.synchronize { @dedup&.include?(batch_id) || false }
|
|
34
35
|
end
|
|
@@ -51,7 +52,7 @@ module LlmCostTracker
|
|
|
51
52
|
next unless response.is_a?(Hash) && response["usage"]
|
|
52
53
|
|
|
53
54
|
record_result({ "id" => entry["id"] }.merge(response), host: host, model: model)
|
|
54
|
-
rescue
|
|
55
|
+
rescue BudgetExceededError, UnknownPricingError => e
|
|
55
56
|
deferred ||= e
|
|
56
57
|
end
|
|
57
58
|
deferred
|
|
@@ -65,24 +66,24 @@ module LlmCostTracker
|
|
|
65
66
|
|
|
66
67
|
def record_result(response, host:, model:)
|
|
67
68
|
provider = Openai.provider_for_host(host)
|
|
68
|
-
return if
|
|
69
|
+
return if Call.already_recorded?(provider: provider, provider_response_id: response["id"])
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
event = parser.event_from_response(
|
|
71
|
+
event = Providers::Openai::ResponseParser.event_from_response(
|
|
72
72
|
response: response,
|
|
73
73
|
request: { "model" => model },
|
|
74
74
|
provider: provider,
|
|
75
75
|
host: host,
|
|
76
|
-
usage_source:
|
|
77
|
-
|
|
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
|
-
)
|
|
76
|
+
usage_source: Usage::Source::SDK_BATCH_RESULT,
|
|
77
|
+
pricing_mode: batch_pricing_mode(host, response["model"] || model)
|
|
83
78
|
)
|
|
84
79
|
Openai.record_once(event)
|
|
85
80
|
end
|
|
81
|
+
|
|
82
|
+
def batch_pricing_mode(host, model)
|
|
83
|
+
Providers::Openai::ResponseParser.combined_pricing_mode(
|
|
84
|
+
host: (host if host.to_s.match?(/\A(?:us|eu)\./i)), model: model, service_tier: "batch"
|
|
85
|
+
)
|
|
86
|
+
end
|
|
86
87
|
end
|
|
87
88
|
end
|
|
88
89
|
end
|