llm_cost_tracker 0.5.0 → 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 +11 -0
- data/README.md +112 -467
- data/lib/llm_cost_tracker/doctor.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -2
- data/lib/llm_cost_tracker/price_freshness.rb +3 -3
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +3 -1
- 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 +103 -111
- data/lib/llm_cost_tracker/prices.json +225 -229
- data/lib/llm_cost_tracker/pricing.rb +27 -15
- data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
- data/lib/llm_cost_tracker/tracker.rb +2 -58
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/tasks/llm_cost_tracker.rake +24 -78
- metadata +18 -11
- 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 -164
- 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
|
@@ -160,7 +160,7 @@ module LlmCostTracker
|
|
|
160
160
|
def feature_generators(columns) = columns.map { |column| FEATURE_COLUMNS.fetch(column) }.uniq
|
|
161
161
|
|
|
162
162
|
def builtin_prices_updated_at
|
|
163
|
-
LlmCostTracker::
|
|
163
|
+
LlmCostTracker::PriceRegistry.metadata.fetch("updated_at", "unknown")
|
|
164
164
|
end
|
|
165
165
|
end
|
|
166
166
|
end
|
|
@@ -33,7 +33,7 @@ LlmCostTracker.configure do |config|
|
|
|
33
33
|
<% if options[:prices] -%>
|
|
34
34
|
|
|
35
35
|
# Local JSON/YAML pricing file generated by --prices. Keep it in source control
|
|
36
|
-
# and refresh it with bin/rails llm_cost_tracker:prices:
|
|
36
|
+
# and refresh it with bin/rails llm_cost_tracker:prices:refresh.
|
|
37
37
|
config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
38
38
|
<% end -%>
|
|
39
39
|
|
|
@@ -78,8 +78,8 @@ module LlmCostTracker
|
|
|
78
78
|
unless response_body
|
|
79
79
|
Logging.warn(
|
|
80
80
|
"Unable to read response body for #{RequestUrl.label(request_url)}; " \
|
|
81
|
-
"streaming responses are captured automatically
|
|
82
|
-
"
|
|
81
|
+
"known streaming responses are captured automatically, or via LlmCostTracker.track_stream " \
|
|
82
|
+
"for custom clients."
|
|
83
83
|
)
|
|
84
84
|
return nil
|
|
85
85
|
end
|
|
@@ -17,20 +17,20 @@ module LlmCostTracker
|
|
|
17
17
|
|
|
18
18
|
[:ok, "updated_at=#{updated_at}"]
|
|
19
19
|
rescue Date::Error
|
|
20
|
-
[:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; run bin/rails llm_cost_tracker:prices:
|
|
20
|
+
[:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; run bin/rails llm_cost_tracker:prices:refresh"]
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
private
|
|
24
24
|
|
|
25
25
|
def missing
|
|
26
|
-
[:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:
|
|
26
|
+
[:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:refresh"]
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def stale(updated_at)
|
|
30
30
|
[
|
|
31
31
|
:warn,
|
|
32
32
|
"updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; " \
|
|
33
|
-
"run bin/rails llm_cost_tracker:prices:
|
|
33
|
+
"run bin/rails llm_cost_tracker:prices:refresh"
|
|
34
34
|
]
|
|
35
35
|
end
|
|
36
36
|
end
|
|
@@ -15,7 +15,7 @@ module LlmCostTracker
|
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
USER_AGENT = "llm_cost_tracker price
|
|
18
|
+
USER_AGENT = "llm_cost_tracker price refresh"
|
|
19
19
|
MAX_REDIRECTS = 5
|
|
20
20
|
OPEN_TIMEOUT = 5
|
|
21
21
|
READ_TIMEOUT = 10
|
|
@@ -25,6 +25,8 @@ module LlmCostTracker
|
|
|
25
25
|
raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
|
|
26
26
|
|
|
27
27
|
uri = URI.parse(url)
|
|
28
|
+
raise Error, "Pricing snapshot URL must use http or https" unless %w[http https].include?(uri.scheme)
|
|
29
|
+
|
|
28
30
|
request = Net::HTTP::Get.new(uri)
|
|
29
31
|
request["User-Agent"] = USER_AGENT
|
|
30
32
|
request["If-None-Match"] = etag if etag
|
|
@@ -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,70 +1,23 @@
|
|
|
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
|
-
:path,
|
|
25
|
-
:updated_models,
|
|
26
|
-
:changes,
|
|
27
|
-
:orphaned_models,
|
|
28
|
-
:failed_sources,
|
|
29
|
-
:discrepancies,
|
|
30
|
-
:rejected,
|
|
31
|
-
:flagged,
|
|
32
|
-
:sources_used,
|
|
33
|
-
:written
|
|
34
|
-
)
|
|
35
|
-
CheckResult = Data.define(
|
|
36
|
-
:path,
|
|
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? }
|
|
62
|
-
end
|
|
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
|
|
63
18
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
end
|
|
67
|
-
end
|
|
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)
|
|
68
21
|
|
|
69
22
|
class << self
|
|
70
23
|
def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
|
|
@@ -74,78 +27,117 @@ module LlmCostTracker
|
|
|
74
27
|
prices_file = config.prices_file
|
|
75
28
|
return prices_file.to_s if prices_file
|
|
76
29
|
|
|
77
|
-
|
|
30
|
+
default_output_path
|
|
78
31
|
end
|
|
79
32
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
path: path,
|
|
84
|
-
seed_path: seed_path,
|
|
85
|
-
fetcher: fetcher,
|
|
86
|
-
today: today
|
|
87
|
-
)
|
|
88
|
-
raise Error, strict_failure_message(plan) if strict_sync_failure?(plan, strict: strict)
|
|
89
|
-
|
|
90
|
-
written = !preview && plan.refresh_succeeded?
|
|
91
|
-
RegistryWriter.new.call(path: plan.path, registry: plan.updated_registry) if written
|
|
92
|
-
|
|
93
|
-
SyncResult.new(
|
|
94
|
-
path: plan.path,
|
|
95
|
-
updated_models: plan.changes.keys.sort,
|
|
96
|
-
changes: plan.changes,
|
|
97
|
-
orphaned_models: plan.orphaned_models,
|
|
98
|
-
failed_sources: plan.failed_sources,
|
|
99
|
-
discrepancies: plan.discrepancies,
|
|
100
|
-
rejected: plan.rejected,
|
|
101
|
-
flagged: plan.flagged,
|
|
102
|
-
sources_used: plan.sources_used,
|
|
103
|
-
written: written
|
|
104
|
-
)
|
|
33
|
+
def configured_remote_url(env: ENV)
|
|
34
|
+
url = env["URL"].to_s.strip
|
|
35
|
+
url.empty? ? DEFAULT_REMOTE_URL : url
|
|
105
36
|
end
|
|
106
37
|
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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)
|
|
50
|
+
end
|
|
51
|
+
|
|
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", {}))
|
|
114
68
|
|
|
115
69
|
CheckResult.new(
|
|
116
|
-
path:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
rejected: plan.rejected,
|
|
122
|
-
flagged: plan.flagged,
|
|
123
|
-
sources_used: plan.sources_used,
|
|
124
|
-
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?
|
|
125
75
|
)
|
|
126
76
|
end
|
|
127
77
|
|
|
128
78
|
private
|
|
129
79
|
|
|
130
|
-
def
|
|
131
|
-
|
|
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
|
|
132
86
|
end
|
|
133
87
|
|
|
134
|
-
def
|
|
135
|
-
|
|
88
|
+
def load_current_registry(path)
|
|
89
|
+
RegistryLoader.new.call(path: path, seed_path: PriceRegistry::DEFAULT_PRICES_PATH)
|
|
136
90
|
end
|
|
137
91
|
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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"
|
|
143
100
|
end
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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}"
|
|
147
105
|
end
|
|
148
|
-
|
|
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
|
+
)
|
|
149
141
|
end
|
|
150
142
|
end
|
|
151
143
|
end
|