llm_cost_tracker 0.2.0 → 0.3.0

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +111 -68
  4. data/Rakefile +2 -0
  5. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -2
  6. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +6 -1
  7. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
  8. data/app/services/llm_cost_tracker/dashboard/filter.rb +22 -0
  9. data/app/views/llm_cost_tracker/calls/index.html.erb +10 -0
  10. data/app/views/llm_cost_tracker/dashboard/index.html.erb +10 -0
  11. data/app/views/llm_cost_tracker/data_quality/index.html.erb +46 -0
  12. data/lib/llm_cost_tracker/assets.rb +6 -11
  13. data/lib/llm_cost_tracker/configuration.rb +78 -42
  14. data/lib/llm_cost_tracker/event.rb +2 -0
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  17. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +4 -0
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  19. data/lib/llm_cost_tracker/llm_api_call.rb +8 -0
  20. data/lib/llm_cost_tracker/middleware/faraday.rb +57 -9
  21. data/lib/llm_cost_tracker/parsed_usage.rb +7 -3
  22. data/lib/llm_cost_tracker/parsers/anthropic.rb +79 -1
  23. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  24. data/lib/llm_cost_tracker/parsers/gemini.rb +59 -6
  25. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  26. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +8 -0
  27. data/lib/llm_cost_tracker/parsers/openai_usage.rb +55 -1
  28. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  29. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  30. data/lib/llm_cost_tracker/price_registry.rb +1 -1
  31. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  32. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  33. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  34. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  35. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  36. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  37. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  38. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  39. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  40. data/lib/llm_cost_tracker/price_sync.rb +310 -0
  41. data/lib/llm_cost_tracker/storage/active_record_store.rb +3 -1
  42. data/lib/llm_cost_tracker/stream_collector.rb +158 -0
  43. data/lib/llm_cost_tracker/tags_column.rb +8 -0
  44. data/lib/llm_cost_tracker/tracker.rb +15 -12
  45. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  46. data/lib/llm_cost_tracker/version.rb +1 -1
  47. data/lib/llm_cost_tracker.rb +50 -29
  48. data/lib/tasks/llm_cost_tracker.rake +116 -0
  49. data/llm_cost_tracker.gemspec +8 -6
  50. metadata +24 -8
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "active_support"
4
4
  require "active_support/notifications"
5
+ require "monitor"
5
6
 
6
7
  require_relative "llm_cost_tracker/version"
7
8
  require_relative "llm_cost_tracker/configuration"
@@ -11,6 +12,7 @@ require_relative "llm_cost_tracker/cost"
11
12
  require_relative "llm_cost_tracker/event"
12
13
  require_relative "llm_cost_tracker/parsed_usage"
13
14
  require_relative "llm_cost_tracker/price_registry"
15
+ require_relative "llm_cost_tracker/price_sync"
14
16
  require_relative "llm_cost_tracker/pricing"
15
17
  require_relative "llm_cost_tracker/parsers/base"
16
18
  require_relative "llm_cost_tracker/parsers/openai_usage"
@@ -18,6 +20,7 @@ require_relative "llm_cost_tracker/parsers/openai"
18
20
  require_relative "llm_cost_tracker/parsers/openai_compatible"
19
21
  require_relative "llm_cost_tracker/parsers/anthropic"
20
22
  require_relative "llm_cost_tracker/parsers/gemini"
23
+ require_relative "llm_cost_tracker/parsers/sse"
21
24
  require_relative "llm_cost_tracker/parsers/registry"
22
25
  require_relative "llm_cost_tracker/middleware/faraday"
23
26
  require_relative "llm_cost_tracker/budget"
@@ -34,11 +37,11 @@ require_relative "llm_cost_tracker/report_formatter"
34
37
  require_relative "llm_cost_tracker/report"
35
38
 
36
39
  module LlmCostTracker
37
- class << self
38
- attr_writer :configuration
40
+ CONFIGURATION_MUTEX = Monitor.new
39
41
 
