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
@@ -7,62 +7,113 @@ module LlmCostTracker
7
7
  class << self
8
8
  def enforce!
9
9
  config = LlmCostTracker.configuration
10
- return unless config.monthly_budget
11
10
  return unless config.budget_exceeded_behavior == :block_requests
12
11
  return unless config.active_record?
13
12
 
14
- monthly_total = active_record_monthly_total
15
- return unless monthly_total >= config.monthly_budget
13
+ budgets = enforce_period_budgets(config)
14
+ return if budgets.empty?
16
15
 
17
- handle_exceeded(monthly_total: monthly_total)
16
+ totals = active_record_totals(budgets.keys, time: Time.now.utc)
17
+
18
+ budgets.each do |period, budget|
19
+ total = totals.fetch(period)
20
+
21
+ handle_exceeded(budget_type: period, total: total, budget: budget) if total >= budget
22
+ end
18
23
  end
19
24
 
20
25
  def check!(event)
21
26
  config = LlmCostTracker.configuration
22
- return unless config.monthly_budget
23
27
  return unless event.cost
24
28
 
25
- monthly_total = if config.active_record?
26
- active_record_monthly_total(time: event.tracked_at)
27
- else
28
- event.cost.total_cost
29
- end
30
- return unless monthly_total >= config.monthly_budget
29
+ check_per_call_budget(event, config)
30
+ budgets = check_period_budgets(config)
31
+ totals = totals_for_check(event, config, budgets)
31
32
 
32
- handle_exceeded(monthly_total: monthly_total, last_event: event)
33
+ budgets.each do |period, budget|
34
+ total = totals.fetch(period)
35
+
36
+ handle_exceeded(budget_type: period, total: total, budget: budget, last_event: event) if total >= budget
37
+ end
33
38
  end
34
39
 
35
40
  private
36
41
 
37
- def active_record_monthly_total(time: Time.now.utc)
42
+ def check_per_call_budget(event, config)
43
+ budget = config.per_call_budget
44
+ return unless budget
45
+
46
+ call_cost = event.cost.total_cost
47
+ return unless call_cost >= budget
48
+
49
+ handle_exceeded(budget_type: :per_call, total: call_cost, budget: budget, last_event: event)
50
+ end
51
+
52
+ def enforce_period_budgets(config)
53
+ {
54
+ monthly: config.monthly_budget,
55
+ daily: config.daily_budget
56
+ }.compact
57
+ end
58
+
59
+ def check_period_budgets(config)
60
+ {
61
+ daily: config.daily_budget,
62
+ monthly: config.monthly_budget
63
+ }.compact
64
+ end
65
+
66
+ def totals_for_check(event, config, budgets)
67
+ return {} if budgets.empty?
68
+ return active_record_totals(budgets.keys, time: event.tracked_at) if config.active_record?
69
+
70
+ budgets.to_h { |period, _budget| [period, event.cost.total_cost] }
71
+ end
72
+
73
+ def active_record_totals(periods, time:)
38
74
  require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
39
75
  require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
40
76
 
41
- LlmCostTracker::Storage::ActiveRecordStore.monthly_total(time: time)
77
+ LlmCostTracker::Storage::ActiveRecordStore.period_totals(periods, time: time)
42
78
  rescue LoadError => e
43
79
  raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
44
80
  end
45
81
 
46
- def handle_exceeded(monthly_total:, last_event: nil)
82
+ def handle_exceeded(budget_type:, total:, budget:, last_event: nil)
47
83
  config = LlmCostTracker.configuration
48
- payload = {
49
- monthly_total: monthly_total,
50
- budget: config.monthly_budget,
84
+ payload = budget_payload(
85
+ budget_type: budget_type,
86
+ total: total,
87
+ budget: budget,
51
88
  last_event: last_event
52
- }
89
+ )
53
90
 
54
- if notify_exceeded?(config, monthly_total: monthly_total, last_event: last_event)
91
+ if notify_exceeded?(config, budget_type: budget_type, total: total, budget: budget, last_event: last_event)
55
92
  config.on_budget_exceeded&.call(payload)
56
93
  end
57
94
  raise BudgetExceededError.new(**payload) if raise_on_exceeded?(config)
