llm_cost_tracker 0.3.3 → 0.4.1

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +46 -25
  4. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +96 -23
  5. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +81 -0
  6. data/app/views/llm_cost_tracker/data_quality/index.html.erb +65 -0
  7. data/lib/llm_cost_tracker/budget.rb +73 -22
  8. data/lib/llm_cost_tracker/configuration.rb +4 -0
  9. data/lib/llm_cost_tracker/cost.rb +1 -2
  10. data/lib/llm_cost_tracker/errors.rb +22 -3
  11. data/lib/llm_cost_tracker/event.rb +4 -0
  12. data/lib/llm_cost_tracker/event_metadata.rb +21 -15
  13. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_monthly_totals_generator.rb → add_period_totals_generator.rb} +4 -4
  14. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +29 -0
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +96 -0
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +29 -0
  17. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +11 -5
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -1
  19. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +11 -3
  20. data/lib/llm_cost_tracker/parsed_usage.rb +16 -7
  21. data/lib/llm_cost_tracker/parsers/anthropic.rb +24 -55
  22. data/lib/llm_cost_tracker/parsers/base.rb +80 -0
  23. data/lib/llm_cost_tracker/parsers/gemini.rb +17 -37
  24. data/lib/llm_cost_tracker/parsers/openai.rb +1 -6
  25. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +6 -15
  26. data/lib/llm_cost_tracker/parsers/openai_usage.rb +25 -34
  27. data/lib/llm_cost_tracker/parsers/registry.rb +17 -2
  28. data/lib/llm_cost_tracker/period_total.rb +9 -0
  29. data/lib/llm_cost_tracker/price_registry.rb +14 -4
  30. data/lib/llm_cost_tracker/price_sync/merger.rb +1 -1
  31. data/lib/llm_cost_tracker/price_sync/raw_price.rb +3 -5
  32. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +2 -3
  33. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +2 -3
  34. data/lib/llm_cost_tracker/prices.json +30 -30
  35. data/lib/llm_cost_tracker/pricing.rb +44 -32
  36. data/lib/llm_cost_tracker/railtie.rb +2 -1
  37. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +142 -0
  38. data/lib/llm_cost_tracker/storage/active_record_store.rb +35 -78
  39. data/lib/llm_cost_tracker/stream_collector.rb +4 -2
  40. data/lib/llm_cost_tracker/tags_column.rb +71 -14
  41. data/lib/llm_cost_tracker/tracker.rb +54 -32
  42. data/lib/llm_cost_tracker/usage_breakdown.rb +30 -0
  43. data/lib/llm_cost_tracker/version.rb +1 -1
  44. data/lib/llm_cost_tracker.rb +10 -3
  45. metadata +9 -4
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_monthly_totals_to_llm_cost_tracker.rb.erb +0 -48
  47. data/lib/llm_cost_tracker/monthly_total.rb +0 -9
@@ -65,9 +65,8 @@ module LlmCostTracker
65
65
  provider: provider,
66
66
  input: price_per_million(entry["input_cost_per_token"]),
67
67
  output: price_per_million(entry["output_cost_per_token"]),
68
- cached_input: provider == "anthropic" ? nil : cache_read,
69
- cache_read_input: provider == "anthropic" ? cache_read : nil,
70
- cache_creation_input: provider == "anthropic" ? cache_write : nil,
68
+ cache_read_input: cache_read,
69
+ cache_write_input: cache_write,
71
70
  source: name,
72
71
  source_version: response_version(response),
73
72
  fetched_at: response.fetched_at
@@ -68,9 +68,8 @@ module LlmCostTracker
68
68
  provider: provider,
69
69
  input: price_per_million(pricing["prompt"]),
70
70
  output: price_per_million(pricing["completion"]),
71
- cached_input: provider == "anthropic" ? nil : cache_read,
72
- cache_read_input: provider == "anthropic" ? cache_read : nil,
73
- cache_creation_input: provider == "anthropic" ? cache_write : nil,
71
+ cache_read_input: cache_read,
72
+ cache_write_input: cache_write,
74
73
  source: name,
75
74
  source_version: response_version(response),
76
75
  fetched_at: response.fetched_at
@@ -10,40 +10,40 @@
10
10
  ]
11
11
  },
