llm_cost_tracker 0.14.1 → 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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -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/calls_controller.rb +1 -3
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  7. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +4 -25
  9. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  10. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  11. data/app/models/llm_cost_tracker/call.rb +10 -14
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -9
  14. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  16. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  17. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  18. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  19. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  21. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  22. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  23. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  26. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  27. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  28. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -5
  29. data/lib/llm_cost_tracker/budget/per_tag.rb +15 -9
  30. data/lib/llm_cost_tracker/budget.rb +44 -55
  31. data/lib/llm_cost_tracker/capture/event_window.rb +3 -2
  32. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  33. data/lib/llm_cost_tracker/capture/stream_collector.rb +25 -26
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +9 -23
  35. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  36. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  37. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  38. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  39. data/lib/llm_cost_tracker/configuration.rb +5 -9
  40. data/lib/llm_cost_tracker/doctor/price_check.rb +13 -3
  41. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  42. data/lib/llm_cost_tracker/doctor.rb +19 -2
  43. data/lib/llm_cost_tracker/engine.rb +0 -1
  44. data/lib/llm_cost_tracker/event.rb +8 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  49. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +11 -9
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  62. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  63. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  64. data/lib/llm_cost_tracker/integrations/anthropic.rb +63 -10
  65. data/lib/llm_cost_tracker/integrations/base.rb +39 -18
  66. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  67. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  68. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  69. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +241 -76
  70. data/lib/llm_cost_tracker/integrations.rb +1 -20
  71. data/lib/llm_cost_tracker/ledger/isolation.rb +0 -7
  72. data/lib/llm_cost_tracker/ledger/period/totals.rb +14 -10
  73. data/lib/llm_cost_tracker/ledger/rollups.rb +14 -14
  74. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  75. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  76. data/lib/llm_cost_tracker/ledger/store.rb +2 -2
  77. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  78. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  79. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  80. data/lib/llm_cost_tracker/middleware/faraday.rb +36 -19
  81. data/lib/llm_cost_tracker/parsers.rb +7 -40
  82. data/lib/llm_cost_tracker/prices.json +2237 -154
  83. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  84. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  85. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  86. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  87. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  88. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  89. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  90. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  91. data/lib/llm_cost_tracker/pricing/registry.rb +5 -16
  92. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  93. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  94. data/lib/llm_cost_tracker/pricing/sync.rb +22 -71
  95. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  96. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  97. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  98. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -7
  99. data/lib/llm_cost_tracker/providers/gemini/parser.rb +172 -48
  100. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  101. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  102. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  103. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +96 -26
  104. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  105. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -1
  106. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +3 -1
  107. data/lib/llm_cost_tracker/report/data.rb +1 -1
  108. data/lib/llm_cost_tracker/retention.rb +1 -3
  109. data/lib/llm_cost_tracker/tracker.rb +17 -9
  110. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  111. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  112. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  113. data/lib/llm_cost_tracker/version.rb +1 -1
  114. data/lib/tasks/llm_cost_tracker.rake +24 -21
  115. data/llm_cost_tracker.gemspec +63 -0
  116. metadata +8 -7
@@ -14,59 +14,117 @@ module LlmCostTracker
14
14
  def patch_targets
15
15
  [
16
16
  patch_target("RubyLLM::Provider", with: ProviderPatch),
17
- patch_target("RubyLLM::Providers::Gemini::Transcription", with: GeminiTranscriptionPatch, optional: true)
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
18
32
  ]
19
33
  end
20
34
 
21
35
  def record_completion(provider, response, request:, latency_ms:, has_block:)
36
+ model = response_model_id(response) || model_id_from_request(request[:model])
22
37
  record_usage(
23
- provider: provider.slug.to_s,
24
- model: response_model_id(response) || model_id_from_request(request[:model]),
38
+ provider: provider,
39
+ model: model,
25
40
  response: response,
26
41
  latency_ms: latency_ms,
27
- 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)
28
45
  )
29
46
  end
30
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
+
31
61
  def record_embedding(provider, response, request:, latency_ms:)
32
62
  record_usage(
33
- provider: provider.slug.to_s,
63
+ provider: provider,
34
64
  model: response_model_id(response) || model_id_from_request(request[:model]),
35
65
  response: response,
36
66
  latency_ms: latency_ms,
37
67
  stream: false,
38
- output_tokens: 0
68
+ output_tokens: 0,
69
+ service_line_items: gemini_video_line_items(provider, response)
39
70
  )
40
71
  end
41
72
 