58
95
  end
59
96
 
60
- def notify_exceeded?(config, monthly_total:, last_event:)
97
+ def budget_payload(budget_type:, total:, budget:, last_event:)
98
+ payload = {
99
+ budget_type: budget_type,
100
+ total: total,
101
+ budget: budget,
102
+ last_event: last_event
103
+ }
104
+ payload[:monthly_total] = total if budget_type == :monthly
105
+ payload[:daily_total] = total if budget_type == :daily
106
+ payload[:call_cost] = total if budget_type == :per_call
107
+ payload
108
+ end
109
+
110
+ def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
61
111
  return false unless config.on_budget_exceeded
62
112
  return true unless config.budget_exceeded_behavior == :notify
63
113
  return true unless last_event&.cost
114
+ return true if budget_type == :per_call
64
115
 
65
- monthly_total - last_event.cost.total_cost < config.monthly_budget
116
+ total - last_event.cost.total_cost < budget
66
117
  end
67
118
 
68
119
  def raise_on_exceeded?(config)
@@ -19,6 +19,8 @@ module LlmCostTracker
19
19
  custom_storage
20
20
  on_budget_exceeded
21
21
  monthly_budget
22
+ daily_budget
23
+ per_call_budget
22
24
  log_level
23
25
  prices_file
24
26
  ].freeze
@@ -48,6 +50,8 @@ module LlmCostTracker
48
50
  @default_tags = {}
49
51
  @on_budget_exceeded = nil
50
52
  @monthly_budget = nil
53
+ @daily_budget = nil
54
+ @per_call_budget = nil
51
55
  self.budget_exceeded_behavior = :notify
52
56
  self.storage_error_behavior = :warn
53
57
  self.unknown_pricing_behavior = :warn
@@ -3,9 +3,8 @@
3
3
  module LlmCostTracker
