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
|
@@ -11,7 +11,7 @@ module LlmCostTracker
|
|
|
11
11
|
class << self
|
|
12
12
|
def match?(url)
|
|
13
13
|
uri_matches?(url) do |uri|
|
|
14
|
-
HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.
|
|
14
|
+
HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.match?(%r{\A/v1/messages(?:/batches)?\z})
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -20,7 +20,7 @@ module LlmCostTracker
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def parse(request_body:, response_status:, response_body:, **)
|
|
23
|
+
def parse(request_body:, response_status:, response_body:, request_url: nil, **)
|
|
24
24
|
return nil unless response_status == 200
|
|
25
25
|
|
|
26
26
|
response = safe_json_parse(response_body)
|
|
@@ -36,37 +36,33 @@ module LlmCostTracker
|
|
|
36
36
|
usage_source: Usage::Source::RESPONSE,
|
|
37
37
|
request: request,
|
|
38
38
|
content: Array(response["content"]).grep(Hash).map(&:deep_symbolize_keys),
|
|
39
|
+
host: parsed_uri(request_url)&.host,
|
|
39
40
|
**stop_fields(response)
|
|
40
41
|
)
|
|
41
42
|
end
|
|
42
43
|
|
|
43
|
-
def parse_stream(response_status:, request_body: nil, events: [], **)
|
|
44
|
+
def parse_stream(response_status:, request_body: nil, request_url: nil, events: [], **)
|
|
44
45
|
return nil unless response_status == 200
|
|
45
46
|
|
|
46
47
|
request = symbolize_request(request_body)
|
|
47
|
-
model = find_event_value(events) { |data| data.dig("message", "model") } || request[:model]
|
|
48
48
|
usage = stream_usage(events)&.deep_symbolize_keys
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
provider_response_id: response_id,
|
|
67
|
-
pricing_mode: UsageExtractor.pricing_mode(request: request, usage: usage)
|
|
68
|
-
)
|
|
69
|
-
end
|
|
49
|
+
message = {
|
|
50
|
+
model: find_event_value(events) { |data| data.dig("message", "model") } || request[:model],
|
|
51
|
+
provider_response_id: find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
|
|
52
|
+
}
|
|
53
|
+
host = parsed_uri(request_url)&.host
|
|
54
|
+
return unknown_stream_event(request: request, host: host, **message) unless usage
|
|
55
|
+
|
|
56
|
+
ResponseParser.event_from_usage(
|
|
57
|
+
usage: usage,
|
|
58
|
+
usage_source: Usage::Source::STREAM_FINAL,
|
|
59
|
+
request: request,
|
|
60
|
+
stream: true,
|
|
61
|
+
content: content_blocks(events),
|
|
62
|
+
host: host,
|
|
63
|
+
**message,
|
|
64
|
+
**stop_fields(final_delta(events))
|
|
65
|
+
)
|
|
70
66
|
end
|
|
71
67
|
|
|
72
68
|
def provider_for(_request_url)
|
|
@@ -83,6 +79,15 @@ module LlmCostTracker
|
|
|
83
79
|
safe_json_parse(request_body).deep_symbolize_keys
|
|
84
80
|
end
|
|
85
81
|
|
|
82
|
+
def unknown_stream_event(model:, provider_response_id:, request:, host:)
|
|
83
|
+
build_unknown_stream_usage(
|
|
84
|
+
provider: "anthropic",
|
|
85
|
+
model: model,
|
|
86
|
+
provider_response_id: provider_response_id,
|
|
87
|
+
pricing_mode: UsageExtractor.pricing_mode(request: request, usage: nil, host: host, model: model)
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
86
91
|
def final_delta(events)
|
|
87
92
|
find_event_value(events, reverse: true) { |data| data["delta"] if data["type"] == "message_delta" }
|
|
88
93
|
end
|
|
@@ -14,8 +14,10 @@ module LlmCostTracker
|
|
|
14
14
|
stream: false,
|
|
15
15
|
stop_reason: nil,
|
|
16
16
|
refusal_category: nil,
|
|
17
|
-
content: nil
|
|
18
|
-
|
|
17
|
+
content: nil,
|
|
18
|
+
host: nil)
|
|
19
|
+
model = UsageExtractor.served_model(usage, content) || model
|
|
20
|
+
pricing_mode = UsageExtractor.pricing_mode(request: request, usage: usage, host: host, model: model)
|
|
19
21
|
line_items = UsageExtractor.service_line_items(usage) +
|
|
20
22
|
UsageExtractor.iteration_line_items(usage, content: content) +
|
|
21
23
|
UsageExtractor.refusal_line_items(usage, stop_reason:, refusal_category:)
|
|
@@ -23,7 +25,7 @@ module LlmCostTracker
|
|
|
23
25
|
provider: "anthropic",
|
|
24
26
|
provider_response_id: provider_response_id,
|
|
25
27
|
pricing_mode: pricing_mode,
|
|
26
|
-
model:
|
|
28
|
+
model: model,
|
|
27
29
|
token_usage: UsageExtractor.token_usage(usage),
|
|
28
30
|
stream: stream,
|
|
29
31
|
usage_source: usage_source,
|
|
@@ -16,129 +16,150 @@ module LlmCostTracker
|
|
|
16
16
|
:BILLED_REFUSAL_CATEGORIES,
|
|
17
17
|
:BEDROCK_REGIONAL_PROFILE
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
19
|
+
class << self
|
|
20
|
+
def token_usage(usage)
|
|
21
|
+
entries = [usage, *iterations(usage, "compaction")]
|
|
22
|
+
writes = entries.map { |entry| cache_writes(entry) }
|
|
23
|
+
|
|
24
|
+
Usage::TokenUsage.build(
|
|
25
|
+
input_tokens: entries.sum { |entry| entry[:input_tokens].to_i },
|
|
26
|
+
output_tokens: entries.sum { |entry| entry[:output_tokens].to_i },
|
|
27
|
+
cache_read_input_tokens: entries.sum { |entry| entry[:cache_read_input_tokens].to_i },
|
|
28
|
+
cache_write_input_tokens: writes.sum(&:first),
|
|
29
|
+
cache_write_extended_input_tokens: writes.sum(&:last),
|
|
30
|
+
hidden_output_tokens: usage.dig(:output_tokens_details, :thinking_tokens).to_i
|
|
31
|
+
)
|
|
32
|
+
end
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
def served_model(usage, content = nil)
|
|
35
|
+
served = iterations(usage, "fallback_message").last&.dig(:model)
|
|
36
|
+
served ||= fallback_blocks(content).last&.dig(:to, :model)
|
|
37
|
+
served&.to_s.presence
|
|
38
|
+
end
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
def iteration_line_items(usage, content: nil)
|
|
41
|
+
other_model_iterations(usage, content).map do |iteration|
|
|
42
|
+
tokens = token_usage(iteration)
|
|
43
|
+
Charges::LineItem.build(
|
|
44
|
+
dimension_key: "model_iteration",
|
|
45
|
+
quantity: 1,
|
|
46
|
+
pricing_basis: "provider_usage",
|
|
47
|
+
provider_field: "usage.iterations.#{iteration[:type]}",
|
|
48
|
+
details: tokens.to_h.select { |_key, count| count.positive? }.merge(model: iteration[:model].to_s)
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def refusal_line_items(usage, stop_reason:, refusal_category:)
|
|
54
|
+
return [] unless stop_reason.to_s == "refusal" && token_usage(usage).output_tokens.zero?
|
|
55
|
+
return [] if BILLED_REFUSAL_CATEGORIES.include?(refusal_category.to_s)
|
|
56
|
+
|
|
57
|
+
[Charges::LineItem.build(
|
|
58
|
+
dimension_key: "billed_request",
|
|
44
59
|
quantity: 1,
|
|
60
|
+
rate_amount: 0,
|
|
61
|
+
cost: 0,
|
|
45
62
|
pricing_basis: "provider_usage",
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
price_source: "provider_response",
|
|
64
|
+
provider_field: "stop_details.category",
|
|
65
|
+
details: { category: refusal_category&.to_s }.compact
|
|
66
|
+
)]
|
|
49
67
|
end
|
|
50
|
-
end
|
|
51
68
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
dimension_key: "billed_request",
|
|
58
|
-
quantity: 1,
|
|
59
|
-
rate_amount: 0,
|
|
60
|
-
cost: 0,
|
|
61
|
-
pricing_basis: "provider_usage",
|
|
62
|
-
price_source: "provider_response",
|
|
63
|
-
provider_field: "stop_details.category",
|
|
64
|
-
details: { category: refusal_category&.to_s }.compact
|
|
65
|
-
)]
|
|
66
|
-
end
|
|
69
|
+
def pricing_mode(request:, usage:, host: nil, model: request&.dig(:model))
|
|
70
|
+
modes = %i[speed service_tier].map { |key| Pricing::Mode.normalize(reported(key, usage, request)) }
|
|
71
|
+
modes << "data_residency" if data_residency?(usage, request) || regional_host?(host, model)
|
|
72
|
+
Pricing::Mode.compose(modes)
|
|
73
|
+
end
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
def regional_host?(host, model)
|
|
76
|
+
Openai::Hosts.vertex_non_global?(host) &&
|
|
77
|
+
Pricing::Matcher.modifier_priced?(provider: "anthropic", model: model, modifier: "data_residency")
|
|
78
|
+
end
|
|
72
79
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
def service_line_items(usage)
|
|
81
|
+
server_tool_use = usage[:server_tool_use]
|
|
82
|
+
return [] unless server_tool_use.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
SERVER_TOOL_LINE_ITEMS.filter_map do |dimension_key, count_key|
|
|
85
|
+
quantity = server_tool_use[count_key].to_i
|
|
86
|
+
next if quantity.zero?
|
|
87
|
+
|
|
88
|
+
Charges::LineItem.build(
|
|
89
|
+
dimension_key: dimension_key,
|
|
90
|
+
quantity: quantity,
|
|
91
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
92
|
+
pricing_basis: "provider_usage",
|
|
93
|
+
provider_field: "usage.server_tool_use.#{count_key}"
|
|
94
|
+
)
|
|
95
|
+
end
|
|
79
96
|
end
|
|
80
|
-
advisors + billed.flatten
|
|
81
|
-
end
|
|
82
97
|
|
|
83
|
-
|
|
84
|
-
Array(content).select { |block| block.is_a?(Hash) && block[:type].to_s == "fallback" }
|
|
85
|
-
end
|
|
98
|
+
private
|
|
86
99
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
100
|
+
def other_model_iterations(usage, content)
|
|
101
|
+
iterations(usage, "advisor_message") + billed_fallback_iterations(usage, content)
|
|
102
|
+
end
|
|
90
103
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
geo = (usage&.dig(:inference_geo) || request&.dig(:inference_geo)).to_s.downcase
|
|
104
|
+
def billed_fallback_iterations(usage, content)
|
|
105
|
+
served = served_model(usage, content)
|
|
106
|
+
return [] unless served
|
|
95
107
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
categories = fallback_blocks(content).map { |block| block.dig(:trigger, :category).to_s }
|
|
109
|
+
declined_hops(usage, served).zip(categories).flat_map do |hop, category|
|
|
110
|
+
billed_hop?(hop, category) ? hop : []
|
|
111
|
+
end
|
|
112
|
+
end
|
|
100
113
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
def declined_hops(usage, served)
|
|
115
|
+
declined = iterations(usage, "message").reject { |entry| ["", served].include?(entry[:model].to_s) }
|
|
116
|
+
declined.group_by { |entry| entry[:model].to_s }.values
|
|
117
|
+
end
|
|
105
118
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
def billed_hop?(hop, category)
|
|
120
|
+
hop.any? { |entry| entry[:output_tokens].to_i.positive? } || BILLED_REFUSAL_CATEGORIES.include?(category)
|
|
121
|
+
end
|
|
109
122
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
123
|
+
def fallback_blocks(content)
|
|
124
|
+
Array(content).select { |block| block.is_a?(Hash) && block[:type].to_s == "fallback" }
|
|
125
|
+
end
|
|
113
126
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
quantity: quantity,
|
|
117
|
-
cost_status: Charges::CostStatus::UNKNOWN,
|
|
118
|
-
pricing_basis: "provider_usage",
|
|
119
|
-
provider_field: "usage.server_tool_use.#{count_key}"
|
|
120
|
-
)
|
|
127
|
+
def iterations(usage, type)
|
|
128
|
+
Array(usage[:iterations]).select { |entry| entry.is_a?(Hash) && entry[:type].to_s == type }
|
|
121
129
|
end
|
|
122
|
-
end
|
|
123
130
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
def reported(key, usage, request)
|
|
132
|
+
usage&.dig(key) || request&.dig(key)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def data_residency?(usage, request)
|
|
136
|
+
DATA_RESIDENCY_GEOS.include?(reported(:inference_geo, usage, request).to_s.downcase) ||
|
|
137
|
+
bedrock_regional?(request&.dig(:model))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def bedrock_regional?(model)
|
|
141
|
+
model.to_s.match?(BEDROCK_REGIONAL_PROFILE) &&
|
|
142
|
+
Pricing::Matcher.modifier_priced?(provider: "bedrock", model: model.to_s, modifier: "data_residency")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def cache_writes(usage)
|
|
146
|
+
cache_creation = usage[:cache_creation]
|
|
147
|
+
if cache_creation.is_a?(Hash)
|
|
148
|
+
five_minute = cache_creation[:ephemeral_5m_input_tokens].to_i
|
|
149
|
+
one_hour = cache_creation[:ephemeral_1h_input_tokens].to_i
|
|
150
|
+
[five_minute + [usage[:cache_creation_input_tokens].to_i - five_minute - one_hour, 0].max, one_hour]
|
|
151
|
+
else
|
|
152
|
+
warn_unexpected_cache_creation(cache_creation, usage)
|
|
153
|
+
[usage[:cache_creation_input_tokens].to_i, 0]
|
|
154
|
+
end
|
|
134
155
|
end
|
|
135
|
-
end
|
|
136
156
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
157
|
+
def warn_unexpected_cache_creation(cache_creation, usage)
|
|
158
|
+
return if cache_creation.nil?
|
|
159
|
+
return if usage.key?(:cache_creation_input_tokens)
|
|
140
160
|
|
|
141
|
-
|
|
161
|
+
Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
|
|
162
|
+
end
|
|
142
163
|
end
|
|
143
164
|
end
|
|
144
165
|
end
|
|
@@ -4,7 +4,7 @@ module LlmCostTracker
|
|
|
4
4
|
module Providers
|
|
5
5
|
module Azure
|
|
6
6
|
module Hosts
|
|
7
|
-
OPENAI_HOST_PATTERN = /\A[a-z0-9][a-z0-9-]*\.(?:openai
|
|
7
|
+
OPENAI_HOST_PATTERN = /\A[a-z0-9][a-z0-9-]*\.(?:openai|services\.ai|cognitiveservices)\.azure\.com\z/i
|
|
8
8
|
def self.openai?(host)
|
|
9
9
|
host.to_s.match?(OPENAI_HOST_PATTERN)
|
|
10
10
|
end
|
|
@@ -5,10 +5,12 @@ module LlmCostTracker
|
|
|
5
5
|
module Azure
|
|
6
6
|
class Parser < LlmCostTracker::Parsers::Base
|
|
7
7
|
include Openai::ResponseParser
|
|
8
|
+
include Openai::StreamParser
|
|
8
9
|
|
|
9
10
|
PATH_PATTERN = %r{
|
|
10
11
|
\A/openai/(?:deployments/[^/]+|v1)/(?:#{Openai::Parser::TRACKED_ENDPOINTS.join('|')}|responses/resp_[^/]+)\z
|
|
11
12
|
}x
|
|
13
|
+
DEPLOYMENT_PATH_PATTERN = %r{/openai/deployments/([^/]+)/}
|
|
12
14
|
|
|
13
15
|
class << self
|
|
14
16
|
def match?(url)
|
|
@@ -27,12 +29,7 @@ module LlmCostTracker
|
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def model_for(request_url, request_parsed)
|
|
30
|
-
|
|
31
|
-
return body_model if body_model
|
|
32
|
-
|
|
33
|
-
uri = parsed_uri(request_url)
|
|
34
|
-
match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
|
|
35
|
-
match && match[1]
|
|
32
|
+
super || parsed_uri(request_url)&.path.to_s[DEPLOYMENT_PATH_PATTERN, 1]
|
|
36
33
|
end
|
|
37
34
|
|
|
38
35
|
def auto_enable_stream_usage?(request_url, request_parsed)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Providers
|
|
5
|
+
module Gemini
|
|
6
|
+
class Grounding
|
|
7
|
+
CANDIDATE_FIELDS = {
|
|
8
|
+
"grounding_request" => "response.candidates.groundingMetadata.webSearchQueries",
|
|
9
|
+
"maps_grounding_request" => "response.candidates.groundingMetadata.groundingChunks.maps"
|
|
10
|
+
}.freeze
|
|
11
|
+
TOOL_KINDS = {
|
|
12
|
+
"google_search" => "grounding_request",
|
|
13
|
+
"google_maps" => "maps_grounding_request"
|
|
14
|
+
}.freeze
|
|
15
|
+
TOOL_COUNT_FIELD = "response.usage.grounding_tool_count"
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def from_response(response)
|
|
19
|
+
usage = response["usage"]
|
|
20
|
+
return from_candidates(response["candidates"]) unless usage.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
from_tool_counts(usage["grounding_tool_count"])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def from_candidates(candidates)
|
|
26
|
+
counts = Array(candidates).each_with_object(Hash.new(0)) do |candidate, totals|
|
|
27
|
+
metadata = candidate["groundingMetadata"]
|
|
28
|
+
next unless metadata.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
kind, count = metadata_count(metadata)
|
|
31
|
+
totals[kind] += count
|
|
32
|
+
end
|
|
33
|
+
new(counts)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def from_tool_counts(entries)
|
|
39
|
+
counts = Array(entries).each_with_object(Hash.new(0)) do |entry, totals|
|
|
40
|
+
kind = TOOL_KINDS[entry["type"]]
|
|
41
|
+
totals[kind] += entry["count"].to_i if kind
|
|
42
|
+
end
|
|
43
|
+
new(counts, provider_field: TOOL_COUNT_FIELD)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def metadata_count(metadata)
|
|
47
|
+
queries = unique_count(metadata["webSearchQueries"])
|
|
48
|
+
if Array(metadata["groundingChunks"]).any? { |chunk| chunk.is_a?(Hash) && chunk.key?("maps") }
|
|
49
|
+
["maps_grounding_request", [queries, 1].max]
|
|
50
|
+
else
|
|
51
|
+
["grounding_request", queries + unique_count(metadata["imageSearchQueries"])]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unique_count(queries)
|
|
56
|
+
Array(queries).map { |query| query.to_s.strip }.reject(&:empty?).uniq.size
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize(counts, provider_field: nil)
|
|
61
|
+
@counts = counts
|
|
62
|
+
@provider_field = provider_field
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def any?
|
|
66
|
+
@counts.values.any?(&:positive?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def line_items(model:)
|
|
70
|
+
per_query = ModelFamilies.per_query_grounding?(model)
|
|
71
|
+
@counts.filter_map do |kind, count|
|
|
72
|
+
next unless count.positive?
|
|
73
|
+
|
|
74
|
+
Charges::LineItem.build(
|
|
75
|
+
dimension_key: kind,
|
|
76
|
+
quantity: per_query ? count : 1,
|
|
77
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
78
|
+
pricing_basis: "provider_usage",
|
|
79
|
+
provider_field: @provider_field || CANDIDATE_FIELDS.fetch(kind),
|
|
80
|
+
details: { web_search_queries: count }
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|