llm_cost_tracker 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/LICENSE.txt +28 -0
- data/README.md +33 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
- data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
- data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
- data/app/models/llm_cost_tracker/call.rb +6 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +19 -157
- data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
- data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
- data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +23 -20
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +22 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +20 -35
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +65 -81
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
- data/lib/llm_cost_tracker/ledger/store.rb +17 -35
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
- data/lib/llm_cost_tracker/parsers.rb +12 -0
- data/lib/llm_cost_tracker/prices.json +1179 -208
- data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
- data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
- data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +32 -26
- data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +35 -5
- data/llm_cost_tracker.gemspec +6 -5
- metadata +58 -5
|
@@ -9,18 +9,19 @@ module LlmCostTracker
|
|
|
9
9
|
class Parser < LlmCostTracker::Parsers::Base
|
|
10
10
|
HOSTS = %w[generativelanguage.googleapis.com].freeze
|
|
11
11
|
INTERACTIONS_PATH_PATTERN = %r{/interactions(?:/[^/]+)?\z}
|
|
12
|
+
INTERACTION_RETRIEVE_PATH_PATTERN = %r{/interactions/[^/]+\z}
|
|
12
13
|
CACHE_PATH_PATTERN = %r{/cachedContents\z}
|
|
13
14
|
TRACKED_PATH_PATTERN = Regexp.union(
|
|
14
15
|
%r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}, INTERACTIONS_PATH_PATTERN, CACHE_PATH_PATTERN
|
|
15
16
|
)
|
|
16
17
|
STREAM_PATH_PATTERN = /:streamGenerateContent\z/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
18
|
+
MODEL_PATH_PATTERN = %r{/models/([^/:]+)}
|
|
19
|
+
PENDING_STATUSES = %w[queued in_progress].freeze
|
|
20
|
+
TRAFFIC_TYPE_MODES = {
|
|
21
|
+
"ON_DEMAND" => nil,
|
|
22
|
+
"TRAFFIC_TYPE_UNSPECIFIED" => nil,
|
|
23
|
+
"ON_DEMAND_PRIORITY" => "priority",
|
|
24
|
+
"ON_DEMAND_FLEX" => "flex"
|
|
24
25
|
}.freeze
|
|
25
26
|
|
|
26
27
|
class << self
|
|
@@ -36,9 +37,7 @@ module LlmCostTracker
|
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
def streaming_request?(request_url, request_parsed)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
super
|
|
40
|
+
path_matches?(request_url, STREAM_PATH_PATTERN) || super
|
|
42
41
|
end
|
|
43
42
|
|
|
44
43
|
def parse(request_url:, request_body:, response_status:, response_body:, response_headers: nil)
|
|
@@ -47,26 +46,11 @@ module LlmCostTracker
|
|
|
47
46
|
response = safe_json_parse(response_body)
|
|
48
47
|
request = safe_json_parse(request_body)
|
|
49
48
|
if path_matches?(request_url, INTERACTIONS_PATH_PATTERN)
|
|
50
|
-
|
|
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
|
|
49
|
+
return interaction_response_event(response, request_url:, request:, response_headers:)
|
|
55
50
|
end
|
|
56
51
|
return cache_storage_event(response) if path_matches?(request_url, CACHE_PATH_PATTERN)
|
|
57
52
|
|
|
58
|
-
|
|
59
|
-
return nil unless usage
|
|
60
|
-
|
|
61
|
-
model = response["modelVersion"].presence || extract_model_from_url(request_url)
|
|
62
|
-
build_event(
|
|
63
|
-
model: model,
|
|
64
|
-
usage: usage,
|
|
65
|
-
usage_source: Usage::Source::RESPONSE,
|
|
66
|
-
provider_response_id: response["responseId"],
|
|
67
|
-
pricing_mode: pricing_mode(request: request, usage: usage, response_headers: response_headers),
|
|
68
|
-
service_line_items: service_line_items_for(response, model: model)
|
|
69
|
-
)
|
|
53
|
+
content_event(response, request_url:, request:, response_headers:)
|
|
70
54
|
end
|
|
71
55
|
|
|
72
56
|
def parse_stream(response_status:,
|
|
@@ -79,45 +63,18 @@ module LlmCostTracker
|
|
|
79
63
|
|
|
80
64
|
request = safe_json_parse(request_body)
|
|
81
65
|
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
|
|
66
|
+
return interaction_event(interaction, request:, response_headers:, stream: true) if interaction
|
|
85
67
|
|
|
86
|
-
|
|
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"] }
|
|
90
|
-
mode = pricing_mode(request: request, usage: usage, response_headers: response_headers)
|
|
91
|
-
service_line_items = grounding_line_items_for_stream(events, model: model)
|
|
92
|
-
|
|
93
|
-
if usage
|
|
94
|
-
build_event(
|
|
95
|
-
model: model,
|
|
96
|
-
usage: usage,
|
|
97
|
-
stream: true,
|
|
98
|
-
usage_source: Usage::Source::STREAM_FINAL,
|
|
99
|
-
provider_response_id: response_id,
|
|
100
|
-
pricing_mode: mode,
|
|
101
|
-
service_line_items: service_line_items
|
|
102
|
-
)
|
|
103
|
-
else
|
|
104
|
-
build_unknown_stream_usage(
|
|
105
|
-
provider: "gemini",
|
|
106
|
-
model: model,
|
|
107
|
-
provider_response_id: response_id,
|
|
108
|
-
pricing_mode: mode,
|
|
109
|
-
service_line_items: service_line_items
|
|
110
|
-
)
|
|
111
|
-
end
|
|
68
|
+
content_stream_event(events, request:, request_url:, response_headers:, fallback_model: model)
|
|
112
69
|
end
|
|
113
70
|
|
|
114
71
|
def model_for(request_url, request_parsed)
|
|
115
|
-
|
|
72
|
+
model_from_url(request_url) ||
|
|
116
73
|
(request_parsed["model"] if path_matches?(request_url, INTERACTIONS_PATH_PATTERN))
|
|
117
74
|
end
|
|
118
75
|
|
|
119
76
|
def retain_stream_event?(data)
|
|
120
|
-
data.is_a?(Hash) &&
|
|
77
|
+
data.is_a?(Hash) && Grounding.from_candidates(data["candidates"]).any?
|
|
121
78
|
end
|
|
122
79
|
|
|
123
80
|
def provider_for(_request_url)
|
|
@@ -125,29 +82,7 @@ module LlmCostTracker
|
|
|
125
82
|
end
|
|
126
83
|
|
|
127
84
|
def service_line_items_for(response, model:)
|
|
128
|
-
|
|
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
|
-
}
|
|
85
|
+
Grounding.from_response(response).line_items(model: model)
|
|
151
86
|
end
|
|
152
87
|
|
|
153
88
|
def cache_storage_event(response)
|
|
@@ -156,92 +91,37 @@ module LlmCostTracker
|
|
|
156
91
|
to = response["expireTime"]
|
|
157
92
|
return nil unless tokens && from && to
|
|
158
93
|
|
|
159
|
-
seconds = Time.iso8601(to) - Time.iso8601(from)
|
|
160
94
|
Event.build(
|
|
161
95
|
provider: "gemini",
|
|
162
|
-
model: response["model"].to_s.
|
|
96
|
+
model: response["model"].to_s.split("/").last,
|
|
163
97
|
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
164
98
|
usage_source: Usage::Source::RESPONSE,
|
|
165
99
|
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
|
-
]
|
|
100
|
+
service_line_items: [cache_storage_line_item(tokens, from, to)]
|
|
176
101
|
)
|
|
177
102
|
end
|
|
178
103
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
usage_source:,
|
|
184
|
-
provider_response_id:,
|
|
185
|
-
pricing_mode:,
|
|
186
|
-
service_line_items:,
|
|
187
|
-
stream: false)
|
|
188
|
-
Event.build(
|
|
189
|
-
provider: "gemini",
|
|
190
|
-
model: model,
|
|
191
|
-
pricing_mode: pricing_mode,
|
|
192
|
-
token_usage: UsageExtractor.token_usage(usage),
|
|
193
|
-
stream: stream,
|
|
194
|
-
usage_source: usage_source,
|
|
195
|
-
provider_response_id: provider_response_id,
|
|
196
|
-
service_line_items: service_line_items + UsageExtractor.cache_read_line_items(usage)
|
|
197
|
-
)
|
|
104
|
+
def pricing_mode(request:, usage:, response_headers:, host: nil, model: nil)
|
|
105
|
+
regional = Openai::Hosts.vertex_non_global?(host) &&
|
|
106
|
+
Pricing::Matcher.modifier_priced?(provider: "gemini", model: model, modifier: "data_residency")
|
|
107
|
+
Pricing::Mode.compose([service_tier(request, usage, response_headers), ("data_residency" if regional)])
|
|
198
108
|
end
|
|
199
109
|
|
|
200
|
-
|
|
201
|
-
uri_matches?(url) { |uri| uri.path.to_s.match?(pattern) }
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def merged_stream_usage(events)
|
|
205
|
-
find_event_value(events, reverse: true) do |data|
|
|
206
|
-
meta = data["usageMetadata"]
|
|
207
|
-
meta if meta.is_a?(Hash)
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def extract_model_from_url(url)
|
|
212
|
-
uri = parsed_uri(url)
|
|
213
|
-
return nil unless uri
|
|
214
|
-
|
|
215
|
-
match = uri.path.match(%r{/models/([^/:]+)})
|
|
216
|
-
match && match[1]
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def pricing_mode(request:, usage:, response_headers:)
|
|
220
|
-
body_mode = Pricing::Mode.normalize(usage && usage["serviceTier"])
|
|
221
|
-
return body_mode if body_mode
|
|
222
|
-
|
|
223
|
-
header_mode = Pricing::Mode.normalize(response_header(response_headers, "x-gemini-service-tier"))
|
|
224
|
-
return header_mode if header_mode
|
|
110
|
+
private
|
|
225
111
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
112
|
+
def interaction_response_event(response, request_url:, request:, response_headers:)
|
|
113
|
+
event = interaction_event(response, request:, response_headers:)
|
|
114
|
+
return event unless event && path_matches?(request_url, INTERACTION_RETRIEVE_PATH_PATTERN)
|
|
229
115
|
|
|
230
|
-
|
|
231
|
-
headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def completed_interaction(data)
|
|
235
|
-
interaction = data["interaction"]
|
|
236
|
-
interaction if interaction.is_a?(Hash) && interaction["usage"].is_a?(Hash)
|
|
116
|
+
event unless Call.already_recorded?(provider: "gemini", provider_response_id: event.provider_response_id)
|
|
237
117
|
end
|
|
238
118
|
|
|
239
119
|
def interaction_event(interaction, request:, response_headers:, stream: false)
|
|
240
120
|
usage = interaction["usage"]
|
|
241
|
-
return nil unless usage.is_a?(Hash) &&
|
|
121
|
+
return nil unless usage.is_a?(Hash) && !PENDING_STATUSES.include?(interaction["status"])
|
|
242
122
|
|
|
243
123
|
model = interaction["model"] || request["model"]
|
|
244
|
-
metadata =
|
|
124
|
+
metadata = UsageExtractor.from_interaction(usage, interaction["service_tier"])
|
|
245
125
|
build_event(
|
|
246
126
|
model: model,
|
|
247
127
|
usage: metadata,
|
|
@@ -253,51 +133,108 @@ module LlmCostTracker
|
|
|
253
133
|
).keyed_by_response_id
|
|
254
134
|
end
|
|
255
135
|
|
|
256
|
-
def
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
136
|
+
def content_event(response, request_url:, request:, response_headers:)
|
|
137
|
+
usage = response["usageMetadata"]
|
|
138
|
+
return nil unless usage
|
|
139
|
+
|
|
140
|
+
model = response["modelVersion"].presence || model_from_url(request_url)
|
|
141
|
+
host = parsed_uri(request_url)&.host
|
|
142
|
+
build_event(
|
|
143
|
+
model: model,
|
|
144
|
+
usage: usage,
|
|
145
|
+
usage_source: Usage::Source::RESPONSE,
|
|
146
|
+
provider_response_id: response["responseId"],
|
|
147
|
+
pricing_mode: pricing_mode(request:, usage:, response_headers:, host:, model:),
|
|
148
|
+
service_line_items: service_line_items_for(response, model: model)
|
|
149
|
+
)
|
|
260
150
|
end
|
|
261
151
|
|
|
262
|
-
def
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
152
|
+
def content_stream_event(events, request:, request_url:, response_headers:, fallback_model:)
|
|
153
|
+
usage = last_usage_metadata(events)
|
|
154
|
+
model = find_event_value(events, reverse: true) { |data| data["modelVersion"] } ||
|
|
155
|
+
model_from_url(request_url) || fallback_model || request["model"]
|
|
156
|
+
host = parsed_uri(request_url)&.host
|
|
157
|
+
attributes = {
|
|
158
|
+
model: model,
|
|
159
|
+
provider_response_id: find_event_value(events) { |data| data["responseId"] },
|
|
160
|
+
pricing_mode: pricing_mode(request:, usage:, response_headers:, host:, model:),
|
|
161
|
+
service_line_items: stream_grounding_line_items(events, model: model)
|
|
162
|
+
}
|
|
163
|
+
return build_unknown_stream_usage(provider: "gemini", **attributes) unless usage
|
|
164
|
+
|
|
165
|
+
build_event(usage: usage, stream: true, usage_source: Usage::Source::STREAM_FINAL, **attributes)
|
|
268
166
|
end
|
|
269
167
|
|
|
270
|
-
def
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
168
|
+
def build_event(usage:, service_line_items:, **attributes)
|
|
169
|
+
Event.build(
|
|
170
|
+
provider: "gemini",
|
|
171
|
+
token_usage: UsageExtractor.token_usage(usage),
|
|
172
|
+
service_line_items: service_line_items + UsageExtractor.line_items(usage),
|
|
173
|
+
**attributes
|
|
174
|
+
)
|
|
175
|
+
end
|
|
274
176
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
177
|
+
def cache_storage_line_item(tokens, from, to)
|
|
178
|
+
seconds = Time.iso8601(to) - Time.iso8601(from)
|
|
179
|
+
Charges::LineItem.build(
|
|
180
|
+
dimension_key: "cache_storage_token_hour",
|
|
181
|
+
quantity: BigDecimal(tokens.to_s) * BigDecimal(seconds.to_s) / 3600,
|
|
182
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
183
|
+
pricing_basis: "provider_usage",
|
|
184
|
+
provider_field: "response.usageMetadata.totalTokenCount",
|
|
185
|
+
details: { cached_tokens: tokens, expire_time: to }
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def service_tier(request, usage, response_headers)
|
|
190
|
+
reported_service_tier(usage) ||
|
|
191
|
+
Pricing::Mode.normalize(response_header(response_headers, "x-gemini-service-tier")) ||
|
|
192
|
+
requested_flex_tier(request)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def reported_service_tier(usage)
|
|
196
|
+
return nil unless usage
|
|
197
|
+
|
|
198
|
+
traffic = usage["trafficType"]
|
|
199
|
+
Pricing::Mode.normalize(usage["serviceTier"]) ||
|
|
200
|
+
TRAFFIC_TYPE_MODES.fetch(traffic.to_s) { Pricing::Mode.normalize(traffic) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def requested_flex_tier(request)
|
|
204
|
+
"flex" if Pricing::Mode.normalize(request["service_tier"] || request["serviceTier"]) == "flex"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def response_header(headers, name)
|
|
208
|
+
headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def path_matches?(url, pattern)
|
|
212
|
+
uri_matches?(url) { |uri| uri.path.to_s.match?(pattern) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def model_from_url(url)
|
|
216
|
+
uri = parsed_uri(url)
|
|
217
|
+
uri.path[MODEL_PATH_PATTERN, 1] if uri
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def last_usage_metadata(events)
|
|
221
|
+
find_event_value(events, reverse: true) do |data|
|
|
222
|
+
metadata = data["usageMetadata"]
|
|
223
|
+
metadata if metadata.is_a?(Hash)
|
|
281
224
|
end
|
|
282
225
|
end
|
|
283
226
|
|
|
284
|
-
def
|
|
285
|
-
|
|
227
|
+
def completed_interaction(data)
|
|
228
|
+
interaction = data["interaction"]
|
|
229
|
+
interaction if interaction.is_a?(Hash) && interaction["usage"].is_a?(Hash)
|
|
286
230
|
end
|
|
287
231
|
|
|
288
|
-
def
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
Charges::LineItem.build(
|
|
293
|
-
dimension_key: kind,
|
|
294
|
-
quantity: ModelFamilies.per_query_grounding?(model) ? count : 1,
|
|
295
|
-
cost_status: Charges::CostStatus::UNKNOWN,
|
|
296
|
-
pricing_basis: "provider_usage",
|
|
297
|
-
provider_field: provider_field || GROUNDING_FIELDS.fetch(kind),
|
|
298
|
-
details: { web_search_queries: count }
|
|
299
|
-
)
|
|
232
|
+
def stream_grounding_line_items(events, model:)
|
|
233
|
+
grounding = find_event_value(events, reverse: true) do |data|
|
|
234
|
+
candidates = Grounding.from_candidates(data["candidates"])
|
|
235
|
+
candidates if candidates.any?
|
|
300
236
|
end
|
|
237
|
+
grounding ? grounding.line_items(model: model) : []
|
|
301
238
|
end
|
|
302
239
|
end
|
|
303
240
|
end
|
|
@@ -4,61 +4,80 @@ module LlmCostTracker
|
|
|
4
4
|
module Providers
|
|
5
5
|
module Gemini
|
|
6
6
|
module UsageExtractor
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
class << self
|
|
8
|
+
def token_usage(usage)
|
|
9
|
+
Usage::TokenUsage.build(
|
|
10
|
+
**input_counts(usage),
|
|
11
|
+
**output_counts(usage),
|
|
12
|
+
total_tokens: usage["totalTokenCount"],
|
|
13
|
+
hidden_output_tokens: usage["thoughtsTokenCount"]
|
|
14
|
+
)
|
|
15
|
+
end
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
image_output: image_output),
|
|
24
|
-
total_tokens: usage["totalTokenCount"],
|
|
25
|
-
cache_read_input_tokens: cache_read,
|
|
26
|
-
audio_input_tokens: audio_input,
|
|
27
|
-
audio_output_tokens: audio_output,
|
|
28
|
-
image_input_tokens: image_input,
|
|
29
|
-
image_output_tokens: image_output,
|
|
30
|
-
hidden_output_tokens: usage["thoughtsTokenCount"]
|
|
31
|
-
)
|
|
32
|
-
end
|
|
17
|
+
def line_items(usage)
|
|
18
|
+
{
|
|
19
|
+
"audio_cache_read_input" => modality_tokens(usage["cacheTokensDetails"], "AUDIO"),
|
|
20
|
+
"video_output" => modality_tokens(usage["candidatesTokensDetails"], "VIDEO")
|
|
21
|
+
}.filter_map do |dimension_key, quantity|
|
|
22
|
+
Charges::LineItem.build(dimension_key: dimension_key, quantity: quantity) if quantity.positive?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
33
25
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
def from_interaction(usage, service_tier)
|
|
27
|
+
{
|
|
28
|
+
"promptTokenCount" => usage["total_input_tokens"],
|
|
29
|
+
"cachedContentTokenCount" => usage["total_cached_tokens"],
|
|
30
|
+
"toolUsePromptTokenCount" => usage["total_tool_use_tokens"],
|
|
31
|
+
"candidatesTokenCount" => usage["total_output_tokens"],
|
|
32
|
+
"thoughtsTokenCount" => usage["total_thought_tokens"],
|
|
33
|
+
"totalTokenCount" => usage["total_tokens"],
|
|
34
|
+
"promptTokensDetails" => interaction_modalities(usage["input_tokens_by_modality"]),
|
|
35
|
+
"cacheTokensDetails" => interaction_modalities(usage["cached_tokens_by_modality"]),
|
|
36
|
+
"candidatesTokensDetails" => interaction_modalities(usage["output_tokens_by_modality"]),
|
|
37
|
+
"serviceTier" => service_tier
|
|
38
|
+
}
|
|
39
|
+
end
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
def modality_tokens(details, modality)
|
|
42
|
+
Array(details).sum do |detail|
|
|
43
|
+
next 0 unless detail["modality"] == modality
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
detail["tokenCount"].to_i
|
|
46
|
+
end
|
|
47
|
+
end
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
[gross_output_tokens(usage) - audio_output - image_output, 0].max
|
|
49
|
-
end
|
|
49
|
+
private
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
def input_counts(usage)
|
|
52
|
+
cache_read = usage["cachedContentTokenCount"].to_i
|
|
53
|
+
audio = uncached_prompt_tokens(usage, "AUDIO")
|
|
54
|
+
image = uncached_prompt_tokens(usage, "IMAGE") + uncached_prompt_tokens(usage, "DOCUMENT")
|
|
55
|
+
text = [usage["promptTokenCount"].to_i - cache_read - audio - image, 0].max
|
|
56
|
+
{
|
|
57
|
+
input_tokens: text + usage["toolUsePromptTokenCount"].to_i,
|
|
58
|
+
cache_read_input_tokens: cache_read,
|
|
59
|
+
audio_input_tokens: audio,
|
|
60
|
+
image_input_tokens: image
|
|
61
|
+
}
|
|
62
|
+
end
|
|
56
63
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
def output_counts(usage)
|
|
65
|
+
audio = modality_tokens(usage["candidatesTokensDetails"], "AUDIO")
|
|
66
|
+
image = modality_tokens(usage["candidatesTokensDetails"], "IMAGE")
|
|
67
|
+
gross = usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
|
|
68
|
+
{ output_tokens: [gross - audio - image, 0].max, audio_output_tokens: audio, image_output_tokens: image }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def uncached_prompt_tokens(usage, modality)
|
|
72
|
+
prompt = modality_tokens(usage["promptTokensDetails"] || usage["promptTokenDetails"], modality)
|
|
73
|
+
cached = modality_tokens(usage["cacheTokensDetails"], modality)
|
|
74
|
+
[prompt - cached, 0].max
|
|
75
|
+
end
|
|
60
76
|
|
|
61
|
-
|
|
77
|
+
def interaction_modalities(entries)
|
|
78
|
+
Array(entries).map do |entry|
|
|
79
|
+
{ "modality" => entry["modality"].to_s.upcase, "tokenCount" => entry["tokens"] }
|
|
80
|
+
end
|
|
62
81
|
end
|
|
63
82
|
end
|
|
64
83
|
end
|
|
@@ -19,10 +19,21 @@ module LlmCostTracker
|
|
|
19
19
|
].freeze
|
|
20
20
|
|
|
21
21
|
DATA_RESIDENCY_HOST_PATTERN = /\A(?:[a-z]{2,3}\.api\.openai\.com|us\.api\.x\.ai|api\.(?:eu|us)\.mistral\.ai)\z/
|
|
22
|
+
VERTEX_NON_GLOBAL_HOST_PATTERN = /\A(?:[a-z0-9-]+-aiplatform|aiplatform\.[a-z0-9-]+\.rep)\.googleapis\.com\z/
|
|
23
|
+
|
|
24
|
+
BEDROCK_HOST_PATTERN = /\Abedrock-(?:mantle|runtime(?:-fips)?)\./
|
|
22
25
|
|
|
23
26
|
def self.data_residency?(host)
|
|
24
27
|
host.to_s.downcase.match?(DATA_RESIDENCY_HOST_PATTERN)
|
|
25
28
|
end
|
|
29
|
+
|
|
30
|
+
def self.vertex_non_global?(host)
|
|
31
|
+
host.to_s.downcase.match?(VERTEX_NON_GLOBAL_HOST_PATTERN)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.bedrock?(host)
|
|
35
|
+
host.to_s.downcase.match?(BEDROCK_HOST_PATTERN)
|
|
36
|
+
end
|
|
26
37
|
end
|
|
27
38
|
end
|
|
28
39
|
end
|
|
@@ -6,7 +6,7 @@ module LlmCostTracker
|
|
|
6
6
|
module ModelFamilies
|
|
7
7
|
IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
|
|
8
8
|
|
|
9
|
-
CHARACTER_BILLED_TTS_MODEL_PATTERN =
|
|
9
|
+
CHARACTER_BILLED_TTS_MODEL_PATTERN = %r{\A(?:tts-1(?:-hd)?|voxtral-mini-tts-[\w-]+|canopylabs/orpheus-[\w-]+)\z}
|
|
10
10
|
|
|
11
11
|
REASONING_MODEL_PATTERNS = [
|
|
12
12
|
/\Agpt-5(\b|[\d.-])/i,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Providers
|
|
5
|
+
module Openai
|
|
6
|
+
class OutputItem
|
|
7
|
+
RENAMED_TYPES = {
|
|
8
|
+
"web_search_call" => "web_search_request",
|
|
9
|
+
"code_interpreter_call" => "container_session"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :dimension
|
|
13
|
+
|
|
14
|
+
def self.wrap(data)
|
|
15
|
+
item = new(data) if data.is_a?(Hash)
|
|
16
|
+
item if item&.dimension
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(data)
|
|
20
|
+
@data = data
|
|
21
|
+
@dimension = shell_session? ? "container_session" : catalog_dimension
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def billable?
|
|
25
|
+
case dimension
|
|
26
|
+
when "image_generation_call" then @data["status"] == "completed"
|
|
27
|
+
when "web_search_request" then [nil, "search"].include?(@data.dig("action", "type"))
|
|
28
|
+
else true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def dedup_key(position)
|
|
33
|
+
return "#{dimension}:#{container_id}" if dimension == "container_session" && container_id
|
|
34
|
+
|
|
35
|
+
@data["id"] || "#{@data['type']}:#{position}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def line_item(dimension_key)
|
|
39
|
+
Charges::LineItem.build(
|
|
40
|
+
dimension_key: dimension_key,
|
|
41
|
+
quantity: 1,
|
|
42
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
43
|
+
pricing_basis: "provider_usage",
|
|
44
|
+
provider_field: @data["provider_field"] || "response.output.#{@data['type']}",
|
|
45
|
+
provider_item_id: dimension_key == "container_session" ? container_id || @data["id"] : @data["id"],
|
|
46
|
+
details: details
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def billing_fields
|
|
51
|
+
@data.slice("type", "id", "status", "container_id").merge(
|
|
52
|
+
"action" => @data["action"]&.slice("type"),
|
|
53
|
+
"environment" => @data["environment"]&.slice("type", "container_id")
|
|
54
|
+
).compact
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def shell_session?
|
|
60
|
+
@data["type"] == "shell_call" && shell_container_id
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def catalog_dimension
|
|
64
|
+
key = RENAMED_TYPES[@data["type"]] || @data["type"]
|
|
65
|
+
dimension = Usage::Catalog[key]
|
|
66
|
+
key if dimension && dimension.token_key.nil?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def container_id
|
|
70
|
+
@data["container_id"] || shell_container_id
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def shell_container_id
|
|
74
|
+
environment = @data["environment"]
|
|
75
|
+
environment["container_id"] if environment.is_a?(Hash) && environment["type"] == "container_reference"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def details
|
|
79
|
+
{ status: @data["status"], action_type: @data.dig("action", "type"), container_id: container_id }.compact
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -5,9 +5,10 @@ module LlmCostTracker
|
|
|
5
5
|
module Openai
|
|
6
6
|
class Parser < LlmCostTracker::Parsers::Base
|
|
7
7
|
include ResponseParser
|
|
8
|
+
include StreamParser
|
|
8
9
|
|
|
9
10
|
TRACKED_ENDPOINTS = %w[
|
|
10
|
-
chat/completions completions embeddings moderations responses
|
|
11
|
+
chat/completions completions embeddings moderations responses responses/compact
|
|
11
12
|
audio/transcriptions audio/translations audio/speech
|
|
12
13
|
images/generations images/edits images/variations
|
|
13
14
|
].freeze
|