42
- 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)
43
80
  model = response_model_id(response) || model_id_from_request(request[:model])
44
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")
45
89
  record_usage(
46
- provider: provider.slug.to_s,
90
+ provider: provider,
47
91
  model: model,
48
92
  response: response,
49
93
  latency_ms: latency_ms,
50
- stream: false,
51
- audio_input: match&.prices&.key?("audio_input")
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?)
52
98
  )
53
99
  end
54
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
+
55
107
  def record_image(provider, response, request:, latency_ms:)
56
108
  image = response.is_a?(Array) ? response.first : response
109
+ model = response_model_id(image) || model_id_from_request(request[:model])
57
110
  usage = image_usage(image)
58
- raw_input = usage[:input_tokens].to_i
59
- raw_output = usage[:output_tokens].to_i
60
- image_input = image_token_detail(usage, :input)
61
- image_output = image_token_detail(usage, :output)
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
+ )
62
120
  record_passthrough(
63
121
  provider: provider.slug.to_s,
64
- model: response_model_id(image) || model_id_from_request(request[:model]),
122
+ model: model,
65
123
  response: image,
66
124
  latency_ms: latency_ms,
67
- input_tokens: [raw_input - image_input, 0].max,
125
+ input_tokens: [usage[:input_tokens].to_i - image_input, 0].max,
68
126
  image_input_tokens: image_input,
69
- output_tokens: [raw_output - image_output, 0].max,
127
+ output_tokens: text_output,
70
128
  image_output_tokens: image_output
71
129
  )
72
130
  end
@@ -82,75 +140,61 @@ module LlmCostTracker
82
140
  )
83
141
  end
84
142
 
143
+ def record_cache_storage(cache)
144
+ return unless active? && cache.provider.to_s == "gemini"
145
+
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
150
+ end
151
+
85
152
  def image_usage(image)
86
153
  usage = image.try(:usage)
87
154
  usage = image.send(:raw_usage) if !usage.is_a?(Hash) && image.respond_to?(:raw_usage, true)
88
155
  (usage.is_a?(Hash) ? usage : {}).with_indifferent_access
89
156
  end
90
157
 
91
- def image_token_detail(usage, direction)
92
- container_key = direction == :input ? :input_tokens_details : :output_tokens_details
93
- details = usage[container_key]
94
- return 0 unless details.is_a?(Hash)
95
-
96
- details.with_indifferent_access[:image_tokens].to_i
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
97
161
  end
98
162
 
99
- def record_passthrough(provider:,
100
- model:,
101
- response:,
102
- latency_ms:,
103
- input_tokens:,
104
- output_tokens:,
105
- image_input_tokens: 0,
106
- image_output_tokens: 0)
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)
107
173
  return unless active?
108
174
 
109
175
  record_safely do
110
- LlmCostTracker::Tracker.record(
111
- event: Event.build(
112
- provider: provider,
113
- model: model,
114
- token_usage: Usage::TokenUsage.build(
115
- input_tokens: input_tokens,
116
- output_tokens: output_tokens,
117
- image_input_tokens: image_input_tokens,
118
- image_output_tokens: image_output_tokens
119
- ),
120
- usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
121
- provider_response_id: provider_response_id_for(response)
122
- ),
123
- latency_ms: latency_ms
124
- )
125
- end
126
- end
127
-
128
- def record_usage(provider:, model:, response:, latency_ms:, stream:, output_tokens: nil, audio_input: false)
129
- return unless active?
130
-
131
- record_safely do
132
- counts = token_counts(response)
133
- input_tokens = counts[:input]
176
+ counts = token_counts(response, provider.slug.to_s)
134
177
  output_tokens = counts[:output] if output_tokens.nil?
135
- next if input_tokens.nil? && output_tokens.nil?
178
+ next if counts[:input].nil? && output_tokens.nil? && service_line_items.empty? && usage_source.nil?
136
179
 