12
12
  "models": {
13
- "gpt-5.2": { "input": 1.75, "cached_input": 0.175, "output": 14.0 },
14
- "gpt-5.1": { "input": 1.25, "cached_input": 0.125, "output": 10.0 },
15
- "gpt-5": { "input": 1.25, "cached_input": 0.125, "output": 10.0 },
16
- "gpt-5-mini": { "input": 0.25, "cached_input": 0.025, "output": 2.0 },
17
- "gpt-5-nano": { "input": 0.05, "cached_input": 0.005, "output": 0.4 },
18
- "gpt-4.1": { "input": 2.0, "cached_input": 0.5, "output": 8.0 },
19
- "gpt-4.1-mini": { "input": 0.4, "cached_input": 0.1, "output": 1.6 },
20
- "gpt-4.1-nano": { "input": 0.1, "cached_input": 0.025, "output": 0.4 },
13
+ "gpt-5.2": { "input": 1.75, "cache_read_input": 0.175, "output": 14.0 },
14
+ "gpt-5.1": { "input": 1.25, "cache_read_input": 0.125, "output": 10.0 },
15
+ "gpt-5": { "input": 1.25, "cache_read_input": 0.125, "output": 10.0 },
16
+ "gpt-5-mini": { "input": 0.25, "cache_read_input": 0.025, "output": 2.0 },
17
+ "gpt-5-nano": { "input": 0.05, "cache_read_input": 0.005, "output": 0.4 },
18
+ "gpt-4.1": { "input": 2.0, "cache_read_input": 0.5, "output": 8.0 },
19
+ "gpt-4.1-mini": { "input": 0.4, "cache_read_input": 0.1, "output": 1.6 },
20
+ "gpt-4.1-nano": { "input": 0.1, "cache_read_input": 0.025, "output": 0.4 },
21
21
  "gpt-4o-2024-05-13": { "input": 5.0, "output": 15.0 },
22
- "gpt-4o": { "input": 2.5, "cached_input": 1.25, "output": 10.0 },
23
- "gpt-4o-mini": { "input": 0.15, "cached_input": 0.075, "output": 0.6 },
22
+ "gpt-4o": { "input": 2.5, "cache_read_input": 1.25, "output": 10.0 },
23
+ "gpt-4o-mini": { "input": 0.15, "cache_read_input": 0.075, "output": 0.6 },
24
24
  "gpt-4-turbo": { "input": 10.0, "output": 30.0 },
25
25
  "gpt-4": { "input": 30.0, "output": 60.0 },
26
26
  "gpt-3.5-turbo": { "input": 0.5, "output": 1.5 },
27
- "o1": { "input": 15.0, "cached_input": 7.5, "output": 60.0 },
28
- "o1-mini": { "input": 1.1, "cached_input": 0.55, "output": 4.4 },
29
- "o3": { "input": 2.0, "cached_input": 0.5, "output": 8.0 },
30
- "o3-mini": { "input": 1.1, "cached_input": 0.55, "output": 4.4 },
31
- "o4-mini": { "input": 1.1, "cached_input": 0.275, "output": 4.4 },
32
- "claude-sonnet-4-6": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_creation_input": 3.75 },
33
- "claude-opus-4-6": { "input": 5.0, "output": 25.0, "cache_read_input": 0.5, "cache_creation_input": 6.25 },
34
- "claude-opus-4-1": { "input": 15.0, "output": 75.0, "cache_read_input": 1.5, "cache_creation_input": 18.75 },
35
- "claude-opus-4": { "input": 15.0, "output": 75.0, "cache_read_input": 1.5, "cache_creation_input": 18.75 },
36
- "claude-sonnet-4-5": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_creation_input": 3.75 },
37
- "claude-sonnet-4": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_creation_input": 3.75 },
38
- "claude-haiku-4-5": { "input": 1.0, "output": 5.0, "cache_read_input": 0.1, "cache_creation_input": 1.25 },
39
- "claude-3-7-sonnet": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_creation_input": 3.75 },
40
- "claude-3-5-sonnet": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_creation_input": 3.75 },
41
- "claude-3-5-haiku": { "input": 0.8, "output": 4.0, "cache_read_input": 0.08, "cache_creation_input": 1.0 },
42
- "claude-3-opus": { "input": 15.0, "output": 75.0, "cache_read_input": 1.5, "cache_creation_input": 18.75 },
43
- "gemini-2.5-pro": { "input": 1.25, "cached_input": 0.125, "output": 10.0 },
44
- "gemini-2.5-flash": { "input": 0.3, "cached_input": 0.03, "output": 2.5 },
45
- "gemini-2.5-flash-lite": { "input": 0.1, "cached_input": 0.01, "output": 0.4 },
46
- "gemini-2.0-flash": { "input": 0.1, "cached_input": 0.025, "output": 0.4 },
27
+ "o1": { "input": 15.0, "cache_read_input": 7.5, "output": 60.0 },
28
+ "o1-mini": { "input": 1.1, "cache_read_input": 0.55, "output": 4.4 },
29
+ "o3": { "input": 2.0, "cache_read_input": 0.5, "output": 8.0 },
30
+ "o3-mini": { "input": 1.1, "cache_read_input": 0.55, "output": 4.4 },
31
+ "o4-mini": { "input": 1.1, "cache_read_input": 0.275, "output": 4.4 },
32
+ "claude-sonnet-4-6": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_write_input": 3.75 },
33
+ "claude-opus-4-6": { "input": 5.0, "output": 25.0, "cache_read_input": 0.5, "cache_write_input": 6.25 },
34
+ "claude-opus-4-1": { "input": 15.0, "output": 75.0, "cache_read_input": 1.5, "cache_write_input": 18.75 },
35
+ "claude-opus-4": { "input": 15.0, "output": 75.0, "cache_read_input": 1.5, "cache_write_input": 18.75 },
36
+ "claude-sonnet-4-5": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_write_input": 3.75 },
37
+ "claude-sonnet-4": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_write_input": 3.75 },
38
+ "claude-haiku-4-5": { "input": 1.0, "output": 5.0, "cache_read_input": 0.1, "cache_write_input": 1.25 },
39
+ "claude-3-7-sonnet": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_write_input": 3.75 },
40
+ "claude-3-5-sonnet": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_write_input": 3.75 },
41
+ "claude-3-5-haiku": { "input": 0.8, "output": 4.0, "cache_read_input": 0.08, "cache_write_input": 1.0 },
42
+ "claude-3-opus": { "input": 15.0, "output": 75.0, "cache_read_input": 1.5, "cache_write_input": 18.75 },
43
+ "gemini-2.5-pro": { "input": 1.25, "cache_read_input": 0.125, "output": 10.0 },
44
+ "gemini-2.5-flash": { "input": 0.3, "cache_read_input": 0.03, "output": 2.5 },
45
+ "gemini-2.5-flash-lite": { "input": 0.1, "cache_read_input": 0.01, "output": 0.4 },
46
+ "gemini-2.0-flash": { "input": 0.1, "cache_read_input": 0.025, "output": 0.4 },
47
47
  "gemini-2.0-flash-lite": { "input": 0.075, "output": 0.3 },
48
48
  "gemini-1.5-pro": { "input": 1.25, "output": 5.0 },
49
49
  "gemini-1.5-flash": { "input": 0.075, "output": 0.3 }
@@ -8,32 +8,40 @@ module LlmCostTracker
8
8
  MUTEX = Monitor.new
9
9
 
10
10
  class << self
11
- def cost_for(model:, input_tokens:, output_tokens:, cached_input_tokens: 0,
12
- cache_read_input_tokens: 0, cache_creation_input_tokens: 0)
13
- prices = lookup(model)
11
+ def cost_for(provider:, model:, input_tokens:, output_tokens:, cache_read_input_tokens: 0,
12
+ cache_write_input_tokens: 0, pricing_mode: nil)
13
+ prices = lookup(provider: provider, model: model)
14
14
  return nil unless prices
15
15
 
16
- token_counts = normalized_token_counts(input_tokens, output_tokens, cached_input_tokens,
17
- cache_read_input_tokens, cache_creation_input_tokens)
18
- costs = calculate_costs(token_counts, prices)
16
+ usage = UsageBreakdown.build(
17
+ input_tokens: input_tokens,
18
+ output_tokens: output_tokens,
19
+ cache_read_input_tokens: cache_read_input_tokens,
20
+ cache_write_input_tokens: cache_write_input_tokens
21
+ )
22
+ costs = calculate_costs(usage, prices, pricing_mode: pricing_mode)
19
23
 
20
24
  Cost.new(
21
25
  input_cost: costs[:input].round(8),
22
- cached_input_cost: costs[:cached_input].round(8),
23
26
  cache_read_input_cost: costs[:cache_read_input].round(8),
24
- cache_creation_input_cost: costs[:cache_creation_input].round(8),
27
+ cache_write_input_cost: costs[:cache_write_input].round(8),
25
28
  output_cost: costs[:output].round(8),
26
29
  total_cost: costs.values.sum.round(8),
27
30
  currency: "USD"
28
31
  )
29
32
  end
30
33
 
31
- def lookup(model)
34
+ def lookup(provider:, model:)
32
35
  table = prices
36
+ provider_name = provider.to_s
33
37
  model_name = model.to_s
38
+ provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
34
39
  normalized_model = normalize_model_name(model_name)
35
40
 
36
- table[model_name] || table[normalized_model] || fuzzy_match(model_name, normalized_model, table)
41
+ table[provider_model] ||
42
+ table[model_name] ||
43
+ table[normalized_model] ||
44
+ fuzzy_match(provider_model, normalized_model, table)
37
45
  end
38
46
 
39
47
  def models
@@ -64,36 +72,40 @@ module LlmCostTracker
64
72
 
65
73
  private
66
74
 
67
- def normalized_token_counts(input_tokens, output_tokens, cached_input_tokens,
68
- cache_read_input_tokens, cache_creation_input_tokens)
69
- cached_input_tokens = cached_input_tokens.to_i
70
-
75
+ def calculate_costs(usage, prices, pricing_mode:)
71
76
  {
72
- input: [input_tokens.to_i - cached_input_tokens, 0].max,
73
- cached_input: cached_input_tokens,
74
- cache_read_input: cache_read_input_tokens.to_i,
75
- cache_creation_input: cache_creation_input_tokens.to_i,
76
- output: output_tokens.to_i
77
- }
78
- end
79
-
80
- def calculate_costs(token_counts, prices)
81
- {
82
- input: token_cost(token_counts[:input], prices[:input]),
83
- cached_input: token_cost(token_counts[:cached_input], prices[:cached_input] || prices[:input]),
77
+ input: token_cost(usage.input_tokens, price_for(prices, :input, pricing_mode)),
84
78
  cache_read_input: token_cost(
85
- token_counts[:cache_read_input],
86
- prices[:cache_read_input] || prices[:cached_input] || prices[:input]
79
+ usage.cache_read_input_tokens,
80
+ price_for(prices, :cache_read_input, pricing_mode) || price_for(prices, :input, pricing_mode)
87
81
  ),
88
- cache_creation_input: token_cost(
89
- token_counts[:cache_creation_input],
90
- prices[:cache_creation_input] || prices[:input]
82
+ cache_write_input: token_cost(
83
+ usage.cache_write_input_tokens,
84
+ price_for(prices, :cache_write_input, pricing_mode) || price_for(prices, :input, pricing_mode)
91
85
  ),
92
- output: token_cost(token_counts[:output], prices[:output])
86
+ output: token_cost(usage.output_tokens, price_for(prices, :output, pricing_mode))
93
87
  }
94
88
  end
95
89
 
90
+ def price_for(prices, key, pricing_mode)
91
+ mode = normalized_pricing_mode(pricing_mode)
92
+ return prices[key] unless mode
93
+
94
+ prices[:"#{mode}_#{key}"] || prices[key]
95
+ end
96
+
97
+ def normalized_pricing_mode(value)
98
+ return nil if value.nil?
99
+
100
+ mode = value.to_s.strip
101
+ return nil if mode.empty? || mode == "standard"
102
+
103
+ mode
104
+ end
105
+
96
106
  def token_cost(tokens, per_million_price)
107
+ return 0.0 if tokens.to_i.zero?
108
+
97
109
  (tokens.to_f / 1_000_000) * per_million_price
98
110
  end
99
111
 
@@ -3,10 +3,11 @@
3
3
  module LlmCostTracker
4
4
  class Railtie < Rails::Railtie
5
5
  generators do
6
- require_relative "generators/llm_cost_tracker/add_monthly_totals_generator"
6
+ require_relative "generators/llm_cost_tracker/add_period_totals_generator"
7
7
  require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
8
8
  require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
9
9
  require_relative "generators/llm_cost_tracker/add_streaming_generator"
10
+ require_relative "generators/llm_cost_tracker/add_usage_breakdown_generator"
10
11
  require_relative "generators/llm_cost_tracker/install_generator"
11
12
  require_relative "generators/llm_cost_tracker/prices_generator"
12
13
  require_relative "generators/llm_cost_tracker/upgrade_cost_precision_generator"
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Storage
5
+ class ActiveRecordRollups
6
+ PERIODS = {
7
+ monthly: "month",
8
+ daily: "day"
9
+ }.freeze
10
+
11
+ class << self
12
+ def reset!
13
+ remove_instance_variable(:@period_totals_enabled) if instance_variable_defined?(:@period_totals_enabled)
14
+ end
15
+
16
+ def increment!(event)
17
+ return unless event.cost&.total_cost
18
+ return unless period_totals_enabled?
19
+
20
+ model = period_total_model
21
+ model.upsert_all(
22
+ period_rows(event),
23
+ on_duplicate: total_upsert_sql(model),
24
+ record_timestamps: true,
25
+ unique_by: unique_by(model, %i[period period_start])
26
+ )
27
+ end
28
+
29
+ def monthly_total(time: Time.now.utc)
30
+ period_totals(%i[monthly], time: time).fetch(:monthly)
31
+ end
32
+
33
+ def daily_total(time: Time.now.utc)
34
+ period_totals(%i[daily], time: time).fetch(:daily)
35
+ end
36
+
37
+ def period_totals(periods, time: Time.now.utc)
38
+ periods = periods.map(&:to_sym).select { |period| PERIODS.key?(period) }
39
+ return {} if periods.empty?
40
+
41
+ if period_totals_enabled?
42
+ rollup_period_totals(periods, time)
43
+ else
44
+ periods.to_h { |period| [period, fallback_period_total(period, time)] }
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def period_rows(event)
51
+ PERIODS.map do |period, name|
52
+ {
53
+ period: name,
54
+ period_start: bucket_for(period, event.tracked_at),
55
+ total_cost: event.cost.total_cost
56
+ }
57
+ end
58
+ end
59
+
60
+ def rollup_period_totals(periods, time)
61
+ buckets = periods.to_h { |period| [period, bucket_for(period, time)] }
62
+ index = buckets.to_h { |period, bucket| [[PERIODS.fetch(period), bucket], period] }
63
+ totals = periods.to_h { |period| [period, 0.0] }
64
+
65
+ period_total_model
66
+ .where(period: periods.map { |period| PERIODS.fetch(period) }, period_start: buckets.values)
67
+ .pluck(:period, :period_start, :total_cost)
68
+ .each do |name, start, total|
69
+ period = index[[name, start.to_date]]
70
+ totals[period] = total.to_f if period
71
+ end
72
+
73
+ totals
74
+ end
75
+
76
+ def fallback_period_total(period, time)
77
+ LlmCostTracker::LlmApiCall
78
+ .where(tracked_at: range_start_for(period, time)..time)
79
+ .sum(:total_cost)
80
+ .to_f
81
+ end
82
+
83
+ def period_totals_enabled?
84
+ return @period_totals_enabled unless @period_totals_enabled.nil?
85
+
86
+ @period_totals_enabled =
87
+ LlmCostTracker::LlmApiCall.connection.data_source_exists?("llm_cost_tracker_period_totals")
88
+ end
89
+
90
+ def period_total_model
91
+ require_relative "../period_total" unless defined?(LlmCostTracker::PeriodTotal)
92
+
93
+ LlmCostTracker::PeriodTotal
94
+ end
95
+
96
+ def range_start_for(period, time)
97
+ utc_time = time.to_time.utc
98
+
99
+ case period
100
+ when :monthly then utc_time.beginning_of_month
101
+ when :daily then utc_time.beginning_of_day
102
+ end
103
+ end
104
+
105
+ def bucket_for(period, time)
106
+ utc_time = time.to_time.utc
107
+
108
+ case period
109
+ when :monthly then utc_time.beginning_of_month.to_date
110
+ when :daily then utc_time.to_date
111
+ end
112
+ end
113
+
114
+ def unique_by(model, column)
115
+ return unless model.connection.supports_insert_conflict_target?
116
+
117
+ column
118
+ end
119
+
120
+ def total_upsert_sql(model)
121
+ Arel.sql(case model.connection.adapter_name
122
+ when /mysql/i
123
+ mysql_upsert_sql(model)
124
+ else
125
+ "total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at"
126
+ end)
127
+ end
128
+
129
+ def mysql_upsert_sql(model)
130
+ connection = model.connection
131
+ if connection.respond_to?(:supports_insert_raw_alias_syntax?, true) &&
132
+ connection.send(:supports_insert_raw_alias_syntax?)
133
+ values_reference = connection.quote_table_name("#{model.table_name}_values")
134
+ "total_cost = total_cost + #{values_reference}.total_cost, updated_at = #{values_reference}.updated_at"
135
+ else
136
+ "total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -1,15 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "active_record_rollups"
4
+
3
5
  module LlmCostTracker
4
6
  module Storage
5
7
  class ActiveRecordStore
6
8
  class << self
7
9
  def reset!
8
- remove_instance_variable(:@monthly_totals_enabled) if instance_variable_defined?(:@monthly_totals_enabled)
10
+ ActiveRecordRollups.reset!
9
11
  end
10
12
 
11
13
  def save(event)
12
14
  tags = stringify_tags(event.tags || {})
15
+ model = LlmCostTracker::LlmApiCall
16
+ columns = model.columns_hash
13
17
 
14
18
  attributes = {
15
19
  provider: event.provider,
@@ -20,102 +24,55 @@ module LlmCostTracker
20
24
  input_cost: event.cost&.input_cost,
21
25
  output_cost: event.cost&.output_cost,
22
26
  total_cost: event.cost&.total_cost,
23
- tags: tags_for_storage(tags),
27
+ tags: tags_for_storage(tags, model),
24
28
  tracked_at: event.tracked_at
25
29
  }
26
- attributes[:latency_ms] = event.latency_ms if LlmCostTracker::LlmApiCall.latency_column?
27
- attributes[:stream] = event.stream if LlmCostTracker::LlmApiCall.stream_column?
28
- attributes[:usage_source] = event.usage_source if LlmCostTracker::LlmApiCall.usage_source_column?
29
- if LlmCostTracker::LlmApiCall.provider_response_id_column?
30
- attributes[:provider_response_id] = event.provider_response_id
30
+ optional_attributes(event).each do |name, value|
31
+ attributes[name] = value if columns.key?(name.to_s)
31
32
  end
32
-
33
- LlmCostTracker::LlmApiCall.transaction do
34
- call = LlmCostTracker::LlmApiCall.create!(attributes)
35
- increment_monthly_total(event)
33
+ attributes[:latency_ms] = event.latency_ms if columns.key?("latency_ms")
34
+ attributes[:stream] = event.stream if columns.key?("stream")
35
+ attributes[:usage_source] = event.usage_source if columns.key?("usage_source")
36
+ attributes[:provider_response_id] = event.provider_response_id if columns.key?("provider_response_id")
37
+
38
+ model.transaction do
39
+ call = model.create!(attributes)
40
+ ActiveRecordRollups.increment!(event)
36
41
  call
37
42
  end
38
43
  end
39
44
 
40
45
  def monthly_total(time: Time.now.utc)
41
- if monthly_totals_enabled?
42
- monthly_total_model.where(month_start: month_start_for(time)).pick(:total_cost).to_f
43
- else
44
- LlmCostTracker::LlmApiCall
45
- .where(tracked_at: time.beginning_of_month..time)
46
- .sum(:total_cost)
47
- .to_f
48
- end
49
- end
50
-
51
- private
52
-
53
- def increment_monthly_total(event)
54
- return unless monthly_totals_enabled?
55
- return unless event.cost&.total_cost
56
-
57
- monthly_total_model.upsert_all(
58
- [
59
- {
60
- month_start: month_start_for(event.tracked_at),
61
- total_cost: event.cost.total_cost
62
- }
63
- ],
64
- on_duplicate: monthly_total_upsert_sql,
65
- record_timestamps: true,
66
- unique_by: monthly_total_unique_by
67
- )
68
- end
69
-
70
- def monthly_totals_enabled?
71
- return @monthly_totals_enabled unless @monthly_totals_enabled.nil?
72
-
73
- @monthly_totals_enabled =
74
- LlmCostTracker::LlmApiCall.connection.data_source_exists?("llm_cost_tracker_monthly_totals")
46
+ ActiveRecordRollups.monthly_total(time: time)
75
47
  end
76
48
 
77
- def monthly_total_model
78
- require_relative "../monthly_total" unless defined?(LlmCostTracker::MonthlyTotal)
79
-
80
- LlmCostTracker::MonthlyTotal
49
+ def daily_total(time: Time.now.utc)
50
+ ActiveRecordRollups.daily_total(time: time)
81
51
  end
82
52
 
83
- def month_start_for(time)
84
- time.to_time.utc.beginning_of_month.to_date
53
+ def period_totals(periods, time: Time.now.utc)
54
+ ActiveRecordRollups.period_totals(periods, time: time)
85
55
  end
86
56
 
87
- def monthly_total_unique_by
88
- return unless monthly_total_model.connection.supports_insert_conflict_target?
89
-
90
- :month_start
91
- end
92
-
93
- def monthly_total_upsert_sql
94
- Arel.sql(case monthly_total_model.connection.adapter_name
95
- when /mysql/i
96
- mysql_upsert_sql
97
- else
98
- "total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at"
99
- end)
100
- end
101
-
102
- def mysql_upsert_sql
103
- connection = monthly_total_model.connection
104
- if connection.respond_to?(:supports_insert_raw_alias_syntax?, true) &&
105
- connection.send(:supports_insert_raw_alias_syntax?)
106
- values_reference = connection.quote_table_name("#{monthly_total_model.table_name}_values")
107
- "total_cost = total_cost + #{values_reference}.total_cost, updated_at = #{values_reference}.updated_at"
108
- else
109
- "total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
110
- end
111
- end
57
+ private
112
58
 
113
59
  def stringify_tags(tags)
114
60
  tags.transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
115
61
  end
116
62
 
117
- def tags_for_storage(tags)
118
- LlmCostTracker::LlmApiCall.tags_json_column? ? tags : tags.to_json
63
+ def tags_for_storage(tags, model)
64
+ model.tags_json_column? ? tags : tags.to_json
65
+ end
66
+
67
+ def optional_attributes(event)
68
+ {
69
+ cache_read_input_tokens: event.cache_read_input_tokens,
70
+ cache_write_input_tokens: event.cache_write_input_tokens,
71
+ hidden_output_tokens: event.hidden_output_tokens,
72
+ cache_read_input_cost: event.cost&.cache_read_input_cost,
73
+ cache_write_input_cost: event.cost&.cache_write_input_cost,
74
+ pricing_mode: event.pricing_mode
75
+ }
119
76
  end
120
77
 
121
78
  def stringify_tag_value(value)
@@ -8,11 +8,12 @@ module LlmCostTracker
8
8
  class StreamCollector
9
9
  attr_reader :provider
10
10
 
11
- def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, metadata: {})
11
+ def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, pricing_mode: nil, metadata: {})
12
12
  @provider = provider.to_s
