llm_cost_tracker 0.14.0 → 0.14.2

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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/README.md +16 -5
  4. data/app/assets/llm_cost_tracker/application.css +16 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
  7. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
  13. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  14. data/app/models/llm_cost_tracker/call.rb +10 -14
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
  17. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  18. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  19. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  20. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  21. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  22. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  23. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  24. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  26. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  27. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  28. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  32. data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
  33. data/config/routes.rb +6 -1
  34. data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
  35. data/lib/llm_cost_tracker/budget.rb +44 -55
  36. data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
  37. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  38. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
  40. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  41. data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
  42. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  43. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  44. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  45. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  46. data/lib/llm_cost_tracker/configuration.rb +5 -9
  47. data/lib/llm_cost_tracker/doctor/price_check.rb +14 -3
  48. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  49. data/lib/llm_cost_tracker/doctor.rb +19 -2
  50. data/lib/llm_cost_tracker/engine.rb +0 -1
  51. data/lib/llm_cost_tracker/errors.rb +13 -0
  52. data/lib/llm_cost_tracker/event.rb +8 -0
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  70. data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
  71. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  72. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  73. data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
  74. data/lib/llm_cost_tracker/integrations/base.rb +56 -19
  75. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
  76. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  77. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  78. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
  79. data/lib/llm_cost_tracker/integrations.rb +1 -20
  80. data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
  81. data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
  82. data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
  83. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  84. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  85. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  86. data/lib/llm_cost_tracker/ledger/store.rb +14 -13
  87. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  88. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  89. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  90. data/lib/llm_cost_tracker/ledger.rb +1 -0
  91. data/lib/llm_cost_tracker/logging.rb +3 -1
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
  93. data/lib/llm_cost_tracker/parsers.rb +12 -41
  94. data/lib/llm_cost_tracker/prices.json +3071 -249
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  100. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  102. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  105. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  107. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  108. data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
  109. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  110. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  111. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  112. data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
  113. data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
  114. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  115. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  116. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  117. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
  118. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  119. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
  120. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
  121. data/lib/llm_cost_tracker/redaction.rb +32 -0
  122. data/lib/llm_cost_tracker/report/data.rb +1 -1
  123. data/lib/llm_cost_tracker/retention.rb +1 -3
  124. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
  125. data/lib/llm_cost_tracker/tracker.rb +17 -10
  126. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  127. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  128. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  129. data/lib/llm_cost_tracker/version.rb +1 -1
  130. data/lib/llm_cost_tracker.rb +4 -2
  131. data/lib/tasks/llm_cost_tracker.rake +26 -21
  132. data/llm_cost_tracker.gemspec +63 -0
  133. metadata +23 -10
@@ -18,19 +18,12 @@ module LlmCostTracker
18
18
  minimum_version "0.59.0"
19
19
 
20
20
  class << self
21
- def stream_pricing_mode(request, host: nil)
22
- LlmCostTracker::Providers::Openai::ResponseParser.combined_pricing_mode(
23
- host: host,
24
- model: (request || {})[:model],
25
- service_tier: (request || {})[:service_tier]
26
- )
27
- end
28
-
29
21
  def stream_collector(request, host: nil)
30
22
  LlmCostTracker::Capture::StreamCollector.new(
31
23
  provider: provider_for_host(host),
32
24
  model: request[:model],
33
- pricing_mode: stream_pricing_mode(request, host: host),
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),
34
27
  request: request
35
28
  )
36
29
  end
@@ -53,29 +46,25 @@ module LlmCostTracker
53
46
  end
54
47
 
55
48
  def provider_for_host(host)
56
- LlmCostTracker::Providers::Azure::Hosts.openai?(host) ? "azure_openai" : "openai"
49
+ return "azure_openai" if LlmCostTracker::Providers::Azure::Hosts.openai?(host)
50
+
51
+ LlmCostTracker.configuration.capture.openai_compatible_providers[host.to_s.downcase] || "openai"
57
52
  end
58
53
 
