llm_cost_tracker 0.14.0 → 0.14.2
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 +128 -0
- data/README.md +16 -5
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
- data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
- data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
- data/app/models/llm_cost_tracker/call.rb +10 -14
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
- data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- 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 +1 -4
- data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
- data/lib/llm_cost_tracker/budget.rb +44 -55
- data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
- data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
- data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
- data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
- 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/price_check.rb +14 -3
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +19 -2
- data/lib/llm_cost_tracker/engine.rb +0 -1
- data/lib/llm_cost_tracker/errors.rb +13 -0
- data/lib/llm_cost_tracker/event.rb +8 -0
- 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 +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -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 +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -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 +37 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
- data/lib/llm_cost_tracker/ingestion.rb +3 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
- data/lib/llm_cost_tracker/integrations/base.rb +56 -19
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
- data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +14 -13
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
- 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/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +3 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
- data/lib/llm_cost_tracker/parsers.rb +12 -41
- data/lib/llm_cost_tracker/prices.json +3071 -249
- data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
- data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
- 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 +36 -22
- data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
- data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/report/data.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
- data/lib/llm_cost_tracker/tracker.rb +17 -10
- 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 +61 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +4 -2
- data/lib/tasks/llm_cost_tracker.rake +26 -21
- data/llm_cost_tracker.gemspec +63 -0
- metadata +23 -10
|
@@ -27,39 +27,19 @@ module LlmCostTracker
|
|
|
27
27
|
line_items_from_output(output_items, request: request, model: model)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
|
|
31
|
-
CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
|
|
32
|
-
|
|
33
30
|
def chat_completions_web_search_items(response, model: nil)
|
|
34
|
-
return [] unless response["choices"]
|
|
35
|
-
|
|
36
|
-
provider_field = chat_completions_search_provider_field(response["choices"], model)
|
|
37
|
-
return [] unless provider_field
|
|
31
|
+
return [] unless response["choices"] && chat_completions_search_model?(model)
|
|
38
32
|
|
|
39
33
|
[{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
|
|
40
|
-
"provider_field" =>
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def chat_completions_search_provider_field(choices, model)
|
|
44
|
-
return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
|
|
45
|
-
return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
|
|
46
|
-
|
|
47
|
-
nil
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def chat_completions_used_web_search?(choices)
|
|
51
|
-
Array(choices).any? do |choice|
|
|
52
|
-
Array(choice.dig("message", "annotations")).any? do |annotation|
|
|
53
|
-
annotation.is_a?(Hash) && annotation["type"].to_s == "url_citation"
|
|
54
|
-
end
|
|
55
|
-
end
|
|
34
|
+
"provider_field" => "request.model" }]
|
|
56
35
|
end
|
|
57
36
|
|
|
58
37
|
def billable?(item)
|
|
59
38
|
return false unless item.is_a?(Hash)
|
|
60
39
|
|
|
61
|
-
dimension =
|
|
40
|
+
dimension = item_dimension(item)
|
|
62
41
|
return false unless dimension
|
|
42
|
+
return item["status"] == "completed" if dimension == "image_generation_call"
|
|
63
43
|
return true unless dimension == "web_search_request"
|
|
64
44
|
|
|
65
45
|
action_type = item.dig("action", "type")
|
|
@@ -69,11 +49,11 @@ module LlmCostTracker
|
|
|
69
49
|
def store_output_item(output_items, item)
|
|
70
50
|
return unless item.is_a?(Hash)
|
|
71
51
|
|
|
72
|
-
dimension =
|
|
52
|
+
dimension = item_dimension(item)
|
|
73
53
|
return unless dimension
|
|
74
54
|
|
|
75
|
-
key = if dimension == "container_session" && item
|
|
76
|
-
"#{dimension}:#{item
|
|
55
|
+
key = if dimension == "container_session" && container_id(item)
|
|
56
|
+
"#{dimension}:#{container_id(item)}"
|
|
77
57
|
else
|
|
78
58
|
item["id"] || "#{item['type']}:#{output_items.length}"
|
|
79
59
|
end
|
|
@@ -87,7 +67,7 @@ module LlmCostTracker
|
|
|
87
67
|
return nil unless dimension_key
|
|
88
68
|
|
|
89
69
|
provider_item_id = if dimension_key == "container_session"
|
|
90
|
-
item
|
|
70
|
+
container_id(item) || item["id"]
|
|
91
71
|
else
|
|
92
72
|
item["id"]
|
|
93
73
|
end
|
|
@@ -103,13 +83,28 @@ module LlmCostTracker
|
|
|
103
83
|
end
|
|
104
84
|
|
|
105
85
|
def dimension_key_for(item, request:, model:)
|
|
106
|
-
dimension =
|
|
86
|
+
dimension = item_dimension(item)
|
|
107
87
|
return dimension unless dimension == "web_search_request"
|
|
108
88
|
return dimension unless web_search_preview_used?(request) || chat_completions_search_model?(model)
|
|
109
89
|
|
|
110
90
|
reasoning_model?(model) ? "web_search_preview_request_reasoning" : "web_search_preview_request_non_reasoning"
|
|
111
91
|
end
|
|
112
92
|
|
|
93
|
+
def item_dimension(item)
|
|
94
|
+
return "container_session" if item["type"] == "shell_call" && shell_container_id(item)
|
|
95
|
+
|
|
96
|
+
output_dimension(item["type"])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def container_id(item)
|
|
100
|
+
item["container_id"] || shell_container_id(item)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def shell_container_id(item)
|
|
104
|
+
environment = item["environment"]
|
|
105
|
+
environment["container_id"] if environment.is_a?(Hash) && environment["type"] == "container_reference"
|
|
106
|
+
end
|
|
107
|
+
|
|
113
108
|
def output_dimension(type)
|
|
114
109
|
key = RESPONSE_OUTPUT_RENAMES[type] || type
|
|
115
110
|
dimension = Usage::Catalog[key]
|
|
@@ -144,19 +139,10 @@ module LlmCostTracker
|
|
|
144
139
|
{
|
|
145
140
|
status: item["status"],
|
|
146
141
|
action_type: item.dig("action", "type"),
|
|
147
|
-
container_id: item
|
|
142
|
+
container_id: container_id(item)
|
|
148
143
|
}.compact
|
|
149
144
|
end
|
|
150
145
|
|
|
151
|
-
def openai_stream_service_line_items(events, request: nil, model: nil)
|
|
152
|
-
output_items = []
|
|
153
|
-
each_event_data(events) do |data|
|
|
154
|
-
output_items.concat(Array(data.dig("response", "output")))
|
|
155
|
-
output_items << data["item"] if data["item"]
|
|
156
|
-
end
|
|
157
|
-
line_items_from_output(output_items, request: request, model: model)
|
|
158
|
-
end
|
|
159
|
-
|
|
160
146
|
def transcription_line_items(usage)
|
|
161
147
|
return [] unless usage
|
|
162
148
|
|
|
@@ -168,13 +154,44 @@ module LlmCostTracker
|
|
|
168
154
|
|
|
169
155
|
[Charges::LineItem.build(
|
|
170
156
|
dimension_key: "transcription_minute",
|
|
171
|
-
quantity: (seconds / 60
|
|
157
|
+
quantity: BigDecimal(seconds.to_s) / 60,
|
|
172
158
|
cost_status: Charges::CostStatus::UNKNOWN,
|
|
173
159
|
pricing_basis: "provider_usage",
|
|
174
160
|
provider_field: "usage.seconds",
|
|
175
161
|
details: { seconds: seconds }
|
|
176
162
|
)]
|
|
177
163
|
end
|
|
164
|
+
|
|
165
|
+
def speech_line_items(request)
|
|
166
|
+
input = request["input"]
|
|
167
|
+
return [] unless input.is_a?(String) && ModelFamilies.character_billed_tts?(request["model"])
|
|
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
|
|
177
|
+
|
|
178
|
+
# OpenRouter bills BYOK inference to the user's own provider key and puts only its fee in usage.cost.
|
|
179
|
+
def billed_line_items(usage)
|
|
180
|
+
amounts = [usage[:cost]]
|
|
181
|
+
amounts << usage.dig(:cost_details, :upstream_inference_cost) if usage[:is_byok]
|
|
182
|
+
return [] unless amounts.all?(Numeric)
|
|
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
|
+
)]
|
|
194
|
+
end
|
|
178
195
|
end
|
|
179
196
|
end
|
|
180
197
|
end
|
|
@@ -8,14 +8,21 @@ module LlmCostTracker
|
|
|
8
8
|
module UsageExtractor
|
|
9
9
|
INPUT_DETAIL_KEYS = %i[input_tokens_details input_token_details prompt_tokens_details].freeze
|
|
10
10
|
OUTPUT_DETAIL_KEYS = %i[output_tokens_details output_token_details completion_tokens_details].freeze
|
|
11
|
+
CACHED_MODALITY_DIMENSIONS = {
|
|
12
|
+
audio_tokens: "audio_cache_read_input", image_tokens: "image_cache_read_input"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
11
15
|
def self.token_usage(usage, model: nil)
|
|
12
16
|
input_tokens = (usage[:input_tokens] || usage[:prompt_tokens]).to_i
|
|
13
17
|
output_tokens = (usage[:output_tokens] || usage[:completion_tokens]).to_i
|
|
18
|
+
reasoning = hidden_output_tokens(usage)
|
|
19
|
+
output_tokens += reasoning if usage[:total_tokens].to_i == input_tokens + output_tokens + reasoning
|
|
14
20
|
cache_read = cache_read_input_tokens(usage)
|
|
15
21
|
cache_write = cache_write_input_tokens(usage)
|
|
16
|
-
|
|
22
|
+
uncached_input = [input_tokens - cache_read - cache_write, 0].max
|
|
23
|
+
image_input = uncached_input_tokens(usage, :image_tokens).clamp(0, uncached_input)
|
|
24
|
+
audio_input = uncached_input_tokens(usage, :audio_tokens).clamp(0, uncached_input - image_input)
|
|
17
25
|
audio_output = audio_output_tokens(usage)
|
|
18
|
-
image_input = image_input_tokens(usage)
|
|
19
26
|
image_output, regular_output = split_output(
|
|
20
27
|
output_tokens: output_tokens,
|
|
21
28
|
image_output_details: image_output_tokens(usage),
|
|
@@ -25,7 +32,7 @@ module LlmCostTracker
|
|
|
25
32
|
)
|
|
26
33
|
|
|
27
34
|
Usage::TokenUsage.build(
|
|
28
|
-
input_tokens:
|
|
35
|
+
input_tokens: uncached_input - audio_input - image_input,
|
|
29
36
|
output_tokens: regular_output,
|
|
30
37
|
total_tokens: usage[:total_tokens],
|
|
31
38
|
cache_read_input_tokens: cache_read,
|
|
@@ -34,10 +41,17 @@ module LlmCostTracker
|
|
|
34
41
|
audio_output_tokens: audio_output,
|
|
35
42
|
image_input_tokens: image_input,
|
|
36
43
|
image_output_tokens: image_output,
|
|
37
|
-
hidden_output_tokens:
|
|
44
|
+
hidden_output_tokens: reasoning
|
|
38
45
|
)
|
|
39
46
|
end
|
|
40
47
|
|
|
48
|
+
def self.cache_read_line_items(usage)
|
|
49
|
+
CACHED_MODALITY_DIMENSIONS.filter_map do |detail_key, dimension_key|
|
|
50
|
+
quantity = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens_details, detail_key)
|
|
51
|
+
Charges::LineItem.build(dimension_key: dimension_key, quantity: quantity) if quantity.positive?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
41
55
|
def self.split_output(output_tokens:,
|
|
42
56
|
image_output_details:,
|
|
43
57
|
text_output_details:,
|
|
@@ -60,9 +74,13 @@ module LlmCostTracker
|
|
|
60
74
|
def self.image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
|
|
61
75
|
def self.text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
|
|
62
76
|
|
|
63
|
-
def self.
|
|
77
|
+
def self.uncached_input_tokens(usage, key)
|
|
78
|
+
detail(usage, INPUT_DETAIL_KEYS, key) - detail(usage, INPUT_DETAIL_KEYS, :cached_tokens_details, key)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.detail(usage, containers, *path)
|
|
64
82
|
containers.each do |container|
|
|
65
|
-
value = usage.dig(container,
|
|
83
|
+
value = usage.dig(container, *path)
|
|
66
84
|
return value.to_i if value
|
|
67
85
|
end
|
|
68
86
|
0
|
|
@@ -10,7 +10,9 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
12
|
def match?(url)
|
|
13
|
-
|
|
13
|
+
uri_matches?(url) do |uri|
|
|
14
|
+
TRACKED_PATH_SUFFIXES.any? { |suffix| uri.path.to_s.end_with?(suffix) } && !provider_for_uri(uri).nil?
|
|
15
|
+
end
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def provider_names
|
|
@@ -30,6 +32,10 @@ module LlmCostTracker
|
|
|
30
32
|
def provider_for(request_url)
|
|
31
33
|
self.class.provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
|
|
32
34
|
end
|
|
35
|
+
|
|
36
|
+
def auto_enable_stream_usage?(request_url, request_parsed)
|
|
37
|
+
super && Configuration::Capture::OPENAI_COMPATIBLE_PROVIDERS.key?(parsed_uri(request_url).host.to_s.downcase)
|
|
38
|
+
end
|
|
33
39
|
end
|
|
34
40
|
end
|
|
35
41
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Redaction
|
|
5
|
+
REDACTED = "[REDACTED]"
|
|
6
|
+
|
|
7
|
+
TOKEN = Regexp.union(
|
|
8
|
+
/sk-(?:ant-|admin-|proj-|svcacct-|live-|test-)?[A-Za-z0-9_-]{16,}/,
|
|
9
|
+
/AKIA[0-9A-Z]{16}/,
|
|
10
|
+
/gh[opsur]_[A-Za-z0-9]{16,}/,
|
|
11
|
+
/github_pat_[A-Za-z0-9_]{20,}/,
|
|
12
|
+
/eyj[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/i,
|
|
13
|
+
/bearer\s+[A-Za-z0-9_.-]{20,}/i,
|
|
14
|
+
/xox[abprs]-[A-Za-z0-9-]{10,}/,
|
|
15
|
+
/(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}/,
|
|
16
|
+
/AIza[0-9A-Za-z_-]{35}/
|
|
17
|
+
)
|
|
18
|
+
EMBEDDED_TOKEN = /(?<![A-Za-z0-9_-])(?:#{TOKEN})(?![A-Za-z0-9_-])/
|
|
19
|
+
PARAM = /(?<![A-Za-z0-9])((?:api)?key|token|secret|password|sig(?:nature)?)=[^&\s"']+/i
|
|
20
|
+
HEADER = /((?:authorization|api-key)["']?\s*(?:=>|:)\s*["']?)(?:[A-Za-z]+\s+)?[^\s"',;]+/i
|
|
21
|
+
USERINFO = %r{://[^/\s@?#]+@}
|
|
22
|
+
private_constant :TOKEN, :EMBEDDED_TOKEN, :PARAM, :HEADER, :USERINFO
|
|
23
|
+
|
|
24
|
+
def self.text(value)
|
|
25
|
+
value.to_s.scrub
|
|
26
|
+
.gsub(USERINFO, "://#{REDACTED}@")
|
|
27
|
+
.gsub(HEADER, "\\1#{REDACTED}")
|
|
28
|
+
.gsub(PARAM, "\\1=#{REDACTED}")
|
|
29
|
+
.gsub(EMBEDDED_TOKEN, REDACTED)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -34,7 +34,7 @@ module LlmCostTracker
|
|
|
34
34
|
end
|
|
35
35
|
from = now - days.days
|
|
36
36
|
scope = LlmCostTracker::Call.where(tracked_at: from..now)
|
|
37
|
-
tag_breakdowns ||= LlmCostTracker.configuration.tags.report_breakdown_keys
|
|
37
|
+
tag_breakdowns ||= LlmCostTracker.configuration.tags.report_breakdown_keys
|
|
38
38
|
aggregate = totals(scope)
|
|
39
39
|
|
|
40
40
|
new(
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Retention
|
|
5
5
|
DEFAULT_BATCH_SIZE = 5_000
|
|
6
|
-
ROLLUP_COLUMNS = %i[tracked_at total_cost pricing_snapshot provider].freeze
|
|
7
|
-
private_constant :ROLLUP_COLUMNS
|
|
8
6
|
|
|
9
7
|
class << self
|
|
10
8
|
def prune(older_than:, batch_size: DEFAULT_BATCH_SIZE, now: Time.now.utc)
|
|
@@ -84,7 +82,7 @@ module LlmCostTracker
|
|
|
84
82
|
def prunable_rows(cutoff, batch_size)
|
|
85
83
|
relation = LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
|
|
86
84
|
columns = [:id]
|
|
87
|
-
columns +=
|
|
85
|
+
columns += LlmCostTracker::Ledger::Rollups::SOURCE_COLUMNS if LlmCostTracker::Ledger::Rollups.cache_active?
|
|
88
86
|
relation.select(*columns).to_a
|
|
89
87
|
end
|
|
90
88
|
end
|
|
@@ -2,23 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/string/inflections"
|
|
4
4
|
|
|
5
|
+
require_relative "../ledger/storable"
|
|
6
|
+
require_relative "../redaction"
|
|
7
|
+
|
|
5
8
|
module LlmCostTracker
|
|
6
9
|
module Tags
|
|
7
10
|
module Sanitizer
|
|
8
|
-
REDACTED_VALUE =
|
|
9
|
-
|
|
10
|
-
SECRET_VALUE_PATTERNS = [
|
|
11
|
-
/\Ask-(?:ant-|admin-|proj-|svcacct-|live-|test-)?[A-Za-z0-9_-]{16,}\z/,
|
|
12
|
-
/\AAKIA[0-9A-Z]{16}\z/,
|
|
13
|
-
/\Agh[opsur]_[A-Za-z0-9]{16,}\z/,
|
|
14
|
-
/\Agithub_pat_[A-Za-z0-9_]{20,}\z/,
|
|
15
|
-
/\Aeyj[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\z/i,
|
|
16
|
-
/\Abearer\s+[A-Za-z0-9_.-]{20,}\z/i,
|
|
17
|
-
/\Axox[abprs]-[A-Za-z0-9-]{10,}\z/,
|
|
18
|
-
/\A(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}\z/,
|
|
19
|
-
/\AAIza[0-9A-Za-z_-]{35}\z/
|
|
20
|
-
].freeze
|
|
21
|
-
private_constant :SECRET_VALUE_PATTERNS
|
|
11
|
+
REDACTED_VALUE = Redaction::REDACTED
|
|
22
12
|
|
|
23
13
|
class << self
|
|
24
14
|
def call(tags, config: LlmCostTracker.configuration)
|
|
@@ -58,15 +48,9 @@ module LlmCostTracker
|
|
|
58
48
|
def sanitized_value(key, value, redacted, limit)
|
|
59
49
|
return REDACTED_VALUE if redacted_key?(key, redacted)
|
|
60
50
|
|
|
61
|
-
|
|
62
|
-
return REDACTED_VALUE if scrubbed.equal?(REDACTED_SENTINEL)
|
|
63
|
-
|
|
64
|
-
scalar_truncate(scrubbed, limit)
|
|
51
|
+
scalar_truncate(scrub_secrets(Ledger::Storable.clean(value)), limit)
|
|
65
52
|
end
|
|
66
53
|
|
|
67
|
-
REDACTED_SENTINEL = Object.new.freeze
|
|
68
|
-
private_constant :REDACTED_SENTINEL
|
|
69
|
-
|
|
70
54
|
def scalar_truncate(value, limit)
|
|
71
55
|
case value
|
|
72
56
|
when Hash
|
|
@@ -86,26 +70,16 @@ module LlmCostTracker
|
|
|
86
70
|
def scrub_secrets(value)
|
|
87
71
|
case value
|
|
88
72
|
when Hash
|
|
89
|
-
value.
|
|
90
|
-
scrubbed = scrub_secrets(nested)
|
|
91
|
-
out[key] = scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
|
|
92
|
-
end
|
|
73
|
+
value.transform_values { |nested| scrub_secrets(nested) }
|
|
93
74
|
when Array
|
|
94
|
-
value.map
|
|
95
|
-
scrubbed = scrub_secrets(nested)
|
|
96
|
-
scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
|
|
97
|
-
end
|
|
75
|
+
value.map { |nested| scrub_secrets(nested) }
|
|
98
76
|
else
|
|
99
|
-
|
|
77
|
+
string = value.to_s
|
|
78
|
+
scrubbed = Redaction.text(string)
|
|
79
|
+
scrubbed == string ? value : scrubbed
|
|
100
80
|
end
|
|
101
81
|
end
|
|
102
82
|
|
|
103
|
-
def secret_shaped?(string)
|
|
104
|
-
return false if string.bytesize < 16
|
|
105
|
-
|
|
106
|
-
SECRET_VALUE_PATTERNS.any? { |pattern| pattern.match?(string) }
|
|
107
|
-
end
|
|
108
|
-
|
|
109
83
|
def redacted_key?(key, redacted)
|
|
110
84
|
return false if redacted.empty?
|
|
111
85
|
|
|
@@ -27,11 +27,6 @@ module LlmCostTracker
|
|
|
27
27
|
|
|
28
28
|
tags = build_tags(context_tags: context_tags, metadata: metadata)
|
|
29
29
|
|
|
30
|
-
if calculation.token_cost.nil? && event.token_usage.total_tokens.positive? &&
|
|
31
|
-
calculation.priced_line_items.none?(&:priced?)
|
|
32
|
-
Pricing::Unknown.process(event.model, pricing_mode: calculation.mode)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
30
|
event = build_event(event: event, calculation: calculation, tags: tags, latency_ms: latency_ms)
|
|
36
31
|
|
|
37
32
|
if Ingestion.async?
|
|
@@ -44,20 +39,32 @@ module LlmCostTracker
|
|
|
44
39
|
yield if block_given?
|
|
45
40
|
notify_subscribers(event)
|
|
46
41
|
behavior_override = :raise if enforce_budget
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
begin
|
|
43
|
+
signal_unpriced(event, calculation)
|
|
44
|
+
ensure
|
|
45
|
+
Budget.check!(event, behavior_override: behavior_override)
|
|
46
|
+
end
|
|
49
47
|
|
|
50
48
|
event
|
|
51
49
|
end
|
|
52
50
|
|
|
53
51
|
def build_tags(context_tags:, metadata:)
|
|
54
52
|
resolved = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
|
|
55
|
-
|
|
56
|
-
LlmCostTracker::Tags::Sanitizer.cap(
|
|
53
|
+
merged = resolved.merge(LlmCostTracker::Tags::Sanitizer.call(metadata.to_h))
|
|
54
|
+
LlmCostTracker::Tags::Sanitizer.cap(merged.to_a.reverse.uniq { |key, _| key.to_s }.reverse.to_h).freeze
|
|
57
55
|
end
|
|
58
56
|
|
|
59
57
|
private
|
|
60
58
|
|
|
59
|
+
def signal_unpriced(event, calculation)
|
|
60
|
+
iterations, lines = calculation.priced_line_items.partition { |line| line.kind == "model_iteration" }
|
|
61
|
+
models = iterations.select(&:unpriced?).map { |line| line.details[:model] }
|
|
62
|
+
if calculation.token_cost.nil? && event.token_usage.total_tokens.positive? && lines.none?(&:priced?)
|
|
63
|
+
models.unshift(event.model)
|
|
64
|
+
end
|
|
65
|
+
models.each { |model| Pricing::Unknown.process(model, pricing_mode: calculation.mode) }
|
|
66
|
+
end
|
|
67
|
+
|
|
61
68
|
def notify_subscribers(event)
|
|
62
69
|
return unless ActiveSupport::Notifications.notifier.listening?(EVENT_NAME)
|
|
63
70
|
|
|
@@ -68,7 +75,7 @@ module LlmCostTracker
|
|
|
68
75
|
|
|
69
76
|
def build_event(event:, calculation:, tags:, latency_ms:)
|
|
70
77
|
event.with(
|
|
71
|
-
event_id: SecureRandom.uuid,
|
|
78
|
+
event_id: event.event_id || SecureRandom.uuid,
|
|
72
79
|
pricing_mode: calculation.mode,
|
|
73
80
|
cost: calculation.cost,
|
|
74
81
|
tags: tags,
|
|
@@ -27,7 +27,7 @@ module LlmCostTracker
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def token_priced
|
|
30
|
-
@token_priced ||= all.select
|
|
30
|
+
@token_priced ||= all.select { |dimension| dimension.token_key && dimension.parent.nil? }.freeze
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def find_by(kind:, direction:, modality:, cache_state:, unit:)
|
|
@@ -35,8 +35,17 @@ module LlmCostTracker
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def token_priced_for(kind:, direction:, cache_state:)
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
dimension = all.find do |candidate|
|
|
39
|
+
candidate.token? && candidate.kind == kind && candidate.direction == direction &&
|
|
40
|
+
candidate.cache_state == cache_state
|
|
41
|
+
end
|
|
42
|
+
dimension&.parent ? fetch(dimension.parent) : dimension
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def costs_by_component(rows)
|
|
46
|
+
rows.each_with_object({}) do |(kind, direction, cache_state, cost), totals|
|
|
47
|
+
component = token_priced_for(kind: kind, direction: direction, cache_state: cache_state)
|
|
48
|
+
totals[component.key] = totals.fetch(component.key, 0) + cost if component && cost
|
|
40
49
|
end
|
|
41
50
|
end
|
|
42
51
|
|
|
@@ -60,7 +69,7 @@ module LlmCostTracker
|
|
|
60
69
|
|
|
61
70
|
def build(attributes)
|
|
62
71
|
rate_basis = attributes[:rate_basis] || DEFAULT_RATE_BASIS_BY_UNIT.fetch(attributes.fetch(:unit))
|
|
63
|
-
Dimension.new(**attributes, rate_basis: rate_basis)
|
|
72
|
+
Dimension.new(parent: nil, **attributes, rate_basis: rate_basis)
|
|
64
73
|
end
|
|
65
74
|
end
|
|
66
75
|
end
|
|
@@ -61,6 +61,30 @@
|
|
|
61
61
|
cache_state: none
|
|
62
62
|
unit: token
|
|
63
63
|
|
|
64
|
+
- key: audio_cache_read_input
|
|
65
|
+
kind: audio_token
|
|
66
|
+
direction: input
|
|
67
|
+
modality: audio
|
|
68
|
+
cache_state: read
|
|
69
|
+
unit: token
|
|
70
|
+
parent: cache_read_input
|
|
71
|
+
|
|
72
|
+
- key: image_cache_read_input
|
|
73
|
+
kind: image_token
|
|
74
|
+
direction: input
|
|
75
|
+
modality: image
|
|
76
|
+
cache_state: read
|
|
77
|
+
unit: token
|
|
78
|
+
parent: cache_read_input
|
|
79
|
+
|
|
80
|
+
- key: video_input
|
|
81
|
+
kind: video_token
|
|
82
|
+
direction: input
|
|
83
|
+
modality: video
|
|
84
|
+
cache_state: none
|
|
85
|
+
unit: token
|
|
86
|
+
parent: input
|
|
87
|
+
|
|
64
88
|
- key: web_search_request
|
|
65
89
|
kind: web_search_request
|
|
66
90
|
direction: neither
|
|
@@ -101,6 +125,13 @@
|
|
|
101
125
|
unit: request
|
|
102
126
|
rate_basis: per_1k_requests
|
|
103
127
|
|
|
128
|
+
- key: image_generation_call
|
|
129
|
+
kind: image_generation_call
|
|
130
|
+
direction: output
|
|
131
|
+
modality: image
|
|
132
|
+
cache_state: none
|
|
133
|
+
unit: request
|
|
134
|
+
|
|
104
135
|
- key: container_session
|
|
105
136
|
kind: container_session
|
|
106
137
|
direction: neither
|
|
@@ -125,6 +156,22 @@
|
|
|
125
156
|
unit: request
|
|
126
157
|
rate_basis: per_1k_requests
|
|
127
158
|
|
|
159
|
+
- key: maps_grounding_request
|
|
160
|
+
kind: maps_grounding_request
|
|
161
|
+
direction: neither
|
|
162
|
+
modality: text
|
|
163
|
+
cache_state: none
|
|
164
|
+
unit: request
|
|
165
|
+
rate_basis: per_1k_requests
|
|
166
|
+
|
|
167
|
+
- key: cache_storage_token_hour
|
|
168
|
+
kind: cache_storage_token_hour
|
|
169
|
+
direction: neither
|
|
170
|
+
modality: none
|
|
171
|
+
cache_state: none
|
|
172
|
+
unit: token_hour
|
|
173
|
+
rate_basis: per_million_token_hours
|
|
174
|
+
|
|
128
175
|
- key: text_to_speech_character
|
|
129
176
|
kind: text_to_speech_character
|
|
130
177
|
direction: output
|
|
@@ -140,3 +187,17 @@
|
|
|
140
187
|
cache_state: none
|
|
141
188
|
unit: minute
|
|
142
189
|
rate_basis: per_minute
|
|
190
|
+
|
|
191
|
+
- key: billed_request
|
|
192
|
+
kind: billed_request
|
|
193
|
+
direction: neither
|
|
194
|
+
modality: none
|
|
195
|
+
cache_state: none
|
|
196
|
+
unit: request
|
|
197
|
+
|
|
198
|
+
- key: model_iteration
|
|
199
|
+
kind: model_iteration
|
|
200
|
+
direction: neither
|
|
201
|
+
modality: none
|
|
202
|
+
cache_state: none
|
|
203
|
+
unit: request
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -57,6 +57,7 @@ module LlmCostTracker
|
|
|
57
57
|
yield(config)
|
|
58
58
|
config.finalize!
|
|
59
59
|
Pricing::Registry.reset!
|
|
60
|
+
Pricing::Registry.validate_file!(config.pricing.file)
|
|
60
61
|
Integrations.install!
|
|
61
62
|
config
|
|
62
63
|
end
|
|
@@ -128,10 +129,11 @@ module LlmCostTracker
|
|
|
128
129
|
metadata: tags
|
|
129
130
|
)
|
|
130
131
|
yield collector
|
|
131
|
-
|
|
132
|
-
rescue StandardError
|
|
132
|
+
rescue Exception # rubocop:disable Lint/RescueException -- record the spend and keep the caller's exception
|
|
133
133
|
collector&.finish!(errored: true)
|
|
134
134
|
raise
|
|
135
|
+
else
|
|
136
|
+
collector.finish!
|
|
135
137
|
end
|
|
136
138
|
end
|
|
137
139
|
end
|