137
- cache_write_5m, cache_write_1h = cache_write_split(provider, response, counts[:cache_write])
180
+ cache_write_5m, cache_write_1h = cache_write_split(provider, response, counts[:cache_write], cache_ttl)
138
181
  LlmCostTracker::Tracker.record(
139
182
  event: Event.build(
140
- provider: provider,
183
+ provider: provider.slug.to_s,
141
184
  model: model,
142
- pricing_mode: pricing_mode_for(provider: provider, response: response),
143
- token_usage: Usage::TokenUsage.build(
144
- input_tokens: audio_input ? 0 : input_tokens.to_i,
145
- audio_input_tokens: audio_input ? input_tokens.to_i : 0,
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,
146
189
  output_tokens: output_tokens.to_i,
147
190
  cache_read_input_tokens: counts[:cache_read].to_i,
148
191
  cache_write_input_tokens: cache_write_5m,
149
192
  cache_write_extended_input_tokens: cache_write_1h,
150
193
  hidden_output_tokens: counts[:thinking].to_i
151
194
  ),
195
+ service_line_items: service_line_items + gemini_cache_read_line_items(provider, response),
152
196
  stream: stream,
153
- usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
197
+ usage_source: usage_source || LlmCostTracker::Usage::Source::SDK_RESPONSE,
154
198
  provider_response_id: provider_response_id_for(response)
155
199
  ),
156
200
  latency_ms: latency_ms
@@ -158,24 +202,66 @@ module LlmCostTracker
158
202
  end
159
203
  end
160
204
 
161
- def token_counts(response)
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
212
+
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
217
+
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)
162
232
  tokens = response.try(:tokens)
163
233
  return { input: response.try(:input_tokens), output: response.try(:output_tokens) } unless tokens
164
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
165
242
  {
166
- input: tokens.input,
167
- output: tokens.output,
243
+ input: input,
244
+ output: output,
168
245
  cache_read: tokens.cache_read,
169
246
  cache_write: tokens.cache_write,
170
247
  thinking: tokens.thinking
171
248
  }
172
249
  end
173
250
 
174
- def cache_write_split(provider, response, cache_write)
175
- cache = raw_body(response).dig("usage", "cache_creation") if provider == "anthropic"
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
176
258
  return [cache_write.to_i, 0] unless cache.is_a?(Hash)
177
259
 
178
- [cache["ephemeral_5m_input_tokens"].to_i, cache["ephemeral_1h_input_tokens"].to_i]
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]
179
265
  end
180
266
 
181
267
  def model_id_from_request(value)
@@ -186,29 +272,55 @@ module LlmCostTracker
186
272
  end
187
273
 
188
274
  def provider_response_id_for(response)
189
- body = raw_body(response)
275
+ body = gemini_body(response)
190
276
  body["id"] || body["responseId"]
191
277
  end
192
278
 
193
279
  def raw_body(response)
194
280
  raw = response.try(:raw)
195
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)
196
283
  body.is_a?(Hash) ? body : {}
197
284
  end
198
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
+
199
294
  def response_model_id(response)
200
295
  (response.try(:model_id) || response.try(:model))&.to_s
201
296
  end
202
297
 
203
- def pricing_mode_for(provider:, response:)
298
+ def pricing_mode_for(provider:, model:, response:)
204
299
  body = raw_body(response)
205
- case provider
206
- when "anthropic" then body.dig("usage", "service_tier")
207
- 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
+ )
208
312
  else body["service_tier"]
209
313
  end
210
314
  end
211
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
+
212
324
  def blocking_seam(resource, record_method, **extras)
213
325
  {
214
326
  provider: resource.slug.to_s,
@@ -230,8 +342,8 @@ module LlmCostTracker
230
342
  LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
231
343
  end
232
344
 
233
- def transcribe(*args, **kwargs)
234
- 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?)
235
347
  LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
236
348
  end
237
349
 
@@ -246,12 +358,65 @@ module LlmCostTracker
246
358
  end
247
359
  end
248
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
+
249
376
  module GeminiTranscriptionPatch
250
377
  def transcribe(*args, **kwargs)
251
378
  seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_transcription)
252
379
  LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
253
380
  end
254
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
255
420
  end
256
421
  end
257
422
  end
@@ -6,19 +6,13 @@ require_relative "errors"
6
6
 
7
7
  module LlmCostTracker
8
8
  module Integrations
9
- autoload :Base, "llm_cost_tracker/integrations/base"
10
-
11
9
  Dir.glob(File.join(__dir__, "integrations", "*.rb")).each do |path|
12
10
  basename = File.basename(path, ".rb")
13
- next if basename == "base"
14
-
15
11
  autoload basename.camelize.to_sym, "llm_cost_tracker/integrations/#{basename}"
16
12
  end
17
13
 
18
14
  def self.install!(names = LlmCostTracker.configuration.instrumented_integrations)
19
- normalized = normalize(names)
20
- warn_double_instrumentation(normalized)
21
- normalized.each do |name|
15
+ normalize(names).each do |name|
22
16
  integration = fetch(name)
23
17
  next integration.install if integration
