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 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
@@ -0,0 +1,3 @@
1
+ --require spec_helper
2
+ --format documentation
3
+ --color
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
+ [![Gem Version](https://badge.fury.io/rb/llm_cost_tracker.svg)](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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ VERSION = "0.1.0"
5
+ 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: []