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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Integrations
|
|
5
|
+
module RubyLlm
|
|
6
|
+
module V2
|
|
7
|
+
class Frame
|
|
8
|
+
STACK = :llm_cost_tracker_ruby_llm_frames
|
|
9
|
+
|
|
10
|
+
attr_reader :payload, :provider, :workflow
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def current = stack.last
|
|
14
|
+
|
|
15
|
+
def open(payload, compaction: false, workflow: nil)
|
|
16
|
+
new(payload, compaction, workflow).tap { |frame| stack << frame }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def close(payload)
|
|
20
|
+
index = stack.rindex { |frame| frame.payload.equal?(payload) }
|
|
21
|
+
index ? stack.slice!(index..) : []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def discard(frame) = stack.delete_if { |open| open.equal?(frame) }
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def stack = Thread.current[STACK] ||= []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(payload, compaction, workflow)
|
|
32
|
+
@payload = payload
|
|
33
|
+
@compaction = compaction
|
|
34
|
+
@workflow = workflow
|
|
35
|
+
@reported = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def observe(seam, value, provider)
|
|
39
|
+
@provider = provider
|
|
40
|
+
case seam
|
|
41
|
+
when :parse_completion_body then @raw = value unless value.is_a?(Hash)
|
|
42
|
+
when :build_chunk then (@window ||= Attempt.stream_window).push(value)
|
|
43
|
+
else @response = value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def request_started
|
|
48
|
+
@request_started_at = Timing.now_monotonic
|
|
49
|
+
@latency_ms = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def request_finished(payload)
|
|
53
|
+
@latency_ms = Timing.elapsed_ms(@request_started_at) if @request_started_at
|
|
54
|
+
@workflow = payload.slice(:workflow_id, :workflow_name, :workflow_step_name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add_attempt(usage)
|
|
58
|
+
raw = @raw unless usage[:status] == :succeeded && usage[:tokens].to_h.empty?
|
|
59
|
+
@reported << [usage, { latency_ms: @latency_ms, events: @window&.events, raw: raw }]
|
|
60
|
+
@window = nil
|
|
61
|
+
@raw = nil if raw
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def attempts
|
|
65
|
+
final = @reported.rindex { |usage, _| usage[:status] == :succeeded } || (@reported.size - 1 if @response)
|
|
66
|
+
payload = @compaction ? @payload.except(:provider_options) : @payload
|
|
67
|
+
@reported.each_with_index.map do |(usage, reported), index|
|
|
68
|
+
Attempt.new(usage, payload, final: index == final, response: @response, provider: @provider, **reported)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Integrations
|
|
5
|
+
module RubyLlm
|
|
6
|
+
module V2
|
|
7
|
+
module Recorder
|
|
8
|
+
RECORDED = ObjectSpace::WeakKeyMap.new
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def record(attempt)
|
|
12
|
+
event = attempt.event
|
|
13
|
+
return unless event
|
|
14
|
+
|
|
15
|
+
LlmCostTracker::Tracker.record(event: event, latency_ms: attempt.latency_ms, **tags_for(attempt.usage))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record_all(attempts) = attempts.filter_map { |attempt| caller_error { record(attempt) } }
|
|
19
|
+
|
|
20
|
+
def record_batch(batch, results, frame)
|
|
21
|
+
errors = Array(results).each_with_index.filter_map do |result, index|
|
|
22
|
+
next if result.nil? || RECORDED.key?(result)
|
|
23
|
+
|
|
24
|
+
caller_error do
|
|
25
|
+
record_batch_result(batch, result, index, frame)
|
|
26
|
+
RECORDED[result] = true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
raise errors.first if errors.any?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tags_for(payload)
|
|
33
|
+
context = LlmCostTracker::Tags::Context.tags
|
|
34
|
+
run_tagged = context.any? { |key, value| key.to_s == "run_id" && !value.to_s.empty? }
|
|
35
|
+
run_id = payload[:workflow_id] unless run_tagged
|
|
36
|
+
{ context_tags: context,
|
|
37
|
+
metadata: { run_id: run_id }.compact.merge(payload.slice(:workflow_name, :workflow_step_name).compact) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def caller_error(&)
|
|
43
|
+
V2.record_safely(&)
|
|
44
|
+
nil
|
|
45
|
+
rescue *CALLER_ERRORS => e
|
|
46
|
+
e
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record_batch_result(batch, result, index, frame)
|
|
50
|
+
usage = batch_usage(batch, result, index)
|
|
51
|
+
base = frame.provider&.api_base || RubyLLM.config.try("#{batch.provider}_api_base")
|
|
52
|
+
event = Attempt.batch_event(usage, result, base)
|
|
53
|
+
return unless event
|
|
54
|
+
|
|
55
|
+
id = event.provider_response_id || "#{batch.id}/#{index}"
|
|
56
|
+
return if Call.already_recorded?(provider: event.provider, provider_response_id: id)
|
|
57
|
+
|
|
58
|
+
V2.record_once(event.with(provider_response_id: id), **tags_for(frame.workflow.to_h))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def batch_usage(batch, result, index)
|
|
62
|
+
{ operation: result.is_a?(RubyLLM::Embedding) ? :embedding : :chat, provider: batch.provider,
|
|
63
|
+
model: result.model || batch.chats.to_a[index]&.model&.id, status: :succeeded, tokens: result.tokens }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Integrations
|
|
5
|
+
module RubyLlm
|
|
6
|
+
module V2
|
|
7
|
+
SEAMS = {
|
|
8
|
+
build_chunk: %w[Protocols::Anthropic Protocols::ChatCompletions Protocols::Responses Protocols::Gemini
|
|
9
|
+
Protocols::Interactions Protocols::Converse Providers::OpenRouter::ChatCompletions
|
|
10
|
+
Protocols::Cohere],
|
|
11
|
+
parse_completion_body: %w[Protocols::Anthropic Protocols::ChatCompletions Protocols::Responses
|
|
12
|
+
Protocols::Gemini Protocols::Interactions Protocols::Converse
|
|
13
|
+
Protocols::Mistral::Conversations Providers::Mistral::ChatCompletions],
|
|
14
|
+
parse_embedding_response: %w[Protocols::ChatCompletions Protocols::Gemini Providers::VertexAI::EmbedContent
|
|
15
|
+
Providers::Perplexity::Embeddings Protocols::Cohere],
|
|
16
|
+
parse_transcription_response: %w[Protocols::ChatCompletions Protocols::Gemini],
|
|
17
|
+
parse_speech_response: %w[Protocols::Gemini],
|
|
18
|
+
stream_transcription: %w[Protocols::ChatCompletions],
|
|
19
|
+
transcribe: %w[Protocol Protocols::Deepgram Protocols::Gemini::LiveTranscription],
|
|
20
|
+
parse_image_responses: %w[Protocols::ChatCompletions Protocols::Gemini Providers::XAI::Images],
|
|
21
|
+
parse_cache_response: %w[Protocols::Gemini],
|
|
22
|
+
messages: %w[Batch],
|
|
23
|
+
results: %w[Batch],
|
|
24
|
+
current_workflow: %w[Support::Instrumentation]
|
|
25
|
+
}.freeze
|
|
26
|
+
StreamTranscriptionBridge = Module.new do
|
|
27
|
+
def stream_transcription(*, **, &block)
|
|
28
|
+
super do |chunk|
|
|
29
|
+
V2.observe(:stream_transcription, chunk.raw, self)
|
|
30
|
+
block.call(chunk)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
TranscribeBridge = Module.new do
|
|
35
|
+
def transcribe(*, **, &block)
|
|
36
|
+
V2.observe(:transcribe, {}, self) if block
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
BatchBridge = Module.new do
|
|
41
|
+
%i[messages results].each { |name| define_method(name) { V2.collect(self) { super() } } }
|
|
42
|
+
end
|
|
43
|
+
BRIDGES = (SEAMS.keys - %i[stream_transcription transcribe messages results current_workflow]).to_h do |seam|
|
|
44
|
+
bridge = Module.new do
|
|
45
|
+
define_method(seam) do |value, *args, **options, &block|
|
|
46
|
+
V2.observe(seam, options.fetch(:raw, value), self)
|
|
47
|
+
super(value, *args, **options, &block)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
[seam, const_set("#{seam.to_s.camelize}Bridge", bridge)]
|
|
51
|
+
end.merge(
|
|
52
|
+
stream_transcription: StreamTranscriptionBridge,
|
|
53
|
+
transcribe: TranscribeBridge,
|
|
54
|
+
messages: BatchBridge,
|
|
55
|
+
results: BatchBridge
|
|
56
|
+
).freeze
|
|
57
|
+
|
|
58
|
+
module Seams
|
|
59
|
+
class << self
|
|
60
|
+
def bridge
|
|
61
|
+
BRIDGES.each do |seam, bridge|
|
|
62
|
+
SEAMS[seam].filter_map { |target| owner(target, seam) }.each do |owner|
|
|
63
|
+
owner.prepend(bridge) unless owner == bridge
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def missing
|
|
69
|
+
SEAMS.flat_map do |seam, targets|
|
|
70
|
+
targets.reject { |target| owner(target, seam) }.map { |target| "RubyLLM::#{target}##{seam}" }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def owner(target, seam)
|
|
77
|
+
"RubyLLM::#{target}".safe_constantize&.instance_method(seam)&.owner
|
|
78
|
+
rescue NameError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
require_relative "../base"
|
|
5
|
+
require_relative "v2/attempt"
|
|
6
|
+
require_relative "v2/frame"
|
|
7
|
+
require_relative "v2/recorder"
|
|
8
|
+
require_relative "v2/seams"
|
|
9
|
+
|
|
10
|
+
module LlmCostTracker
|
|
11
|
+
module Integrations
|
|
12
|
+
module RubyLlm
|
|
13
|
+
module V2
|
|
14
|
+
extend Base
|
|
15
|
+
|
|
16
|
+
minimum_version "2.0.0"
|
|
17
|
+
maximum_version "3.0.0"
|
|
18
|
+
|
|
19
|
+
OPERATIONS = %i[chat embedding image transcription moderation speech ocr rerank judgment].freeze
|
|
20
|
+
JOBS = %w[batch.ruby_llm video_job.ruby_llm research_job.ruby_llm].freeze
|
|
21
|
+
EVENTS = [*OPERATIONS, :compaction, :request, :usage].map { |name| "#{name}.ruby_llm" }.concat(JOBS).freeze
|
|
22
|
+
CACHE_CREATED = :llm_cost_tracker_ruby_llm_cache_created
|
|
23
|
+
|
|
24
|
+
module TagRelay
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def instrument(*) = yield
|
|
28
|
+
|
|
29
|
+
def capture_context = LlmCostTracker::Tags::Context.scoped
|
|
30
|
+
|
|
31
|
+
def with_context(tags, &) = LlmCostTracker::Tags::Context.with(tags, &)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
def integration_name = :ruby_llm
|
|
36
|
+
|
|
37
|
+
def install
|
|
38
|
+
validate_contract!
|
|
39
|
+
Logging.warn(untested_version_message) if untested_version?
|
|
40
|
+
@subscriptions ||= EVENTS.map { |name| ActiveSupport::Notifications.subscribe(name, self) }
|
|
41
|
+
RubyLLM.config.instrumenter ||= ActiveSupport::Notifications
|
|
42
|
+
Seams.bridge
|
|
43
|
+
instrumentation = "RubyLLM::Support::Instrumentation".safe_constantize
|
|
44
|
+
instrumentation.subscribe(TagRelay) if instrumentation.respond_to?(:capture_context)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def status
|
|
48
|
+
name = integration_name.to_s
|
|
49
|
+
warning = install_warning
|
|
50
|
+
return Check.new(:warn, name, warning) if warning
|
|
51
|
+
|
|
52
|
+
missing = Seams.missing
|
|
53
|
+
return Check.new(:ok, name, "#{name} integration installed") if missing.empty?
|
|
54
|
+
|
|
55
|
+
message = "#{name} integration installed, but these RubyLLM methods are missing, so what they carry " \
|
|
56
|
+
"is not read: #{missing.join(', ')}"
|
|
57
|
+
Check.new(:warn, name, message)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def start(name, _id, payload)
|
|
61
|
+
case name
|
|
62
|
+
when "usage.ruby_llm" then nil
|
|
63
|
+
when "request.ruby_llm" then start_request(payload)
|
|
64
|
+
else start_operation(name, payload)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def finish(name, _id, payload)
|
|
69
|
+
case name
|
|
70
|
+
when "usage.ruby_llm" then add_attempt(payload) unless %i[video research].include?(payload[:operation])
|
|
71
|
+
when "request.ruby_llm" then Frame.current&.request_finished(payload)
|
|
72
|
+
else finish_operation(payload)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def observe(seam, value, protocol = nil)
|
|
77
|
+
record_safely do
|
|
78
|
+
next record_cache_storage(value) if seam == :parse_cache_response
|
|
79
|
+
|
|
80
|
+
Frame.current&.observe(seam, value, protocol&.provider)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def collect(batch)
|
|
85
|
+
frame = Frame.open(batch, workflow: current_workflow)
|
|
86
|
+
results = yield
|
|
87
|
+
Recorder.record_batch(batch, results, frame) if active?
|
|
88
|
+
results
|
|
89
|
+
ensure
|
|
90
|
+
Frame.discard(frame)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def install_warning
|
|
96
|
+
problems = version_problems + target_problems
|
|
97
|
+
return "#{integration_name} integration cannot be installed: #{problems.join('; ')}" if problems.any?
|
|
98
|
+
return untested_version_message if untested_version?
|
|
99
|
+
return "#{integration_name} integration is enabled but not installed" unless @subscriptions
|
|
100
|
+
|
|
101
|
+
instrumenter = RubyLLM.config.instrumenter
|
|
102
|
+
return if instrumenter == ActiveSupport::Notifications
|
|
103
|
+
|
|
104
|
+
"RubyLLM.config.instrumenter is #{instrumenter.inspect}, not ActiveSupport::Notifications, " \
|
|
105
|
+
"so RubyLLM calls are not recorded"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def target_problems = defined?(RubyLLM.config) ? [] : ["RubyLLM is not loaded"]
|
|
109
|
+
|
|
110
|
+
def current_workflow
|
|
111
|
+
instrumentation = "RubyLLM::Support::Instrumentation".safe_constantize
|
|
112
|
+
instrumentation.current_workflow if instrumentation.respond_to?(:current_workflow)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def start_operation(name, payload)
|
|
116
|
+
return unless active?
|
|
117
|
+
|
|
118
|
+
enforce_budget!(
|
|
119
|
+
request: budget_request(payload),
|
|
120
|
+
provider: payload[:provider].to_s,
|
|
121
|
+
tags: (LlmCostTracker::Tracker.build_tags(**Recorder.tags_for(payload)) if payload[:workflow_id])
|
|
122
|
+
)
|
|
123
|
+
Frame.open(payload, compaction: name.start_with?("compaction")) if JOBS.exclude?(name)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def budget_request(payload)
|
|
127
|
+
input = payload[:input_messages]&.map { |message| message.try(:content).then { |c| c.try(:text) || c } }
|
|
128
|
+
{ model: payload[:model], input: input || payload.values_at(:input, :prompt, :query) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def finish_operation(payload)
|
|
132
|
+
errors = Recorder.record_all(Frame.close(payload).flat_map(&:attempts))
|
|
133
|
+
raise errors.first if errors.any? && !payload[:exception]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def start_request(payload)
|
|
137
|
+
cache = payload[:method] == :post && payload[:url].to_s.end_with?("cachedContents")
|
|
138
|
+
Thread.current[CACHE_CREATED] = (payload[:provider].to_s if cache)
|
|
139
|
+
enforce_budget!(request: {}, provider: payload[:provider].to_s) if cache
|
|
140
|
+
Frame.current&.request_started
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def add_attempt(usage)
|
|
144
|
+
if OPERATIONS.include?(usage[:operation])
|
|
145
|
+
Frame.current&.add_attempt(usage)
|
|
146
|
+
elsif active?
|
|
147
|
+
record_safely { Recorder.record(Attempt.new(usage, {}, final: false)) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def record_cache_storage(data)
|
|
152
|
+
event = Providers::Gemini::Parser.new.cache_storage_event(data) if Thread.current[CACHE_CREATED] && active?
|
|
153
|
+
LlmCostTracker::Tracker.record(event: event.with(provider: Thread.current[CACHE_CREATED])) if event
|
|
154
|
+
rescue LlmCostTracker::BudgetExceededError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|