24
18
 
@@ -41,19 +35,6 @@ module LlmCostTracker
41
35
  Array(names).flatten.uniq
42
36
  end
43
37
 
44
- def self.warn_double_instrumentation(names)
45
- return unless names.include?(:ruby_llm)
46
-
47
- overlapping = names - [:ruby_llm]
48
- return if overlapping.empty?
49
-
50
- Logging.warn(
51
- ":ruby_llm is enabled together with #{overlapping.map(&:inspect).join(', ')}. " \
52
- "RubyLLM uses HTTP underneath, so calls routed to those providers may be recorded twice " \
53
- "(once via the SDK patch, once via the Faraday parser). Pick one path per provider."
54
- )
55
- end
56
-
57
38
  def self.fetch(name)
58
39
  const_name = name.to_s.camelize
59
40
  return nil unless const_name.match?(/\A[A-Z]\w*\z/)
@@ -17,13 +17,6 @@ module LlmCostTracker
17
17
 
18
18
  raise TransactionAbortedError, e
19
19
  end
20
-
21
- def after_commit(&)
22
- current = LlmCostTracker::Call.connection.current_transaction
23
- return yield unless current.open? && current.joinable? && current.respond_to?(:after_commit)
24
-
25
- current.after_commit(&)
26
- end
27
20
  end
28
21
  end
29
22
  end
@@ -39,25 +39,29 @@ module LlmCostTracker
39
39
 
40
40
  def period_select(period)
41
41
  start = Period.range_start(period, time)
42
- components = ["(#{recorded_sql(period, start)})"]
42
+ components = ["(#{recorded_sql(start)})"]
43
43
  components << "(#{pending_sql(start)})" if Ingestion.async?
44
44
  "SELECT #{quote(period.to_s)} AS period_key, #{components.join(' + ')} AS total_cost"
45
45
  end
46
46
 
47
- def recorded_sql(period, start)
48
- calls = "COALESCE(#{sum_sql(LlmCostTracker::Call.between(start, time))}, 0)"
49
- return calls unless Rollups.cache_active?
47
+ def recorded_sql(start)
48
+ today = Period.range_start(:day, time)
49
+ return calls_sql(start) unless Rollups.cache_active? && start < today
50
50
 
51
- rollup = "COALESCE(#{sum_sql(rollup_scope(period))}, 0)"
52
- "GREATEST(#{rollup}, #{calls})"
51
+ "#{completed_days_sql(start, today)} + #{calls_sql(today)}"
53
52
  end
54
53
 
55
- def pending_sql(start)
56
- "COALESCE(#{sum_sql(Ingestion::InboxEntry.pending.where(tracked_at: start..time))}, 0)"
54
+ def calls_sql(start)
55
+ "COALESCE(#{sum_sql(LlmCostTracker::Call.between(start, time))}, 0)"
56
+ end
57
+
58
+ def completed_days_sql(start, today)
59
+ days = LlmCostTracker::CallRollup.where(period: "day", period_start: start.to_date...today.to_date)
60
+ "COALESCE(#{sum_sql(days)}, 0)"
57
61
  end
58
62
 
59
- def rollup_scope(period)
60
- LlmCostTracker::CallRollup.where(period: period.to_s, period_start: Period.bucket(period, time))
63
+ def pending_sql(start)
64
+ "COALESCE(#{sum_sql(Ingestion::InboxEntry.pending.where(tracked_at: start..time))}, 0)"
61
65
  end
62
66
 
63
67
  def sum_sql(scope)
@@ -8,10 +8,17 @@ require_relative "period"
8
8
  module LlmCostTracker
9
9
  module Ledger
10
10
  module Rollups
11
+ SOURCE_COLUMNS = %i[tracked_at total_cost pricing_snapshot provider].freeze
12
+
11
13
  class << self
12
14
  def cache_active?
13
15
  return false unless LlmCostTracker.configuration.budgets.totals_source == :cache
14
- return true if LlmCostTracker::CallRollup.table_exists?
16
+
17
+ rollup = LlmCostTracker::CallRollup
18
+ return true if rollup.table_exists?
19
+
20
+ rollup.connection.schema_cache.clear_data_source_cache!(rollup.table_name)
21
+ return true if rollup.table_exists?
15
22
 
16
23
  warn_missing_table
17
24
  false
@@ -20,10 +27,8 @@ module LlmCostTracker
20
27
  def increment!(events)
21
28
  return unless cache_active?
22
29
 
