llm_cost_tracker 0.3.3 → 0.4.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +32 -15
  4. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +101 -19
  5. data/app/views/llm_cost_tracker/data_quality/index.html.erb +65 -0
  6. data/lib/llm_cost_tracker/budget.rb +76 -22
  7. data/lib/llm_cost_tracker/configuration.rb +4 -0
  8. data/lib/llm_cost_tracker/cost.rb +1 -2
  9. data/lib/llm_cost_tracker/errors.rb +22 -3
  10. data/lib/llm_cost_tracker/event.rb +4 -0
  11. data/lib/llm_cost_tracker/event_metadata.rb +21 -15
  12. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_monthly_totals_generator.rb → add_period_totals_generator.rb} +4 -4
  13. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +29 -0
  14. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +66 -0
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +29 -0
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +10 -3
  17. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -1
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +11 -3
  19. data/lib/llm_cost_tracker/parsed_usage.rb +16 -7
  20. data/lib/llm_cost_tracker/parsers/anthropic.rb +7 -6
  21. data/lib/llm_cost_tracker/parsers/gemini.rb +5 -2
  22. data/lib/llm_cost_tracker/parsers/openai_usage.rb +18 -5
  23. data/lib/llm_cost_tracker/period_total.rb +9 -0
  24. data/lib/llm_cost_tracker/price_registry.rb +14 -4
  25. data/lib/llm_cost_tracker/price_sync/merger.rb +1 -1
  26. data/lib/llm_cost_tracker/price_sync/raw_price.rb +3 -5
  27. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +2 -3
  28. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +2 -3
  29. data/lib/llm_cost_tracker/prices.json +30 -30
  30. data/lib/llm_cost_tracker/pricing.rb +44 -32
  31. data/lib/llm_cost_tracker/railtie.rb +2 -1
  32. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +122 -0
  33. data/lib/llm_cost_tracker/storage/active_record_store.rb +33 -80
  34. data/lib/llm_cost_tracker/stream_collector.rb +4 -2
  35. data/lib/llm_cost_tracker/tags_column.rb +19 -0
  36. data/lib/llm_cost_tracker/tracker.rb +54 -32
  37. data/lib/llm_cost_tracker/usage_breakdown.rb +30 -0
  38. data/lib/llm_cost_tracker/version.rb +1 -1
  39. data/lib/llm_cost_tracker.rb +10 -3
  40. metadata +8 -4
  41. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_monthly_totals_to_llm_cost_tracker.rb.erb +0 -48
  42. data/lib/llm_cost_tracker/monthly_total.rb +0 -9
@@ -16,28 +16,70 @@ module LlmCostTracker
16
16
  end
17
17
 
18
18
  def record(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false,
19
- usage_source: nil, provider_response_id: nil, metadata: {})
19
+ usage_source: nil, provider_response_id: nil, pricing_mode: nil, metadata: {})
20
20
  return unless LlmCostTracker.configuration.enabled
21
21
 
22
- usage = EventMetadata.usage_data(input_tokens, output_tokens, metadata)
22
+ usage = usage_data(input_tokens, output_tokens, metadata, pricing_mode)
23
+ cost_data = cost_for_usage(provider, model, usage)
23
24
 
