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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +16 -5
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +4 -25
- data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
- data/app/models/llm_cost_tracker/call.rb +10 -14
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -9
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
- data/app/views/llm_cost_tracker/tags/show.html.erb +5 -5
- data/lib/llm_cost_tracker/budget/per_tag.rb +15 -9
- data/lib/llm_cost_tracker/budget.rb +44 -55
- data/lib/llm_cost_tracker/capture/event_window.rb +3 -2
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +25 -26
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +9 -23
- data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
- data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
- data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
- data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/price_check.rb +13 -3
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +19 -2
- data/lib/llm_cost_tracker/engine.rb +0 -1
- data/lib/llm_cost_tracker/event.rb +8 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +11 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
- data/lib/llm_cost_tracker/ingestion.rb +3 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +63 -10
- data/lib/llm_cost_tracker/integrations/base.rb +39 -18
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +241 -76
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +0 -7
- data/lib/llm_cost_tracker/ledger/period/totals.rb +14 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +14 -14
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
- data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
- data/lib/llm_cost_tracker/ledger/store.rb +2 -2
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/middleware/faraday.rb +36 -19
- data/lib/llm_cost_tracker/parsers.rb +7 -40
- data/lib/llm_cost_tracker/prices.json +2237 -154
- data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
- data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
- data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +5 -16
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync.rb +22 -71
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -7
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +172 -48
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +96 -26
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -1
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +3 -1
- data/lib/llm_cost_tracker/report/data.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tracker.rb +17 -9
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/tasks/llm_cost_tracker.rake +24 -21
- data/llm_cost_tracker.gemspec +63 -0
- 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
|
|
24
|
-
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
|
|
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
|
|
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
|
|
90
|
+
provider: provider,
|
|
47
91
|
model: model,
|
|
48
92
|
response: response,
|
|
49
93
|
latency_ms: latency_ms,
|
|
50
|
-
stream:
|
|
51
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
122
|
+
model: model,
|
|
65
123
|
response: image,
|
|
66
124
|
latency_ms: latency_ms,
|
|
67
|
-
input_tokens: [
|
|
125
|
+
input_tokens: [usage[:input_tokens].to_i - image_input, 0].max,
|
|
68
126
|
image_input_tokens: image_input,
|
|
69
|
-
output_tokens:
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
145
|
-
audio_input_tokens:
|
|
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
|
|
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:
|
|
167
|
-
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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"
|
|
207
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
48
|
-
|
|
49
|
-
return
|
|
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
|
-
|
|
52
|
-
"GREATEST(#{rollup}, #{calls})"
|
|
51
|
+
"#{completed_days_sql(start, today)} + #{calls_sql(today)}"
|
|
53
52
|
end
|
|
54
53
|
|
|
55
|
-
def
|
|
56
|
-
"COALESCE(#{sum_sql(
|
|
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
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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}.
|
|
59
|
-
"
|
|
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
|