llm_cost_tracker 0.4.0 → 0.5.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 +35 -0
- data/README.md +195 -109
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +46 -55
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +81 -0
- data/lib/llm_cost_tracker/budget.rb +34 -37
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +37 -0
- data/lib/llm_cost_tracker/configuration.rb +10 -5
- data/lib/llm_cost_tracker/doctor.rb +166 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +33 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +12 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +38 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +1 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +53 -21
- data/lib/llm_cost_tracker/integrations/anthropic.rb +75 -0
- data/lib/llm_cost_tracker/integrations/base.rb +72 -0
- data/lib/llm_cost_tracker/integrations/object_reader.rb +56 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +95 -0
- data/lib/llm_cost_tracker/integrations/registry.rb +41 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +4 -3
- data/lib/llm_cost_tracker/parsed_usage.rb +8 -1
- data/lib/llm_cost_tracker/parsers/anthropic.rb +17 -49
- data/lib/llm_cost_tracker/parsers/base.rb +80 -0
- data/lib/llm_cost_tracker/parsers/gemini.rb +12 -35
- 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 +8 -30
- data/lib/llm_cost_tracker/parsers/registry.rb +17 -2
- data/lib/llm_cost_tracker/price_freshness.rb +38 -0
- data/lib/llm_cost_tracker/price_registry.rb +14 -0
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +2 -1
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +4 -2
- data/lib/llm_cost_tracker/price_sync.rb +10 -0
- data/lib/llm_cost_tracker/prices.json +394 -41
- data/lib/llm_cost_tracker/pricing.rb +8 -1
- data/lib/llm_cost_tracker/request_url.rb +20 -0
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +47 -27
- data/lib/llm_cost_tracker/storage/active_record_store.rb +4 -0
- data/lib/llm_cost_tracker/stream_collector.rb +3 -3
- data/lib/llm_cost_tracker/tag_context.rb +52 -0
- data/lib/llm_cost_tracker/tags_column.rb +62 -24
- data/lib/llm_cost_tracker/tracker.rb +5 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +14 -4
- data/lib/tasks/llm_cost_tracker.rake +21 -3
- metadata +13 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +0 -51
|
@@ -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
|
|
@@ -52,25 +47,25 @@ module LlmCostTracker
|
|
|
52
47
|
usage = stream_usage(events)
|
|
53
48
|
response_id = stream_response_id(events)
|
|
54
49
|
|
|
55
|
-
|
|
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
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
private
|
|
59
62
|
|
|
60
63
|
def stream_usage(events)
|
|
61
|
-
start_usage =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
events
|
|
65
|
-
data
|
|
66
|
-
next unless data.is_a?(Hash)
|
|
67
|
-
|
|
68
|
-
case data["type"]
|
|
69
|
-
when "message_start"
|
|
70
|
-
start_usage = data.dig("message", "usage")
|
|
71
|
-
when "message_delta"
|
|
72
|
-
latest_delta = data["usage"] if data["usage"].is_a?(Hash)
|
|
73
|
-
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)
|
|
74
69
|
end
|
|
75
70
|
|
|
76
71
|
return nil unless start_usage || latest_delta
|
|
@@ -81,25 +76,11 @@ module LlmCostTracker
|
|
|
81
76
|
end
|
|
82
77
|
|
|
83
78
|
def stream_model(events)
|
|
84
|
-
events
|
|
85
|
-
data = event[:data]
|
|
86
|
-
next unless data.is_a?(Hash)
|
|
87
|
-
|
|
88
|
-
model = data.dig("message", "model")
|
|
89
|
-
return model if model && !model.empty?
|
|
90
|
-
end
|
|
91
|
-
nil
|
|
79
|
+
find_event_value(events) { |data| data.dig("message", "model") }
|
|
92
80
|
end
|
|
93
81
|
|
|
94
82
|
def stream_response_id(events)
|
|
95
|
-
events
|
|
96
|
-
data = event[:data]
|
|
97
|
-
next unless data.is_a?(Hash)
|
|
98
|
-
|
|
99
|
-
id = data.dig("message", "id") || data["id"]
|
|
100
|
-
return id if id && !id.to_s.empty?
|
|
101
|
-
end
|
|
102
|
-
nil
|
|
83
|
+
find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
|
|
103
84
|
end
|
|
104
85
|
|
|
105
86
|
def build_stream_result(model, usage, response_id)
|
|
@@ -121,19 +102,6 @@ module LlmCostTracker
|
|
|
121
102
|
usage_source: :stream_final
|
|
122
103
|
)
|
|
123
104
|
end
|
|
124
|
-
|
|
125
|
-
def build_unknown_stream_result(model, response_id)
|
|
126
|
-
ParsedUsage.build(
|
|
127
|
-
provider: "anthropic",
|
|
128
|
-
provider_response_id: response_id,
|
|
129
|
-
model: model,
|
|
130
|
-
input_tokens: 0,
|
|
131
|
-
output_tokens: 0,
|
|
132
|
-
total_tokens: 0,
|
|
133
|
-
stream: true,
|
|
134
|
-
usage_source: :unknown
|
|
135
|
-
)
|
|
136
|
-
end
|
|
137
105
|
end
|
|
138
106
|
end
|
|
139
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 || ParsedUsage::UNKNOWN_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
|
|
@@ -91,15 +82,10 @@ module LlmCostTracker
|
|
|
91
82
|
end
|
|
92
83
|
|
|
93
84
|
def merged_stream_usage(events)
|
|
94
|
-
|
|
95
|
-
events.each do |event|
|
|
96
|
-
data = event[:data]
|
|
97
|
-
next unless data.is_a?(Hash)
|
|
98
|
-
|
|
85
|
+
find_event_value(events, reverse: true) do |data|
|
|
99
86
|
meta = data["usageMetadata"]
|
|
100
|
-
|
|
87
|
+
meta if meta.is_a?(Hash)
|
|
101
88
|
end
|
|
102
|
-
latest
|
|
103
89
|
end
|
|
104
90
|
|
|
105
91
|
def output_tokens(usage)
|
|
@@ -107,28 +93,19 @@ module LlmCostTracker
|
|
|
107
93
|
end
|
|
108
94
|
|
|
109
95
|
def stream_response_id(events)
|
|
110
|
-
events
|
|
111
|
-
data = event[:data]
|
|
112
|
-
next unless data.is_a?(Hash)
|
|
113
|
-
|
|
114
|
-
id = data["responseId"]
|
|
115
|
-
return id if id && !id.to_s.empty?
|
|
116
|
-
end
|
|
117
|
-
nil
|
|
96
|
+
find_event_value(events) { |data| data["responseId"] }
|
|
118
97
|
end
|
|
119
98
|
|
|
120
99
|
def streaming_url?(request_url)
|
|
121
|
-
|
|
122
|
-
rescue URI::InvalidURIError
|
|
123
|
-
false
|
|
100
|
+
match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
|
|
124
101
|
end
|
|
125
102
|
|
|
126
103
|
def extract_model_from_url(url)
|
|
127
|
-
uri =
|
|
104
|
+
uri = parsed_uri(url)
|
|
105
|
+
return nil unless uri
|
|
106
|
+
|
|
128
107
|
match = uri.path.match(%r{/models/([^/:]+)})
|
|
129
108
|
match && match[1]
|
|
130
|
-
rescue URI::InvalidURIError
|
|
131
|
-
nil
|
|
132
109
|
end
|
|
133
110
|
end
|
|
134
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
|
|
@@ -34,12 +34,13 @@ module LlmCostTracker
|
|
|
34
34
|
request = safe_json_parse(request_body)
|
|
35
35
|
model = detect_stream_model(events) || request["model"]
|
|
36
36
|
usage = detect_stream_usage(events)
|
|
37
|
+
response_id = detect_stream_response_id(events)
|
|
37
38
|
|
|
38
39
|
if usage
|
|
39
40
|
cache_read = cache_read_input_tokens(usage)
|
|
40
41
|
ParsedUsage.build(
|
|
41
42
|
provider: provider_for(request_url),
|
|
42
|
-
provider_response_id:
|
|
43
|
+
provider_response_id: response_id,
|
|
43
44
|
model: model,
|
|
44
45
|
input_tokens: regular_input_tokens(usage, cache_read),
|
|
45
46
|
output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
|
|
@@ -50,50 +51,27 @@ module LlmCostTracker
|
|
|
50
51
|
usage_source: :stream_final
|
|
51
52
|
)
|
|
52
53
|
else
|
|
53
|
-
|
|
54
|
+
build_unknown_stream_usage(
|
|
54
55
|
provider: provider_for(request_url),
|
|
55
|
-
provider_response_id: detect_stream_response_id(events),
|
|
56
56
|
model: model,
|
|
57
|
-
|
|
58
|
-
output_tokens: 0,
|
|
59
|
-
total_tokens: 0,
|
|
60
|
-
stream: true,
|
|
61
|
-
usage_source: :unknown
|
|
57
|
+
provider_response_id: response_id
|
|
62
58
|
)
|
|
63
59
|
end
|
|
64
60
|
end
|
|
65
61
|
|
|
66
62
|
def detect_stream_usage(events)
|
|
67
|
-
events
|
|
68
|
-
data = event[:data]
|
|
69
|
-
next unless data.is_a?(Hash)
|
|
70
|
-
|
|
63
|
+
find_event_value(events, reverse: true) do |data|
|
|
71
64
|
usage = data["usage"]
|
|
72
|
-
|
|
65
|
+
usage if usage.is_a?(Hash)
|
|
73
66
|
end
|
|
74
|
-
nil
|
|
75
67
|
end
|
|
76
68
|
|
|
77
69
|
def detect_stream_model(events)
|
|
78
|
-
events
|
|
79
|
-
data = event[:data]
|
|
80
|
-
next unless data.is_a?(Hash)
|
|
81
|
-
|
|
82
|
-
model = data["model"]
|
|
83
|
-
return model if model && !model.to_s.empty?
|
|
84
|
-
end
|
|
85
|
-
nil
|
|
70
|
+
find_event_value(events) { |data| data["model"] || data.dig("response", "model") }
|
|
86
71
|
end
|
|
87
72
|
|
|
88
73
|
def detect_stream_response_id(events)
|
|
89
|
-
events
|
|
90
|
-
data = event[:data]
|
|
91
|
-
next unless data.is_a?(Hash)
|
|
92
|
-
|
|
93
|
-
id = data["id"] || data.dig("response", "id")
|
|
94
|
-
return id if id && !id.to_s.empty?
|
|
95
|
-
end
|
|
96
|
-
nil
|
|
74
|
+
find_event_value(events) { |data| data["id"] || data.dig("response", "id") }
|
|
97
75
|
end
|
|
98
76
|
|
|
99
77
|
def regular_input_tokens(usage, cache_read)
|
|
@@ -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
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module PriceFreshness
|
|
7
|
+
STALE_AFTER_DAYS = 30
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def call(metadata, today: Date.today)
|
|
11
|
+
updated_at = metadata["updated_at"] || metadata[:updated_at]
|
|
12
|
+
return missing unless updated_at
|
|
13
|
+
|
|
14
|
+
date = Date.iso8601(updated_at.to_s)
|
|
15
|
+
age_days = (today - date).to_i
|
|
16
|
+
return stale(updated_at) if age_days > STALE_AFTER_DAYS
|
|
17
|
+
|
|
18
|
+
[:ok, "updated_at=#{updated_at}"]
|
|
19
|
+
rescue Date::Error
|
|
20
|
+
[:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; run bin/rails llm_cost_tracker:prices:sync"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def missing
|
|
26
|
+
[:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:sync"]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stale(updated_at)
|
|
30
|
+
[
|
|
31
|
+
:warn,
|
|
32
|
+
"updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; " \
|
|
33
|
+
"run bin/rails llm_cost_tracker:prices:sync"
|
|
34
|
+
]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -25,6 +25,20 @@ module LlmCostTracker
|
|
|
25
25
|
@metadata ||= MUTEX.synchronize { @metadata || raw_registry.fetch("metadata", {}).freeze }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def file_metadata(path)
|
|
29
|
+
return {} unless path
|
|
30
|
+
|
|
31
|
+
registry = load_price_file(path.to_s)
|
|
32
|
+
raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
metadata = registry.fetch("metadata", {})
|
|
35
|
+
raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
metadata
|
|
38
|
+
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
39
|
+
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
28
42
|
def normalize_price_table(table)
|
|
29
43
|
normalize_price_entries(table, context: "price table")
|
|
30
44
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "net/http"
|
|
5
|
+
require "openssl"
|
|
5
6
|
require "time"
|
|
6
7
|
require "uri"
|
|
7
8
|
|
|
@@ -52,7 +53,7 @@ module LlmCostTracker
|
|
|
52
53
|
else
|
|
53
54
|
raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
|
|
54
55
|
end
|
|
55
|
-
rescue SocketError, SystemCallError, Timeout::Error => e
|
|
56
|
+
rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
|
|
56
57
|
raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
|
|
57
58
|
end
|
|
58
59
|
|
|
@@ -31,7 +31,7 @@ module LlmCostTracker
|
|
|
31
31
|
),
|
|
32
32
|
accepted: validated.accepted,
|
|
33
33
|
changes: price_changes(current_models, updated_models),
|
|
34
|
-
orphaned_models: compute_orphaned(current_models, merged.keys),
|
|
34
|
+
orphaned_models: compute_orphaned(current_models, merged.keys, source_results),
|
|
35
35
|
failed_sources: failed_sources,
|
|
36
36
|
discrepancies: discrepancies,
|
|
37
37
|
rejected: validated.rejected,
|
|
@@ -70,7 +70,9 @@ module LlmCostTracker
|
|
|
70
70
|
merged.sort.to_h
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def compute_orphaned(current_models, merged_models)
|
|
73
|
+
def compute_orphaned(current_models, merged_models, source_results)
|
|
74
|
+
return [] if source_results.empty?
|
|
75
|
+
|
|
74
76
|
seed_models(current_models).keys.reject do |model|
|
|
75
77
|
manual_model?(current_models[model]) || merged_models.include?(model)
|
|
76
78
|
end.sort
|
|
@@ -67,6 +67,16 @@ module LlmCostTracker
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
class << self
|
|
70
|
+
def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
|
|
71
|
+
output = env["OUTPUT"].to_s.strip
|
|
72
|
+
return output unless output.empty?
|
|
73
|
+
|
|
74
|
+
prices_file = config.prices_file
|
|
75
|
+
return prices_file.to_s if prices_file
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
70
80
|
def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
|
|
71
81
|
fetcher: Fetcher.new, today: Date.today)
|
|
72
82
|
plan = RefreshPlanBuilder.new(sources: sources).call(
|