llm_cost_tracker 0.3.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 +16 -0
- data/README.md +14 -1
- data/app/assets/llm_cost_tracker/application.css +1 -4
- 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 +0 -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
- metadata +7 -1
|
@@ -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
|
|
@@ -3,21 +3,11 @@
|
|
|
3
3
|
require "monitor"
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
|
-
# Calculates costs from price entries expressed in USD per 1M tokens.
|
|
7
6
|
module Pricing
|
|
8
7
|
PRICES = PriceRegistry.builtin_prices
|
|
9
8
|
MUTEX = Monitor.new
|
|
10
9
|
|
|
11
10
|
class << self
|
|
12
|
-
# Estimate model cost from token counts.
|
|
13
|
-
#
|
|
14
|
-
# @param model [String] Provider model identifier.
|
|
15
|
-
# @param input_tokens [Integer] Input token count, including cached tokens if reported that way.
|
|
16
|
-
# @param output_tokens [Integer] Output token count.
|
|
17
|
-
# @param cached_input_tokens [Integer] OpenAI-style cached input tokens.
|
|
18
|
-
# @param cache_read_input_tokens [Integer] Anthropic-style cache read tokens.
|
|
19
|
-
# @param cache_creation_input_tokens [Integer] Anthropic-style cache creation tokens.
|
|
20
|
-
# @return [LlmCostTracker::Cost, nil] nil when no price is configured for the model.
|
|
21
11
|
def cost_for(model:, input_tokens:, output_tokens:, cached_input_tokens: 0,
|
|
22
12
|
cache_read_input_tokens: 0, cache_creation_input_tokens: 0)
|
|
23
13
|
prices = lookup(model)
|
|
@@ -111,7 +101,6 @@ module LlmCostTracker
|
|
|
111
101
|
model.to_s.split("/").last
|
|
112
102
|
end
|
|
113
103
|
|
|
114
|
-
# Try to match model names like "gpt-4o-2024-08-06" to "gpt-4o".
|
|
115
104
|
def fuzzy_match(model, normalized_model, table)
|
|
116
105
|
sorted_price_keys(table).each do |key|
|
|
117
106
|
return table[key] if model.start_with?(key) || normalized_model.start_with?(key)
|
|
@@ -8,11 +8,6 @@ module LlmCostTracker
|
|
|
8
8
|
DEFAULT_DAYS = ReportData::DEFAULT_DAYS
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
|
-
# Render a terminal-friendly cost report from ActiveRecord storage.
|
|
12
|
-
#
|
|
13
|
-
# @param days [Integer] Number of trailing days to include.
|
|
14
|
-
# @param now [Time] Report end time.
|
|
15
|
-
# @return [String]
|
|
16
11
|
def generate(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
17
12
|
ReportFormatter.new(data(days: days, now: now, tag_breakdowns: tag_breakdowns)).to_s
|
|
18
13
|
rescue LoadError => e
|
|
@@ -19,24 +19,23 @@ module LlmCostTracker
|
|
|
19
19
|
tags: tags_for_storage(tags),
|
|
20
20
|
tracked_at: event.tracked_at
|
|
21
21
|
}
|
|
22
|
-
attributes[:latency_ms]
|
|
23
|
-
attributes[:stream]
|
|
24
|
-
attributes[:usage_source] = event.usage_source if
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
attributes[:latency_ms] = event.latency_ms if LlmCostTracker::LlmApiCall.latency_column?
|
|
23
|
+
attributes[:stream] = event.stream if LlmCostTracker::LlmApiCall.stream_column?
|
|
24
|
+
attributes[:usage_source] = event.usage_source if LlmCostTracker::LlmApiCall.usage_source_column?
|
|
25
|
+
if LlmCostTracker::LlmApiCall.provider_response_id_column?
|
|
26
|
+
attributes[:provider_response_id] = event.provider_response_id
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
LlmCostTracker::LlmApiCall.create!(attributes)
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
def monthly_total(time: Time.now.utc)
|
|
30
|
-
|
|
33
|
+
LlmCostTracker::LlmApiCall
|
|
31
34
|
.where(tracked_at: time.beginning_of_month..time)
|
|
32
35
|
.sum(:total_cost)
|
|
33
36
|
.to_f
|
|
34
37
|
end
|
|
35
38
|
|
|
36
|
-
def model_class
|
|
37
|
-
LlmCostTracker::LlmApiCall
|
|
38
|
-
end
|
|
39
|
-
|
|
40
39
|
private
|
|
41
40
|
|
|
42
41
|
def stringify_tags(tags)
|
|
@@ -44,7 +43,7 @@ module LlmCostTracker
|
|
|
44
43
|
end
|
|
45
44
|
|
|
46
45
|
def tags_for_storage(tags)
|
|
47
|
-
|
|
46
|
+
LlmCostTracker::LlmApiCall.tags_json_column? ? tags : tags.to_json
|
|
48
47
|
end
|
|
49
48
|
|
|
50
49
|
def stringify_tag_value(value)
|