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
@@ -1,420 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
3
+ require_relative "ruby_llm/v1"
4
+ require_relative "ruby_llm/v2"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Integrations
7
8
  module RubyLlm
8
- extend Base
9
-
10
- minimum_version "1.15.0"
11
- maximum_version "3.0.0"
12
-
13
9
  class << self
14
- def patch_targets
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
- ]
33
- end
34
-
35
- def record_completion(provider, response, request:, latency_ms:, has_block:)
36
- model = response_model_id(response) || model_id_from_request(request[:model])
37
- record_usage(
38
- provider: provider,
39
- model: model,
40
- response: response,
41
- latency_ms: latency_ms,
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)
45
- )
46
- end
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
-
61
- def record_embedding(provider, response, request:, latency_ms:)
62
- record_usage(
63
- provider: provider,
64
- model: response_model_id(response) || model_id_from_request(request[:model]),
65
- response: response,
66
- latency_ms: latency_ms,
67
- stream: false,
68
- output_tokens: 0,
69
- service_line_items: gemini_video_line_items(provider, response)
70
- )
71
- end
72
-
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")
89
- record_usage(
90
- provider: provider,
91
- model: model,
92
- response: response,
93
- latency_ms: latency_ms,
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?)
98
- )
99
- end
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
-
107
- def record_image(provider, response, request:, latency_ms:)
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
- )
120
- record_passthrough(
121
- provider: provider.slug.to_s,
122
- model: model,
123
- response: image,
124
- latency_ms: latency_ms,
125
- input_tokens: [usage[:input_tokens].to_i - image_input, 0].max,
126
- image_input_tokens: image_input,
127
- output_tokens: text_output,
128
- image_output_tokens: image_output
129
- )
130
- end
131
-
132
- def record_moderation(provider, response, request:, latency_ms:)
133
- record_passthrough(
134
- provider: provider.slug.to_s,
135
- model: response_model_id(response) || model_id_from_request(request[:model]),
136
- response: response,
137
- latency_ms: latency_ms,
138
- input_tokens: 0,
139
- output_tokens: 0
140
- )
141
- end
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
-
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
157
-
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
161
- end
162
-
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)
173
- return unless active?
174
-
175
- record_safely do
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?
179
-
180
- cache_write_5m, cache_write_1h = cache_write_split(provider, response, counts[:cache_write], cache_ttl)
181
- LlmCostTracker::Tracker.record(
182
- event: Event.build(
183
- provider: provider.slug.to_s,
184
- model: model,
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,
189
- output_tokens: output_tokens.to_i,
190
- cache_read_input_tokens: counts[:cache_read].to_i,
191
- cache_write_input_tokens: cache_write_5m,
192
- cache_write_extended_input_tokens: cache_write_1h,
193
- hidden_output_tokens: counts[:thinking].to_i
194
- ),
195
- service_line_items: service_line_items + gemini_cache_read_line_items(provider, response),
196
- stream: stream,
197
- usage_source: usage_source || LlmCostTracker::Usage::Source::SDK_RESPONSE,
198
- provider_response_id: provider_response_id_for(response)
199
- ),
200
- latency_ms: latency_ms
201
- )
202
- end
203
- end
204
-
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)
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]
265
- end
266
-
267
- def model_id_from_request(value)
268
- return nil if value.nil?
269
- return value.to_s if value.is_a?(String) || value.is_a?(Symbol)
10
+ def install = implementation.install
270
11
 
271
- (value.try(:id) || value.try(:model_id) || value.try(:model))&.to_s
272
- end
273
-
274
- def provider_response_id_for(response)
275
- body = gemini_body(response)
276
- body["id"] || body["responseId"]
277
- end
278
-
279
- def raw_body(response)
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)
283
- body.is_a?(Hash) ? body : {}
284
- end
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
-
294
- def response_model_id(response)
295
- (response.try(:model_id) || response.try(:model))&.to_s
296
- end
297
-
298
- def pricing_mode_for(provider:, model:, response:)
299
- body = raw_body(response)
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
- )
312
- else body["service_tier"]
313
- end
314
- end
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
-
324
- def blocking_seam(resource, record_method, **extras)
325
- {
326
- provider: resource.slug.to_s,
327
- record: lambda do |response, request, latency_ms|
328
- public_send(record_method, resource, response, request: request, latency_ms: latency_ms, **extras)
329
- end
330
- }
331
- end
332
- end
333
-
334
- module ProviderPatch
335
- def complete(*args, **kwargs, &)
336
- seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_completion, has_block: block_given?)
337
- LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
338
- end
339
-
340
- def embed(*args, **kwargs)
341
- seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_embedding)
342
- LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
343
- end
344
-
345
- def transcribe(*args, **kwargs, &)
346
- seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_transcription, stream: block_given?)
347
- LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
348
- end
349
-
350
- def paint(*args, **kwargs)
351
- seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_image)
352
- LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
353
- end
354
-
355
- def moderate(*args, **kwargs)
356
- seam = LlmCostTracker::Integrations::RubyLlm.blocking_seam(self, :record_moderation)
357
- LlmCostTracker::Integrations::RubyLlm.wrap_blocking(args, kwargs, **seam) { super }
358
- end
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
12
+ def status = implementation.status
409
13
 
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
14
+ def implementation
15
+ version = V2.gem_version
16
+ version && version >= Gem::Version.new("2.0.0") ? V2 : V1
418
17
  end