24
- cost_data = Pricing.cost_for(
25
+ UnknownPricing.handle!(model) unless cost_data
26
+
27
+ event = build_event(
28
+ provider: provider,
29
+ model: model,
30
+ usage: usage,
31
+ cost_data: cost_data,
32
+ metadata: metadata,
33
+ latency_ms: latency_ms,
34
+ stream: stream,
35
+ usage_source: usage_source,
36
+ provider_response_id: provider_response_id
37
+ )
38
+
39
+ ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
40
+
41
+ stored = store(event)
42
+ Budget.check!(event) unless stored == false
43
+
44
+ event
45
+ end
46
+
47
+ private
48
+
49
+ def usage_data(input_tokens, output_tokens, metadata, pricing_mode)
50
+ metadata = metadata.merge(pricing_mode: pricing_mode) unless pricing_mode.nil?
51
+
52
+ EventMetadata.usage_data(
53
+ input_tokens,
54
+ output_tokens,
55
+ metadata
56
+ )
57
+ end
58
+
59
+ def cost_for_usage(provider, model, usage)
60
+ Pricing.cost_for(
61
+ provider: provider,
25
62
  model: model,
26
63
  input_tokens: usage[:input_tokens],
27
64
  output_tokens: usage[:output_tokens],
28
- cached_input_tokens: usage[:cached_input_tokens],
29
65
  cache_read_input_tokens: usage[:cache_read_input_tokens],
30
- cache_creation_input_tokens: usage[:cache_creation_input_tokens]
66
+ cache_write_input_tokens: usage[:cache_write_input_tokens],
67
+ pricing_mode: usage[:pricing_mode]
31
68
  )
69
+ end
32
70
 
33
- UnknownPricing.handle!(model) unless cost_data
34
-
35
- event = Event.new(
71
+ def build_event(provider:, model:, usage:, cost_data:, metadata:, latency_ms:, stream:, usage_source:,
72
+ provider_response_id:)
73
+ Event.new(
36
74
  provider: provider,
37
75
  model: model,
38
76
  input_tokens: usage[:input_tokens],
39
77
  output_tokens: usage[:output_tokens],
40
78
  total_tokens: usage[:total_tokens],
79
+ cache_read_input_tokens: usage[:cache_read_input_tokens],
80
+ cache_write_input_tokens: usage[:cache_write_input_tokens],
81
+ hidden_output_tokens: usage[:hidden_output_tokens],
82
+ pricing_mode: usage[:pricing_mode],
41
83
  cost: cost_data,
42
84
  tags: LlmCostTracker.configuration.default_tags.merge(EventMetadata.tags(metadata)).freeze,
43
85
  latency_ms: normalized_latency_ms(latency_ms),
@@ -46,17 +88,8 @@ module LlmCostTracker
46
88
  provider_response_id: normalized_provider_response_id(provider_response_id),
47
89
  tracked_at: Time.now.utc
48
90
  )
49
-
50
- ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
51
-
52
- stored = store(event)
53
- Budget.check!(event) unless stored == false
54
-
55
- event
56
91
  end
57
92
 
58
- private
59
-
60
93
  def store(event)
61
94
  config = LlmCostTracker.configuration
62
95
  case config.storage_backend
@@ -73,7 +106,7 @@ module LlmCostTracker
73
106
 
74
107
  def log_event(event, config)
75
108
  message = "#{event.provider}/#{event.model} " \
76
- "tokens=#{event.input_tokens}+#{event.output_tokens} " \
109
+ "tokens=#{event.total_tokens} " \
77
110
  "cost=#{log_cost_label(event)}"
78
111
  message += " latency=#{event.latency_ms}ms" if event.latency_ms
79
112
  message += " stream=#{event.stream}" if event.stream
@@ -84,9 +117,7 @@ module LlmCostTracker
84
117
  event
85
118
  end
86
119
 
87
- def log_cost_label(event)
88
- event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
89
- end
120
+ def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
90
121
 
91
122
  def active_record_save(event)
92
123
  require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
@@ -115,11 +146,7 @@ module LlmCostTracker
115
146
  end
116
147
  end
117
148
 
118
- def normalized_latency_ms(latency_ms)
119
- return nil if latency_ms.nil?
120
-
121
- [latency_ms.to_i, 0].max
122
- end
149
+ def normalized_latency_ms(latency_ms) = latency_ms.nil? ? nil : [latency_ms.to_i, 0].max
123
150
 
124
151
  def normalized_usage_source(value)
125
152
  return nil if value.nil?
@@ -128,12 +155,7 @@ module LlmCostTracker
128
155
  USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
129
156
  end
130
157
 
131
- def normalized_provider_response_id(value)
132
- return nil if value.nil?
133
-
134
- string = value.to_s
135
- string.empty? ? nil : string
136
- end
158
+ def normalized_provider_response_id(value) = value.nil? || value.to_s.empty? ? nil : value.to_s
137
159
  end
138
160
  end
139
161
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ UsageBreakdown = Data.define(
5
+ :input_tokens,
6
+ :cache_read_input_tokens,
7
+ :cache_write_input_tokens,
8
+ :output_tokens,
9
+ :hidden_output_tokens
10
+ ) do
11
+ def self.build(input_tokens:, output_tokens:, cache_read_input_tokens: 0,
12
+ cache_write_input_tokens: 0, hidden_output_tokens: 0)
13
+ new(
14
+ input_tokens: input_tokens.to_i,
15
+ cache_read_input_tokens: cache_read_input_tokens.to_i,
16
+ cache_write_input_tokens: cache_write_input_tokens.to_i,
17
+ output_tokens: output_tokens.to_i,
18
+ hidden_output_tokens: hidden_output_tokens.to_i
19
+ )
20
+ end
21
+
22
+ def total_tokens
23
+ input_tokens + cache_read_input_tokens + cache_write_input_tokens + output_tokens
24
+ end
25
+
26
+ def to_h
27
+ super.merge(total_tokens: total_tokens).compact
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.3.3"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -10,6 +10,7 @@ require_relative "llm_cost_tracker/errors"
10
10
  require_relative "llm_cost_tracker/logging"
11
11
  require_relative "llm_cost_tracker/parameter_hash"
12
12
  require_relative "llm_cost_tracker/cost"
13
+ require_relative "llm_cost_tracker/usage_breakdown"
13
14
  require_relative "llm_cost_tracker/event"
14
15
  require_relative "llm_cost_tracker/parsed_usage"
15
16
  require_relative "llm_cost_tracker/price_registry"
@@ -69,7 +70,7 @@ module LlmCostTracker
69
70
  end
70
71
 
71
72
  def track(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false, usage_source: :manual,
72
- enforce_budget: false, provider_response_id: nil, **metadata)
73
+ enforce_budget: false, provider_response_id: nil, pricing_mode: nil, **metadata)
73
74
  enforce_budget! if enforce_budget
74
75
  Tracker.record(
75
76
  provider: provider.to_s,
@@ -80,11 +81,13 @@ module LlmCostTracker
80
81
  stream: stream,
81
82
  usage_source: usage_source,
82
83
  provider_response_id: provider_response_id,
84
+ pricing_mode: pricing_mode,
83
85
  metadata: metadata
84
86
  )
85
87
  end
86
88
 
87
- def track_stream(provider:, model:, latency_ms: nil, enforce_budget: false, provider_response_id: nil, **metadata)
89
+ def track_stream(provider:, model:, latency_ms: nil, enforce_budget: false, provider_response_id: nil,
90
+ pricing_mode: nil, **metadata)
88
91
  require_relative "llm_cost_tracker/stream_collector"
89
92
  enforce_budget! if enforce_budget
90
93
  collector = StreamCollector.new(
@@ -92,6 +95,7 @@ module LlmCostTracker
92
95
  model: model,
93
96
  latency_ms: latency_ms,
94
97
  provider_response_id: provider_response_id,
98
+ pricing_mode: pricing_mode,
95
99
  metadata: metadata
96
100
  )
97
101
  yield collector
@@ -107,7 +111,10 @@ module LlmCostTracker
107
111
  return unless config.budget_exceeded_behavior == :block_requests
108
112
  return if config.active_record?
109
113
 
110
- Logging.warn(":block_requests requires storage_backend = :active_record; preflight blocking will be skipped.")
114
+ Logging.warn(
115
+ ":block_requests requires storage_backend = :active_record for monthly and daily preflight; " \
116
+ "preflight blocking will be skipped."
117
+ )
111
118
  end
112
119
  end
113
120
  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.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko
@@ -278,15 +278,17 @@ files:
278
278
  - lib/llm_cost_tracker/event.rb
279
279
  - lib/llm_cost_tracker/event_metadata.rb
280
280
  - lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb
281
- - lib/llm_cost_tracker/generators/llm_cost_tracker/add_monthly_totals_generator.rb
281
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb
282
282
  - lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb
283
283
  - lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb
284
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb
284
285
  - lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
285
286
  - lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb
286
287
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb
287
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_monthly_totals_to_llm_cost_tracker.rb.erb
288
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb
288
289
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb
289
290
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb
291
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb
290
292
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb
291
293
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb
292
294
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb
@@ -297,7 +299,6 @@ files:
297
299
  - lib/llm_cost_tracker/llm_api_call.rb
298
300
  - lib/llm_cost_tracker/logging.rb
299
301
  - lib/llm_cost_tracker/middleware/faraday.rb
300
- - lib/llm_cost_tracker/monthly_total.rb
301
302
  - lib/llm_cost_tracker/parameter_hash.rb
302
303
  - lib/llm_cost_tracker/parsed_usage.rb
303
304
  - lib/llm_cost_tracker/parsers/anthropic.rb
@@ -309,6 +310,7 @@ files:
309
310
  - lib/llm_cost_tracker/parsers/registry.rb
310
311
  - lib/llm_cost_tracker/parsers/sse.rb
311
312
  - lib/llm_cost_tracker/period_grouping.rb
313
+ - lib/llm_cost_tracker/period_total.rb
312
314
  - lib/llm_cost_tracker/price_registry.rb
313
315
  - lib/llm_cost_tracker/price_sync.rb
314
316
  - lib/llm_cost_tracker/price_sync/fetcher.rb
@@ -330,6 +332,7 @@ files:
330
332
  - lib/llm_cost_tracker/report_data.rb
331
333
  - lib/llm_cost_tracker/report_formatter.rb
332
334
  - lib/llm_cost_tracker/retention.rb
335
+ - lib/llm_cost_tracker/storage/active_record_rollups.rb
333
336
  - lib/llm_cost_tracker/storage/active_record_store.rb
334
337
  - lib/llm_cost_tracker/stream_collector.rb
335
338
  - lib/llm_cost_tracker/tag_accessors.rb
@@ -338,6 +341,7 @@ files:
338
341
  - lib/llm_cost_tracker/tags_column.rb
339
342
  - lib/llm_cost_tracker/tracker.rb
340
343
  - lib/llm_cost_tracker/unknown_pricing.rb
344
+ - lib/llm_cost_tracker/usage_breakdown.rb
341
345
  - lib/llm_cost_tracker/value_helpers.rb
342
346
  - lib/llm_cost_tracker/version.rb
343
347
  - lib/tasks/llm_cost_tracker.rake
@@ -1,48 +0,0 @@
1
- class AddMonthlyTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
2
- def up
3
- create_table :llm_cost_tracker_monthly_totals do |t|
4
- t.date :month_start, null: false
5
- t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
6
-
7
- t.timestamps
8
- end unless table_exists?(:llm_cost_tracker_monthly_totals)
9
-
10
- add_index :llm_cost_tracker_monthly_totals, :month_start,
11
- unique: true unless index_exists?(:llm_cost_tracker_monthly_totals, :month_start)
12
-
13
- backfill_monthly_totals
14
- end
15
-
16
- def down
17
- remove_index :llm_cost_tracker_monthly_totals, :month_start if index_exists?(:llm_cost_tracker_monthly_totals, :month_start)
18
- drop_table :llm_cost_tracker_monthly_totals if table_exists?(:llm_cost_tracker_monthly_totals)
19
- end
20
-
21
- private
22
-
23
- def backfill_monthly_totals
24
- return unless table_exists?(:llm_api_calls)
25
-
26
- execute <<~SQL
27
- INSERT INTO llm_cost_tracker_monthly_totals (month_start, total_cost, created_at, updated_at)
28
- SELECT #{month_bucket_sql} AS month_start,
29
- SUM(total_cost) AS total_cost,
30
- CURRENT_TIMESTAMP,
31
- CURRENT_TIMESTAMP
32
- FROM llm_api_calls
33
- WHERE total_cost IS NOT NULL
34
- GROUP BY #{month_bucket_sql}
35
- SQL
36
- end
37
-
38
- def month_bucket_sql
39
- case connection.adapter_name
40
- when /postgres/i
41
- "DATE_TRUNC('month', tracked_at)::date"
42
- when /mysql/i
43
- "DATE_FORMAT(tracked_at, '%Y-%m-01')"
44
- else
45
- "strftime('%Y-%m-01', tracked_at)"
46
- end
47
- end
48
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_record"
4
-
5
- module LlmCostTracker
6
- class MonthlyTotal < ActiveRecord::Base
7
- self.table_name = "llm_cost_tracker_monthly_totals"
8
- end
9
- end