42
+ class << self
40
43
  def configuration
41
- @configuration ||= Configuration.new
44
+ CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new }
42
45
  end
43
46
 
44
47
  # Configure the gem once during application boot.
@@ -46,49 +49,67 @@ module LlmCostTracker
46
49
  # @yieldparam configuration [LlmCostTracker::Configuration]
47
50
  # @return [void]
48
51
  def configure
49
- yield(configuration)
50
- configuration.normalize_openai_compatible_providers!
51
- warn_for_configuration!
52
+ config = CONFIGURATION_MUTEX.synchronize do
53
+ current = @configuration || Configuration.new
54
+ current = current.dup_for_configuration if current.finalized?
55
+ @configuration = current
56
+ yield(current)
57
+ current.normalize_openai_compatible_providers!
58
+ current.finalize!
59
+ current
60
+ end
61
+ warn_for_configuration!(config)
52
62
  end
53
63
 
54
64
  def reset_configuration!
55
- @configuration = Configuration.new
65
+ CONFIGURATION_MUTEX.synchronize { @configuration = Configuration.new }
56
66
  end
57
67
 
58
- # Track an LLM request manually for non-Faraday clients.
59
- #
60
- # LlmCostTracker.track(
61
- # provider: :openai,
62
- # model: "gpt-4o",
63
- # input_tokens: 150,
64
- # output_tokens: 50,
65
- # feature: "chat",
66
- # user_id: current_user.id
67
- # )
68
- #
69
- # @param provider [String, Symbol] Provider name, such as :openai or :anthropic.
70
- # @param model [String] Provider model identifier.
71
- # @param input_tokens [Integer] Billed input token count.
72
- # @param output_tokens [Integer] Billed output token count.
73
- # @param latency_ms [Integer, nil] Optional request latency in milliseconds.
74
- # @param metadata [Hash] Attribution tags and provider-specific usage metadata.
75
- # @return [LlmCostTracker::Event] The tracked event.
76
- def track(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, **metadata)
68
+ def enforce_budget!
69
+ Tracker.enforce_budget!
70
+ end
71
+
72
+ def track(provider:, model:, input_tokens:, output_tokens:, **options)
73
+ latency_ms = options.delete(:latency_ms)
74
+ stream = options.key?(:stream) ? options.delete(:stream) : false
75
+ usage_source = options.key?(:usage_source) ? options.delete(:usage_source) : :manual
76
+ enforce_budget = options.key?(:enforce_budget) ? options.delete(:enforce_budget) : false
77
+ metadata = options
78
+
79
+ enforce_budget! if enforce_budget
77
80
  Tracker.record(
78
81
  provider: provider.to_s,
79
82
  model: model,
80
83
  input_tokens: input_tokens,
81
84
  output_tokens: output_tokens,
82
85
  latency_ms: latency_ms,
86
+ stream: stream,
87
+ usage_source: usage_source,
88
+ metadata: metadata
89
+ )
90
+ end
91
+
92
+ def track_stream(provider:, model:, latency_ms: nil, enforce_budget: false, **metadata)
93
+ require_relative "llm_cost_tracker/stream_collector"
94
+ enforce_budget! if enforce_budget
95
+ collector = StreamCollector.new(
96
+ provider: provider.to_s,
97
+ model: model,
98
+ latency_ms: latency_ms,
83
99
  metadata: metadata
84
100
  )
101
+ yield collector
102
+ collector.finish!
103
+ rescue StandardError
104
+ collector&.finish!(errored: true)
105
+ raise
85
106
  end
86
107
 
87
108
  private
88
109
 
89
- def warn_for_configuration!
90
- return unless configuration.budget_exceeded_behavior == :block_requests
91
- return if configuration.active_record?
110
+ def warn_for_configuration!(config = configuration)
111
+ return unless config.budget_exceeded_behavior == :block_requests
112
+ return if config.active_record?
92
113
 
93
114
  Logging.warn(":block_requests requires storage_backend = :active_record; preflight blocking will be skipped.")
