llm_cost_tracker 0.14.1 → 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 +198 -0
- data/LICENSE.txt +28 -0
- data/README.md +38 -35
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
- data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
- data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
- 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 +36 -26
- data/app/models/llm_cost_tracker/call.rb +15 -17
- 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 +67 -222
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
- 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 +7 -32
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- 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 +2 -5
- data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
- 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 +63 -25
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +22 -171
- data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
- data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
- data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
- 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/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +42 -22
- data/lib/llm_cost_tracker/engine.rb +8 -1
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +28 -27
- 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 +5 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -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 +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -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 +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +23 -36
- 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 +82 -90
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +98 -93
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
- 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 -243
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
- data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
- data/lib/llm_cost_tracker/ledger/store.rb +19 -37
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/middleware/faraday/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 -284
- data/lib/llm_cost_tracker/parsers.rb +19 -40
- data/lib/llm_cost_tracker/prices.json +3241 -187
- data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
- 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 +113 -161
- 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 +61 -30
- data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- 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_diff.rb +13 -9
- 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 +24 -119
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
- 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 +12 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -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/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +45 -31
- 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 +85 -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 +58 -25
- data/llm_cost_tracker.gemspec +64 -0
- metadata +65 -11
|
@@ -1,16 +1,34 @@
|
|
|
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
|
+
INTERACTION_RETRIEVE_PATH_PATTERN = %r{/interactions/[^/]+\z}
|
|
13
|
+
CACHE_PATH_PATTERN = %r{/cachedContents\z}
|
|
14
|
+
TRACKED_PATH_PATTERN = Regexp.union(
|
|
15
|
+
%r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}, INTERACTIONS_PATH_PATTERN, CACHE_PATH_PATTERN
|
|
16
|
+
)
|
|
17
|
+
STREAM_PATH_PATTERN = /:streamGenerateContent\z/
|
|
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"
|
|
25
|
+
}.freeze
|
|
10
26
|
|
|
11
27
|
class << self
|
|
12
28
|
def match?(url)
|
|
13
|
-
|
|
29
|
+
uri_matches?(url) do |uri|
|
|
30
|
+
HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.match?(TRACKED_PATH_PATTERN)
|
|
31
|
+
end
|
|
14
32
|
end
|
|
15
33
|
|
|
16
34
|
def provider_names
|
|
@@ -19,161 +37,204 @@ module LlmCostTracker
|
|
|
19
37
|
end
|
|
20
38
|
|
|
21
39
|
def streaming_request?(request_url, request_parsed)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
super
|
|
40
|
+
path_matches?(request_url, STREAM_PATH_PATTERN) || super
|
|
25
41
|
end
|
|
26
42
|
|
|
27
43
|
def parse(request_url:, request_body:, response_status:, response_body:, response_headers: nil)
|
|
28
44
|
return nil unless response_status == 200
|
|
29
45
|
|
|
30
46
|
response = safe_json_parse(response_body)
|
|
31
|
-
usage = response["usageMetadata"]
|
|
32
|
-
return nil unless usage
|
|
33
|
-
|
|
34
47
|
request = safe_json_parse(request_body)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
pricing_mode: pricing_mode(request: request, usage: usage, response_headers: response_headers),
|
|
42
|
-
service_line_items: grounding_line_items(grounding_request_count(response["candidates"]), model: model)
|
|
43
|
-
)
|
|
48
|
+
if path_matches?(request_url, INTERACTIONS_PATH_PATTERN)
|
|
49
|
+
return interaction_response_event(response, request_url:, request:, response_headers:)
|
|
50
|
+
end
|
|
51
|
+
return cache_storage_event(response) if path_matches?(request_url, CACHE_PATH_PATTERN)
|
|
52
|
+
|
|
53
|
+
content_event(response, request_url:, request:, response_headers:)
|
|
44
54
|
end
|
|
45
55
|
|
|
46
|
-
def parse_stream(response_status:,
|
|
56
|
+
def parse_stream(response_status:,
|
|
57
|
+
request_url: nil,
|
|
58
|
+
request_body: nil,
|
|
59
|
+
events: [],
|
|
60
|
+
response_headers: nil,
|
|
61
|
+
model: nil)
|
|
47
62
|
return nil unless response_status == 200
|
|
48
63
|
|
|
49
64
|
request = safe_json_parse(request_body)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
service_line_items = grounding_line_items_for_stream(events, model: model)
|
|
55
|
-
|
|
56
|
-
if usage
|
|
57
|
-
build_event(
|
|
58
|
-
request_url: request_url,
|
|
59
|
-
usage: usage,
|
|
60
|
-
stream: true,
|
|
61
|
-
usage_source: Usage::Source::STREAM_FINAL,
|
|
62
|
-
provider_response_id: response_id,
|
|
63
|
-
pricing_mode: mode,
|
|
64
|
-
service_line_items: service_line_items
|
|
65
|
-
)
|
|
66
|
-
else
|
|
67
|
-
build_unknown_stream_usage(
|
|
68
|
-
provider: "gemini",
|
|
69
|
-
model: model,
|
|
70
|
-
provider_response_id: response_id,
|
|
71
|
-
pricing_mode: mode,
|
|
72
|
-
service_line_items: service_line_items
|
|
73
|
-
)
|
|
74
|
-
end
|
|
65
|
+
interaction = find_event_value(events, reverse: true) { |data| completed_interaction(data) }
|
|
66
|
+
return interaction_event(interaction, request:, response_headers:, stream: true) if interaction
|
|
67
|
+
|
|
68
|
+
content_stream_event(events, request:, request_url:, response_headers:, fallback_model: model)
|
|
75
69
|
end
|
|
76
70
|
|
|
77
|
-
def model_for(request_url,
|
|
78
|
-
|
|
71
|
+
def model_for(request_url, request_parsed)
|
|
72
|
+
model_from_url(request_url) ||
|
|
73
|
+
(request_parsed["model"] if path_matches?(request_url, INTERACTIONS_PATH_PATTERN))
|
|
79
74
|
end
|
|
80
75
|
|
|
81
76
|
def retain_stream_event?(data)
|
|
82
|
-
data.is_a?(Hash) &&
|
|
77
|
+
data.is_a?(Hash) && Grounding.from_candidates(data["candidates"]).any?
|
|
83
78
|
end
|
|
84
79
|
|
|
85
80
|
def provider_for(_request_url)
|
|
86
81
|
"gemini"
|
|
87
82
|
end
|
|
88
83
|
|
|
89
|
-
|
|
84
|
+
def service_line_items_for(response, model:)
|
|
85
|
+
Grounding.from_response(response).line_items(model: model)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def cache_storage_event(response)
|
|
89
|
+
tokens = response.dig("usageMetadata", "totalTokenCount")
|
|
90
|
+
from = response["createTime"]
|
|
91
|
+
to = response["expireTime"]
|
|
92
|
+
return nil unless tokens && from && to
|
|
90
93
|
|
|
91
|
-
def build_event(request_url:,
|
|
92
|
-
usage:,
|
|
93
|
-
usage_source:,
|
|
94
|
-
stream: false,
|
|
95
|
-
provider_response_id: nil,
|
|
96
|
-
pricing_mode: nil,
|
|
97
|
-
service_line_items: nil)
|
|
98
94
|
Event.build(
|
|
99
95
|
provider: "gemini",
|
|
100
|
-
model:
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
model: response["model"].to_s.split("/").last,
|
|
97
|
+
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
98
|
+
usage_source: Usage::Source::RESPONSE,
|
|
99
|
+
provider_response_id: response["name"],
|
|
100
|
+
service_line_items: [cache_storage_line_item(tokens, from, to)]
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
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)])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
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)
|
|
115
|
+
|
|
116
|
+
event unless Call.already_recorded?(provider: "gemini", provider_response_id: event.provider_response_id)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def interaction_event(interaction, request:, response_headers:, stream: false)
|
|
120
|
+
usage = interaction["usage"]
|
|
121
|
+
return nil unless usage.is_a?(Hash) && !PENDING_STATUSES.include?(interaction["status"])
|
|
122
|
+
|
|
123
|
+
model = interaction["model"] || request["model"]
|
|
124
|
+
metadata = UsageExtractor.from_interaction(usage, interaction["service_tier"])
|
|
125
|
+
build_event(
|
|
126
|
+
model: model,
|
|
127
|
+
usage: metadata,
|
|
103
128
|
stream: stream,
|
|
104
|
-
usage_source:
|
|
105
|
-
provider_response_id:
|
|
106
|
-
|
|
129
|
+
usage_source: stream ? Usage::Source::STREAM_FINAL : Usage::Source::RESPONSE,
|
|
130
|
+
provider_response_id: interaction["id"],
|
|
131
|
+
pricing_mode: pricing_mode(request: request, usage: metadata, response_headers: response_headers),
|
|
132
|
+
service_line_items: service_line_items_for(interaction, model: model)
|
|
133
|
+
).keyed_by_response_id
|
|
134
|
+
end
|
|
135
|
+
|
|
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)
|
|
107
149
|
)
|
|
108
150
|
end
|
|
109
151
|
|
|
110
|
-
def
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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)
|
|
115
166
|
end
|
|
116
167
|
|
|
117
|
-
def
|
|
118
|
-
|
|
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
|
+
)
|
|
119
175
|
end
|
|
120
176
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
124
188
|
|
|
125
|
-
|
|
126
|
-
|
|
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)
|
|
127
193
|
end
|
|
128
194
|
|
|
129
|
-
def
|
|
130
|
-
|
|
131
|
-
return body_mode if body_mode
|
|
195
|
+
def reported_service_tier(usage)
|
|
196
|
+
return nil unless usage
|
|
132
197
|
|
|
133
|
-
|
|
134
|
-
|
|
198
|
+
traffic = usage["trafficType"]
|
|
199
|
+
Pricing::Mode.normalize(usage["serviceTier"]) ||
|
|
200
|
+
TRAFFIC_TYPE_MODES.fetch(traffic.to_s) { Pricing::Mode.normalize(traffic) }
|
|
201
|
+
end
|
|
135
202
|
|
|
136
|
-
|
|
137
|
-
|
|
203
|
+
def requested_flex_tier(request)
|
|
204
|
+
"flex" if Pricing::Mode.normalize(request["service_tier"] || request["serviceTier"]) == "flex"
|
|
138
205
|
end
|
|
139
206
|
|
|
140
207
|
def response_header(headers, name)
|
|
141
208
|
headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
|
|
142
209
|
end
|
|
143
210
|
|
|
144
|
-
def
|
|
145
|
-
|
|
146
|
-
count = grounding_request_count(data["candidates"])
|
|
147
|
-
count if count.positive?
|
|
148
|
-
end
|
|
149
|
-
grounding_line_items(quantity || 0, model: model)
|
|
211
|
+
def path_matches?(url, pattern)
|
|
212
|
+
uri_matches?(url) { |uri| uri.path.to_s.match?(pattern) }
|
|
150
213
|
end
|
|
151
214
|
|
|
152
|
-
def
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
Array(queries).size
|
|
156
|
-
end
|
|
215
|
+
def model_from_url(url)
|
|
216
|
+
uri = parsed_uri(url)
|
|
217
|
+
uri.path[MODEL_PATH_PATTERN, 1] if uri
|
|
157
218
|
end
|
|
158
219
|
|
|
159
|
-
def
|
|
160
|
-
|
|
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)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
161
226
|
|
|
162
|
-
|
|
163
|
-
[
|
|
164
|
-
|
|
165
|
-
dimension_key: "grounding_request",
|
|
166
|
-
quantity: billed_quantity,
|
|
167
|
-
cost_status: Charges::CostStatus::UNKNOWN,
|
|
168
|
-
pricing_basis: "provider_usage",
|
|
169
|
-
provider_field: "response.candidates.groundingMetadata.webSearchQueries",
|
|
170
|
-
details: { web_search_queries: query_count }
|
|
171
|
-
)
|
|
172
|
-
]
|
|
227
|
+
def completed_interaction(data)
|
|
228
|
+
interaction = data["interaction"]
|
|
229
|
+
interaction if interaction.is_a?(Hash) && interaction["usage"].is_a?(Hash)
|
|
173
230
|
end
|
|
174
231
|
|
|
175
|
-
def
|
|
176
|
-
|
|
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?
|
|
236
|
+
end
|
|
237
|
+
grounding ? grounding.line_items(model: model) : []
|
|
177
238
|
end
|
|
178
239
|
end
|
|
179
240
|
end
|
|
@@ -4,70 +4,80 @@ module LlmCostTracker
|
|
|
4
4
|
module Providers
|
|
5
5
|
module Gemini
|
|
6
6
|
module UsageExtractor
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
input_tokens: regular_input_tokens(usage: usage,
|
|
17
|
-
cache_read: cache_read,
|
|
18
|
-
audio_input: audio_input,
|
|
19
|
-
image_input: image_input) +
|
|
20
|
-
tool_use_prompt,
|
|
21
|
-
output_tokens: regular_output_tokens(usage: usage,
|
|
22
|
-
audio_output: audio_output,
|
|
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
|
|
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
|
|
33
16
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
def modality_tokens(details, modality)
|
|
42
|
+
Array(details).sum do |detail|
|
|
43
|
+
next 0 unless detail["modality"] == modality
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
[prompt_audio - cache_audio, 0].max
|
|
50
|
-
end
|
|
45
|
+
detail["tokenCount"].to_i
|
|
46
|
+
end
|
|
47
|
+
end
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
modality_tokens(usage["candidatesTokensDetails"], "AUDIO")
|
|
54
|
-
end
|
|
49
|
+
private
|
|
55
50
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
69
76
|
|
|
70
|
-
|
|
77
|
+
def interaction_modalities(entries)
|
|
78
|
+
Array(entries).map do |entry|
|
|
79
|
+
{ "modality" => entry["modality"].to_s.upcase, "tokenCount" => entry["tokens"] }
|
|
80
|
+
end
|
|
71
81
|
end
|
|
72
82
|
end
|
|
73
83
|
end
|
|
@@ -18,11 +18,22 @@ 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
|
+
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,24 +5,23 @@ module LlmCostTracker
|
|
|
5
5
|
module Openai
|
|
6
6
|
class Parser < LlmCostTracker::Parsers::Base
|
|
7
7
|
include ResponseParser
|
|
8
|
+
include StreamParser
|
|
8
9
|
|
|
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
|
|
10
|
+
TRACKED_ENDPOINTS = %w[
|
|
11
|
+
chat/completions completions embeddings moderations responses responses/compact
|
|
12
|
+
audio/transcriptions audio/translations audio/speech
|
|
13
|
+
images/generations images/edits images/variations
|
|
21
14
|
].freeze
|
|
15
|
+
TRACKED_PATHS = TRACKED_ENDPOINTS.map { |endpoint| "/v1/#{endpoint}" }.freeze
|
|
16
|
+
RETRIEVE_PATH = %r{\A/v1/responses/resp_[^/]+\z}
|
|
22
17
|
|
|
23
18
|
class << self
|
|
24
19
|
def match?(url)
|
|
25
|
-
|
|
20
|
+
uri_matches?(url) do |uri|
|
|
21
|
+
path = uri.path.to_s
|
|
22
|
+
Hosts::API_HOSTS.include?(uri.host.to_s.downcase) &&
|
|
23
|
+
(TRACKED_PATHS.include?(path) || path.match?(RETRIEVE_PATH))
|
|
24
|
+
end
|
|
26
25
|
end
|
|
27
26
|
|
|
28
27
|
def provider_names
|