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
|
@@ -11,13 +11,18 @@ module LlmCostTracker
|
|
|
11
11
|
module Providers
|
|
12
12
|
module Openai
|
|
13
13
|
module ResponseParser
|
|
14
|
-
|
|
14
|
+
PENDING_STATUSES = %w[queued in_progress].freeze
|
|
15
|
+
RETRIEVE_PATH_PATTERN = %r{/(?:responses|agent)/resp_}
|
|
16
|
+
TRANSCRIPTION_PATH_PATTERN = %r{/audio/(?:transcriptions|translations)\z}
|
|
15
17
|
|
|
16
18
|
class << self
|
|
17
|
-
def
|
|
19
|
+
def included(base) = base.include(StreamParser)
|
|
20
|
+
|
|
21
|
+
def combined_pricing_mode(host:, model:, service_tier:, provider: "openai")
|
|
18
22
|
modes = [Pricing::Mode.normalize(service_tier)]
|
|
19
|
-
if Hosts.
|
|
20
|
-
|
|
23
|
+
owners = %w[gemini/ anthropic/] if Hosts.vertex_non_global?(host)
|
|
24
|
+
if (owners || Hosts.data_residency?(host)) &&
|
|
25
|
+
Pricing::Matcher.modifier_priced?(provider:, model:, modifier: "data_residency", owners:)
|
|
21
26
|
modes << "data_residency"
|
|
22
27
|
end
|
|
23
28
|
Pricing::Mode.compose(modes)
|
|
@@ -25,133 +30,130 @@ module LlmCostTracker
|
|
|
25
30
|
|
|
26
31
|
def event_from_response(response:, request:, provider:, host:, usage_source:, pricing_mode: nil)
|
|
27
32
|
usage = response["usage"]&.deep_symbolize_keys
|
|
28
|
-
return nil
|
|
33
|
+
return nil if usage.nil? || pending?(response)
|
|
29
34
|
|
|
30
35
|
model = response["model"] || request["model"]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Event.build(
|
|
36
|
+
service_tier = response["service_tier"] || usage[:service_tier] || request["service_tier"]
|
|
37
|
+
usage_event(
|
|
38
|
+
usage,
|
|
35
39
|
provider: provider,
|
|
36
40
|
provider_response_id: response["id"],
|
|
37
|
-
pricing_mode: pricing_mode || combined_pricing_mode(
|
|
38
|
-
host: host, model: model, service_tier: response["service_tier"] || request["service_tier"]
|
|
39
|
-
),
|
|
40
41
|
model: model,
|
|
41
|
-
token_usage: UsageExtractor.token_usage(usage, model: model),
|
|
42
42
|
usage_source: usage_source,
|
|
43
|
-
service_line_items:
|
|
43
|
+
service_line_items: ServiceCharges.service_line_items_for(response, request: request, model: model),
|
|
44
|
+
pricing_mode: pricing_mode || combined_pricing_mode(provider:, host:, model:, service_tier:)
|
|
44
45
|
)
|
|
45
46
|
end
|
|
46
|
-
end
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
def retrieved_event(response:, provider:, host:, usage_source:)
|
|
49
|
+
return nil unless response["background"] && response["usage"] && !pending?(response)
|
|
50
|
+
return nil if Call.already_recorded?(provider: provider, provider_response_id: response["id"])
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
|
|
61
|
-
return nil unless response_status == 200
|
|
52
|
+
event_from_response(
|
|
53
|
+
response: response,
|
|
54
|
+
request: { "tools" => response["tools"] },
|
|
55
|
+
provider: provider,
|
|
56
|
+
host: host,
|
|
57
|
+
usage_source: usage_source
|
|
58
|
+
)&.keyed_by_response_id
|
|
59
|
+
end
|
|
62
60
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
def usage_event(usage, model:, service_line_items:, **attributes)
|
|
62
|
+
Event.build(
|
|
63
|
+
model: model,
|
|
64
|
+
token_usage: UsageExtractor.token_usage(usage, model: model),
|
|
65
|
+
service_line_items: service_line_items + ServiceCharges.transcription_line_items(usage) +
|
|
66
|
+
ServiceCharges.billed_line_items(usage) + UsageExtractor.cache_read_line_items(usage),
|
|
67
|
+
**attributes
|
|
68
|
+
)
|
|
69
|
+
end
|
|
66
70
|
|
|
67
|
-
|
|
71
|
+
private
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
def pending?(response)
|
|
74
|
+
PENDING_STATUSES.include?(response["status"].to_s)
|
|
75
|
+
end
|
|
71
76
|
end
|
|
72
77
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
def parse(request_url:, request_body:, response_status:, response_body:, **)
|
|
79
|
+
return nil unless response_status == 200
|
|
80
|
+
|
|
81
|
+
response = safe_json_parse(response_body)
|
|
82
|
+
uri = parsed_uri(request_url)
|
|
83
|
+
source = { provider: provider_for(request_url), host: uri&.host, usage_source: Usage::Source::RESPONSE }
|
|
84
|
+
if uri&.path.to_s.match?(RETRIEVE_PATH_PATTERN)
|
|
85
|
+
return ResponseParser.retrieved_event(response: response, **source)
|
|
86
|
+
end
|
|
76
87
|
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
request = safe_json_parse(request_body)
|
|
89
|
+
ResponseParser.event_from_response(response: response, request: request, **source) ||
|
|
90
|
+
usage_free_event(request_url, request, response)
|
|
79
91
|
end
|
|
80
92
|
|
|
81
93
|
private
|
|
82
94
|
|
|
83
|
-
def
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
pricing_mode: pricing_mode(
|
|
94
|
-
request_url: request_url,
|
|
95
|
-
model: model,
|
|
96
|
-
service_tier: stream_pricing_mode(events) || request["service_tier"]
|
|
97
|
-
),
|
|
98
|
-
service_line_items: openai_stream_service_line_items(events, request: request, model: model)
|
|
99
|
-
}
|
|
95
|
+
def usage_free_event(request_url, request, response)
|
|
96
|
+
path = parsed_uri(request_url)&.path.to_s
|
|
97
|
+
if path.end_with?("/audio/speech")
|
|
98
|
+
speech_event(request_url, request)
|
|
99
|
+
elsif path.match?(TRANSCRIPTION_PATH_PATTERN)
|
|
100
|
+
transcription_event(request_url, request, response)
|
|
101
|
+
else
|
|
102
|
+
ocr_event(request_url, request, response) ||
|
|
103
|
+
(moderation_event(request_url, request, response) if path.end_with?("/moderations"))
|
|
104
|
+
end
|
|
100
105
|
end
|
|
101
106
|
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
model:,
|
|
105
|
-
provider_response_id:,
|
|
106
|
-
pricing_mode:,
|
|
107
|
-
service_line_items:)
|
|
108
|
-
Event.build(
|
|
109
|
-
provider: provider,
|
|
110
|
-
provider_response_id: provider_response_id,
|
|
111
|
-
pricing_mode: pricing_mode,
|
|
112
|
-
model: model,
|
|
113
|
-
token_usage: UsageExtractor.token_usage(usage, model: model),
|
|
114
|
-
stream: true,
|
|
115
|
-
usage_source: Usage::Source::STREAM_FINAL,
|
|
116
|
-
service_line_items: service_line_items
|
|
117
|
-
)
|
|
107
|
+
def speech_event(request_url, request)
|
|
108
|
+
line_item_event(request_url, model_for(request_url, request), ServiceCharges.speech_line_items(request))
|
|
118
109
|
end
|
|
119
110
|
|
|
120
|
-
def
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
111
|
+
def transcription_event(request_url, request, response)
|
|
112
|
+
seconds = response["duration"].to_f.ceil
|
|
113
|
+
line_items = ServiceCharges.transcription_line_items(type: "duration", seconds: seconds)
|
|
114
|
+
line_item_event(request_url, model_for(request_url, request) || Event::UNKNOWN_MODEL, line_items)
|
|
115
|
+
end
|
|
124
116
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
117
|
+
def line_item_event(request_url, model, line_items)
|
|
118
|
+
zero_token_event(
|
|
119
|
+
request_url,
|
|
120
|
+
model: model,
|
|
121
|
+
usage_source: line_items.empty? ? Usage::Source::UNKNOWN : Usage::Source::RESPONSE,
|
|
122
|
+
service_line_items: line_items
|
|
129
123
|
)
|
|
130
124
|
end
|
|
131
125
|
|
|
132
|
-
def
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
end
|
|
126
|
+
def ocr_event(request_url, request, response)
|
|
127
|
+
line_items = ServiceCharges.ocr_line_items(response)
|
|
128
|
+
return nil if line_items.empty?
|
|
136
129
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
130
|
+
model = response["model"] || model_for(request_url, request)
|
|
131
|
+
zero_token_event(
|
|
132
|
+
request_url,
|
|
133
|
+
model: model,
|
|
134
|
+
pricing_mode: ResponseParser.combined_pricing_mode(
|
|
135
|
+
provider: provider_for(request_url), host: parsed_uri(request_url)&.host, model: model, service_tier: nil
|
|
136
|
+
),
|
|
137
|
+
usage_source: Usage::Source::RESPONSE,
|
|
138
|
+
service_line_items: line_items
|
|
139
|
+
)
|
|
143
140
|
end
|
|
144
141
|
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
142
|
+
def moderation_event(request_url, request, response)
|
|
143
|
+
zero_token_event(
|
|
144
|
+
request_url,
|
|
145
|
+
model: response["model"] || model_for(request_url, request),
|
|
146
|
+
provider_response_id: response["id"],
|
|
147
|
+
usage_source: Usage::Source::RESPONSE
|
|
148
|
+
)
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
def zero_token_event(request_url, **attributes)
|
|
152
|
+
Event.build(
|
|
153
|
+
provider: provider_for(request_url),
|
|
154
|
+
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0),
|
|
155
|
+
**attributes
|
|
156
|
+
)
|
|
155
157
|
end
|
|
156
158
|
end
|
|
157
159
|
end
|
|
@@ -1,179 +1,173 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "model_families"
|
|
4
|
+
require_relative "output_item"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Providers
|
|
7
8
|
module Openai
|
|
8
9
|
module ServiceCharges
|
|
9
|
-
|
|
10
|
-
"web_search_call" => "web_search_request",
|
|
11
|
-
"code_interpreter_call" => "container_session"
|
|
12
|
-
}.freeze
|
|
13
|
-
|
|
14
|
-
module_function
|
|
15
|
-
|
|
16
|
-
def line_items_from_output(output_items, request: nil, model: nil)
|
|
17
|
-
deduped = {}
|
|
18
|
-
Array(output_items).each { |item| store_output_item(deduped, item) }
|
|
19
|
-
deduped.values
|
|
20
|
-
.select { |item| billable?(item) }
|
|
21
|
-
.filter_map { |item| build_line_item(item, request: request, model: model) }
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def service_line_items_for(response, request: nil, model: nil)
|
|
25
|
-
output_items = Array(response["output"])
|
|
26
|
-
output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
|
|
27
|
-
line_items_from_output(output_items, request: request, model: model)
|
|
28
|
-
end
|
|
10
|
+
TICKS_PER_USD = 10_000_000_000
|
|
29
11
|
|
|
30
|
-
|
|
31
|
-
|
|
12
|
+
class << self
|
|
13
|
+
def service_line_items_for(response, request: nil, model: nil)
|
|
14
|
+
output_items = Array(response["output"])
|
|
15
|
+
output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
|
|
16
|
+
line_items_from_output(output_items, request: request, model: model)
|
|
17
|
+
end
|
|
32
18
|
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
def line_items_from_output(output_items, request: nil, model: nil)
|
|
20
|
+
unique = {}
|
|
21
|
+
Array(output_items).each do |data|
|
|
22
|
+
item = OutputItem.wrap(data)
|
|
23
|
+
unique[item.dedup_key(unique.length)] = item if item
|
|
24
|
+
end
|
|
25
|
+
unique.values.select(&:billable?).map { |item| priced_line_item(item, request: request, model: model) }
|
|
26
|
+
end
|
|
35
27
|
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
def billable?(item)
|
|
29
|
+
OutputItem.wrap(item)&.billable? || false
|
|
30
|
+
end
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
def build_line_item(item, request: nil, model: nil)
|
|
33
|
+
output_item = OutputItem.wrap(item)
|
|
34
|
+
priced_line_item(output_item, request: request, model: model) if output_item
|
|
35
|
+
end
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
37
|
+
def billing_fields(item)
|
|
38
|
+
OutputItem.wrap(item)&.billing_fields
|
|
39
|
+
end
|
|
46
40
|
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
def transcription_line_items(usage)
|
|
42
|
+
return [] unless usage
|
|
43
|
+
|
|
44
|
+
field = (usage[:type] || usage["type"]).to_s == "duration" ? "seconds" : "prompt_audio_seconds"
|
|
45
|
+
seconds = (usage[field.to_sym] || usage[field]).to_f
|
|
46
|
+
return [] unless seconds.positive?
|
|
47
|
+
|
|
48
|
+
[Charges::LineItem.build(
|
|
49
|
+
dimension_key: "transcription_minute",
|
|
50
|
+
quantity: BigDecimal(seconds.to_s) / 60,
|
|
51
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
52
|
+
pricing_basis: "provider_usage",
|
|
53
|
+
provider_field: "usage.#{field}",
|
|
54
|
+
details: { seconds: seconds }
|
|
55
|
+
)]
|
|
56
|
+
end
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
def speech_line_items(request)
|
|
59
|
+
input = request["input"]
|
|
60
|
+
return [] unless input.is_a?(String) && ModelFamilies.character_billed_tts?(request["model"])
|
|
61
|
+
|
|
62
|
+
[Charges::LineItem.build(
|
|
63
|
+
dimension_key: "text_to_speech_character",
|
|
64
|
+
quantity: input.length,
|
|
65
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
66
|
+
pricing_basis: "provider_usage",
|
|
67
|
+
provider_field: "request.input"
|
|
68
|
+
)]
|
|
55
69
|
end
|
|
56
|
-
end
|
|
57
70
|
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
def ocr_line_items(response)
|
|
72
|
+
pages = response.dig("usage_info", "pages_processed")
|
|
73
|
+
return unit_line_items("ocr_page", pages, "usage_info.pages_processed") if pages
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return true unless dimension == "web_search_request"
|
|
75
|
+
unit_line_items("ocr_page", response.dig("meta", "billed_units", "pages"), "meta.billed_units.pages")
|
|
76
|
+
end
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
def rerank_line_items(response)
|
|
79
|
+
units = response.dig("meta", "billed_units", "search_units")
|
|
80
|
+
unit_line_items("rerank_search_unit", units, "meta.billed_units.search_units")
|
|
81
|
+
end
|
|
68
82
|
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
def billed_line_items(usage)
|
|
84
|
+
amount, field = billed_amount(usage)
|
|
85
|
+
return [] unless amount
|
|
86
|
+
|
|
87
|
+
[Charges::LineItem.build(
|
|
88
|
+
dimension_key: "billed_request",
|
|
89
|
+
quantity: 1,
|
|
90
|
+
rate_amount: amount,
|
|
91
|
+
cost: amount,
|
|
92
|
+
pricing_basis: "provider_usage",
|
|
93
|
+
price_source: "provider_response",
|
|
94
|
+
provider_field: field
|
|
95
|
+
)]
|
|
96
|
+
end
|
|
71
97
|
|
|
72
|
-
|
|
73
|
-
return unless dimension
|
|
98
|
+
private
|
|
74
99
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
else
|
|
78
|
-
item["id"] || "#{item['type']}:#{output_items.length}"
|
|
79
|
-
end
|
|
80
|
-
output_items[key] = item
|
|
81
|
-
end
|
|
100
|
+
def chat_completions_web_search_items(response, model: nil)
|
|
101
|
+
return [] unless response["choices"] && chat_completions_search_model?(model)
|
|
82
102
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
dimension_key = dimension_key_for(item, request: request, model: model)
|
|
87
|
-
return nil unless dimension_key
|
|
88
|
-
|
|
89
|
-
provider_item_id = if dimension_key == "container_session"
|
|
90
|
-
item["container_id"] || item["id"]
|
|
91
|
-
else
|
|
92
|
-
item["id"]
|
|
93
|
-
end
|
|
94
|
-
Charges::LineItem.build(
|
|
95
|
-
dimension_key: dimension_key,
|
|
96
|
-
quantity: 1,
|
|
97
|
-
cost_status: Charges::CostStatus::UNKNOWN,
|
|
98
|
-
pricing_basis: "provider_usage",
|
|
99
|
-
provider_field: item["provider_field"] || "response.output.#{item['type']}",
|
|
100
|
-
provider_item_id: provider_item_id,
|
|
101
|
-
details: line_item_details(item)
|
|
102
|
-
)
|
|
103
|
-
end
|
|
103
|
+
[{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
|
|
104
|
+
"provider_field" => "request.model" }]
|
|
105
|
+
end
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return dimension unless web_search_preview_used?(request) || chat_completions_search_model?(model)
|
|
107
|
+
def priced_line_item(item, request:, model:)
|
|
108
|
+
item.line_item(priced_dimension(item.dimension, request: request, model: model))
|
|
109
|
+
end
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
def priced_dimension(dimension, request:, model:)
|
|
112
|
+
return dimension unless dimension == "web_search_request"
|
|
113
|
+
return dimension unless web_search_preview_used?(request) || chat_completions_search_model?(model)
|
|
114
|
+
return "web_search_preview_request_reasoning" if reasoning_model?(model)
|
|
112
115
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
dimension = Usage::Catalog[key]
|
|
116
|
-
key if dimension && dimension.token_key.nil?
|
|
117
|
-
end
|
|
116
|
+
"web_search_preview_request_non_reasoning"
|
|
117
|
+
end
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
def web_search_preview_used?(request)
|
|
120
|
+
tools = request && (request[:tools] || request["tools"])
|
|
121
|
+
Array(tools).any? do |tool|
|
|
122
|
+
type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
|
|
123
|
+
type.to_s.include?("web_search_preview")
|
|
124
|
+
end
|
|
124
125
|
end
|
|
125
|
-
end
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
def chat_completions_search_model?(model)
|
|
128
|
+
name = local_model_name(model)
|
|
129
|
+
name && ModelFamilies.chat_completions_search?(name)
|
|
130
|
+
end
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
132
|
+
def reasoning_model?(model)
|
|
133
|
+
name = local_model_name(model)
|
|
134
|
+
name && ModelFamilies.reasoning?(name)
|
|
135
|
+
end
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
def local_model_name(model)
|
|
138
|
+
return nil unless model
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
model.to_s.split("/", 2).last
|
|
141
|
+
end
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
status: item["status"],
|
|
146
|
-
action_type: item.dig("action", "type"),
|
|
147
|
-
container_id: item["container_id"]
|
|
148
|
-
}.compact
|
|
149
|
-
end
|
|
143
|
+
def unit_line_items(dimension_key, quantity, provider_field)
|
|
144
|
+
return [] unless quantity.is_a?(Numeric) && quantity.positive?
|
|
150
145
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
146
|
+
[Charges::LineItem.build(
|
|
147
|
+
dimension_key: dimension_key,
|
|
148
|
+
quantity: quantity,
|
|
149
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
150
|
+
pricing_basis: "provider_usage",
|
|
151
|
+
provider_field: provider_field
|
|
152
|
+
)]
|
|
156
153
|
end
|
|
157
|
-
line_items_from_output(output_items, request: request, model: model)
|
|
158
|
-
end
|
|
159
154
|
|
|
160
|
-
|
|
161
|
-
|
|
155
|
+
def billed_amount(usage)
|
|
156
|
+
cost = usage[:cost]
|
|
157
|
+
return reported_cost(usage, cost) if cost.is_a?(Numeric)
|
|
162
158
|
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
ticks = usage[:cost_in_usd_ticks]
|
|
160
|
+
return [BigDecimal(ticks.to_s) / TICKS_PER_USD, "usage.cost_in_usd_ticks"] if ticks.is_a?(Numeric)
|
|
165
161
|
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
total = cost[:total_cost] if cost.is_a?(Hash)
|
|
163
|
+
[BigDecimal(total.to_s), "usage.cost.total_cost"] if total.is_a?(Numeric) && total.positive?
|
|
164
|
+
end
|
|
168
165
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
provider_field: "usage.seconds",
|
|
175
|
-
details: { seconds: seconds }
|
|
176
|
-
)]
|
|
166
|
+
def reported_cost(usage, cost)
|
|
167
|
+
amounts = [cost]
|
|
168
|
+
amounts << usage.dig(:cost_details, :upstream_inference_cost) if usage[:is_byok]
|
|
169
|
+
[amounts.sum { |value| BigDecimal(value.to_s) }, "usage.cost"] if amounts.all?(Numeric)
|
|
170
|
+
end
|
|
177
171
|
end
|
|
178
172
|
end
|
|
179
173
|
end
|