4
4
  Cost = Data.define(
5
5
  :input_cost,
6
- :cached_input_cost,
7
6
  :cache_read_input_cost,
8
- :cache_creation_input_cost,
7
+ :cache_write_input_cost,
9
8
  :output_cost,
10
9
  :total_cost,
11
10
  :currency
@@ -6,14 +6,33 @@ module LlmCostTracker
6
6
  class InvalidFilterError < Error; end
7
7
 
8
8
  class BudgetExceededError < Error
9
- attr_reader :monthly_total, :budget, :last_event
9
+ attr_reader :monthly_total, :daily_total, :call_cost, :total, :budget, :budget_type, :last_event
10
10
 
11
- def initialize(monthly_total:, budget:, last_event: nil)
11
+ def initialize(budget:, last_event: nil, budget_type: nil, total: nil, monthly_total: nil, daily_total: nil,
12
+ call_cost: nil)
12
13
  @monthly_total = monthly_total
14
+ @daily_total = daily_total
15
+ @call_cost = call_cost
16
+ @total = total || monthly_total || daily_total || call_cost
13
17
  @budget = budget
18
+ @budget_type = budget_type || inferred_budget_type
14
19
  @last_event = last_event
15
20
 
16
- super("LLM monthly budget exceeded: $#{format('%.6f', monthly_total)} / $#{format('%.6f', budget)}")
21
+ super("LLM #{budget_label} budget exceeded: $#{format('%.6f', @total)} / $#{format('%.6f', budget)}")
22
+ end
23
+
24
+ private
25
+
26
+ def inferred_budget_type
27
+ return :monthly if monthly_total
28
+ return :daily if daily_total
29
+ return :per_call if call_cost
30
+
31
+ :unknown
32
+ end
33
+
34
+ def budget_label
35
+ budget_type.to_s.tr("_", "-")
17
36
  end
18
37
  end
19
38
 
@@ -7,6 +7,10 @@ module LlmCostTracker
7
7
  :input_tokens,
8
8
  :output_tokens,
9
9
  :total_tokens,
10
+ :cache_read_input_tokens,
11
+ :cache_write_input_tokens,
12
+ :hidden_output_tokens,
13
+ :pricing_mode,
10
14
  :cost,
11
15
  :tags,
12
16
  :latency_ms,
@@ -3,32 +3,31 @@
3
3
  module LlmCostTracker
4
4
  module EventMetadata
5
5
  INTERNAL_TAG_KEYS = %w[
6
- cache_creation_input_tokens
7
- cache_creation_tokens
8
6
  cache_read_input_tokens
9
- cache_read_tokens
10
- cached_input_tokens
7
+ cache_write_input_tokens
8
+ hidden_output_tokens
11
9
  input_tokens
12
10
  output_tokens
11
+ pricing_mode
13
12
  provider_response_id
14
- reasoning_tokens
15
13
  total_tokens
16
14
  ].freeze
17
15
 
18
16
  class << self
19
17
  def usage_data(input_tokens, output_tokens, metadata)
20
18
  metadata = metadata.to_h.symbolize_keys
21
- cache_read = first_integer(metadata, :cache_read_input_tokens, :cache_read_tokens)
22
- cache_creation = first_integer(metadata, :cache_creation_input_tokens, :cache_creation_tokens)
23
-
24
- {
25
- input_tokens: input_tokens.to_i,
26
- output_tokens: output_tokens.to_i,
27
- cached_input_tokens: metadata[:cached_input_tokens].to_i,
19
+ cache_read = first_integer(metadata, :cache_read_input_tokens)
20
+ cache_write = first_integer(metadata, :cache_write_input_tokens)
21
+ hidden_output = first_integer(metadata, :hidden_output_tokens)
22
+ breakdown = UsageBreakdown.build(
23
+ input_tokens: input_tokens,
24
+ output_tokens: output_tokens,
28
25
  cache_read_input_tokens: cache_read,
29
- cache_creation_input_tokens: cache_creation,
30
- total_tokens: input_tokens.to_i + output_tokens.to_i + cache_read + cache_creation
31
- }
26
+ cache_write_input_tokens: cache_write,
27
+ hidden_output_tokens: hidden_output
28
+ )
29
+
30
+ breakdown.to_h.merge(pricing_mode: normalized_pricing_mode(metadata[:pricing_mode])).compact
32
31
  end
33
32
 
34
33
  def tags(metadata)
@@ -41,6 +40,13 @@ module LlmCostTracker
41
40
  keys.each { |key| return metadata[key].to_i unless metadata[key].nil? }
42
41
  0
43
42
  end
43
+
44
+ def normalized_pricing_mode(value)
45
+ return nil if value.nil?
46
+
47
+ mode = value.to_s.strip
48
+ mode.empty? || mode == "standard" ? nil : mode
49
+ end
44
50
  end
45
51
  end
46
52
  end
@@ -5,17 +5,17 @@ require "rails/generators/active_record"
5
5
 
6
6
  module LlmCostTracker
7
7
  module Generators
8
- class AddMonthlyTotalsGenerator < Rails::Generators::Base
8
+ class AddPeriodTotalsGenerator < Rails::Generators::Base
9
9
  include ActiveRecord::Generators::Migration
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Creates a migration to add llm_cost_tracker_monthly_totals"
13
+ desc "Creates a migration to add llm_cost_tracker_period_totals"
14
14
 
15
15
  def create_migration_file
16
16
  migration_template(
17
- "add_monthly_totals_to_llm_cost_tracker.rb.erb",
18
- "db/migrate/add_monthly_totals_to_llm_cost_tracker.rb"
17
+ "add_period_totals_to_llm_cost_tracker.rb.erb",
18
+ "db/migrate/add_period_totals_to_llm_cost_tracker.rb"
19
19
  )
20
20
  end
21
21
 
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class AddUsageBreakdownGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates a migration to add usage and cost breakdown columns to llm_api_calls"
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "add_usage_breakdown_to_llm_api_calls.rb.erb",
18
+ "db/migrate/add_usage_breakdown_to_llm_api_calls.rb"
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def migration_version
25
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,96 @@
1
+ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ create_table :llm_cost_tracker_period_totals do |t|
4
+ t.string :period, null: false
5
+ t.date :period_start, null: false
6
+ t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
7
+
8
+ t.timestamps
9
+ end unless table_exists?(:llm_cost_tracker_period_totals)
10
+
11
+ backfill_period_totals
12
+
13
+ add_index :llm_cost_tracker_period_totals, [:period, :period_start],
14
+ unique: true unless index_exists?(:llm_cost_tracker_period_totals, [:period, :period_start])
15
+ end
16
+
17
+ def down
18
+ remove_index :llm_cost_tracker_period_totals, [:period, :period_start] if index_exists?(:llm_cost_tracker_period_totals, [:period, :period_start])
19
+ drop_table :llm_cost_tracker_period_totals if table_exists?(:llm_cost_tracker_period_totals)
20
+ end
21
+
22
+ private
23
+
24
+ def backfill_period_totals
25
+ backfill_legacy_monthly_totals if table_exists?(:llm_cost_tracker_monthly_totals)
26
+ return unless table_exists?(:llm_api_calls)
27
+
28
+ backfill_period_total("day", day_bucket_sql)
29
+ backfill_period_total("month", month_bucket_sql)
30
+ end
31
+
32
+ def backfill_legacy_monthly_totals
33
+ execute <<~SQL
34
+ INSERT INTO llm_cost_tracker_period_totals (period, period_start, total_cost, created_at, updated_at)
35
+ SELECT #{connection.quote("month")} AS period,
36
+ month AS period_start,
37
+ total_cost,
38
+ CURRENT_TIMESTAMP,
39
+ CURRENT_TIMESTAMP
40
+ FROM llm_cost_tracker_monthly_totals legacy
41
+ WHERE NOT EXISTS (
42
+ SELECT 1
43
+ FROM llm_cost_tracker_period_totals existing
44
+ WHERE existing.period = #{connection.quote("month")}
45
+ AND existing.period_start = legacy.month
46
+ )
47
+ SQL
48
+ end
49
+
50
+ def backfill_period_total(period, bucket_sql)
51
+ execute <<~SQL
52
+ INSERT INTO llm_cost_tracker_period_totals (period, period_start, total_cost, created_at, updated_at)
53
+ SELECT aggregated.period,
54
+ aggregated.period_start,
55
+ aggregated.total_cost,
56
+ CURRENT_TIMESTAMP,
57
+ CURRENT_TIMESTAMP
58
+ FROM (
59
+ SELECT #{connection.quote(period)} AS period,
60
+ #{bucket_sql} AS period_start,
61
+ SUM(total_cost) AS total_cost
62
+ FROM llm_api_calls
63
+ WHERE total_cost IS NOT NULL
64
+ GROUP BY #{bucket_sql}
65
+ ) aggregated
66
+ WHERE NOT EXISTS (
67
+ SELECT 1
68
+ FROM llm_cost_tracker_period_totals existing
69
+ WHERE existing.period = aggregated.period
70
+ AND existing.period_start = aggregated.period_start
71
+ )
72
+ SQL
73
+ end
74
+
75
+ def day_bucket_sql
76
+ case connection.adapter_name
77
+ when /postgres/i
78
+ "DATE_TRUNC('day', tracked_at)::date"
79
+ when /mysql/i
80
+ "DATE(tracked_at)"
81
+ else
82
+ "date(tracked_at)"
83
+ end
84
+ end
85
+
86
+ def month_bucket_sql
87
+ case connection.adapter_name
88
+ when /postgres/i
89
+ "DATE_TRUNC('month', tracked_at)::date"
90
+ when /mysql/i
91
+ "DATE_FORMAT(tracked_at, '%Y-%m-01')"
92
+ else
93
+ "strftime('%Y-%m-01', tracked_at)"
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,29 @@
1
+ class AddUsageBreakdownToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ unless column_exists?(:llm_api_calls, :cache_read_input_tokens)
4
+ add_column :llm_api_calls, :cache_read_input_tokens, :integer, null: false, default: 0
5
+ end
6
+ unless column_exists?(:llm_api_calls, :cache_write_input_tokens)
7
+ add_column :llm_api_calls, :cache_write_input_tokens, :integer, null: false, default: 0
8
+ end
9
+ unless column_exists?(:llm_api_calls, :hidden_output_tokens)
10
+ add_column :llm_api_calls, :hidden_output_tokens, :integer, null: false, default: 0
11
+ end
12
+ unless column_exists?(:llm_api_calls, :cache_read_input_cost)
13
+ add_column :llm_api_calls, :cache_read_input_cost, :decimal, precision: 20, scale: 8
14
+ end
15
+ unless column_exists?(:llm_api_calls, :cache_write_input_cost)
16
+ add_column :llm_api_calls, :cache_write_input_cost, :decimal, precision: 20, scale: 8
17
+ end
18
+ add_column :llm_api_calls, :pricing_mode, :string unless column_exists?(:llm_api_calls, :pricing_mode)
19
+ end
20
+
21
+ def down
22
+ remove_column :llm_api_calls, :pricing_mode if column_exists?(:llm_api_calls, :pricing_mode)
23
+ remove_column :llm_api_calls, :cache_write_input_cost if column_exists?(:llm_api_calls, :cache_write_input_cost)
24
+ remove_column :llm_api_calls, :cache_read_input_cost if column_exists?(:llm_api_calls, :cache_read_input_cost)
25
+ remove_column :llm_api_calls, :hidden_output_tokens if column_exists?(:llm_api_calls, :hidden_output_tokens)
26
+ remove_column :llm_api_calls, :cache_write_input_tokens if column_exists?(:llm_api_calls, :cache_write_input_tokens)
27
+ remove_column :llm_api_calls, :cache_read_input_tokens if column_exists?(:llm_api_calls, :cache_read_input_tokens)
28
+ end
29
+ end
@@ -6,13 +6,19 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
6
6
  t.integer :input_tokens, null: false, default: 0
7
7
  t.integer :output_tokens, null: false, default: 0
8
8
  t.integer :total_tokens, null: false, default: 0
9
+ t.integer :cache_read_input_tokens, null: false, default: 0
10
+ t.integer :cache_write_input_tokens, null: false, default: 0
11
+ t.integer :hidden_output_tokens, null: false, default: 0
9
12
  t.decimal :input_cost, precision: 20, scale: 8
13
+ t.decimal :cache_read_input_cost, precision: 20, scale: 8
14
+ t.decimal :cache_write_input_cost, precision: 20, scale: 8
10
15
  t.decimal :output_cost, precision: 20, scale: 8
11
16
  t.decimal :total_cost, precision: 20, scale: 8
12
17
  t.integer :latency_ms
13
18
  t.boolean :stream, null: false, default: false
14
19
  t.string :usage_source
15
20
  t.string :provider_response_id
21
+ t.string :pricing_mode
16
22
  if postgresql?
17
23
  t.jsonb :tags, null: false, default: {}
18
24
  else
@@ -23,22 +29,22 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
23
29
  t.timestamps
24
30
  end
25
31
 
26
- create_table :llm_cost_tracker_monthly_totals do |t|
27
- t.date :month_start, null: false
32
+ create_table :llm_cost_tracker_period_totals do |t|
33
+ t.string :period, null: false
34
+ t.date :period_start, null: false
28
35
  t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
29
36
 
30
37
  t.timestamps
31
38
  end
32
39
 
33
- add_index :llm_api_calls, :provider
34
- add_index :llm_api_calls, :model
35
40
  add_index :llm_api_calls, :tracked_at
36
41
  add_index :llm_api_calls, [:provider, :tracked_at]
42
+ add_index :llm_api_calls, [:model, :tracked_at]
37
43
  add_index :llm_api_calls, :stream
38
44
  add_index :llm_api_calls, :usage_source
39
45
  add_index :llm_api_calls, :provider_response_id
40
46
  add_index :llm_api_calls, :tags, using: :gin if postgresql?
41
- add_index :llm_cost_tracker_monthly_totals, :month_start, unique: true
47
+ add_index :llm_cost_tracker_period_totals, [:period, :period_start], unique: true
42
48
  end
43
49
 
44
50
  private
@@ -12,6 +12,8 @@ LlmCostTracker.configure do |config|
12
12
 
13
13
  # Monthly budget in USD. Set to nil to disable budget alerts.
14
14
  # config.monthly_budget = 100.00
15
+ # config.daily_budget = 10.00
16
+ # config.per_call_budget = 1.00
15
17
  # config.budget_exceeded_behavior = :notify # :notify, :raise, or :block_requests
16
18
 
17
19
  # What to do when storage fails.
@@ -23,7 +25,7 @@ LlmCostTracker.configure do |config|
23
25
  # Callback when monthly budget is exceeded.
24
26
  # config.on_budget_exceeded = ->(data) {
25
27
  # Rails.logger.warn "[LlmCostTracker] Budget exceeded! " \
26
- # "Monthly total: $#{data[:monthly_total]}, Budget: $#{data[:budget]}"
28
+ # "#{data[:budget_type]} total: $#{data[:total]}, Budget: $#{data[:budget]}"
27
29
  # # Or send a Slack notification, email, etc.
28
30
  # }
29
31
 
@@ -8,9 +8,9 @@
8
8
  # Supported price keys:
9
9
  # - input
10
10
  # - output
11
- # - cached_input
12
11
  # - cache_read_input
13
- # - cache_creation_input
12
+ # - cache_write_input
13
+ # - mode_input / mode_output / mode_cache_read_input / mode_cache_write_input
14
14
  #
15
15
  # Optional metadata keys, ignored by cost calculation:
16
16
  # - _source
@@ -24,10 +24,18 @@
24
24
  # models:
25
25
  # "ft:gpt-4o-mini:my-org":
26
26
  # input: 0.30
27
- # cached_input: 0.15
27
+ # cache_read_input: 0.15
28
28
  # output: 1.20
29
29
  # _notes: "Internal fine-tune rate"
30
30
  #
31
+ # Example: alternate pricing mode
32
+ # models:
33
+ # "batchable-model":
34
+ # input: 1.00
35
+ # output: 2.00
36
+ # batch_input: 0.50
37
+ # batch_output: 1.00
38
+ #
31
39
  # Example: negotiated provider discount
32
40
  # models:
33
41
  # "gpt-4o":
@@ -7,10 +7,9 @@ module LlmCostTracker
7
7
  :input_tokens,
8
8
  :output_tokens,
9
9
  :total_tokens,
10
- :cached_input_tokens,
11
10
  :cache_read_input_tokens,
12
- :cache_creation_input_tokens,
13
- :reasoning_tokens,
11
+ :cache_write_input_tokens,
12
+ :hidden_output_tokens,
14
13
  :stream,
15
14
  :usage_source,
16
15
  :provider_response_id
@@ -34,11 +33,10 @@ module LlmCostTracker
34
33
  model: attributes.fetch(:model),
35
34
  input_tokens: attributes.fetch(:input_tokens).to_i,
36
35
  output_tokens: attributes.fetch(:output_tokens).to_i,
37
- total_tokens: attributes.fetch(:total_tokens, 0).to_i,
38
- cached_input_tokens: attributes[:cached_input_tokens],
36
+ total_tokens: attributes.fetch(:total_tokens, usage_breakdown(attributes).total_tokens).to_i,
39
37
  cache_read_input_tokens: attributes[:cache_read_input_tokens],
40
- cache_creation_input_tokens: attributes[:cache_creation_input_tokens],
41
- reasoning_tokens: attributes[:reasoning_tokens],
38
+ cache_write_input_tokens: attributes[:cache_write_input_tokens],
39
+ hidden_output_tokens: attributes[:hidden_output_tokens],
42
40
  stream: attributes[:stream] || false,
43
41
  usage_source: attributes[:usage_source],
44
42
  provider_response_id: attributes[:provider_response_id]
@@ -52,5 +50,16 @@ module LlmCostTracker
52
50
  def to_h
53
51
  super.compact
54
52
  end
53
+
54
+ def self.usage_breakdown(attributes)
55
+ UsageBreakdown.build(
56
+ input_tokens: attributes.fetch(:input_tokens),
57
+ output_tokens: attributes.fetch(:output_tokens),
58
+ cache_read_input_tokens: attributes[:cache_read_input_tokens],
59
+ cache_write_input_tokens: attributes[:cache_write_input_tokens],
60
+ hidden_output_tokens: attributes[:hidden_output_tokens]
61
+ )
62
+ end
63
+ private_class_method :usage_breakdown
55
64
  end
56
65
  end