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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +116 -467
  4. data/app/controllers/llm_cost_tracker/calls_controller.rb +2 -1
  5. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +3 -15
  6. data/app/controllers/llm_cost_tracker/tags_controller.rb +7 -6
  7. data/app/helpers/llm_cost_tracker/application_helper.rb +21 -6
  8. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +3 -1
  9. data/app/services/llm_cost_tracker/dashboard/date_range.rb +42 -0
  10. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -8
  11. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +6 -5
  12. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +74 -18
  13. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +15 -4
  14. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  15. data/app/views/llm_cost_tracker/tags/show.html.erb +4 -0
  16. data/lib/llm_cost_tracker/configuration.rb +22 -16
  17. data/lib/llm_cost_tracker/doctor.rb +1 -1
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
  19. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +8 -2
  20. data/lib/llm_cost_tracker/integrations/anthropic.rb +12 -3
  21. data/lib/llm_cost_tracker/integrations/base.rb +77 -6
  22. data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
  23. data/lib/llm_cost_tracker/integrations/openai.rb +14 -5
  24. data/lib/llm_cost_tracker/integrations/registry.rb +3 -1
  25. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
  26. data/lib/llm_cost_tracker/llm_api_call.rb +10 -9
  27. data/lib/llm_cost_tracker/middleware/faraday.rb +10 -6
  28. data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
  29. data/lib/llm_cost_tracker/parsers/openai_usage.rb +11 -2
  30. data/lib/llm_cost_tracker/price_freshness.rb +3 -3
  31. data/lib/llm_cost_tracker/price_registry.rb +3 -0
  32. data/lib/llm_cost_tracker/price_sync/fetcher.rb +43 -12
  33. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
  34. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -0
  35. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
  36. data/lib/llm_cost_tracker/price_sync.rb +103 -111
  37. data/lib/llm_cost_tracker/prices.json +225 -229
  38. data/lib/llm_cost_tracker/pricing.rb +27 -15
  39. data/lib/llm_cost_tracker/report.rb +8 -1
  40. data/lib/llm_cost_tracker/report_data.rb +25 -9
  41. data/lib/llm_cost_tracker/retention.rb +30 -7
  42. data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
  43. data/lib/llm_cost_tracker/stream_capture.rb +7 -0
  44. data/lib/llm_cost_tracker/stream_collector.rb +25 -1
  45. data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
  46. data/lib/llm_cost_tracker/tracker.rb +7 -59
  47. data/lib/llm_cost_tracker/version.rb +1 -1
  48. data/lib/llm_cost_tracker.rb +1 -0
  49. data/lib/tasks/llm_cost_tracker.rake +24 -78
  50. metadata +26 -15
  51. data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
  52. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
  53. data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
  54. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -164
  55. data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
  56. data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
  57. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
  58. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
  59. 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
- File.write(path, payload)
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/raw_price"
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 = PriceRegistry::DEFAULT_PRICES_PATH
21
-
22
- SourceUsage = Data.define(:prices_count, :source_version)
23
- SyncResult = Data.define(
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
- def up_to_date?
65
- changes.empty? && failed_sources.empty? && rejected.empty?
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
- nil
30
+ default_output_path
78
31
  end
79
32
 
80
- def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
81
- fetcher: Fetcher.new, today: Date.today)
82
- plan = RefreshPlanBuilder.new(sources: sources).call(
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 check(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, fetcher: Fetcher.new, today: Date.today)
108
- plan = RefreshPlanBuilder.new(sources: sources).call(
109
- path: path,
110
- seed_path: seed_path,
111
- fetcher: fetcher,
112
- today: today
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: plan.path,
117
- changes: plan.changes,
118
- orphaned_models: plan.orphaned_models,
119
- failed_sources: plan.failed_sources,
120
- discrepancies: plan.discrepancies,
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 sources
131
- [Sources::Litellm.new, Sources::OpenRouter.new]
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 strict_sync_failure?(plan, strict:)
135
- strict && (plan.failed_sources.any? || plan.rejected.any?)
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 strict_failure_message(plan)
139
- messages = []
140
- if plan.failed_sources.any?
141
- details = plan.failed_sources.map { |source, message| "#{source}: #{message}" }.join(", ")
142
- messages << "source failures: #{details}"
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
- if plan.rejected.any?
145
- details = plan.rejected.map { |issue| "#{issue.model} (#{issue.reason})" }.join(", ")
146
- messages << "validator rejections: #{details}"
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
- "Price sync failed in strict mode: #{messages.join('; ')}"
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