llm_cost_tracker 0.5.2 → 0.5.3

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 +16 -0
  3. data/README.md +8 -3
  4. data/docs/architecture.md +28 -0
  5. data/docs/budgets.md +45 -0
  6. data/docs/configuration.md +65 -0
  7. data/docs/cookbook.md +185 -0
  8. data/docs/dashboard-overview.png +0 -0
  9. data/docs/dashboard.md +38 -0
  10. data/docs/extending.md +32 -0
  11. data/docs/operations.md +44 -0
  12. data/docs/pricing.md +94 -0
  13. data/docs/querying.md +36 -0
  14. data/docs/streaming.md +70 -0
  15. data/docs/technical/README.md +10 -0
  16. data/docs/technical/data-flow.md +67 -0
  17. data/docs/technical/extension-points.md +111 -0
  18. data/docs/technical/module-map.md +197 -0
  19. data/docs/technical/operational-notes.md +77 -0
  20. data/docs/upgrading.md +46 -0
  21. data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
  22. data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
  23. data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
  24. data/lib/llm_cost_tracker/configuration.rb +2 -1
  25. data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
  26. data/lib/llm_cost_tracker/doctor.rb +6 -1
  27. data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
  28. data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
  29. data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
  30. data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
  31. data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
  32. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
  33. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  34. data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
  35. data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
  36. data/lib/llm_cost_tracker/pricing/lookup.rb +110 -0
  37. data/lib/llm_cost_tracker/pricing.rb +25 -108
  38. data/lib/llm_cost_tracker/retention.rb +3 -9
  39. data/lib/llm_cost_tracker/storage/active_record_backend.rb +115 -0
  40. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +42 -0
  41. data/lib/llm_cost_tracker/storage/active_record_store.rb +26 -0
  42. data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
  43. data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
  44. data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
  45. data/lib/llm_cost_tracker/storage/registry.rb +63 -0
  46. data/lib/llm_cost_tracker/tag_sql.rb +34 -0
  47. data/lib/llm_cost_tracker/version.rb +1 -1
  48. data/lib/llm_cost_tracker.rb +3 -0
  49. data/lib/tasks/llm_cost_tracker.rake +49 -0
  50. metadata +32 -2
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+
5
+ module LlmCostTracker
6
+ module Storage
7
+ class CustomBackend
8
+ class << self
9
+ def save(event)
10
+ result = LlmCostTracker.configuration.custom_storage&.call(event)
11
+ result == false ? false : event
12
+ end
13
+
14
+ def verify
15
+ if LlmCostTracker.configuration.custom_storage.respond_to?(:call)
16
+ return [
17
+ VerificationResult.new(
18
+ :ok,
19
+ "storage",
20
+ "custom storage callable configured; external sink was not invoked"
21
+ )
22
+ ]
23
+ end
24
+
25
+ [
26
+ VerificationResult.new(:error, "storage", "custom storage backend requires config.custom_storage")
27
+ ]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,18 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../logging"
4
+ require_relative "registry"
5
+ require_relative "active_record_backend"
6
+ require_relative "custom_backend"
7
+ require_relative "log_backend"
4
8
 
5
9
  module LlmCostTracker
6
10
  module Storage
7
11
  class Dispatcher
8
12
  class << self
9
13
  def save(event)
10
- config = LlmCostTracker.configuration
11
- case config.storage_backend
12
- when :log then log_event(event, config)
13
- when :active_record then active_record_save(event)
14
- when :custom then custom_save(event, config)
15
- end
14
+ backend.save(event)
16
15
  rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
17
16
  raise
18
17
  rescue StandardError => e
@@ -22,34 +21,8 @@ module LlmCostTracker
22
21
 
23
22
  private
24
23
 
25
- def log_event(event, config)
26
- message = "#{event.provider}/#{event.model} " \
27
- "tokens=#{event.total_tokens} " \
28
- "cost=#{log_cost_label(event)}"
29
- message += " latency=#{event.latency_ms}ms" if event.latency_ms
30
- message += " stream=#{event.stream}" if event.stream
31
- message += " source=#{event.usage_source}" if event.usage_source
32
- message += " tags=#{event.tags}" unless event.tags.empty?
33
-
34
- Logging.log(config.log_level, message)
35
- event
36
- end
37
-
38
- def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
39
-
40
- def active_record_save(event)
41
- require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
42
- require_relative "active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
43
-
44
- ActiveRecordStore.save(event)
45
- event
46
- rescue LoadError => e
47
- raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
48
- end
49
-
50
- def custom_save(event, config)
51
- result = config.custom_storage&.call(event)
52
- result == false ? false : event
24
+ def backend
25
+ Registry.fetch(LlmCostTracker.configuration.storage_backend)
53
26
  end