419
18
  end
420
19
  end
@@ -6,8 +6,21 @@ module LlmCostTracker
6
6
  module Ledger
7
7
  module Isolation
8
8
  class << self
9
- def guard(model = LlmCostTracker::Call, &)
10
- connection = model.connection
9
+ def guard(model = LlmCostTracker::Call, &block)
10
+ leased(model) { |connection| isolate(connection, model, &block) }
11
+ end
12
+
13
+ def leased(model = LlmCostTracker::Call, &block)
14
+ writing { model.with_connection(prevent_permanent_checkout: true, &block) }
15
+ end
16
+
17
+ def writing(&)
18
+ ActiveRecord::Base.connected_to(role: ActiveRecord.writing_role, prevent_writes: false, &)
19
+ end
20
+
21
+ private
22
+
23
+ def isolate(connection, model, &)
11
24
  nested = connection.transaction_open?
12
25
  return yield unless nested
13
26
 
@@ -69,7 +69,7 @@ module LlmCostTracker
69
69
  end
70
70
 
71
71
  def quote(value)
72
- LlmCostTracker::Call.connection.quote(value)
72
+ LlmCostTracker::Call.lease_connection.quote(value)
73
73
  end
74
74
  end
75
75
  end
@@ -4,11 +4,13 @@ require "bigdecimal/util"
4
4
 
5
5
  require_relative "isolation"
6
6
  require_relative "period"
7
+ require_relative "schema/adapter"
7
8
 
8
9
  module LlmCostTracker
9
10
  module Ledger
10
11
  module Rollups
11
12
  SOURCE_COLUMNS = %i[tracked_at total_cost pricing_snapshot provider].freeze
13
+ BUCKET = %i[period period_start currency provider].freeze
12
14
 
13
15
  class << self
14
16
  def cache_active?
@@ -17,7 +19,7 @@ module LlmCostTracker
17
19
  rollup = LlmCostTracker::CallRollup
18
20
  return true if rollup.table_exists?
19
21
 
20
- rollup.connection.schema_cache.clear_data_source_cache!(rollup.table_name)
22
+ rollup.with_connection { |connection| connection.schema_cache.clear_data_source_cache!(rollup.table_name) }
21
23
  return true if rollup.table_exists?
22
24
 
23
25
  warn_missing_table
@@ -25,10 +27,7 @@ module LlmCostTracker
25
27
  end
26
28
 
27
29
  def increment!(events)
28
- return unless cache_active?
29
-
30
- rows = rows_from_buckets(bucket_totals(Array(events)))
31
- LlmCostTracker::CallRollup.increment_all(rows) if rows.any?
30
+ write(rollup_rows(events)) if cache_active?
32
31
  end
33
32
 
34
33
  ROLLUP_INCREMENT_ATTEMPTS = 3
@@ -41,15 +40,18 @@ module LlmCostTracker
41
40
  def increment_safely!(events)
42
41
  return unless LlmCostTracker.configuration.budgets.totals_source == :cache
43
42
 
44
- LlmCostTracker::Call.current_transaction.after_commit { increment_with_retries(events) }
43
+ Isolation.leased do
44
+ LlmCostTracker::Call.current_transaction.after_commit { increment_with_retries(events) }
45
+ end
45
46
  end
46
47
 
47
48
  def increment_with_retries(events)
48
- retryable = !LlmCostTracker::Call.connection.transaction_open?
49
+ retryable = LlmCostTracker::Call.with_connection { |connection| !connection.transaction_open? }
50
+ rows = nil
49
51
  attempt = 0
50
52
  begin
51
53
  attempt += 1
52
- Isolation.guard { increment!(events) }
54
+ Isolation.guard { write(rows ||= cache_active? ? rollup_rows(events) : []) }
53
55
  rescue LlmCostTracker::Error
54
56
  raise
55
57
  rescue StandardError => e
@@ -66,6 +68,20 @@ module LlmCostTracker
66
68
  end
67
69
  end
68
70
 