59
54
  def patch_targets
60
55
  [
61
56
  patch_target("OpenAI::Resources::Responses", with: ResponsesPatch),
62
57
  patch_target("OpenAI::Resources::Chat::Completions", with: ChatCompletionsPatch),
63
- *auxiliary_patch_targets
64
- ]
65
- end
66
-
67
- def auxiliary_patch_targets
68
- [
69
58
  patch_target("OpenAI::Resources::Embeddings", with: EmbeddingsPatch, optional: true),
70
59
  patch_target("OpenAI::Resources::Images", with: ImagesPatch, optional: true),
71
60
  patch_target("OpenAI::Resources::Images",
72
61
  with: StreamingImagesPatch,
73
- optional: true,
62
+ optional: true,
74
63
  skip_when_methods_missing: true),
75
64
  patch_target("OpenAI::Resources::Audio::Transcriptions", with: TranscriptionsPatch, optional: true),
76
65
  patch_target("OpenAI::Resources::Audio::Transcriptions",
77
66
  with: StreamingTranscriptionsPatch,
78
- optional: true,
67
+ optional: true,
79
68
  skip_when_methods_missing: true),
80
69
  patch_target("OpenAI::Resources::Audio::Translations", with: TranslationsPatch, optional: true),
81
70
  patch_target("OpenAI::Resources::Audio::Speech", with: SpeechPatch, optional: true),
@@ -89,11 +78,14 @@ module LlmCostTracker
89
78
 
90
79
  record_safely do
91
80
  normalized = LlmCostTracker::Capture::SdkPayload.normalize(response)
92
- usage = normalized["usage"]
93
- if usage
94
- input_tokens = usage["input_tokens"] || usage["prompt_tokens"]
95
- output_tokens = usage["output_tokens"] || usage["completion_tokens"]
96
- next if input_tokens.nil? && output_tokens.nil?
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
97
89
  end
98
90
 
99
91
  event = LlmCostTracker::Providers::Openai::ResponseParser.event_from_response(
@@ -103,7 +95,21 @@ module LlmCostTracker
103
95
  host: host,
104
96
  usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE
105
97
  )
106
- LlmCostTracker::Tracker.record(event: event, latency_ms: latency_ms) if event
98
+ LlmCostTracker::Tracker.record(event: event, latency_ms: latency_ms)
99
+ end
100
+ end
101
+
102
+ def record_retrieved_response(response, host:)
103
+ return unless active?
104
+
105
+ record_safely do
106
+ event = LlmCostTracker::Providers::Openai::ResponseParser.retrieved_event(
107
+ response: LlmCostTracker::Capture::SdkPayload.normalize(response),
108
+ provider: provider_for_host(host),
109
+ host: host,
110
+ usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE
111
+ )
112
+ record_once(event) if event
107
113
  end
108
114
  end
109
115
 
@@ -123,7 +129,8 @@ module LlmCostTracker
123
129
  model: request[:model],
124
130
  response: response,
125
131
  latency_ms: latency_ms,
126
- host: host,
132
+ provider: provider_for_host(host),
133
+ pricing_mode: host_pricing_mode(host, request),
127
134
  input_tokens: [raw_input - image_input - cache_read, 0].max,
128
135
  image_input_tokens: image_input,
129
136
  output_tokens: text_output,
@@ -138,8 +145,10 @@ module LlmCostTracker
138
145
  model: request[:model],
139
146
  response: response,
140
147
  latency_ms: latency_ms,
141
- host: host,
148
+ provider: provider_for_host(host),
149
+ pricing_mode: host_pricing_mode(host, request),
142
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,
143
152
  **transcription_token_attributes(usage)
144
153
  )
145
154
  end
@@ -161,54 +170,28 @@ module LlmCostTracker
161
170
  model: request[:model],
162
171
  response: nil,
163
172
  latency_ms: latency_ms,
164
- host: host,
173
+ provider: provider_for_host(host),
165
174
  input_tokens: 0,
166
175
  output_tokens: 0,