54
27
 
55
28
  def handle_error(error)
@@ -64,5 +37,9 @@ module LlmCostTracker
64
37
  end
65
38
  end
66
39
  end
40
+
41
+ Registry.register(:log, LogBackend)
42
+ Registry.register(:active_record, ActiveRecordBackend)
43
+ Registry.register(:custom, CustomBackend)
67
44
  end
68
45
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logging"
4
+ require_relative "registry"
5
+
6
+ module LlmCostTracker
7
+ module Storage
8
+ class LogBackend
9
+ class << self
10
+ def save(event)
11
+ config = LlmCostTracker.configuration
12
+ message = "#{event.provider}/#{event.model} " \
13
+ "tokens=#{event.total_tokens} " \
14
+ "cost=#{cost_label(event)}"
15
+ message += " latency=#{event.latency_ms}ms" if event.latency_ms
16
+ message += " stream=#{event.stream}" if event.stream
17
+ message += " source=#{event.usage_source}" if event.usage_source
18
+ message += " tags=#{event.tags}" unless event.tags.empty?
19
+
20
+ Logging.log(config.log_level, message)
21
+ event
22
+ end
23
+
24
+ def verify
25
+ [
26
+ VerificationResult.new(:ok, "storage", "log backend configured; capture writes to logs only")
27
+ ]
28
+ end
29
+
30
+ private
31
+
32
+ def cost_label(event)
33
+ event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ require_relative "../errors"
6
+
7
+ module LlmCostTracker
8
+ module Storage
9
+ VerificationResult = Data.define(:status, :name, :message)
10
+
11
+ module Registry
12
+ MUTEX = Monitor.new
13
+
14
+ class << self
15
+ def register(name, backend)
16
+ name = normalize_name(name)
17
+ validate_backend!(backend)
18
+ MUTEX.synchronize { @backends = backends.merge(name => backend).freeze }
19
+ backend
20
+ end
21
+
22
+ def fetch(name)
23
+ key = normalize_name(name)
24
+ backends.fetch(key) do
25
+ raise Error, "Unknown storage_backend: #{key.inspect}. Use one of: #{names.join(', ')}"
26
+ end
27
+ end
28
+
29
+ def registered?(name)
30
+ backends.key?(normalize_name(name))
31
+ end
32
+
33
+ def names
34
+ backends.keys
35
+ end
36
+
37
+ private
38
+
39
+ def backends
40
+ @backends || MUTEX.synchronize { @backends ||= {}.freeze }
41
+ end
42
+
43
+ def normalize_name(name)
44
+ name.to_sym
45
+ end
46
+
47
+ def validate_backend!(backend)
48
+ return if backend.respond_to?(:save)
49
+
50
+ raise ArgumentError, "storage backend must respond to save"
51
+ end
52
+ end
53
+ end
54
+
55
+ def self.register(name, backend)
56
+ Registry.register(name, backend)
57
+ end
58
+
59
+ def self.backends
60
+ Registry.names
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tag_key"
4
+
5
+ module LlmCostTracker
6
+ module TagSql
7
+ class << self
8
+ def value_expression(model, key, table_name:)
9
+ key = TagKey.validate!(key)
10
+ column = "#{table_name}.#{model.connection.quote_column_name('tags')}"
11
+
12
+ case model.connection.adapter_name
13
+ when /postgres/i
14
+ json_column = model.tags_jsonb_column? ? column : "(#{column})::jsonb"
15
+ "#{json_column}->>#{model.connection.quote(key)}"
16
+ when /mysql/i
17
+ "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
18
+ else
19
+ "json_extract(#{column}, #{model.connection.quote(json_path(key))})"
20
+ end
21
+ end
22
+
23
+ def value_label(value)
24
+ value.nil? || value == "" ? "(untagged)" : value.to_s
25
+ end
26
+
27
+ private
28
+
29
+ def json_path(key)
30
+ "$.\"#{key}\""
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.5.2"
4
+ VERSION = "0.5.3"
5
5
  end
@@ -33,14 +33,17 @@ require_relative "llm_cost_tracker/tag_context"
33
33
  require_relative "llm_cost_tracker/tag_sanitizer"
34
34
  require_relative "llm_cost_tracker/tags_column"
35
35
  require_relative "llm_cost_tracker/tag_key"
36
+ require_relative "llm_cost_tracker/tag_sql"
36
37
  require_relative "llm_cost_tracker/tag_query"
