llm_cost_tracker 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +44 -0
  3. data/CHANGELOG.md +62 -0
  4. data/README.md +243 -26
  5. data/Rakefile +3 -1
  6. data/lib/llm_cost_tracker/budget.rb +97 -0
  7. data/lib/llm_cost_tracker/configuration.rb +37 -0
  8. data/lib/llm_cost_tracker/errors.rb +37 -0
  9. data/lib/llm_cost_tracker/event_metadata.rb +54 -0
  10. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +29 -0
  11. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +9 -0
  12. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +16 -4
  13. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -1
  14. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +15 -0
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +41 -0
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +29 -0
  17. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +29 -0
  18. data/lib/llm_cost_tracker/llm_api_call.rb +69 -1
  19. data/lib/llm_cost_tracker/middleware/faraday.rb +51 -14
  20. data/lib/llm_cost_tracker/parsers/anthropic.rb +10 -5
  21. data/lib/llm_cost_tracker/parsers/gemini.rb +13 -5
  22. data/lib/llm_cost_tracker/parsers/openai.rb +22 -7
  23. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +44 -0
  24. data/lib/llm_cost_tracker/parsers/registry.rb +16 -7
  25. data/lib/llm_cost_tracker/price_registry.rb +69 -0
  26. data/lib/llm_cost_tracker/prices.json +51 -0
  27. data/lib/llm_cost_tracker/pricing.rb +76 -41
  28. data/lib/llm_cost_tracker/railtie.rb +3 -0
  29. data/lib/llm_cost_tracker/storage/active_record_store.rb +24 -3
  30. data/lib/llm_cost_tracker/tracker.rb +65 -33
  31. data/lib/llm_cost_tracker/unknown_pricing.rb +47 -0
  32. data/lib/llm_cost_tracker/version.rb +1 -1
  33. data/lib/llm_cost_tracker.rb +33 -5
  34. data/llm_cost_tracker.gemspec +9 -7
  35. metadata +38 -23
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16c6c4c230300b2caebc69e1f0aeb9a7af232278a6e77401aba0d490bb16b4a2
4
- data.tar.gz: 4b32fb25d22c645e4d66767264130bc44ce730e553636cefca8e4884dede7b94
3
+ metadata.gz: 6a014d7c3de26b91ba6a0b99d300a803d04ab8a95c55b374377d6b8cdf631e50
4
+ data.tar.gz: 7e042cf740a65c1019d0ee986eeec9fc2266a1ec59c55d4807f306521f95f869
5
5
  SHA512:
6
- metadata.gz: f403ebeeb6a98164bc2318b9f3b8f49e03750fd5c5d328ac0b0f3c0557f16c39d8f247aa663bdd15b605df779be7d4b4db7445fb79d10eb4c89ef17a687a77d7
7
- data.tar.gz: fd04a24708901e998127d6582290da90b35f62f7a36d794805a4677656be9191eed14235b634b72924ac4178837aa9dbace3a57fde18829d47b6a8208e295af2
6
+ metadata.gz: 1c41d7a9002fb484df80b6d3e5c7ce1fc14a3d481443f0bbefe1c74a4e2a0f92a039f56677a7a354dbfaeaaae6b5d4727bb5ca9466b06b77d574d26efd477fdb
7
+ data.tar.gz: d8017bbf7975f5bafbc4328dc8df76a614bc5e7700bb14569cead5ffc06aac6a4828707a9a609b9b7af69e4b7e3a08543712a750ed79d7b125ab2c96336cefa1
data/.rubocop.yml ADDED
@@ -0,0 +1,44 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 3.1
4
+ SuggestExtensions: false
5
+ UseCache: false
6
+ Exclude:
7
+ - "tmp/**/*"
8
+ - "vendor/**/*"
9
+ - "pkg/**/*"
10
+
11
+ Style/Documentation:
12
+ Enabled: false
13
+
14
+ Style/StringLiterals:
15
+ EnforcedStyle: double_quotes
16
+
17
+ Metrics/BlockLength:
18
+ Exclude:
19
+ - "*.gemspec"
20
+ - "spec/**/*.rb"
21
+
22
+ Metrics/MethodLength:
23
+ Max: 25
24
+
25
+ Metrics/AbcSize:
26
+ Max: 45
27
+
28
+ Metrics/ClassLength:
29
+ Max: 130
30
+
31
+ Metrics/CyclomaticComplexity:
32
+ Max: 10
33
+
34
+ Metrics/ParameterLists:
35
+ Max: 6
36
+
37
+ Metrics/PerceivedComplexity:
38
+ Max: 10
39
+
40
+ Gemspec/DevelopmentDependencies:
41
+ Enabled: false
42
+
43
+ Layout/HashAlignment:
44
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.2] - 2026-04-18
9
+
10
+ ### Added
11
+
12
+ - Auto-detect OpenRouter and DeepSeek as OpenAI-compatible providers.
13
+ - Add `openai_compatible_providers` configuration for private OpenAI-compatible gateways.
14
+ - Add `BudgetExceededError` and `budget_exceeded_behavior` for best-effort budget guardrails.
15
+ - Add `:raise` and `:block_requests` budget behaviors; `:block_requests` is not a hard cap under concurrency.
16
+ - Add `StorageError` and `storage_error_behavior` so storage failures do not have to break host LLM calls.
17
+ - Add `UnknownPricingError` and `unknown_pricing_behavior` for unknown model pricing.
18
+ - Add built-in `prices.json` registry with metadata and source URLs.
19
+ - Add `prices_file` configuration for local JSON/YAML pricing overrides.
20
+ - Add `with_cost`, `without_cost`, and `unknown_pricing` ActiveRecord scopes.
21
+ - Add `latency_ms` tracking for Faraday calls, manual tracking, notifications, and ActiveRecord storage.
22
+ - Add `with_latency`, `average_latency_ms`, `latency_by_model`, and `latency_by_provider`.
23
+ - Use PostgreSQL `jsonb` storage for tags in newly generated migrations.
24
+ - Add a GIN index on `llm_api_calls.tags` for PostgreSQL installs.
25
+ - Add adapter-aware `by_tag` querying with JSONB containment on PostgreSQL and text fallback elsewhere.
26
+ - Add `by_tags`, `by_user`, and `by_feature` scopes for common attribution queries.
27
+ - Add `llm_cost_tracker:upgrade_tags_to_jsonb` generator for existing PostgreSQL installs.
28
+ - Add `llm_cost_tracker:upgrade_cost_precision` generator for widening stored cost columns.
29
+ - Add `llm_cost_tracker:add_latency_ms` generator for existing installs.
30
+
31
+ ### Changed
32
+
33
+ - Store tags as a Hash for JSON-backed columns and as JSON text for fallback columns.
34
+ - Keep internal usage metadata such as cache token counts out of stored attribution tags.
35
+ - Normalize provider-prefixed model IDs like `openai/gpt-4o-mini` for built-in price lookup.
36
+ - Normalize configured OpenAI-compatible host keys to lowercase after configuration.
37
+ - Avoid double fuzzy-match passes during price lookup.
38
+ - Widen generated cost decimal columns to `precision: 20, scale: 8`.
39
+ - Count Gemini `thoughtsTokenCount` as output tokens for better thinking-mode cost estimates.
40
+ - Warn when Faraday exposes an unreadable streaming/SSE response body.
41
+ - Document tag storage behavior, budget guardrail limits, known limitations, common tag scopes, and upgrade flows.
42
+ - Clarify that budget errors raised after a response occur after the event has been recorded.
43
+ - Route custom storage exceptions that inherit from `LlmCostTracker::Error` through `storage_error_behavior`.
44
+
45
+ ## [0.1.1] - 2026-04-17
46
+
47
+ ### Fixed
48
+
49
+ - Lazy-load ActiveRecord storage so `storage_backend = :active_record` persists events reliably.
50
+ - Avoid double-counting the latest ActiveRecord event in monthly budget callbacks.
51
+ - Track OpenAI Responses API usage via `/v1/responses`.
52
+ - Parse OpenAI cached input token details for cache-aware cost estimates.
53
+ - Parse Anthropic cache read and cache creation token usage under canonical metadata keys.
54
+ - Parse Gemini cached content token usage when present.
55
+ - Store ActiveRecord tag values as strings so `by_tag("user_id", "42")` works for numeric IDs.
56
+
57
+ ### Changed
58
+
59
+ - Refresh built-in pricing for current OpenAI, Anthropic, and Gemini models.
60
+ - Add cache-aware cost calculation fields for cached input, cache reads, and cache creation.
61
+ - Tighten OpenAI URL matching to supported endpoint families only.
62
+ - Reposition README around self-hosted Rails/Ruby cost tracking for Faraday-based clients.
63
+
64
+ ### Added
65
+
66
+ - Add ActiveRecord integration specs for persistence, tag querying, and budget callbacks.
67
+ - Add RuboCop configuration, rake task, and CI lint step.
68
+ - Require MFA metadata for RubyGems publishing.
69
+
8
70
  ## [0.1.0] - 2026-04-16