167
- service_line_items: speech_line_items(request)
176
+ service_line_items: LlmCostTracker::Providers::Openai::ServiceCharges.speech_line_items(request)
168
177
  )
169
178
  end
170
179
 
171
- def speech_line_items(request)
172
- input = request[:input]
173
- return [] unless input.is_a?(String)
174
- return [] unless LlmCostTracker::Providers::Openai::ModelFamilies.character_billed_tts?(request[:model])
175
-
176
- [LlmCostTracker::Charges::LineItem.build(
177
- dimension_key: "text_to_speech_character",
178
- quantity: input.length,
179
- cost_status: LlmCostTracker::Charges::CostStatus::UNKNOWN,
180
- pricing_basis: "provider_usage",
181
- provider_field: "request.input"
182
- )]
183
- end
184
-
185
180
  def record_moderation(response, request:, latency_ms:, host: nil)
186
181
  record_passthrough(
187
182
  model: response.model || request[:model],
188
183
  response: response,
189
184
  latency_ms: latency_ms,
190
- host: host,
185
+ provider: provider_for_host(host),
191
186
  input_tokens: 0,
192
187
  output_tokens: 0
193
188
  )
194
189
  end
195
190
 
196
- def record_passthrough(model:, response:, latency_ms:, host: nil, service_line_items: [], **token_attributes)
197
- return unless active?
198
-
199
- record_safely do
200
- LlmCostTracker::Tracker.record(
201
- event: Event.build(
202
- provider: provider_for_host(host),
203
- model: model,
204
- token_usage: Usage::TokenUsage.build(**token_attributes),
205
- usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
206
- provider_response_id: response&.try(:id),
207
- service_line_items: service_line_items
208
- ),
209
- latency_ms: latency_ms
210
- )
211
- end
191
+ def host_pricing_mode(host, request)
192
+ LlmCostTracker::Providers::Openai::ResponseParser.combined_pricing_mode(
193
+ provider: provider_for_host(host), host: host, model: request[:model], service_tier: nil
194
+ )
212
195
  end
213
196
 
214
197
  def usage_hash_from(response)
@@ -8,57 +8,123 @@ module LlmCostTracker
8
8
  extend Base
9
9
 
10
10
  minimum_version "1.15.0"
11
+ maximum_version "3.0.0"
11
12
 
12
13
  class << self
13
14
  def patch_targets
14
- [patch_target("RubyLLM::Provider", with: ProviderPatch)]
15
+ [
16
+ patch_target("RubyLLM::Provider", with: ProviderPatch),
17
+ patch_target("RubyLLM::Providers::Gemini::Transcription", with: GeminiTranscriptionPatch, optional: true),
18
+ patch_target("RubyLLM::Protocols::Gemini",
19
+ with: GeminiImagesPatch,
20
+ optional: true,
21
+ skip_when_methods_missing: true),
22
+ patch_target("RubyLLM::Protocols::Gemini",
23
+ with: GeminiCachePatch,
24
+ optional: true,
25
+ skip_when_methods_missing: true),
26
+ patch_target("RubyLLM::Streaming", with: StreamPatch, optional: true),
27
+ patch_target("RubyLLM::Protocol::Streaming", with: StreamPatch, optional: true),
28
+ *%w[RubyLLM::Providers::OpenAI RubyLLM::Providers::Gemini
29
+ RubyLLM::Protocols::ChatCompletions RubyLLM::Protocols::Gemini].map do |name|
30
+ patch_target(name, with: ResponseBodyPatch, optional: true, skip_when_methods_missing: true)
31
+ end
32
+ ]
15
33
  end
16
34
 
17
35
  def record_completion(provider, response, request:, latency_ms:, has_block:)