23
- events = Array(events).select(&:total_cost)
24
- return if events.empty?
25
-
26
- LlmCostTracker::CallRollup.increment_all(period_rows_for_events(events))
30
+ rows = rows_from_buckets(bucket_totals(Array(events)))
31
+ LlmCostTracker::CallRollup.increment_all(rows) if rows.any?
27
32
  end
28
33
 
29
34
  ROLLUP_INCREMENT_ATTEMPTS = 3
@@ -36,7 +41,7 @@ module LlmCostTracker
36
41
  def increment_safely!(events)
37
42
  return unless LlmCostTracker.configuration.budgets.totals_source == :cache
38
43
 
39
- Isolation.after_commit { increment_with_retries(events) }
44
+ LlmCostTracker::Call.current_transaction.after_commit { increment_with_retries(events) }
40
45
  end
41
46
 
42
47
  def increment_with_retries(events)
@@ -55,8 +60,8 @@ module LlmCostTracker
55
60
 
56
61
  LlmCostTracker::Logging.warn(
57
62
  "Rollup increment failed for #{events.size} events after #{attempt} attempt(s): " \
58
- "#{e.class}: #{e.message}. Budget reads fall back to the calls ledger; " \
59
- "run bin/rails llm_cost_tracker:rebuild_rollups to resync the cache."
63
+ "#{e.class}: #{e.message}. Monthly budget totals under-count these calls until you run " \
64
+ "bin/rails llm_cost_tracker:rebuild_rollups."
60
65
  )
61
66
  end
62
67
  end
@@ -107,12 +112,7 @@ module LlmCostTracker
107
112
  end
108
113
 
109
114
  def priced_calls
110
- LlmCostTracker::Call.where.not(total_cost: nil)
111
- .select(:id, :total_cost, :pricing_snapshot, :provider, :tracked_at)
112
- end
113
-
114
- def period_rows_for_events(events)
115
- rows_from_buckets(bucket_totals(events))
115
+ LlmCostTracker::Call.where.not(total_cost: nil).select(:id, *SOURCE_COLUMNS)
116
116
  end
117
117
 
118
118
  def rows_from_buckets(buckets)
@@ -8,8 +8,6 @@ module LlmCostTracker
8
8
  module Adapter
9
9
  MYSQL_ADAPTERS = %w[
10
10
  ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
11
- ActiveRecord::ConnectionAdapters::Mysql2Adapter
12
- ActiveRecord::ConnectionAdapters::TrilogyAdapter
13
11
  ].freeze
14
12
  POSTGRESQL_ADAPTERS = %w[
15
13
  ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
@@ -36,11 +34,16 @@ module LlmCostTracker
36
34
  MYSQL_PERIOD_FORMATS = { day: "%Y-%m-%d", month: "%Y-%m" }.freeze
37
35
  private_constant :PG_PERIOD_FORMATS, :MYSQL_PERIOD_FORMATS
38
36
 
39
- def period_bucket_sql(connection, period, column)
37
+ def period_bucket_sql(connection, period, column, time_zone: nil)
40
38
  period = period.to_sym
39
+ zone = time_zone&.tzinfo&.name
41
40
  if postgresql?(connection)
41
+ if zone && postgresql_zone?(connection, zone)
42
+ column = "(#{column}::timestamp AT TIME ZONE 'UTC') AT TIME ZONE '#{zone}'"
43
+ end
42
44
  "TO_CHAR(DATE_TRUNC('#{period}', #{column}), '#{PG_PERIOD_FORMATS.fetch(period)}')"
43
45
  elsif mysql?(connection)
46
+ column = "COALESCE(CONVERT_TZ(#{column}, '+00:00', '#{zone}'), #{column})" if zone
44
47
  "DATE_FORMAT(#{column}, '#{MYSQL_PERIOD_FORMATS.fetch(period)}')"
45
48
  else
46
49
  ensure_supported!(connection)
@@ -51,6 +54,14 @@ module LlmCostTracker
51
54
 
52
55
  private
53
56
 
57
+ def postgresql_zone?(connection, zone)
58
+ @postgresql_zones ||= {}
59
+ @postgresql_zones.fetch(zone) do
60
+ sql = "SELECT 1 FROM pg_timezone_names WHERE name = #{connection.quote(zone)}"
61
+ @postgresql_zones[zone] = !connection.select_value(sql).nil?
62
+ end
63
+ end
64
+
54
65
  def adapter_instance?(value, class_names)
55
66
  class_names.any? do |class_name|
56
67
  adapter_class = class_name.safe_constantize