37
38
  require_relative "llm_cost_tracker/tag_accessors"
39
+ require_relative "llm_cost_tracker/llm_api_call_metrics"
38
40
  require_relative "llm_cost_tracker/tracker"
39
41
  require_relative "llm_cost_tracker/retention"
40
42
  require_relative "llm_cost_tracker/report_data"
41
43
  require_relative "llm_cost_tracker/report_formatter"
42
44
  require_relative "llm_cost_tracker/report"
43
45
  require_relative "llm_cost_tracker/doctor"
46
+ require_relative "llm_cost_tracker/capture_verifier"
44
47
 
45
48
  module LlmCostTracker
46
49
  CONFIGURATION_MUTEX = Monitor.new
@@ -7,11 +7,21 @@ namespace :llm_cost_tracker do
7
7
  desc "Check LLM Cost Tracker setup"
8
8
  task :doctor do
9
9
  Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
10
+ require_relative "../llm_cost_tracker"
10
11
  checks = LlmCostTracker::Doctor.call
11
12
  puts LlmCostTracker::Doctor.report(checks)
12
13
  abort("llm_cost_tracker: doctor found setup errors") unless LlmCostTracker::Doctor.healthy?(checks)
13
14
  end
14
15
 
16
+ desc "Verify that LLM Cost Tracker can capture and persist a synthetic event"
17
+ task :verify_capture do
18
+ Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
19
+ require_relative "../llm_cost_tracker"
20
+ checks = LlmCostTracker::CaptureVerifier.call
21
+ puts LlmCostTracker::CaptureVerifier.report(checks)
22
+ abort("llm_cost_tracker: capture verification failed") unless LlmCostTracker::CaptureVerifier.healthy?(checks)
23
+ end
24
+
15
25
  desc "Print an LLM cost report from ActiveRecord storage"
16
26
  task report: :environment do
17
27
  days = (ENV["DAYS"] || LlmCostTracker::Report::DEFAULT_DAYS).to_i
@@ -74,6 +84,17 @@ namespace :llm_cost_tracker do
74
84
  puts " pricing is up to date" if result.up_to_date
75
85
  abort("llm_cost_tracker: pricing check failed") unless result.up_to_date
76
86
  end
87
+
88
+ desc "Explain how a provider/model price is matched. Use PROVIDER=... MODEL=..."
89
+ task :explain do
90
+ Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
91
+ require_relative "../llm_cost_tracker"
92
+
93
+ explanation = price_explanation_from_env
94
+ puts "llm_cost_tracker: #{explanation.message}"
95
+ print_price_explanation(explanation)
96
+ abort("llm_cost_tracker: price is incomplete or unknown") unless explanation.complete?
97
+ end
77
98
  end
78
99
  end
79
100
  # rubocop:enable Metrics/BlockLength
@@ -95,3 +116,31 @@ def price_refresh_output_path
95
116
  FileUtils.mkdir_p(File.dirname(path))
96
117
  path
97
118
  end
119
+
120
+ def price_explanation_from_env
121
+ provider = ENV["PROVIDER"].to_s.strip
122
+ model = ENV["MODEL"].to_s.strip
123
+ abort("llm_cost_tracker: use PROVIDER=... MODEL=...") if provider.empty? || model.empty?
124
+
125
+ LlmCostTracker::Pricing.explain(
126
+ provider: provider,
127
+ model: model,
128
+ pricing_mode: ENV.fetch("PRICING_MODE", nil),
129
+ input_tokens: ENV.fetch("INPUT_TOKENS", 1).to_i,
130
+ output_tokens: ENV.fetch("OUTPUT_TOKENS", 1).to_i,
131
+ cache_read_input_tokens: ENV.fetch("CACHE_READ_INPUT_TOKENS", 0).to_i,
132
+ cache_write_input_tokens: ENV.fetch("CACHE_WRITE_INPUT_TOKENS", 0).to_i
133
+ )
134
+ end
135
+
136
+ def print_price_explanation(explanation)
137
+ return unless explanation.matched?
138
+
139
+ puts " source: #{explanation.source}"
140
+ puts " matched_key: #{explanation.matched_key}"
141
+ puts " matched_by: #{explanation.matched_by}"
142
+ puts " pricing_mode: #{explanation.pricing_mode || 'standard'}"
143
+ explanation.effective_prices.each do |key, value|
144
+ puts " #{key}: #{value.nil? ? 'missing' : value}"
145
+ end
146
+ end
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.5.2
4
+ version: 0.5.3
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-27 00:00:00.000000000 Z
11
+ date: 2026-04-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -283,13 +283,33 @@ files:
283
283
  - app/views/llm_cost_tracker/tags/index.html.erb
