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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/hash/keys"
|
|
4
|
+
|
|
5
|
+
require_relative "response_parser"
|
|
6
|
+
require_relative "service_charges"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Providers
|
|
10
|
+
module Openai
|
|
11
|
+
module StreamParser
|
|
12
|
+
RETAINED_RESPONSE_FIELDS = %w[id model service_tier background usage].freeze
|
|
13
|
+
|
|
14
|
+
def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
|
|
15
|
+
return nil unless response_status == 200
|
|
16
|
+
|
|
17
|
+
request = safe_json_parse(request_body)
|
|
18
|
+
usage = stream_usage(events)
|
|
19
|
+
attributes = stream_attributes(events, request: request, request_url: request_url, usage: usage)
|
|
20
|
+
background = find_event_value(events) { |data| data.dig("response", "background") }
|
|
21
|
+
if usage
|
|
22
|
+
event = ResponseParser.usage_event(
|
|
23
|
+
usage, stream: true, usage_source: Usage::Source::STREAM_FINAL, **attributes
|
|
24
|
+
)
|
|
25
|
+
return background ? event.keyed_by_response_id : event
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
warn_missing_stream_usage(request_url: request_url, request: request)
|
|
29
|
+
attributes[:service_line_items] = [] if background
|
|
30
|
+
build_unknown_stream_usage(**attributes)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def streaming_request?(request_url, request_parsed)
|
|
34
|
+
super || request_parsed["stream_format"] == "sse"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def auto_enable_stream_usage?(request_url, _request_parsed)
|
|
38
|
+
openai_chat_completions_url?(request_url)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def retain_stream_event?(data)
|
|
42
|
+
data.is_a?(Hash) && (data["item"].is_a?(Hash) || data["response"].is_a?(Hash))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def trim_stream_event(data)
|
|
46
|
+
return data unless data.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
item, response = data.values_at("item", "response")
|
|
49
|
+
data = data.merge("item" => ServiceCharges.billing_fields(item)) if item
|
|
50
|
+
return data unless response.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
output = Array(response["output"]).filter_map { |output_item| ServiceCharges.billing_fields(output_item) }
|
|
53
|
+
data.merge("response" => response.slice(*RETAINED_RESPONSE_FIELDS).merge("output" => output))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def stream_attributes(events, request:, request_url:, usage:)
|
|
59
|
+
provider = provider_for(request_url)
|
|
60
|
+
model = stream_value(events, "model", reverse: true) || request["model"]
|
|
61
|
+
service_tier = stream_value(events, "service_tier", reverse: true) || usage&.dig(:service_tier) ||
|
|
62
|
+
request["service_tier"]
|
|
63
|
+
{
|
|
64
|
+
provider: provider,
|
|
65
|
+
model: model,
|
|
66
|
+
provider_response_id: stream_value(events, "id"),
|
|
67
|
+
pricing_mode: ResponseParser.combined_pricing_mode(
|
|
68
|
+
provider: provider, host: parsed_uri(request_url)&.host, model: model, service_tier: service_tier
|
|
69
|
+
),
|
|
70
|
+
service_line_items: ServiceCharges.service_line_items_for(streamed_response(events), request:, model:) +
|
|
71
|
+
ServiceCharges.speech_line_items(request)
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stream_value(events, key, reverse: false)
|
|
76
|
+
find_event_value(events, reverse: reverse) { |data| envelope_value(data, key) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def envelope_value(data, key)
|
|
80
|
+
data[key] || data.dig("response", key) || data.dig("chunk", key)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stream_usage(events)
|
|
84
|
+
usage = find_event_value(events, reverse: true) do |data|
|
|
85
|
+
found = envelope_value(data, "usage") || data.dig("x_groq", "usage") || data.dig("chunk", "x_groq", "usage")
|
|
86
|
+
next unless found.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
audio = { "output_tokens_details" => { "audio_tokens" => found["output_tokens"] } }
|
|
89
|
+
data["type"] == "speech.audio.done" ? found.merge(audio) : found
|
|
90
|
+
end
|
|
91
|
+
usage&.deep_symbolize_keys
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def streamed_response(events)
|
|
95
|
+
response = { "output" => [] }
|
|
96
|
+
each_event_data(events) do |data|
|
|
97
|
+
response["output"].concat(Array(data.dig("response", "output")))
|
|
98
|
+
response["output"] << data["item"] if data["item"]
|
|
99
|
+
chunk = data["chunk"] || data
|
|
100
|
+
next unless chunk["choices"].is_a?(Array)
|
|
101
|
+
|
|
102
|
+
response["id"] ||= chunk["id"]
|
|
103
|
+
response["choices"] ||= chunk["choices"]
|
|
104
|
+
end
|
|
105
|
+
response
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def warn_missing_stream_usage(request_url:, request:)
|
|
109
|
+
return unless request_url.nil? || request["stream"]
|
|
110
|
+
return unless request_url ? openai_chat_completions_url?(request_url) : request["messages"]
|
|
111
|
+
return if request.dig("stream_options", "include_usage")
|
|
112
|
+
|
|
113
|
+
Logging.warn(
|
|
114
|
+
"OpenAI-compatible chat-completions stream finished without a final usage chunk. " \
|
|
115
|
+
"Set `stream_options: { include_usage: true }` in your request body so the gem can " \
|
|
116
|
+
"record token counts. This call was stored with usage_source=#{Usage::Source::UNKNOWN}."
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def openai_chat_completions_url?(request_url)
|
|
121
|
+
uri = parsed_uri(request_url)
|
|
122
|
+
uri && uri.path.to_s.end_with?("/chat/completions")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -12,78 +12,88 @@ module LlmCostTracker
|
|
|
12
12
|
audio_tokens: "audio_cache_read_input", image_tokens: "image_cache_read_input"
|
|
13
13
|
}.freeze
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
image_output_details: image_output_tokens(usage),
|
|
29
|
-
text_output_details: text_output_tokens(usage),
|
|
30
|
-
audio_output: audio_output,
|
|
31
|
-
default_to_image: ModelFamilies.image_output?(model)
|
|
32
|
-
)
|
|
15
|
+
class << self
|
|
16
|
+
def token_usage(usage, model: nil, default_to_image: ModelFamilies.image_output?(model))
|
|
17
|
+
cache_read = cache_read_input_tokens(usage)
|
|
18
|
+
cache_write = cache_write_input_tokens(usage)
|
|
19
|
+
Usage::TokenUsage.build(
|
|
20
|
+
**input_counts(usage, cached: cache_read + cache_write),
|
|
21
|
+
**output_counts(usage, default_to_image: default_to_image),
|
|
22
|
+
total_tokens: usage[:total_tokens],
|
|
23
|
+
cache_read_input_tokens: cache_read,
|
|
24
|
+
cache_write_input_tokens: cache_write,
|
|
25
|
+
hidden_output_tokens: hidden_output_tokens(usage)
|
|
26
|
+
)
|
|
27
|
+
end
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
audio_input_tokens: audio_input,
|
|
41
|
-
audio_output_tokens: audio_output,
|
|
42
|
-
image_input_tokens: image_input,
|
|
43
|
-
image_output_tokens: image_output,
|
|
44
|
-
hidden_output_tokens: reasoning
|
|
45
|
-
)
|
|
46
|
-
end
|
|
29
|
+
def cache_read_line_items(usage)
|
|
30
|
+
CACHED_MODALITY_DIMENSIONS.filter_map do |detail_key, dimension_key|
|
|
31
|
+
quantity = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens_details, detail_key)
|
|
32
|
+
Charges::LineItem.build(dimension_key: dimension_key, quantity: quantity) if quantity.positive?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
47
35
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
36
|
+
def split_output(output_tokens:,
|
|
37
|
+
image_output_details:,
|
|
38
|
+
text_output_details:,
|
|
39
|
+
audio_output:,
|
|
40
|
+
default_to_image: false)
|
|
41
|
+
if image_output_details.zero? && text_output_details.zero?
|
|
42
|
+
remainder = [output_tokens - audio_output, 0].max
|
|
43
|
+
return default_to_image ? [remainder, 0] : [0, remainder]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
[image_output_details, [output_tokens - image_output_details - audio_output, 0].max]
|
|
52
47
|
end
|
|
53
|
-
end
|
|
54
48
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
49
|
+
def cache_read_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens)
|
|
50
|
+
def cache_write_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cache_write_tokens)
|
|
51
|
+
def hidden_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :reasoning_tokens)
|
|
52
|
+
def audio_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :audio_tokens)
|
|
53
|
+
def audio_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :audio_tokens)
|
|
54
|
+
def image_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :image_tokens)
|
|
55
|
+
def image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
|
|
56
|
+
def text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
|
|
57
|
+
|
|
58
|
+
def uncached_input_tokens(usage, key)
|
|
59
|
+
detail(usage, INPUT_DETAIL_KEYS, key) - detail(usage, INPUT_DETAIL_KEYS, :cached_tokens_details, key)
|
|
63
60
|
end
|
|
64
61
|
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
def detail(usage, containers, *path)
|
|
63
|
+
containers.each do |container|
|
|
64
|
+
value = usage.dig(container, *path)
|
|
65
|
+
return value.to_i if value
|
|
66
|
+
end
|
|
67
|
+
0
|
|
68
|
+
end
|
|
67
69
|
|
|
68
|
-
|
|
69
|
-
def self.cache_write_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cache_write_tokens)
|
|
70
|
-
def self.hidden_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :reasoning_tokens)
|
|
71
|
-
def self.audio_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :audio_tokens)
|
|
72
|
-
def self.audio_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :audio_tokens)
|
|
73
|
-
def self.image_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :image_tokens)
|
|
74
|
-
def self.image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
|
|
75
|
-
def self.text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
|
|
70
|
+
private
|
|
76
71
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
def input_counts(usage, cached:)
|
|
73
|
+
uncached = [prompt_tokens(usage) - cached, 0].max
|
|
74
|
+
image = uncached_input_tokens(usage, :image_tokens).clamp(0, uncached)
|
|
75
|
+
audio = uncached_input_tokens(usage, :audio_tokens).clamp(0, uncached - image)
|
|
76
|
+
{ input_tokens: uncached - audio - image, image_input_tokens: image, audio_input_tokens: audio }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def output_counts(usage, default_to_image:)
|
|
80
|
+
output = (usage[:output_tokens] || usage[:completion_tokens]).to_i
|
|
81
|
+
reasoning = hidden_output_tokens(usage)
|
|
82
|
+
output += reasoning if usage[:total_tokens].to_i == prompt_tokens(usage) + output + reasoning
|
|
83
|
+
audio = audio_output_tokens(usage)
|
|
84
|
+
image, regular = split_output(
|
|
85
|
+
output_tokens: output,
|
|
86
|
+
image_output_details: image_output_tokens(usage),
|
|
87
|
+
text_output_details: text_output_tokens(usage),
|
|
88
|
+
audio_output: audio,
|
|
89
|
+
default_to_image: default_to_image
|
|
90
|
+
)
|
|
91
|
+
{ output_tokens: regular, image_output_tokens: image, audio_output_tokens: audio }
|
|
92
|
+
end
|
|
80
93
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
value = usage.dig(container, *path)
|
|
84
|
-
return value.to_i if value
|
|
94
|
+
def prompt_tokens(usage)
|
|
95
|
+
(usage[:input_tokens] || usage[:prompt_tokens]).to_i
|
|
85
96
|
end
|
|
86
|
-
0
|
|
87
97
|
end
|
|
88
98
|
end
|
|
89
99
|
end
|
|
@@ -5,13 +5,24 @@ module LlmCostTracker
|
|
|
5
5
|
module OpenaiCompatible
|
|
6
6
|
class Parser < LlmCostTracker::Parsers::Base
|
|
7
7
|
include Openai::ResponseParser
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
include Openai::StreamParser
|
|
9
|
+
|
|
10
|
+
TRACKED_PATH_SUFFIXES = %w[
|
|
11
|
+
/chat/completions /completions /embeddings /responses /images/generations /images/edits /v1/sonar /v1/agent
|
|
12
|
+
/v1/ocr /audio/transcriptions /audio/translations /audio/speech /moderations
|
|
13
|
+
].freeze
|
|
14
|
+
RETRIEVE_PATH = %r{/(?:responses|agent)/resp_[^/]+\z}
|
|
15
|
+
STREAM_USAGE_HOSTS = %w[
|
|
16
|
+
openrouter.ai api.deepseek.com api.groq.com api.x.ai us.api.x.ai
|
|
17
|
+
api.mistral.ai api.eu.mistral.ai api.us.mistral.ai
|
|
18
|
+
].freeze
|
|
10
19
|
|
|
11
20
|
class << self
|
|
12
21
|
def match?(url)
|
|
13
22
|
uri_matches?(url) do |uri|
|
|
14
|
-
|
|
23
|
+
path = uri.path.to_s
|
|
24
|
+
(path.end_with?(*TRACKED_PATH_SUFFIXES) || path.match?(RETRIEVE_PATH)) &&
|
|
25
|
+
!provider_for_uri(uri).nil?
|
|
15
26
|
end
|
|
16
27
|
end
|
|
17
28
|
|
|
@@ -34,7 +45,7 @@ module LlmCostTracker
|
|
|
34
45
|
end
|
|
35
46
|
|
|
36
47
|
def auto_enable_stream_usage?(request_url, request_parsed)
|
|
37
|
-
super &&
|
|
48
|
+
super && STREAM_USAGE_HOSTS.include?(parsed_uri(request_url).host.to_s.downcase)
|
|
38
49
|
end
|
|
39
50
|
end
|
|
40
51
|
end
|
|
@@ -14,6 +14,7 @@ module LlmCostTracker
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
module Gemini
|
|
17
|
+
autoload :Grounding, "llm_cost_tracker/providers/gemini/grounding"
|
|
17
18
|
autoload :ModelFamilies, "llm_cost_tracker/providers/gemini/model_families"
|
|
18
19
|
autoload :Parser, "llm_cost_tracker/providers/gemini/parser"
|
|
19
20
|
autoload :UsageExtractor, "llm_cost_tracker/providers/gemini/usage_extractor"
|
|
@@ -22,8 +23,10 @@ module LlmCostTracker
|
|
|
22
23
|
module Openai
|
|
23
24
|
autoload :Hosts, "llm_cost_tracker/providers/openai/hosts"
|
|
24
25
|
autoload :ModelFamilies, "llm_cost_tracker/providers/openai/model_families"
|
|
26
|
+
autoload :OutputItem, "llm_cost_tracker/providers/openai/output_item"
|
|
25
27
|
autoload :Parser, "llm_cost_tracker/providers/openai/parser"
|
|
26
28
|
autoload :ServiceCharges, "llm_cost_tracker/providers/openai/service_charges"
|
|
29
|
+
autoload :StreamParser, "llm_cost_tracker/providers/openai/stream_parser"
|
|
27
30
|
autoload :UsageExtractor, "llm_cost_tracker/providers/openai/usage_extractor"
|
|
28
31
|
autoload :ResponseParser, "llm_cost_tracker/providers/openai/response_parser"
|
|
29
32
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Redaction
|
|
5
7
|
REDACTED = "[REDACTED]"
|
|
@@ -28,5 +30,16 @@ module LlmCostTracker
|
|
|
28
30
|
.gsub(PARAM, "\\1=#{REDACTED}")
|
|
29
31
|
.gsub(EMBEDDED_TOKEN, REDACTED)
|
|
30
32
|
end
|
|
33
|
+
|
|
34
|
+
def self.url(value)
|
|
35
|
+
uri = URI.parse(value.to_s)
|
|
36
|
+
uri.query = nil
|
|
37
|
+
uri.fragment = nil
|
|
38
|
+
uri.user = nil
|
|
39
|
+
uri.password = nil
|
|
40
|
+
uri.to_s
|
|
41
|
+
rescue URI::InvalidURIError
|
|
42
|
+
value.to_s.split("?", 2).first
|
|
43
|
+
end
|
|
31
44
|
end
|
|
32
45
|
end
|
|
@@ -26,46 +26,43 @@ module LlmCostTracker
|
|
|
26
26
|
TOP_LIMIT = 5
|
|
27
27
|
|
|
28
28
|
def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil, breakdown_limit: nil)
|
|
29
|
-
days = days
|
|
30
|
-
|
|
31
|
-
unless breakdown_limit.nil?
|
|
32
|
-
breakdown_limit = breakdown_limit.to_i
|
|
33
|
-
breakdown_limit = nil unless breakdown_limit.positive?
|
|
34
|
-
end
|
|
29
|
+
days = positive_integer(days) || DEFAULT_DAYS
|
|
30
|
+
limit = positive_integer(breakdown_limit)
|
|
35
31
|
from = now - days.days
|
|
36
32
|
scope = LlmCostTracker::Call.where(tracked_at: from..now)
|
|
37
|
-
tag_breakdowns
|
|
38
|
-
aggregate = totals(scope)
|
|
33
|
+
tag_keys = tag_breakdowns || LlmCostTracker.configuration.tags.report_breakdown_keys
|
|
39
34
|
|
|
40
35
|
new(
|
|
41
36
|
days: days,
|
|
42
37
|
from_time: from,
|
|
43
38
|
to_time: now,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
cost_by_provider: scope.cost_by_provider(limit: breakdown_limit).to_a,
|
|
49
|
-
cost_by_model: scope.cost_by_model(limit: breakdown_limit).to_a,
|
|
50
|
-
cost_by_tags: cost_by_tags(scope, tag_breakdowns, limit: breakdown_limit),
|
|
39
|
+
**totals(scope),
|
|
40
|
+
cost_by_provider: scope.cost_by_provider(limit: limit).to_a,
|
|
41
|
+
cost_by_model: scope.cost_by_model(limit: limit).to_a,
|
|
42
|
+
cost_by_tags: tag_keys.to_h { |key| [key, scope.cost_by_tag(key, limit: limit).to_a] },
|
|
51
43
|
top_calls: top_calls(scope)
|
|
52
44
|
)
|
|
53
45
|
end
|
|
54
46
|
|
|
55
|
-
def self.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"COALESCE(SUM(#{LlmCostTracker::Call.qualified(:total_cost)}), 0) AS total_cost, " \
|
|
59
|
-
"COUNT(*) AS requests_count, " \
|
|
60
|
-
"AVG(latency_ms) AS average_latency_ms, " \
|
|
61
|
-
"COALESCE(SUM(CASE WHEN #{Charges::CostStatus.unknown_pricing_sql} " \
|
|
62
|
-
"THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
|
|
63
|
-
)
|
|
64
|
-
.take
|
|
47
|
+
def self.positive_integer(value)
|
|
48
|
+
integer = value.to_i
|
|
49
|
+
integer if integer.positive?
|
|
65
50
|
end
|
|
66
51
|
|
|
67
|
-
def self.
|
|
68
|
-
|
|
52
|
+
def self.totals(scope)
|
|
53
|
+
row = scope.select(
|
|
54
|
+
"COALESCE(SUM(#{LlmCostTracker::Call.qualified(:total_cost)}), 0) AS total_cost, " \
|
|
55
|
+
"COUNT(*) AS requests_count, " \
|
|
56
|
+
"AVG(latency_ms) AS average_latency_ms, " \
|
|
57
|
+
"COALESCE(SUM(CASE WHEN #{Charges::CostStatus.unknown_pricing_sql} " \
|
|
58
|
+
"THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
|
|
59
|
+
).take
|
|
60
|
+
{
|
|
61
|
+
total_cost: row.total_cost.to_f,
|
|
62
|
+
requests_count: row.requests_count.to_i,
|
|
63
|
+
average_latency_ms: row.average_latency_ms&.to_f,
|
|
64
|
+
unknown_pricing_count: row.unknown_pricing_count.to_i
|
|
65
|
+
}
|
|
69
66
|
end
|
|
70
67
|
|
|
71
68
|
def self.top_calls(scope)
|
|
@@ -76,7 +73,7 @@ module LlmCostTracker
|
|
|
76
73
|
.to_a
|
|
77
74
|
end
|
|
78
75
|
|
|
79
|
-
private_class_method :
|
|
76
|
+
private_class_method :positive_integer, :totals, :top_calls
|
|
80
77
|
end
|
|
81
78
|
end
|
|
82
79
|
end
|
|
@@ -9,26 +9,41 @@ module LlmCostTracker
|
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
11
|
def with(tags)
|
|
12
|
-
stack =
|
|
13
|
-
|
|
12
|
+
stack = Fiber[KEY] || []
|
|
13
|
+
scope = [Thread.current, Sanitizer.call((tags || {}).to_h)]
|
|
14
|
+
shared = scope.dup
|
|
15
|
+
Fiber[KEY] = stack + [scope]
|
|
16
|
+
ActiveSupport::IsolatedExecutionState[KEY] = shared_scopes + [shared]
|
|
14
17
|
yield
|
|
15
18
|
ensure
|
|
16
|
-
|
|
19
|
+
scope&.clear
|
|
20
|
+
Fiber[KEY] = stack
|
|
21
|
+
ActiveSupport::IsolatedExecutionState[KEY] = shared_scopes.reject { |entry| entry.equal?(shared) } if shared
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
def tags
|
|
20
25
|
config = LlmCostTracker.configuration
|
|
21
|
-
base = config.tags.static_sanitized_default ||
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
base = config.tags.static_sanitized_default || Sanitizer.call(call_default_tags(config.tags.default))
|
|
27
|
+
base.merge(scoped)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def scoped
|
|
31
|
+
current = Thread.current
|
|
32
|
+
shared = shared_scopes.reject { |owner, _| owner.equal?(current) }
|
|
33
|
+
own = Array(Fiber[KEY]).select { |owner, _| owner.equal?(current) }
|
|
34
|
+
(shared + own).each_with_object({}) { |(_, tags), merged| merged.merge!(tags) }
|
|
24
35
|
end
|
|
25
36
|
|
|
26
37
|
def call_default_tags(proc_or_lambda)
|
|
27
|
-
proc_or_lambda.call
|
|
38
|
+
(proc_or_lambda.call || {}).to_h
|
|
28
39
|
rescue StandardError => e
|
|
29
40
|
Logging.warn("LlmCostTracker tags.default proc raised: #{e.class}: #{e.message}; using empty default tags")
|
|
30
41
|
{}
|
|
31
42
|
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def shared_scopes = Array(ActiveSupport::IsolatedExecutionState[KEY])
|
|
32
47
|
end
|
|
33
48
|
end
|
|
34
49
|
end
|
|
@@ -16,33 +16,17 @@ module LlmCostTracker
|
|
|
16
16
|
def record(event:, latency_ms: nil, metadata: {}, context_tags: nil, enforce_budget: false)
|
|
17
17
|
return unless LlmCostTracker.configuration.enabled
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
model: event.model,
|
|
22
|
-
tokens: event.token_usage,
|
|
23
|
-
line_items: event.line_items,
|
|
24
|
-
pricing_mode: event.pricing_mode,
|
|
25
|
-
usage_source: event.usage_source
|
|
26
|
-
)
|
|
27
|
-
|
|
19
|
+
tracked_at = Time.now.utc
|
|
20
|
+
calculation = price(event, at: tracked_at)
|
|
28
21
|
tags = build_tags(context_tags: context_tags, metadata: metadata)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if Ingestion.async?
|
|
33
|
-
Ingestion::Inbox.save(event)
|
|
34
|
-
Ingestion::Worker.ensure_started
|
|
35
|
-
else
|
|
36
|
-
Ledger::Store.insert(event)
|
|
37
|
-
end
|
|
38
|
-
|
|
22
|
+
event = build_event(event:, calculation:, tags:, latency_ms:, tracked_at:)
|
|
23
|
+
persist(event)
|
|
39
24
|
yield if block_given?
|
|
40
25
|
notify_subscribers(event)
|
|
41
|
-
behavior_override = :raise if enforce_budget
|
|
42
26
|
begin
|
|
43
27
|
signal_unpriced(event, calculation)
|
|
44
28
|
ensure
|
|
45
|
-
Budget.check!(event, behavior_override:
|
|
29
|
+
Budget.check!(event, behavior_override: enforce_budget ? :raise : nil)
|
|
46
30
|
end
|
|
47
31
|
|
|
48
32
|
event
|
|
@@ -56,15 +40,37 @@ module LlmCostTracker
|
|
|
56
40
|
|
|
57
41
|
private
|
|
58
42
|
|
|
43
|
+
def price(event, at:)
|
|
44
|
+
Pricing::Calculation.for(
|
|
45
|
+
provider: event.provider,
|
|
46
|
+
model: event.model,
|
|
47
|
+
tokens: event.token_usage,
|
|
48
|
+
line_items: event.line_items,
|
|
49
|
+
pricing_mode: event.pricing_mode,
|
|
50
|
+
usage_source: event.usage_source,
|
|
51
|
+
at: at
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def persist(event)
|
|
56
|
+
return Ledger::Store.insert(event) unless Ingestion.async?
|
|
57
|
+
|
|
58
|
+
Ingestion::Inbox.save(event)
|
|
59
|
+
Ingestion::Worker.ensure_started
|
|
60
|
+
end
|
|
61
|
+
|
|
59
62
|
def signal_unpriced(event, calculation)
|
|
60
63
|
iterations, lines = calculation.priced_line_items.partition { |line| line.kind == "model_iteration" }
|
|
61
64
|
models = iterations.select(&:unpriced?).map { |line| line.details[:model] }
|
|
62
|
-
|
|
63
|
-
models.unshift(event.model)
|
|
64
|
-
end
|
|
65
|
+
models.unshift(event.model) if unpriced_tokens?(event, calculation, lines)
|
|
65
66
|
models.each { |model| Pricing::Unknown.process(model, pricing_mode: calculation.mode) }
|
|
66
67
|
end
|
|
67
68
|
|
|
69
|
+
def unpriced_tokens?(event, calculation, lines)
|
|
70
|
+
calculation.token_cost.nil? && event.token_usage.total_tokens.positive? && lines.none?(&:priced?) &&
|
|
71
|
+
!calculation.unsplit_total?
|
|
72
|
+
end
|
|
73
|
+
|
|
68
74
|
def notify_subscribers(event)
|
|
69
75
|
return unless ActiveSupport::Notifications.notifier.listening?(EVENT_NAME)
|
|
70
76
|
|
|
@@ -73,14 +79,14 @@ module LlmCostTracker
|
|
|
73
79
|
Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
|
|
74
80
|
end
|
|
75
81
|
|
|
76
|
-
def build_event(event:, calculation:, tags:, latency_ms:)
|
|
82
|
+
def build_event(event:, calculation:, tags:, latency_ms:, tracked_at:)
|
|
77
83
|
event.with(
|
|
78
84
|
event_id: event.event_id || SecureRandom.uuid,
|
|
79
85
|
pricing_mode: calculation.mode,
|
|
80
86
|
cost: calculation.cost,
|
|
81
87
|
tags: tags,
|
|
82
88
|
latency_ms: finite_latency_ms(latency_ms),
|
|
83
|
-
tracked_at:
|
|
89
|
+
tracked_at: tracked_at,
|
|
84
90
|
cost_status: calculation.cost_status,
|
|
85
91
|
pricing_snapshot: calculation.snapshot,
|
|
86
92
|
line_items: calculation.priced_line_items
|
|
@@ -85,6 +85,14 @@
|
|
|
85
85
|
unit: token
|
|
86
86
|
parent: input
|
|
87
87
|
|
|
88
|
+
- key: video_output
|
|
89
|
+
kind: video_token
|
|
90
|
+
direction: output
|
|
91
|
+
modality: video
|
|
92
|
+
cache_state: none
|
|
93
|
+
unit: token
|
|
94
|
+
parent: output
|
|
95
|
+
|
|
88
96
|
- key: web_search_request
|
|
89
97
|
kind: web_search_request
|
|
90
98
|
direction: neither
|
|
@@ -188,6 +196,22 @@
|
|
|
188
196
|
unit: minute
|
|
189
197
|
rate_basis: per_minute
|
|
190
198
|
|
|
199
|
+
- key: ocr_page
|
|
200
|
+
kind: ocr_page
|
|
201
|
+
direction: input
|
|
202
|
+
modality: image
|
|
203
|
+
cache_state: none
|
|
204
|
+
unit: page
|
|
205
|
+
rate_basis: per_1k_pages
|
|
206
|
+
|
|
207
|
+
- key: rerank_search_unit
|
|
208
|
+
kind: rerank_search_unit
|
|
209
|
+
direction: input
|
|
210
|
+
modality: text
|
|
211
|
+
cache_state: none
|
|
212
|
+
unit: request
|
|
213
|
+
rate_basis: per_1k_requests
|
|
214
|
+
|
|
191
215
|
- key: billed_request
|
|
192
216
|
kind: billed_request
|
|
193
217
|
direction: neither
|
|
@@ -16,10 +16,9 @@ module LlmCostTracker
|
|
|
16
16
|
priced = Catalog.token_priced.to_h do |dimension|
|
|
17
17
|
[dimension.token_key, non_negative_int(values[dimension.token_key])]
|
|
18
18
|
end
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
new(**priced, total_tokens: total, hidden_output_tokens: non_negative_int(values[:hidden_output_tokens]))
|
|
19
|
+
new(**priced,
|
|
20
|
+
total_tokens: total(values[:total_tokens], priced.values.sum),
|
|
21
|
+
hidden_output_tokens: non_negative_int(values[:hidden_output_tokens]))
|
|
23
22
|
end
|
|
24
23
|
|
|
25
24
|
def self.build_from_tokens(tokens)
|
|
@@ -40,6 +39,11 @@ module LlmCostTracker
|
|
|
40
39
|
def self.non_negative_int(value)
|
|
41
40
|
[value.to_i, 0].max
|
|
42
41
|
end
|
|
42
|
+
|
|
43
|
+
def self.total(declared, counted)
|
|
44
|
+
declared ? [non_negative_int(declared), counted].max : counted
|
|
45
|
+
end
|
|
46
|
+
private_class_method :total
|
|
43
47
|
end
|
|
44
48
|
end
|
|
45
49
|
end
|