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
@@ -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 |mod|
9
- methods.each { |method_name| define_blocking_method(mod, method_name, record_method) }
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 |mod|
15
- methods.each { |method_name| define_stream_method(mod, method_name) }
16
- end
17
- end
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
- ) { super(*args, **kwargs, &block) }
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 = super
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
- LlmCostTracker::Integrations::Openai.wrap_stream(
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
- EmbeddingsPatch = PatchBuilder.build(record_method: :record_response, methods: %i[create])
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 = super
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::Embeddings", with: EmbeddingsPatch, optional: true),
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
- normalized = LlmCostTracker::Capture::SdkPayload.normalize(response)
81
- usage = normalized["usage"] || {}
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 = LlmCostTracker::Providers::Openai::ResponseParser.event_from_response(
92
- response: normalized,
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: LlmCostTracker::Usage::Source::SDK_RESPONSE
98
+ usage_source: Usage::Source::SDK_RESPONSE
97
99
  )
98
- LlmCostTracker::Tracker.record(event: event, latency_ms: latency_ms)
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 = LlmCostTracker::Providers::Openai::ResponseParser.retrieved_event(
107
- response: LlmCostTracker::Capture::SdkPayload.normalize(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: LlmCostTracker::Usage::Source::SDK_RESPONSE
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
- raw_input = usage[:input_tokens].to_i
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
- input_tokens: [raw_input - image_input - cache_read, 0].max,
135
- image_input_tokens: image_input,
136
- output_tokens: text_output,
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 = usage_hash_from(response)
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: LlmCostTracker::Providers::Openai::ServiceCharges.transcription_line_items(usage),
151
- usage_source: usage ? LlmCostTracker::Usage::Source::SDK_RESPONSE : LlmCostTracker::Usage::Source::UNKNOWN,
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 transcription_token_attributes(usage)
157
- return { input_tokens: 0, output_tokens: 0 } unless usage && usage[:type].to_s == "tokens"
158
-
159
- raw_input = usage[:input_tokens].to_i
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
- def record_speech(_response, request:, latency_ms:, host: nil)
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: LlmCostTracker::Providers::Openai::ServiceCharges.speech_line_items(request)
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
- LlmCostTracker::Providers::Openai::ResponseParser.combined_pricing_mode(
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