llm_cost_tracker 0.3.0 → 0.3.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 +23 -0
  3. data/CODE_OF_CONDUCT.md +23 -0
  4. data/README.md +86 -8
  5. data/SECURITY.md +36 -0
  6. data/app/assets/llm_cost_tracker/application.css +1 -4
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +0 -2
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
  9. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
  10. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
  11. data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
  12. data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +10 -10
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -26
  17. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
  18. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
  19. data/app/services/llm_cost_tracker/pagination.rb +1 -9
  20. data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
  21. data/app/views/llm_cost_tracker/calls/index.html.erb +13 -13
  22. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
  23. data/app/views/llm_cost_tracker/dashboard/index.html.erb +1 -1
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +36 -14
  25. data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
  26. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
  27. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
  28. data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
  29. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  30. data/lib/llm_cost_tracker/configuration.rb +0 -1
  31. data/lib/llm_cost_tracker/event.rb +1 -0
  32. data/lib/llm_cost_tracker/event_metadata.rb +1 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +2 -0
  36. data/lib/llm_cost_tracker/llm_api_call.rb +6 -2
  37. data/lib/llm_cost_tracker/middleware/faraday.rb +1 -0
  38. data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
  39. data/lib/llm_cost_tracker/parsed_usage.rb +14 -3
  40. data/lib/llm_cost_tracker/parsers/anthropic.rb +47 -28
  41. data/lib/llm_cost_tracker/parsers/gemini.rb +28 -4
  42. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -6
  43. data/lib/llm_cost_tracker/parsers/openai_usage.rb +14 -0
  44. data/lib/llm_cost_tracker/price_registry.rb +22 -7
  45. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
  46. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
  47. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
  48. data/lib/llm_cost_tracker/price_sync.rb +16 -184
  49. data/lib/llm_cost_tracker/pricing.rb +0 -11
  50. data/lib/llm_cost_tracker/railtie.rb +2 -1
  51. data/lib/llm_cost_tracker/report.rb +0 -5
  52. data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -11
  53. data/lib/llm_cost_tracker/stream_collector.rb +17 -13
  54. data/lib/llm_cost_tracker/tags_column.rb +4 -0
  55. data/lib/llm_cost_tracker/tracker.rb +10 -2
  56. data/lib/llm_cost_tracker/version.rb +1 -1
  57. data/lib/llm_cost_tracker.rb +6 -14
  58. data/llm_cost_tracker.gemspec +3 -1
  59. metadata +37 -1
@@ -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)
@@ -4,6 +4,8 @@ module LlmCostTracker
4
4
  class Railtie < Rails::Railtie
5
5
  generators do
6
6
  require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
7
+ require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
8
+ require_relative "generators/llm_cost_tracker/add_streaming_generator"
7
9
  require_relative "generators/llm_cost_tracker/install_generator"
8
10
  require_relative "generators/llm_cost_tracker/prices_generator"
9
11
  require_relative "generators/llm_cost_tracker/upgrade_cost_precision_generator"
@@ -15,7 +17,6 @@ module LlmCostTracker
15
17
  end
16
18
 
17
19
  initializer "llm_cost_tracker.configure" do
18
- # Auto-require ActiveRecord storage if configured
19
20
  ActiveSupport.on_load(:active_record) do
20
21
  if LlmCostTracker.configuration.active_record?
21
22
  require_relative "llm_api_call"
@@ -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] = event.latency_ms if model_class.latency_column?
23
- attributes[:stream] = event.stream if model_class.stream_column?
24
- attributes[:usage_source] = event.usage_source if model_class.usage_source_column?
25
-
26
- model_class.create!(attributes)
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
- model_class
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
- model_class.tags_json_column? ? tags : tags.to_json
46
+ LlmCostTracker::LlmApiCall.tags_json_column? ? tags : tags.to_json
48
47
  end
49
48
 
50
49
  def stringify_tag_value(value)