71
+ def rollup_rows(events)
72
+ rows_from_buckets(bucket_totals(Array(events))).sort_by { |row| row.values_at(*BUCKET).map(&:to_s) }
73
+ end
74
+
75
+ def write(rows)
76
+ return if rows.empty?
77
+
78
+ size = Schema::Adapter.mysql?(LlmCostTracker::CallRollup.lease_connection) ? 1 : rows.size
79
+ until rows.empty?
80
+ LlmCostTracker::CallRollup.increment_all(rows.first(size))
81
+ rows.shift(size)
82
+ end
83
+ end
84
+
69
85
  def rolled_back?(error)
70
86
  error.is_a?(ActiveRecord::Deadlocked) || error.is_a?(ActiveRecord::LockWaitTimeout)
71
87
  end
@@ -30,6 +30,15 @@ module LlmCostTracker
30
30
  raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
31
31
  end
32
32
 
33
+ def drop_invalid_index(connection, name)
34
+ return unless postgresql?(connection)
35
+
36
+ invalid = connection.select_value(
37
+ "SELECT 1 FROM pg_index WHERE indexrelid = to_regclass(#{connection.quote(name.to_s)}) AND NOT indisvalid"
38
+ )
39
+ connection.execute("DROP INDEX CONCURRENTLY IF EXISTS #{connection.quote_table_name(name)}") if invalid
40
+ end
41
+
33
42
  PG_PERIOD_FORMATS = { day: "YYYY-MM-DD", month: "YYYY-MM" }.freeze
34
43
  MYSQL_PERIOD_FORMATS = { day: "%Y-%m-%d", month: "%Y-%m" }.freeze
35
44
  private_constant :PG_PERIOD_FORMATS, :MYSQL_PERIOD_FORMATS
@@ -38,13 +47,9 @@ module LlmCostTracker
38
47
  period = period.to_sym
39
48
  zone = time_zone&.tzinfo&.name
40
49
  if postgresql?(connection)
41
- if zone && postgresql_zone?(connection, zone)
42
- column = "(#{column}::timestamp AT TIME ZONE 'UTC') AT TIME ZONE '#{zone}'"
43
- end
44
- "TO_CHAR(DATE_TRUNC('#{period}', #{column}), '#{PG_PERIOD_FORMATS.fetch(period)}')"
50
+ postgresql_period_sql(connection, period, column, zone)
45
51
  elsif mysql?(connection)
46
- column = "COALESCE(CONVERT_TZ(#{column}, '+00:00', '#{zone}'), #{column})" if zone
47
- "DATE_FORMAT(#{column}, '#{MYSQL_PERIOD_FORMATS.fetch(period)}')"
52
+ mysql_period_sql(period, column, zone)
48
53
  else
49
54
  ensure_supported!(connection)
50
55
  end
@@ -54,6 +59,18 @@ module LlmCostTracker
54
59
 
55
60
  private
56
61
 
62
+ def postgresql_period_sql(connection, period, column, zone)
63
+ if zone && postgresql_zone?(connection, zone)
64
+ column = "(#{column}::timestamp AT TIME ZONE 'UTC') AT TIME ZONE '#{zone}'"
65
+ end
66
+ "TO_CHAR(DATE_TRUNC('#{period}', #{column}), '#{PG_PERIOD_FORMATS.fetch(period)}')"
67
+ end
68
+
69
+ def mysql_period_sql(period, column, zone)
70
+ column = "COALESCE(CONVERT_TZ(#{column}, '+00:00', '#{zone}'), #{column})" if zone
71
+ "DATE_FORMAT(#{column}, '#{MYSQL_PERIOD_FORMATS.fetch(period)}')"
72
+ end
73
+
57
74
  def postgresql_zone?(connection, zone)
58
75
  @postgresql_zones ||= {}
59
76
  @postgresql_zones.fetch(zone) do
@@ -21,11 +21,8 @@ module LlmCostTracker
21
21
  end
22
22
 
23
23
  def current_schema_errors
24
- Adapter.ensure_supported!(model.connection)
25
- unless model.table_exists?
26
- model.connection.schema_cache.clear_data_source_cache!(model.table_name)
27
- return ["#{model.table_name} table is missing"] unless model.table_exists?
28
- end
24
+ model.with_connection { |connection| Adapter.ensure_supported!(connection) }
25
+ return ["#{model.table_name} table is missing"] unless table_exists?
29
26
 
30
27
  columns_hash = model.columns_hash
31
28
  cache = @schema_capabilities
@@ -38,6 +35,13 @@ module LlmCostTracker
38
35
 
39
36
  private
40
37
 
38
+ def table_exists?
39
+ return true if model.table_exists?
40
+
41
+ model.with_connection { |connection| connection.schema_cache.clear_data_source_cache!(model.table_name) }
42
+ model.table_exists?
43
+ end
44
+
41
45
  def column_errors(columns_hash)
42
46
  missing = required_columns - columns_hash.keys
43
47
  missing.any? ? ["missing columns: #{missing.join(', ')}"] : []