llm_cost_tracker 0.3.0 → 0.3.2
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 +23 -0
- data/CODE_OF_CONDUCT.md +23 -0
- data/README.md +86 -8
- data/SECURITY.md +36 -0
- data/app/assets/llm_cost_tracker/application.css +1 -4
- data/app/controllers/llm_cost_tracker/assets_controller.rb +0 -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_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 +10 -10
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -26
- 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 +13 -13
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +36 -14
- 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/configuration.rb +0 -1
- data/lib/llm_cost_tracker/event.rb +1 -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/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +2 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +6 -2
- data/lib/llm_cost_tracker/middleware/faraday.rb +1 -0
- data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
- data/lib/llm_cost_tracker/parsed_usage.rb +14 -3
- data/lib/llm_cost_tracker/parsers/anthropic.rb +47 -28
- data/lib/llm_cost_tracker/parsers/gemini.rb +28 -4
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -6
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +14 -0
- data/lib/llm_cost_tracker/price_registry.rb +22 -7
- 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.rb +16 -184
- data/lib/llm_cost_tracker/pricing.rb +0 -11
- data/lib/llm_cost_tracker/railtie.rb +2 -1
- data/lib/llm_cost_tracker/report.rb +0 -5
- data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -11
- data/lib/llm_cost_tracker/stream_collector.rb +17 -13
- data/lib/llm_cost_tracker/tags_column.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +10 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +6 -14
- data/llm_cost_tracker.gemspec +3 -1
- metadata +37 -1
|
@@ -35,7 +35,12 @@ module LlmCostTracker
|
|
|
35
35
|
usage = response["usageMetadata"]
|
|
36
36
|
return nil unless usage
|
|
37
37
|
|
|
38
|
-
build_parsed_usage(
|
|
38
|
+
build_parsed_usage(
|
|
39
|
+
request_url,
|
|
40
|
+
usage,
|
|
41
|
+
usage_source: :response,
|
|
42
|
+
provider_response_id: response["responseId"]
|
|
43
|
+
)
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
def parse_stream(request_url, _request_body, response_status, events)
|
|
@@ -45,10 +50,17 @@ module LlmCostTracker
|
|
|
45
50
|
model = extract_model_from_url(request_url)
|
|
46
51
|
|
|
47
52
|
if usage
|
|
48
|
-
build_parsed_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
|
+
)
|
|
49
60
|
else
|
|
50
61
|
ParsedUsage.build(
|
|
51
62
|
provider: "gemini",
|
|
63
|
+
provider_response_id: stream_response_id(events),
|
|
52
64
|
model: model,
|
|
53
65
|
input_tokens: 0,
|
|
54
66
|
output_tokens: 0,
|
|
@@ -61,7 +73,7 @@ module LlmCostTracker
|
|
|
61
73
|
|
|
62
74
|
private
|
|
63
75
|
|
|
64
|
-
def build_parsed_usage(request_url, usage, usage_source:, stream: false)
|
|
76
|
+
def build_parsed_usage(request_url, usage, usage_source:, stream: false, provider_response_id: nil)
|
|
65
77
|
ParsedUsage.build(
|
|
66
78
|
provider: "gemini",
|
|
67
79
|
model: extract_model_from_url(request_url),
|
|
@@ -70,7 +82,8 @@ module LlmCostTracker
|
|
|
70
82
|
total_tokens: usage["totalTokenCount"].to_i,
|
|
71
83
|
cached_input_tokens: usage["cachedContentTokenCount"],
|
|
72
84
|
stream: stream,
|
|
73
|
-
usage_source: usage_source
|
|
85
|
+
usage_source: usage_source,
|
|
86
|
+
provider_response_id: provider_response_id
|
|
74
87
|
)
|
|
75
88
|
end
|
|
76
89
|
|
|
@@ -90,6 +103,17 @@ module LlmCostTracker
|
|
|
90
103
|
usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
|
|
91
104
|
end
|
|
92
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
|
+
|
|
93
117
|
def streaming_url?(request_url)
|
|
94
118
|
URI.parse(request_url.to_s).path.match?(STREAM_PATH_PATTERN)
|
|
95
119
|
rescue URI::InvalidURIError
|
|
@@ -20,7 +20,10 @@ module LlmCostTracker
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def provider_names
|
|
23
|
-
[
|
|
23
|
+
[
|
|
24
|
+
"openai_compatible",
|
|
25
|
+
*LlmCostTracker.configuration.openai_compatible_providers.each_value.map(&:to_s)
|
|
26
|
+
].uniq.freeze
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def parse(request_url, request_body, response_status, response_body)
|
|
@@ -41,11 +44,7 @@ module LlmCostTracker
|
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
def provider_for_host(host)
|
|
44
|
-
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def configured_providers
|
|
48
|
-
LlmCostTracker.configuration.openai_compatible_providers
|
|
47
|
+
LlmCostTracker.configuration.openai_compatible_providers[host.to_s.downcase]&.to_s
|
|
49
48
|
end
|
|
50
49
|
|
|
51
50
|
def tracked_path?(path)
|
|
@@ -16,6 +16,7 @@ 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,
|
|
@@ -35,6 +36,7 @@ module LlmCostTracker
|
|
|
35
36
|
if usage
|
|
36
37
|
ParsedUsage.build(
|
|
37
38
|
provider: provider_for(request_url),
|
|
39
|
+
provider_response_id: detect_stream_response_id(events),
|
|
38
40
|
model: model,
|
|
39
41
|
input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
|
|
40
42
|
output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
|
|
@@ -46,6 +48,7 @@ module LlmCostTracker
|
|
|
46
48
|
else
|
|
47
49
|
ParsedUsage.build(
|
|
48
50
|
provider: provider_for(request_url),
|
|
51
|
+
provider_response_id: detect_stream_response_id(events),
|
|
49
52
|
model: model,
|
|
50
53
|
input_tokens: 0,
|
|
51
54
|
output_tokens: 0,
|
|
@@ -78,6 +81,17 @@ module LlmCostTracker
|
|
|
78
81
|
nil
|
|
79
82
|
end
|
|
80
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
|
+
|
|
81
95
|
def cached_input_tokens(usage)
|
|
82
96
|
details = usage["prompt_tokens_details"] || usage["input_tokens_details"] || {}
|
|
83
97
|
details["cached_tokens"]
|
|
@@ -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,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module PriceSync
|
|
5
|
+
class RefreshPlanBuilder
|
|
6
|
+
def initialize(sources:, loader: RegistryLoader.new)
|
|
7
|
+
@sources = sources
|
|
8
|
+
@loader = loader
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(path:, seed_path:, fetcher:, today:)
|
|
12
|
+
path = path.to_s
|
|
13
|
+
registry = loader.call(path: path, seed_path: seed_path)
|
|
14
|
+
current_models = registry.fetch("models", {})
|
|
15
|
+
source_results, failed_sources = fetch_all(current_models, fetcher)
|
|
16
|
+
merged, discrepancies = Merger.new.merge(source_results)
|
|
17
|
+
validated = Validator.new.validate_batch(merged, existing_registry: current_models)
|
|
18
|
+
updated_models = apply_changes(current_models, validated.accepted, today)
|
|
19
|
+
|
|
20
|
+
PriceSync::RefreshPlan.new(
|
|
21
|
+
path: path,
|
|
22
|
+
registry: registry,
|
|
23
|
+
updated_registry: registry.merge(
|
|
24
|
+
"metadata" => updated_metadata(
|
|
25
|
+
registry["metadata"],
|
|
26
|
+
today,
|
|
27
|
+
refresh_succeeded: source_results.any? { |_source, result| result.prices.any? },
|
|
28
|
+
source_results: source_results
|
|
29
|
+
),
|
|
30
|
+
"models" => updated_models
|
|
31
|
+
),
|
|
32
|
+
accepted: validated.accepted,
|
|
33
|
+
changes: price_changes(current_models, updated_models),
|
|
34
|
+
orphaned_models: compute_orphaned(current_models, merged.keys),
|
|
35
|
+
failed_sources: failed_sources,
|
|
36
|
+
discrepancies: discrepancies,
|
|
37
|
+
rejected: validated.rejected,
|
|
38
|
+
flagged: validated.flagged,
|
|
39
|
+
sources_used: source_usage(source_results),
|
|
40
|
+
source_results: source_results
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :sources, :loader
|
|
47
|
+
|
|
48
|
+
def fetch_all(current_models, fetcher)
|
|
49
|
+
results = {}
|
|
50
|
+
failures = {}
|
|
51
|
+
|
|
52
|
+
sources.each do |source|
|
|
53
|
+
results[source.name.to_sym] = source.fetch(current_models: current_models, fetcher: fetcher)
|
|
54
|
+
rescue Error => e
|
|
55
|
+
failures[source.name.to_sym] = e.message
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
[results, failures]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def apply_changes(current_models, accepted, today)
|
|
62
|
+
merged = seed_models(current_models)
|
|
63
|
+
|
|
64
|
+
accepted.each do |model, price|
|
|
65
|
+
next if manual_model?(merged[model])
|
|
66
|
+
|
|
67
|
+
merged[model] = registry_entry_for(merged[model], price, today)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
merged.sort.to_h
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def compute_orphaned(current_models, merged_models)
|
|
74
|
+
seed_models(current_models).keys.reject do |model|
|
|
75
|
+
manual_model?(current_models[model]) || merged_models.include?(model)
|
|
76
|
+
end.sort
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def seed_models(current_models)
|
|
80
|
+
normalize_models(current_models).transform_values do |entry|
|
|
81
|
+
next entry if entry.key?("_source")
|
|
82
|
+
|
|
83
|
+
entry.merge("_source" => "seed")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize_models(models)
|
|
88
|
+
normalize_hash(models).each_with_object({}) do |(model, entry), normalized|
|
|
89
|
+
normalized[model.to_s] = normalize_hash(entry)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def normalize_hash(hash)
|
|
94
|
+
return {} if hash.nil?
|
|
95
|
+
raise ArgumentError, "price sync entries must be hashes" unless hash.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
98
|
+
normalized[key.to_s] = value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def manual_model?(entry)
|
|
103
|
+
normalize_hash(entry)["_source"] == "manual"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def registry_entry_for(existing_entry, price, today)
|
|
107
|
+
normalize_hash(existing_entry)
|
|
108
|
+
.except(*PriceRegistry::PRICE_KEYS)
|
|
109
|
+
.merge(price.to_registry_entry(today: today))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def updated_metadata(existing, today, refresh_succeeded:, source_results:)
|
|
113
|
+
metadata = normalize_hash(existing)
|
|
114
|
+
metadata["currency"] ||= "USD"
|
|
115
|
+
metadata["unit"] ||= "1M tokens"
|
|
116
|
+
return metadata unless refresh_succeeded
|
|
117
|
+
|
|
118
|
+
metadata["updated_at"] = today.iso8601
|
|
119
|
+
metadata["source_urls"] = source_urls(source_results)
|
|
120
|
+
metadata
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def source_usage(source_results)
|
|
124
|
+
source_results.transform_values do |result|
|
|
125
|
+
PriceSync::SourceUsage.new(prices_count: result.prices.size, source_version: result.source_version)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def price_changes(current_models, updated_models)
|
|
130
|
+
current_models = normalize_models(current_models)
|
|
131
|
+
updated_models = normalize_models(updated_models)
|
|
132
|
+
|
|
133
|
+
(current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
|
|
134
|
+
fields = price_field_changes(current_models[model], updated_models[model])
|
|
135
|
+
changes[model] = fields if fields.any?
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def price_field_changes(current_entry, updated_entry)
|
|
140
|
+
current_price = comparable_price(current_entry)
|
|
141
|
+
updated_price = comparable_price(updated_entry)
|
|
142
|
+
|
|
143
|
+
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
144
|
+
from = current_price[field]
|
|
145
|
+
to = updated_price[field]
|
|
146
|
+
next if from == to
|
|
147
|
+
|
|
148
|
+
changes[field] = { "from" => from, "to" => to }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def comparable_price(entry)
|
|
153
|
+
normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def source_urls(source_results)
|
|
157
|
+
names = source_results.keys.map(&:to_sym)
|
|
158
|
+
sources.select { |source| names.include?(source.name.to_sym) }.map(&:url)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module PriceSync
|
|
8
|
+
class RegistryLoader
|
|
9
|
+
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
10
|
+
|
|
11
|
+
def call(path:, seed_path:)
|
|
12
|
+
source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
|
|
13
|
+
normalize_registry(load_registry_file(source_path))
|
|
14
|
+
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
15
|
+
raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def load_registry_file(path)
|
|
21
|
+
contents = File.read(path)
|
|
22
|
+
registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
|
|
23
|
+
raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
registry
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def normalize_registry(registry)
|
|
29
|
+
{
|
|
30
|
+
"metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
|
|
31
|
+
"models" => normalize_models(registry.fetch("models", {}))
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize_models(models)
|
|
36
|
+
normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
|
|
37
|
+
normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def normalize_hash(hash, label:)
|
|
42
|
+
return {} if hash.nil?
|
|
43
|
+
raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
46
|
+
normalized[key.to_s] = value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def yaml_file?(path)
|
|
51
|
+
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module PriceSync
|
|
9
|
+
class RegistryWriter
|
|
10
|
+
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
11
|
+
|
|
12
|
+
def call(path:, registry:)
|
|
13
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
14
|
+
payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
|
|
15
|
+
File.write(path, payload)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def yaml_file?(path)
|
|
21
|
+
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "date"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "json"
|
|
6
|
-
require "yaml"
|
|
7
4
|
|
|
8
5
|
require_relative "price_sync/fetcher"
|
|
9
6
|
require_relative "price_sync/raw_price"
|
|
10
7
|
require_relative "price_sync/source"
|
|
11
8
|
require_relative "price_sync/source_result"
|
|
9
|
+
require_relative "price_sync/registry_loader"
|
|
10
|
+
require_relative "price_sync/registry_writer"
|
|
11
|
+
require_relative "price_sync/refresh_plan_builder"
|
|
12
12
|
require_relative "price_sync/model_catalog"
|
|
13
13
|
require_relative "price_sync/merger"
|
|
14
14
|
require_relative "price_sync/validator"
|
|
@@ -16,10 +16,8 @@ require_relative "price_sync/sources/litellm"
|
|
|
16
16
|
require_relative "price_sync/sources/open_router"
|
|
17
17
|
|
|
18
18
|
module LlmCostTracker
|
|
19
|
-
# rubocop:disable Metrics/ModuleLength, Metrics/ClassLength
|
|
20
19
|
module PriceSync
|
|
21
20
|
DEFAULT_OUTPUT_PATH = PriceRegistry::DEFAULT_PRICES_PATH
|
|
22
|
-
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
23
21
|
|
|
24
22
|
SourceUsage = Data.define(:prices_count, :source_version)
|
|
25
23
|
SyncResult = Data.define(
|
|
@@ -71,11 +69,16 @@ module LlmCostTracker
|
|
|
71
69
|
class << self
|
|
72
70
|
def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
|
|
73
71
|
fetcher: Fetcher.new, today: Date.today)
|
|
74
|
-
plan =
|
|
72
|
+
plan = RefreshPlanBuilder.new(sources: sources).call(
|
|
73
|
+
path: path,
|
|
74
|
+
seed_path: seed_path,
|
|
75
|
+
fetcher: fetcher,
|
|
76
|
+
today: today
|
|
77
|
+
)
|
|
75
78
|
raise Error, strict_failure_message(plan) if strict_sync_failure?(plan, strict: strict)
|
|
76
79
|
|
|
77
80
|
written = !preview && plan.refresh_succeeded?
|
|
78
|
-
|
|
81
|
+
RegistryWriter.new.call(path: plan.path, registry: plan.updated_registry) if written
|
|
79
82
|
|
|
80
83
|
SyncResult.new(
|
|
81
84
|
path: plan.path,
|
|
@@ -92,7 +95,12 @@ module LlmCostTracker
|
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
def check(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, fetcher: Fetcher.new, today: Date.today)
|
|
95
|
-
plan =
|
|
98
|
+
plan = RefreshPlanBuilder.new(sources: sources).call(
|
|
99
|
+
path: path,
|
|
100
|
+
seed_path: seed_path,
|
|
101
|
+
fetcher: fetcher,
|
|
102
|
+
today: today
|
|
103
|
+
)
|
|
96
104
|
|
|
97
105
|
CheckResult.new(
|
|
98
106
|
path: plan.path,
|
|
@@ -113,166 +121,6 @@ module LlmCostTracker
|
|
|
113
121
|
[Sources::Litellm.new, Sources::OpenRouter.new]
|
|
114
122
|
end
|
|
115
123
|
|
|
116
|
-
def build_refresh_plan(path:, seed_path:, fetcher:, today:)
|
|
117
|
-
path = path.to_s
|
|
118
|
-
registry = load_registry(path, seed_path: seed_path)
|
|
119
|
-
current_models = registry.fetch("models", {})
|
|
120
|
-
source_results, failed_sources = fetch_all(current_models, fetcher)
|
|
121
|
-
merged, discrepancies = Merger.new.merge(source_results)
|
|
122
|
-
validated = Validator.new.validate_batch(merged, existing_registry: current_models)
|
|
123
|
-
updated_models = apply_changes(current_models, validated.accepted, today)
|
|
124
|
-
refresh_succeeded = source_results.any? { |_source, result| result.prices.any? }
|
|
125
|
-
|
|
126
|
-
RefreshPlan.new(
|
|
127
|
-
path: path,
|
|
128
|
-
registry: registry,
|
|
129
|
-
updated_registry: registry.merge(
|
|
130
|
-
"metadata" => updated_metadata(
|
|
131
|
-
registry["metadata"],
|
|
132
|
-
today,
|
|
133
|
-
refresh_succeeded: refresh_succeeded,
|
|
134
|
-
source_results: source_results
|
|
135
|
-
),
|
|
136
|
-
"models" => updated_models
|
|
137
|
-
),
|
|
138
|
-
accepted: validated.accepted,
|
|
139
|
-
changes: price_changes(current_models, updated_models),
|
|
140
|
-
orphaned_models: compute_orphaned(current_models, merged.keys),
|
|
141
|
-
failed_sources: failed_sources,
|
|
142
|
-
discrepancies: discrepancies,
|
|
143
|
-
rejected: validated.rejected,
|
|
144
|
-
flagged: validated.flagged,
|
|
145
|
-
sources_used: source_usage(source_results),
|
|
146
|
-
source_results: source_results
|
|
147
|
-
)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def fetch_all(current_models, fetcher)
|
|
151
|
-
results = {}
|
|
152
|
-
failures = {}
|
|
153
|
-
|
|
154
|
-
sources.each do |source|
|
|
155
|
-
results[source.name.to_sym] = source.fetch(current_models: current_models, fetcher: fetcher)
|
|
156
|
-
rescue Error => e
|
|
157
|
-
failures[source.name.to_sym] = e.message
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
[results, failures]
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def apply_changes(current_models, accepted, today)
|
|
164
|
-
merged = seed_models(current_models)
|
|
165
|
-
|
|
166
|
-
accepted.each do |model, price|
|
|
167
|
-
next if manual_model?(merged[model])
|
|
168
|
-
|
|
169
|
-
merged[model] = registry_entry_for(merged[model], price, today)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
merged.sort.to_h
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def compute_orphaned(current_models, merged_models)
|
|
176
|
-
seed_models(current_models).keys.reject do |model|
|
|
177
|
-
manual_model?(current_models[model]) || merged_models.include?(model)
|
|
178
|
-
end.sort
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def load_registry(path, seed_path:)
|
|
182
|
-
source_path = File.exist?(path) ? path : seed_path.to_s
|
|
183
|
-
normalize_registry(load_registry_file(source_path))
|
|
184
|
-
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError, NoMethodError => e
|
|
185
|
-
raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def load_registry_file(path)
|
|
189
|
-
contents = File.read(path)
|
|
190
|
-
return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
|
|
191
|
-
|
|
192
|
-
JSON.parse(contents)
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def normalize_registry(registry)
|
|
196
|
-
{
|
|
197
|
-
"metadata" => normalize_hash(registry.fetch("metadata", {})),
|
|
198
|
-
"models" => normalize_models(registry.fetch("models", {}))
|
|
199
|
-
}
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def normalize_models(models)
|
|
203
|
-
(models || {}).each_with_object({}) do |(model, entry), normalized|
|
|
204
|
-
normalized[model.to_s] = normalize_hash(entry)
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def normalize_hash(hash)
|
|
209
|
-
(hash || {}).each_with_object({}) do |(key, value), normalized|
|
|
210
|
-
normalized[key.to_s] = value
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def seed_models(current_models)
|
|
215
|
-
normalize_models(current_models).transform_values do |entry|
|
|
216
|
-
next entry if entry.key?("_source")
|
|
217
|
-
|
|
218
|
-
entry.merge("_source" => "seed")
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def manual_model?(entry)
|
|
223
|
-
normalize_hash(entry)["_source"] == "manual"
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def registry_entry_for(existing_entry, price, today)
|
|
227
|
-
normalize_hash(existing_entry)
|
|
228
|
-
.except(*PriceRegistry::PRICE_KEYS)
|
|
229
|
-
.merge(price.to_registry_entry(today: today))
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def updated_metadata(existing, today, refresh_succeeded:, source_results:)
|
|
233
|
-
metadata = normalize_hash(existing)
|
|
234
|
-
metadata["currency"] ||= "USD"
|
|
235
|
-
metadata["unit"] ||= "1M tokens"
|
|
236
|
-
return metadata unless refresh_succeeded
|
|
237
|
-
|
|
238
|
-
metadata["updated_at"] = today.iso8601
|
|
239
|
-
metadata["source_urls"] = source_urls(source_results)
|
|
240
|
-
metadata
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def source_usage(source_results)
|
|
244
|
-
source_results.transform_values do |result|
|
|
245
|
-
SourceUsage.new(prices_count: result.prices.size, source_version: result.source_version)
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
def price_changes(current_models, updated_models)
|
|
250
|
-
current_models = normalize_models(current_models)
|
|
251
|
-
updated_models = normalize_models(updated_models)
|
|
252
|
-
|
|
253
|
-
(current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
|
|
254
|
-
fields = price_field_changes(current_models[model], updated_models[model])
|
|
255
|
-
changes[model] = fields if fields.any?
|
|
256
|
-
end
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
def price_field_changes(current_entry, updated_entry)
|
|
260
|
-
current_price = comparable_price(current_entry)
|
|
261
|
-
updated_price = comparable_price(updated_entry)
|
|
262
|
-
|
|
263
|
-
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
264
|
-
from = current_price[field]
|
|
265
|
-
to = updated_price[field]
|
|
266
|
-
next if from == to
|
|
267
|
-
|
|
268
|
-
changes[field] = { "from" => from, "to" => to }
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def comparable_price(entry)
|
|
273
|
-
normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
|
|
274
|
-
end
|
|
275
|
-
|
|
276
124
|
def strict_sync_failure?(plan, strict:)
|
|
277
125
|
strict && (plan.failed_sources.any? || plan.rejected.any?)
|
|
278
126
|
end
|
|
@@ -289,22 +137,6 @@ module LlmCostTracker
|
|
|
289
137
|
end
|
|
290
138
|
"Price sync failed in strict mode: #{messages.join('; ')}"
|
|
291
139
|
end
|
|
292
|
-
|
|
293
|
-
def source_urls(source_results)
|
|
294
|
-
names = source_results.keys.map(&:to_sym)
|
|
295
|
-
sources.select { |source| names.include?(source.name.to_sym) }.map(&:url)
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def write_registry(path, registry)
|
|
299
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
300
|
-
payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
|
|
301
|
-
File.write(path, payload)
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
def yaml_file?(path)
|
|
305
|
-
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
306
|
-
end
|
|
307
140
|
end
|
|
308
141
|
end
|
|
309
|
-
# rubocop:enable Metrics/ModuleLength, Metrics/ClassLength
|
|
310
142
|
end
|