llm_cost_tracker 0.4.1 → 0.5.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 +30 -0
- data/README.md +132 -405
- 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/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 +6 -5
- data/lib/llm_cost_tracker/parsed_usage.rb +8 -1
- data/lib/llm_cost_tracker/parsers/base.rb +1 -1
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
- 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 +5 -2
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
- data/lib/llm_cost_tracker/price_sync.rb +111 -109
- data/lib/llm_cost_tracker/prices.json +391 -42
- data/lib/llm_cost_tracker/pricing.rb +35 -16
- data/lib/llm_cost_tracker/request_url.rb +20 -0
- data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -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/tracker.rb +7 -60
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +14 -4
- data/lib/tasks/llm_cost_tracker.rake +33 -69
- metadata +28 -12
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +0 -51
- data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
- data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -162
- data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
- data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
- data/lib/llm_cost_tracker/price_sync/validator.rb +0 -66
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Integrations
|
|
7
|
+
module Openai
|
|
8
|
+
extend Base
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def integration_name = :openai
|
|
12
|
+
|
|
13
|
+
def target_patches
|
|
14
|
+
[
|
|
15
|
+
[constant("OpenAI::Resources::Responses"), ResponsesPatch],
|
|
16
|
+
[constant("OpenAI::Resources::Chat::Completions"), ChatCompletionsPatch]
|
|
17
|
+
]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_response(response, request:, latency_ms:)
|
|
21
|
+
return unless active?
|
|
22
|
+
|
|
23
|
+
record_safely do
|
|
24
|
+
usage = ObjectReader.first(response, :usage)
|
|
25
|
+
next unless usage
|
|
26
|
+
|
|
27
|
+
input_tokens = ObjectReader.first(usage, :input_tokens, :prompt_tokens)
|
|
28
|
+
output_tokens = ObjectReader.first(usage, :output_tokens, :completion_tokens)
|
|
29
|
+
next if input_tokens.nil? && output_tokens.nil?
|
|
30
|
+
|
|
31
|
+
LlmCostTracker::Tracker.record(
|
|
32
|
+
provider: "openai",
|
|
33
|
+
model: ObjectReader.first(response, :model) || request[:model],
|
|
34
|
+
input_tokens: ObjectReader.integer(input_tokens),
|
|
35
|
+
output_tokens: ObjectReader.integer(output_tokens),
|
|
36
|
+
latency_ms: latency_ms,
|
|
37
|
+
usage_source: :sdk_response,
|
|
38
|
+
provider_response_id: ObjectReader.first(response, :id),
|
|
39
|
+
metadata: usage_metadata(usage)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def usage_metadata(usage)
|
|
45
|
+
{
|
|
46
|
+
cache_read_input_tokens: cache_read_input_tokens(usage),
|
|
47
|
+
hidden_output_tokens: hidden_output_tokens(usage)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def cache_read_input_tokens(usage)
|
|
52
|
+
ObjectReader.integer(
|
|
53
|
+
ObjectReader.nested(usage, :input_tokens_details, :cached_tokens) ||
|
|
54
|
+
ObjectReader.nested(usage, :prompt_tokens_details, :cached_tokens)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def hidden_output_tokens(usage)
|
|
59
|
+
ObjectReader.integer(
|
|
60
|
+
ObjectReader.nested(usage, :output_tokens_details, :reasoning_tokens) ||
|
|
61
|
+
ObjectReader.nested(usage, :completion_tokens_details, :reasoning_tokens)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
module ResponsesPatch
|
|
67
|
+
def create(*args, **kwargs)
|
|
68
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
69
|
+
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
70
|
+
response = super
|
|
71
|
+
LlmCostTracker::Integrations::Openai.record_response(
|
|
72
|
+
response,
|
|
73
|
+
request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
|
|
74
|
+
latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at)
|
|
75
|
+
)
|
|
76
|
+
response
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
module ChatCompletionsPatch
|
|
81
|
+
def create(*args, **kwargs)
|
|
82
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
84
|
+
response = super
|
|
85
|
+
LlmCostTracker::Integrations::Openai.record_response(
|
|
86
|
+
response,
|
|
87
|
+
request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
|
|
88
|
+
latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at)
|
|
89
|
+
)
|
|
90
|
+
response
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openai"
|
|
4
|
+
require_relative "anthropic"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Integrations
|
|
8
|
+
module Registry
|
|
9
|
+
INTEGRATIONS = {
|
|
10
|
+
openai: Openai,
|
|
11
|
+
anthropic: Anthropic
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def install!(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
17
|
+
normalize(names).each { |name| fetch(name).install }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def checks(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
21
|
+
return [Base::Result.new(:integrations, :ok, "no SDK integrations enabled")] if names.empty?
|
|
22
|
+
|
|
23
|
+
normalize(names).map { |name| fetch(name).status }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def normalize(names)
|
|
27
|
+
Array(names).flatten.map(&:to_sym).uniq
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch(name)
|
|
31
|
+
INTEGRATIONS.fetch(name.to_sym) do
|
|
32
|
+
message = "Unknown integration: #{name.inspect}. Use one of: #{INTEGRATIONS.keys.join(', ')}"
|
|
33
|
+
raise LlmCostTracker::Error, message
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.install! = Registry.install!
|
|
39
|
+
def self.checks = Registry.checks
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -4,6 +4,7 @@ require "faraday"
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
6
|
require_relative "../logging"
|
|
7
|
+
require_relative "../request_url"
|
|
7
8
|
|
|
8
9
|
module LlmCostTracker
|
|
9
10
|
module Middleware
|
|
@@ -76,9 +77,9 @@ module LlmCostTracker
|
|
|
76
77
|
response_body = read_body(response_env.body)
|
|
77
78
|
unless response_body
|
|
78
79
|
Logging.warn(
|
|
79
|
-
"Unable to read response body for #{request_url}; " \
|
|
80
|
-
"streaming responses are captured automatically
|
|
81
|
-
"
|
|
80
|
+
"Unable to read response body for #{RequestUrl.label(request_url)}; " \
|
|
81
|
+
"known streaming responses are captured automatically, or via LlmCostTracker.track_stream " \
|
|
82
|
+
"for custom clients."
|
|
82
83
|
)
|
|
83
84
|
return nil
|
|
84
85
|
end
|
|
@@ -156,11 +157,11 @@ module LlmCostTracker
|
|
|
156
157
|
|
|
157
158
|
def capture_warning(request_url, stream_buffer)
|
|
158
159
|
unless stream_buffer&.dig(:overflowed)
|
|
159
|
-
return "Unable to capture streaming response for #{request_url}; " \
|
|
160
|
+
return "Unable to capture streaming response for #{RequestUrl.label(request_url)}; " \
|
|
160
161
|
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
161
162
|
end
|
|
162
163
|
|
|
163
|
-
"Streaming response for #{request_url} exceeded #{STREAM_CAPTURE_LIMIT_BYTES} bytes; " \
|
|
164
|
+
"Streaming response for #{RequestUrl.label(request_url)} exceeded #{STREAM_CAPTURE_LIMIT_BYTES} bytes; " \
|
|
164
165
|
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
165
166
|
end
|
|
166
167
|
end
|
|
@@ -16,6 +16,7 @@ module LlmCostTracker
|
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
class ParsedUsage
|
|
19
|
+
UNKNOWN_MODEL = "unknown"
|
|
19
20
|
TRACKING_KEYS = %i[
|
|
20
21
|
provider
|
|
21
22
|
model
|
|
@@ -30,7 +31,7 @@ module LlmCostTracker
|
|
|
30
31
|
def self.build(**attributes)
|
|
31
32
|
new(
|
|
32
33
|
provider: attributes.fetch(:provider),
|
|
33
|
-
model: attributes.fetch(:model),
|
|
34
|
+
model: normalize_model(attributes.fetch(:model)),
|
|
34
35
|
input_tokens: attributes.fetch(:input_tokens).to_i,
|
|
35
36
|
output_tokens: attributes.fetch(:output_tokens).to_i,
|
|
36
37
|
total_tokens: attributes.fetch(:total_tokens, usage_breakdown(attributes).total_tokens).to_i,
|
|
@@ -61,5 +62,11 @@ module LlmCostTracker
|
|
|
61
62
|
)
|
|
62
63
|
end
|
|
63
64
|
private_class_method :usage_breakdown
|
|
65
|
+
|
|
66
|
+
def self.normalize_model(value)
|
|
67
|
+
model = value.to_s.strip
|
|
68
|
+
model.empty? ? UNKNOWN_MODEL : model
|
|
69
|
+
end
|
|
70
|
+
private_class_method :normalize_model
|
|
64
71
|
end
|
|
65
72
|
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:refresh"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def missing
|
|
26
|
+
[:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:refresh"]
|
|
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:refresh"
|
|
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
|
|
|
@@ -14,7 +15,7 @@ module LlmCostTracker
|
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
USER_AGENT = "llm_cost_tracker price
|
|
18
|
+
USER_AGENT = "llm_cost_tracker price refresh"
|
|
18
19
|
MAX_REDIRECTS = 5
|
|
19
20
|
OPEN_TIMEOUT = 5
|
|
20
21
|
READ_TIMEOUT = 10
|
|
@@ -24,6 +25,8 @@ module LlmCostTracker
|
|
|
24
25
|
raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
|
|
25
26
|
|
|
26
27
|
uri = URI.parse(url)
|
|
28
|
+
raise Error, "Pricing snapshot URL must use http or https" unless %w[http https].include?(uri.scheme)
|
|
29
|
+
|
|
27
30
|
request = Net::HTTP::Get.new(uri)
|
|
28
31
|
request["User-Agent"] = USER_AGENT
|
|
29
32
|
request["If-None-Match"] = etag if etag
|
|
@@ -52,7 +55,7 @@ module LlmCostTracker
|
|
|
52
55
|
else
|
|
53
56
|
raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
|
|
54
57
|
end
|
|
55
|
-
rescue SocketError, SystemCallError, Timeout::Error => e
|
|
58
|
+
rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
|
|
56
59
|
raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
|
|
57
60
|
end
|
|
58
61
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module PriceSync
|
|
5
|
+
module RegistryDiff
|
|
6
|
+
class << self
|
|
7
|
+
def call(current_models, updated_models)
|
|
8
|
+
current_models = normalize_models(current_models)
|
|
9
|
+
updated_models = normalize_models(updated_models)
|
|
10
|
+
|
|
11
|
+
(current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
|
|
12
|
+
fields = price_field_changes(current_models[model], updated_models[model])
|
|
13
|
+
changes[model] = fields if fields.any?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def price_field_changes(current_entry, updated_entry)
|
|
20
|
+
current_price = comparable_price(current_entry)
|
|
21
|
+
updated_price = comparable_price(updated_entry)
|
|
22
|
+
|
|
23
|
+
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
24
|
+
from = current_price[field]
|
|
25
|
+
to = updated_price[field]
|
|
26
|
+
next if from == to
|
|
27
|
+
|
|
28
|
+
changes[field] = { "from" => from, "to" => to }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def comparable_price(entry)
|
|
33
|
+
normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def normalize_models(models)
|
|
37
|
+
normalize_hash(models).transform_values { |entry| normalize_hash(entry) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def normalize_hash(hash)
|
|
41
|
+
return {} if hash.nil?
|
|
42
|
+
raise Error, "pricing entries must be hashes" unless hash.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
45
|
+
normalized[key.to_s] = value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -12,7 +12,11 @@ module LlmCostTracker
|
|
|
12
12
|
def call(path:, registry:)
|
|
13
13
|
FileUtils.mkdir_p(File.dirname(path))
|
|
14
14
|
payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
|
|
15
|
-
|
|
15
|
+
temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
16
|
+
File.write(temp_path, payload)
|
|
17
|
+
File.rename(temp_path, path)
|
|
18
|
+
ensure
|
|
19
|
+
FileUtils.rm_f(temp_path) if temp_path && File.exist?(temp_path)
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
private
|
|
@@ -1,141 +1,143 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "rubygems"
|
|
4
6
|
|
|
5
7
|
require_relative "price_sync/fetcher"
|
|
6
|
-
require_relative "price_sync/
|
|
7
|
-
require_relative "price_sync/source"
|
|
8
|
-
require_relative "price_sync/source_result"
|
|
8
|
+
require_relative "price_sync/registry_diff"
|
|
9
9
|
require_relative "price_sync/registry_loader"
|
|
10
10
|
require_relative "price_sync/registry_writer"
|
|
11
|
-
require_relative "price_sync/refresh_plan_builder"
|
|
12
|
-
require_relative "price_sync/model_catalog"
|
|
13
|
-
require_relative "price_sync/merger"
|
|
14
|
-
require_relative "price_sync/validator"
|
|
15
|
-
require_relative "price_sync/sources/litellm"
|
|
16
|
-
require_relative "price_sync/sources/open_router"
|
|
17
11
|
|
|
18
12
|
module LlmCostTracker
|
|
19
13
|
module PriceSync
|
|
20
|
-
DEFAULT_OUTPUT_PATH =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
:changes,
|
|
38
|
-
:orphaned_models,
|
|
39
|
-
:failed_sources,
|
|
40
|
-
:discrepancies,
|
|
41
|
-
:rejected,
|
|
42
|
-
:flagged,
|
|
43
|
-
:sources_used,
|
|
44
|
-
:up_to_date
|
|
45
|
-
)
|
|
46
|
-
RefreshPlan = Data.define(
|
|
47
|
-
:path,
|
|
48
|
-
:registry,
|
|
49
|
-
:updated_registry,
|
|
50
|
-
:accepted,
|
|
51
|
-
:changes,
|
|
52
|
-
:orphaned_models,
|
|
53
|
-
:failed_sources,
|
|
54
|
-
:discrepancies,
|
|
55
|
-
:rejected,
|
|
56
|
-
:flagged,
|
|
57
|
-
:sources_used,
|
|
58
|
-
:source_results
|
|
59
|
-
) do
|
|
60
|
-
def refresh_succeeded?
|
|
61
|
-
source_results.any? { |_source, result| result.prices.any? }
|
|
14
|
+
DEFAULT_OUTPUT_PATH = "config/llm_cost_tracker_prices.yml"
|
|
15
|
+
DEFAULT_REMOTE_URL =
|
|
16
|
+
"https://raw.githubusercontent.com/sergey-homenko/llm_cost_tracker/main/lib/llm_cost_tracker/prices.json"
|
|
17
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
18
|
+
|
|
19
|
+
RefreshResult = Data.define(:path, :source_url, :source_version, :changes, :written, :not_modified)
|
|
20
|
+
CheckResult = Data.define(:path, :source_url, :source_version, :changes, :up_to_date)
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
|
|
24
|
+
output = env["OUTPUT"].to_s.strip
|
|
25
|
+
return output unless output.empty?
|
|
26
|
+
|
|
27
|
+
prices_file = config.prices_file
|
|
28
|
+
return prices_file.to_s if prices_file
|
|
29
|
+
|
|
30
|
+
default_output_path
|
|
62
31
|
end
|
|
63
32
|
|
|
64
|
-
def
|
|
65
|
-
|
|
33
|
+
def configured_remote_url(env: ENV)
|
|
34
|
+
url = env["URL"].to_s.strip
|
|
35
|
+
url.empty? ? DEFAULT_REMOTE_URL : url
|
|
66
36
|
end
|
|
67
|
-
end
|
|
68
37
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
written
|
|
81
|
-
RegistryWriter.new.call(path: plan.path, registry: plan.updated_registry) if written
|
|
82
|
-
|
|
83
|
-
SyncResult.new(
|
|
84
|
-
path: plan.path,
|
|
85
|
-
updated_models: plan.changes.keys.sort,
|
|
86
|
-
changes: plan.changes,
|
|
87
|
-
orphaned_models: plan.orphaned_models,
|
|
88
|
-
failed_sources: plan.failed_sources,
|
|
89
|
-
discrepancies: plan.discrepancies,
|
|
90
|
-
rejected: plan.rejected,
|
|
91
|
-
flagged: plan.flagged,
|
|
92
|
-
sources_used: plan.sources_used,
|
|
93
|
-
written: written
|
|
94
|
-
)
|
|
38
|
+
def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
|
|
39
|
+
today: Date.today)
|
|
40
|
+
current = load_current_registry(path)
|
|
41
|
+
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
42
|
+
|
|
43
|
+
if response.not_modified
|
|
44
|
+
return refresh_result(path, url, response, current, current, written: false, not_modified: true)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
48
|
+
RegistryWriter.new.call(path: path, registry: remote) unless preview
|
|
49
|
+
refresh_result(path, url, response, current, remote, written: !preview, not_modified: false)
|
|
95
50
|
end
|
|
96
51
|
|
|
97
|
-
def check(path: DEFAULT_OUTPUT_PATH,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
52
|
+
def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
|
|
53
|
+
current = load_current_registry(path)
|
|
54
|
+
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
55
|
+
|
|
56
|
+
if response.not_modified
|
|
57
|
+
return CheckResult.new(
|
|
58
|
+
path: path,
|
|
59
|
+
source_url: url,
|
|
60
|
+
source_version: response.source_version,
|
|
61
|
+
changes: {},
|
|
62
|
+
up_to_date: true
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
67
|
+
changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
|
|
104
68
|
|
|
105
69
|
CheckResult.new(
|
|
106
|
-
path:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
rejected: plan.rejected,
|
|
112
|
-
flagged: plan.flagged,
|
|
113
|
-
sources_used: plan.sources_used,
|
|
114
|
-
up_to_date: plan.up_to_date?
|
|
70
|
+
path: path,
|
|
71
|
+
source_url: url,
|
|
72
|
+
source_version: response.source_version,
|
|
73
|
+
changes: changes,
|
|
74
|
+
up_to_date: changes.empty?
|
|
115
75
|
)
|
|
116
76
|
end
|
|
117
77
|
|
|
118
78
|
private
|
|
119
79
|
|
|
120
|
-
def
|
|
121
|
-
|
|
80
|
+
def default_output_path
|
|
81
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
82
|
+
Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
|
|
83
|
+
else
|
|
84
|
+
DEFAULT_OUTPUT_PATH
|
|
85
|
+
end
|
|
122
86
|
end
|
|
123
87
|
|
|
124
|
-
def
|
|
125
|
-
|
|
88
|
+
def load_current_registry(path)
|
|
89
|
+
RegistryLoader.new.call(path: path, seed_path: PriceRegistry::DEFAULT_PRICES_PATH)
|
|
126
90
|
end
|
|
127
91
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
92
|
+
def normalize_remote_registry(body, url:, response:, today:)
|
|
93
|
+
registry = parse_registry(body)
|
|
94
|
+
metadata = registry.fetch("metadata", {})
|
|
95
|
+
raise Error, "remote pricing metadata must be a hash" unless metadata.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
schema_version = Integer(metadata.fetch("schema_version", 1))
|
|
98
|
+
if schema_version > SUPPORTED_SCHEMA_VERSION
|
|
99
|
+
raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
|
|
133
100
|
end
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
101
|
+
|
|
102
|
+
min_gem_version = metadata["min_gem_version"]
|
|
103
|
+
if min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
|
|
104
|
+
raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
|
|
137
105
|
end
|
|
138
|
-
|
|
106
|
+
|
|
107
|
+
models = registry.fetch("models", {})
|
|
108
|
+
PriceRegistry.normalize_price_table(models)
|
|
109
|
+
|
|
110
|
+
registry.merge(
|
|
111
|
+
"metadata" => metadata.merge(
|
|
112
|
+
"schema_version" => schema_version,
|
|
113
|
+
"updated_at" => metadata["updated_at"] || today.iso8601,
|
|
114
|
+
"source_url" => url,
|
|
115
|
+
"source_version" => response.source_version
|
|
116
|
+
),
|
|
117
|
+
"models" => models
|
|
118
|
+
)
|
|
119
|
+
rescue ArgumentError, TypeError => e
|
|
120
|
+
raise Error, "Unable to load remote pricing snapshot: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_registry(body)
|
|
124
|
+
registry = JSON.parse(body.to_s)
|
|
125
|
+
raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
|
|
126
|
+
|
|
127
|
+
registry
|
|
128
|
+
rescue JSON::ParserError => e
|
|
129
|
+
raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def refresh_result(path, url, response, current, remote, written:, not_modified:)
|
|
133
|
+
RefreshResult.new(
|
|
134
|
+
path: path,
|
|
135
|
+
source_url: url,
|
|
136
|
+
source_version: response.source_version,
|
|
137
|
+
changes: RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {})),
|
|
138
|
+
written: written,
|
|
139
|
+
not_modified: not_modified
|
|
140
|
+
)
|
|
139
141
|
end
|
|
140
142
|
end
|
|
141
143
|
end
|