36
+ model = response_model_id(response) || model_id_from_request(request[:model])
18
37
  record_usage(
19
- provider: provider.slug.to_s,
20
- model: response_model_id(response) || model_id_from_request(request[:model]),
38
+ provider: provider,
39
+ model: model,
21
40
  response: response,
22
41
  latency_ms: latency_ms,
23
- stream: has_block || request[:stream] == true
42
+ stream: has_block || request[:stream] == true,
43
+ cache_ttl: request[:caching].try(:[], :ttl),
44
+ service_line_items: service_line_items(provider.slug.to_s, response, model)
24
45
  )
25
46
  end
26
47
 
48
+ def service_line_items(provider, response, model)
49
+ body = raw_body(response)
50
+ case provider
51
+ when "anthropic"
52
+ counts = response.try(:tokens).try(:server_tool_use) || body.dig("usage", "server_tool_use")
53
+ Providers::Anthropic::UsageExtractor.service_line_items(server_tool_use: counts&.symbolize_keys)
54
+ when "openai" then Providers::Openai::ServiceCharges.service_line_items_for(body, model: model)
55
+ when "gemini" then Providers::Gemini::Parser.new.service_line_items_for(gemini_body(response), model: model)
56
+ when "openrouter" then Providers::Openai::ServiceCharges.billed_line_items(usage_hash(body))
57
+ else []
58
+ end
59
+ end
60
+
27
61
  def record_embedding(provider, response, request:, latency_ms:)
28
62
  record_usage(
29
- provider: provider.slug.to_s,
63
+ provider: provider,
30
64
  model: response_model_id(response) || model_id_from_request(request[:model]),
31
65
  response: response,
32
66
  latency_ms: latency_ms,
33
67
  stream: false,
34
- output_tokens: 0
68
+ output_tokens: 0,
69
+ service_line_items: gemini_video_line_items(provider, response)
35
70
  )
36
71
  end
37
72
 
38
- def record_transcription(provider, response, request:, latency_ms:)
73
+ def gemini_video_line_items(provider, response)
74
+ details = raw_body(response).dig("usageMetadata", "promptTokenDetails") if provider.slug.to_s == "gemini"
75
+ video = Providers::Gemini::UsageExtractor.modality_tokens(details, "VIDEO")
76
+ video.positive? ? [Charges::LineItem.build(dimension_key: "video_input", quantity: video)] : []
77
+ end
78
+
79
+ def record_transcription(provider, response, request:, latency_ms:, stream: false)
80
+ model = response_model_id(response) || model_id_from_request(request[:model])
81
+ match = LlmCostTracker::Pricing::Matcher.lookup(provider: provider.slug.to_s, model: model)
82
+ counts = token_counts(response)
83
+ usage = usage_hash(raw_body(response))
84
+ no_tokens = counts[:input].to_i.zero? && counts[:output].to_i.zero?
85
+ duration = billed_duration(usage, response, no_tokens)
86
+ line_items = Providers::Openai::ServiceCharges.transcription_line_items(duration)
87
+ audio_input = Providers::Openai::UsageExtractor.audio_input_tokens(usage)
88
+ audio_input = counts[:input].to_i if audio_input.zero? && match&.prices&.key?("audio_input")
39
89
  record_usage(
40
- provider: provider.slug.to_s,
41
- model: response_model_id(response) || model_id_from_request(request[:model]),
90
+ provider: provider,
91
+ model: model,
42
92
  response: response,
43
93
  latency_ms: latency_ms,
44
- stream: false
94
+ stream: stream,
95
+ audio_input_tokens: audio_input,
96
+ service_line_items: line_items,
97
+ usage_source: (Usage::Source::UNKNOWN if no_tokens && line_items.empty?)
45
98
  )
46
99
  end
47
100
 
101
+ def billed_duration(usage, response, no_tokens)
102
+ return usage if usage[:type].to_s == "duration"
103
+
104
+ { type: "duration", seconds: response.duration&.ceil } if no_tokens
105
+ end
106
+
48
107
  def record_image(provider, response, request:, latency_ms:)