94
115
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Metrics/BlockLength
3
4
  namespace :llm_cost_tracker do
4
5
  desc "Print an LLM cost report from ActiveRecord storage"
5
6
  task report: :environment do
@@ -14,4 +15,119 @@ namespace :llm_cost_tracker do
14
15
  deleted = LlmCostTracker::Retention.prune(older_than: days, batch_size: batch_size)
15
16
  puts "llm_cost_tracker: pruned #{deleted} calls older than #{days} days"
16
17
  end
18
+
19
+ namespace :prices do
20
+ desc(
21
+ "Sync built-in pricing data from LiteLLM/OpenRouter JSON sources. " \
22
+ "Use PREVIEW=1 to preview, STRICT=1 to fail on provider errors, " \
23
+ "or OUTPUT=path/to/file.json."
24
+ )
25
+ task :sync do
26
+ Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
27
+ require_relative "../llm_cost_tracker"
28
+
29
+ output_path = ENV["OUTPUT"] || LlmCostTracker.configuration.prices_file || LlmCostTracker::PriceSync::DEFAULT_OUTPUT_PATH
30
+ strict = ENV["STRICT"] == "1" || ARGV.include?("--strict")
31
+ result = LlmCostTracker::PriceSync.sync(
32
+ path: output_path,
33
+ preview: ENV["PREVIEW"] == "1",
34
+ strict: strict
35
+ )
36
+
37
+ action = if ENV["PREVIEW"] == "1"
38
+ "previewed"
39
+ elsif result.written
40
+ "updated"
41
+ else
42
+ "kept"
43
+ end
44
+
45
+ puts "llm_cost_tracker: #{action} pricing file #{result.path}"
46
+ print_source_usage(result.sources_used)
47
+ print_changes(result.changes)
48
+ print_discrepancies(result.discrepancies)
49
+ print_issues("validator rejected", result.rejected)
50
+ print_issues("validator flagged", result.flagged)
51
+ print_models("orphaned models (no JSON source match)", result.orphaned_models)
52
+ print_failures(result.failed_sources, heading: "source failures (kept existing values)")
53
+ end
54
+
55
+ desc "Compare the current pricing snapshot with LiteLLM/OpenRouter JSON sources and exit non-zero on drift."
56
+ task :check do
57
+ Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
58
+ require_relative "../llm_cost_tracker"
59
+
60
+ output_path = ENV["OUTPUT"] || LlmCostTracker.configuration.prices_file || LlmCostTracker::PriceSync::DEFAULT_OUTPUT_PATH
61
+ result = LlmCostTracker::PriceSync.check(path: output_path)
62
+
63
+ puts "llm_cost_tracker: checked pricing file #{result.path}"
64
+ print_source_usage(result.sources_used)
65
+ print_changes(result.changes)
66
+ print_discrepancies(result.discrepancies)
67
+ print_issues("validator rejected", result.rejected)
68
+ print_issues("validator flagged", result.flagged)
69
+ print_models("orphaned models (no JSON source match)", result.orphaned_models)
70
+ print_failures(result.failed_sources, heading: "source failures")
71
+ puts " pricing is up to date" if result.up_to_date
72
+ abort("llm_cost_tracker: pricing check failed") unless result.up_to_date
73
+ end
74
+ end
75
+ end
76
+ # rubocop:enable Metrics/BlockLength
77
+
78
+ def print_source_usage(sources_used)
79
+ return if sources_used.empty?
80
+
81
+ puts " sources used:"
82
+ sources_used.each do |source, usage|
83
+ version = usage.source_version ? ", version=#{usage.source_version.inspect}" : ""
84
+ puts " - #{source} (#{usage.prices_count} prices#{version})"
85
+ end
86
+ end
87
+
88
+ def print_changes(changes)
89
+ puts " changed models: #{changes.size}"
90
+ return if changes.empty?
91
+
92
+ changes.each do |model, fields|
93
+ puts " - #{model}"
94
+ fields.each do |field, values|
95
+ puts " #{field}: #{values['from'].inspect} -> #{values['to'].inspect}"
96
+ end
97
+ end
98
+ end
99
+
100
+ def print_discrepancies(discrepancies)
101
+ return if discrepancies.empty?
102
+
103
+ puts " source discrepancies: #{discrepancies.size}"
104
+ discrepancies.each do |issue|
105
+ formatted = issue.values.map { |source, value| "#{source}=#{value.inspect}" }.join(", ")
106
+ puts " - #{issue.model} #{issue.field}: #{formatted}"
107
+ end
108
+ end
109
+
110
+ def print_issues(heading, issues)
111
+ return if issues.empty?
112
+
113
+ puts " #{heading}: #{issues.size}"
114
+ issues.each do |issue|
115
+ puts " - #{issue.model}: #{issue.reason}"
116
+ end
117
+ end
118
+
119
+ def print_models(heading, models)
120
+ return if models.empty?
121
+
122
+ puts " #{heading}: #{models.size}"
123
+ models.each { |model| puts " - #{model}" }
124
+ end
125
+
126
+ def print_failures(failed_sources, heading:)
127
+ return if failed_sources.empty?
128
+
129
+ puts " #{heading}: #{failed_sources.size}"
130
+ failed_sources.each do |source, message|
131
+ puts " - #{source}: #{message}"
132
+ end
17
133
  end
