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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +46 -25
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +96 -23
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +81 -0
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +65 -0
- data/lib/llm_cost_tracker/budget.rb +73 -22
- data/lib/llm_cost_tracker/configuration.rb +4 -0
- data/lib/llm_cost_tracker/cost.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +22 -3
- data/lib/llm_cost_tracker/event.rb +4 -0
- data/lib/llm_cost_tracker/event_metadata.rb +21 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_monthly_totals_generator.rb → add_period_totals_generator.rb} +4 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +96 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +11 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +11 -3
- data/lib/llm_cost_tracker/parsed_usage.rb +16 -7
- data/lib/llm_cost_tracker/parsers/anthropic.rb +24 -55
- data/lib/llm_cost_tracker/parsers/base.rb +80 -0
- data/lib/llm_cost_tracker/parsers/gemini.rb +17 -37
- data/lib/llm_cost_tracker/parsers/openai.rb +1 -6
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +6 -15
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +25 -34
- data/lib/llm_cost_tracker/parsers/registry.rb +17 -2
- data/lib/llm_cost_tracker/period_total.rb +9 -0
- data/lib/llm_cost_tracker/price_registry.rb +14 -4
- data/lib/llm_cost_tracker/price_sync/merger.rb +1 -1
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +3 -5
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +2 -3
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +2 -3
- data/lib/llm_cost_tracker/prices.json +30 -30
- data/lib/llm_cost_tracker/pricing.rb +44 -32
- data/lib/llm_cost_tracker/railtie.rb +2 -1
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +142 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +35 -78
- data/lib/llm_cost_tracker/stream_collector.rb +4 -2
- data/lib/llm_cost_tracker/tags_column.rb +71 -14
- data/lib/llm_cost_tracker/tracker.rb +54 -32
- data/lib/llm_cost_tracker/usage_breakdown.rb +30 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +10 -3
- metadata +9 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_monthly_totals_to_llm_cost_tracker.rb.erb +0 -48
- 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
|
-
|
|
15
|
-
return
|
|
13
|
+
budgets = enforce_period_budgets(config)
|
|
14
|
+
return if budgets.empty?
|
|
16
15
|
|
|
17
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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(
|
|
82
|
+
def handle_exceeded(budget_type:, total:, budget:, last_event: nil)
|
|
47
83
|
config = LlmCostTracker.configuration
|
|
48
|
-
payload =
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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(
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
input_tokens: input_tokens
|
|
26
|
-
output_tokens: output_tokens
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|
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
|
-
"
|
|
18
|
-
"db/migrate/
|
|
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 :
|
|
27
|
-
t.
|
|
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 :
|
|
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
|
-
# "
|
|
28
|
+
# "#{data[:budget_type]} total: $#{data[:total]}, Budget: $#{data[:budget]}"
|
|
27
29
|
# # Or send a Slack notification, email, etc.
|
|
28
30
|
# }
|
|
29
31
|
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
# Supported price keys:
|
|
9
9
|
# - input
|
|
10
10
|
# - output
|
|
11
|
-
# - cached_input
|
|
12
11
|
# - cache_read_input
|
|
13
|
-
# -
|
|
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
|
-
#
|
|
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
|
-
:
|
|
13
|
-
:
|
|
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,
|
|
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
|
-
|
|
41
|
-
|
|
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
|