@@ -8,10 +8,11 @@ module LlmCostTracker
8
8
  class StreamCollector
9
9
  attr_reader :provider
10
10
 
11
- def initialize(provider:, model:, latency_ms: nil, metadata: {})
11
+ def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, metadata: {})
12
12
  @provider = provider.to_s
13
13
  @model = model
14
14
  @latency_ms = latency_ms
15
+ @provider_response_id = provider_response_id
15
16
  @metadata = ValueHelpers.deep_dup(metadata || {})
16
17
  @events = []
17
18
  @explicit_usage = nil
@@ -20,13 +21,11 @@ module LlmCostTracker
20
21
  @monitor = Monitor.new
21
22
  end
22
23
 
23
- def model
24
- @monitor.synchronize { @model }
25
- end
24
+ def model = @monitor.synchronize { @model }
26
25
 
27
- def metadata
28
- @monitor.synchronize { ValueHelpers.deep_dup(@metadata) }
29
- end
26
+ def metadata = @monitor.synchronize { ValueHelpers.deep_dup(@metadata) }
27
+
28
+ def provider_response_id = @monitor.synchronize { @provider_response_id }
30
29
 
31
30
  def model=(value)
32
31
  @monitor.synchronize do
@@ -35,6 +34,13 @@ module LlmCostTracker
35
34
  end
36
35
  end
37
36
 
37
+ def provider_response_id=(value)
38
+ @monitor.synchronize do
39
+ ensure_open!
40
+ @provider_response_id = value
41
+ end
42
+ end
43
+
38
44
  def event(data, type: nil)
39
45
  @monitor.synchronize do
40
46
  ensure_open!
@@ -67,6 +73,7 @@ module LlmCostTracker
67
73
  explicit_usage: ValueHelpers.deep_dup(@explicit_usage),
68
74
  model: @model,
69
75
  latency_ms: @latency_ms,
76
+ provider_response_id: @provider_response_id,
70
77
  metadata: ValueHelpers.deep_dup(@metadata)
71
78
  }
72
79
  end
@@ -80,6 +87,7 @@ module LlmCostTracker
80
87
  latency_ms: snapshot[:latency_ms] || elapsed_ms,
81
88
  stream: true,
82
89
  usage_source: parsed.usage_source,
90
+ provider_response_id: parsed.provider_response_id || snapshot[:provider_response_id],
83
91
  metadata: error_metadata(errored).merge(snapshot[:metadata]).merge(parsed.metadata)
84
92
  )
85
93
  end
@@ -147,12 +155,8 @@ module LlmCostTracker
147
155
  )
148
156
  end
149
157
 
150
- def error_metadata(errored)
151
- errored ? { stream_errored: true } : {}
152
- end
158
+ def error_metadata(errored) = errored ? { stream_errored: true } : {}
153
159
 
154
- def elapsed_ms
155
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round
156
- end
160
+ def elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round
157
161
  end
158
162
  end
@@ -32,5 +32,9 @@ module LlmCostTracker
32
32
  def usage_source_column?
33
33
  columns_hash.key?("usage_source")
34
34
  end
35
+
36
+ def provider_response_id_column?
37
+ columns_hash.key?("provider_response_id")
38
+ end
35
39
  end
36
40
  end
@@ -13,8 +13,8 @@ module LlmCostTracker
13
13
  Budget.enforce!
14
14
  end
15
15
 
16
- def record(provider:, model:, input_tokens:, output_tokens:,
17
- metadata: {}, latency_ms: nil, stream: false, usage_source: nil)
16
+ def record(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false,
17
+ usage_source: nil, provider_response_id: nil, metadata: {})
18
18
  usage = EventMetadata.usage_data(input_tokens, output_tokens, metadata)
19
19
 
20
20
  cost_data = Pricing.cost_for(
@@ -39,6 +39,7 @@ module LlmCostTracker
39
39
  latency_ms: normalized_latency_ms(latency_ms),
40
40
  stream: stream ? true : false,
41
41
  usage_source: normalized_usage_source(usage_source),
42
+ provider_response_id: normalized_provider_response_id(provider_response_id),
42
43
  tracked_at: Time.now.utc
43
44
  )