49
- usage = response.usage.with_indifferent_access
50
- raw_input = usage[:input_tokens].to_i
51
- raw_output = usage[:output_tokens].to_i
52
- image_input = image_token_detail(usage, :input)
53
- image_output = image_token_detail(usage, :output)
108
+ image = response.is_a?(Array) ? response.first : response
109
+ model = response_model_id(image) || model_id_from_request(request[:model])
110
+ usage = image_usage(image)
111
+ extractor = Providers::Openai::UsageExtractor
112
+ image_input = extractor.image_input_tokens(usage)
113
+ image_output, text_output = extractor.split_output(
114
+ output_tokens: usage[:output_tokens].to_i,
115
+ image_output_details: gemini_image_output_tokens(image) || extractor.image_output_tokens(usage),
116
+ text_output_details: extractor.text_output_tokens(usage),
117
+ audio_output: 0,
118
+ default_to_image: model.to_s.match?(/\A(gpt-image-|gemini-.*-image)/)
119
+ )
54
120
  record_passthrough(
55
121
  provider: provider.slug.to_s,
56
- model: response_model_id(response) || model_id_from_request(request[:model]),
57
- response: response,
122
+ model: model,
123
+ response: image,
58
124
  latency_ms: latency_ms,
59
- input_tokens: [raw_input - image_input, 0].max,
125
+ input_tokens: [usage[:input_tokens].to_i - image_input, 0].max,
60
126
  image_input_tokens: image_input,
61
- output_tokens: [raw_output - image_output, 0].max,
127
+ output_tokens: text_output,
62
128
  image_output_tokens: image_output
63
129
  )
64
130
  end
@@ -74,67 +140,61 @@ module LlmCostTracker
74
140
  )
75
141
  end
76
142
 
77
- def image_token_detail(usage, direction)
78
- container_key = direction == :input ? :input_tokens_details : :output_tokens_details
79
- details = usage[container_key]
80
- return 0 unless details.is_a?(Hash)
143
+ def record_cache_storage(cache)
144
+ return unless active? && cache.provider.to_s == "gemini"
81
145
 
82
- details.with_indifferent_access[:image_tokens].to_i
146
+ record_safely do
147
+ event = Providers::Gemini::Parser.new.cache_storage_event(cache.metadata)
148
+ LlmCostTracker::Tracker.record(event: event) if event
149
+ end
83
150
  end
84
151
 
85
- def record_passthrough(provider:,
86
- model:,
87
- response:,
88
- latency_ms:,
89
- input_tokens:,
90
- output_tokens:,
91
- image_input_tokens: 0,
92
- image_output_tokens: 0)
93
- return unless active?
152
+ def image_usage(image)
153
+ usage = image.try(:usage)
154
+ usage = image.send(:raw_usage) if !usage.is_a?(Hash) && image.respond_to?(:raw_usage, true)
155
+ (usage.is_a?(Hash) ? usage : {}).with_indifferent_access
156
+ end
94
157
 
95
- record_safely do
96
- LlmCostTracker::Tracker.record(
97
- event: Event.build(
98
- provider: provider,
99
- model: model,
100
- token_usage: Usage::TokenUsage.build(
101
- input_tokens: input_tokens,
102
- output_tokens: output_tokens,
103
- image_input_tokens: image_input_tokens,
104
- image_output_tokens: image_output_tokens
105
- ),
106
- usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
107
- provider_response_id: provider_response_id_for(response)
108
- ),
109
- latency_ms: latency_ms
110
- )
111
- end
158
+ def gemini_image_output_tokens(image)
159
+ metadata = image.instance_variable_get(:@llm_cost_tracker_usage_metadata)
160
+ Providers::Gemini::UsageExtractor.modality_tokens(metadata["candidatesTokensDetails"], "IMAGE") if metadata
112
161
  end
113
162
 
114
- def record_usage(provider:, model:, response:, latency_ms:, stream:, output_tokens: nil)
163
+ def record_usage(provider:,
164
+ model:,
165
+ response:,
166
+ latency_ms:,
167
+ stream:,
168
+ output_tokens: nil,
169
+ audio_input_tokens: 0,
170
+ cache_ttl: nil,
171
+ service_line_items: [],
172
+ usage_source: nil)
115
173
  return unless active?
