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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. 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