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