@@ -8,19 +8,21 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Sergii Khomenko"]
9
9
  spec.email = ["sergey@mm.st"]
10
10
 
11
- spec.summary = "Self-hosted LLM API cost guardrails for Ruby and Rails"
12
- spec.description = "Tracks token usage and estimated costs for OpenAI, Anthropic, Google Gemini, " \
13
- "OpenRouter, DeepSeek, and OpenAI-compatible calls. " \
14
- "Works as Faraday middleware for Ruby clients, with ActiveRecord storage, " \
15
- "arbitrary tag-based attribution, and budget guardrails."
11
+ spec.summary = "Self-hosted LLM usage and cost tracking for Ruby and Rails"
12
+ spec.description = "Tracks token usage, latency, and estimated costs for OpenAI, Anthropic, " \
13
+ "Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. " \
14
+ "Works through Faraday middleware or explicit track/track_stream helpers, " \
15
+ "with ActiveRecord storage, tag-based attribution, price sync tasks, " \
16
+ "and budget guardrails."
16
17
  spec.homepage = "https://github.com/sergey-homenko/llm_cost_tracker"
17
18
  spec.license = "MIT"
18
19
 
19
20
  spec.required_ruby_version = ">= 3.3.0"
20
21
 
21
- spec.metadata["homepage_uri"] = spec.homepage
22
22
  spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
23
23
  spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
24
+ spec.metadata["source_code_uri"] = spec.homepage
25
+ spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
24
26
  spec.metadata["rubygems_mfa_required"] = "true"
25
27
 
26
28
  spec.files = Dir.chdir(__dir__) do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_cost_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-20 00:00:00.000000000 Z
11
+ date: 2026-04-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -180,10 +180,10 @@ dependencies:
180
180
  - - "~>"
181
181
  - !ruby/object:Gem::Version
182
182
  version: '3.0'
183
- description: Tracks token usage and estimated costs for OpenAI, Anthropic, Google
184
- Gemini, OpenRouter, DeepSeek, and OpenAI-compatible calls. Works as Faraday middleware
185
- for Ruby clients, with ActiveRecord storage, arbitrary tag-based attribution, and
186
- budget guardrails.
183
+ description: Tracks token usage, latency, and estimated costs for OpenAI, Anthropic,
184
+ Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. Works through Faraday
185
+ middleware or explicit track/track_stream helpers, with ActiveRecord storage, tag-based
186
+ attribution, price sync tasks, and budget guardrails.
187
187
  email:
188
188
  - sergey@mm.st
189
189
  executables: []
