llm_cost_tracker 0.3.3 → 0.4.1
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 +31 -0
- data/README.md +46 -25
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +96 -23
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +81 -0
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +65 -0
- data/lib/llm_cost_tracker/budget.rb +73 -22
- data/lib/llm_cost_tracker/configuration.rb +4 -0
- data/lib/llm_cost_tracker/cost.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +22 -3
- data/lib/llm_cost_tracker/event.rb +4 -0
- data/lib/llm_cost_tracker/event_metadata.rb +21 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_monthly_totals_generator.rb → add_period_totals_generator.rb} +4 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +96 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +11 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +11 -3
- data/lib/llm_cost_tracker/parsed_usage.rb +16 -7
- data/lib/llm_cost_tracker/parsers/anthropic.rb +24 -55
- data/lib/llm_cost_tracker/parsers/base.rb +80 -0
- data/lib/llm_cost_tracker/parsers/gemini.rb +17 -37
- data/lib/llm_cost_tracker/parsers/openai.rb +1 -6
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +6 -15
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +25 -34
- data/lib/llm_cost_tracker/parsers/registry.rb +17 -2
- data/lib/llm_cost_tracker/period_total.rb +9 -0
- data/lib/llm_cost_tracker/price_registry.rb +14 -4
- data/lib/llm_cost_tracker/price_sync/merger.rb +1 -1
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +3 -5
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +2 -3
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +2 -3
- data/lib/llm_cost_tracker/prices.json +30 -30
- data/lib/llm_cost_tracker/pricing.rb +44 -32
- data/lib/llm_cost_tracker/railtie.rb +2 -1
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +142 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +35 -78
- data/lib/llm_cost_tracker/stream_collector.rb +4 -2
- data/lib/llm_cost_tracker/tags_column.rb +71 -14
- data/lib/llm_cost_tracker/tracker.rb +54 -32
- data/lib/llm_cost_tracker/usage_breakdown.rb +30 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +10 -3
- metadata +9 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_monthly_totals_to_llm_cost_tracker.rb.erb +0 -48
- data/lib/llm_cost_tracker/monthly_total.rb +0 -9
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
require_relative "base"
|
|
6
4
|
|
|
7
5
|
module LlmCostTracker
|
|
@@ -10,10 +8,7 @@ module LlmCostTracker
|
|
|
10
8
|
HOSTS = %w[api.anthropic.com].freeze
|
|
11
9
|
|
|
12
10
|
def match?(url)
|
|
13
|
-
|
|
14
|
-
HOSTS.include?(uri.host.to_s.downcase) && uri.path.include?("/v1/messages")
|
|
15
|
-
rescue URI::InvalidURIError
|
|
16
|
-
false
|
|
11
|
+
match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
|
|
17
12
|
end
|
|
18
13
|
|
|
19
14
|
def provider_names
|
|
@@ -28,6 +23,8 @@ module LlmCostTracker
|
|
|
28
23
|
return nil unless usage
|
|
29
24
|
|
|
30
25
|
request = safe_json_parse(request_body)
|
|
26
|
+
cache_read = usage["cache_read_input_tokens"].to_i
|
|
27
|
+
cache_write = usage["cache_creation_input_tokens"].to_i
|
|
31
28
|
|
|
32
29
|
ParsedUsage.build(
|
|
33
30
|
provider: "anthropic",
|
|
@@ -35,10 +32,9 @@ module LlmCostTracker
|
|
|
35
32
|
model: response["model"] || request["model"],
|
|
36
33
|
input_tokens: usage["input_tokens"].to_i,
|
|
37
34
|
output_tokens: usage["output_tokens"].to_i,
|
|
38
|
-
total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i +
|
|
39
|
-
usage["cache_read_input_tokens"].to_i + usage["cache_creation_input_tokens"].to_i,
|
|
35
|
+
total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i + cache_read + cache_write,
|
|
40
36
|
cache_read_input_tokens: usage["cache_read_input_tokens"],
|
|
41
|
-
|
|
37
|
+
cache_write_input_tokens: usage["cache_creation_input_tokens"],
|
|
42
38
|
usage_source: :response
|
|
43
39
|
)
|
|
44
40
|
end
|
|
@@ -51,25 +47,25 @@ module LlmCostTracker
|
|
|
51
47
|
usage = stream_usage(events)
|
|
52
48
|
response_id = stream_response_id(events)
|
|
53
49
|
|
|
54
|
-
|
|
50
|
+
if usage
|
|
51
|
+
build_stream_result(model, usage, response_id)
|
|
52
|
+
else
|
|
53
|
+
build_unknown_stream_usage(
|
|
54
|
+
provider: "anthropic",
|
|
55
|
+
model: model,
|
|
56
|
+
provider_response_id: response_id
|
|
57
|
+
)
|
|
58
|
+
end
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
private
|
|
58
62
|
|
|
59
63
|
def stream_usage(events)
|
|
60
|
-
start_usage =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
events
|
|
64
|
-
data
|
|
65
|
-
next unless data.is_a?(Hash)
|
|
66
|
-
|
|
67
|
-
case data["type"]
|
|
68
|
-
when "message_start"
|
|
69
|
-
start_usage = data.dig("message", "usage")
|
|
70
|
-
when "message_delta"
|
|
71
|
-
latest_delta = data["usage"] if data["usage"].is_a?(Hash)
|
|
72
|
-
end
|
|
64
|
+
start_usage = find_event_value(events, reverse: true) do |data|
|
|
65
|
+
data.dig("message", "usage") if data["type"] == "message_start"
|
|
66
|
+
end
|
|
67
|
+
latest_delta = find_event_value(events, reverse: true) do |data|
|
|
68
|
+
data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
|
|
73
69
|
end
|
|
74
70
|
|
|
75
71
|
return nil unless start_usage || latest_delta
|
|
@@ -80,32 +76,18 @@ module LlmCostTracker
|
|
|
80
76
|
end
|
|
81
77
|
|
|
82
78
|
def stream_model(events)
|
|
83
|
-
events
|
|
84
|
-
data = event[:data]
|
|
85
|
-
next unless data.is_a?(Hash)
|
|
86
|
-
|
|
87
|
-
model = data.dig("message", "model")
|
|
88
|
-
return model if model && !model.empty?
|
|
89
|
-
end
|
|
90
|
-
nil
|
|
79
|
+
find_event_value(events) { |data| data.dig("message", "model") }
|
|
91
80
|
end
|
|
92
81
|
|
|
93
82
|
def stream_response_id(events)
|
|
94
|
-
events
|
|
95
|
-
data = event[:data]
|
|
96
|
-
next unless data.is_a?(Hash)
|
|
97
|
-
|
|
98
|
-
id = data.dig("message", "id") || data["id"]
|
|
99
|
-
return id if id && !id.to_s.empty?
|
|
100
|
-
end
|
|
101
|
-
nil
|
|
83
|
+
find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
|
|
102
84
|
end
|
|
103
85
|
|
|
104
86
|
def build_stream_result(model, usage, response_id)
|
|
105
87
|
input = usage["input_tokens"].to_i
|
|
106
88
|
output = usage["output_tokens"].to_i
|
|
107
89
|
cache_read = usage["cache_read_input_tokens"].to_i
|
|
108
|
-
|
|
90
|
+
cache_write = usage["cache_creation_input_tokens"].to_i
|
|
109
91
|
|
|
110
92
|
ParsedUsage.build(
|
|
111
93
|
provider: "anthropic",
|
|
@@ -113,26 +95,13 @@ module LlmCostTracker
|
|
|
113
95
|
model: model,
|
|
114
96
|
input_tokens: input,
|
|
115
97
|
output_tokens: output,
|
|
116
|
-
total_tokens: input + output + cache_read +
|
|
98
|
+
total_tokens: input + output + cache_read + cache_write,
|
|
117
99
|
cache_read_input_tokens: usage["cache_read_input_tokens"],
|
|
118
|
-
|
|
100
|
+
cache_write_input_tokens: usage["cache_creation_input_tokens"],
|
|
119
101
|
stream: true,
|
|
120
102
|
usage_source: :stream_final
|
|
121
103
|
)
|
|
122
104
|
end
|
|
123
|
-
|
|
124
|
-
def build_unknown_stream_result(model, response_id)
|
|
125
|
-
ParsedUsage.build(
|
|
126
|
-
provider: "anthropic",
|
|
127
|
-
provider_response_id: response_id,
|
|
128
|
-
model: model,
|
|
129
|
-
input_tokens: 0,
|
|
130
|
-
output_tokens: 0,
|
|
131
|
-
total_tokens: 0,
|
|
132
|
-
stream: true,
|
|
133
|
-
usage_source: :unknown
|
|
134
|
-
)
|
|
135
|
-
end
|
|
136
105
|
end
|
|
137
106
|
end
|
|
138
107
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "uri"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Parsers
|
|
@@ -40,6 +41,85 @@ module LlmCostTracker
|
|
|
40
41
|
rescue JSON::ParserError
|
|
41
42
|
{}
|
|
42
43
|
end
|
|
44
|
+
|
|
45
|
+
def uri_matches?(url)
|
|
46
|
+
uri = parsed_uri(url)
|
|
47
|
+
uri ? yield(uri) : false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
|
|
51
|
+
uri_matches?(url) do |uri|
|
|
52
|
+
host_match = hosts.nil? || host_matches?(uri, hosts)
|
|
53
|
+
path_match = path_matches?(
|
|
54
|
+
uri,
|
|
55
|
+
exact_paths: exact_paths,
|
|
56
|
+
path_includes: path_includes,
|
|
57
|
+
path_suffixes: path_suffixes,
|
|
58
|
+
path_pattern: path_pattern
|
|
59
|
+
)
|
|
60
|
+
extra_match = block_given? ? yield(uri) : true
|
|
61
|
+
|
|
62
|
+
host_match && path_match && extra_match ? true : false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parsed_uri(url)
|
|
67
|
+
URI.parse(url.to_s)
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def host_matches?(uri, hosts)
|
|
73
|
+
hosts.include?(uri.host.to_s.downcase)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def path_matches?(uri, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
|
|
77
|
+
path = uri.path.to_s
|
|
78
|
+
matches = true
|
|
79
|
+
|
|
80
|
+
matches &&= exact_paths.include?(path) if exact_paths
|
|
81
|
+
matches &&= Array(path_includes).all? { |fragment| path.include?(fragment) } if path_includes
|
|
82
|
+
matches &&= path.match?(path_pattern) if path_pattern
|
|
83
|
+
|
|
84
|
+
matches &&= path_suffixes.any? { |suffix| path == suffix || path.end_with?(suffix) } if path_suffixes
|
|
85
|
+
|
|
86
|
+
matches
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def each_event_data(events, reverse: false)
|
|
90
|
+
enumerator = reverse ? events.reverse_each : events.each
|
|
91
|
+
|
|
92
|
+
enumerator.each do |event|
|
|
93
|
+
data = event[:data]
|
|
94
|
+
yield data if data.is_a?(Hash)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def find_event_value(events, reverse: false)
|
|
99
|
+
each_event_data(events, reverse:) do |data|
|
|
100
|
+
value = yield(data)
|
|
101
|
+
return value if event_value_present?(value)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_unknown_stream_usage(provider:, model:, provider_response_id:)
|
|
108
|
+
ParsedUsage.build(
|
|
109
|
+
provider: provider,
|
|
110
|
+
provider_response_id: provider_response_id,
|
|
111
|
+
model: model,
|
|
112
|
+
input_tokens: 0,
|
|
113
|
+
output_tokens: 0,
|
|
114
|
+
total_tokens: 0,
|
|
115
|
+
stream: true,
|
|
116
|
+
usage_source: :unknown
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def event_value_present?(value)
|
|
121
|
+
!value.nil? && (!value.respond_to?(:empty?) || !value.empty?)
|
|
122
|
+
end
|
|
43
123
|
end
|
|
44
124
|
end
|
|
45
125
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
require_relative "base"
|
|
6
4
|
|
|
7
5
|
module LlmCostTracker
|
|
@@ -12,10 +10,7 @@ module LlmCostTracker
|
|
|
12
10
|
STREAM_PATH_PATTERN = /:streamGenerateContent\z/
|
|
13
11
|
|
|
14
12
|
def match?(url)
|
|
15
|
-
|
|
16
|
-
HOSTS.include?(uri.host.to_s.downcase) && uri.path.match?(TRACKED_PATH_PATTERN)
|
|
17
|
-
rescue URI::InvalidURIError
|
|
18
|
-
false
|
|
13
|
+
match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
|
|
19
14
|
end
|
|
20
15
|
|
|
21
16
|
def provider_names
|
|
@@ -48,6 +43,7 @@ module LlmCostTracker
|
|
|
48
43
|
|
|
49
44
|
usage = merged_stream_usage(events)
|
|
50
45
|
model = extract_model_from_url(request_url)
|
|
46
|
+
response_id = stream_response_id(events)
|
|
51
47
|
|
|
52
48
|
if usage
|
|
53
49
|
build_parsed_usage(
|
|
@@ -55,18 +51,13 @@ module LlmCostTracker
|
|
|
55
51
|
usage,
|
|
56
52
|
stream: true,
|
|
57
53
|
usage_source: :stream_final,
|
|
58
|
-
provider_response_id:
|
|
54
|
+
provider_response_id: response_id
|
|
59
55
|
)
|
|
60
56
|
else
|
|
61
|
-
|
|
57
|
+
build_unknown_stream_usage(
|
|
62
58
|
provider: "gemini",
|
|
63
|
-
provider_response_id: stream_response_id(events),
|
|
64
59
|
model: model,
|
|
65
|
-
|
|
66
|
-
output_tokens: 0,
|
|
67
|
-
total_tokens: 0,
|
|
68
|
-
stream: true,
|
|
69
|
-
usage_source: :unknown
|
|
60
|
+
provider_response_id: response_id
|
|
70
61
|
)
|
|
71
62
|
end
|
|
72
63
|
end
|
|
@@ -74,13 +65,16 @@ module LlmCostTracker
|
|
|
74
65
|
private
|
|
75
66
|
|
|
76
67
|
def build_parsed_usage(request_url, usage, usage_source:, stream: false, provider_response_id: nil)
|
|
68
|
+
cache_read = usage["cachedContentTokenCount"].to_i
|
|
69
|
+
|
|
77
70
|
ParsedUsage.build(
|
|
78
71
|
provider: "gemini",
|
|
79
72
|
model: extract_model_from_url(request_url),
|
|
80
|
-
input_tokens: usage["promptTokenCount"].to_i,
|
|
73
|
+
input_tokens: [usage["promptTokenCount"].to_i - cache_read, 0].max,
|
|
81
74
|
output_tokens: output_tokens(usage),
|
|
82
75
|
total_tokens: usage["totalTokenCount"].to_i,
|
|
83
|
-
|
|
76
|
+
cache_read_input_tokens: usage["cachedContentTokenCount"],
|
|
77
|
+
hidden_output_tokens: usage["thoughtsTokenCount"],
|
|
84
78
|
stream: stream,
|
|
85
79
|
usage_source: usage_source,
|
|
86
80
|
provider_response_id: provider_response_id
|
|
@@ -88,15 +82,10 @@ module LlmCostTracker
|
|
|
88
82
|
end
|
|
89
83
|
|
|
90
84
|
def merged_stream_usage(events)
|
|
91
|
-
|
|
92
|
-
events.each do |event|
|
|
93
|
-
data = event[:data]
|
|
94
|
-
next unless data.is_a?(Hash)
|
|
95
|
-
|
|
85
|
+
find_event_value(events, reverse: true) do |data|
|
|
96
86
|
meta = data["usageMetadata"]
|
|
97
|
-
|
|
87
|
+
meta if meta.is_a?(Hash)
|
|
98
88
|
end
|
|
99
|
-
latest
|
|
100
89
|
end
|
|
101
90
|
|
|
102
91
|
def output_tokens(usage)
|
|
@@ -104,28 +93,19 @@ module LlmCostTracker
|
|
|
104
93
|
end
|
|
105
94
|
|
|
106
95
|
def stream_response_id(events)
|
|
107
|
-
events
|
|
108
|
-
data = event[:data]
|
|
109
|
-
next unless data.is_a?(Hash)
|
|
110
|
-
|
|
111
|
-
id = data["responseId"]
|
|
112
|
-
return id if id && !id.to_s.empty?
|
|
113
|
-
end
|
|
114
|
-
nil
|
|
96
|
+
find_event_value(events) { |data| data["responseId"] }
|
|
115
97
|
end
|
|
116
98
|
|
|
117
99
|
def streaming_url?(request_url)
|
|
118
|
-
|
|
119
|
-
rescue URI::InvalidURIError
|
|
120
|
-
false
|
|
100
|
+
match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
|
|
121
101
|
end
|
|
122
102
|
|
|
123
103
|
def extract_model_from_url(url)
|
|
124
|
-
uri =
|
|
104
|
+
uri = parsed_uri(url)
|
|
105
|
+
return nil unless uri
|
|
106
|
+
|
|
125
107
|
match = uri.path.match(%r{/models/([^/:]+)})
|
|
126
108
|
match && match[1]
|
|
127
|
-
rescue URI::InvalidURIError
|
|
128
|
-
nil
|
|
129
109
|
end
|
|
130
110
|
end
|
|
131
111
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
require_relative "base"
|
|
6
4
|
require_relative "openai_usage"
|
|
7
5
|
|
|
@@ -14,10 +12,7 @@ module LlmCostTracker
|
|
|
14
12
|
TRACKED_PATHS = %w[/v1/chat/completions /v1/completions /v1/embeddings /v1/responses].freeze
|
|
15
13
|
|
|
16
14
|
def match?(url)
|
|
17
|
-
|
|
18
|
-
HOSTS.include?(uri.host.to_s.downcase) && TRACKED_PATHS.include?(uri.path)
|
|
19
|
-
rescue URI::InvalidURIError
|
|
20
|
-
false
|
|
15
|
+
match_uri?(url, hosts: HOSTS, exact_paths: TRACKED_PATHS)
|
|
21
16
|
end
|
|
22
17
|
|
|
23
18
|
def provider_names
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
require_relative "base"
|
|
6
4
|
require_relative "openai_usage"
|
|
7
5
|
|
|
@@ -13,10 +11,7 @@ module LlmCostTracker
|
|
|
13
11
|
TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
|
|
14
12
|
|
|
15
13
|
def match?(url)
|
|
16
|
-
uri
|
|
17
|
-
!provider_for_host(uri.host).nil? && tracked_path?(uri.path)
|
|
18
|
-
rescue URI::InvalidURIError
|
|
19
|
-
false
|
|
14
|
+
match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
|
|
20
15
|
end
|
|
21
16
|
|
|
22
17
|
def provider_names
|
|
@@ -37,18 +32,14 @@ module LlmCostTracker
|
|
|
37
32
|
private
|
|
38
33
|
|
|
39
34
|
def provider_for(request_url)
|
|
40
|
-
uri =
|
|
41
|
-
|
|
42
|
-
rescue URI::InvalidURIError
|
|
43
|
-
"openai_compatible"
|
|
35
|
+
uri = parsed_uri(request_url)
|
|
36
|
+
provider_for_uri(uri) || "openai_compatible"
|
|
44
37
|
end
|
|
45
38
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
end
|
|
39
|
+
def provider_for_uri(uri)
|
|
40
|
+
return nil unless uri
|
|
49
41
|
|
|
50
|
-
|
|
51
|
-
TRACKED_PATH_SUFFIXES.any? { |suffix| path == suffix || path.end_with?(suffix) }
|
|
42
|
+
LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
|
|
52
43
|
end
|
|
53
44
|
end
|
|
54
45
|
end
|
|
@@ -13,15 +13,17 @@ module LlmCostTracker
|
|
|
13
13
|
return nil unless usage
|
|
14
14
|
|
|
15
15
|
request = safe_json_parse(request_body)
|
|
16
|
+
cache_read = cache_read_input_tokens(usage)
|
|
16
17
|
|
|
17
18
|
ParsedUsage.build(
|
|
18
19
|
provider: provider_for(request_url),
|
|
19
20
|
provider_response_id: response["id"],
|
|
20
21
|
model: response["model"] || request["model"],
|
|
21
|
-
input_tokens: (usage
|
|
22
|
+
input_tokens: regular_input_tokens(usage, cache_read),
|
|
22
23
|
output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
|
|
23
24
|
total_tokens: usage["total_tokens"].to_i,
|
|
24
|
-
|
|
25
|
+
cache_read_input_tokens: cache_read,
|
|
26
|
+
hidden_output_tokens: hidden_output_tokens(usage),
|
|
25
27
|
usage_source: :response
|
|
26
28
|
)
|
|
27
29
|
end
|
|
@@ -32,70 +34,59 @@ module LlmCostTracker
|
|
|
32
34
|
request = safe_json_parse(request_body)
|
|
33
35
|
model = detect_stream_model(events) || request["model"]
|
|
34
36
|
usage = detect_stream_usage(events)
|
|
37
|
+
response_id = detect_stream_response_id(events)
|
|
35
38
|
|
|
36
39
|
if usage
|
|
40
|
+
cache_read = cache_read_input_tokens(usage)
|
|
37
41
|
ParsedUsage.build(
|
|
38
42
|
provider: provider_for(request_url),
|
|
39
|
-
provider_response_id:
|
|
43
|
+
provider_response_id: response_id,
|
|
40
44
|
model: model,
|
|
41
|
-
input_tokens: (usage
|
|
45
|
+
input_tokens: regular_input_tokens(usage, cache_read),
|
|
42
46
|
output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
|
|
43
47
|
total_tokens: usage["total_tokens"].to_i,
|
|
44
|
-
|
|
48
|
+
cache_read_input_tokens: cache_read,
|
|
49
|
+
hidden_output_tokens: hidden_output_tokens(usage),
|
|
45
50
|
stream: true,
|
|
46
51
|
usage_source: :stream_final
|
|
47
52
|
)
|
|
48
53
|
else
|
|
49
|
-
|
|
54
|
+
build_unknown_stream_usage(
|
|
50
55
|
provider: provider_for(request_url),
|
|
51
|
-
provider_response_id: detect_stream_response_id(events),
|
|
52
56
|
model: model,
|
|
53
|
-
|
|
54
|
-
output_tokens: 0,
|
|
55
|
-
total_tokens: 0,
|
|
56
|
-
stream: true,
|
|
57
|
-
usage_source: :unknown
|
|
57
|
+
provider_response_id: response_id
|
|
58
58
|
)
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def detect_stream_usage(events)
|
|
63
|
-
events
|
|
64
|
-
data = event[:data]
|
|
65
|
-
next unless data.is_a?(Hash)
|
|
66
|
-
|
|
63
|
+
find_event_value(events, reverse: true) do |data|
|
|
67
64
|
usage = data["usage"]
|
|
68
|
-
|
|
65
|
+
usage if usage.is_a?(Hash)
|
|
69
66
|
end
|
|
70
|
-
nil
|
|
71
67
|
end
|
|
72
68
|
|
|
73
69
|
def detect_stream_model(events)
|
|
74
|
-
events
|
|
75
|
-
data = event[:data]
|
|
76
|
-
next unless data.is_a?(Hash)
|
|
77
|
-
|
|
78
|
-
model = data["model"]
|
|
79
|
-
return model if model && !model.to_s.empty?
|
|
80
|
-
end
|
|
81
|
-
nil
|
|
70
|
+
find_event_value(events) { |data| data["model"] }
|
|
82
71
|
end
|
|
83
72
|
|
|
84
73
|
def detect_stream_response_id(events)
|
|
85
|
-
events
|
|
86
|
-
|
|
87
|
-
next unless data.is_a?(Hash)
|
|
74
|
+
find_event_value(events) { |data| data["id"] || data.dig("response", "id") }
|
|
75
|
+
end
|
|
88
76
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
end
|
|
92
|
-
nil
|
|
77
|
+
def regular_input_tokens(usage, cache_read)
|
|
78
|
+
[(usage["prompt_tokens"] || usage["input_tokens"]).to_i - cache_read.to_i, 0].max
|
|
93
79
|
end
|
|
94
80
|
|
|
95
|
-
def
|
|
81
|
+
def cache_read_input_tokens(usage)
|
|
96
82
|
details = usage["prompt_tokens_details"] || usage["input_tokens_details"] || {}
|
|
97
83
|
details["cached_tokens"]
|
|
98
84
|
end
|
|
85
|
+
|
|
86
|
+
def hidden_output_tokens(usage)
|
|
87
|
+
details = usage["completion_tokens_details"] || usage["output_tokens_details"] || {}
|
|
88
|
+
details["reasoning_tokens"]
|
|
89
|
+
end
|
|
99
90
|
end
|
|
100
91
|
end
|
|
101
92
|
end
|
|
@@ -13,10 +13,14 @@ module LlmCostTracker
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def register(parser)
|
|
16
|
+
parser = coerce_parser(parser)
|
|
17
|
+
|
|
16
18
|
MUTEX.synchronize do
|
|
17
19
|
current = @parsers || default_parsers.freeze
|
|
18
20
|
@parsers = ([parser] + current).freeze
|
|
19
21
|
end
|
|
22
|
+
|
|
23
|
+
parser
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
def find_for(url)
|
|
@@ -24,8 +28,8 @@ module LlmCostTracker
|
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
def find_for_provider(provider)
|
|
27
|
-
provider_name = provider.to_s
|
|
28
|
-
parsers.find { |parser| parser.
|
|
31
|
+
provider_name = provider.to_s.downcase
|
|
32
|
+
parsers.find { |parser| provider_names_for(parser).include?(provider_name) }
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
def reset!
|
|
@@ -34,6 +38,17 @@ module LlmCostTracker
|
|
|
34
38
|
|
|
35
39
|
private
|
|
36
40
|
|
|
41
|
+
def coerce_parser(parser)
|
|
42
|
+
return parser.new if parser.is_a?(Class) && parser <= Base
|
|
43
|
+
return parser if parser.is_a?(Base)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "parser must be a LlmCostTracker::Parsers::Base instance or class"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def provider_names_for(parser)
|
|
49
|
+
Array(parser.provider_names).map { |name| name.to_s.downcase }
|
|
50
|
+
end
|
|
51
|
+
|
|
37
52
|
def default_parsers
|
|
38
53
|
[Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new]
|
|
39
54
|
end
|
|
@@ -10,7 +10,7 @@ module LlmCostTracker
|
|
|
10
10
|
module PriceRegistry
|
|
11
11
|
DEFAULT_PRICES_PATH = File.expand_path("prices.json", __dir__)
|
|
12
12
|
EMPTY_PRICES = {}.freeze
|
|
13
|
-
PRICE_KEYS = %w[input
|
|
13
|
+
PRICE_KEYS = %w[input output cache_read_input cache_write_input].freeze
|
|
14
14
|
METADATA_KEYS = %w[_source _source_version _fetched_at _updated _notes _validator_override].freeze
|
|
15
15
|
MUTEX = Monitor.new
|
|
16
16
|
|
|
@@ -60,7 +60,7 @@ module LlmCostTracker
|
|
|
60
60
|
def normalize_price_entry(price)
|
|
61
61
|
price.each_with_object({}) do |(key, value), normalized|
|
|
62
62
|
key = key.to_s
|
|
63
|
-
normalized[key.to_sym] = Float(value) if
|
|
63
|
+
normalized[key.to_sym] = Float(value) if price_key?(key)
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
@@ -80,15 +80,25 @@ module LlmCostTracker
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def warn_unknown_keys(model, price, path)
|
|
83
|
-
unknown_keys = price.keys.map(&:to_s)
|
|
83
|
+
unknown_keys = price.keys.map(&:to_s).reject do |key|
|
|
84
|
+
price_key?(key) || METADATA_KEYS.include?(key)
|
|
85
|
+
end
|
|
84
86
|
return if unknown_keys.empty?
|
|
85
87
|
|
|
86
88
|
Logging.warn(
|
|
87
89
|
"Unknown price keys #{unknown_keys.inspect} for #{model.inspect} in #{path}; " \
|
|
88
|
-
"ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}"
|
|
90
|
+
"ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
|
|
89
91
|
)
|
|
90
92
|
end
|
|
91
93
|
|
|
94
|
+
def price_key?(key)
|
|
95
|
+
return true if PRICE_KEYS.include?(key)
|
|
96
|
+
|
|
97
|
+
PRICE_KEYS.any? do |base_key|
|
|
98
|
+
key.end_with?("_#{base_key}") && key.delete_suffix("_#{base_key}") != ""
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
92
102
|
def load_price_file(path)
|
|
93
103
|
contents = File.read(path)
|
|
94
104
|
return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
|
|
@@ -6,7 +6,7 @@ module LlmCostTracker
|
|
|
6
6
|
Discrepancy = Data.define(:model, :field, :values)
|
|
7
7
|
|
|
8
8
|
PRIORITY_ORDER = %i[litellm openrouter].freeze
|
|
9
|
-
SUPPLEMENTAL_FIELDS = %i[
|
|
9
|
+
SUPPLEMENTAL_FIELDS = %i[cache_read_input cache_write_input].freeze
|
|
10
10
|
|
|
11
11
|
def merge(results_by_source)
|
|
12
12
|
prices = collect_prices(results_by_source)
|
|
@@ -7,24 +7,22 @@ module LlmCostTracker
|
|
|
7
7
|
:provider,
|
|
8
8
|
:input,
|
|
9
9
|
:output,
|
|
10
|
-
:cached_input,
|
|
11
10
|
:cache_read_input,
|
|
12
|
-
:
|
|
11
|
+
:cache_write_input,
|
|
13
12
|
:source,
|
|
14
13
|
:source_version,
|
|
15
14
|
:fetched_at
|
|
16
15
|
)
|
|
17
16
|
|
|
18
17
|
class RawPrice
|
|
19
|
-
PRICE_FIELDS = %w[input output
|
|
18
|
+
PRICE_FIELDS = %w[input output cache_read_input cache_write_input].freeze
|
|
20
19
|
|
|
21
20
|
def to_registry_entry(today:)
|
|
22
21
|
{
|
|
23
22
|
"input" => input,
|
|
24
23
|
"output" => output,
|
|
25
|
-
"cached_input" => cached_input,
|
|
26
24
|
"cache_read_input" => cache_read_input,
|
|
27
|
-
"
|
|
25
|
+
"cache_write_input" => cache_write_input,
|
|
28
26
|
"_source" => source.to_s,
|
|
29
27
|
"_source_version" => source_version,
|
|
30
28
|
"_fetched_at" => fetched_at || today.iso8601
|