116
174
 
117
175
  record_safely do
118
- input_tokens = response.input_tokens
119
- output_tokens = response.output_tokens if output_tokens.nil?
120
- next if input_tokens.nil? && output_tokens.nil?
176
+ counts = token_counts(response, provider.slug.to_s)
177
+ output_tokens = counts[:output] if output_tokens.nil?
178
+ next if counts[:input].nil? && output_tokens.nil? && service_line_items.empty? && usage_source.nil?
121
179
 
122
- cache_write_5m, cache_write_1h = cache_creation_split(provider, response)
180
+ cache_write_5m, cache_write_1h = cache_write_split(provider, response, counts[:cache_write], cache_ttl)
123
181
  LlmCostTracker::Tracker.record(
124
182
  event: Event.build(
125
- provider: provider,
183
+ provider: provider.slug.to_s,
126
184
  model: model,
127
- pricing_mode: pricing_mode_for(provider: provider, response: response),
128
- token_usage: Usage::TokenUsage.build(
129
- input_tokens: input_tokens.to_i,
185
+ pricing_mode: pricing_mode_for(provider: provider, model: model, response: response),
186
+ token_usage: gemini_token_usage(provider, response) || Usage::TokenUsage.build(
187
+ input_tokens: counts[:input].to_i - audio_input_tokens,
188
+ audio_input_tokens: audio_input_tokens,
130
189
  output_tokens: output_tokens.to_i,
131
- cache_read_input_tokens: response.try(:cached_tokens).to_i,
190
+ cache_read_input_tokens: counts[:cache_read].to_i,
132
191
  cache_write_input_tokens: cache_write_5m,
133
192
  cache_write_extended_input_tokens: cache_write_1h,
134
- hidden_output_tokens: response.try(:thinking_tokens).to_i
193
+ hidden_output_tokens: counts[:thinking].to_i
135
194
  ),
195
+ service_line_items: service_line_items + gemini_cache_read_line_items(provider, response),
136
196
  stream: stream,
137
- usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
197
+ usage_source: usage_source || LlmCostTracker::Usage::Source::SDK_RESPONSE,
138
198
  provider_response_id: provider_response_id_for(response)
139
199
  ),
140
200
  latency_ms: latency_ms
@@ -142,13 +202,66 @@ module LlmCostTracker
142
202
  end
143
203
  end
144
204
 
145
- def cache_creation_split(provider, response)
146
- return [response.try(:cache_creation_tokens).to_i, 0] unless provider == "anthropic"
205
+ def gemini_token_usage(provider, response)
206
+ usage = gemini_usage_metadata(response) if provider.slug.to_s == "gemini"
207
+ return unless usage.is_a?(Hash)
208
+
209
+ usage = usage.merge("promptTokensDetails" => usage["promptTokenDetails"]) if usage.key?("promptTokenDetails")
210
+ Providers::Gemini::UsageExtractor.token_usage(usage)
211
+ end
147
212
 
148
- cache = raw_body(response).dig("usage", "cache_creation")
149
- return [response.try(:cache_creation_tokens).to_i, 0] unless cache.is_a?(Hash)
213
+ def gemini_cache_read_line_items(provider, response)
214
+ usage = gemini_usage_metadata(response) if provider.slug.to_s == "gemini"
215
+ usage.is_a?(Hash) ? Providers::Gemini::UsageExtractor.cache_read_line_items(usage) : []
216
+ end
150
217
 
151
- [cache["ephemeral_5m_input_tokens"].to_i, cache["ephemeral_1h_input_tokens"].to_i]
218
+ def gemini_body(response)
219
+ content = response.try(:raw_content)
220
+ interaction = content["response"] if content.is_a?(Hash)
221
+ interaction.is_a?(Hash) && interaction["usage"].is_a?(Hash) ? interaction : raw_body(response)
222
+ end
223
+
224
+ def gemini_usage_metadata(response)
225
+ body = gemini_body(response)
226
+ return body["usageMetadata"] unless body["usage"].is_a?(Hash)
227
+
228
+ Providers::Gemini::Parser.new.interaction_usage_metadata(body["usage"], body["service_tier"])
229
+ end
230
+
231
+ def token_counts(response, provider = nil)
232
+ tokens = response.try(:tokens)
233
+ return { input: response.try(:input_tokens), output: response.try(:output_tokens) } unless tokens
234
+
235
+ usage = raw_body(response)["usage"] || {}
236
+ input = usage["inputTokens"] || tokens.input
237
+ input = [input, usage["input_tokens"]].compact.max if provider == "anthropic"
238
+ output = tokens.output
239
+ thinking = tokens.thinking.to_i
240
+ raw_input = (usage["input_tokens"] || usage["prompt_tokens"]).to_i
241
+ output += thinking if output && usage["total_tokens"] == raw_input + output + thinking
242
+ {
243
+ input: input,
244
+ output: output,
245
+ cache_read: tokens.cache_read,
246
+ cache_write: tokens.cache_write,
247
+ thinking: tokens.thinking
248
+ }
249
+ end
250
+
251
+ def cache_write_split(provider, response, cache_write, cache_ttl)
252
+ usage = raw_body(response)["usage"] || {}
253
+ cache = case provider.slug.to_s
254
+ when "anthropic" then usage["cache_creation"]
255
+ when "bedrock"
256
+ Array(usage["cacheDetails"]).to_h { |d| ["ephemeral_#{d['ttl']}_input_tokens", d["inputTokens"]] }
257
+ end
258
+ return [cache_write.to_i, 0] unless cache.is_a?(Hash)
259
+
260
+ five_minute = cache["ephemeral_5m_input_tokens"].to_i
261
+ one_hour = cache["ephemeral_1h_input_tokens"].to_i
262
+ # RubyLLM sums the writes of every pause_turn segment, but the raw body is only the last segment's.
263
+ earlier = [cache_write.to_i - five_minute - one_hour, 0].max
264
+ cache_ttl.to_s == "1h" ? [five_minute, one_hour + earlier] : [five_minute + earlier, one_hour]
152
265
  end
153
266
 
154
267
  def model_id_from_request(value)
@@ -159,28 +272,55 @@ module LlmCostTracker
159
272
  end
160
273
 
161
274
  def provider_response_id_for(response)
162
- body = raw_body(response)
275
+ body = gemini_body(response)
163
276
  body["id"] || body["responseId"]
164
277
  end
165
278
 
166
279
  def raw_body(response)
167
- body = response.try(:raw)&.body
280
+ raw = response.try(:raw)
281
+ body = raw.respond_to?(:body) ? raw.body : raw
282
+ body = (raw || response).instance_variable_get(:@llm_cost_tracker_body) unless body.is_a?(Hash)
168
283
  body.is_a?(Hash) ? body : {}
169
284
  end
170
285
 
286
+ def usage_hash(body) = (body["usage"] || {}).deep_symbolize_keys
287
+
288
+ def keep_usage(result, body)
289
+ usage = body.slice("usage", "usageMetadata") if body.is_a?(Hash)
290
+ result.instance_variable_set(:@llm_cost_tracker_body, usage) if usage
291
+ result
292
+ end
293
+
171
294
  def response_model_id(response)
172
295
  (response.try(:model_id) || response.try(:model))&.to_s
173
296
  end
174
297
 
175
- def pricing_mode_for(provider:, response:)
298
+ def pricing_mode_for(provider:, model:, response:)
176
299
  body = raw_body(response)
177
- case provider
178
- when "anthropic" then body.dig("usage", "service_tier")
179
- when "gemini" then body.dig("usageMetadata", "serviceTier")
300
+ case provider.slug.to_s
301
+ when "anthropic", "bedrock"
302
+ Providers::Anthropic::UsageExtractor.pricing_mode(request: { model: model },
303
+ usage: body["usage"]&.deep_symbolize_keys)
304
+ when "gemini" then gemini_usage_metadata(response).try(:[], "serviceTier")
305
+ when "openai", "xai", "mistral"
306
+ Providers::Openai::ResponseParser.combined_pricing_mode(
307
+ provider: provider.slug.to_s,
308
+ host: URI(provider.api_base).host,
309
+ model: model,
310
+ service_tier: body["service_tier"] || body.dig("usage", "service_tier")
311
+ )
180
312
  else body["service_tier"]
181
313
  end
182
314
  end
183
315
 
316
+ def request_params(args, kwargs)
317
+ input = args.first
318
+ if input.is_a?(Array)
319
+ input = input.map { |msg| msg.try(:content).then { |content| content.try(:text) || content } || msg }
320
+ end
321
+ kwargs.merge(input: input, model: model_id_from_request(kwargs[:model])).with_indifferent_access
322
+ end
323
+
184
324
  def blocking_seam(resource, record_method, **extras)
185
325
  {
186
326
  provider: resource.slug.to_s,
@@ -202,8 +342,8 @@ module LlmCostTracker
202
342
  LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
203
343
  end
204
344
 
205
- def transcribe(*args, **kwargs)
206
- seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_transcription)
345
+ def transcribe(*args, **kwargs, &)
346
+ seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_transcription, stream: block_given?)
207
347
  LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
208
348
  end
209
349
 
@@ -217,6 +357,66 @@ module LlmCostTracker
217
357
  LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
218
358
  end
219
359
  end
360
+
361
+ module GeminiImagesPatch
362
+ def parse_image_responses(response, *, **)
363
+ images = super
364
+ metadata = response.body["usageMetadata"]
365
+ Array(images).each { |image| image.instance_variable_set(:@llm_cost_tracker_usage_metadata, metadata) }
366
+ images
367
+ end
368
+ end
369
+
370
+ module GeminiCachePatch
371
+ def cache_content(*, **)
372
+ super.tap { |cache| LlmCostTracker::Integrations::RubyLlm.record_cache_storage(cache) }
373
+ end
374
+ end
375
+
376
+ module GeminiTranscriptionPatch
377
+ def transcribe(*args, **kwargs)
378
+ seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_transcription)
379
+ LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
380
+ end
381
+ end
382
+
383
+ module ResponseBodyPatch
384
+ def parse_transcription_response(response, **)
385
+ LlmCostTracker::Integrations::RubyLlm.keep_usage(super, response.body)
386
+ end
387
+
388
+ def parse_embedding_response(response, **)
389
+ LlmCostTracker::Integrations::RubyLlm.keep_usage(super, response.body)
390
+ end
391
+
392
+ def build_streamed_transcription(chunks, **)
393
+ LlmCostTracker::Integrations::RubyLlm.keep_usage(super, chunks.reverse.find(&:done?)&.raw)
394
+ end
395
+ end
396
+
397
+ module StreamPatch
398
+ private
399
+
400
+ def stream_response(...)
401
+ body = @llm_cost_tracker_stream_body = {}
402
+ super.tap do |message|
403
+ message.raw.instance_variable_set(:@llm_cost_tracker_body, body)
404
+ input = body.dig("usage", "input_tokens")
405
+ input = body["usage"]["input_tokens"] = input + @llm_cost_tracker_paused_input.to_i if input
406
+ @llm_cost_tracker_paused_input = (input if message.try(:finish_reason) == :pause_turn)
407
+ end
408
+ end
409
+
410
+ def build_on_data_handler(*, &handler)
411
+ body = @llm_cost_tracker_stream_body
412
+ super do |data|
413
+ if body && data.is_a?(Hash)
414
+ body.deep_merge!(data.values_at("message", "response").find { |part| part.is_a?(Hash) } || data)
415
+ end
416
+ handler.call(data)
417
+ end
418
+ end
419
+ end
220
420
  end
221
421
  end
222
422
  end