llm_cost_tracker 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93ef8bc5c6bc0e850398b7555499a4667d1cc3d8ba2328c1fb926204a794a5a7
4
- data.tar.gz: e7208b7bf518332040837498b5de7d1e5e6c761a276d6fb732d14133d38d8c74
3
+ metadata.gz: b966913d302d5c5c3466615d1fa3983855c241f6cd9e3e26558c0fcc5fc4e7d5
4
+ data.tar.gz: 52804e702d5f01e5a4d247e8b50e601dede2b328bd7075c68ffd5f472b3b0d58
5
5
  SHA512:
6
- metadata.gz: 5d19b85e0a4398332a0161f75bc561b79c6ebf12546fe21013b12f2b7f5ff931179fcb8d5610faccd4d84063bf10f4297bd1688df04c24e41ffb63d4ff38b851
7
- data.tar.gz: e7b4f3a2164cc9f6e9545e123fe4aeabac356ab66becfc94466f8f25d54329ed5af4056339cfda1c71e20bcba1c4de30a9922ff3b7b5664528bde515834e19f1
6
+ metadata.gz: 609ba1a18be86dce0b567b2ea33b3f3123da88683f0c65d9aef780f2e4854d1dde6686adfa505fc154d13da6dd6cb2b31d9f38c303de5fb22f6fda65c7f44aa7
7
+ data.tar.gz: de372e0940b4cfc400dacfc6dbf9e00f256c6944209da8cceaadd20a318b8c7aa8982d5190e21d38640ad20d04cc86400a4e872ec640189796e045acf1f7dfad
data/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.3] - 2026-04-24
8
+
9
+ ### Added
10
+
11
+ - Monthly rollup totals for ActiveRecord budget checks, plus `llm_cost_tracker:add_monthly_totals` for upgrading existing installs.
12
+
13
+ ### Changed
14
+
15
+ - ActiveRecord monthly totals now update through a single atomic upsert.
16
+ - Faraday stream capture overflow now records `usage_source: "unknown"` instead of dropping the tracked event.
17
+ - Budget `:notify` callbacks now fire only on the first event that crosses the monthly limit.
18
+
19
+ ### Fixed
20
+
21
+ - Treat `config.enabled = false` as a global kill switch for direct `track` and `track_stream` calls too.
22
+ - Deduplicate unknown-pricing warnings per model.
23
+ - Detect streaming requests from parsed JSON instead of raw body substring matching.
24
+ - Cap automatic SSE capture to avoid unbounded memory growth on large streaming responses.
25
+ - Warn that the generated PostgreSQL `tags -> jsonb` upgrade migration rewrites large tables and should run in a maintenance window.
26
+
27
+ ## [0.3.2] - 2026-04-22
28
+
29
+ ### Added
30
+
31
+ - Test coverage reporting via SimpleCov with LCOV upload to Codecov from CI.
32
+ - Repository governance files: `CODE_OF_CONDUCT.md`, `SECURITY.md`, `CONTRIBUTING.md`, and GitHub issue templates.
33
+
7
34
  ## [0.3.1] - 2026-04-22
8
35
 
9
36
  ### Added
@@ -0,0 +1,23 @@
1
+ # Code of Conduct
2
+
3
+ This project adopts the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1, as its code of conduct. The full text is available at:
4
+
5
+ <https://www.contributor-covenant.org/version/2/1/code_of_conduct/>
6
+
7
+ ## Our pledge
8
+
9
+ We as contributors and maintainers pledge to make participation in this project a welcoming, respectful, and inclusive experience for everyone.
10
+
11
+ ## Scope
12
+
13
+ This Code of Conduct applies within all project spaces (issues, pull requests, discussions, commit messages, review comments) and in public spaces when an individual is representing the project.
14
+
15
+ ## Reporting
16
+
17
+ Instances of unacceptable behavior may be reported to the project maintainer at **sergey@mm.st**. All reports will be reviewed and investigated promptly and fairly. The maintainer is obligated to respect the privacy and safety of the reporter of any incident.
18
+
19
+ ## Enforcement
20
+
21
+ Maintainers are responsible for clarifying and enforcing the standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, or harmful. This may include warnings, temporary bans, or permanent removal from project participation.
22
+
23
+ For the full set of community standards, enforcement guidelines, and attribution, see the canonical document linked above.
data/README.md CHANGED
@@ -4,14 +4,70 @@
4
4
 
