llm_cost_tracker 0.9.0 → 0.11.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 +55 -0
- data/README.md +6 -2
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
- data/config/routes.rb +1 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +29 -8
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
- data/lib/llm_cost_tracker/configuration.rb +30 -44
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor.rb +80 -25
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
- data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
- data/lib/llm_cost_tracker/parsers.rb +29 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +71 -43
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -5
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +31 -12
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
4
|
require_relative "../billing/line_item"
|
|
5
|
-
require_relative "../
|
|
5
|
+
require_relative "../providers/azure/hosts"
|
|
6
|
+
require_relative "../providers/openai/model_families"
|
|
7
|
+
require_relative "../providers/openai/service_charges"
|
|
6
8
|
|
|
7
9
|
module LlmCostTracker
|
|
8
10
|
module Integrations
|
|
@@ -24,22 +26,35 @@ module LlmCostTracker
|
|
|
24
26
|
|
|
25
27
|
def stream_collector(request, host: nil)
|
|
26
28
|
LlmCostTracker::Capture::StreamCollector.new(
|
|
27
|
-
provider:
|
|
29
|
+
provider: provider_for_host(host),
|
|
28
30
|
model: request[:model],
|
|
29
31
|
pricing_mode: stream_pricing_mode(request, host: host),
|
|
30
32
|
request: request
|
|
31
33
|
)
|
|
32
34
|
end
|
|
33
35
|
|
|
36
|
+
def wrap_stream_call(args, kwargs, resource)
|
|
37
|
+
request = request_params(args, kwargs)
|
|
38
|
+
enforce_budget!(request: request)
|
|
39
|
+
host = client_host_for(resource)
|
|
40
|
+
collector = stream_collector(request, host: host)
|
|
41
|
+
stream = yield(normalize_sdk_args(args, kwargs), collector)
|
|
42
|
+
track_stream(stream, collector: collector)
|
|
43
|
+
end
|
|
44
|
+
|
|
34
45
|
def client_host_for(resource)
|
|
35
46
|
client = resource.instance_variable_get(:@client)
|
|
36
|
-
return nil unless client
|
|
47
|
+
return nil unless client
|
|
37
48
|
|
|
38
|
-
URI.parse(client.
|
|
49
|
+
URI.parse(client.base_url.to_s).host
|
|
39
50
|
rescue URI::InvalidURIError
|
|
40
51
|
nil
|
|
41
52
|
end
|
|
42
53
|
|
|
54
|
+
def provider_for_host(host)
|
|
55
|
+
LlmCostTracker::Providers::Azure::Hosts.openai?(host) ? "azure_openai" : "openai"
|
|
56
|
+
end
|
|
57
|
+
|
|
43
58
|
def minimum_version
|
|
44
59
|
"0.59.0"
|
|
45
60
|
end
|
|
@@ -97,8 +112,8 @@ module LlmCostTracker
|
|
|
97
112
|
cache_read = cache_read_input_tokens(usage)
|
|
98
113
|
model = object_value(response, :model) || request[:model]
|
|
99
114
|
LlmCostTracker::Tracker.record(
|
|
100
|
-
|
|
101
|
-
provider:
|
|
115
|
+
event: Event.build(
|
|
116
|
+
provider: provider_for_host(host),
|
|
102
117
|
model: model,
|
|
103
118
|
pricing_mode: LlmCostTracker::Parsers::OpenaiUsage.combined_pricing_mode(
|
|
104
119
|
host: host,
|
|
@@ -115,7 +130,7 @@ module LlmCostTracker
|
|
|
115
130
|
end
|
|
116
131
|
end
|
|
117
132
|
|
|
118
|
-
def record_image(response, request:, latency_ms:)
|
|
133
|
+
def record_image(response, request:, latency_ms:, host: nil)
|
|
119
134
|
usage = object_value(response, :usage)
|
|
120
135
|
raw_input = usage ? object_value(usage, :input_tokens).to_i : 0
|
|
121
136
|
raw_output = usage ? object_value(usage, :output_tokens).to_i : 0
|
|
@@ -127,6 +142,7 @@ module LlmCostTracker
|
|
|
127
142
|
model: request[:model],
|
|
128
143
|
response: response,
|
|
129
144
|
latency_ms: latency_ms,
|
|
145
|
+
host: host,
|
|
130
146
|
input_tokens: text_input,
|
|
131
147
|
image_input_tokens: image_input,
|
|
132
148
|
output_tokens: text_output,
|
|
@@ -144,11 +160,12 @@ module LlmCostTracker
|
|
|
144
160
|
[image_tokens, text_tokens]
|
|
145
161
|
end
|
|
146
162
|
|
|
147
|
-
def record_transcription(response, request:, latency_ms:)
|
|
163
|
+
def record_transcription(response, request:, latency_ms:, host: nil)
|
|
148
164
|
record_passthrough(
|
|
149
165
|
model: request[:model],
|
|
150
166
|
response: response,
|
|
151
167
|
latency_ms: latency_ms,
|
|
168
|
+
host: host,
|
|
152
169
|
**transcription_token_attributes(object_value(response, :usage))
|
|
153
170
|
)
|
|
154
171
|
end
|
|
@@ -165,24 +182,22 @@ module LlmCostTracker
|
|
|
165
182
|
}
|
|
166
183
|
end
|
|
167
184
|
|
|
168
|
-
def record_speech(_response, request:, latency_ms:)
|
|
185
|
+
def record_speech(_response, request:, latency_ms:, host: nil)
|
|
169
186
|
record_passthrough(
|
|
170
187
|
model: request[:model],
|
|
171
188
|
response: nil,
|
|
172
189
|
latency_ms: latency_ms,
|
|
190
|
+
host: host,
|
|
173
191
|
input_tokens: 0,
|
|
174
192
|
output_tokens: 0,
|
|
175
193
|
service_line_items: speech_line_items(request)
|
|
176
194
|
)
|
|
177
195
|
end
|
|
178
196
|
|
|
179
|
-
CHARACTER_BILLED_TTS_MODELS = /\Atts-1(-hd)?\z/
|
|
180
|
-
private_constant :CHARACTER_BILLED_TTS_MODELS
|
|
181
|
-
|
|
182
197
|
def speech_line_items(request)
|
|
183
198
|
input = request[:input]
|
|
184
199
|
return [] unless input.is_a?(String)
|
|
185
|
-
return [] unless
|
|
200
|
+
return [] unless LlmCostTracker::Providers::Openai::ModelFamilies.character_billed_tts?(request[:model])
|
|
186
201
|
|
|
187
202
|
[LlmCostTracker::Billing::LineItem.build(
|
|
188
203
|
component_key: :text_to_speech_character,
|
|
@@ -193,23 +208,24 @@ module LlmCostTracker
|
|
|
193
208
|
)]
|
|
194
209
|
end
|
|
195
210
|
|
|
196
|
-
def record_moderation(response, request:, latency_ms:)
|
|
211
|
+
def record_moderation(response, request:, latency_ms:, host: nil)
|
|
197
212
|
record_passthrough(
|
|
198
213
|
model: object_value(response, :model) || request[:model],
|
|
199
214
|
response: response,
|
|
200
215
|
latency_ms: latency_ms,
|
|
216
|
+
host: host,
|
|
201
217
|
input_tokens: 0,
|
|
202
218
|
output_tokens: 0
|
|
203
219
|
)
|
|
204
220
|
end
|
|
205
221
|
|
|
206
|
-
def record_passthrough(model:, response:, latency_ms:, service_line_items: [], **token_attributes)
|
|
222
|
+
def record_passthrough(model:, response:, latency_ms:, host: nil, service_line_items: [], **token_attributes)
|
|
207
223
|
return unless active?
|
|
208
224
|
|
|
209
225
|
record_safely do
|
|
210
226
|
LlmCostTracker::Tracker.record(
|
|
211
|
-
|
|
212
|
-
provider:
|
|
227
|
+
event: Event.build(
|
|
228
|
+
provider: provider_for_host(host),
|
|
213
229
|
model: model,
|
|
214
230
|
token_usage: TokenUsage.build(**token_attributes),
|
|
215
231
|
usage_source: :sdk_response,
|
|
@@ -222,16 +238,41 @@ module LlmCostTracker
|
|
|
222
238
|
end
|
|
223
239
|
|
|
224
240
|
def service_line_items_from(response, request: nil)
|
|
241
|
+
model = object_value(response, :model) || request&.dig(:model)
|
|
225
242
|
output = object_value(response, :output)
|
|
226
|
-
|
|
243
|
+
output_items = output.respond_to?(:each) ? output.map { |item| normalize_output_item(item) }.compact : []
|
|
244
|
+
chat_search = output_items.empty? ? chat_completions_search_item(response, model: model) : nil
|
|
245
|
+
output_items << chat_search if chat_search
|
|
246
|
+
return [] if output_items.empty?
|
|
227
247
|
|
|
228
|
-
LlmCostTracker::
|
|
229
|
-
|
|
230
|
-
request: request,
|
|
231
|
-
model: object_value(response, :model) || request&.dig(:model)
|
|
248
|
+
LlmCostTracker::Providers::Openai::ServiceCharges.line_items_from_output(
|
|
249
|
+
output_items, request: request, model: model
|
|
232
250
|
)
|
|
233
251
|
end
|
|
234
252
|
|
|
253
|
+
def chat_completions_search_item(response, model: nil)
|
|
254
|
+
choices = object_value(response, :choices)
|
|
255
|
+
return nil unless choices.respond_to?(:any?)
|
|
256
|
+
|
|
257
|
+
provider_field = if choices.any? { |choice| choice_used_url_citation?(choice) }
|
|
258
|
+
LlmCostTracker::Providers::Openai::ServiceCharges::CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD
|
|
259
|
+
elsif LlmCostTracker::Providers::Openai::ModelFamilies.chat_completions_search?(model)
|
|
260
|
+
LlmCostTracker::Providers::Openai::ServiceCharges::CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD
|
|
261
|
+
end
|
|
262
|
+
return nil unless provider_field
|
|
263
|
+
|
|
264
|
+
{ "type" => "web_search_call", "id" => object_value(response, :id),
|
|
265
|
+
"action" => { "type" => "search" }, "provider_field" => provider_field }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def choice_used_url_citation?(choice)
|
|
269
|
+
message = object_value(choice, :message)
|
|
270
|
+
annotations = message && object_value(message, :annotations)
|
|
271
|
+
return false unless annotations.respond_to?(:any?)
|
|
272
|
+
|
|
273
|
+
annotations.any? { |annotation| object_value(annotation, :type).to_s == "url_citation" }
|
|
274
|
+
end
|
|
275
|
+
|
|
235
276
|
def normalize_output_item(item)
|
|
236
277
|
return item if item.is_a?(Hash)
|
|
237
278
|
return nil if item.nil?
|
|
@@ -252,9 +293,6 @@ module LlmCostTracker
|
|
|
252
293
|
{ "type" => object_value(action, :type)&.to_s }
|
|
253
294
|
end
|
|
254
295
|
|
|
255
|
-
IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
|
|
256
|
-
private_constant :IMAGE_OUTPUT_MODEL_PATTERN
|
|
257
|
-
|
|
258
296
|
def token_usage(usage:, input_tokens:, output_tokens:, cache_read:, model: nil)
|
|
259
297
|
audio_input = audio_input_tokens(usage)
|
|
260
298
|
audio_output = audio_output_tokens(usage)
|
|
@@ -266,7 +304,7 @@ module LlmCostTracker
|
|
|
266
304
|
image_output_details: image_output_details,
|
|
267
305
|
text_output_details: text_output_details,
|
|
268
306
|
audio_output: audio_output,
|
|
269
|
-
default_to_image:
|
|
307
|
+
default_to_image: LlmCostTracker::Providers::Openai::ModelFamilies.image_output?(model)
|
|
270
308
|
)
|
|
271
309
|
|
|
272
310
|
TokenUsage.build(
|
|
@@ -317,137 +355,72 @@ module LlmCostTracker
|
|
|
317
355
|
end
|
|
318
356
|
end
|
|
319
357
|
|
|
320
|
-
module ResponsesPatch
|
|
321
|
-
def create(*args, **kwargs)
|
|
322
|
-
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
323
|
-
started_at = LlmCostTracker::Timing.now_monotonic
|
|
324
|
-
response = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
|
|
325
|
-
LlmCostTracker::Integrations::Openai.record_response(
|
|
326
|
-
response,
|
|
327
|
-
request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
|
|
328
|
-
latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at),
|
|
329
|
-
host: LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
330
|
-
)
|
|
331
|
-
response
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
def stream(*args, **kwargs)
|
|
335
|
-
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
336
|
-
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
337
|
-
host = LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
338
|
-
collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
|
|
339
|
-
stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
|
|
340
|
-
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
def stream_raw(*args, **kwargs)
|
|
344
|
-
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
345
|
-
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
346
|
-
host = LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
347
|
-
collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
|
|
348
|
-
stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
|
|
349
|
-
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
def retrieve_streaming(response_id, *args, **kwargs)
|
|
353
|
-
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
354
|
-
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
355
|
-
host = LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
356
|
-
collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
|
|
357
|
-
collector.provider_response_id = response_id
|
|
358
|
-
stream = super(response_id, *LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
|
|
359
|
-
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
360
|
-
end
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
module ChatCompletionsPatch
|
|
364
|
-
def create(*args, **kwargs)
|
|
365
|
-
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
366
|
-
started_at = LlmCostTracker::Timing.now_monotonic
|
|
367
|
-
response = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
|
|
368
|
-
LlmCostTracker::Integrations::Openai.record_response(
|
|
369
|
-
response,
|
|
370
|
-
request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
|
|
371
|
-
latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at),
|
|
372
|
-
host: LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
373
|
-
)
|
|
374
|
-
response
|
|
375
|
-
end
|
|
376
|
-
|
|
377
|
-
def stream(*args, **kwargs)
|
|
378
|
-
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
379
|
-
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
380
|
-
host = LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
381
|
-
collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
|
|
382
|
-
stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
|
|
383
|
-
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
def stream_raw(*args, **kwargs)
|
|
387
|
-
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
388
|
-
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
389
|
-
host = LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
390
|
-
collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
|
|
391
|
-
stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
|
|
392
|
-
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
393
|
-
end
|
|
394
|
-
end
|
|
395
|
-
|
|
396
358
|
module PatchBuilder
|
|
397
359
|
module_function
|
|
398
360
|
|
|
399
361
|
def build(record_method:, methods:)
|
|
400
362
|
Module.new.tap do |mod|
|
|
401
|
-
methods.each { |method_name|
|
|
363
|
+
methods.each { |method_name| define_blocking_method(mod, method_name, record_method) }
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def build_stream(methods:)
|
|
368
|
+
Module.new.tap do |mod|
|
|
369
|
+
methods.each { |method_name| define_stream_method(mod, method_name) }
|
|
402
370
|
end
|
|
403
371
|
end
|
|
404
372
|
|
|
405
|
-
def
|
|
373
|
+
def define_blocking_method(mod, method_name, record_method)
|
|
406
374
|
mod.define_method(method_name) do |*args, **kwargs, &block|
|
|
407
375
|
integration = LlmCostTracker::Integrations::Openai
|
|
408
|
-
integration.
|
|
376
|
+
request = integration.request_params(args, kwargs)
|
|
377
|
+
integration.enforce_budget!(request: request)
|
|
409
378
|
started_at = LlmCostTracker::Timing.now_monotonic
|
|
410
379
|
response = super(*integration.normalize_sdk_args(args, kwargs), &block)
|
|
411
380
|
integration.public_send(
|
|
412
381
|
record_method, response,
|
|
413
|
-
request:
|
|
414
|
-
latency_ms:
|
|
382
|
+
request: request,
|
|
383
|
+
latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
|
|
384
|
+
host: integration.client_host_for(self)
|
|
415
385
|
)
|
|
416
386
|
response
|
|
417
387
|
end
|
|
418
388
|
end
|
|
389
|
+
|
|
390
|
+
def define_stream_method(mod, method_name)
|
|
391
|
+
mod.define_method(method_name) do |*args, **kwargs|
|
|
392
|
+
LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
|
|
393
|
+
super(*normalized)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
419
397
|
end
|
|
420
398
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
TranslationsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
|
|
425
|
-
SpeechPatch = PatchBuilder.build(record_method: :record_speech, methods: %i[create])
|
|
426
|
-
ModerationsPatch = PatchBuilder.build(record_method: :record_moderation, methods: %i[create])
|
|
399
|
+
module ResponsesPatch
|
|
400
|
+
include PatchBuilder.build(record_method: :record_response, methods: %i[create])
|
|
401
|
+
include PatchBuilder.build_stream(methods: %i[stream stream_raw])
|
|
427
402
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
433
|
-
host = LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
434
|
-
collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
|
|
435
|
-
stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
|
|
436
|
-
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
403
|
+
def retrieve_streaming(response_id, *args, **kwargs)
|
|
404
|
+
LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, collector|
|
|
405
|
+
collector.provider_response_id = response_id
|
|
406
|
+
super(response_id, *normalized)
|
|
437
407
|
end
|
|
438
408
|
end
|
|
439
409
|
end
|
|
440
410
|
|
|
441
|
-
module
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
445
|
-
host = LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
446
|
-
collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
|
|
447
|
-
stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
|
|
448
|
-
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
449
|
-
end
|
|
411
|
+
module ChatCompletionsPatch
|
|
412
|
+
include PatchBuilder.build(record_method: :record_response, methods: %i[create])
|
|
413
|
+
include PatchBuilder.build_stream(methods: %i[stream stream_raw])
|
|
450
414
|
end
|
|
415
|
+
|
|
416
|
+
EmbeddingsPatch = PatchBuilder.build(record_method: :record_response, methods: %i[create])
|
|
417
|
+
ImagesPatch = PatchBuilder.build(record_method: :record_image, methods: %i[generate edit create_variation])
|
|
418
|
+
TranscriptionsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
|
|
419
|
+
TranslationsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
|
|
420
|
+
SpeechPatch = PatchBuilder.build(record_method: :record_speech, methods: %i[create])
|
|
421
|
+
ModerationsPatch = PatchBuilder.build(record_method: :record_moderation, methods: %i[create])
|
|
422
|
+
StreamingImagesPatch = PatchBuilder.build_stream(methods: %i[generate_stream_raw edit_stream_raw])
|
|
423
|
+
StreamingTranscriptionsPatch = PatchBuilder.build_stream(methods: %i[create_streaming])
|
|
451
424
|
end
|
|
452
425
|
end
|
|
453
426
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../providers/anthropic/tier_classification"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Integrations
|
|
@@ -30,20 +31,16 @@ module LlmCostTracker
|
|
|
30
31
|
]
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
def record_completion(provider, response, request:, latency_ms:,
|
|
34
|
+
def record_completion(provider, response, request:, latency_ms:, has_block:)
|
|
34
35
|
record_usage(
|
|
35
36
|
provider: provider_slug(provider),
|
|
36
37
|
model: response_model_id(response) || model_id(request[:model]),
|
|
37
38
|
response: response,
|
|
38
39
|
latency_ms: latency_ms,
|
|
39
|
-
stream: stream
|
|
40
|
+
stream: has_block || request[:stream] == true
|
|
40
41
|
)
|
|
41
42
|
end
|
|
42
43
|
|
|
43
|
-
def streaming_request?(request, has_block:)
|
|
44
|
-
has_block || request[:stream] == true
|
|
45
|
-
end
|
|
46
|
-
|
|
47
44
|
def record_embedding(provider, response, request:, latency_ms:)
|
|
48
45
|
record_usage(
|
|
49
46
|
provider: provider_slug(provider),
|
|
@@ -111,7 +108,7 @@ module LlmCostTracker
|
|
|
111
108
|
|
|
112
109
|
record_safely do
|
|
113
110
|
LlmCostTracker::Tracker.record(
|
|
114
|
-
|
|
111
|
+
event: Event.build(
|
|
115
112
|
provider: provider,
|
|
116
113
|
model: model,
|
|
117
114
|
token_usage: TokenUsage.build(
|
|
@@ -140,7 +137,7 @@ module LlmCostTracker
|
|
|
140
137
|
hidden_output = object_value(response, :thinking_tokens, :reasoning_tokens).to_i
|
|
141
138
|
|
|
142
139
|
LlmCostTracker::Tracker.record(
|
|
143
|
-
|
|
140
|
+
event: Event.build(
|
|
144
141
|
provider: provider,
|
|
145
142
|
model: model,
|
|
146
143
|
pricing_mode: pricing_mode(provider: provider, response: response),
|
|
@@ -182,17 +179,14 @@ module LlmCostTracker
|
|
|
182
179
|
end
|
|
183
180
|
|
|
184
181
|
def provider_response_id(response)
|
|
185
|
-
object_value(response, :id, :provider_response_id)
|
|
182
|
+
object_value(response, :id, :provider_response_id)
|
|
186
183
|
end
|
|
187
184
|
|
|
188
|
-
ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
|
|
189
|
-
private_constant :ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS
|
|
190
|
-
|
|
191
185
|
def pricing_mode(provider:, response:)
|
|
192
|
-
raw = object_value(response, :pricing_mode, :service_tier)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
186
|
+
raw = object_value(response, :pricing_mode, :service_tier)
|
|
187
|
+
if provider == "anthropic" && LlmCostTracker::Providers::Anthropic::TierClassification.standard_equivalent_tier?(raw)
|
|
188
|
+
return nil
|
|
189
|
+
end
|
|
196
190
|
|
|
197
191
|
raw
|
|
198
192
|
end
|
|
@@ -200,77 +194,37 @@ module LlmCostTracker
|
|
|
200
194
|
|
|
201
195
|
module ProviderPatch
|
|
202
196
|
def complete(*args, **kwargs, &)
|
|
203
|
-
|
|
204
|
-
request = integration.request_params(args, kwargs)
|
|
205
|
-
integration.enforce_budget!
|
|
206
|
-
started_at = LlmCostTracker::Timing.now_monotonic
|
|
207
|
-
response = super
|
|
208
|
-
integration.record_completion(
|
|
209
|
-
self,
|
|
210
|
-
response,
|
|
211
|
-
request: request,
|
|
212
|
-
latency_ms: integration.elapsed_ms(started_at),
|
|
213
|
-
stream: integration.streaming_request?(request, has_block: block_given?)
|
|
214
|
-
)
|
|
215
|
-
response
|
|
197
|
+
measure(args, kwargs, recorder: :record_completion, has_block: block_given?) { super }
|
|
216
198
|
end
|
|
217
199
|
|
|
218
200
|
def embed(*args, **kwargs)
|
|
219
|
-
|
|
220
|
-
request = integration.request_params(args, kwargs)
|
|
221
|
-
integration.enforce_budget!
|
|
222
|
-
started_at = LlmCostTracker::Timing.now_monotonic
|
|
223
|
-
response = super
|
|
224
|
-
integration.record_embedding(
|
|
225
|
-
self,
|
|
226
|
-
response,
|
|
227
|
-
request: request,
|
|
228
|
-
latency_ms: integration.elapsed_ms(started_at)
|
|
229
|
-
)
|
|
230
|
-
response
|
|
201
|
+
measure(args, kwargs, recorder: :record_embedding) { super }
|
|
231
202
|
end
|
|
232
203
|
|
|
233
204
|
def transcribe(*args, **kwargs)
|
|
234
|
-
|
|
235
|
-
request = integration.request_params(args, kwargs)
|
|
236
|
-
integration.enforce_budget!
|
|
237
|
-
started_at = LlmCostTracker::Timing.now_monotonic
|
|
238
|
-
response = super
|
|
239
|
-
integration.record_transcription(
|
|
240
|
-
self,
|
|
241
|
-
response,
|
|
242
|
-
request: request,
|
|
243
|
-
latency_ms: integration.elapsed_ms(started_at)
|
|
244
|
-
)
|
|
245
|
-
response
|
|
205
|
+
measure(args, kwargs, recorder: :record_transcription) { super }
|
|
246
206
|
end
|
|
247
207
|
|
|
248
208
|
def paint(*args, **kwargs)
|
|
249
|
-
|
|
250
|
-
request = integration.request_params(args, kwargs)
|
|
251
|
-
integration.enforce_budget!
|
|
252
|
-
started_at = LlmCostTracker::Timing.now_monotonic
|
|
253
|
-
response = super
|
|
254
|
-
integration.record_image(
|
|
255
|
-
self,
|
|
256
|
-
response,
|
|
257
|
-
request: request,
|
|
258
|
-
latency_ms: integration.elapsed_ms(started_at)
|
|
259
|
-
)
|
|
260
|
-
response
|
|
209
|
+
measure(args, kwargs, recorder: :record_image) { super }
|
|
261
210
|
end
|
|
262
211
|
|
|
263
212
|
def moderate(*args, **kwargs)
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
213
|
+
measure(args, kwargs, recorder: :record_moderation) { super }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
def measure(args, kwargs, recorder:, **extras)
|
|
219
|
+
request = RubyLlm.request_params(args, kwargs)
|
|
220
|
+
RubyLlm.enforce_budget!(request: request)
|
|
267
221
|
started_at = LlmCostTracker::Timing.now_monotonic
|
|
268
|
-
response =
|
|
269
|
-
|
|
270
|
-
self,
|
|
271
|
-
response,
|
|
222
|
+
response = yield
|
|
223
|
+
RubyLlm.public_send(
|
|
224
|
+
recorder, self, response,
|
|
272
225
|
request: request,
|
|
273
|
-
latency_ms:
|
|
226
|
+
latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
|
|
227
|
+
**extras
|
|
274
228
|
)
|
|
275
229
|
response
|
|
276
230
|
end
|
|
@@ -2,19 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
4
|
require_relative "logging"
|
|
5
|
-
require_relative "integrations/openai"
|
|
6
|
-
require_relative "integrations/anthropic"
|
|
7
|
-
require_relative "integrations/ruby_llm"
|
|
8
5
|
|
|
9
6
|
module LlmCostTracker
|
|
10
7
|
module Integrations
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}.freeze
|
|
8
|
+
autoload :Base, "llm_cost_tracker/integrations/base"
|
|
9
|
+
autoload :Openai, "llm_cost_tracker/integrations/openai"
|
|
10
|
+
autoload :Anthropic, "llm_cost_tracker/integrations/anthropic"
|
|
11
|
+
autoload :RubyLlm, "llm_cost_tracker/integrations/ruby_llm"
|
|
16
12
|
|
|
13
|
+
INTEGRATION_CONSTANTS = { openai: :Openai, anthropic: :Anthropic, ruby_llm: :RubyLlm }.freeze
|
|
17
14
|
DOUBLE_INSTRUMENTATION_OVERLAPS = %i[openai anthropic].freeze
|
|
15
|
+
private_constant :DOUBLE_INSTRUMENTATION_OVERLAPS
|
|
18
16
|
|
|
19
17
|
module_function
|
|
20
18
|
|
|
@@ -25,7 +23,7 @@ module LlmCostTracker
|
|
|
25
23
|
end
|
|
26
24
|
|
|
27
25
|
def checks(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
28
|
-
return [Base::Result.new(:
|
|
26
|
+
return [Base::Result.new(:ok, "integrations", "no SDK integrations enabled")] if names.empty?
|
|
29
27
|
|
|
30
28
|
normalize(names).map { |name| fetch(name).status }
|
|
31
29
|
end
|
|
@@ -48,14 +46,17 @@ module LlmCostTracker
|
|
|
48
46
|
end
|
|
49
47
|
|
|
50
48
|
def fetch(name)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
raise LlmCostTracker::Error,
|
|
49
|
+
const_name = INTEGRATION_CONSTANTS[name.to_sym]
|
|
50
|
+
unless const_name
|
|
51
|
+
raise LlmCostTracker::Error,
|
|
52
|
+
"Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
|
|
54
53
|
end
|
|
54
|
+
|
|
55
|
+
const_get(const_name)
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def names
|
|
58
|
-
|
|
59
|
+
INTEGRATION_CONSTANTS.keys
|
|
59
60
|
end
|
|
60
61
|
end
|
|
61
62
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
3
5
|
require_relative "../period"
|
|
4
6
|
|
|
5
7
|
module LlmCostTracker
|
|
@@ -26,12 +28,12 @@ module LlmCostTracker
|
|
|
26
28
|
attr_reader :periods, :time
|
|
27
29
|
|
|
28
30
|
def snapshot_totals
|
|
29
|
-
values = periods.to_h { |period| [period, 0
|
|
31
|
+
values = periods.to_h { |period| [period, BigDecimal("0")] }
|
|
30
32
|
period_by_name = periods.to_h { |period| [period.name, period] }
|
|
31
33
|
sql = periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
|
|
32
34
|
LlmCostTracker::Call.find_by_sql(sql).each do |row|
|
|
33
35
|
period = period_by_name.fetch(row.period_key)
|
|
34
|
-
values[period] = row.total_cost.
|
|
36
|
+
values[period] = BigDecimal(row.total_cost.to_s)
|
|
35
37
|
end
|
|
36
38
|
values
|
|
37
39
|
end
|
|
@@ -39,7 +41,7 @@ module LlmCostTracker
|
|
|
39
41
|
def snapshot_select(period)
|
|
40
42
|
start = Period.range_start(period, time)
|
|
41
43
|
components = [period_total_sql(period, start)]
|
|
42
|
-
components << pending_total_sql(start) if Ingestion.
|
|
44
|
+
components << pending_total_sql(start) if Ingestion.async?
|
|
43
45
|
"SELECT #{connection.quote(period.name)} AS period_key, " \
|
|
44
46
|
"(#{components.join(') + (')}) AS total_cost"
|
|
45
47
|
end
|