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
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
3
6
|
module LlmCostTracker
|
|
4
7
|
module Providers
|
|
5
8
|
module Gemini
|
|
6
9
|
class Parser < LlmCostTracker::Parsers::Base
|
|
7
10
|
HOSTS = %w[generativelanguage.googleapis.com].freeze
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
INTERACTIONS_PATH_PATTERN = %r{/interactions(?:/[^/]+)?\z}
|
|
12
|
+
CACHE_PATH_PATTERN = %r{/cachedContents\z}
|
|
13
|
+
TRACKED_PATH_PATTERN = Regexp.union(
|
|
14
|
+
%r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}, INTERACTIONS_PATH_PATTERN, CACHE_PATH_PATTERN
|
|
15
|
+
)
|
|
16
|
+
STREAM_PATH_PATTERN = /:streamGenerateContent\z/
|
|
17
|
+
GROUNDING_FIELDS = {
|
|
18
|
+
"grounding_request" => "response.candidates.groundingMetadata.webSearchQueries",
|
|
19
|
+
"maps_grounding_request" => "response.candidates.groundingMetadata.groundingChunks.maps"
|
|
20
|
+
}.freeze
|
|
21
|
+
INTERACTION_GROUNDING_KINDS = {
|
|
22
|
+
"google_search" => "grounding_request",
|
|
23
|
+
"google_maps" => "maps_grounding_request"
|
|
24
|
+
}.freeze
|
|
10
25
|
|
|
11
26
|
class << self
|
|
12
27
|
def match?(url)
|
|
13
|
-
|
|
28
|
+
uri_matches?(url) do |uri|
|
|
29
|
+
HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.match?(TRACKED_PATH_PATTERN)
|
|
30
|
+
end
|
|
14
31
|
end
|
|
15
32
|
|
|
16
33
|
def provider_names
|
|
@@ -19,7 +36,7 @@ module LlmCostTracker
|
|
|
19
36
|
end
|
|
20
37
|
|
|
21
38
|
def streaming_request?(request_url, request_parsed)
|
|
22
|
-
return true if
|
|
39
|
+
return true if path_matches?(request_url, STREAM_PATH_PATTERN)
|
|
23
40
|
|
|
24
41
|
super
|
|
25
42
|
end
|
|
@@ -28,34 +45,54 @@ module LlmCostTracker
|
|
|
28
45
|
return nil unless response_status == 200
|
|
29
46
|
|
|
30
47
|
response = safe_json_parse(response_body)
|
|
31
|
-
|
|
48
|
+
request = safe_json_parse(request_body)
|
|
49
|
+
if path_matches?(request_url, INTERACTIONS_PATH_PATTERN)
|
|
50
|
+
event = interaction_event(response, request: request, response_headers: response_headers)
|
|
51
|
+
return nil if event && path_matches?(request_url, %r{/interactions/[^/]+\z}) &&
|
|
52
|
+
Call.already_recorded?(provider: "gemini", provider_response_id: event.provider_response_id)
|
|
53
|
+
|
|
54
|
+
return event
|
|
55
|
+
end
|
|
56
|
+
return cache_storage_event(response) if path_matches?(request_url, CACHE_PATH_PATTERN)
|
|
57
|
+
|
|
58
|
+
usage = response["usageMetadata"]
|
|
32
59
|
return nil unless usage
|
|
33
60
|
|
|
34
|
-
|
|
35
|
-
model = extract_model_from_url(request_url)
|
|
61
|
+
model = response["modelVersion"].presence || extract_model_from_url(request_url)
|
|
36
62
|
build_event(
|
|
37
|
-
|
|
63
|
+
model: model,
|
|
38
64
|
usage: usage,
|
|
39
65
|
usage_source: Usage::Source::RESPONSE,
|
|
40
66
|
provider_response_id: response["responseId"],
|
|
41
67
|
pricing_mode: pricing_mode(request: request, usage: usage, response_headers: response_headers),
|
|
42
|
-
service_line_items:
|
|
68
|
+
service_line_items: service_line_items_for(response, model: model)
|
|
43
69
|
)
|
|
44
70
|
end
|
|
45
71
|
|
|
46
|
-
def parse_stream(response_status:,
|
|
72
|
+
def parse_stream(response_status:,
|
|
73
|
+
request_url: nil,
|
|
74
|
+
request_body: nil,
|
|
75
|
+
events: [],
|
|
76
|
+
response_headers: nil,
|
|
77
|
+
model: nil)
|
|
47
78
|
return nil unless response_status == 200
|
|
48
79
|
|
|
49
80
|
request = safe_json_parse(request_body)
|
|
81
|
+
interaction = find_event_value(events, reverse: true) { |data| completed_interaction(data) }
|
|
82
|
+
if interaction
|
|
83
|
+
return interaction_event(interaction, request: request, response_headers: response_headers, stream: true)
|
|
84
|
+
end
|
|
85
|
+
|
|
50
86
|
usage = merged_stream_usage(events)
|
|
51
|
-
model =
|
|
52
|
-
|
|
87
|
+
model = find_event_value(events, reverse: true) { |data| data["modelVersion"] } ||
|
|
88
|
+
extract_model_from_url(request_url) || model || request["model"]
|
|
89
|
+
response_id = find_event_value(events) { |data| data["responseId"] }
|
|
53
90
|
mode = pricing_mode(request: request, usage: usage, response_headers: response_headers)
|
|
54
91
|
service_line_items = grounding_line_items_for_stream(events, model: model)
|
|
55
92
|
|
|
56
93
|
if usage
|
|
57
94
|
build_event(
|
|
58
|
-
|
|
95
|
+
model: model,
|
|
59
96
|
usage: usage,
|
|
60
97
|
stream: true,
|
|
61
98
|
usage_source: Usage::Source::STREAM_FINAL,
|
|
@@ -74,35 +111,96 @@ module LlmCostTracker
|
|
|
74
111
|
end
|
|
75
112
|
end
|
|
76
113
|
|
|
77
|
-
def model_for(request_url,
|
|
78
|
-
extract_model_from_url(request_url)
|
|
114
|
+
def model_for(request_url, request_parsed)
|
|
115
|
+
extract_model_from_url(request_url) ||
|
|
116
|
+
(request_parsed["model"] if path_matches?(request_url, INTERACTIONS_PATH_PATTERN))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def retain_stream_event?(data)
|
|
120
|
+
data.is_a?(Hash) && grounding_counts(data["candidates"]).values.any?(&:positive?)
|
|
79
121
|
end
|
|
80
122
|
|
|
81
123
|
def provider_for(_request_url)
|
|
82
124
|
"gemini"
|
|
83
125
|
end
|
|
84
126
|
|
|
127
|
+
def service_line_items_for(response, model:)
|
|
128
|
+
usage = response["usage"]
|
|
129
|
+
return grounding_line_items(grounding_counts(response["candidates"]), model: model) unless usage.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
counts = Array(usage["grounding_tool_count"]).each_with_object(Hash.new(0)) do |entry, acc|
|
|
132
|
+
kind = INTERACTION_GROUNDING_KINDS[entry["type"]]
|
|
133
|
+
acc[kind] += entry["count"].to_i if kind
|
|
134
|
+
end
|
|
135
|
+
grounding_line_items(counts, model: model, provider_field: "response.usage.grounding_tool_count")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def interaction_usage_metadata(usage, service_tier)
|
|
139
|
+
{
|
|
140
|
+
"promptTokenCount" => usage["total_input_tokens"],
|
|
141
|
+
"cachedContentTokenCount" => usage["total_cached_tokens"],
|
|
142
|
+
"toolUsePromptTokenCount" => usage["total_tool_use_tokens"],
|
|
143
|
+
"candidatesTokenCount" => usage["total_output_tokens"],
|
|
144
|
+
"thoughtsTokenCount" => usage["total_thought_tokens"],
|
|
145
|
+
"totalTokenCount" => usage["total_tokens"],
|
|
146
|
+
"promptTokensDetails" => modality_details(usage["input_tokens_by_modality"]),
|
|
147
|
+
"cacheTokensDetails" => modality_details(usage["cached_tokens_by_modality"]),
|
|
148
|
+
"candidatesTokensDetails" => modality_details(usage["output_tokens_by_modality"]),
|
|
149
|
+
"serviceTier" => service_tier
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def cache_storage_event(response)
|
|
154
|
+
tokens = response.dig("usageMetadata", "totalTokenCount")
|
|
155
|
+
from = response["createTime"]
|
|
156
|
+
to = response["expireTime"]
|
|
157
|
+
return nil unless tokens && from && to
|
|
158
|
+
|
|
159
|
+
seconds = Time.iso8601(to) - Time.iso8601(from)
|
|
160
|
+
Event.build(
|
|
161
|
+
provider: "gemini",
|
|
162
|
+
model: response["model"].to_s.delete_prefix("models/"),
|
|
163
|
+
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
164
|
+
usage_source: Usage::Source::RESPONSE,
|
|
165
|
+
provider_response_id: response["name"],
|
|
166
|
+
service_line_items: [
|
|
167
|
+
Charges::LineItem.build(
|
|
168
|
+
dimension_key: "cache_storage_token_hour",
|
|
169
|
+
quantity: BigDecimal(tokens.to_s) * BigDecimal(seconds.to_s) / 3600,
|
|
170
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
171
|
+
pricing_basis: "provider_usage",
|
|
172
|
+
provider_field: "response.usageMetadata.totalTokenCount",
|
|
173
|
+
details: { cached_tokens: tokens, expire_time: to }
|
|
174
|
+
)
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
85
179
|
private
|
|
86
180
|
|
|
87
|
-
def build_event(
|
|
181
|
+
def build_event(model:,
|
|
88
182
|
usage:,
|
|
89
183
|
usage_source:,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
184
|
+
provider_response_id:,
|
|
185
|
+
pricing_mode:,
|
|
186
|
+
service_line_items:,
|
|
187
|
+
stream: false)
|
|
94
188
|
Event.build(
|
|
95
189
|
provider: "gemini",
|
|
96
|
-
model:
|
|
190
|
+
model: model,
|
|
97
191
|
pricing_mode: pricing_mode,
|
|
98
192
|
token_usage: UsageExtractor.token_usage(usage),
|
|
99
193
|
stream: stream,
|
|
100
194
|
usage_source: usage_source,
|
|
101
195
|
provider_response_id: provider_response_id,
|
|
102
|
-
service_line_items: service_line_items
|
|
196
|
+
service_line_items: service_line_items + UsageExtractor.cache_read_line_items(usage)
|
|
103
197
|
)
|
|
104
198
|
end
|
|
105
199
|
|
|
200
|
+
def path_matches?(url, pattern)
|
|
201
|
+
uri_matches?(url) { |uri| uri.path.to_s.match?(pattern) }
|
|
202
|
+
end
|
|
203
|
+
|
|
106
204
|
def merged_stream_usage(events)
|
|
107
205
|
find_event_value(events, reverse: true) do |data|
|
|
108
206
|
meta = data["usageMetadata"]
|
|
@@ -110,10 +208,6 @@ module LlmCostTracker
|
|
|
110
208
|
end
|
|
111
209
|
end
|
|
112
210
|
|
|
113
|
-
def stream_response_id(events)
|
|
114
|
-
find_event_value(events) { |data| data["responseId"] }
|
|
115
|
-
end
|
|
116
|
-
|
|
117
211
|
def extract_model_from_url(url)
|
|
118
212
|
uri = parsed_uri(url)
|
|
119
213
|
return nil unless uri
|
|
@@ -137,39 +231,73 @@ module LlmCostTracker
|
|
|
137
231
|
headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
|
|
138
232
|
end
|
|
139
233
|
|
|
234
|
+
def completed_interaction(data)
|
|
235
|
+
interaction = data["interaction"]
|
|
236
|
+
interaction if interaction.is_a?(Hash) && interaction["usage"].is_a?(Hash)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def interaction_event(interaction, request:, response_headers:, stream: false)
|
|
240
|
+
usage = interaction["usage"]
|
|
241
|
+
return nil unless usage.is_a?(Hash) && !%w[queued in_progress].include?(interaction["status"])
|
|
242
|
+
|
|
243
|
+
model = interaction["model"] || request["model"]
|
|
244
|
+
metadata = interaction_usage_metadata(usage, interaction["service_tier"])
|
|
245
|
+
build_event(
|
|
246
|
+
model: model,
|
|
247
|
+
usage: metadata,
|
|
248
|
+
stream: stream,
|
|
249
|
+
usage_source: stream ? Usage::Source::STREAM_FINAL : Usage::Source::RESPONSE,
|
|
250
|
+
provider_response_id: interaction["id"],
|
|
251
|
+
pricing_mode: pricing_mode(request: request, usage: metadata, response_headers: response_headers),
|
|
252
|
+
service_line_items: service_line_items_for(interaction, model: model)
|
|
253
|
+
).keyed_by_response_id
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def modality_details(entries)
|
|
257
|
+
Array(entries).map do |entry|
|
|
258
|
+
{ "modality" => entry["modality"].to_s.upcase, "tokenCount" => entry["tokens"] }
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
140
262
|
def grounding_line_items_for_stream(events, model:)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
263
|
+
counts = find_event_value(events, reverse: true) do |data|
|
|
264
|
+
candidate_counts = grounding_counts(data["candidates"])
|
|
265
|
+
candidate_counts if candidate_counts.values.any?(&:positive?)
|
|
144
266
|
end
|
|
145
|
-
grounding_line_items(
|
|
267
|
+
grounding_line_items(counts || {}, model: model)
|
|
146
268
|
end
|
|
147
269
|
|
|
148
|
-
def
|
|
149
|
-
Array(candidates).
|
|
150
|
-
|
|
151
|
-
|
|
270
|
+
def grounding_counts(candidates)
|
|
271
|
+
Array(candidates).each_with_object(Hash.new(0)) do |candidate, counts|
|
|
272
|
+
meta = candidate["groundingMetadata"]
|
|
273
|
+
next unless meta.is_a?(Hash)
|
|
274
|
+
|
|
275
|
+
queries = unique_query_count(meta["webSearchQueries"])
|
|
276
|
+
if Array(meta["groundingChunks"]).any? { |chunk| chunk.is_a?(Hash) && chunk.key?("maps") }
|
|
277
|
+
counts["maps_grounding_request"] += [queries, 1].max
|
|
278
|
+
else
|
|
279
|
+
counts["grounding_request"] += queries + unique_query_count(meta["imageSearchQueries"])
|
|
280
|
+
end
|
|
152
281
|
end
|
|
153
282
|
end
|
|
154
283
|
|
|
155
|
-
def
|
|
156
|
-
|
|
284
|
+
def unique_query_count(queries)
|
|
285
|
+
Array(queries).map { |query| query.to_s.strip }.reject(&:empty?).uniq.size
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def grounding_line_items(counts, model:, provider_field: nil)
|
|
289
|
+
counts.filter_map do |kind, count|
|
|
290
|
+
next unless count.positive?
|
|
157
291
|
|
|
158
|
-
billed_quantity = grounding_billed_quantity(query_count, model: model)
|
|
159
|
-
[
|
|
160
292
|
Charges::LineItem.build(
|
|
161
|
-
dimension_key:
|
|
162
|
-
quantity:
|
|
293
|
+
dimension_key: kind,
|
|
294
|
+
quantity: ModelFamilies.per_query_grounding?(model) ? count : 1,
|
|
163
295
|
cost_status: Charges::CostStatus::UNKNOWN,
|
|
164
296
|
pricing_basis: "provider_usage",
|
|
165
|
-
provider_field:
|
|
166
|
-
details: { web_search_queries:
|
|
297
|
+
provider_field: provider_field || GROUNDING_FIELDS.fetch(kind),
|
|
298
|
+
details: { web_search_queries: count }
|
|
167
299
|
)
|
|
168
|
-
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def grounding_billed_quantity(query_count, model:)
|
|
172
|
-
ModelFamilies.per_query_grounding?(model) ? query_count : 1
|
|
300
|
+
end
|
|
173
301
|
end
|
|
174
302
|
end
|
|
175
303
|
end
|
|
@@ -7,10 +7,10 @@ module LlmCostTracker
|
|
|
7
7
|
def self.token_usage(usage)
|
|
8
8
|
cache_read = usage["cachedContentTokenCount"].to_i
|
|
9
9
|
tool_use_prompt = usage["toolUsePromptTokenCount"].to_i
|
|
10
|
-
audio_input =
|
|
11
|
-
audio_output =
|
|
12
|
-
image_input =
|
|
13
|
-
image_output =
|
|
10
|
+
audio_input = uncached_prompt_tokens(usage, "AUDIO")
|
|
11
|
+
audio_output = modality_tokens(usage["candidatesTokensDetails"], "AUDIO")
|
|
12
|
+
image_input = uncached_prompt_tokens(usage, "IMAGE") + uncached_prompt_tokens(usage, "DOCUMENT")
|
|
13
|
+
image_output = modality_tokens(usage["candidatesTokensDetails"], "IMAGE")
|
|
14
14
|
|
|
15
15
|
Usage::TokenUsage.build(
|
|
16
16
|
input_tokens: regular_input_tokens(usage: usage,
|
|
@@ -31,6 +31,11 @@ module LlmCostTracker
|
|
|
31
31
|
)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
def self.cache_read_line_items(usage)
|
|
35
|
+
audio = modality_tokens(usage["cacheTokensDetails"], "AUDIO")
|
|
36
|
+
audio.positive? ? [Charges::LineItem.build(dimension_key: "audio_cache_read_input", quantity: audio)] : []
|
|
37
|
+
end
|
|
38
|
+
|
|
34
39
|
def self.gross_output_tokens(usage)
|
|
35
40
|
usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
|
|
36
41
|
end
|
|
@@ -43,24 +48,10 @@ module LlmCostTracker
|
|
|
43
48
|
[gross_output_tokens(usage) - audio_output - image_output, 0].max
|
|
44
49
|
end
|
|
45
50
|
|
|
46
|
-
def self.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
[
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def self.audio_output_tokens(usage)
|
|
53
|
-
modality_tokens(usage["candidatesTokensDetails"], "AUDIO")
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def self.image_input_tokens(usage)
|
|
57
|
-
prompt_image = modality_tokens(usage["promptTokensDetails"], "IMAGE")
|
|
58
|
-
cache_image = modality_tokens(usage["cacheTokensDetails"], "IMAGE")
|
|
59
|
-
[prompt_image - cache_image, 0].max
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def self.image_output_tokens(usage)
|
|
63
|
-
modality_tokens(usage["candidatesTokensDetails"], "IMAGE")
|
|
51
|
+
def self.uncached_prompt_tokens(usage, modality)
|
|
52
|
+
prompt = modality_tokens(usage["promptTokensDetails"], modality)
|
|
53
|
+
cached = modality_tokens(usage["cacheTokensDetails"], modality)
|
|
54
|
+
[prompt - cached, 0].max
|
|
64
55
|
end
|
|
65
56
|
|
|
66
57
|
def self.modality_tokens(details, modality)
|
|
@@ -18,7 +18,7 @@ module LlmCostTracker
|
|
|
18
18
|
ae.api.openai.com
|
|
19
19
|
].freeze
|
|
20
20
|
|
|
21
|
-
DATA_RESIDENCY_HOST_PATTERN = /\A[a-z]{2,3}\.api\.openai\.com\z/
|
|
21
|
+
DATA_RESIDENCY_HOST_PATTERN = /\A(?:[a-z]{2,3}\.api\.openai\.com|us\.api\.x\.ai|api\.(?:eu|us)\.mistral\.ai)\z/
|
|
22
22
|
|
|
23
23
|
def self.data_residency?(host)
|
|
24
24
|
host.to_s.downcase.match?(DATA_RESIDENCY_HOST_PATTERN)
|
|
@@ -6,23 +6,21 @@ module LlmCostTracker
|
|
|
6
6
|
class Parser < LlmCostTracker::Parsers::Base
|
|
7
7
|
include ResponseParser
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
/
|
|
12
|
-
/
|
|
13
|
-
/v1/responses
|
|
14
|
-
/v1/images/generations
|
|
15
|
-
/v1/images/edits
|
|
16
|
-
/v1/images/variations
|
|
17
|
-
/v1/audio/transcriptions
|
|
18
|
-
/v1/audio/translations
|
|
19
|
-
/v1/audio/speech
|
|
20
|
-
/v1/moderations
|
|
9
|
+
TRACKED_ENDPOINTS = %w[
|
|
10
|
+
chat/completions completions embeddings moderations responses
|
|
11
|
+
audio/transcriptions audio/translations audio/speech
|
|
12
|
+
images/generations images/edits images/variations
|
|
21
13
|
].freeze
|
|
14
|
+
TRACKED_PATHS = TRACKED_ENDPOINTS.map { |endpoint| "/v1/#{endpoint}" }.freeze
|
|
15
|
+
RETRIEVE_PATH = %r{\A/v1/responses/resp_[^/]+\z}
|
|
22
16
|
|
|
23
17
|
class << self
|
|
24
18
|
def match?(url)
|
|
25
|
-
|
|
19
|
+
uri_matches?(url) do |uri|
|
|
20
|
+
path = uri.path.to_s
|
|
21
|
+
Hosts::API_HOSTS.include?(uri.host.to_s.downcase) &&
|
|
22
|
+
(TRACKED_PATHS.include?(path) || path.match?(RETRIEVE_PATH))
|
|
23
|
+
end
|
|
26
24
|
end
|
|
27
25
|
|
|
28
26
|
def provider_names
|
|
@@ -11,13 +11,11 @@ module LlmCostTracker
|
|
|
11
11
|
module Providers
|
|
12
12
|
module Openai
|
|
13
13
|
module ResponseParser
|
|
14
|
-
include LlmCostTracker::Providers::Openai::ServiceCharges
|
|
15
|
-
|
|
16
14
|
class << self
|
|
17
|
-
def combined_pricing_mode(host:, model:, service_tier:)
|
|
15
|
+
def combined_pricing_mode(host:, model:, service_tier:, provider: "openai")
|
|
18
16
|
modes = [Pricing::Mode.normalize(service_tier)]
|
|
19
17
|
if Hosts.data_residency?(host) &&
|
|
20
|
-
Pricing::Matcher.modifier_priced?(provider:
|
|
18
|
+
Pricing::Matcher.modifier_priced?(provider: provider, model: model, modifier: "data_residency")
|
|
21
19
|
modes << "data_residency"
|
|
22
20
|
end
|
|
23
21
|
Pricing::Mode.compose(modes)
|
|
@@ -30,12 +28,17 @@ module LlmCostTracker
|
|
|
30
28
|
model = response["model"] || request["model"]
|
|
31
29
|
service_line_items =
|
|
32
30
|
ServiceCharges.service_line_items_for(response, request: request, model: model) +
|
|
33
|
-
ServiceCharges.transcription_line_items(usage)
|
|
31
|
+
ServiceCharges.transcription_line_items(usage) +
|
|
32
|
+
ServiceCharges.billed_line_items(usage) +
|
|
33
|
+
UsageExtractor.cache_read_line_items(usage)
|
|
34
34
|
Event.build(
|
|
35
35
|
provider: provider,
|
|
36
36
|
provider_response_id: response["id"],
|
|
37
37
|
pricing_mode: pricing_mode || combined_pricing_mode(
|
|
38
|
-
|
|
38
|
+
provider: provider,
|
|
39
|
+
host: host,
|
|
40
|
+
model: model,
|
|
41
|
+
service_tier: response["service_tier"] || usage[:service_tier] || request["service_tier"]
|
|
39
42
|
),
|
|
40
43
|
model: model,
|
|
41
44
|
token_usage: UsageExtractor.token_usage(usage, model: model),
|
|
@@ -43,18 +46,44 @@ module LlmCostTracker
|
|
|
43
46
|
service_line_items: service_line_items
|
|
44
47
|
)
|
|
45
48
|
end
|
|
49
|
+
|
|
50
|
+
def retrieved_event(response:, provider:, host:, usage_source:)
|
|
51
|
+
finished = !%w[queued in_progress].include?(response["status"].to_s)
|
|
52
|
+
return nil unless finished && response["background"] && response["usage"]
|
|
53
|
+
return nil if Call.already_recorded?(provider: provider, provider_response_id: response["id"])
|
|
54
|
+
|
|
55
|
+
event_from_response(
|
|
56
|
+
response: response,
|
|
57
|
+
request: { "tools" => response["tools"] },
|
|
58
|
+
provider: provider,
|
|
59
|
+
host: host,
|
|
60
|
+
usage_source: usage_source
|
|
61
|
+
)&.keyed_by_response_id
|
|
62
|
+
end
|
|
46
63
|
end
|
|
47
64
|
|
|
48
65
|
def parse(request_url:, request_body:, response_status:, response_body:, **)
|
|
49
66
|
return nil unless response_status == 200
|
|
50
67
|
|
|
68
|
+
response = safe_json_parse(response_body)
|
|
69
|
+
host = parsed_uri(request_url)&.host
|
|
70
|
+
if parsed_uri(request_url)&.path.to_s.include?("/responses/resp_")
|
|
71
|
+
return ResponseParser.retrieved_event(
|
|
72
|
+
response: response,
|
|
73
|
+
provider: provider_for(request_url),
|
|
74
|
+
host: host,
|
|
75
|
+
usage_source: Usage::Source::RESPONSE
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
request = safe_json_parse(request_body)
|
|
51
80
|
ResponseParser.event_from_response(
|
|
52
|
-
response:
|
|
53
|
-
request:
|
|
81
|
+
response: response,
|
|
82
|
+
request: request,
|
|
54
83
|
provider: provider_for(request_url),
|
|
55
|
-
host:
|
|
84
|
+
host: host,
|
|
56
85
|
usage_source: Usage::Source::RESPONSE
|
|
57
|
-
)
|
|
86
|
+
) || speech_event(request_url, request) || transcription_without_usage_event(request_url, request)
|
|
58
87
|
end
|
|
59
88
|
|
|
60
89
|
def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
|
|
@@ -62,34 +91,69 @@ module LlmCostTracker
|
|
|
62
91
|
|
|
63
92
|
request = safe_json_parse(request_body)
|
|
64
93
|
usage = detect_stream_usage(events)
|
|
65
|
-
context = stream_capture_context(events: events, request: request, request_url: request_url)
|
|
94
|
+
context = stream_capture_context(events: events, request: request, request_url: request_url, usage: usage)
|
|
66
95
|
|
|
67
|
-
|
|
96
|
+
background = find_event_value(events) { |data| data.dig("response", "background") }
|
|
97
|
+
if usage
|
|
98
|
+
event = build_known_stream_usage(usage: usage, **context)
|
|
99
|
+
return background ? event.keyed_by_response_id : event
|
|
100
|
+
end
|
|
68
101
|
|
|
69
102
|
warn_missing_stream_usage(request_url: request_url, request: request)
|
|
70
|
-
build_unknown_stream_usage(**context)
|
|
103
|
+
build_unknown_stream_usage(**context, service_line_items: background ? [] : context[:service_line_items])
|
|
71
104
|
end
|
|
72
105
|
|
|
73
|
-
def auto_enable_stream_usage?(request_url)
|
|
106
|
+
def auto_enable_stream_usage?(request_url, _request_parsed)
|
|
74
107
|
openai_chat_completions_url?(request_url)
|
|
75
108
|
end
|
|
76
109
|
|
|
110
|
+
def retain_stream_event?(data)
|
|
111
|
+
data.is_a?(Hash) && (data["item"].is_a?(Hash) || data["response"].is_a?(Hash))
|
|
112
|
+
end
|
|
113
|
+
|
|
77
114
|
private
|
|
78
115
|
|
|
79
|
-
def
|
|
116
|
+
def speech_event(request_url, request)
|
|
117
|
+
uri = parsed_uri(request_url)
|
|
118
|
+
return nil unless uri && uri.path.to_s.end_with?("/audio/speech")
|
|
119
|
+
|
|
120
|
+
Event.build(
|
|
121
|
+
provider: provider_for(request_url),
|
|
122
|
+
model: model_for(request_url, request),
|
|
123
|
+
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0),
|
|
124
|
+
usage_source: Usage::Source::RESPONSE,
|
|
125
|
+
service_line_items: ServiceCharges.speech_line_items(request)
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def transcription_without_usage_event(request_url, request)
|
|
130
|
+
uri = parsed_uri(request_url)
|
|
131
|
+
return nil unless uri && uri.path.to_s.match?(%r{/audio/(?:transcriptions|translations)\z})
|
|
132
|
+
|
|
133
|
+
Event.build(
|
|
134
|
+
provider: provider_for(request_url),
|
|
135
|
+
model: model_for(request_url, request) || Event::UNKNOWN_MODEL,
|
|
136
|
+
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0),
|
|
137
|
+
usage_source: Usage::Source::UNKNOWN
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def stream_capture_context(events:, request:, request_url:, usage:)
|
|
80
142
|
model = find_event_value(events) do |data|
|
|
81
143
|
data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
|
|
82
144
|
end || request["model"]
|
|
145
|
+
provider = provider_for(request_url)
|
|
83
146
|
{
|
|
84
|
-
provider:
|
|
147
|
+
provider: provider,
|
|
85
148
|
model: model,
|
|
86
149
|
provider_response_id: find_event_value(events) do |data|
|
|
87
150
|
data["id"] || data.dig("response", "id") || data.dig("chunk", "id")
|
|
88
151
|
end,
|
|
89
|
-
pricing_mode:
|
|
90
|
-
|
|
152
|
+
pricing_mode: ResponseParser.combined_pricing_mode(
|
|
153
|
+
provider: provider,
|
|
154
|
+
host: parsed_uri(request_url)&.host,
|
|
91
155
|
model: model,
|
|
92
|
-
service_tier: stream_pricing_mode(events) || request["service_tier"]
|
|
156
|
+
service_tier: stream_pricing_mode(events) || usage&.dig(:service_tier) || request["service_tier"]
|
|
93
157
|
),
|
|
94
158
|
service_line_items: openai_stream_service_line_items(events, request: request, model: model)
|
|
95
159
|
}
|
|
@@ -109,13 +173,14 @@ module LlmCostTracker
|
|
|
109
173
|
token_usage: UsageExtractor.token_usage(usage, model: model),
|
|
110
174
|
stream: true,
|
|
111
175
|
usage_source: Usage::Source::STREAM_FINAL,
|
|
112
|
-
service_line_items: service_line_items
|
|
176
|
+
service_line_items: service_line_items + ServiceCharges.transcription_line_items(usage) +
|
|
177
|
+
ServiceCharges.billed_line_items(usage) + UsageExtractor.cache_read_line_items(usage)
|
|
113
178
|
)
|
|
114
179
|
end
|
|
115
180
|
|
|
116
181
|
def warn_missing_stream_usage(request_url:, request:)
|
|
117
|
-
return unless request["stream"]
|
|
118
|
-
return unless openai_chat_completions_url?(request_url)
|
|
182
|
+
return unless request_url.nil? || request["stream"]
|
|
183
|
+
return unless request_url ? openai_chat_completions_url?(request_url) : request["messages"]
|
|
119
184
|
return if request.dig("stream_options", "include_usage")
|
|
120
185
|
|
|
121
186
|
Logging.warn(
|
|
@@ -132,7 +197,8 @@ module LlmCostTracker
|
|
|
132
197
|
|
|
133
198
|
def detect_stream_usage(events)
|
|
134
199
|
usage = find_event_value(events, reverse: true) do |data|
|
|
135
|
-
candidate = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage")
|
|
200
|
+
candidate = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage") ||
|
|
201
|
+
data.dig("x_groq", "usage") || data.dig("chunk", "x_groq", "usage")
|
|
136
202
|
candidate if candidate.is_a?(Hash)
|
|
137
203
|
end
|
|
138
204
|
usage&.deep_symbolize_keys
|
|
@@ -144,10 +210,18 @@ module LlmCostTracker
|
|
|
144
210
|
end
|
|
145
211
|
end
|
|
146
212
|
|
|
147
|
-
def
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
213
|
+
def openai_stream_service_line_items(events, request: nil, model: nil)
|
|
214
|
+
response = { "output" => [] }
|
|
215
|
+
each_event_data(events) do |data|
|
|
216
|
+
response["output"].concat(Array(data.dig("response", "output")))
|
|
217
|
+
response["output"] << data["item"] if data["item"]
|
|
218
|
+
chunk = data["chunk"] || data
|
|
219
|
+
next unless chunk["choices"].is_a?(Array)
|
|
220
|
+
|
|
221
|
+
response["id"] ||= chunk["id"]
|
|
222
|
+
response["choices"] ||= chunk["choices"]
|
|
223
|
+
end
|
|
224
|
+
ServiceCharges.service_line_items_for(response, request: request, model: model)
|
|
151
225
|
end
|
|
152
226
|
end
|
|
153
227
|
end
|