llm_cost_tracker 0.2.0 → 0.3.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 +36 -0
- data/README.md +124 -68
- data/Rakefile +2 -0
- data/app/assets/llm_cost_tracker/application.css +1 -4
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
- data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +6 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +26 -24
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
- data/app/services/llm_cost_tracker/pagination.rb +1 -9
- data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
- data/app/views/llm_cost_tracker/calls/index.html.erb +23 -13
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +11 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +78 -10
- data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/assets.rb +6 -11
- data/lib/llm_cost_tracker/configuration.rb +78 -43
- data/lib/llm_cost_tracker/event.rb +3 -0
- data/lib/llm_cost_tracker/event_metadata.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +6 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
- data/lib/llm_cost_tracker/llm_api_call.rb +14 -2
- data/lib/llm_cost_tracker/middleware/faraday.rb +58 -9
- data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
- data/lib/llm_cost_tracker/parsed_usage.rb +18 -3
- data/lib/llm_cost_tracker/parsers/anthropic.rb +98 -1
- data/lib/llm_cost_tracker/parsers/base.rb +17 -5
- data/lib/llm_cost_tracker/parsers/gemini.rb +83 -6
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +12 -5
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +69 -1
- data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
- data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
- data/lib/llm_cost_tracker/price_registry.rb +23 -8
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
- data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
- data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
- data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
- data/lib/llm_cost_tracker/price_sync.rb +142 -0
- data/lib/llm_cost_tracker/pricing.rb +0 -11
- data/lib/llm_cost_tracker/railtie.rb +0 -1
- data/lib/llm_cost_tracker/report.rb +0 -5
- data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -9
- data/lib/llm_cost_tracker/stream_collector.rb +162 -0
- data/lib/llm_cost_tracker/tags_column.rb +12 -0
- data/lib/llm_cost_tracker/tracker.rb +23 -12
- data/lib/llm_cost_tracker/value_helpers.rb +40 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +48 -35
- data/lib/tasks/llm_cost_tracker.rake +116 -0
- data/llm_cost_tracker.gemspec +8 -6
- metadata +30 -8
|
@@ -5,19 +5,31 @@ require "json"
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Parsers
|
|
7
7
|
class Base
|
|
8
|
-
# Parse a provider response into a {LlmCostTracker::ParsedUsage}, or return
|
|
9
|
-
# nil when the response is not trackable (non-200, missing usage, etc).
|
|
10
|
-
#
|
|
11
|
-
# @return [LlmCostTracker::ParsedUsage, nil]
|
|
12
8
|
def parse(request_url, request_body, response_status, response_body)
|
|
13
9
|
raise NotImplementedError
|
|
14
10
|
end
|
|
15
11
|
|
|
16
|
-
|
|
12
|
+
def provider_names
|
|
13
|
+
[]
|
|
14
|
+
end
|
|
15
|
+
|
|
17
16
|
def match?(url)
|
|
18
17
|
raise NotImplementedError
|
|
19
18
|
end
|
|
20
19
|
|
|
20
|
+
def streaming_request?(_request_url, request_body)
|
|
21
|
+
return false if request_body.nil?
|
|
22
|
+
|
|
23
|
+
body = request_body.to_s
|
|
24
|
+
return false if body.empty?
|
|
25
|
+
|
|
26
|
+
body.include?('"stream":true') || body.include?('"stream": true') || body.include?("stream: true")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse_stream(_request_url, _request_body, _response_status, _events)
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
21
33
|
private
|
|
22
34
|
|
|
23
35
|
def safe_json_parse(body)
|
|
@@ -9,6 +9,7 @@ module LlmCostTracker
|
|
|
9
9
|
class Gemini < Base
|
|
10
10
|
HOSTS = %w[generativelanguage.googleapis.com].freeze
|
|
11
11
|
TRACKED_PATH_PATTERN = %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}
|
|
12
|
+
STREAM_PATH_PATTERN = /:streamGenerateContent\z/
|
|
12
13
|
|
|
13
14
|
def match?(url)
|
|
14
15
|
uri = URI.parse(url.to_s)
|
|
@@ -17,6 +18,16 @@ module LlmCostTracker
|
|
|
17
18
|
false
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
def provider_names
|
|
22
|
+
%w[gemini]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def streaming_request?(request_url, request_body)
|
|
26
|
+
return true if streaming_url?(request_url)
|
|
27
|
+
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
20
31
|
def parse(request_url, _request_body, response_status, response_body)
|
|
21
32
|
return nil unless response_status == 200
|
|
22
33
|
|
|
@@ -24,31 +35,97 @@ module LlmCostTracker
|
|
|
24
35
|
usage = response["usageMetadata"]
|
|
25
36
|
return nil unless usage
|
|
26
37
|
|
|
27
|
-
|
|
38
|
+
build_parsed_usage(
|
|
39
|
+
request_url,
|
|
40
|
+
usage,
|
|
41
|
+
usage_source: :response,
|
|
42
|
+
provider_response_id: response["responseId"]
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_stream(request_url, _request_body, response_status, events)
|
|
47
|
+
return nil unless response_status == 200
|
|
48
|
+
|
|
49
|
+
usage = merged_stream_usage(events)
|
|
28
50
|
model = extract_model_from_url(request_url)
|
|
29
51
|
|
|
52
|
+
if usage
|
|
53
|
+
build_parsed_usage(
|
|
54
|
+
request_url,
|
|
55
|
+
usage,
|
|
56
|
+
stream: true,
|
|
57
|
+
usage_source: :stream_final,
|
|
58
|
+
provider_response_id: stream_response_id(events)
|
|
59
|
+
)
|
|
60
|
+
else
|
|
61
|
+
ParsedUsage.build(
|
|
62
|
+
provider: "gemini",
|
|
63
|
+
provider_response_id: stream_response_id(events),
|
|
64
|
+
model: model,
|
|
65
|
+
input_tokens: 0,
|
|
66
|
+
output_tokens: 0,
|
|
67
|
+
total_tokens: 0,
|
|
68
|
+
stream: true,
|
|
69
|
+
usage_source: :unknown
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def build_parsed_usage(request_url, usage, usage_source:, stream: false, provider_response_id: nil)
|
|
30
77
|
ParsedUsage.build(
|
|
31
78
|
provider: "gemini",
|
|
32
|
-
model:
|
|
79
|
+
model: extract_model_from_url(request_url),
|
|
33
80
|
input_tokens: usage["promptTokenCount"].to_i,
|
|
34
81
|
output_tokens: output_tokens(usage),
|
|
35
82
|
total_tokens: usage["totalTokenCount"].to_i,
|
|
36
|
-
cached_input_tokens: usage["cachedContentTokenCount"]
|
|
83
|
+
cached_input_tokens: usage["cachedContentTokenCount"],
|
|
84
|
+
stream: stream,
|
|
85
|
+
usage_source: usage_source,
|
|
86
|
+
provider_response_id: provider_response_id
|
|
37
87
|
)
|
|
38
88
|
end
|
|
39
89
|
|
|
40
|
-
|
|
90
|
+
def merged_stream_usage(events)
|
|
91
|
+
latest = nil
|
|
92
|
+
events.each do |event|
|
|
93
|
+
data = event[:data]
|
|
94
|
+
next unless data.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
meta = data["usageMetadata"]
|
|
97
|
+
latest = meta if meta.is_a?(Hash)
|
|
98
|
+
end
|
|
99
|
+
latest
|
|
100
|
+
end
|
|
41
101
|
|
|
42
102
|
def output_tokens(usage)
|
|
43
103
|
usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
|
|
44
104
|
end
|
|
45
105
|
|
|
106
|
+
def stream_response_id(events)
|
|
107
|
+
events.each do |event|
|
|
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
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def streaming_url?(request_url)
|
|
118
|
+
URI.parse(request_url.to_s).path.match?(STREAM_PATH_PATTERN)
|
|
119
|
+
rescue URI::InvalidURIError
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
46
123
|
def extract_model_from_url(url)
|
|
47
124
|
uri = URI.parse(url.to_s)
|
|
48
125
|
match = uri.path.match(%r{/models/([^/:]+)})
|
|
49
|
-
match
|
|
126
|
+
match && match[1]
|
|
50
127
|
rescue URI::InvalidURIError
|
|
51
|
-
|
|
128
|
+
nil
|
|
52
129
|
end
|
|
53
130
|
end
|
|
54
131
|
end
|
|
@@ -20,10 +20,18 @@ module LlmCostTracker
|
|
|
20
20
|
false
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def provider_names
|
|
24
|
+
%w[openai]
|
|
25
|
+
end
|
|
26
|
+
|
|
23
27
|
def parse(request_url, request_body, response_status, response_body)
|
|
24
28
|
parse_openai_usage(request_url, request_body, response_status, response_body)
|
|
25
29
|
end
|
|
26
30
|
|
|
31
|
+
def parse_stream(request_url, request_body, response_status, events)
|
|
32
|
+
parse_openai_stream_usage(request_url, request_body, response_status, events)
|
|
33
|
+
end
|
|
34
|
+
|
|
27
35
|
private
|
|
28
36
|
|
|
29
37
|
def provider_for(_request_url)
|
|
@@ -19,10 +19,21 @@ module LlmCostTracker
|
|
|
19
19
|
false
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def provider_names
|
|
23
|
+
[
|
|
24
|
+
"openai_compatible",
|
|
25
|
+
*LlmCostTracker.configuration.openai_compatible_providers.each_value.map(&:to_s)
|
|
26
|
+
].uniq.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
22
29
|
def parse(request_url, request_body, response_status, response_body)
|
|
23
30
|
parse_openai_usage(request_url, request_body, response_status, response_body)
|
|
24
31
|
end
|
|
25
32
|
|
|
33
|
+
def parse_stream(request_url, request_body, response_status, events)
|
|
34
|
+
parse_openai_stream_usage(request_url, request_body, response_status, events)
|
|
35
|
+
end
|
|
36
|
+
|
|
26
37
|
private
|
|
27
38
|
|
|
28
39
|
def provider_for(request_url)
|
|
@@ -33,11 +44,7 @@ module LlmCostTracker
|
|
|
33
44
|
end
|
|
34
45
|
|
|
35
46
|
def provider_for_host(host)
|
|
36
|
-
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def configured_providers
|
|
40
|
-
LlmCostTracker.configuration.openai_compatible_providers
|
|
47
|
+
LlmCostTracker.configuration.openai_compatible_providers[host.to_s.downcase]&.to_s
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
def tracked_path?(path)
|
|
@@ -16,14 +16,82 @@ module LlmCostTracker
|
|
|
16
16
|
|
|
17
17
|
ParsedUsage.build(
|
|
18
18
|
provider: provider_for(request_url),
|
|
19
|
+
provider_response_id: response["id"],
|
|
19
20
|
model: response["model"] || request["model"],
|
|
20
21
|
input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
|
|
21
22
|
output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
|
|
22
23
|
total_tokens: usage["total_tokens"].to_i,
|
|
23
|
-
cached_input_tokens: cached_input_tokens(usage)
|
|
24
|
+
cached_input_tokens: cached_input_tokens(usage),
|
|
25
|
+
usage_source: :response
|
|
24
26
|
)
|
|
25
27
|
end
|
|
26
28
|
|
|
29
|
+
def parse_openai_stream_usage(request_url, request_body, response_status, events)
|
|
30
|
+
return nil unless response_status == 200
|
|
31
|
+
|
|
32
|
+
request = safe_json_parse(request_body)
|
|
33
|
+
model = detect_stream_model(events) || request["model"]
|
|
34
|
+
usage = detect_stream_usage(events)
|
|
35
|
+
|
|
36
|
+
if usage
|
|
37
|
+
ParsedUsage.build(
|
|
38
|
+
provider: provider_for(request_url),
|
|
39
|
+
provider_response_id: detect_stream_response_id(events),
|
|
40
|
+
model: model,
|
|
41
|
+
input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
|
|
42
|
+
output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
|
|
43
|
+
total_tokens: usage["total_tokens"].to_i,
|
|
44
|
+
cached_input_tokens: cached_input_tokens(usage),
|
|
45
|
+
stream: true,
|
|
46
|
+
usage_source: :stream_final
|
|
47
|
+
)
|
|
48
|
+
else
|
|
49
|
+
ParsedUsage.build(
|
|
50
|
+
provider: provider_for(request_url),
|
|
51
|
+
provider_response_id: detect_stream_response_id(events),
|
|
52
|
+
model: model,
|
|
53
|
+
input_tokens: 0,
|
|
54
|
+
output_tokens: 0,
|
|
55
|
+
total_tokens: 0,
|
|
56
|
+
stream: true,
|
|
57
|
+
usage_source: :unknown
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def detect_stream_usage(events)
|
|
63
|
+
events.reverse_each do |event|
|
|
64
|
+
data = event[:data]
|
|
65
|
+
next unless data.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
usage = data["usage"]
|
|
68
|
+
return usage if usage.is_a?(Hash) && !usage.empty?
|
|
69
|
+
end
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def detect_stream_model(events)
|
|
74
|
+
events.each do |event|
|
|
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
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def detect_stream_response_id(events)
|
|
85
|
+
events.each do |event|
|
|
86
|
+
data = event[:data]
|
|
87
|
+
next unless data.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
id = data["id"] || data.dig("response", "id")
|
|
90
|
+
return id if id && !id.to_s.empty?
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
27
95
|
def cached_input_tokens(usage)
|
|
28
96
|
details = usage["prompt_tokens_details"] || usage["input_tokens_details"] || {}
|
|
29
97
|
details["cached_tokens"]
|
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Parsers
|
|
5
7
|
class Registry
|
|
8
|
+
MUTEX = Monitor.new
|
|
9
|
+
|
|
6
10
|
class << self
|
|
7
11
|
def parsers
|
|
8
|
-
@parsers ||= default_parsers
|
|
12
|
+
@parsers || MUTEX.synchronize { @parsers ||= default_parsers.freeze }
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def register(parser)
|
|
12
|
-
|
|
16
|
+
MUTEX.synchronize do
|
|
17
|
+
current = @parsers || default_parsers.freeze
|
|
18
|
+
@parsers = ([parser] + current).freeze
|
|
19
|
+
end
|
|
13
20
|
end
|
|
14
21
|
|
|
15
22
|
def find_for(url)
|
|
16
23
|
parsers.find { |parser| parser.match?(url) }
|
|
17
24
|
end
|
|
18
25
|
|
|
26
|
+
def find_for_provider(provider)
|
|
27
|
+
provider_name = provider.to_s
|
|
28
|
+
parsers.find { |parser| parser.provider_names.include?(provider_name) }
|
|
29
|
+
end
|
|
30
|
+
|
|
19
31
|
def reset!
|
|
20
|
-
@parsers = nil
|
|
32
|
+
MUTEX.synchronize { @parsers = nil }
|
|
21
33
|
end
|
|
22
34
|
|
|
23
35
|
private
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Parsers
|
|
7
|
+
module SSE
|
|
8
|
+
DONE_MARKER = "[DONE]"
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def parse(body)
|
|
12
|
+
return [] if body.nil? || body.empty?
|
|
13
|
+
|
|
14
|
+
return parse_json_array(body) if probably_json_array?(body)
|
|
15
|
+
|
|
16
|
+
parse_event_stream(body)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def parse_event_stream(body)
|
|
22
|
+
events = []
|
|
23
|
+
current_event = nil
|
|
24
|
+
data_lines = []
|
|
25
|
+
|
|
26
|
+
body.each_line do |raw|
|
|
27
|
+
line = raw.chomp
|
|
28
|
+
|
|
29
|
+
if line.empty?
|
|
30
|
+
events << finalize_event(current_event, data_lines) if data_lines.any?
|
|
31
|
+
current_event = nil
|
|
32
|
+
data_lines = []
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
next if line.start_with?(":")
|
|
37
|
+
|
|
38
|
+
field, _, value = line.partition(":")
|
|
39
|
+
value = value[1..] if value.start_with?(" ")
|
|
40
|
+
|
|
41
|
+
case field
|
|
42
|
+
when "event" then current_event = value
|
|
43
|
+
when "data" then data_lines << value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
events << finalize_event(current_event, data_lines) if data_lines.any?
|
|
48
|
+
events.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_json_array(body)
|
|
52
|
+
parsed = JSON.parse(body)
|
|
53
|
+
return [] unless parsed.is_a?(Array)
|
|
54
|
+
|
|
55
|
+
parsed.map { |entry| { event: nil, data: entry } }
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def finalize_event(event_name, data_lines)
|
|
61
|
+
payload = data_lines.join("\n")
|
|
62
|
+
return nil if payload == DONE_MARKER
|
|
63
|
+
|
|
64
|
+
{ event: event_name, data: decode_data(payload) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def decode_data(payload)
|
|
68
|
+
return payload if payload.empty?
|
|
69
|
+
|
|
70
|
+
JSON.parse(payload)
|
|
71
|
+
rescue JSON::ParserError
|
|
72
|
+
payload
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def probably_json_array?(body)
|
|
76
|
+
body.lstrip.start_with?("[")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -11,7 +11,7 @@ module LlmCostTracker
|
|
|
11
11
|
DEFAULT_PRICES_PATH = File.expand_path("prices.json", __dir__)
|
|
12
12
|
EMPTY_PRICES = {}.freeze
|
|
13
13
|
PRICE_KEYS = %w[input cached_input output cache_read_input cache_creation_input].freeze
|
|
14
|
-
METADATA_KEYS = %w[_source _updated _notes].freeze
|
|
14
|
+
METADATA_KEYS = %w[_source _source_version _fetched_at _updated _notes _validator_override].freeze
|
|
15
15
|
MUTEX = Monitor.new
|
|
16
16
|
|
|
17
17
|
class << self
|
|
@@ -26,9 +26,7 @@ module LlmCostTracker
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def normalize_price_table(table)
|
|
29
|
-
(table
|
|
30
|
-
normalized[model.to_s] = normalize_price_entry(price)
|
|
31
|
-
end
|
|
29
|
+
normalize_price_entries(table, context: "price table")
|
|
32
30
|
end
|
|
33
31
|
|
|
34
32
|
def file_prices(path)
|
|
@@ -47,7 +45,7 @@ module LlmCostTracker
|
|
|
47
45
|
@file_prices_cache = { key: cache_key, value: value }.freeze
|
|
48
46
|
value
|
|
49
47
|
end
|
|
50
|
-
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError
|
|
48
|
+
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
51
49
|
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
52
50
|
end
|
|
53
51
|
|
|
@@ -60,15 +58,23 @@ module LlmCostTracker
|
|
|
60
58
|
end
|
|
61
59
|
|
|
62
60
|
def normalize_price_entry(price)
|
|
63
|
-
|
|
61
|
+
price.each_with_object({}) do |(key, value), normalized|
|
|
64
62
|
key = key.to_s
|
|
65
63
|
normalized[key.to_sym] = Float(value) if PRICE_KEYS.include?(key)
|
|
66
64
|
end
|
|
67
65
|
end
|
|
68
66
|
|
|
69
67
|
def normalize_file_prices(table, path:)
|
|
70
|
-
(table
|
|
71
|
-
|
|
68
|
+
normalize_price_entries(table, context: path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_price_entries(table, context:)
|
|
72
|
+
table = {} if table.nil?
|
|
73
|
+
raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
table.each_with_object({}) do |(model, price), normalized|
|
|
76
|
+
price = validate_price_entry(price, model: model, context: context)
|
|
77
|
+
warn_unknown_keys(model, price, context)
|
|
72
78
|
normalized[model.to_s] = normalize_price_entry(price)
|
|
73
79
|
end
|
|
74
80
|
end
|
|
@@ -95,8 +101,17 @@ module LlmCostTracker
|
|
|
95
101
|
end
|
|
96
102
|
|
|
97
103
|
def price_file_models(registry)
|
|
104
|
+
raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
|
|
105
|
+
|
|
98
106
|
registry.fetch("models", registry)
|
|
99
107
|
end
|
|
108
|
+
|
|
109
|
+
def validate_price_entry(price, model:, context:)
|
|
110
|
+
return {} if price.nil?
|
|
111
|
+
return price if price.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
raise ArgumentError, "price entry for #{model.inspect} in #{context} must be a hash"
|
|
114
|
+
end
|
|
100
115
|
end
|
|
101
116
|
end
|
|
102
117
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "time"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module PriceSync
|
|
10
|
+
class Fetcher
|
|
11
|
+
Response = Data.define(:body, :etag, :last_modified, :not_modified, :fetched_at) do
|
|
12
|
+
def source_version
|
|
13
|
+
etag || last_modified || Digest::SHA256.hexdigest(body.to_s)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
USER_AGENT = "llm_cost_tracker price sync"
|
|
18
|
+
MAX_REDIRECTS = 5
|
|
19
|
+
OPEN_TIMEOUT = 5
|
|
20
|
+
READ_TIMEOUT = 10
|
|
21
|
+
WRITE_TIMEOUT = 10
|
|
22
|
+
|
|
23
|
+
def get(url, etag: nil, redirects: 0)
|
|
24
|
+
raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
|
|
25
|
+
|
|
26
|
+
uri = URI.parse(url)
|
|
27
|
+
request = Net::HTTP::Get.new(uri)
|
|
28
|
+
request["User-Agent"] = USER_AGENT
|
|
29
|
+
request["If-None-Match"] = etag if etag
|
|
30
|
+
|
|
31
|
+
response = Net::HTTP.start(
|
|
32
|
+
uri.host,
|
|
33
|
+
uri.port,
|
|
34
|
+
use_ssl: uri.scheme == "https",
|
|
35
|
+
open_timeout: OPEN_TIMEOUT,
|
|
36
|
+
read_timeout: READ_TIMEOUT,
|
|
37
|
+
write_timeout: WRITE_TIMEOUT
|
|
38
|
+
) do |http|
|
|
39
|
+
http.request(request)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
case response
|
|
43
|
+
when Net::HTTPSuccess
|
|
44
|
+
build_response(response, not_modified: false)
|
|
45
|
+
when Net::HTTPNotModified
|
|
46
|
+
build_response(response, body: nil, not_modified: true)
|
|
47
|
+
when Net::HTTPRedirection
|
|
48
|
+
location = response["location"]
|
|
49
|
+
raise Error, "Redirect without location while fetching #{url}" if location.nil? || location.empty?
|
|
50
|
+
|
|
51
|
+
get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
|
|
52
|
+
else
|
|
53
|
+
raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
|
|
54
|
+
end
|
|
55
|
+
rescue SocketError, SystemCallError, Timeout::Error => e
|
|
56
|
+
raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def build_response(response, not_modified:, body: response.body)
|
|
62
|
+
Response.new(
|
|
63
|
+
body: body,
|
|
64
|
+
etag: response["etag"],
|
|
65
|
+
last_modified: response["last-modified"],
|
|
66
|
+
not_modified: not_modified,
|
|
67
|
+
fetched_at: Time.now.utc.iso8601
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module PriceSync
|
|
5
|
+
class Merger
|
|
6
|
+
Discrepancy = Data.define(:model, :field, :values)
|
|
7
|
+
|
|
8
|
+
PRIORITY_ORDER = %i[litellm openrouter].freeze
|
|
9
|
+
SUPPLEMENTAL_FIELDS = %i[cached_input cache_read_input cache_creation_input].freeze
|
|
10
|
+
|
|
11
|
+
def merge(results_by_source)
|
|
12
|
+
prices = collect_prices(results_by_source)
|
|
13
|
+
discrepancies = []
|
|
14
|
+
|
|
15
|
+
merged = prices.group_by(&:model).sort.to_h.transform_values do |candidates|
|
|
16
|
+
sorted = sort_candidates(candidates)
|
|
17
|
+
discrepancies.concat(detect_discrepancies(sorted))
|
|
18
|
+
fill_missing_fields(sorted.first, sorted.drop(1))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
[merged, discrepancies]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def collect_prices(results_by_source)
|
|
27
|
+
results_by_source.flat_map do |source_name, result|
|
|
28
|
+
result.prices.map do |price|
|
|
29
|
+
price.with(source: source_name)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sort_candidates(candidates)
|
|
35
|
+
candidates.sort_by do |price|
|
|
36
|
+
PRIORITY_ORDER.index(price.source.to_sym) || PRIORITY_ORDER.length
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fill_missing_fields(primary, fallbacks)
|
|
41
|
+
SUPPLEMENTAL_FIELDS.reduce(primary) do |current, field|
|
|
42
|
+
next current if current.public_send(field)
|
|
43
|
+
|
|
44
|
+
fallback = fallbacks.find { |candidate| candidate.public_send(field) }
|
|
45
|
+
fallback ? current.with(field => fallback.public_send(field)) : current
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def detect_discrepancies(candidates)
|
|
50
|
+
return [] if candidates.length < 2
|
|
51
|
+
|
|
52
|
+
RawPrice::PRICE_FIELDS.filter_map do |field|
|
|
53
|
+
values = candidates.each_with_object({}) do |price, collected|
|
|
54
|
+
value = price.public_send(field)
|
|
55
|
+
collected[price.source] = value unless value.nil?
|
|
56
|
+
end
|
|
57
|
+
next if values.size < 2
|
|
58
|
+
next unless discrepant?(values.values)
|
|
59
|
+
|
|
60
|
+
Discrepancy.new(model: candidates.first.model, field: field, values: values)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def discrepant?(values)
|
|
65
|
+
min, max = values.minmax
|
|
66
|
+
return max != min if min.to_f.zero?
|
|
67
|
+
|
|
68
|
+
((max - min).abs / min.to_f) >= 0.05
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|