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,11 +11,18 @@ module LlmCostTracker
|
|
|
11
11
|
module Providers
|
|
12
12
|
module Openai
|
|
13
13
|
module ResponseParser
|
|
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}
|
|
17
|
+
|
|
14
18
|
class << self
|
|
19
|
+
def included(base) = base.include(StreamParser)
|
|
20
|
+
|
|
15
21
|
def combined_pricing_mode(host:, model:, service_tier:, provider: "openai")
|
|
16
22
|
modes = [Pricing::Mode.normalize(service_tier)]
|
|
17
|
-
if Hosts.
|
|
18
|
-
|
|
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:)
|
|
19
26
|
modes << "data_residency"
|
|
20
27
|
end
|
|
21
28
|
Pricing::Mode.compose(modes)
|
|
@@ -23,33 +30,23 @@ module LlmCostTracker
|
|
|
23
30
|
|
|
24
31
|
def event_from_response(response:, request:, provider:, host:, usage_source:, pricing_mode: nil)
|
|
25
32
|
usage = response["usage"]&.deep_symbolize_keys
|
|
26
|
-
return nil
|
|
33
|
+
return nil if usage.nil? || pending?(response)
|
|
27
34
|
|
|
28
35
|
model = response["model"] || request["model"]
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
ServiceCharges.billed_line_items(usage) +
|
|
33
|
-
UsageExtractor.cache_read_line_items(usage)
|
|
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
|
-
provider: provider,
|
|
39
|
-
host: host,
|
|
40
|
-
model: model,
|
|
41
|
-
service_tier: response["service_tier"] || usage[:service_tier] || request["service_tier"]
|
|
42
|
-
),
|
|
43
41
|
model: model,
|
|
44
|
-
token_usage: UsageExtractor.token_usage(usage, model: model),
|
|
45
42
|
usage_source: usage_source,
|
|
46
|
-
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:)
|
|
47
45
|
)
|
|
48
46
|
end
|
|
49
47
|
|
|
50
48
|
def retrieved_event(response:, provider:, host:, usage_source:)
|
|
51
|
-
|
|
52
|
-
return nil unless finished && response["background"] && response["usage"]
|
|
49
|
+
return nil unless response["background"] && response["usage"] && !pending?(response)
|
|
53
50
|
return nil if Call.already_recorded?(provider: provider, provider_response_id: response["id"])
|
|
54
51
|
|
|
55
52
|
event_from_response(
|
|
@@ -60,168 +57,103 @@ module LlmCostTracker
|
|
|
60
57
|
usage_source: usage_source
|
|
61
58
|
)&.keyed_by_response_id
|
|
62
59
|
end
|
|
60
|
+
|
|
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
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def pending?(response)
|
|
74
|
+
PENDING_STATUSES.include?(response["status"].to_s)
|
|
75
|
+
end
|
|
63
76
|
end
|
|
64
77
|
|
|
65
78
|
def parse(request_url:, request_body:, response_status:, response_body:, **)
|
|
66
79
|
return nil unless response_status == 200
|
|
67
80
|
|
|
68
81
|
response = safe_json_parse(response_body)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
provider: provider_for(request_url),
|
|
74
|
-
host: host,
|
|
75
|
-
usage_source: Usage::Source::RESPONSE
|
|
76
|
-
)
|
|
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)
|
|
77
86
|
end
|
|
78
87
|
|
|
79
88
|
request = safe_json_parse(request_body)
|
|
80
|
-
ResponseParser.event_from_response(
|
|
81
|
-
|
|
82
|
-
request: request,
|
|
83
|
-
provider: provider_for(request_url),
|
|
84
|
-
host: host,
|
|
85
|
-
usage_source: Usage::Source::RESPONSE
|
|
86
|
-
) || speech_event(request_url, request) || transcription_without_usage_event(request_url, request)
|
|
89
|
+
ResponseParser.event_from_response(response: response, request: request, **source) ||
|
|
90
|
+
usage_free_event(request_url, request, response)
|
|
87
91
|
end
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
return nil unless response_status == 200
|
|
91
|
-
|
|
92
|
-
request = safe_json_parse(request_body)
|
|
93
|
-
usage = detect_stream_usage(events)
|
|
94
|
-
context = stream_capture_context(events: events, request: request, request_url: request_url, usage: usage)
|
|
93
|
+
private
|
|
95
94
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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"))
|
|
100
104
|
end
|
|
101
|
-
|
|
102
|
-
warn_missing_stream_usage(request_url: request_url, request: request)
|
|
103
|
-
build_unknown_stream_usage(**context, service_line_items: background ? [] : context[:service_line_items])
|
|
104
105
|
end
|
|
105
106
|
|
|
106
|
-
def
|
|
107
|
-
|
|
107
|
+
def speech_event(request_url, request)
|
|
108
|
+
line_item_event(request_url, model_for(request_url, request), ServiceCharges.speech_line_items(request))
|
|
108
109
|
end
|
|
109
110
|
|
|
110
|
-
def
|
|
111
|
-
|
|
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)
|
|
112
115
|
end
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
Event.build(
|
|
121
|
-
provider: provider_for(request_url),
|
|
122
|
-
model: model_for(request_url, request),
|
|
123
|
-
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0),
|
|
124
|
-
usage_source: Usage::Source::RESPONSE,
|
|
125
|
-
service_line_items: ServiceCharges.speech_line_items(request)
|
|
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
|
|
126
123
|
)
|
|
127
124
|
end
|
|
128
125
|
|
|
129
|
-
def
|
|
130
|
-
|
|
131
|
-
return nil
|
|
132
|
-
|
|
133
|
-
Event.build(
|
|
134
|
-
provider: provider_for(request_url),
|
|
135
|
-
model: model_for(request_url, request) || Event::UNKNOWN_MODEL,
|
|
136
|
-
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0),
|
|
137
|
-
usage_source: Usage::Source::UNKNOWN
|
|
138
|
-
)
|
|
139
|
-
end
|
|
126
|
+
def ocr_event(request_url, request, response)
|
|
127
|
+
line_items = ServiceCharges.ocr_line_items(response)
|
|
128
|
+
return nil if line_items.empty?
|
|
140
129
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
end || request["model"]
|
|
145
|
-
provider = provider_for(request_url)
|
|
146
|
-
{
|
|
147
|
-
provider: provider,
|
|
130
|
+
model = response["model"] || model_for(request_url, request)
|
|
131
|
+
zero_token_event(
|
|
132
|
+
request_url,
|
|
148
133
|
model: model,
|
|
149
|
-
provider_response_id: find_event_value(events) do |data|
|
|
150
|
-
data["id"] || data.dig("response", "id") || data.dig("chunk", "id")
|
|
151
|
-
end,
|
|
152
134
|
pricing_mode: ResponseParser.combined_pricing_mode(
|
|
153
|
-
provider:
|
|
154
|
-
host: parsed_uri(request_url)&.host,
|
|
155
|
-
model: model,
|
|
156
|
-
service_tier: stream_pricing_mode(events) || usage&.dig(:service_tier) || request["service_tier"]
|
|
135
|
+
provider: provider_for(request_url), host: parsed_uri(request_url)&.host, model: model, service_tier: nil
|
|
157
136
|
),
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def build_known_stream_usage(usage:,
|
|
163
|
-
provider:,
|
|
164
|
-
model:,
|
|
165
|
-
provider_response_id:,
|
|
166
|
-
pricing_mode:,
|
|
167
|
-
service_line_items:)
|
|
168
|
-
Event.build(
|
|
169
|
-
provider: provider,
|
|
170
|
-
provider_response_id: provider_response_id,
|
|
171
|
-
pricing_mode: pricing_mode,
|
|
172
|
-
model: model,
|
|
173
|
-
token_usage: UsageExtractor.token_usage(usage, model: model),
|
|
174
|
-
stream: true,
|
|
175
|
-
usage_source: Usage::Source::STREAM_FINAL,
|
|
176
|
-
service_line_items: service_line_items + ServiceCharges.transcription_line_items(usage) +
|
|
177
|
-
ServiceCharges.billed_line_items(usage) + UsageExtractor.cache_read_line_items(usage)
|
|
137
|
+
usage_source: Usage::Source::RESPONSE,
|
|
138
|
+
service_line_items: line_items
|
|
178
139
|
)
|
|
179
140
|
end
|
|
180
141
|
|
|
181
|
-
def
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
"OpenAI-compatible chat-completions stream finished without a final usage chunk. " \
|
|
188
|
-
"Set `stream_options: { include_usage: true }` in your request body so the gem can " \
|
|
189
|
-
"record token counts. This call was stored with usage_source=#{Usage::Source::UNKNOWN}."
|
|
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
|
|
190
148
|
)
|
|
191
149
|
end
|
|
192
150
|
|
|
193
|
-
def
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
usage = find_event_value(events, reverse: true) do |data|
|
|
200
|
-
candidate = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage") ||
|
|
201
|
-
data.dig("x_groq", "usage") || data.dig("chunk", "x_groq", "usage")
|
|
202
|
-
candidate if candidate.is_a?(Hash)
|
|
203
|
-
end
|
|
204
|
-
usage&.deep_symbolize_keys
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def stream_pricing_mode(events)
|
|
208
|
-
find_event_value(events, reverse: true) do |data|
|
|
209
|
-
data["service_tier"] || data.dig("response", "service_tier") || data.dig("chunk", "service_tier")
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def openai_stream_service_line_items(events, request: nil, model: nil)
|
|
214
|
-
response = { "output" => [] }
|
|
215
|
-
each_event_data(events) do |data|
|
|
216
|
-
response["output"].concat(Array(data.dig("response", "output")))
|
|
217
|
-
response["output"] << data["item"] if data["item"]
|
|
218
|
-
chunk = data["chunk"] || data
|
|
219
|
-
next unless chunk["choices"].is_a?(Array)
|
|
220
|
-
|
|
221
|
-
response["id"] ||= chunk["id"]
|
|
222
|
-
response["choices"] ||= chunk["choices"]
|
|
223
|
-
end
|
|
224
|
-
ServiceCharges.service_line_items_for(response, request: request, model: model)
|
|
151
|
+
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
|
+
)
|
|
225
157
|
end
|
|
226
158
|
end
|
|
227
159
|
end
|
|
@@ -1,196 +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
|
-
|
|
35
|
-
|
|
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
|
|
36
27
|
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
def billable?(item)
|
|
29
|
+
OutputItem.wrap(item)&.billable? || false
|
|
30
|
+
end
|
|
39
31
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
def billing_fields(item)
|
|
38
|
+
OutputItem.wrap(item)&.billing_fields
|
|
39
|
+
end
|
|
48
40
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
)]
|
|
69
|
+
end
|
|
54
70
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
item["id"] || "#{item['type']}:#{output_items.length}"
|
|
59
|
-
end
|
|
60
|
-
output_items[key] = item
|
|
61
|
-
end
|
|
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
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
dimension_key = dimension_key_for(item, request: request, model: model)
|
|
67
|
-
return nil unless dimension_key
|
|
68
|
-
|
|
69
|
-
provider_item_id = if dimension_key == "container_session"
|
|
70
|
-
container_id(item) || item["id"]
|
|
71
|
-
else
|
|
72
|
-
item["id"]
|
|
73
|
-
end
|
|
74
|
-
Charges::LineItem.build(
|
|
75
|
-
dimension_key: dimension_key,
|
|
76
|
-
quantity: 1,
|
|
77
|
-
cost_status: Charges::CostStatus::UNKNOWN,
|
|
78
|
-
pricing_basis: "provider_usage",
|
|
79
|
-
provider_field: item["provider_field"] || "response.output.#{item['type']}",
|
|
80
|
-
provider_item_id: provider_item_id,
|
|
81
|
-
details: line_item_details(item)
|
|
82
|
-
)
|
|
83
|
-
end
|
|
75
|
+
unit_line_items("ocr_page", response.dig("meta", "billed_units", "pages"), "meta.billed_units.pages")
|
|
76
|
+
end
|
|
84
77
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
89
82
|
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
return "container_session" if item["type"] == "shell_call" && shell_container_id(item)
|
|
98
|
+
private
|
|
95
99
|
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
def chat_completions_web_search_items(response, model: nil)
|
|
101
|
+
return [] unless response["choices"] && chat_completions_search_model?(model)
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
[{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
|
|
104
|
+
"provider_field" => "request.model" }]
|
|
105
|
+
end
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
end
|
|
107
|
+
def priced_line_item(item, request:, model:)
|
|
108
|
+
item.line_item(priced_dimension(item.dimension, request: request, model: model))
|
|
109
|
+
end
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
end
|
|
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)
|
|
113
115
|
|
|
114
|
-
|
|
115
|
-
tools = request && (request[:tools] || request["tools"])
|
|
116
|
-
Array(tools).any? do |tool|
|
|
117
|
-
type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
|
|
118
|
-
type.to_s.include?("web_search_preview")
|
|
116
|
+
"web_search_preview_request_non_reasoning"
|
|
119
117
|
end
|
|
120
|
-
end
|
|
121
118
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
-
|
|
132
|
+
def reasoning_model?(model)
|
|
133
|
+
name = local_model_name(model)
|
|
134
|
+
name && ModelFamilies.reasoning?(name)
|
|
135
|
+
end
|
|
134
136
|
|
|
135
|
-
model
|
|
136
|
-
|
|
137
|
+
def local_model_name(model)
|
|
138
|
+
return nil unless model
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
status: item["status"],
|
|
141
|
-
action_type: item.dig("action", "type"),
|
|
142
|
-
container_id: container_id(item)
|
|
143
|
-
}.compact
|
|
144
|
-
end
|
|
140
|
+
model.to_s.split("/", 2).last
|
|
141
|
+
end
|
|
145
142
|
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
def unit_line_items(dimension_key, quantity, provider_field)
|
|
144
|
+
return [] unless quantity.is_a?(Numeric) && quantity.positive?
|
|
148
145
|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
)]
|
|
153
|
+
end
|
|
151
154
|
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
def billed_amount(usage)
|
|
156
|
+
cost = usage[:cost]
|
|
157
|
+
return reported_cost(usage, cost) if cost.is_a?(Numeric)
|
|
154
158
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
quantity: BigDecimal(seconds.to_s) / 60,
|
|
158
|
-
cost_status: Charges::CostStatus::UNKNOWN,
|
|
159
|
-
pricing_basis: "provider_usage",
|
|
160
|
-
provider_field: "usage.seconds",
|
|
161
|
-
details: { seconds: seconds }
|
|
162
|
-
)]
|
|
163
|
-
end
|
|
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)
|
|
164
161
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
[Charges::LineItem.build(
|
|
170
|
-
dimension_key: "text_to_speech_character",
|
|
171
|
-
quantity: input.length,
|
|
172
|
-
cost_status: Charges::CostStatus::UNKNOWN,
|
|
173
|
-
pricing_basis: "provider_usage",
|
|
174
|
-
provider_field: "request.input"
|
|
175
|
-
)]
|
|
176
|
-
end
|
|
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
|
|
177
165
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
amount = amounts.sum { |value| BigDecimal(value.to_s) }
|
|
185
|
-
[Charges::LineItem.build(
|
|
186
|
-
dimension_key: "billed_request",
|
|
187
|
-
quantity: 1,
|
|
188
|
-
rate_amount: amount,
|
|
189
|
-
cost: amount,
|
|
190
|
-
pricing_basis: "provider_usage",
|
|
191
|
-
price_source: "provider_response",
|
|
192
|
-
provider_field: "usage.cost"
|
|
193
|
-
)]
|
|
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
|
|
194
171
|
end
|
|
195
172
|
end
|
|
196
173
|
end
|