13
13
  @model = model
14
14
  @latency_ms = latency_ms
15
15
  @provider_response_id = provider_response_id
16
+ @pricing_mode = pricing_mode
16
17
  @metadata = ValueHelpers.deep_dup(metadata || {})
17
18
  @events = []
18
19
  @explicit_usage = nil
@@ -74,6 +75,7 @@ module LlmCostTracker
74
75
  model: @model,
75
76
  latency_ms: @latency_ms,
76
77
  provider_response_id: @provider_response_id,
78
+ pricing_mode: @pricing_mode,
77
79
  metadata: ValueHelpers.deep_dup(@metadata)
78
80
  }
79
81
  end
@@ -88,6 +90,7 @@ module LlmCostTracker
88
90
  stream: true,
89
91
  usage_source: parsed.usage_source,
90
92
  provider_response_id: parsed.provider_response_id || snapshot[:provider_response_id],
93
+ pricing_mode: snapshot[:pricing_mode],
91
94
  metadata: error_metadata(errored).merge(snapshot[:metadata]).merge(parsed.metadata)
92
95
  )
93
96
  end
@@ -136,7 +139,6 @@ module LlmCostTracker
136
139
  model: snapshot[:model],
137
140
  input_tokens: input,
138
141
  output_tokens: output,
139
- total_tokens: input + output,
140
142
  stream: true,
141
143
  usage_source: :manual,
142
144
  **extras