@@ -248,9 +248,11 @@ files:
248
248
  - lib/llm_cost_tracker/event.rb
249
249
  - lib/llm_cost_tracker/event_metadata.rb
250
250
  - lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb
251
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb
251
252
  - lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
252
253
  - lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb
253
254
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb
255
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb
254
256
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb
255
257
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb
256
258
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb
@@ -269,8 +271,19 @@ files:
269
271
  - lib/llm_cost_tracker/parsers/openai_compatible.rb
270
272
  - lib/llm_cost_tracker/parsers/openai_usage.rb
271
273
  - lib/llm_cost_tracker/parsers/registry.rb
274
+ - lib/llm_cost_tracker/parsers/sse.rb
272
275
  - lib/llm_cost_tracker/period_grouping.rb
273
276
  - lib/llm_cost_tracker/price_registry.rb
277
+ - lib/llm_cost_tracker/price_sync.rb
278
+ - lib/llm_cost_tracker/price_sync/fetcher.rb
279
+ - lib/llm_cost_tracker/price_sync/merger.rb
280
+ - lib/llm_cost_tracker/price_sync/model_catalog.rb
281
+ - lib/llm_cost_tracker/price_sync/raw_price.rb
282
+ - lib/llm_cost_tracker/price_sync/source.rb
283
+ - lib/llm_cost_tracker/price_sync/source_result.rb
284
+ - lib/llm_cost_tracker/price_sync/sources/litellm.rb
285
+ - lib/llm_cost_tracker/price_sync/sources/open_router.rb
286
+ - lib/llm_cost_tracker/price_sync/validator.rb
274
287
  - lib/llm_cost_tracker/prices.json
275
288
  - lib/llm_cost_tracker/pricing.rb
276
289
  - lib/llm_cost_tracker/railtie.rb
@@ -279,12 +292,14 @@ files:
279
292
  - lib/llm_cost_tracker/report_formatter.rb
280
293
  - lib/llm_cost_tracker/retention.rb
281
294
  - lib/llm_cost_tracker/storage/active_record_store.rb
295
+ - lib/llm_cost_tracker/stream_collector.rb
282
296
  - lib/llm_cost_tracker/tag_accessors.rb
283
297
  - lib/llm_cost_tracker/tag_key.rb
284
298
  - lib/llm_cost_tracker/tag_query.rb
285
299
  - lib/llm_cost_tracker/tags_column.rb
286
300
  - lib/llm_cost_tracker/tracker.rb
287
301
  - lib/llm_cost_tracker/unknown_pricing.rb
302
+ - lib/llm_cost_tracker/value_helpers.rb
288
303
  - lib/llm_cost_tracker/version.rb
289
304
  - lib/tasks/llm_cost_tracker.rake
290
305
  - llm_cost_tracker.gemspec
@@ -292,9 +307,10 @@ homepage: https://github.com/sergey-homenko/llm_cost_tracker
292
307
  licenses:
293
308
  - MIT
294
309
  metadata:
295
- homepage_uri: https://github.com/sergey-homenko/llm_cost_tracker
296
310
  bug_tracker_uri: https://github.com/sergey-homenko/llm_cost_tracker/issues
297
311
  changelog_uri: https://github.com/sergey-homenko/llm_cost_tracker/blob/main/CHANGELOG.md
312
+ source_code_uri: https://github.com/sergey-homenko/llm_cost_tracker
313
+ documentation_uri: https://github.com/sergey-homenko/llm_cost_tracker#readme
298
314
  rubygems_mfa_required: 'true'
299
315
  post_install_message:
300
316
  rdoc_options: []
@@ -314,5 +330,5 @@ requirements: []
314
330
  rubygems_version: 3.5.9
315
331
  signing_key:
316
332
  specification_version: 4
317
- summary: Self-hosted LLM API cost guardrails for Ruby and Rails
333
+ summary: Self-hosted LLM usage and cost tracking for Ruby and Rails
318
334
  test_files: []