llm_cost_tracker 0.5.0 → 0.5.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 +38 -0
- data/README.md +116 -467
- data/app/controllers/llm_cost_tracker/calls_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +3 -15
- data/app/controllers/llm_cost_tracker/tags_controller.rb +7 -6
- data/app/helpers/llm_cost_tracker/application_helper.rb +21 -6
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +42 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -8
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +6 -5
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +74 -18
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +15 -4
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +4 -0
- data/lib/llm_cost_tracker/configuration.rb +22 -16
- data/lib/llm_cost_tracker/doctor.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +8 -2
- data/lib/llm_cost_tracker/integrations/anthropic.rb +12 -3
- data/lib/llm_cost_tracker/integrations/base.rb +77 -6
- data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
- data/lib/llm_cost_tracker/integrations/openai.rb +14 -5
- data/lib/llm_cost_tracker/integrations/registry.rb +3 -1
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +10 -9
- data/lib/llm_cost_tracker/middleware/faraday.rb +10 -6
- data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +11 -2
- data/lib/llm_cost_tracker/price_freshness.rb +3 -3
- data/lib/llm_cost_tracker/price_registry.rb +3 -0
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +43 -12
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -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/report.rb +8 -1
- data/lib/llm_cost_tracker/report_data.rb +25 -9
- data/lib/llm_cost_tracker/retention.rb +30 -7
- data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
- data/lib/llm_cost_tracker/stream_capture.rb +7 -0
- data/lib/llm_cost_tracker/stream_collector.rb +25 -1
- data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
- data/lib/llm_cost_tracker/tracker.rb +7 -59
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +1 -0
- data/lib/tasks/llm_cost_tracker.rake +24 -78
- metadata +26 -15
- 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
|
@@ -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
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "yaml"
|
|
5
5
|
|
|
6
|
+
require_relative "../price_registry"
|
|
7
|
+
|
|
6
8
|
module LlmCostTracker
|
|
7
9
|
module PriceSync
|
|
8
10
|
class RegistryLoader
|
|
@@ -18,6 +20,10 @@ module LlmCostTracker
|
|
|
18
20
|
private
|
|
19
21
|
|
|
20
22
|
def load_registry_file(path)
|
|
23
|
+
if File.size(path) > PriceRegistry::MAX_FILE_BYTES
|
|
24
|
+
raise ArgumentError, "pricing registry exceeds #{PriceRegistry::MAX_FILE_BYTES} bytes"
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
contents = File.read(path)
|
|
22
28
|
registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
|
|
23
29
|
raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
|
|
@@ -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
|