llm_cost_tracker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +248 -0
- data/Rakefile +8 -0
- data/lib/llm_cost_tracker/configuration.rb +33 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +23 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +27 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +56 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +61 -0
- data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -0
- data/lib/llm_cost_tracker/parsers/base.rb +35 -0
- data/lib/llm_cost_tracker/parsers/gemini.rb +47 -0
- data/lib/llm_cost_tracker/parsers/openai.rb +37 -0
- data/lib/llm_cost_tracker/parsers/registry.rb +29 -0
- data/lib/llm_cost_tracker/pricing.rb +73 -0
- data/lib/llm_cost_tracker/railtie.rb +19 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +37 -0
- data/lib/llm_cost_tracker/tracker.rb +107 -0
- data/lib/llm_cost_tracker/version.rb +5 -0
- data/lib/llm_cost_tracker.rb +65 -0
- data/llm_cost_tracker.gemspec +41 -0
- metadata +201 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 16c6c4c230300b2caebc69e1f0aeb9a7af232278a6e77401aba0d490bb16b4a2
|
|
4
|
+
data.tar.gz: 4b32fb25d22c645e4d66767264130bc44ce730e553636cefca8e4884dede7b94
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f403ebeeb6a98164bc2318b9f3b8f49e03750fd5c5d328ac0b0f3c0557f16c39d8f247aa663bdd15b605df779be7d4b4db7445fb79d10eb4c89ef17a687a77d7
|
|
7
|
+
data.tar.gz: fd04a24708901e998127d6582290da90b35f62f7a36d794805a4677656be9191eed14235b634b72924ac4178837aa9dbace3a57fde18829d47b6a8208e295af2
|
data/.rspec
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-04-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Faraday middleware for automatic LLM API call interception
|
|
13
|
+
- Provider parsers: OpenAI, Anthropic, Google Gemini
|
|
14
|
+
- Built-in pricing table for 20+ models
|
|
15
|
+
- Fuzzy model name matching (e.g. `gpt-4o-2024-08-06` → `gpt-4o`)
|
|
16
|
+
- ActiveSupport::Notifications integration
|
|
17
|
+
- ActiveRecord storage backend with scopes and aggregations
|
|
18
|
+
- Manual `LlmCostTracker.track()` for non-Faraday clients
|
|
19
|
+
- Per-user / per-feature tagging
|
|
20
|
+
- Monthly budget alerts with configurable callbacks
|
|
21
|
+
- Rails generator: `rails generate llm_cost_tracker:install`
|
|
22
|
+
- Custom storage backend support
|
|
23
|
+
- Pricing overrides via configuration
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergii Khomenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# LlmCostTracker
|
|
2
|
+
|
|
3
|
+
**Provider-agnostic LLM API cost tracking for Ruby.**
|
|
4
|
+
|
|
5
|
+
Track token usage and costs for every LLM API call your app makes — OpenAI, Anthropic, Google Gemini, and any OpenAI-compatible provider. Works as Faraday middleware, so it plugs into **any** Ruby LLM client without code changes.
|
|
6
|
+
|
|
7
|
+
[](https://rubygems.org/gems/llm_cost_tracker)
|
|
8
|
+
|
|
9
|
+
## Why?
|
|
10
|
+
|
|
11
|
+
Every Rails app integrating LLMs faces the same problem: **you don't know how much AI is costing you** until the invoice arrives. Existing solutions either lock you into a specific LLM gem (like `ruby_llm-monitoring`) or require external SaaS (Langfuse, Helicone).
|
|
12
|
+
|
|
13
|
+
`llm_cost_tracker` takes a different approach:
|
|
14
|
+
|
|
15
|
+
- 🔌 **Provider-agnostic** — intercepts HTTP responses at the Faraday level
|
|
16
|
+
- 🏠 **Self-hosted** — your data stays in your database
|
|
17
|
+
- 🧩 **Zero coupling** — works with `ruby-openai`, `anthropic-rb`, `ruby_llm`, or raw Faraday
|
|
18
|
+
- ⚡ **Zero config** — add the middleware, done
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add to your Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "llm_cost_tracker"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For ActiveRecord storage (recommended for production):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bin/rails generate llm_cost_tracker:install
|
|
32
|
+
bin/rails db:migrate
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
### Option 1: Faraday Middleware (automatic)
|
|
38
|
+
|
|
39
|
+
If your LLM client uses Faraday (most do), just add the middleware:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
conn = Faraday.new(url: "https://api.openai.com") do |f|
|
|
43
|
+
f.use :llm_cost_tracker, tags: { feature: "chat", user_id: current_user.id }
|
|
44
|
+
f.request :json
|
|
45
|
+
f.response :json
|
|
46
|
+
f.adapter Faraday.default_adapter
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Every request through this connection is now tracked automatically
|
|
50
|
+
response = conn.post("/v1/chat/completions", {
|
|
51
|
+
model: "gpt-4o",
|
|
52
|
+
messages: [{ role: "user", content: "Hello!" }]
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Option 2: Patch an existing client
|
|
57
|
+
|
|
58
|
+
Most LLM gems expose their Faraday connection. For example, with `ruby-openai`:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# config/initializers/openai.rb
|
|
62
|
+
OpenAI.configure do |config|
|
|
63
|
+
config.access_token = ENV["OPENAI_API_KEY"]
|
|
64
|
+
|
|
65
|
+
config.faraday do |f|
|
|
66
|
+
f.use :llm_cost_tracker, tags: { feature: "openai_default" }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Option 3: Manual tracking
|
|
72
|
+
|
|
73
|
+
For non-Faraday clients, track manually:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
LlmCostTracker.track(
|
|
77
|
+
provider: :anthropic,
|
|
78
|
+
model: "claude-sonnet-4-6",
|
|
79
|
+
input_tokens: 1500,
|
|
80
|
+
output_tokens: 320,
|
|
81
|
+
feature: "summarizer",
|
|
82
|
+
user_id: current_user.id
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# config/initializers/llm_cost_tracker.rb
|
|
90
|
+
LlmCostTracker.configure do |config|
|
|
91
|
+
# Storage: :log (default), :active_record, or :custom
|
|
92
|
+
config.storage_backend = :active_record
|
|
93
|
+
|
|
94
|
+
# Default tags on every event
|
|
95
|
+
config.default_tags = { app: "my_app", environment: Rails.env }
|
|
96
|
+
|
|
97
|
+
# Monthly budget in USD
|
|
98
|
+
config.monthly_budget = 500.00
|
|
99
|
+
|
|
100
|
+
# Alert callback
|
|
101
|
+
config.on_budget_exceeded = ->(data) {
|
|
102
|
+
SlackNotifier.notify(
|
|
103
|
+
"#alerts",
|
|
104
|
+
"🚨 LLM budget exceeded! $#{data[:monthly_total].round(2)} / $#{data[:budget]}"
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Override pricing for custom/fine-tuned models (per 1M tokens)
|
|
109
|
+
config.pricing_overrides = {
|
|
110
|
+
"ft:gpt-4o-mini:my-org" => { input: 0.30, output: 1.20 }
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Querying Costs (ActiveRecord)
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
# Today's total spend
|
|
119
|
+
LlmCostTracker::LlmApiCall.today.total_cost
|
|
120
|
+
# => 12.45
|
|
121
|
+
|
|
122
|
+
# Cost breakdown by model this month
|
|
123
|
+
LlmCostTracker::LlmApiCall.this_month.cost_by_model
|
|
124
|
+
# => { "gpt-4o" => 8.20, "claude-sonnet-4-6" => 4.25 }
|
|
125
|
+
|
|
126
|
+
# Cost by provider
|
|
127
|
+
LlmCostTracker::LlmApiCall.this_month.cost_by_provider
|
|
128
|
+
# => { "openai" => 8.20, "anthropic" => 4.25 }
|
|
129
|
+
|
|
130
|
+
# Daily cost trend
|
|
131
|
+
LlmCostTracker::LlmApiCall.daily_costs(days: 7)
|
|
132
|
+
# => { "2026-04-10" => 1.5, "2026-04-11" => 2.3, ... }
|
|
133
|
+
|
|
134
|
+
# Filter by feature
|
|
135
|
+
LlmCostTracker::LlmApiCall.by_tag("feature", "chat").this_month.total_cost
|
|
136
|
+
|
|
137
|
+
# Filter by user
|
|
138
|
+
LlmCostTracker::LlmApiCall.by_tag("user_id", "42").today.total_cost
|
|
139
|
+
|
|
140
|
+
# Custom date range
|
|
141
|
+
LlmCostTracker::LlmApiCall.between(1.week.ago, Time.current).cost_by_model
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## ActiveSupport::Notifications
|
|
145
|
+
|
|
146
|
+
Every tracked call emits an `llm_request.llm_cost_tracker` event:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
ActiveSupport::Notifications.subscribe("llm_request.llm_cost_tracker") do |*, payload|
|
|
150
|
+
# payload =>
|
|
151
|
+
# {
|
|
152
|
+
# provider: "openai",
|
|
153
|
+
# model: "gpt-4o",
|
|
154
|
+
# input_tokens: 150,
|
|
155
|
+
# output_tokens: 42,
|
|
156
|
+
# total_tokens: 192,
|
|
157
|
+
# cost: { input_cost: 0.000375, output_cost: 0.00042, total_cost: 0.000795, currency: "USD" },
|
|
158
|
+
# tags: { feature: "chat", user_id: 42 },
|
|
159
|
+
# tracked_at: 2026-04-16 14:30:00 UTC
|
|
160
|
+
# }
|
|
161
|
+
|
|
162
|
+
StatsD.increment("llm.requests", tags: ["provider:#{payload[:provider]}"])
|
|
163
|
+
StatsD.histogram("llm.cost", payload[:cost][:total_cost])
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Custom Storage Backend
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
LlmCostTracker.configure do |config|
|
|
171
|
+
config.storage_backend = :custom
|
|
172
|
+
config.custom_storage = ->(event) {
|
|
173
|
+
InfluxDB.write("llm_costs", {
|
|
174
|
+
values: { cost: event[:cost][:total_cost], tokens: event[:total_tokens] },
|
|
175
|
+
tags: { provider: event[:provider], model: event[:model] }
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Adding a Custom Provider Parser
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
class DeepSeekParser < LlmCostTracker::Parsers::Base
|
|
185
|
+
def match?(url)
|
|
186
|
+
url.to_s.include?("api.deepseek.com")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse(request_url, request_body, response_status, response_body)
|
|
190
|
+
return nil unless response_status == 200
|
|
191
|
+
|
|
192
|
+
response = safe_json_parse(response_body)
|
|
193
|
+
usage = response["usage"]
|
|
194
|
+
return nil unless usage
|
|
195
|
+
|
|
196
|
+
{
|
|
197
|
+
provider: "deepseek",
|
|
198
|
+
model: response["model"],
|
|
199
|
+
input_tokens: usage["prompt_tokens"] || 0,
|
|
200
|
+
output_tokens: usage["completion_tokens"] || 0
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Register it
|
|
206
|
+
LlmCostTracker::Parsers::Registry.register(DeepSeekParser.new)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Supported Providers
|
|
210
|
+
|
|
211
|
+
| Provider | Auto-detected | Models with pricing |
|
|
212
|
+
|----------|:---:|---|
|
|
213
|
+
| OpenAI | ✅ | GPT-4o, GPT-4o-mini, GPT-4-turbo, GPT-4, GPT-3.5-turbo, o1, o1-mini, o3-mini |
|
|
214
|
+
| Anthropic | ✅ | Claude Opus 4.6, Sonnet 4.6, Haiku 4.5, Claude 3.5 Sonnet, Claude 3 Opus |
|
|
215
|
+
| Google Gemini | ✅ | Gemini 2.5 Pro/Flash, 2.0 Flash, 1.5 Pro/Flash |
|
|
216
|
+
| Any other | 🔧 | Via custom parser (see above) |
|
|
217
|
+
|
|
218
|
+
## How It Works
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
Your App → Faraday → [LlmCostTracker Middleware] → LLM API
|
|
222
|
+
↓
|
|
223
|
+
Parses response body
|
|
224
|
+
Extracts token usage
|
|
225
|
+
Calculates cost
|
|
226
|
+
↓
|
|
227
|
+
ActiveSupport::Notifications
|
|
228
|
+
ActiveRecord / Log / Custom
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The middleware intercepts **outgoing** HTTP responses (not incoming requests), parses the `usage` object from the LLM provider's response body, looks up pricing, and records the event. It never modifies requests or responses — it's read-only.
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
git clone https://github.com/sergey-homenko/llm_cost_tracker.git
|
|
237
|
+
cd llm_cost_tracker
|
|
238
|
+
bundle install
|
|
239
|
+
bundle exec rspec
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Contributing
|
|
243
|
+
|
|
244
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/sergey-homenko/llm_cost_tracker).
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :enabled,
|
|
6
|
+
:storage_backend, # :log, :active_record, :custom
|
|
7
|
+
:custom_storage, # callable object for :custom backend
|
|
8
|
+
:default_tags, # Hash of default tags added to every event
|
|
9
|
+
:on_budget_exceeded, # callable, receives event hash
|
|
10
|
+
:monthly_budget, # Float, in USD — nil means no limit
|
|
11
|
+
:log_level, # :debug, :info, :warn
|
|
12
|
+
:pricing_overrides # Hash to override built-in pricing
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@enabled = true
|
|
16
|
+
@storage_backend = :log
|
|
17
|
+
@custom_storage = nil
|
|
18
|
+
@default_tags = {}
|
|
19
|
+
@on_budget_exceeded = nil
|
|
20
|
+
@monthly_budget = nil
|
|
21
|
+
@log_level = :info
|
|
22
|
+
@pricing_overrides = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def active_record?
|
|
26
|
+
storage_backend == :active_record
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def log?
|
|
30
|
+
storage_backend == :log
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
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 InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates the LlmCostTracker migration and initializer"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"create_llm_api_calls.rb.erb",
|
|
18
|
+
"db/migrate/create_llm_api_calls.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_initializer
|
|
23
|
+
template(
|
|
24
|
+
"initializer.rb.erb",
|
|
25
|
+
"config/initializers/llm_cost_tracker.rb"
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def migration_version
|
|
32
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :llm_api_calls do |t|
|
|
4
|
+
t.string :provider, null: false
|
|
5
|
+
t.string :model, null: false
|
|
6
|
+
t.integer :input_tokens, null: false, default: 0
|
|
7
|
+
t.integer :output_tokens, null: false, default: 0
|
|
8
|
+
t.integer :total_tokens, null: false, default: 0
|
|
9
|
+
t.decimal :input_cost, precision: 12, scale: 8
|
|
10
|
+
t.decimal :output_cost, precision: 12, scale: 8
|
|
11
|
+
t.decimal :total_cost, precision: 12, scale: 8
|
|
12
|
+
t.text :tags
|
|
13
|
+
t.datetime :tracked_at, null: false
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :llm_api_calls, :provider
|
|
19
|
+
add_index :llm_api_calls, :model
|
|
20
|
+
add_index :llm_api_calls, :tracked_at
|
|
21
|
+
add_index :llm_api_calls, [:provider, :tracked_at]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
LlmCostTracker.configure do |config|
|
|
4
|
+
# Enable/disable tracking
|
|
5
|
+
config.enabled = true
|
|
6
|
+
|
|
7
|
+
# Storage backend: :log, :active_record, or :custom
|
|
8
|
+
config.storage_backend = :active_record
|
|
9
|
+
|
|
10
|
+
# Default tags added to every tracked event
|
|
11
|
+
# config.default_tags = { environment: Rails.env, app: "my_app" }
|
|
12
|
+
|
|
13
|
+
# Monthly budget in USD. Set to nil to disable budget alerts.
|
|
14
|
+
# config.monthly_budget = 100.00
|
|
15
|
+
|
|
16
|
+
# Callback when monthly budget is exceeded.
|
|
17
|
+
# config.on_budget_exceeded = ->(data) {
|
|
18
|
+
# Rails.logger.warn "[LlmCostTracker] Budget exceeded! " \
|
|
19
|
+
# "Monthly total: $#{data[:monthly_total]}, Budget: $#{data[:budget]}"
|
|
20
|
+
# # Or send a Slack notification, email, etc.
|
|
21
|
+
# }
|
|
22
|
+
|
|
23
|
+
# Override built-in pricing for specific models (per 1M tokens, USD)
|
|
24
|
+
# config.pricing_overrides = {
|
|
25
|
+
# "my-custom-model" => { input: 1.00, output: 2.00 }
|
|
26
|
+
# }
|
|
27
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class LlmApiCall < ActiveRecord::Base
|
|
7
|
+
self.table_name = "llm_api_calls"
|
|
8
|
+
|
|
9
|
+
# Scopes for querying
|
|
10
|
+
scope :by_provider, ->(provider) { where(provider: provider) }
|
|
11
|
+
scope :by_model, ->(model) { where(model: model) }
|
|
12
|
+
scope :by_tag, ->(key, value) { where("tags LIKE ?", "%\"#{key}\":\"#{value}\"%") }
|
|
13
|
+
|
|
14
|
+
scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
|
|
15
|
+
scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
|
|
16
|
+
scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
|
|
17
|
+
scope :between, ->(from, to) { where(tracked_at: from..to) }
|
|
18
|
+
|
|
19
|
+
# Aggregations
|
|
20
|
+
def self.total_cost
|
|
21
|
+
sum(:total_cost).to_f
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.total_tokens
|
|
25
|
+
sum(:total_tokens).to_i
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.cost_by_model
|
|
29
|
+
group(:model).sum(:total_cost)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.cost_by_provider
|
|
33
|
+
group(:provider).sum(:total_cost)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.daily_costs(days: 30)
|
|
37
|
+
where(tracked_at: days.days.ago..)
|
|
38
|
+
.group("DATE(tracked_at)")
|
|
39
|
+
.sum(:total_cost)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parsed_tags
|
|
43
|
+
JSON.parse(tags || "{}")
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
{}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def feature
|
|
49
|
+
parsed_tags["feature"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def user_id
|
|
53
|
+
parsed_tags["user_id"]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Middleware
|
|
7
|
+
class Faraday < ::Faraday::Middleware
|
|
8
|
+
def initialize(app, **options)
|
|
9
|
+
super(app)
|
|
10
|
+
@tags = options.fetch(:tags, {})
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(request_env)
|
|
14
|
+
return @app.call(request_env) unless LlmCostTracker.configuration.enabled
|
|
15
|
+
|
|
16
|
+
request_url = request_env.url.to_s
|
|
17
|
+
request_body = read_body(request_env.body)
|
|
18
|
+
|
|
19
|
+
@app.call(request_env).on_complete do |response_env|
|
|
20
|
+
process(request_url, request_body, response_env)
|
|
21
|
+
end
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
# Never break the actual request — log and re-raise
|
|
24
|
+
raise e
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def process(request_url, request_body, response_env)
|
|
30
|
+
parser = Parsers::Registry.find_for(request_url)
|
|
31
|
+
return unless parser
|
|
32
|
+
|
|
33
|
+
parsed = parser.parse(
|
|
34
|
+
request_url,
|
|
35
|
+
request_body,
|
|
36
|
+
response_env.status,
|
|
37
|
+
read_body(response_env.body)
|
|
38
|
+
)
|
|
39
|
+
return unless parsed
|
|
40
|
+
|
|
41
|
+
Tracker.record(
|
|
42
|
+
provider: parsed[:provider],
|
|
43
|
+
model: parsed[:model],
|
|
44
|
+
input_tokens: parsed[:input_tokens],
|
|
45
|
+
output_tokens: parsed[:output_tokens],
|
|
46
|
+
metadata: @tags.merge(parsed.except(:provider, :model, :input_tokens, :output_tokens, :total_tokens))
|
|
47
|
+
)
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
warn "[LlmCostTracker] Error processing response: #{e.message}" if LlmCostTracker.configuration.log_level == :debug
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def read_body(body)
|
|
53
|
+
case body
|
|
54
|
+
when String then body
|
|
55
|
+
when nil then ""
|
|
56
|
+
else body.to_s
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Parsers
|
|
7
|
+
class Anthropic < Base
|
|
8
|
+
HOSTS = %w[api.anthropic.com].freeze
|
|
9
|
+
|
|
10
|
+
def match?(url)
|
|
11
|
+
uri = URI.parse(url.to_s)
|
|
12
|
+
HOSTS.include?(uri.host) && uri.path.include?("/v1/messages")
|
|
13
|
+
rescue URI::InvalidURIError
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse(request_url, request_body, response_status, response_body)
|
|
18
|
+
return nil unless response_status == 200
|
|
19
|
+
|
|
20
|
+
response = safe_json_parse(response_body)
|
|
21
|
+
usage = response["usage"]
|
|
22
|
+
return nil unless usage
|
|
23
|
+
|
|
24
|
+
request = safe_json_parse(request_body)
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
provider: "anthropic",
|
|
28
|
+
model: response["model"] || request["model"],
|
|
29
|
+
input_tokens: usage["input_tokens"] || 0,
|
|
30
|
+
output_tokens: usage["output_tokens"] || 0,
|
|
31
|
+
total_tokens: (usage["input_tokens"] || 0) + (usage["output_tokens"] || 0),
|
|
32
|
+
cache_read_tokens: usage["cache_read_input_tokens"],
|
|
33
|
+
cache_creation_tokens: usage["cache_creation_input_tokens"]
|
|
34
|
+
}.compact
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Parsers
|
|
5
|
+
class Base
|
|
6
|
+
# Returns a hash with parsed usage data, or nil if not applicable.
|
|
7
|
+
#
|
|
8
|
+
# Expected return format:
|
|
9
|
+
# {
|
|
10
|
+
# provider: "openai",
|
|
11
|
+
# model: "gpt-4o",
|
|
12
|
+
# input_tokens: 150,
|
|
13
|
+
# output_tokens: 42
|
|
14
|
+
# }
|
|
15
|
+
def parse(request_url, request_body, response_status, response_body)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns true if this parser can handle the given URL.
|
|
20
|
+
def match?(url)
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def safe_json_parse(body)
|
|
27
|
+
return {} if body.nil? || body.empty?
|
|
28
|
+
|
|
29
|
+
JSON.parse(body)
|
|
30
|
+
rescue JSON::ParserError
|
|
31
|
+
{}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Parsers
|
|
7
|
+
class Gemini < Base
|
|
8
|
+
HOSTS = %w[generativelanguage.googleapis.com].freeze
|
|
9
|
+
|
|
10
|
+
def match?(url)
|
|
11
|
+
uri = URI.parse(url.to_s)
|
|
12
|
+
HOSTS.include?(uri.host)
|
|
13
|
+
rescue URI::InvalidURIError
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse(request_url, request_body, response_status, response_body)
|
|
18
|
+
return nil unless response_status == 200
|
|
19
|
+
|
|
20
|
+
response = safe_json_parse(response_body)
|
|
21
|
+
usage = response["usageMetadata"]
|
|
22
|
+
return nil unless usage
|
|
23
|
+
|
|
24
|
+
# Extract model from URL: /v1beta/models/gemini-2.5-flash:generateContent
|
|
25
|
+
model = extract_model_from_url(request_url)
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
provider: "gemini",
|
|
29
|
+
model: model,
|
|
30
|
+
input_tokens: usage["promptTokenCount"] || 0,
|
|
31
|
+
output_tokens: usage["candidatesTokenCount"] || 0,
|
|
32
|
+
total_tokens: usage["totalTokenCount"] || 0
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def extract_model_from_url(url)
|
|
39
|
+
uri = URI.parse(url.to_s)
|
|
40
|
+
match = uri.path.match(%r{/models/([^/:]+)})
|
|
41
|
+
match ? match[1] : "unknown"
|
|
42
|
+
rescue URI::InvalidURIError
|
|
43
|
+
"unknown"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Parsers
|
|
7
|
+
class Openai < Base
|
|
8
|
+
HOSTS = %w[api.openai.com].freeze
|
|
9
|
+
TRACKED_PATHS = %w[/v1/chat/completions /v1/completions /v1/embeddings].freeze
|
|
10
|
+
|
|
11
|
+
def match?(url)
|
|
12
|
+
uri = URI.parse(url.to_s)
|
|
13
|
+
HOSTS.include?(uri.host) && TRACKED_PATHS.any? { |p| uri.path.start_with?(p) }
|
|
14
|
+
rescue URI::InvalidURIError
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse(request_url, request_body, response_status, response_body)
|
|
19
|
+
return nil unless response_status == 200
|
|
20
|
+
|
|
21
|
+
response = safe_json_parse(response_body)
|
|
22
|
+
usage = response["usage"]
|
|
23
|
+
return nil unless usage
|
|
24
|
+
|
|
25
|
+
request = safe_json_parse(request_body)
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
provider: "openai",
|
|
29
|
+
model: response["model"] || request["model"],
|
|
30
|
+
input_tokens: usage["prompt_tokens"] || 0,
|
|
31
|
+
output_tokens: usage["completion_tokens"] || 0,
|
|
32
|
+
total_tokens: usage["total_tokens"] || 0
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Parsers
|
|
5
|
+
class Registry
|
|
6
|
+
class << self
|
|
7
|
+
def parsers
|
|
8
|
+
@parsers ||= [
|
|
9
|
+
Openai.new,
|
|
10
|
+
Anthropic.new,
|
|
11
|
+
Gemini.new
|
|
12
|
+
]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def register(parser)
|
|
16
|
+
parsers.unshift(parser)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def find_for(url)
|
|
20
|
+
parsers.find { |p| p.match?(url) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset!
|
|
24
|
+
@parsers = nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
# Prices per 1M tokens in USD.
|
|
5
|
+
# Updated: April 2026. Override via configuration.
|
|
6
|
+
module Pricing
|
|
7
|
+
PRICES = {
|
|
8
|
+
# OpenAI
|
|
9
|
+
"gpt-4o" => { input: 2.50, output: 10.00 },
|
|
10
|
+
"gpt-4o-mini" => { input: 0.15, output: 0.60 },
|
|
11
|
+
"gpt-4-turbo" => { input: 10.00, output: 30.00 },
|
|
12
|
+
"gpt-4" => { input: 30.00, output: 60.00 },
|
|
13
|
+
"gpt-3.5-turbo" => { input: 0.50, output: 1.50 },
|
|
14
|
+
"o1" => { input: 15.00, output: 60.00 },
|
|
15
|
+
"o1-mini" => { input: 3.00, output: 12.00 },
|
|
16
|
+
"o3-mini" => { input: 1.10, output: 4.40 },
|
|
17
|
+
|
|
18
|
+
# Anthropic
|
|
19
|
+
"claude-sonnet-4-6" => { input: 3.00, output: 15.00 },
|
|
20
|
+
"claude-opus-4-6" => { input: 15.00, output: 75.00 },
|
|
21
|
+
"claude-haiku-4-5" => { input: 0.80, output: 4.00 },
|
|
22
|
+
"claude-3-5-sonnet-20241022" => { input: 3.00, output: 15.00 },
|
|
23
|
+
"claude-3-5-haiku-20241022" => { input: 0.80, output: 4.00 },
|
|
24
|
+
"claude-3-opus-20240229" => { input: 15.00, output: 75.00 },
|
|
25
|
+
|
|
26
|
+
# Google Gemini
|
|
27
|
+
"gemini-2.5-pro" => { input: 1.25, output: 10.00 },
|
|
28
|
+
"gemini-2.5-flash" => { input: 0.15, output: 0.60 },
|
|
29
|
+
"gemini-2.0-flash" => { input: 0.10, output: 0.40 },
|
|
30
|
+
"gemini-1.5-pro" => { input: 1.25, output: 5.00 },
|
|
31
|
+
"gemini-1.5-flash" => { input: 0.075, output: 0.30 },
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
def cost_for(model:, input_tokens:, output_tokens:)
|
|
36
|
+
prices = lookup(model)
|
|
37
|
+
return nil unless prices
|
|
38
|
+
|
|
39
|
+
input_cost = (input_tokens.to_f / 1_000_000) * prices[:input]
|
|
40
|
+
output_cost = (output_tokens.to_f / 1_000_000) * prices[:output]
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
input_cost: input_cost.round(8),
|
|
44
|
+
output_cost: output_cost.round(8),
|
|
45
|
+
total_cost: (input_cost + output_cost).round(8),
|
|
46
|
+
currency: "USD"
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def lookup(model)
|
|
51
|
+
overrides = LlmCostTracker.configuration.pricing_overrides
|
|
52
|
+
overrides[model] || PRICES[model] || fuzzy_match(model)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def models
|
|
56
|
+
PRICES.keys | LlmCostTracker.configuration.pricing_overrides.keys
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Try to match model names like "gpt-4o-2024-08-06" to "gpt-4o"
|
|
62
|
+
def fuzzy_match(model)
|
|
63
|
+
return nil unless model
|
|
64
|
+
|
|
65
|
+
PRICES.each do |key, value|
|
|
66
|
+
return value if model.start_with?(key)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
generators do
|
|
6
|
+
require_relative "generators/llm_cost_tracker/install_generator"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
initializer "llm_cost_tracker.configure" do
|
|
10
|
+
# Auto-require ActiveRecord storage if configured
|
|
11
|
+
ActiveSupport.on_load(:active_record) do
|
|
12
|
+
if LlmCostTracker.configuration.active_record?
|
|
13
|
+
require_relative "llm_api_call"
|
|
14
|
+
require_relative "storage/active_record_store"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Storage
|
|
5
|
+
class ActiveRecordStore
|
|
6
|
+
class << self
|
|
7
|
+
def save(event)
|
|
8
|
+
model_class.create!(
|
|
9
|
+
provider: event[:provider],
|
|
10
|
+
model: event[:model],
|
|
11
|
+
input_tokens: event[:input_tokens],
|
|
12
|
+
output_tokens: event[:output_tokens],
|
|
13
|
+
total_tokens: event[:total_tokens],
|
|
14
|
+
input_cost: event.dig(:cost, :input_cost),
|
|
15
|
+
output_cost: event.dig(:cost, :output_cost),
|
|
16
|
+
total_cost: event.dig(:cost, :total_cost),
|
|
17
|
+
tags: event[:tags].to_json,
|
|
18
|
+
tracked_at: event[:tracked_at]
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def monthly_total(time: Time.now.utc)
|
|
23
|
+
beginning_of_month = Time.new(time.year, time.month, 1, 0, 0, 0, "+00:00")
|
|
24
|
+
|
|
25
|
+
model_class
|
|
26
|
+
.where(tracked_at: beginning_of_month..time)
|
|
27
|
+
.sum(:total_cost)
|
|
28
|
+
.to_f
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def model_class
|
|
32
|
+
LlmCostTracker::LlmApiCall
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class Tracker
|
|
5
|
+
EVENT_NAME = "llm_request.llm_cost_tracker"
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def record(provider:, model:, input_tokens:, output_tokens:, metadata: {})
|
|
9
|
+
cost_data = Pricing.cost_for(
|
|
10
|
+
model: model,
|
|
11
|
+
input_tokens: input_tokens,
|
|
12
|
+
output_tokens: output_tokens
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
event = {
|
|
16
|
+
provider: provider,
|
|
17
|
+
model: model,
|
|
18
|
+
input_tokens: input_tokens,
|
|
19
|
+
output_tokens: output_tokens,
|
|
20
|
+
total_tokens: input_tokens + output_tokens,
|
|
21
|
+
cost: cost_data,
|
|
22
|
+
tags: LlmCostTracker.configuration.default_tags.merge(metadata),
|
|
23
|
+
tracked_at: Time.now.utc
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Emit ActiveSupport::Notifications event
|
|
27
|
+
ActiveSupport::Notifications.instrument(EVENT_NAME, event)
|
|
28
|
+
|
|
29
|
+
# Store based on backend
|
|
30
|
+
store(event)
|
|
31
|
+
|
|
32
|
+
# Budget check
|
|
33
|
+
check_budget(event)
|
|
34
|
+
|
|
35
|
+
event
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def store(event)
|
|
41
|
+
config = LlmCostTracker.configuration
|
|
42
|
+
|
|
43
|
+
case config.storage_backend
|
|
44
|
+
when :log
|
|
45
|
+
log_event(event)
|
|
46
|
+
when :active_record
|
|
47
|
+
store_active_record(event)
|
|
48
|
+
when :custom
|
|
49
|
+
config.custom_storage&.call(event)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def log_event(event)
|
|
54
|
+
cost_str = event[:cost] ? "$#{'%.6f' % event[:cost][:total_cost]}" : "unknown"
|
|
55
|
+
|
|
56
|
+
message = "[LlmCostTracker] #{event[:provider]}/#{event[:model]} " \
|
|
57
|
+
"tokens=#{event[:input_tokens]}+#{event[:output_tokens]} " \
|
|
58
|
+
"cost=#{cost_str}"
|
|
59
|
+
message += " tags=#{event[:tags]}" unless event[:tags].empty?
|
|
60
|
+
|
|
61
|
+
case LlmCostTracker.configuration.log_level
|
|
62
|
+
when :debug
|
|
63
|
+
Rails.logger.debug(message) if defined?(Rails)
|
|
64
|
+
when :warn
|
|
65
|
+
Rails.logger.warn(message) if defined?(Rails)
|
|
66
|
+
else
|
|
67
|
+
Rails.logger.info(message) if defined?(Rails)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Fallback if Rails is not available
|
|
71
|
+
warn(message) unless defined?(Rails)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def store_active_record(event)
|
|
75
|
+
return unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
76
|
+
|
|
77
|
+
LlmCostTracker::Storage::ActiveRecordStore.save(event)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def check_budget(event)
|
|
81
|
+
config = LlmCostTracker.configuration
|
|
82
|
+
return unless config.monthly_budget && config.on_budget_exceeded
|
|
83
|
+
return unless event[:cost]
|
|
84
|
+
|
|
85
|
+
monthly_total = calculate_monthly_total(event[:cost][:total_cost])
|
|
86
|
+
return unless monthly_total > config.monthly_budget
|
|
87
|
+
|
|
88
|
+
config.on_budget_exceeded.call(
|
|
89
|
+
monthly_total: monthly_total,
|
|
90
|
+
budget: config.monthly_budget,
|
|
91
|
+
last_event: event
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def calculate_monthly_total(latest_cost)
|
|
96
|
+
# For :active_record backend, query the DB
|
|
97
|
+
if LlmCostTracker.configuration.active_record? &&
|
|
98
|
+
defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
99
|
+
LlmCostTracker::Storage::ActiveRecordStore.monthly_total + latest_cost
|
|
100
|
+
else
|
|
101
|
+
# For other backends, we can only report the latest cost
|
|
102
|
+
latest_cost
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/notifications"
|
|
5
|
+
|
|
6
|
+
require_relative "llm_cost_tracker/version"
|
|
7
|
+
require_relative "llm_cost_tracker/configuration"
|
|
8
|
+
require_relative "llm_cost_tracker/pricing"
|
|
9
|
+
require_relative "llm_cost_tracker/parsers/base"
|
|
10
|
+
require_relative "llm_cost_tracker/parsers/openai"
|
|
11
|
+
require_relative "llm_cost_tracker/parsers/anthropic"
|
|
12
|
+
require_relative "llm_cost_tracker/parsers/gemini"
|
|
13
|
+
require_relative "llm_cost_tracker/parsers/registry"
|
|
14
|
+
require_relative "llm_cost_tracker/middleware/faraday"
|
|
15
|
+
require_relative "llm_cost_tracker/tracker"
|
|
16
|
+
|
|
17
|
+
module LlmCostTracker
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
attr_writer :configuration
|
|
22
|
+
|
|
23
|
+
def configuration
|
|
24
|
+
@configuration ||= Configuration.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def configure
|
|
28
|
+
yield(configuration)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_configuration!
|
|
32
|
+
@configuration = Configuration.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Manual tracking for non-Faraday clients
|
|
36
|
+
#
|
|
37
|
+
# LlmCostTracker.track(
|
|
38
|
+
# provider: :openai,
|
|
39
|
+
# model: "gpt-4o",
|
|
40
|
+
# input_tokens: 150,
|
|
41
|
+
# output_tokens: 50,
|
|
42
|
+
# feature: "chat",
|
|
43
|
+
# user_id: current_user.id
|
|
44
|
+
# )
|
|
45
|
+
def track(provider:, model:, input_tokens:, output_tokens:, **metadata)
|
|
46
|
+
Tracker.record(
|
|
47
|
+
provider: provider.to_s,
|
|
48
|
+
model: model,
|
|
49
|
+
input_tokens: input_tokens,
|
|
50
|
+
output_tokens: output_tokens,
|
|
51
|
+
metadata: metadata
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Load Railtie if Rails is present
|
|
58
|
+
require_relative "llm_cost_tracker/railtie" if defined?(Rails::Railtie)
|
|
59
|
+
|
|
60
|
+
# Auto-register Faraday middleware
|
|
61
|
+
if defined?(Faraday)
|
|
62
|
+
Faraday::Middleware.register_middleware(
|
|
63
|
+
llm_cost_tracker: LlmCostTracker::Middleware::Faraday
|
|
64
|
+
)
|
|
65
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
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 = "Provider-agnostic LLM API cost tracking for Ruby"
|
|
12
|
+
spec.description = "Automatically tracks token usage and costs for LLM API calls (OpenAI, Anthropic, Google Gemini, and more). " \
|
|
13
|
+
"Works as Faraday middleware — plugs into any Ruby HTTP client. " \
|
|
14
|
+
"Provides ActiveRecord storage, per-user/per-feature attribution, and budget alerts."
|
|
15
|
+
spec.homepage = "https://github.com/sergey-homenko/llm_cost_tracker"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
|
|
18
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
19
|
+
|
|
20
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
22
|
+
|
|
23
|
+
spec.files = Dir.chdir(__dir__) do
|
|
24
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
25
|
+
(File.expand_path(f) == __FILE__) ||
|
|
26
|
+
f.start_with?("bin/", "test/", "spec/", ".git", ".github", "Gemfile")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
spec.require_paths = ["lib"]
|
|
31
|
+
|
|
32
|
+
spec.add_dependency "faraday", ">= 1.0", "< 3.0"
|
|
33
|
+
spec.add_dependency "activesupport", ">= 7.0", "< 9.0"
|
|
34
|
+
|
|
35
|
+
spec.add_development_dependency "activerecord", ">= 7.0", "< 9.0"
|
|
36
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
37
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
|
38
|
+
spec.add_development_dependency "webmock", "~> 3.0"
|
|
39
|
+
spec.add_development_dependency "sqlite3", "~> 2.0"
|
|
40
|
+
spec.add_development_dependency "rubocop", "~> 1.0"
|
|
41
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: llm_cost_tracker
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sergii Khomenko
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-16 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.0'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '3.0'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '1.0'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.0'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: activesupport
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.0'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '9.0'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '7.0'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '9.0'
|
|
53
|
+
- !ruby/object:Gem::Dependency
|
|
54
|
+
name: activerecord
|
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '7.0'
|
|
60
|
+
- - "<"
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '9.0'
|
|
63
|
+
type: :development
|
|
64
|
+
prerelease: false
|
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '7.0'
|
|
70
|
+
- - "<"
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '9.0'
|
|
73
|
+
- !ruby/object:Gem::Dependency
|
|
74
|
+
name: rake
|
|
75
|
+
requirement: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - "~>"
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '13.0'
|
|
80
|
+
type: :development
|
|
81
|
+
prerelease: false
|
|
82
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - "~>"
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '13.0'
|
|
87
|
+
- !ruby/object:Gem::Dependency
|
|
88
|
+
name: rspec
|
|
89
|
+
requirement: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - "~>"
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '3.0'
|
|
94
|
+
type: :development
|
|
95
|
+
prerelease: false
|
|
96
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - "~>"
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '3.0'
|
|
101
|
+
- !ruby/object:Gem::Dependency
|
|
102
|
+
name: webmock
|
|
103
|
+
requirement: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - "~>"
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '3.0'
|
|
108
|
+
type: :development
|
|
109
|
+
prerelease: false
|
|
110
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - "~>"
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: '3.0'
|
|
115
|
+
- !ruby/object:Gem::Dependency
|
|
116
|
+
name: sqlite3
|
|
117
|
+
requirement: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - "~>"
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '2.0'
|
|
122
|
+
type: :development
|
|
123
|
+
prerelease: false
|
|
124
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - "~>"
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: '2.0'
|
|
129
|
+
- !ruby/object:Gem::Dependency
|
|
130
|
+
name: rubocop
|
|
131
|
+
requirement: !ruby/object:Gem::Requirement
|
|
132
|
+
requirements:
|
|
133
|
+
- - "~>"
|
|
134
|
+
- !ruby/object:Gem::Version
|
|
135
|
+
version: '1.0'
|
|
136
|
+
type: :development
|
|
137
|
+
prerelease: false
|
|
138
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
139
|
+
requirements:
|
|
140
|
+
- - "~>"
|
|
141
|
+
- !ruby/object:Gem::Version
|
|
142
|
+
version: '1.0'
|
|
143
|
+
description: Automatically tracks token usage and costs for LLM API calls (OpenAI,
|
|
144
|
+
Anthropic, Google Gemini, and more). Works as Faraday middleware — plugs into any
|
|
145
|
+
Ruby HTTP client. Provides ActiveRecord storage, per-user/per-feature attribution,
|
|
146
|
+
and budget alerts.
|
|
147
|
+
email:
|
|
148
|
+
- sergey@mm.st
|
|
149
|
+
executables: []
|
|
150
|
+
extensions: []
|
|
151
|
+
extra_rdoc_files: []
|
|
152
|
+
files:
|
|
153
|
+
- ".rspec"
|
|
154
|
+
- CHANGELOG.md
|
|
155
|
+
- LICENSE.txt
|
|
156
|
+
- README.md
|
|
157
|
+
- Rakefile
|
|
158
|
+
- lib/llm_cost_tracker.rb
|
|
159
|
+
- lib/llm_cost_tracker/configuration.rb
|
|
160
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
|
|
161
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb
|
|
162
|
+
- lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb
|
|
163
|
+
- lib/llm_cost_tracker/llm_api_call.rb
|
|
164
|
+
- lib/llm_cost_tracker/middleware/faraday.rb
|
|
165
|
+
- lib/llm_cost_tracker/parsers/anthropic.rb
|
|
166
|
+
- lib/llm_cost_tracker/parsers/base.rb
|
|
167
|
+
- lib/llm_cost_tracker/parsers/gemini.rb
|
|
168
|
+
- lib/llm_cost_tracker/parsers/openai.rb
|
|
169
|
+
- lib/llm_cost_tracker/parsers/registry.rb
|
|
170
|
+
- lib/llm_cost_tracker/pricing.rb
|
|
171
|
+
- lib/llm_cost_tracker/railtie.rb
|
|
172
|
+
- lib/llm_cost_tracker/storage/active_record_store.rb
|
|
173
|
+
- lib/llm_cost_tracker/tracker.rb
|
|
174
|
+
- lib/llm_cost_tracker/version.rb
|
|
175
|
+
- llm_cost_tracker.gemspec
|
|
176
|
+
homepage: https://github.com/sergey-homenko/llm_cost_tracker
|
|
177
|
+
licenses:
|
|
178
|
+
- MIT
|
|
179
|
+
metadata:
|
|
180
|
+
homepage_uri: https://github.com/sergey-homenko/llm_cost_tracker
|
|
181
|
+
changelog_uri: https://github.com/sergey-homenko/llm_cost_tracker/blob/main/CHANGELOG.md
|
|
182
|
+
post_install_message:
|
|
183
|
+
rdoc_options: []
|
|
184
|
+
require_paths:
|
|
185
|
+
- lib
|
|
186
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
187
|
+
requirements:
|
|
188
|
+
- - ">="
|
|
189
|
+
- !ruby/object:Gem::Version
|
|
190
|
+
version: 3.1.0
|
|
191
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
192
|
+
requirements:
|
|
193
|
+
- - ">="
|
|
194
|
+
- !ruby/object:Gem::Version
|
|
195
|
+
version: '0'
|
|
196
|
+
requirements: []
|
|
197
|
+
rubygems_version: 3.5.9
|
|
198
|
+
signing_key:
|
|
199
|
+
specification_version: 4
|
|
200
|
+
summary: Provider-agnostic LLM API cost tracking for Ruby
|
|
201
|
+
test_files: []
|