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
|
@@ -5,70 +5,78 @@ module LlmCostTracker
|
|
|
5
5
|
module Openai
|
|
6
6
|
module PatchBuilder
|
|
7
7
|
def self.build(record_method:, methods:)
|
|
8
|
-
Module.new.tap do |
|
|
9
|
-
methods.each
|
|
8
|
+
Module.new.tap do |patch|
|
|
9
|
+
methods.each do |method_name|
|
|
10
|
+
patch.define_method(method_name) do |*args, **kwargs, &block|
|
|
11
|
+
seam = Openai.blocking_seam(@client, record_method)
|
|
12
|
+
Openai.wrap_blocking(args, kwargs, **seam) { super(*args, **kwargs, &block) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
10
15
|
end
|
|
11
16
|
end
|
|
12
17
|
|
|
13
18
|
def self.build_stream(methods:)
|
|
14
|
-
Module.new.tap do |
|
|
15
|
-
methods.each
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def self.define_blocking_method(mod, method_name, record_method)
|
|
20
|
-
mod.define_method(method_name) do |*args, **kwargs, &block|
|
|
21
|
-
host = LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
22
|
-
LlmCostTracker::Integrations::Openai.wrap_blocking(
|
|
23
|
-
args,
|
|
24
|
-
kwargs,
|
|
25
|
-
provider: LlmCostTracker::Integrations::Openai.provider_for_host(host),
|
|
26
|
-
record: lambda do |response, request, latency_ms|
|
|
27
|
-
LlmCostTracker::Integrations::Openai.public_send(
|
|
28
|
-
record_method, response, request: request, latency_ms: latency_ms, host: host
|
|
29
|
-
)
|
|
19
|
+
Module.new.tap do |patch|
|
|
20
|
+
methods.each do |method_name|
|
|
21
|
+
patch.define_method(method_name) do |*args, **kwargs|
|
|
22
|
+
Openai.wrap_stream(args, kwargs, **Openai.stream_seam(@client)) { super(*args, **kwargs) }
|
|
30
23
|
end
|
|
31
|
-
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def self.define_stream_method(mod, method_name)
|
|
36
|
-
mod.define_method(method_name) do |*args, **kwargs|
|
|
37
|
-
LlmCostTracker::Integrations::Openai.wrap_stream(
|
|
38
|
-
args, kwargs, **LlmCostTracker::Integrations::Openai.stream_seam(self)
|
|
39
|
-
) { super(*args, **kwargs) }
|
|
24
|
+
end
|
|
40
25
|
end
|
|
41
26
|
end
|
|
42
27
|
end
|
|
43
28
|
|
|
44
29
|
module ResponsesPatch
|
|
45
|
-
include PatchBuilder.build(record_method: :record_response, methods: %i[create])
|
|
30
|
+
include PatchBuilder.build(record_method: :record_response, methods: %i[create compact])
|
|
46
31
|
include PatchBuilder.build_stream(methods: %i[stream stream_raw])
|
|
47
32
|
|
|
48
33
|
def retrieve(response_id, *args, **kwargs)
|
|
49
|
-
response
|
|
50
|
-
LlmCostTracker::Integrations::Openai.record_retrieved_response(
|
|
51
|
-
response, host: LlmCostTracker::Integrations::Openai.client_host_for(self)
|
|
52
|
-
)
|
|
53
|
-
response
|
|
34
|
+
super.tap { |response| Openai.record_retrieved_response(response, host: Openai.client_host(@client)) }
|
|
54
35
|
end
|
|
55
36
|
|
|
56
37
|
def retrieve_streaming(response_id, *args, **kwargs)
|
|
57
|
-
|
|
58
|
-
args, kwargs, **LlmCostTracker::Integrations::Openai.stream_seam(self)
|
|
59
|
-
) do |collector|
|
|
38
|
+
Openai.wrap_stream(args, kwargs, **Openai.stream_seam(@client)) do |collector|
|
|
60
39
|
collector.provider_response_id = response_id
|
|
61
40
|
super(response_id, *args, **kwargs)
|
|
62
41
|
end
|
|
63
42
|
end
|
|
64
43
|
end
|
|
65
44
|
|
|
45
|
+
module ResponsesConnectionPatch
|
|
46
|
+
def send_event(event)
|
|
47
|
+
llm_cost_tracker_capture.sending(event) { super }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def each(&block)
|
|
51
|
+
return super unless block
|
|
52
|
+
|
|
53
|
+
capture = llm_cost_tracker_capture
|
|
54
|
+
capture.reading do
|
|
55
|
+
super do |event|
|
|
56
|
+
capture.track(event)
|
|
57
|
+
block.call(event)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def receive
|
|
63
|
+
llm_cost_tracker_capture.raise_deferred
|
|
64
|
+
super.tap { |event| llm_cost_tracker_capture.track(event) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def llm_cost_tracker_capture
|
|
70
|
+
@llm_cost_tracker_capture ||= WebsocketCapture.new(url)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
66
74
|
module ChatCompletionsPatch
|
|
67
75
|
include PatchBuilder.build(record_method: :record_response, methods: %i[create])
|
|
68
76
|
include PatchBuilder.build_stream(methods: %i[stream stream_raw])
|
|
69
77
|
end
|
|
70
78
|
|
|
71
|
-
|
|
79
|
+
CreatePatch = PatchBuilder.build(record_method: :record_response, methods: %i[create])
|
|
72
80
|
ImagesPatch = PatchBuilder.build(record_method: :record_image, methods: %i[generate edit create_variation])
|
|
73
81
|
TranscriptionsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
|
|
74
82
|
TranslationsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
|
|
@@ -78,10 +86,13 @@ module LlmCostTracker
|
|
|
78
86
|
StreamingTranscriptionsPatch = PatchBuilder.build_stream(methods: %i[create_streaming])
|
|
79
87
|
|
|
80
88
|
module BatchesPatch
|
|
89
|
+
def create(*args, **kwargs)
|
|
90
|
+
Openai.enforce_budget!(request: Openai.request_params(args, kwargs))
|
|
91
|
+
super
|
|
92
|
+
end
|
|
93
|
+
|
|
81
94
|
def retrieve(batch_id, *args, **kwargs)
|
|
82
|
-
batch
|
|
83
|
-
LlmCostTracker::Integrations::Openai::BatchCapture.maybe_capture(batch, resource: self)
|
|
84
|
-
batch
|
|
95
|
+
super.tap { |batch| BatchCapture.capture(batch, client: @client) }
|
|
85
96
|
end
|
|
86
97
|
end
|
|
87
98
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Integrations
|
|
7
|
+
module Openai
|
|
8
|
+
class WebsocketCapture
|
|
9
|
+
TRACKED_EVENTS = %w[response.created error response.completed response.incomplete response.failed].freeze
|
|
10
|
+
private_constant :TRACKED_EVENTS
|
|
11
|
+
|
|
12
|
+
def initialize(url)
|
|
13
|
+
@url = url
|
|
14
|
+
@lanes = {}
|
|
15
|
+
@tags_by_response = {}
|
|
16
|
+
@deferred = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def sending(event)
|
|
20
|
+
request = budget_checked_create(event)
|
|
21
|
+
yield.tap { (@lanes[request["stream_id"]] ||= [nil]) << Tags::Context.tags if request }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reading
|
|
25
|
+
yield
|
|
26
|
+
ensure
|
|
27
|
+
raise_deferred unless $ERROR_INFO
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def track(event)
|
|
31
|
+
type = (event.type if event.respond_to?(:type)).to_s
|
|
32
|
+
return unless Openai.active? && TRACKED_EVENTS.include?(type)
|
|
33
|
+
|
|
34
|
+
Openai.record_safely { route(type, Capture::SdkPayload.normalize(event)) }
|
|
35
|
+
rescue BudgetExceededError, UnknownPricingError => e
|
|
36
|
+
@deferred ||= e
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def raise_deferred
|
|
40
|
+
error = @deferred
|
|
41
|
+
@deferred = nil
|
|
42
|
+
raise error if error
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def budget_checked_create(event)
|
|
48
|
+
return unless Openai.active?
|
|
49
|
+
|
|
50
|
+
request = Capture::SdkPayload.normalize(event)
|
|
51
|
+
return unless request.is_a?(Hash) && request["type"] == "response.create"
|
|
52
|
+
|
|
53
|
+
Openai.enforce_budget!(request: request.with_indifferent_access, provider: Openai.provider_for_host(url_host))
|
|
54
|
+
request
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def route(type, data)
|
|
58
|
+
response = data["response"].to_h
|
|
59
|
+
case type
|
|
60
|
+
when "response.created" then @tags_by_response[response["id"]] = advance_lane(data["stream_id"])
|
|
61
|
+
when "error" then advance_lane(data["stream_id"])
|
|
62
|
+
else record(response)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def advance_lane(stream_id)
|
|
67
|
+
lane = @lanes[stream_id]
|
|
68
|
+
lane.shift if lane && lane.size > 1
|
|
69
|
+
lane&.first
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def record(response)
|
|
73
|
+
tags = @tags_by_response.delete(response["id"])
|
|
74
|
+
host = url_host
|
|
75
|
+
event = Providers::Openai::ResponseParser.event_from_response(
|
|
76
|
+
response: response,
|
|
77
|
+
request: { "tools" => response["tools"] },
|
|
78
|
+
provider: Openai.provider_for_host(host),
|
|
79
|
+
host: host,
|
|
80
|
+
usage_source: Usage::Source::STREAM_FINAL
|
|
81
|
+
)
|
|
82
|
+
Tracker.record(event: event.with(stream: true), context_tags: tags) if event
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def url_host = URI(@url.to_s).host
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -9,6 +9,7 @@ require_relative "../providers/openai/service_charges"
|
|
|
9
9
|
require_relative "../providers/openai/usage_extractor"
|
|
10
10
|
require_relative "openai/patches"
|
|
11
11
|
require_relative "openai/batch_capture"
|
|
12
|
+
require_relative "openai/websocket_capture"
|
|
12
13
|
|
|
13
14
|
module LlmCostTracker
|
|
14
15
|
module Integrations
|
|
@@ -18,44 +19,21 @@ module LlmCostTracker
|
|
|
18
19
|
minimum_version "0.59.0"
|
|
19
20
|
|
|
20
21
|
class << self
|
|
21
|
-
def stream_collector(request, host: nil)
|
|
22
|
-
LlmCostTracker::Capture::StreamCollector.new(
|
|
23
|
-
provider: provider_for_host(host),
|
|
24
|
-
model: request[:model],
|
|
25
|
-
# Host part only: the parser prefers the served tier over the requested one, e.g. after a downgrade.
|
|
26
|
-
pricing_mode: host_pricing_mode(host, request),
|
|
27
|
-
request: request
|
|
28
|
-
)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def stream_seam(resource)
|
|
32
|
-
host = client_host_for(resource)
|
|
33
|
-
{
|
|
34
|
-
provider: provider_for_host(host),
|
|
35
|
-
collector: ->(request) { stream_collector(request, host: host) }
|
|
36
|
-
}
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def client_host_for(resource)
|
|
40
|
-
client = resource.instance_variable_get(:@client)
|
|
41
|
-
return nil unless client
|
|
42
|
-
|
|
43
|
-
URI.parse(client.base_url.to_s).host
|
|
44
|
-
rescue URI::InvalidURIError
|
|
45
|
-
nil
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def provider_for_host(host)
|
|
49
|
-
return "azure_openai" if LlmCostTracker::Providers::Azure::Hosts.openai?(host)
|
|
50
|
-
|
|
51
|
-
LlmCostTracker.configuration.capture.openai_compatible_providers[host.to_s.downcase] || "openai"
|
|
52
|
-
end
|
|
53
|
-
|
|
54
22
|
def patch_targets
|
|
55
23
|
[
|
|
56
24
|
patch_target("OpenAI::Resources::Responses", with: ResponsesPatch),
|
|
25
|
+
patch_target("OpenAI::Resources::Beta::Responses",
|
|
26
|
+
with: ResponsesPatch,
|
|
27
|
+
optional: true,
|
|
28
|
+
skip_when_methods_missing: true,
|
|
29
|
+
since: "0.69.0"),
|
|
30
|
+
patch_target("OpenAI::Responses::Connection",
|
|
31
|
+
with: ResponsesConnectionPatch,
|
|
32
|
+
optional: true,
|
|
33
|
+
since: "0.86.0"),
|
|
57
34
|
patch_target("OpenAI::Resources::Chat::Completions", with: ChatCompletionsPatch),
|
|
58
|
-
patch_target("OpenAI::Resources::
|
|
35
|
+
patch_target("OpenAI::Resources::Completions", with: CreatePatch, optional: true),
|
|
36
|
+
patch_target("OpenAI::Resources::Embeddings", with: CreatePatch, optional: true),
|
|
59
37
|
patch_target("OpenAI::Resources::Images", with: ImagesPatch, optional: true),
|
|
60
38
|
patch_target("OpenAI::Resources::Images",
|
|
61
39
|
with: StreamingImagesPatch,
|
|
@@ -73,29 +51,53 @@ module LlmCostTracker
|
|
|
73
51
|
]
|
|
74
52
|
end
|
|
75
53
|
|
|
54
|
+
def provider_for_host(host)
|
|
55
|
+
return "azure_openai" if Providers::Azure::Hosts.openai?(host)
|
|
56
|
+
|
|
57
|
+
LlmCostTracker.configuration.capture.openai_compatible_providers[host.to_s.downcase] ||
|
|
58
|
+
(Providers::Openai::Hosts.bedrock?(host) ? "bedrock" : "openai")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def blocking_seam(client, record_method)
|
|
62
|
+
host = client_host(client)
|
|
63
|
+
{
|
|
64
|
+
provider: provider_for_host(host),
|
|
65
|
+
record: lambda do |response, request, latency_ms|
|
|
66
|
+
public_send(record_method, response, request: request, latency_ms: latency_ms, host: host)
|
|
67
|
+
end
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def stream_seam(client)
|
|
72
|
+
host = client_host(client)
|
|
73
|
+
{ provider: provider_for_host(host), collector: ->(request) { stream_collector(request, host: host) } }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stream_collector(request, host: nil)
|
|
77
|
+
Capture::StreamCollector.new(
|
|
78
|
+
provider: provider_for_host(host),
|
|
79
|
+
parsed_as: "openai",
|
|
80
|
+
model: request[:model],
|
|
81
|
+
pricing_mode: host_pricing_mode(host, request),
|
|
82
|
+
request: request
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
76
86
|
def record_response(response, request:, latency_ms:, host: nil)
|
|
77
87
|
return unless active?
|
|
78
88
|
|
|
79
89
|
record_safely do
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
input_tokens = usage["input_tokens"] || usage["prompt_tokens"]
|
|
83
|
-
output_tokens = usage["output_tokens"] || usage["completion_tokens"]
|
|
84
|
-
if input_tokens.nil? && output_tokens.nil?
|
|
85
|
-
unless normalized["background"]
|
|
86
|
-
Logging.warn("OpenAI response #{normalized['id']} has no usage; not recorded")
|
|
87
|
-
end
|
|
88
|
-
next
|
|
89
|
-
end
|
|
90
|
+
body = Capture::SdkPayload.normalize(response)
|
|
91
|
+
next warn_missing_usage(body) unless tokens_reported?(body)
|
|
90
92
|
|
|
91
|
-
event =
|
|
92
|
-
response:
|
|
93
|
+
event = Providers::Openai::ResponseParser.event_from_response(
|
|
94
|
+
response: body,
|
|
93
95
|
request: request,
|
|
94
96
|
provider: provider_for_host(host),
|
|
95
97
|
host: host,
|
|
96
|
-
usage_source:
|
|
98
|
+
usage_source: Usage::Source::SDK_RESPONSE
|
|
97
99
|
)
|
|
98
|
-
|
|
100
|
+
Tracker.record(event: event, latency_ms: latency_ms) if event
|
|
99
101
|
end
|
|
100
102
|
end
|
|
101
103
|
|
|
@@ -103,11 +105,11 @@ module LlmCostTracker
|
|
|
103
105
|
return unless active?
|
|
104
106
|
|
|
105
107
|
record_safely do
|
|
106
|
-
event =
|
|
107
|
-
response:
|
|
108
|
+
event = Providers::Openai::ResponseParser.retrieved_event(
|
|
109
|
+
response: Capture::SdkPayload.normalize(response),
|
|
108
110
|
provider: provider_for_host(host),
|
|
109
111
|
host: host,
|
|
110
|
-
usage_source:
|
|
112
|
+
usage_source: Usage::Source::SDK_RESPONSE
|
|
111
113
|
)
|
|
112
114
|
record_once(event) if event
|
|
113
115
|
end
|
|
@@ -115,57 +117,39 @@ module LlmCostTracker
|
|
|
115
117
|
|
|
116
118
|
def record_image(response, request:, latency_ms:, host: nil)
|
|
117
119
|
usage = usage_hash_from(response) || {}
|
|
118
|
-
|
|
119
|
-
image_input = LlmCostTracker::Providers::Openai::UsageExtractor.image_input_tokens(usage)
|
|
120
|
-
cache_read = LlmCostTracker::Providers::Openai::UsageExtractor.cache_read_input_tokens(usage)
|
|
121
|
-
image_output, text_output = LlmCostTracker::Providers::Openai::UsageExtractor.split_output(
|
|
122
|
-
output_tokens: usage[:output_tokens].to_i,
|
|
123
|
-
image_output_details: LlmCostTracker::Providers::Openai::UsageExtractor.image_output_tokens(usage),
|
|
124
|
-
text_output_details: LlmCostTracker::Providers::Openai::UsageExtractor.text_output_tokens(usage),
|
|
125
|
-
audio_output: 0,
|
|
126
|
-
default_to_image: true
|
|
127
|
-
)
|
|
120
|
+
extractor = Providers::Openai::UsageExtractor
|
|
128
121
|
record_passthrough(
|
|
129
122
|
model: request[:model],
|
|
130
123
|
response: response,
|
|
131
124
|
latency_ms: latency_ms,
|
|
132
125
|
provider: provider_for_host(host),
|
|
133
126
|
pricing_mode: host_pricing_mode(host, request),
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
image_output_tokens: image_output,
|
|
138
|
-
cache_read_input_tokens: cache_read
|
|
127
|
+
service_line_items: Providers::Openai::ServiceCharges.billed_line_items(usage) +
|
|
128
|
+
extractor.cache_read_line_items(usage),
|
|
129
|
+
**extractor.token_usage(usage, model: request[:model], default_to_image: true).to_h
|
|
139
130
|
)
|
|
140
131
|
end
|
|
141
132
|
|
|
142
133
|
def record_transcription(response, request:, latency_ms:, host: nil)
|
|
143
|
-
usage =
|
|
134
|
+
usage = transcription_usage(response)
|
|
144
135
|
record_passthrough(
|
|
145
136
|
model: request[:model],
|
|
146
137
|
response: response,
|
|
147
138
|
latency_ms: latency_ms,
|
|
148
139
|
provider: provider_for_host(host),
|
|
149
140
|
pricing_mode: host_pricing_mode(host, request),
|
|
150
|
-
service_line_items:
|
|
151
|
-
usage_source: usage ?
|
|
141
|
+
service_line_items: Providers::Openai::ServiceCharges.transcription_line_items(usage),
|
|
142
|
+
usage_source: usage ? Usage::Source::SDK_RESPONSE : Usage::Source::UNKNOWN,
|
|
152
143
|
**transcription_token_attributes(usage)
|
|
153
144
|
)
|
|
154
145
|
end
|
|
155
146
|
|
|
156
|
-
def
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
audio_input = LlmCostTracker::Providers::Openai::UsageExtractor.audio_input_tokens(usage)
|
|
161
|
-
{
|
|
162
|
-
input_tokens: [raw_input - audio_input, 0].max,
|
|
163
|
-
audio_input_tokens: audio_input,
|
|
164
|
-
output_tokens: usage[:output_tokens].to_i
|
|
165
|
-
}
|
|
166
|
-
end
|
|
147
|
+
def record_speech(response, request:, latency_ms:, host: nil)
|
|
148
|
+
if request[:stream_format].to_s == "sse" && response.respond_to?(:string)
|
|
149
|
+
return record_speech_stream(response, request: request, latency_ms: latency_ms, host: host)
|
|
150
|
+
end
|
|
167
151
|
|
|
168
|
-
|
|
152
|
+
line_items = Providers::Openai::ServiceCharges.speech_line_items(request)
|
|
169
153
|
record_passthrough(
|
|
170
154
|
model: request[:model],
|
|
171
155
|
response: nil,
|
|
@@ -173,7 +157,8 @@ module LlmCostTracker
|
|
|
173
157
|
provider: provider_for_host(host),
|
|
174
158
|
input_tokens: 0,
|
|
175
159
|
output_tokens: 0,
|
|
176
|
-
service_line_items:
|
|
160
|
+
service_line_items: line_items,
|
|
161
|
+
usage_source: line_items.empty? ? Usage::Source::UNKNOWN : Usage::Source::SDK_RESPONSE
|
|
177
162
|
)
|
|
178
163
|
end
|
|
179
164
|
|
|
@@ -188,8 +173,10 @@ module LlmCostTracker
|
|
|
188
173
|
)
|
|
189
174
|
end
|
|
190
175
|
|
|
176
|
+
private
|
|
177
|
+
|
|
191
178
|
def host_pricing_mode(host, request)
|
|
192
|
-
|
|
179
|
+
Providers::Openai::ResponseParser.combined_pricing_mode(
|
|
193
180
|
provider: provider_for_host(host), host: host, model: request[:model], service_tier: nil
|
|
194
181
|
)
|
|
195
182
|
end
|
|
@@ -197,6 +184,50 @@ module LlmCostTracker
|
|
|
197
184
|
def usage_hash_from(response)
|
|
198
185
|
response.try(:usage)&.deep_to_h
|
|
199
186
|
end
|
|
187
|
+
|
|
188
|
+
def tokens_reported?(body)
|
|
189
|
+
usage = body["usage"] || {}
|
|
190
|
+
input_tokens = usage["input_tokens"] || usage["prompt_tokens"]
|
|
191
|
+
output_tokens = usage["output_tokens"] || usage["completion_tokens"]
|
|
192
|
+
!(input_tokens.nil? && output_tokens.nil?)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def warn_missing_usage(body)
|
|
196
|
+
Logging.warn("OpenAI response #{body['id']} has no usage; not recorded") unless body["background"]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def transcription_usage(response)
|
|
200
|
+
body = Hash.try_convert(Capture::SdkPayload.normalize(response)) || {}
|
|
201
|
+
usage = body["usage"]&.deep_symbolize_keys
|
|
202
|
+
usage ||= { type: "duration", seconds: body["duration"].ceil } if body["duration"].to_f.positive?
|
|
203
|
+
usage
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def transcription_token_attributes(usage)
|
|
207
|
+
return { input_tokens: 0, output_tokens: 0 } unless usage && usage[:type].to_s == "tokens"
|
|
208
|
+
|
|
209
|
+
raw_input = usage[:input_tokens].to_i
|
|
210
|
+
audio_input = Providers::Openai::UsageExtractor.audio_input_tokens(usage)
|
|
211
|
+
{
|
|
212
|
+
input_tokens: [raw_input - audio_input, 0].max,
|
|
213
|
+
audio_input_tokens: audio_input,
|
|
214
|
+
output_tokens: usage[:output_tokens].to_i
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def record_speech_stream(response, request:, latency_ms:, host:)
|
|
219
|
+
return unless active?
|
|
220
|
+
|
|
221
|
+
collector = Capture::StreamCollector.new(
|
|
222
|
+
provider: provider_for_host(host),
|
|
223
|
+
model: request[:model],
|
|
224
|
+
latency_ms: latency_ms,
|
|
225
|
+
pricing_mode: host_pricing_mode(host, request),
|
|
226
|
+
request: request
|
|
227
|
+
)
|
|
228
|
+
Capture::SSE.parse(response.string).each { |event| collector.event(event[:data]) }
|
|
229
|
+
collector.finish!
|
|
230
|
+
end
|
|
200
231
|
end
|
|
201
232
|
end
|
|
202
233
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Integrations
|
|
5
|
+
module RubyLlm
|
|
6
|
+
module V1
|
|
7
|
+
module ProviderPatch
|
|
8
|
+
def complete(*args, **kwargs, &)
|
|
9
|
+
seam = V1.blocking_seam(self, :record_completion, has_block: block_given?)
|
|
10
|
+
V1.wrap_blocking(args, kwargs, **seam) { super }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def embed(*args, **kwargs)
|
|
14
|
+
V1.wrap_blocking(args, kwargs, **V1.blocking_seam(self, :record_embedding)) { super }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def transcribe(*args, **kwargs)
|
|
18
|
+
V1.wrap_blocking(args, kwargs, **V1.blocking_seam(self, :record_transcription)) { super }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def paint(*args, **kwargs)
|
|
22
|
+
V1.wrap_blocking(args, kwargs, **V1.blocking_seam(self, :record_image)) { super }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def moderate(*args, **kwargs)
|
|
26
|
+
V1.wrap_blocking(args, kwargs, **V1.blocking_seam(self, :record_moderation)) { super }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module GeminiTranscriptionPatch
|
|
31
|
+
def transcribe(*args, **kwargs)
|
|
32
|
+
V1.wrap_blocking(args, kwargs, **V1.blocking_seam(self, :record_transcription)) { super }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module ResponseBodyPatch
|
|
37
|
+
def parse_transcription_response(response, **)
|
|
38
|
+
V1.keep_usage(super, response.body)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_embedding_response(response, **)
|
|
42
|
+
V1.keep_usage(super, response.body)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module StreamPatch
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def stream_response(...)
|
|
50
|
+
body = @llm_cost_tracker_stream_body = {}
|
|
51
|
+
super.tap { |message| message.raw.instance_variable_set(Reply::KEPT_BODY, body) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_on_data_handler(*, &handler)
|
|
55
|
+
body = @llm_cost_tracker_stream_body
|
|
56
|
+
super do |data|
|
|
57
|
+
if body && data.is_a?(Hash)
|
|
58
|
+
body.deep_merge!(data.values_at("message", "response").find { |part| part.is_a?(Hash) } || data)
|
|
59
|
+
end
|
|
60
|
+
handler.call(data)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|