5
5
  [![Gem Version](https://img.shields.io/gem/v/llm_cost_tracker.svg)](https://rubygems.org/gems/llm_cost_tracker)
6
6
  [![CI](https://github.com/sergey-homenko/llm_cost_tracker/actions/workflows/ruby.yml/badge.svg)](https://github.com/sergey-homenko/llm_cost_tracker/actions)
7
+ [![codecov](https://codecov.io/gh/sergey-homenko/llm_cost_tracker/branch/main/graph/badge.svg)](https://codecov.io/gh/sergey-homenko/llm_cost_tracker)
8
+
9
+ Requires Ruby 3.3+, Rails/ActiveRecord 7.1+, and Faraday 2.0+.
10
+ Core tracking works without Rails; the mounted dashboard requires Rails 7.1+.
7
11
 
8
12
  ## Why
9
13
 
10
14
  Every Rails app with LLM integrations eventually runs into the same question: where did that invoice come from? Full observability platforms like Langfuse and Helicone solve a broader set of problems; sometimes you just need a small Rails-native ledger in your own database.
11
15
 
12
- `llm_cost_tracker` is built for that. It plugs into Faraday or lets you record usage explicitly with `track` / `track_stream`, looks up pricing locally, and writes an event. You end up with a ledger you can query with plain ActiveRecord, slice by any tag dimension, and optionally surface on a built-in dashboard. No proxy, no SaaS, no separate service to run.
16
+ ## What You Get
17
+
18
+ - A local ActiveRecord ledger of provider, model, tokens, cost, latency, tags, streaming usage, and provider response IDs
19
+ - Faraday middleware plus explicit `track` / `track_stream` helpers for non-Faraday clients
20
+ - Server-rendered Rails dashboard with overview, calls, tags, CSV export, and data-quality pages
21
+ - Local pricing snapshots, price sync tasks, and budget guardrails
22
+ - Prompt and response bodies are never persisted
23
+
24
+ ## Dashboard
25
+
26
+ LLM Cost Tracker ships with an optional server-rendered Rails Engine dashboard for spend review, attribution, and data quality checks.
27
+
28
+ ![LLM Cost Tracker dashboard](docs/dashboard-overview.png)
13
29
 
14
- It is not a tracing platform, prompt CMS, eval system, or gateway. The goal is to answer _"what did this app spend on LLM APIs, and where did that spend come from?"_ clearly enough to make spend review routine.
30
+ The overview page includes spend trend, budget status, provider breakdown, top models, and filterable slices. The engine also includes Calls, Tags, and Data Quality pages. Plain ERB, no JavaScript bundle.
31
+
32
+ ## Quickstart
33
+
34
+ ```ruby
35
+ gem "llm_cost_tracker"
36
+ ```
37
+
38
+ ```bash
39
+ bin/rails generate llm_cost_tracker:install
40
+ bin/rails db:migrate
41
+ ```
42
+
43
+ ```ruby
44
+ LlmCostTracker.configure do |config|
45
+ config.storage_backend = :active_record
46
+ config.default_tags = { app: "my_app", environment: Rails.env }
47
+ end
48
+
49
+ OpenAI.configure do |config|
50
+ config.access_token = ENV["OPENAI_API_KEY"]
51
+ config.faraday do |f|
52
+ f.use :llm_cost_tracker, tags: -> { { user_id: Current.user&.id, feature: "chat" } }
53
+ end
54
+ end
55
+ ```
56
+
57
+ ```ruby
58
+ mount LlmCostTracker::Engine => "/llm-costs"
59
+ ```
60
+
61
+ After that, LLM Cost Tracker starts recording calls into `llm_api_calls` and the dashboard becomes available at `/llm-costs`.
62
+ Protect the mounted engine with your application's authentication before exposing it outside development.
63
+
64
+ ## Tradeoffs
65
+
66
+ - Self-hosted ledger first: no proxy, no SaaS, no separate service to operate
67
+ - Best-effort pricing for spend review and attribution, not invoice-grade billing
68
+ - No prompt or response body storage
69
+ - No built-in auth on the mounted dashboard
70
+ - Use `:active_record` when you want shared dashboards and budget checks across Puma workers and Sidekiq processes
15
71
 
16
72
  ## Installation
17
73
 
@@ -287,13 +343,16 @@ On other adapters tags fall back to JSON in a text column. `by_tag` uses JSONB c
287
343
  Upgrade an existing install:
288
344
 
289
345
  ```bash
346
+ bin/rails generate llm_cost_tracker:add_monthly_totals # shared monthly budget rollups
290
347
  bin/rails generate llm_cost_tracker:upgrade_tags_to_jsonb # PG: text → jsonb + GIN
291
348
  bin/rails generate llm_cost_tracker:upgrade_cost_precision # widen cost columns
292
349
  bin/rails generate llm_cost_tracker:add_latency_ms
293
350
  bin/rails db:migrate
294
351
  ```
295
352
 
296
- ## Dashboard (optional)
353
+ On PostgreSQL, the generated `upgrade_tags_to_jsonb` migration rewrites `llm_api_calls`. Run it during a maintenance window on large tables, or replace it with a two-phase backfill for zero-downtime deploys.
354
+
355
+ ## Mounting the dashboard
297
356
 
298
357
  Optional Rails Engine. Plain ERB, no JavaScript framework, no asset pipeline required. Requires Rails 7.1+; the core middleware works without Rails.
299
358
 
@@ -315,7 +374,7 @@ Routes (GET-only; CSV export included):
315
374
  - `/llm-costs/tags/:key` — breakdown by values of a given tag key
316
375
  - `/llm-costs/data_quality` — unknown pricing share, untagged calls, missing latency
317
376
 
318
- > ⚠️ **No built-in auth.** Tags carry whatever your app puts in them. Protect the mount point with your application's authentication.
377
+ No built-in auth is included. Tags carry whatever your app puts in them, so protect the mount point with your application's authentication.
319
378
 
320
379
  ### Basic auth
321
380
 
@@ -381,20 +440,26 @@ Configured hosts are parsed using the OpenAI-compatible usage shape (`prompt_tok
381
440
  For providers with a non-OpenAI usage shape:
382
441
 
383
442
  ```ruby
443
+ require "uri"
444
+
384
445
  class AcmeParser < LlmCostTracker::Parsers::Base
385
446
  def match?(url)
386
- url.to_s.include?("api.acme-llm.example")
447
+ uri = URI.parse(url.to_s)
448
+ uri.host == "api.acme-llm.example" && uri.path == "/v1/generate"
449
+ rescue URI::InvalidURIError
450
+ false
387
451
  end
388
452
 
389
453
  def parse(request_url, request_body, response_status, response_body)
390
454
  return nil unless response_status == 200
391
455
 
392
- usage = safe_json_parse(response_body)&.dig("usage")
456
+ payload = safe_json_parse(response_body)
457
+ usage = payload&.dig("usage")
393
458
  return nil unless usage
394
459
 
395
460
  LlmCostTracker::ParsedUsage.build(
396
461
  provider: "acme",
397
- model: safe_json_parse(response_body)["model"],
462
+ model: payload["model"],
398
463
  input_tokens: usage["input"] || 0,
399
464
  output_tokens: usage["output"] || 0
400
465
  )
@@ -420,9 +485,12 @@ Endpoints: OpenAI Chat Completions / Responses / Completions / Embeddings; OpenA
420
485
 
421
486
  ## Safety
422
487
 
488
+ **By design, `llm_cost_tracker` never persists prompt or response content.** The only data stored per call is the metadata needed for a cost ledger (provider, model, token counts, cost, latency, tags, provider response ID, HTTP status, and a timestamp). Tags carry whatever your application passes in — treat them as user-controlled input and avoid putting request bodies, completions, or secrets into them.
489
+
423
490
  - No external HTTP calls at request-tracking time.
424
491
  - No prompt or response bodies stored.
425
492
  - Faraday responses not modified.
493
+ - Authorization headers and API keys are never stored or logged.
426
494
  - Storage failures non-fatal by default (`storage_error_behavior = :warn`).
427
495
  - Budget and unknown-pricing errors are raised only when you opt in.
428
496
 
data/SECURITY.md ADDED
@@ -0,0 +1,36 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ The gem is pre-1.0. Only the latest released version receives security fixes — please upgrade rather than expecting backports to older releases.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Please **do not open a public GitHub issue** for security reports.
10
+
11
+ Email **sergey@mm.st** with:
12
+
13
+ - A description of the issue and its potential impact
14
+ - Steps to reproduce (a minimal proof-of-concept is ideal)
15
+ - The affected version(s)
16
+ - Any suggested mitigation or fix
17
+
18
+ You will receive an acknowledgment within **72 hours**. I will work with you on a disclosure timeline — typically a fix plus a coordinated release within 14 days for confirmed vulnerabilities, longer if the issue is complex.
19
+
20
+ ## Scope
21
+
22
+ In scope:
23
+
24
+ - Vulnerabilities in the gem's middleware, parsers, storage adapters, dashboard controllers, or generators
25
+ - Data-exposure issues (unintended persistence of prompt content, API keys, or response bodies)
26
+ - Injection, auth-bypass, or privilege-escalation in the mounted dashboard
27
+
28
+ Out of scope:
29
+
30
+ - Issues in third-party dependencies (report those upstream; mention them here only if this gem's usage pattern creates the vulnerability)
31
+ - Missing security hardening recommendations that are not vulnerabilities (open a regular issue instead)
32
+ - Social engineering or physical attacks
33
+
34
+ ## Credit
35
+
36
+ Reporters who follow this policy will be credited in the release notes for the fix unless they request anonymity.
@@ -2,8 +2,6 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  class AssetsController < ActionController::Base
5
- skip_forgery_protection if respond_to?(:skip_forgery_protection)
6
-
7
5
  def stylesheet
8
6
  response.set_header("Cache-Control", "public, max-age=31536000, immutable")
9
7
  send_file LlmCostTracker::Assets::STYLESHEET_PATH, type: "text/css", disposition: "inline"
@@ -23,7 +23,7 @@ module LlmCostTracker
23
23
  return unless event.cost
24
24
 
25
25
  monthly_total = if config.active_record?
26
- active_record_monthly_total
26
+ active_record_monthly_total(time: event.tracked_at)
27
27
  else
28
28
  event.cost.total_cost
29
29
  end
@@ -34,11 +34,11 @@ module LlmCostTracker
34
34
 
35
35
  private
36
36
 
37
- def active_record_monthly_total
37
+ def active_record_monthly_total(time: Time.now.utc)
38
38
  require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
39
39
  require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
40
40
 
41
- LlmCostTracker::Storage::ActiveRecordStore.monthly_total
41
+ LlmCostTracker::Storage::ActiveRecordStore.monthly_total(time: time)
42
42
  rescue LoadError => e
43
43
  raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
44
44
  end
@@ -51,10 +51,20 @@ module LlmCostTracker
51
51
  last_event: last_event
52
52
  }
53
53
 
54
- config.on_budget_exceeded&.call(payload)
54
+ if notify_exceeded?(config, monthly_total: monthly_total, last_event: last_event)
55
+ config.on_budget_exceeded&.call(payload)
56
+ end
55
57
  raise BudgetExceededError.new(**payload) if raise_on_exceeded?(config)
56
58
  end
57
59
 
60
+ def notify_exceeded?(config, monthly_total:, last_event:)
61
+ return false unless config.on_budget_exceeded
62
+ return true unless config.budget_exceeded_behavior == :notify
63
+ return true unless last_event&.cost
64
+
65
+ monthly_total - last_event.cost.total_cost < config.monthly_budget
66
+ end
67
+
58
68
  def raise_on_exceeded?(config)
59
69
  %i[raise block_requests].include?(config.budget_exceeded_behavior)
60
70
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class AddMonthlyTotalsGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates a migration to add llm_cost_tracker_monthly_totals"
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "add_monthly_totals_to_llm_cost_tracker.rb.erb",
18
+ "db/migrate/add_monthly_totals_to_llm_cost_tracker.rb"
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def migration_version
25
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,48 @@
1
+ class AddMonthlyTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ create_table :llm_cost_tracker_monthly_totals do |t|
4
+ t.date :month_start, null: false
5
+ t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
6
+
7
+ t.timestamps
8
+ end unless table_exists?(:llm_cost_tracker_monthly_totals)
9
+
10
+ add_index :llm_cost_tracker_monthly_totals, :month_start,
11
+ unique: true unless index_exists?(:llm_cost_tracker_monthly_totals, :month_start)
12
+
13
+ backfill_monthly_totals
14
+ end
15
+
16
+ def down
17
+ remove_index :llm_cost_tracker_monthly_totals, :month_start if index_exists?(:llm_cost_tracker_monthly_totals, :month_start)
18
+ drop_table :llm_cost_tracker_monthly_totals if table_exists?(:llm_cost_tracker_monthly_totals)
19
+ end
20
+
21
+ private
22
+
23
+ def backfill_monthly_totals
24
+ return unless table_exists?(:llm_api_calls)
25
+
26
+ execute <<~SQL
27
+ INSERT INTO llm_cost_tracker_monthly_totals (month_start, total_cost, created_at, updated_at)
28
+ SELECT #{month_bucket_sql} AS month_start,
29
+ SUM(total_cost) AS total_cost,
30
+ CURRENT_TIMESTAMP,
31
+ CURRENT_TIMESTAMP
32
+ FROM llm_api_calls
33
+ WHERE total_cost IS NOT NULL
34
+ GROUP BY #{month_bucket_sql}
35
+ SQL
36
+ end
37
+
38
+ def month_bucket_sql
39
+ case connection.adapter_name
40
+ when /postgres/i
41
+ "DATE_TRUNC('month', tracked_at)::date"
42
+ when /mysql/i
43
+ "DATE_FORMAT(tracked_at, '%Y-%m-01')"
44
+ else
45
+ "strftime('%Y-%m-01', tracked_at)"
46
+ end
47
+ end
48
+ end
@@ -23,6 +23,13 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
23
23
  t.timestamps
24
24
  end
25
25
 
26
+ create_table :llm_cost_tracker_monthly_totals do |t|
27
+ t.date :month_start, null: false
28
+ t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
29
+
30
+ t.timestamps
31
+ end
32
+
26
33
  add_index :llm_api_calls, :provider
27
34
  add_index :llm_api_calls, :model
28
35
  add_index :llm_api_calls, :tracked_at
@@ -31,6 +38,7 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
31
38
  add_index :llm_api_calls, :usage_source
32
39
  add_index :llm_api_calls, :provider_response_id
33
40
  add_index :llm_api_calls, :tags, using: :gin if postgresql?
41
+ add_index :llm_cost_tracker_monthly_totals, :month_start, unique: true
34
42
  end
35
43
 
36
44
  private
@@ -8,6 +8,7 @@ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_versio
8
8
  return if tags_jsonb?
9
9
 
10
10
  remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
11
+ say "Upgrading llm_api_calls.tags to jsonb rewrites the table on PostgreSQL. Run this migration during a maintenance window on large datasets."
11
12
 
12
13
  change_column(
13
14
  :llm_api_calls,
@@ -8,6 +8,8 @@ require_relative "../logging"
8
8
  module LlmCostTracker
9
9
  module Middleware
10
10
  class Faraday < ::Faraday::Middleware
11
+ STREAM_CAPTURE_LIMIT_BYTES = 1_048_576
12
+
11
13
  def initialize(app, **options)
12
14
  super(app)
13
15
  @tags = options.fetch(:tags, {})
@@ -85,15 +87,12 @@ module LlmCostTracker
85
87
  end
86
88
 
87
89
  def parse_stream(parser, request_url, request_body, response_env, stream_buffer)
88
- body = stream_buffer&.string
90
+ body = stream_buffer&.dig(:buffer)&.string
89
91
  body = read_body(response_env.body) if body.nil? || body.empty?
90
92
 
91
93
  if body.nil? || body.empty?
92
- Logging.warn(
93
- "Unable to capture streaming response for #{request_url}; " \
94
- "fall back to LlmCostTracker.track_stream for manual capture."
95
- )
96
- return nil
94
+ Logging.warn(capture_warning(request_url, stream_buffer))
95
+ return parser.parse_stream(request_url, request_body, response_env.status, [])
97
96
  end
98
97
 
99
98
  events = Parsers::SSE.parse(body)
@@ -106,12 +105,21 @@ module LlmCostTracker
106
105
  original = request_env.request.on_data
107
106
  return nil unless original
108
107
 
109
- buffer = StringIO.new
108
+ state = { buffer: StringIO.new, bytes: 0, overflowed: false }
110
109
  request_env.request.on_data = proc do |chunk, size, env|
111
- buffer << chunk.to_s
110
+ chunk = chunk.to_s
111
+ unless state[:overflowed]
112
+ if state[:bytes] + chunk.bytesize <= STREAM_CAPTURE_LIMIT_BYTES
113
+ state[:buffer] << chunk
114
+ state[:bytes] += chunk.bytesize
115
+ else
116
+ state[:overflowed] = true
117
+ state[:buffer] = nil
118
+ end
119
+ end
112
120
  original.call(chunk, size, env)
113
121
  end
114
- buffer
122
+ state
115
123
  rescue StandardError => e
116
124
  Logging.warn("Unable to install streaming tap: #{e.class}: #{e.message}")
117
125
  nil
@@ -145,6 +153,16 @@ module LlmCostTracker
145
153
  def elapsed_ms(started_at)
146
154
  ((monotonic_time - started_at) * 1000).round
147
155
  end
156
+
157
+ def capture_warning(request_url, stream_buffer)
158
+ unless stream_buffer&.dig(:overflowed)
159
+ return "Unable to capture streaming response for #{request_url}; " \
160
+ "recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
161
+ end
162
+
163
+ "Streaming response for #{request_url} exceeded #{STREAM_CAPTURE_LIMIT_BYTES} bytes; " \
164
+ "recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
165
+ end
148
166
  end
149
167
  end
150
168
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ class MonthlyTotal < ActiveRecord::Base
7
+ self.table_name = "llm_cost_tracker_monthly_totals"
8
+ end
9
+ end
@@ -23,7 +23,8 @@ module LlmCostTracker
23
23
  body = request_body.to_s
24
24
  return false if body.empty?
25
25
 
26
- body.include?('"stream":true') || body.include?('"stream": true') || body.include?("stream: true")
26
+ request = safe_json_parse(body)
27
+ request.is_a?(Hash) && request["stream"] == true
27
28
  end
28
29
 
29
30
  def parse_stream(_request_url, _request_body, _response_status, _events)
@@ -3,7 +3,10 @@
3
3
  module LlmCostTracker
4
4
  class Railtie < Rails::Railtie
5
5
  generators do
6
+ require_relative "generators/llm_cost_tracker/add_monthly_totals_generator"
6
7
  require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
8
+ require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
9
+ require_relative "generators/llm_cost_tracker/add_streaming_generator"
7
10
  require_relative "generators/llm_cost_tracker/install_generator"
8
11
  require_relative "generators/llm_cost_tracker/prices_generator"
9
12
  require_relative "generators/llm_cost_tracker/upgrade_cost_precision_generator"
@@ -4,6 +4,10 @@ module LlmCostTracker
4
4
  module Storage
5
5
  class ActiveRecordStore
6
6
  class << self
7
+ def reset!
8
+ remove_instance_variable(:@monthly_totals_enabled) if instance_variable_defined?(:@monthly_totals_enabled)
9
+ end
10
+
7
11
  def save(event)
8
12
  tags = stringify_tags(event.tags || {})
9
13
 
@@ -26,18 +30,86 @@ module LlmCostTracker
26
30
  attributes[:provider_response_id] = event.provider_response_id
27
31
  end
28
32
 
29
- LlmCostTracker::LlmApiCall.create!(attributes)
33
+ LlmCostTracker::LlmApiCall.transaction do
34
+ call = LlmCostTracker::LlmApiCall.create!(attributes)
35
+ increment_monthly_total(event)
36
+ call
37
+ end
30
38
  end
31
39
 
32
40
  def monthly_total(time: Time.now.utc)
33
- LlmCostTracker::LlmApiCall
34
- .where(tracked_at: time.beginning_of_month..time)
35
- .sum(:total_cost)
36
- .to_f
41
+ if monthly_totals_enabled?
42
+ monthly_total_model.where(month_start: month_start_for(time)).pick(:total_cost).to_f
43
+ else
44
+ LlmCostTracker::LlmApiCall
45
+ .where(tracked_at: time.beginning_of_month..time)
46
+ .sum(:total_cost)
47
+ .to_f
48
+ end
37
49
  end
38
50
 
39
51
  private
40
52
 
53
+ def increment_monthly_total(event)
54
+ return unless monthly_totals_enabled?
55
+ return unless event.cost&.total_cost
56
+
57
+ monthly_total_model.upsert_all(
58
+ [
59
+ {
60
+ month_start: month_start_for(event.tracked_at),
61
+ total_cost: event.cost.total_cost
62
+ }
63
+ ],
64
+ on_duplicate: monthly_total_upsert_sql,
65
+ record_timestamps: true,
66
+ unique_by: monthly_total_unique_by
67
+ )
68
+ end
69
+
70
+ def monthly_totals_enabled?
71
+ return @monthly_totals_enabled unless @monthly_totals_enabled.nil?
72
+
73
+ @monthly_totals_enabled =
74
+ LlmCostTracker::LlmApiCall.connection.data_source_exists?("llm_cost_tracker_monthly_totals")
75
+ end
76
+
77
+ def monthly_total_model
78
+ require_relative "../monthly_total" unless defined?(LlmCostTracker::MonthlyTotal)
79
+
80
+ LlmCostTracker::MonthlyTotal
81
+ end
82
+
83
+ def month_start_for(time)
84
+ time.to_time.utc.beginning_of_month.to_date
85
+ end
86
+
87
+ def monthly_total_unique_by
88
+ return unless monthly_total_model.connection.supports_insert_conflict_target?
89
+
90
+ :month_start
91
+ end
92
+
93
+ def monthly_total_upsert_sql
94
+ Arel.sql(case monthly_total_model.connection.adapter_name
95
+ when /mysql/i
96
+ mysql_upsert_sql
97
+ else
98
+ "total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at"
99
+ end)
100
+ end
101
+
102
+ def mysql_upsert_sql
103
+ connection = monthly_total_model.connection
104
+ if connection.respond_to?(:supports_insert_raw_alias_syntax?, true) &&
105
+ connection.send(:supports_insert_raw_alias_syntax?)
106
+ values_reference = connection.quote_table_name("#{monthly_total_model.table_name}_values")
107
+ "total_cost = total_cost + #{values_reference}.total_cost, updated_at = #{values_reference}.updated_at"
108
+ else
109
+ "total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
110
+ end
111
+ end
112
+
41
113
  def stringify_tags(tags)
42
114
  tags.transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
43
115
  end
@@ -69,7 +69,7 @@ module LlmCostTracker
69
69
 
70
70
  @finished = true
71
71
  {
72
- events: ValueHelpers.deep_dup(@events),
72
+ events: @events.dup,
73
73
  explicit_usage: ValueHelpers.deep_dup(@explicit_usage),
74
74
  model: @model,
75
75
  latency_ms: @latency_ms,
@@ -10,11 +10,15 @@ module LlmCostTracker
10
10
 
11
11
  class << self
12
12
  def enforce_budget!
13
+ return unless LlmCostTracker.configuration.enabled
14
+
13
15
  Budget.enforce!
14
16
  end
15
17
 
16
18
  def record(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false,
17
19
  usage_source: nil, provider_response_id: nil, metadata: {})
20
+ return unless LlmCostTracker.configuration.enabled
21
+
18
22
  usage = EventMetadata.usage_data(input_tokens, output_tokens, metadata)
19
23
 
20
24
  cost_data = Pricing.cost_for(
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  require_relative "logging"
4
6
 
5
7
  module LlmCostTracker
6
8
  class UnknownPricing
9
+ MUTEX = Monitor.new
10
+
7
11
  class << self
8
12
  def handle!(model)
9
13
  model = normalized_model_name(model)
@@ -18,6 +22,10 @@ module LlmCostTracker
18
22
  end
19
23
  end
20
24
 
25
+ def reset!
26
+ MUTEX.synchronize { @warned_models = Set.new }
27
+ end
28
+
21
29
  private
22
30
 
23
31
  def normalized_model_name(model)
@@ -25,6 +33,12 @@ module LlmCostTracker
25
33
  end
26
34
 
27
35
  def warn_missing(model)
36
+ should_warn = MUTEX.synchronize do
37
+ @warned_models ||= Set.new
38
+ @warned_models.add?(model)
39
+ end
40
+ return unless should_warn
41
+
28
42
  Logging.warn(
29
43
  "No pricing configured for model #{model.inspect}. " \
30
44
  "Cost and budget guardrails will be skipped for this event. " \
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.3"
5
5
  end
@@ -60,6 +60,8 @@ module LlmCostTracker
60
60
 
61
61
  def reset_configuration!
62
62
  CONFIGURATION_MUTEX.synchronize { @configuration = Configuration.new }
63
+ UnknownPricing.reset! if defined?(UnknownPricing)
64
+ Storage::ActiveRecordStore.reset! if defined?(Storage::ActiveRecordStore)
63
65
  end
64
66
 
65
67
  def enforce_budget!
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_cost_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-22 00:00:00.000000000 Z
11
+ date: 2026-04-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -146,6 +146,34 @@ dependencies:
146
146
  - - "~>"
147
147
  - !ruby/object:Gem::Version
148
148
  version: '1.0'
149
+ - !ruby/object:Gem::Dependency
150
+ name: simplecov
151
+ requirement: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - "~>"
154
+ - !ruby/object:Gem::Version
155
+ version: '0.22'
156
+ type: :development
157
+ prerelease: false
158
+ version_requirements: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - "~>"
161
+ - !ruby/object:Gem::Version
162
+ version: '0.22'
163
+ - !ruby/object:Gem::Dependency
164
+ name: simplecov-lcov
165
+ requirement: !ruby/object:Gem::Requirement
166
+ requirements:
167
+ - - "~>"
168
+ - !ruby/object:Gem::Version
169
+ version: '0.8'
170
+ type: :development
171
+ prerelease: false
172
+ version_requirements: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - "~>"
175
+ - !ruby/object:Gem::Version
176
+ version: '0.8'
149
177
  - !ruby/object:Gem::Dependency
150
178
  name: sqlite3
151
179
  requirement: !ruby/object:Gem::Requirement
@@ -192,9 +220,11 @@ extra_rdoc_files: []
192
220
  files:
193
221
  - ".rspec"
194
222
  - CHANGELOG.md
223
+ - CODE_OF_CONDUCT.md
195
224
  - LICENSE.txt
196
225
  - README.md
197
226
  - Rakefile
227
+ - SECURITY.md
198
228
  - app/assets/llm_cost_tracker/application.css
199
229
  - app/controllers/llm_cost_tracker/application_controller.rb
200
230
  - app/controllers/llm_cost_tracker/assets_controller.rb
@@ -248,11 +278,13 @@ files:
248
278
  - lib/llm_cost_tracker/event.rb
249
279
  - lib/llm_cost_tracker/event_metadata.rb
250
280
  - lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb
281
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/add_monthly_totals_generator.rb
251
282
  - lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb
252
283
  - lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb
253
284
  - lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
254
285
  - lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb
255
286
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb
287
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_monthly_totals_to_llm_cost_tracker.rb.erb
256
288
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb
257
289
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb
258
290
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb
@@ -265,6 +297,7 @@ files:
265
297
  - lib/llm_cost_tracker/llm_api_call.rb
266
298
  - lib/llm_cost_tracker/logging.rb
267
299
  - lib/llm_cost_tracker/middleware/faraday.rb
300
+ - lib/llm_cost_tracker/monthly_total.rb
268
301
  - lib/llm_cost_tracker/parameter_hash.rb
269
302
  - lib/llm_cost_tracker/parsed_usage.rb
270
303
  - lib/llm_cost_tracker/parsers/anthropic.rb
@@ -308,7 +341,6 @@ files:
308
341
  - lib/llm_cost_tracker/value_helpers.rb
309
342
  - lib/llm_cost_tracker/version.rb
310
343
  - lib/tasks/llm_cost_tracker.rake
311
- - llm_cost_tracker.gemspec
312
344
  homepage: https://github.com/sergey-homenko/llm_cost_tracker
313
345
  licenses:
314
346
  - MIT
@@ -333,7 +365,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
333
365
  - !ruby/object:Gem::Version
334
366
  version: '0'
335
367
  requirements: []
336
- rubygems_version: 3.5.9
368
+ rubygems_version: 3.5.22
337
369
  signing_key:
338
370
  specification_version: 4
339
371
  summary: Self-hosted LLM usage and cost tracking for Ruby and Rails
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/llm_cost_tracker/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "llm_cost_tracker"
7
- spec.version = LlmCostTracker::VERSION
8
- spec.authors = ["Sergii Khomenko"]
9
- spec.email = ["sergey@mm.st"]
10
-
11
- spec.summary = "Self-hosted LLM usage and cost tracking for Ruby and Rails"
12
- spec.description = "Tracks token usage, latency, and estimated costs for OpenAI, Anthropic, " \
13
- "Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. " \
14
- "Works through Faraday middleware or explicit track/track_stream helpers, " \
15
- "with ActiveRecord storage, tag-based attribution, price sync tasks, " \
16
- "and budget guardrails."
17
- spec.homepage = "https://github.com/sergey-homenko/llm_cost_tracker"
18
- spec.license = "MIT"
19
-
20
- spec.required_ruby_version = ">= 3.3.0"
21
-
22
- spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
23
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
24
- spec.metadata["source_code_uri"] = spec.homepage
25
- spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
26
- spec.metadata["rubygems_mfa_required"] = "true"
27
-
28
- spec.files = Dir.chdir(__dir__) do
29
- `git ls-files -z`.split("\x0").reject do |f|
30
- (File.expand_path(f) == __FILE__) ||
31
- f.start_with?("bin/", "test/", "spec/", ".git", ".github", "gemfiles/", ".rubocop", "Gemfile")
32
- end
33
- end
34
-
35
- spec.require_paths = ["lib"]
36
-
37
- spec.add_dependency "activesupport", ">= 7.1", "< 9.0"
38
- spec.add_dependency "csv", "~> 3.0"
39
- spec.add_dependency "faraday", ">= 2.0", "< 3.0"
40
-
41
- spec.add_development_dependency "activerecord", ">= 7.1", "< 9.0"
42
- spec.add_development_dependency "railties", ">= 7.1", "< 9.0"
43
- spec.add_development_dependency "rake", "~> 13.0"
44
- spec.add_development_dependency "rspec", "~> 3.0"
45
- spec.add_development_dependency "rubocop", "~> 1.0"
46
- spec.add_development_dependency "sqlite3", ">= 1.4", "< 3.0"
47
- spec.add_development_dependency "webmock", "~> 3.0"
48
- end