284
284
  - app/views/llm_cost_tracker/tags/show.html.erb
285
285
  - config/routes.rb
286
+ - docs/architecture.md
287
+ - docs/budgets.md
288
+ - docs/configuration.md
289
+ - docs/cookbook.md
290
+ - docs/dashboard-overview.png
291
+ - docs/dashboard.md
292
+ - docs/extending.md
293
+ - docs/operations.md
294
+ - docs/pricing.md
295
+ - docs/querying.md
296
+ - docs/streaming.md
297
+ - docs/technical/README.md
298
+ - docs/technical/data-flow.md
299
+ - docs/technical/extension-points.md
300
+ - docs/technical/module-map.md
301
+ - docs/technical/operational-notes.md
302
+ - docs/upgrading.md
286
303
  - lib/llm_cost_tracker.rb
287
304
  - lib/llm_cost_tracker/assets.rb
288
305
  - lib/llm_cost_tracker/budget.rb
306
+ - lib/llm_cost_tracker/capture_verifier.rb
289
307
  - lib/llm_cost_tracker/configuration.rb
290
308
  - lib/llm_cost_tracker/configuration/instrumentation.rb
309
+ - lib/llm_cost_tracker/configuration/storage_backend.rb
291
310
  - lib/llm_cost_tracker/cost.rb
292
311
  - lib/llm_cost_tracker/doctor.rb
312
+ - lib/llm_cost_tracker/doctor/capture_check.rb
293
313
  - lib/llm_cost_tracker/engine.rb
294
314
  - lib/llm_cost_tracker/engine_compatibility.rb
295
315
  - lib/llm_cost_tracker/errors.rb
@@ -319,7 +339,9 @@ files:
319
339
  - lib/llm_cost_tracker/integrations/openai.rb
320
340
  - lib/llm_cost_tracker/integrations/registry.rb
321
341
  - lib/llm_cost_tracker/integrations/ruby_llm.rb
342
+ - lib/llm_cost_tracker/integrations/stream_tracker.rb
322
343
  - lib/llm_cost_tracker/llm_api_call.rb
344
+ - lib/llm_cost_tracker/llm_api_call_metrics.rb
323
345
  - lib/llm_cost_tracker/logging.rb
324
346
  - lib/llm_cost_tracker/middleware/faraday.rb
325
347
  - lib/llm_cost_tracker/parameter_hash.rb
@@ -343,15 +365,22 @@ files:
343
365
  - lib/llm_cost_tracker/price_sync/registry_writer.rb
344
366
  - lib/llm_cost_tracker/prices.json
345
367
  - lib/llm_cost_tracker/pricing.rb
368
+ - lib/llm_cost_tracker/pricing/effective_prices.rb
369
+ - lib/llm_cost_tracker/pricing/explainer.rb
370
+ - lib/llm_cost_tracker/pricing/lookup.rb
346
371
  - lib/llm_cost_tracker/railtie.rb
347
372
  - lib/llm_cost_tracker/report.rb
348
373
  - lib/llm_cost_tracker/report_data.rb
349
374
  - lib/llm_cost_tracker/report_formatter.rb
350
375
  - lib/llm_cost_tracker/request_url.rb
351
376
  - lib/llm_cost_tracker/retention.rb
377
+ - lib/llm_cost_tracker/storage/active_record_backend.rb
352
378
  - lib/llm_cost_tracker/storage/active_record_rollups.rb
353
379
  - lib/llm_cost_tracker/storage/active_record_store.rb
380
+ - lib/llm_cost_tracker/storage/custom_backend.rb
354
381
  - lib/llm_cost_tracker/storage/dispatcher.rb
382
+ - lib/llm_cost_tracker/storage/log_backend.rb
383
+ - lib/llm_cost_tracker/storage/registry.rb
355
384
  - lib/llm_cost_tracker/stream_capture.rb
356
385
  - lib/llm_cost_tracker/stream_collector.rb
357
386
  - lib/llm_cost_tracker/tag_accessors.rb
@@ -359,6 +388,7 @@ files:
359
388
  - lib/llm_cost_tracker/tag_key.rb
360
389
  - lib/llm_cost_tracker/tag_query.rb
361
390
  - lib/llm_cost_tracker/tag_sanitizer.rb
391
+ - lib/llm_cost_tracker/tag_sql.rb
362
392
  - lib/llm_cost_tracker/tags_column.rb
363
393
  - lib/llm_cost_tracker/tracker.rb
364
394
  - lib/llm_cost_tracker/unknown_pricing.rb