llm_cost_tracker 0.3.2 → 0.3.3
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 +20 -0
- data/README.md +3 -0
- data/lib/llm_cost_tracker/budget.rb +14 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_monthly_totals_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_monthly_totals_to_llm_cost_tracker.rb.erb +48 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +8 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +1 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +27 -9
- data/lib/llm_cost_tracker/monthly_total.rb +9 -0
- data/lib/llm_cost_tracker/parsers/base.rb +2 -1
- data/lib/llm_cost_tracker/railtie.rb +1 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +77 -5
- data/lib/llm_cost_tracker/stream_collector.rb +1 -1
- data/lib/llm_cost_tracker/tracker.rb +4 -0
- data/lib/llm_cost_tracker/unknown_pricing.rb +14 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +2 -0
- metadata +6 -4
- data/llm_cost_tracker.gemspec +0 -50
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b966913d302d5c5c3466615d1fa3983855c241f6cd9e3e26558c0fcc5fc4e7d5
|
|
4
|
+
data.tar.gz: 52804e702d5f01e5a4d247e8b50e601dede2b328bd7075c68ffd5f472b3b0d58
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 609ba1a18be86dce0b567b2ea33b3f3123da88683f0c65d9aef780f2e4854d1dde6686adfa505fc154d13da6dd6cb2b31d9f38c303de5fb22f6fda65c7f44aa7
|
|
7
|
+
data.tar.gz: de372e0940b4cfc400dacfc6dbf9e00f256c6944209da8cceaadd20a318b8c7aa8982d5190e21d38640ad20d04cc86400a4e872ec640189796e045acf1f7dfad
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.3] - 2026-04-24
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Monthly rollup totals for ActiveRecord budget checks, plus `llm_cost_tracker:add_monthly_totals` for upgrading existing installs.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- ActiveRecord monthly totals now update through a single atomic upsert.
|
|
16
|
+
- Faraday stream capture overflow now records `usage_source: "unknown"` instead of dropping the tracked event.
|
|
17
|
+
- Budget `:notify` callbacks now fire only on the first event that crosses the monthly limit.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Treat `config.enabled = false` as a global kill switch for direct `track` and `track_stream` calls too.
|
|
22
|
+
- Deduplicate unknown-pricing warnings per model.
|
|
23
|
+
- Detect streaming requests from parsed JSON instead of raw body substring matching.
|
|
24
|
+
- Cap automatic SSE capture to avoid unbounded memory growth on large streaming responses.
|
|
25
|
+
- Warn that the generated PostgreSQL `tags -> jsonb` upgrade migration rewrites large tables and should run in a maintenance window.
|
|
26
|
+
|
|
7
27
|
## [0.3.2] - 2026-04-22
|
|
8
28
|
|
|
9
29
|
### Added
|
data/README.md
CHANGED
|
@@ -343,12 +343,15 @@ On other adapters tags fall back to JSON in a text column. `by_tag` uses JSONB c
|
|
|
343
343
|
Upgrade an existing install:
|
|
344
344
|
|
|
345
345
|
```bash
|
|
346
|
+
bin/rails generate llm_cost_tracker:add_monthly_totals # shared monthly budget rollups
|
|
346
347
|
bin/rails generate llm_cost_tracker:upgrade_tags_to_jsonb # PG: text → jsonb + GIN
|
|
347
348
|
bin/rails generate llm_cost_tracker:upgrade_cost_precision # widen cost columns
|
|
348
349
|
bin/rails generate llm_cost_tracker:add_latency_ms
|
|
349
350
|
bin/rails db:migrate
|
|
350
351
|
```
|
|
351
352
|
|
|
353
|
+
On PostgreSQL, the generated `upgrade_tags_to_jsonb` migration rewrites `llm_api_calls`. Run it during a maintenance window on large tables, or replace it with a two-phase backfill for zero-downtime deploys.
|
|
354
|
+
|
|
352
355
|
## Mounting the dashboard
|
|
353
356
|
|
|
354
357
|
Optional Rails Engine. Plain ERB, no JavaScript framework, no asset pipeline required. Requires Rails 7.1+; the core middleware works without Rails.
|
|
@@ -23,7 +23,7 @@ module LlmCostTracker
|
|
|
23
23
|
return unless event.cost
|
|
24
24
|
|
|
25
25
|
monthly_total = if config.active_record?
|
|
26
|
-
active_record_monthly_total
|
|
26
|
+
active_record_monthly_total(time: event.tracked_at)
|
|
27
27
|
else
|
|
28
28
|
event.cost.total_cost
|
|
29
29
|
end
|
|
@@ -34,11 +34,11 @@ module LlmCostTracker
|
|
|
34
34
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
|
-
def active_record_monthly_total
|
|
37
|
+
def active_record_monthly_total(time: Time.now.utc)
|
|
38
38
|
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
39
39
|
require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
40
40
|
|
|
41
|
-
LlmCostTracker::Storage::ActiveRecordStore.monthly_total
|
|
41
|
+
LlmCostTracker::Storage::ActiveRecordStore.monthly_total(time: time)
|
|
42
42
|
rescue LoadError => e
|
|
43
43
|
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
44
44
|
end
|
|
@@ -51,10 +51,20 @@ module LlmCostTracker
|
|
|
51
51
|
last_event: last_event
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
config
|
|
54
|
+
if notify_exceeded?(config, monthly_total: monthly_total, last_event: last_event)
|
|
55
|
+
config.on_budget_exceeded&.call(payload)
|
|
56
|
+
end
|
|
55
57
|
raise BudgetExceededError.new(**payload) if raise_on_exceeded?(config)
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
def notify_exceeded?(config, monthly_total:, last_event:)
|
|
61
|
+
return false unless config.on_budget_exceeded
|
|
62
|
+
return true unless config.budget_exceeded_behavior == :notify
|
|
63
|
+
return true unless last_event&.cost
|
|
64
|
+
|
|
65
|
+
monthly_total - last_event.cost.total_cost < config.monthly_budget
|
|
66
|
+
end
|
|
67
|
+
|
|
58
68
|
def raise_on_exceeded?(config)
|
|
59
69
|
%i[raise block_requests].include?(config.budget_exceeded_behavior)
|
|
60
70
|
end
|
|
@@ -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 AddMonthlyTotalsGenerator < 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 llm_cost_tracker_monthly_totals"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"add_monthly_totals_to_llm_cost_tracker.rb.erb",
|
|
18
|
+
"db/migrate/add_monthly_totals_to_llm_cost_tracker.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,48 @@
|
|
|
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
|
|
@@ -23,6 +23,13 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
23
23
|
t.timestamps
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
create_table :llm_cost_tracker_monthly_totals do |t|
|
|
27
|
+
t.date :month_start, null: false
|
|
28
|
+
t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
|
|
29
|
+
|
|
30
|
+
t.timestamps
|
|
31
|
+
end
|
|
32
|
+
|
|
26
33
|
add_index :llm_api_calls, :provider
|
|
27
34
|
add_index :llm_api_calls, :model
|
|
28
35
|
add_index :llm_api_calls, :tracked_at
|
|
@@ -31,6 +38,7 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
31
38
|
add_index :llm_api_calls, :usage_source
|
|
32
39
|
add_index :llm_api_calls, :provider_response_id
|
|
33
40
|
add_index :llm_api_calls, :tags, using: :gin if postgresql?
|
|
41
|
+
add_index :llm_cost_tracker_monthly_totals, :month_start, unique: true
|
|
34
42
|
end
|
|
35
43
|
|
|
36
44
|
private
|
|
@@ -8,6 +8,7 @@ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_versio
|
|
|
8
8
|
return if tags_jsonb?
|
|
9
9
|
|
|
10
10
|
remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
|
|
11
|
+
say "Upgrading llm_api_calls.tags to jsonb rewrites the table on PostgreSQL. Run this migration during a maintenance window on large datasets."
|
|
11
12
|
|
|
12
13
|
change_column(
|
|
13
14
|
:llm_api_calls,
|
|
@@ -8,6 +8,8 @@ require_relative "../logging"
|
|
|
8
8
|
module LlmCostTracker
|
|
9
9
|
module Middleware
|
|
10
10
|
class Faraday < ::Faraday::Middleware
|
|
11
|
+
STREAM_CAPTURE_LIMIT_BYTES = 1_048_576
|
|
12
|
+
|
|
11
13
|
def initialize(app, **options)
|
|
12
14
|
super(app)
|
|
13
15
|
@tags = options.fetch(:tags, {})
|
|
@@ -85,15 +87,12 @@ module LlmCostTracker
|
|
|
85
87
|
end
|
|
86
88
|
|
|
87
89
|
def parse_stream(parser, request_url, request_body, response_env, stream_buffer)
|
|
88
|
-
body = stream_buffer&.string
|
|
90
|
+
body = stream_buffer&.dig(:buffer)&.string
|
|
89
91
|
body = read_body(response_env.body) if body.nil? || body.empty?
|
|
90
92
|
|
|
91
93
|
if body.nil? || body.empty?
|
|
92
|
-
Logging.warn(
|
|
93
|
-
|
|
94
|
-
"fall back to LlmCostTracker.track_stream for manual capture."
|
|
95
|
-
)
|
|
96
|
-
return nil
|
|
94
|
+
Logging.warn(capture_warning(request_url, stream_buffer))
|
|
95
|
+
return parser.parse_stream(request_url, request_body, response_env.status, [])
|
|
97
96
|
end
|
|
98
97
|
|
|
99
98
|
events = Parsers::SSE.parse(body)
|
|
@@ -106,12 +105,21 @@ module LlmCostTracker
|
|
|
106
105
|
original = request_env.request.on_data
|
|
107
106
|
return nil unless original
|
|
108
107
|
|
|
109
|
-
|
|
108
|
+
state = { buffer: StringIO.new, bytes: 0, overflowed: false }
|
|
110
109
|
request_env.request.on_data = proc do |chunk, size, env|
|
|
111
|
-
|
|
110
|
+
chunk = chunk.to_s
|
|
111
|
+
unless state[:overflowed]
|
|
112
|
+
if state[:bytes] + chunk.bytesize <= STREAM_CAPTURE_LIMIT_BYTES
|
|
113
|
+
state[:buffer] << chunk
|
|
114
|
+
state[:bytes] += chunk.bytesize
|
|
115
|
+
else
|
|
116
|
+
state[:overflowed] = true
|
|
117
|
+
state[:buffer] = nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
112
120
|
original.call(chunk, size, env)
|
|
113
121
|
end
|
|
114
|
-
|
|
122
|
+
state
|
|
115
123
|
rescue StandardError => e
|
|
116
124
|
Logging.warn("Unable to install streaming tap: #{e.class}: #{e.message}")
|
|
117
125
|
nil
|
|
@@ -145,6 +153,16 @@ module LlmCostTracker
|
|
|
145
153
|
def elapsed_ms(started_at)
|
|
146
154
|
((monotonic_time - started_at) * 1000).round
|
|
147
155
|
end
|
|
156
|
+
|
|
157
|
+
def capture_warning(request_url, stream_buffer)
|
|
158
|
+
unless stream_buffer&.dig(:overflowed)
|
|
159
|
+
return "Unable to capture streaming response for #{request_url}; " \
|
|
160
|
+
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
"Streaming response for #{request_url} exceeded #{STREAM_CAPTURE_LIMIT_BYTES} bytes; " \
|
|
164
|
+
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
165
|
+
end
|
|
148
166
|
end
|
|
149
167
|
end
|
|
150
168
|
end
|
|
@@ -23,7 +23,8 @@ module LlmCostTracker
|
|
|
23
23
|
body = request_body.to_s
|
|
24
24
|
return false if body.empty?
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
request = safe_json_parse(body)
|
|
27
|
+
request.is_a?(Hash) && request["stream"] == true
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def parse_stream(_request_url, _request_body, _response_status, _events)
|
|
@@ -3,6 +3,7 @@
|
|
|
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
7
|
require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
|
|
7
8
|
require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
|
|
8
9
|
require_relative "generators/llm_cost_tracker/add_streaming_generator"
|
|
@@ -4,6 +4,10 @@ module LlmCostTracker
|
|
|
4
4
|
module Storage
|
|
5
5
|
class ActiveRecordStore
|
|
6
6
|
class << self
|
|
7
|
+
def reset!
|
|
8
|
+
remove_instance_variable(:@monthly_totals_enabled) if instance_variable_defined?(:@monthly_totals_enabled)
|
|
9
|
+
end
|
|
10
|
+
|
|
7
11
|
def save(event)
|
|
8
12
|
tags = stringify_tags(event.tags || {})
|
|
9
13
|
|
|
@@ -26,18 +30,86 @@ module LlmCostTracker
|
|
|
26
30
|
attributes[:provider_response_id] = event.provider_response_id
|
|
27
31
|
end
|
|
28
32
|
|
|
29
|
-
LlmCostTracker::LlmApiCall.
|
|
33
|
+
LlmCostTracker::LlmApiCall.transaction do
|
|
34
|
+
call = LlmCostTracker::LlmApiCall.create!(attributes)
|
|
35
|
+
increment_monthly_total(event)
|
|
36
|
+
call
|
|
37
|
+
end
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
def monthly_total(time: Time.now.utc)
|
|
33
|
-
|
|
34
|
-
.where(
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
private
|
|
40
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")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def monthly_total_model
|
|
78
|
+
require_relative "../monthly_total" unless defined?(LlmCostTracker::MonthlyTotal)
|
|
79
|
+
|
|
80
|
+
LlmCostTracker::MonthlyTotal
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def month_start_for(time)
|
|
84
|
+
time.to_time.utc.beginning_of_month.to_date
|
|
85
|
+
end
|
|
86
|
+
|
|
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
|
|
112
|
+
|
|
41
113
|
def stringify_tags(tags)
|
|
42
114
|
tags.transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
|
|
43
115
|
end
|
|
@@ -10,11 +10,15 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
12
|
def enforce_budget!
|
|
13
|
+
return unless LlmCostTracker.configuration.enabled
|
|
14
|
+
|
|
13
15
|
Budget.enforce!
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def record(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false,
|
|
17
19
|
usage_source: nil, provider_response_id: nil, metadata: {})
|
|
20
|
+
return unless LlmCostTracker.configuration.enabled
|
|
21
|
+
|
|
18
22
|
usage = EventMetadata.usage_data(input_tokens, output_tokens, metadata)
|
|
19
23
|
|
|
20
24
|
cost_data = Pricing.cost_for(
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
3
5
|
require_relative "logging"
|
|
4
6
|
|
|
5
7
|
module LlmCostTracker
|
|
6
8
|
class UnknownPricing
|
|
9
|
+
MUTEX = Monitor.new
|
|
10
|
+
|
|
7
11
|
class << self
|
|
8
12
|
def handle!(model)
|
|
9
13
|
model = normalized_model_name(model)
|
|
@@ -18,6 +22,10 @@ module LlmCostTracker
|
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
def reset!
|
|
26
|
+
MUTEX.synchronize { @warned_models = Set.new }
|
|
27
|
+
end
|
|
28
|
+
|
|
21
29
|
private
|
|
22
30
|
|
|
23
31
|
def normalized_model_name(model)
|
|
@@ -25,6 +33,12 @@ module LlmCostTracker
|
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
def warn_missing(model)
|
|
36
|
+
should_warn = MUTEX.synchronize do
|
|
37
|
+
@warned_models ||= Set.new
|
|
38
|
+
@warned_models.add?(model)
|
|
39
|
+
end
|
|
40
|
+
return unless should_warn
|
|
41
|
+
|
|
28
42
|
Logging.warn(
|
|
29
43
|
"No pricing configured for model #{model.inspect}. " \
|
|
30
44
|
"Cost and budget guardrails will be skipped for this event. " \
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -60,6 +60,8 @@ module LlmCostTracker
|
|
|
60
60
|
|
|
61
61
|
def reset_configuration!
|
|
62
62
|
CONFIGURATION_MUTEX.synchronize { @configuration = Configuration.new }
|
|
63
|
+
UnknownPricing.reset! if defined?(UnknownPricing)
|
|
64
|
+
Storage::ActiveRecordStore.reset! if defined?(Storage::ActiveRecordStore)
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
def enforce_budget!
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llm_cost_tracker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergii Khomenko
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -278,11 +278,13 @@ 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
282
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb
|
|
282
283
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb
|
|
283
284
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
|
|
284
285
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb
|
|
285
286
|
- 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
|
|
286
288
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb
|
|
287
289
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb
|
|
288
290
|
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb
|
|
@@ -295,6 +297,7 @@ files:
|
|
|
295
297
|
- lib/llm_cost_tracker/llm_api_call.rb
|
|
296
298
|
- lib/llm_cost_tracker/logging.rb
|
|
297
299
|
- lib/llm_cost_tracker/middleware/faraday.rb
|
|
300
|
+
- lib/llm_cost_tracker/monthly_total.rb
|
|
298
301
|
- lib/llm_cost_tracker/parameter_hash.rb
|
|
299
302
|
- lib/llm_cost_tracker/parsed_usage.rb
|
|
300
303
|
- lib/llm_cost_tracker/parsers/anthropic.rb
|
|
@@ -338,7 +341,6 @@ files:
|
|
|
338
341
|
- lib/llm_cost_tracker/value_helpers.rb
|
|
339
342
|
- lib/llm_cost_tracker/version.rb
|
|
340
343
|
- lib/tasks/llm_cost_tracker.rake
|
|
341
|
-
- llm_cost_tracker.gemspec
|
|
342
344
|
homepage: https://github.com/sergey-homenko/llm_cost_tracker
|
|
343
345
|
licenses:
|
|
344
346
|
- MIT
|
|
@@ -363,7 +365,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
363
365
|
- !ruby/object:Gem::Version
|
|
364
366
|
version: '0'
|
|
365
367
|
requirements: []
|
|
366
|
-
rubygems_version: 3.5.
|
|
368
|
+
rubygems_version: 3.5.22
|
|
367
369
|
signing_key:
|
|
368
370
|
specification_version: 4
|
|
369
371
|
summary: Self-hosted LLM usage and cost tracking for Ruby and Rails
|
data/llm_cost_tracker.gemspec
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "lib/llm_cost_tracker/version"
|
|
4
|
-
|
|
5
|
-
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name = "llm_cost_tracker"
|
|
7
|
-
spec.version = LlmCostTracker::VERSION
|
|
8
|
-
spec.authors = ["Sergii Khomenko"]
|
|
9
|
-
spec.email = ["sergey@mm.st"]
|
|
10
|
-
|
|
11
|
-
spec.summary = "Self-hosted LLM usage and cost tracking for Ruby and Rails"
|
|
12
|
-
spec.description = "Tracks token usage, latency, and estimated costs for OpenAI, Anthropic, " \
|
|
13
|
-
"Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. " \
|
|
14
|
-
"Works through Faraday middleware or explicit track/track_stream helpers, " \
|
|
15
|
-
"with ActiveRecord storage, tag-based attribution, price sync tasks, " \
|
|
16
|
-
"and budget guardrails."
|
|
17
|
-
spec.homepage = "https://github.com/sergey-homenko/llm_cost_tracker"
|
|
18
|
-
spec.license = "MIT"
|
|
19
|
-
|
|
20
|
-
spec.required_ruby_version = ">= 3.3.0"
|
|
21
|
-
|
|
22
|
-
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
23
|
-
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
24
|
-
spec.metadata["source_code_uri"] = spec.homepage
|
|
25
|
-
spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
|
|
26
|
-
spec.metadata["rubygems_mfa_required"] = "true"
|
|
27
|
-
|
|
28
|
-
spec.files = Dir.chdir(__dir__) do
|
|
29
|
-
`git ls-files -z`.split("\x0").reject do |f|
|
|
30
|
-
(File.expand_path(f) == __FILE__) ||
|
|
31
|
-
f.start_with?("bin/", "docs/", "test/", "spec/", ".git", ".github", "gemfiles/", ".rubocop", "Gemfile")
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
spec.require_paths = ["lib"]
|
|
36
|
-
|
|
37
|
-
spec.add_dependency "activesupport", ">= 7.1", "< 9.0"
|
|
38
|
-
spec.add_dependency "csv", "~> 3.0"
|
|
39
|
-
spec.add_dependency "faraday", ">= 2.0", "< 3.0"
|
|
40
|
-
|
|
41
|
-
spec.add_development_dependency "activerecord", ">= 7.1", "< 9.0"
|
|
42
|
-
spec.add_development_dependency "railties", ">= 7.1", "< 9.0"
|
|
43
|
-
spec.add_development_dependency "rake", "~> 13.0"
|
|
44
|
-
spec.add_development_dependency "rspec", "~> 3.0"
|
|
45
|
-
spec.add_development_dependency "rubocop", "~> 1.0"
|
|
46
|
-
spec.add_development_dependency "simplecov", "~> 0.22"
|
|
47
|
-
spec.add_development_dependency "simplecov-lcov", "~> 0.8"
|
|
48
|
-
spec.add_development_dependency "sqlite3", ">= 1.4", "< 3.0"
|
|
49
|
-
spec.add_development_dependency "webmock", "~> 3.0"
|
|
50
|
-
end
|