44
45
 
@@ -122,6 +123,13 @@ module LlmCostTracker
122
123
  symbol = value.to_sym
123
124
  USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
124
125
  end
126
+
127
+ def normalized_provider_response_id(value)
128
+ return nil if value.nil?
129
+
130
+ string = value.to_s
131
+ string.empty? ? nil : string
132
+ end
125
133
  end
126
134
  end
127
135
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.2"
5
5
  end
@@ -8,6 +8,7 @@ require_relative "llm_cost_tracker/version"
8
8
  require_relative "llm_cost_tracker/configuration"
9
9
  require_relative "llm_cost_tracker/errors"
10
10
  require_relative "llm_cost_tracker/logging"
11
+ require_relative "llm_cost_tracker/parameter_hash"
11
12
  require_relative "llm_cost_tracker/cost"
12
13
  require_relative "llm_cost_tracker/event"
13
14
  require_relative "llm_cost_tracker/parsed_usage"
@@ -44,10 +45,6 @@ module LlmCostTracker
44
45
  CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new }
45
46
  end
46
47
 
47
- # Configure the gem once during application boot.
48
- #
49
- # @yieldparam configuration [LlmCostTracker::Configuration]
50
- # @return [void]
51
48
  def configure
52
49
  config = CONFIGURATION_MUTEX.synchronize do
53
50
  current = @configuration || Configuration.new
@@ -69,13 +66,8 @@ module LlmCostTracker
69
66
  Tracker.enforce_budget!
70
67
  end
71
68
 
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
-
69
+ def track(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false, usage_source: :manual,
70
+ enforce_budget: false, provider_response_id: nil, **metadata)
79
71
  enforce_budget! if enforce_budget
80
72
  Tracker.record(
81
73
  provider: provider.to_s,
@@ -85,17 +77,19 @@ module LlmCostTracker
85
77
  latency_ms: latency_ms,
86
78
  stream: stream,
87
79
  usage_source: usage_source,
80
+ provider_response_id: provider_response_id,
88
81
  metadata: metadata
89
82
  )
90
83
  end
91
84
 
92
- def track_stream(provider:, model:, latency_ms: nil, enforce_budget: false, **metadata)
85
+ def track_stream(provider:, model:, latency_ms: nil, enforce_budget: false, provider_response_id: nil, **metadata)
93
86
  require_relative "llm_cost_tracker/stream_collector"
94
87
  enforce_budget! if enforce_budget
95
88
  collector = StreamCollector.new(
96
89
  provider: provider.to_s,
97
90
  model: model,
98
91
  latency_ms: latency_ms,
92
+ provider_response_id: provider_response_id,
99
93
  metadata: metadata
100
94
  )
101
95
  yield collector
@@ -116,10 +110,8 @@ module LlmCostTracker
116
110
  end
117
111
  end
118
112
 
119
- # Load Railtie if Rails is present
120
113
  require_relative "llm_cost_tracker/railtie" if defined?(Rails::Railtie)
121
114
 
122
- # Auto-register Faraday middleware
123
115
  if defined?(Faraday)