9
71
 
10
72
  ### Added
data/README.md CHANGED
@@ -1,21 +1,27 @@
1
1
  # LlmCostTracker
2
2
 
3
- **Provider-agnostic LLM API cost tracking for Ruby.**
3
+ **Self-hosted LLM API cost tracking for Ruby and Rails apps.**
4
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.
5
+ Track, attribute, and enforce AI costs for OpenAI, Anthropic, Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible calls from Faraday-based Ruby clients. Store the data in your own database, tag calls by user or feature, and get budget alerts without adding an external SaaS or proxy.
6
6
 
7
7
  [![Gem Version](https://badge.fury.io/rb/llm_cost_tracker.svg)](https://rubygems.org/gems/llm_cost_tracker)
8
+ [![CI](https://github.com/sergey-homenko/llm_cost_tracker/actions/workflows/ruby.yml/badge.svg)](https://github.com/sergey-homenko/llm_cost_tracker/actions)
8
9
 
9
10
  ## Why?
10
11
 
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
+ Every Rails app integrating LLMs faces the same problem: **you don't know how much AI is costing you** until the invoice arrives. Full observability platforms like Langfuse and Helicone are powerful, but sometimes you just need a small Rails-native cost ledger that lives in your app database.
12
13
 
13
14
  `llm_cost_tracker` takes a different approach:
14
15
 
15
- - 🔌 **Provider-agnostic** — intercepts HTTP responses at the Faraday level
16
+ - 🔌 **Faraday-native** — intercepts LLM HTTP responses without changing the response
16
17
  - 🏠 **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
18
+ - 🧩 **Client-light** — works with raw Faraday and LLM gems that expose their Faraday connection
19
+ - 🏷️ **Attribution-first** — tag spend by feature, tenant, user, job, or environment
20
+ - 🌐 **OpenAI-compatible** — auto-detect OpenRouter and DeepSeek, with custom compatible hosts configurable
21
+ - 🛑 **Budget guardrails** — notify, raise, or block requests when monthly spend is exhausted
22
+ - 💸 **Budget-aware** — emit notifications and callbacks before spend surprises you
23
+
24
+ This gem is intentionally not a tracing platform, prompt CMS, eval system, or gateway. It focuses on the boring but valuable question: "What did this app spend on LLM APIs, and where did that spend come from?"
19
25
 
20
26
  ## Installation
21
27
 
@@ -34,9 +40,9 @@ bin/rails db:migrate
34
40
 
35
41
  ## Quick Start
36
42
 
37
- ### Option 1: Faraday Middleware (automatic)
43
+ ### Option 1: Faraday Middleware
38
44
 
39
- If your LLM client uses Faraday (most do), just add the middleware:
45
+ If your LLM client uses Faraday, add the middleware to that connection:
40
46
 
41
47
  ```ruby
42
48
  conn = Faraday.new(url: "https://api.openai.com") do |f|
@@ -46,16 +52,16 @@ conn = Faraday.new(url: "https://api.openai.com") do |f|
46
52
  f.adapter Faraday.default_adapter
47
53
  end
48
54
 
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!" }]
55
+ # Every supported LLM request through this connection is tracked
56
+ response = conn.post("/v1/responses", {
57
+ model: "gpt-5-mini",
58
+ input: "Hello!"
53
59
  })
54
60
  ```
55
61
 
56
62
  ### Option 2: Patch an existing client
57
63
 
58
- Most LLM gems expose their Faraday connection. For example, with `ruby-openai`:
64
+ Some LLM gems expose their Faraday connection. For example, with `ruby-openai`:
59
65
 
60
66
  ```ruby
61
67
  # config/initializers/openai.rb
@@ -68,6 +74,8 @@ OpenAI.configure do |config|
68
74
  end
69
75
  ```
70
76
 
77
+ If a client does not expose its HTTP connection, use manual tracking or register a custom parser around the HTTP layer you control.
78
+
71
79
  ### Option 3: Manual tracking
72
80
 
73
81
  For non-Faraday clients, track manually:
@@ -78,6 +86,7 @@ LlmCostTracker.track(
78
86
  model: "claude-sonnet-4-6",
79
87
  input_tokens: 1500,
80
88
  output_tokens: 320,
89
+ cache_read_input_tokens: 1200,
81
90
  feature: "summarizer",
82
91
  user_id: current_user.id
83
92
  )
@@ -96,6 +105,9 @@ LlmCostTracker.configure do |config|
96
105
 
97
106
  # Monthly budget in USD
98
107
  config.monthly_budget = 500.00
108
+ config.budget_exceeded_behavior = :notify # :notify, :raise, or :block_requests
109
+ config.storage_error_behavior = :warn # :ignore, :warn, or :raise
110
+ config.unknown_pricing_behavior = :warn # :ignore, :warn, or :raise
99
111
 
100
112
  # Alert callback
101
113
  config.on_budget_exceeded = ->(data) {
@@ -106,12 +118,103 @@ LlmCostTracker.configure do |config|
106
118
  }
107
119
 
108
120
  # Override pricing for custom/fine-tuned models (per 1M tokens)
121
+ config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.json")
109
122
  config.pricing_overrides = {
110
- "ft:gpt-4o-mini:my-org" => { input: 0.30, output: 1.20 }
123
+ "ft:gpt-4o-mini:my-org" => { input: 0.30, cached_input: 0.15, output: 1.20 }
124
+ }
125
+
126
+ # OpenAI-compatible APIs. OpenRouter and DeepSeek are included by default.
127
+ config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
128
+ end
129
+ ```
130
+
131
+ Pricing is best-effort and based on public provider pricing for standard token usage. Providers change pricing frequently, and some features have extra charges or tiered pricing. OpenRouter-style model IDs such as `openai/gpt-4o-mini` are normalized to built-in model names when possible. Use `prices_file` or `pricing_overrides` for fine-tunes, gateway-specific model IDs, enterprise discounts, batch pricing, long-context premiums, and any model this gem does not know yet.
132
+
133
+ Storage errors are non-fatal by default:
134
+
135
+ ```ruby
136
+ config.storage_error_behavior = :warn # default
137
+ config.storage_error_behavior = :raise # fail fast with StorageError
138
+ config.storage_error_behavior = :ignore # skip storage failures silently
139
+ ```
140
+
141
+ With the default `:warn` behavior, tracking emits a warning and lets the LLM response continue if ActiveRecord or custom storage fails. `LlmCostTracker::StorageError` exposes `original_error` when `:raise` is enabled.
142
+
143
+ Unknown model pricing is visible by default:
144
+
145
+ ```ruby
146
+ config.unknown_pricing_behavior = :warn # default
147
+ config.unknown_pricing_behavior = :raise # fail fast with UnknownPricingError
148
+ config.unknown_pricing_behavior = :ignore # keep tracking tokens silently
149
+ ```
150
+
151
+ When pricing is unknown, the event can still be recorded with token counts, but `cost` is `nil` and budget enforcement is skipped for that event. Use this ActiveRecord query to find the gaps:
152
+
153
+ ```ruby
154
+ LlmCostTracker::LlmApiCall.unknown_pricing.group(:model).count
155
+ ```
156
+
157
+ ### Keeping Prices Current
158
+
159
+ Built-in prices live in `lib/llm_cost_tracker/prices.json`, with `updated_at`, `unit`, `currency`, and source URLs in the file metadata. The gem does not fetch pricing on boot; that keeps it self-hosted and avoids hidden external dependencies.
160
+
161
+ For production apps, keep a local JSON or YAML price file and point the gem at it:
162
+
163
+ ```ruby
164
+ config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.json")
165
+ ```
166
+
167
+ Example JSON:
168
+
169
+ ```json
170
+ {
171
+ "metadata": {
172
+ "updated_at": "2026-04-18",
173
+ "currency": "USD",
174
+ "unit": "1M tokens"
175
+ },
176
+ "models": {
177
+ "my-gateway/gpt-4o-mini": {
178
+ "input": 0.20,
179
+ "cached_input": 0.10,
180
+ "output": 0.80
181
+ }
111
182
  }
183
+ }
184
+ ```
185
+
186
+ `pricing_overrides` still has the highest precedence, so you can use it for small Ruby-only overrides and keep broader provider tables in the file. A practical release rhythm is to refresh built-in `prices.json` quarterly and use `prices_file` for urgent provider changes between gem releases.
187
+
188
+ ## Budget Enforcement
189
+
190
+ ```ruby
191
+ LlmCostTracker.configure do |config|
192
+ config.storage_backend = :active_record
193
+ config.monthly_budget = 100.00
194
+ config.budget_exceeded_behavior = :block_requests
112
195
  end
113
196
  ```
114
197
 
198
+ Budget behavior options:
199
+
200
+ - `:notify` — default. Calls `on_budget_exceeded` after a tracked event pushes the month over budget.
201
+ - `:raise` — records the event, then raises `LlmCostTracker::BudgetExceededError` when the month is over budget.
202
+ - `:block_requests` — blocks Faraday LLM requests before the HTTP call when the ActiveRecord monthly total has already reached the budget. If a request pushes the month over budget, it also raises after recording the event.
203
+
204
+ `BudgetExceededError` exposes `monthly_total`, `budget`, and `last_event`:
205
+
206
+ ```ruby
207
+ begin
208
+ client.chat(...)
209
+ rescue LlmCostTracker::BudgetExceededError => e
210
+ Rails.logger.warn("LLM budget exhausted: #{e.monthly_total} / #{e.budget}")
211
+ end
212
+ ```
213
+
214
+ Pre-request blocking needs `storage_backend = :active_record` because the middleware must query your stored monthly total before sending the request. With `:log` or `:custom` storage, `:raise` and the post-response part of `:block_requests` still work for the event being tracked.
215
+
216
+ `:block_requests` is a best-effort guardrail, not a transactional hard quota. In highly concurrent deployments, multiple workers can pass the preflight check at the same time before any of them records its final cost. The request that first pushes the month over budget is stored before the post-response `BudgetExceededError` is raised; later Faraday requests are blocked during preflight once the stored monthly total is exhausted. Use provider-side limits or a gateway-level quota if you need strict cross-process enforcement.
217
+
115
218
  ## Querying Costs (ActiveRecord)
116
219
 
117
220
  ```ruby
@@ -131,16 +234,65 @@ LlmCostTracker::LlmApiCall.this_month.cost_by_provider
131
234
  LlmCostTracker::LlmApiCall.daily_costs(days: 7)
132
235
  # => { "2026-04-10" => 1.5, "2026-04-11" => 2.3, ... }
133
236
 
237
+ # Latency overview
238
+ LlmCostTracker::LlmApiCall.with_latency.average_latency_ms
239
+ LlmCostTracker::LlmApiCall.this_month.latency_by_model
240
+
134
241
  # Filter by feature
135
242
  LlmCostTracker::LlmApiCall.by_tag("feature", "chat").this_month.total_cost
136
243
 
137
244
  # Filter by user
138
245
  LlmCostTracker::LlmApiCall.by_tag("user_id", "42").today.total_cost
246
+ LlmCostTracker::LlmApiCall.by_user(42).today.total_cost
247
+
248
+ # Filter by multiple tags
249
+ LlmCostTracker::LlmApiCall.by_tags(user_id: 42, feature: "chat").this_month.total_cost
250
+
251
+ # Feature shortcut
252
+ LlmCostTracker::LlmApiCall.by_feature("summarizer").this_month.total_cost
253
+
254
+ # Find models without pricing
255
+ LlmCostTracker::LlmApiCall.unknown_pricing.group(:model).count
256
+ LlmCostTracker::LlmApiCall.with_cost.this_month.total_cost
139
257
 
140
258
  # Custom date range
141
259
  LlmCostTracker::LlmApiCall.between(1.week.ago, Time.current).cost_by_model
142
260
  ```
143
261
 
262
+ ### Tag Storage
263
+
264
+ The install generator uses `jsonb` tags with a GIN index on PostgreSQL:
265
+
266
+ ```ruby
267
+ t.jsonb :tags, null: false, default: {}
268
+ add_index :llm_api_calls, :tags, using: :gin
269
+ ```
270
+
271
+ On SQLite and other adapters, tags fall back to JSON stored in a text column. The `by_tag` scope automatically uses PostgreSQL JSONB containment when the column supports it, and the text fallback otherwise.
272
+
273
+ If you installed `llm_cost_tracker` before JSONB tags were available and your app uses PostgreSQL, generate an upgrade migration:
274
+
275
+ ```bash
276
+ bin/rails generate llm_cost_tracker:upgrade_tags_to_jsonb
277
+ bin/rails db:migrate
278
+ ```
279
+
280
+ This converts the existing `tags` text column to `jsonb`, keeps existing tag data, and adds the GIN index.
281
+
282
+ If you installed an earlier version with `precision: 12, scale: 8` cost columns, widen them for larger production ledgers:
283
+
284
+ ```bash
285
+ bin/rails generate llm_cost_tracker:upgrade_cost_precision
286
+ bin/rails db:migrate
287
+ ```
288
+
289
+ If you installed before `latency_ms` was available, add the latency column:
290
+
291
+ ```bash
292
+ bin/rails generate llm_cost_tracker:add_latency_ms
293
+ bin/rails db:migrate
294
+ ```
295
+
144
296
  ## ActiveSupport::Notifications
145
297
 
146
298
  Every tracked call emits an `llm_request.llm_cost_tracker` event:
@@ -154,7 +306,16 @@ ActiveSupport::Notifications.subscribe("llm_request.llm_cost_tracker") do |*, pa
154
306
  # input_tokens: 150,
155
307
  # output_tokens: 42,
156
308
  # total_tokens: 192,
157
- # cost: { input_cost: 0.000375, output_cost: 0.00042, total_cost: 0.000795, currency: "USD" },
309
+ # latency_ms: 248,
310
+ # cost: {
311
+ # input_cost: 0.000375,
312
+ # cached_input_cost: 0.0,
313
+ # cache_read_input_cost: 0.0,
314
+ # cache_creation_input_cost: 0.0,
315
+ # output_cost: 0.00042,
316
+ # total_cost: 0.000795,
317
+ # currency: "USD"
318
+ # },
158
319
  # tags: { feature: "chat", user_id: 42 },
159
320
  # tracked_at: 2026-04-16 14:30:00 UTC
160
321
  # }
@@ -171,19 +332,62 @@ LlmCostTracker.configure do |config|
171
332
  config.storage_backend = :custom
172
333
  config.custom_storage = ->(event) {
173
334
  InfluxDB.write("llm_costs", {
174
- values: { cost: event[:cost][:total_cost], tokens: event[:total_tokens] },
335
+ values: {
336
+ cost: event[:cost]&.fetch(:total_cost, nil),
337
+ tokens: event[:total_tokens],
338
+ latency_ms: event[:latency_ms]
339
+ },
175
340
  tags: { provider: event[:provider], model: event[:model] }
176
341
  })
177
342
  }
178
343
  end
179
344
  ```
180
345
 
346
+ ## OpenAI-Compatible Providers
347
+
348
+ ```ruby
349
+ LlmCostTracker.configure do |config|
350
+ # Built in:
351
+ # "openrouter.ai" => "openrouter"
352
+ # "api.deepseek.com" => "deepseek"
353
+ config.openai_compatible_providers["gateway.example.com"] = "internal_gateway"
354
+ end
355
+ ```
356
+
357
+ Any configured host is parsed with the OpenAI-compatible usage shape:
358
+
359
+ - `prompt_tokens` / `completion_tokens` / `total_tokens`
360
+ - `input_tokens` / `output_tokens` / `total_tokens`
361
+ - optional cached input details when the response includes them
362
+
363
+ This covers OpenRouter, DeepSeek, and private gateways that expose OpenAI-style Chat Completions, Responses, Completions, or Embeddings endpoints.
364
+
365
+ ## Production Checklist
366
+
367
+ - Use `storage_backend = :active_record` in production.
368
+ - Set `monthly_budget` and choose `budget_exceeded_behavior`.
369
+ - Treat `:block_requests` as best-effort in concurrent systems, not a strict quota.
370
+ - Keep `unknown_pricing_behavior = :warn` or `:raise` until pricing overrides are complete.
371
+ - Add `pricing_overrides` for custom, fine-tuned, gateway-specific, or newly released models.
372
+ - Tag calls with `tenant_id`, `user_id`, and `feature` where possible.
373
+ - Check `LlmCostTracker::LlmApiCall.unknown_pricing.group(:model).count` after deploys.
374
+ - Track `latency_ms` and watch `latency_by_model` for slow or degraded providers.
375
+
376
+ ## Known Limitations
377
+
378
+ - `:block_requests` is best-effort under concurrency. For hard caps, use an external quota system, provider-side limits, or a gateway-level budget.
379
+ - Streaming/SSE calls are tracked only when Faraday exposes a final response body with usage data. Otherwise the gem warns and skips automatic tracking.
380
+ - Anthropic cache creation TTL variants are not modeled separately yet; 1-hour cache writes may be underestimated compared with the default 5-minute cache write rate.
381
+ - OpenAI reasoning tokens are included in output-token totals when providers report them that way, but separate reasoning-token attribution is not stored yet.
382
+
181
383
  ## Adding a Custom Provider Parser
182
384
 
385
+ Use this for providers that are not OpenAI-compatible and return a different usage shape.
386
+
183
387
  ```ruby
184
- class DeepSeekParser < LlmCostTracker::Parsers::Base
388
+ class AcmeParser < LlmCostTracker::Parsers::Base
185
389
  def match?(url)
186
- url.to_s.include?("api.deepseek.com")
390
+ url.to_s.include?("api.acme-llm.example")
187
391
  end
188
392
 
189
393
  def parse(request_url, request_body, response_status, response_body)
@@ -194,27 +398,37 @@ class DeepSeekParser < LlmCostTracker::Parsers::Base
194
398
  return nil unless usage
195
399
 
196
400
  {
197
- provider: "deepseek",
401
+ provider: "acme",
198
402
  model: response["model"],
199
- input_tokens: usage["prompt_tokens"] || 0,
200
- output_tokens: usage["completion_tokens"] || 0
403
+ input_tokens: usage["input"] || 0,
404
+ output_tokens: usage["output"] || 0
201
405
  }
202
406
  end
203
407
  end
204
408
 
205
409
  # Register it
206
- LlmCostTracker::Parsers::Registry.register(DeepSeekParser.new)
410
+ LlmCostTracker::Parsers::Registry.register(AcmeParser.new)
207
411
  ```
208
412
 
209
413
  ## Supported Providers
210
414
 
211
415
  | Provider | Auto-detected | Models with pricing |
212
416
  |----------|:---:|---|
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 |
417
+ | OpenAI | ✅ | GPT-5.2/5.1/5, GPT-5 mini/nano, GPT-4.1, GPT-4o, o1/o3/o4-mini |
418
+ | OpenRouter | ✅ | Uses OpenAI-compatible usage; provider-prefixed OpenAI model IDs are normalized when possible |
419
+ | DeepSeek | ✅ | Uses OpenAI-compatible usage; add `pricing_overrides` for DeepSeek model pricing |
420
+ | OpenAI-compatible hosts | 🔧 | Configure `openai_compatible_providers` |
421
+ | Anthropic | ✅ | Claude Opus 4.6/4.1/4, Sonnet 4.6/4.5/4, Haiku 4.5, Claude 3.x |
422
+ | Google Gemini | ✅ | Gemini 2.5 Pro/Flash/Flash-Lite, 2.0 Flash/Flash-Lite, 1.5 Pro/Flash |
216
423
  | Any other | 🔧 | Via custom parser (see above) |
217
424
 
425
+ Supported endpoint families:
426
+
427
+ - OpenAI: Chat Completions, Responses, Completions, Embeddings
428
+ - OpenAI-compatible: Chat Completions, Responses, Completions, Embeddings
429
+ - Anthropic: Messages
430
+ - Google Gemini: `generateContent` responses with `usageMetadata`
431
+
218
432
  ## How It Works
219
433
 
220
434
  ```
@@ -228,7 +442,9 @@ Your App → Faraday → [LlmCostTracker Middleware] → LLM API
228
442
  ActiveRecord / Log / Custom
229
443
  ```
230
444
 
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.
445
+ The middleware intercepts **outgoing** HTTP responses (not incoming Rails requests), parses the provider usage object, looks up pricing, and records the event. It never modifies requests or responses. Put `llm_cost_tracker` inside the Faraday stack where it can see the final response body; if another middleware consumes or transforms streaming bodies, use manual tracking.
446
+
447
+ For streaming APIs, tracking depends on the final response body including provider usage data. If the client consumes server-sent events without exposing the final usage payload to Faraday, the gem logs a warning and skips tracking; use manual tracking for those calls.
232
448
 
233
449
  ## Development
234
450
 
@@ -237,6 +453,7 @@ git clone https://github.com/sergey-homenko/llm_cost_tracker.git
237
453
  cd llm_cost_tracker
238
454
  bundle install
239
455
  bundle exec rspec
456
+ bundle exec rubocop
240
457
  ```
241
458
 
242
459
  ## Contributing
data/Rakefile CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
5
6
 
6
7
  RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new(:rubocop)
7
9
 
8
- task default: :spec
10
+ task default: %i[spec rubocop]
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class Budget
5
+ class << self
6
+ WARNING_MUTEX = Mutex.new
7
+
8
+ def enforce!
9
+ return unless LlmCostTracker.configuration.monthly_budget
10
+ return unless behavior == :block_requests
11
+ return warn_non_active_record_block_requests unless LlmCostTracker.configuration.active_record?
12
+
13
+ monthly_total = calculate_monthly_total(0)
14
+ return unless monthly_total >= LlmCostTracker.configuration.monthly_budget
15
+
16
+ handle_exceeded(monthly_total: monthly_total)
17
+ end
18
+
19
+ def check!(event)
20
+ config = LlmCostTracker.configuration
21
+ return unless config.monthly_budget
22
+ return unless event[:cost]
23
+
24
+ monthly_total = calculate_monthly_total(event[:cost][:total_cost])
25
+ return unless monthly_total > config.monthly_budget
26
+
27
+ handle_exceeded(monthly_total: monthly_total, last_event: event)
28
+ end
29
+
30
+ private
31
+
32
+ def calculate_monthly_total(latest_cost)
33
+ if LlmCostTracker.configuration.active_record?
34
+ active_record_monthly_total
35
+ else
36
+ latest_cost
37
+ end
38
+ end
39
+
40
+ def active_record_monthly_total
41
+ require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
42
+ require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
43
+
44
+ LlmCostTracker::Storage::ActiveRecordStore.monthly_total
45
+ rescue LoadError => e
46
+ raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
47
+ end
48
+
49
+ def warn_non_active_record_block_requests
50
+ should_warn = WARNING_MUTEX.synchronize do
51
+ unless @warned_non_active_record_block_requests
52
+ @warned_non_active_record_block_requests = true
53
+ true
54
+ end
55
+ end
56
+ return unless should_warn
57
+
58
+ log_warning(":block_requests preflight requires storage_backend = :active_record; request was not blocked.")
59
+ end
60
+
61
+ def handle_exceeded(monthly_total:, last_event: nil)
62
+ config = LlmCostTracker.configuration
63
+ payload = {
64
+ monthly_total: monthly_total,
65
+ budget: config.monthly_budget,
66
+ last_event: last_event
67
+ }
68
+
69
+ config.on_budget_exceeded&.call(payload)
70
+ raise BudgetExceededError.new(**payload) if raise_on_exceeded?
71
+ end
72
+
73
+ def raise_on_exceeded?
74
+ %i[raise block_requests].include?(behavior)
75
+ end
76
+
77
+ def behavior
78
+ behavior = (LlmCostTracker.configuration.budget_exceeded_behavior || :notify).to_sym
79
+ return behavior if Configuration::BUDGET_EXCEEDED_BEHAVIORS.include?(behavior)
80
+
81
+ raise Error,
82
+ "Unknown budget_exceeded_behavior: #{behavior.inspect}. " \
83
+ "Use one of: #{Configuration::BUDGET_EXCEEDED_BEHAVIORS.join(', ')}"
84
+ end
85
+
86
+ def log_warning(message)
87
+ message = "[LlmCostTracker] #{message}"
88
+
89
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
90
+ Rails.logger.warn(message)
91
+ else
92
+ warn message
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end