124
116
  Faraday::Middleware.register_middleware(
125
117
  llm_cost_tracker: LlmCostTracker::Middleware::Faraday
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.files = Dir.chdir(__dir__) do
29
29
  `git ls-files -z`.split("\x0").reject do |f|
30
30
  (File.expand_path(f) == __FILE__) ||
31
- f.start_with?("bin/", "test/", "spec/", ".git", ".github", "gemfiles/", ".rubocop", "Gemfile")
31
+ f.start_with?("bin/", "docs/", "test/", "spec/", ".git", ".github", "gemfiles/", ".rubocop", "Gemfile")
32
32
  end
33
33
  end
34
34
 
@@ -43,6 +43,8 @@ Gem::Specification.new do |spec|
43
43
  spec.add_development_dependency "rake", "~> 13.0"
44
44
  spec.add_development_dependency "rspec", "~> 3.0"
45
45
  spec.add_development_dependency "rubocop", "~> 1.0"
46
+ spec.add_development_dependency "simplecov", "~> 0.22"
47
+ spec.add_development_dependency "simplecov-lcov", "~> 0.8"
46
48
  spec.add_development_dependency "sqlite3", ">= 1.4", "< 3.0"
47
49
  spec.add_development_dependency "webmock", "~> 3.0"
48
50
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_cost_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko
@@ -146,6 +146,34 @@ dependencies:
146
146
  - - "~>"
147
147
  - !ruby/object:Gem::Version
148
148
  version: '1.0'
149
+ - !ruby/object:Gem::Dependency
150
+ name: simplecov
151
+ requirement: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - "~>"
154
+ - !ruby/object:Gem::Version
155
+ version: '0.22'
156
+ type: :development
157
+ prerelease: false
158
+ version_requirements: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - "~>"
161
+ - !ruby/object:Gem::Version
162
+ version: '0.22'
163
+ - !ruby/object:Gem::Dependency
164
+ name: simplecov-lcov
165
+ requirement: !ruby/object:Gem::Requirement
166
+ requirements:
167
+ - - "~>"
168
+ - !ruby/object:Gem::Version
169
+ version: '0.8'
170
+ type: :development
171
+ prerelease: false
172
+ version_requirements: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - "~>"
175
+ - !ruby/object:Gem::Version
176
+ version: '0.8'
149
177
  - !ruby/object:Gem::Dependency
150
178
  name: sqlite3
151
179
  requirement: !ruby/object:Gem::Requirement
@@ -192,9 +220,11 @@ extra_rdoc_files: []
192
220
  files:
193
221
  - ".rspec"
194
222
  - CHANGELOG.md
223
+ - CODE_OF_CONDUCT.md
195
224
  - LICENSE.txt
196
225
  - README.md
197
226
  - Rakefile
227
+ - SECURITY.md
198
228
  - app/assets/llm_cost_tracker/application.css
199
229
  - app/controllers/llm_cost_tracker/application_controller.rb
200
230
  - app/controllers/llm_cost_tracker/assets_controller.rb
@@ -248,10 +278,12 @@ files:
248
278
  - lib/llm_cost_tracker/event.rb
249
279
  - lib/llm_cost_tracker/event_metadata.rb
250
280
  - lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb
281
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb
251
282
  - lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb
252
283
  - lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
253
284
  - lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb
254
285
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb
286
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb
255
287
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb
256
288
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb
257
289
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb
@@ -263,6 +295,7 @@ files:
263
295
  - lib/llm_cost_tracker/llm_api_call.rb
264
296
  - lib/llm_cost_tracker/logging.rb
265
297
  - lib/llm_cost_tracker/middleware/faraday.rb
298
+ - lib/llm_cost_tracker/parameter_hash.rb
266
299
  - lib/llm_cost_tracker/parsed_usage.rb
267
300
  - lib/llm_cost_tracker/parsers/anthropic.rb
268
301
  - lib/llm_cost_tracker/parsers/base.rb
@@ -279,6 +312,9 @@ files:
279
312
  - lib/llm_cost_tracker/price_sync/merger.rb
280
313
  - lib/llm_cost_tracker/price_sync/model_catalog.rb
281
314
  - lib/llm_cost_tracker/price_sync/raw_price.rb
315
+ - lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb
316
+ - lib/llm_cost_tracker/price_sync/registry_loader.rb
317
+ - lib/llm_cost_tracker/price_sync/registry_writer.rb
282
318
  - lib/llm_cost_tracker/price_sync/source.rb
283
319
  - lib/llm_cost_tracker/price_sync/source_result.rb
284
320
  - lib/llm_cost_tracker/price_sync/sources/litellm.rb