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 +4 -4
- data/CHANGELOG.md +27 -0
- data/CODE_OF_CONDUCT.md +23 -0
- data/README.md +75 -7
- data/SECURITY.md +36 -0
- data/app/controllers/llm_cost_tracker/assets_controller.rb +0 -2
- data/lib/llm_cost_tracker/budget.rb +14 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_monthly_totals_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_monthly_totals_to_llm_cost_tracker.rb.erb +48 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +8 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +1 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +27 -9
- data/lib/llm_cost_tracker/monthly_total.rb +9 -0
- data/lib/llm_cost_tracker/parsers/base.rb +2 -1
- data/lib/llm_cost_tracker/railtie.rb +3 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +77 -5
- data/lib/llm_cost_tracker/stream_collector.rb +1 -1
- data/lib/llm_cost_tracker/tracker.rb +4 -0
- data/lib/llm_cost_tracker/unknown_pricing.rb +14 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +2 -0
- metadata +36 -4
- data/llm_cost_tracker.gemspec +0 -48
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b966913d302d5c5c3466615d1fa3983855c241f6cd9e3e26558c0fcc5fc4e7d5
|
|
4
|
+
data.tar.gz: 52804e702d5f01e5a4d247e8b50e601dede2b328bd7075c68ffd5f472b3b0d58
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 609ba1a18be86dce0b567b2ea33b3f3123da88683f0c65d9aef780f2e4854d1dde6686adfa505fc154d13da6dd6cb2b31d9f38c303de5fb22f6fda65c7f44aa7
|
|
7
|
+
data.tar.gz: de372e0940b4cfc400dacfc6dbf9e00f256c6944209da8cceaadd20a318b8c7aa8982d5190e21d38640ad20d04cc86400a4e872ec640189796e045acf1f7dfad
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,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
|
data/CODE_OF_CONDUCT.md
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
|
[](https://rubygems.org/gems/llm_cost_tracker)
|
|
6
6
|
[](https://github.com/sergey-homenko/llm_cost_tracker/actions)
|
|
7
|
+
[](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
|
-
|
|
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
|
+

|
|
13
29
|
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
54
|
+
if notify_exceeded?(config, monthly_total: monthly_total, last_event: last_event)
|
|
55
|
+
config.on_budget_exceeded&.call(payload)
|
|
56
|
+
end
|
|
55
57
|
raise BudgetExceededError.new(**payload) if raise_on_exceeded?(config)
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
def notify_exceeded?(config, monthly_total:, last_event:)
|
|
61
|
+
return false unless config.on_budget_exceeded
|
|
62
|
+
return true unless config.budget_exceeded_behavior == :notify
|
|
63
|
+
return true unless last_event&.cost
|
|
64
|
+
|
|
65
|
+
monthly_total - last_event.cost.total_cost < config.monthly_budget
|
|
66
|
+
end
|
|
67
|
+
|
|
58
68
|
def raise_on_exceeded?(config)
|
|
59
69
|
%i[raise block_requests].include?(config.budget_exceeded_behavior)
|
|
60
70
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class AddMonthlyTotalsGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a migration to add llm_cost_tracker_monthly_totals"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"add_monthly_totals_to_llm_cost_tracker.rb.erb",
|
|
18
|
+
"db/migrate/add_monthly_totals_to_llm_cost_tracker.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
class AddMonthlyTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
create_table :llm_cost_tracker_monthly_totals do |t|
|
|
4
|
+
t.date :month_start, null: false
|
|
5
|
+
t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
|
|
6
|
+
|
|
7
|
+
t.timestamps
|
|
8
|
+
end unless table_exists?(:llm_cost_tracker_monthly_totals)
|
|
9
|
+
|
|
10
|
+
add_index :llm_cost_tracker_monthly_totals, :month_start,
|
|
11
|
+
unique: true unless index_exists?(:llm_cost_tracker_monthly_totals, :month_start)
|
|
12
|
+
|
|
13
|
+
backfill_monthly_totals
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def down
|
|
17
|
+
remove_index :llm_cost_tracker_monthly_totals, :month_start if index_exists?(:llm_cost_tracker_monthly_totals, :month_start)
|
|
18
|
+
drop_table :llm_cost_tracker_monthly_totals if table_exists?(:llm_cost_tracker_monthly_totals)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def backfill_monthly_totals
|
|
24
|
+
return unless table_exists?(:llm_api_calls)
|
|
25
|
+
|
|
26
|
+
execute <<~SQL
|
|
27
|
+
INSERT INTO llm_cost_tracker_monthly_totals (month_start, total_cost, created_at, updated_at)
|
|
28
|
+
SELECT #{month_bucket_sql} AS month_start,
|
|
29
|
+
SUM(total_cost) AS total_cost,
|
|
30
|
+
CURRENT_TIMESTAMP,
|
|
31
|
+
CURRENT_TIMESTAMP
|
|
32
|
+
FROM llm_api_calls
|
|
33
|
+
WHERE total_cost IS NOT NULL
|
|
34
|
+
GROUP BY #{month_bucket_sql}
|
|
35
|
+
SQL
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def month_bucket_sql
|
|
39
|
+
case connection.adapter_name
|
|
40
|
+
when /postgres/i
|
|
41
|
+
"DATE_TRUNC('month', tracked_at)::date"
|
|
42
|
+
when /mysql/i
|
|
43
|
+
"DATE_FORMAT(tracked_at, '%Y-%m-01')"
|
|
44
|
+
else
|
|
45
|
+
"strftime('%Y-%m-01', tracked_at)"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -23,6 +23,13 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
23
23
|
t.timestamps
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
create_table :llm_cost_tracker_monthly_totals do |t|
|
|
27
|
+
t.date :month_start, null: false
|
|
28
|
+
t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
|
|
29
|
+
|
|
30
|
+
t.timestamps
|
|
31
|
+
end
|
|
32
|
+
|
|
26
33
|
add_index :llm_api_calls, :provider
|
|
27
34
|
add_index :llm_api_calls, :model
|
|
28
35
|
add_index :llm_api_calls, :tracked_at
|
|
@@ -31,6 +38,7 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
31
38
|
add_index :llm_api_calls, :usage_source
|
|
32
39
|
add_index :llm_api_calls, :provider_response_id
|
|
33
40
|
add_index :llm_api_calls, :tags, using: :gin if postgresql?
|
|
41
|
+
add_index :llm_cost_tracker_monthly_totals, :month_start, unique: true
|
|
34
42
|
end
|
|
35
43
|
|
|
36
44
|
private
|
|
@@ -8,6 +8,7 @@ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_versio
|
|
|
8
8
|
return if tags_jsonb?
|
|
9
9
|
|
|
10
10
|
remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
|
|
11
|
+
say "Upgrading llm_api_calls.tags to jsonb rewrites the table on PostgreSQL. Run this migration during a maintenance window on large datasets."
|
|
11
12
|
|
|
12
13
|
change_column(
|
|
13
14
|
:llm_api_calls,
|
|
@@ -8,6 +8,8 @@ require_relative "../logging"
|
|
|
8
8
|
module LlmCostTracker
|
|
9
9
|
module Middleware
|
|
10
10
|
class Faraday < ::Faraday::Middleware
|
|
11
|
+
STREAM_CAPTURE_LIMIT_BYTES = 1_048_576
|
|
12
|
+
|
|
11
13
|
def initialize(app, **options)
|
|
12
14
|
super(app)
|
|
13
15
|
@tags = options.fetch(:tags, {})
|
|
@@ -85,15 +87,12 @@ module LlmCostTracker
|
|
|
85
87
|
end
|
|
86
88
|
|
|
87
89
|
def parse_stream(parser, request_url, request_body, response_env, stream_buffer)
|
|
88
|
-
body = stream_buffer&.string
|
|
90
|
+
body = stream_buffer&.dig(:buffer)&.string
|
|
89
91
|
body = read_body(response_env.body) if body.nil? || body.empty?
|
|
90
92
|
|
|
91
93
|
if body.nil? || body.empty?
|
|
92
|
-
Logging.warn(
|
|
93
|
-
|
|
94
|
-
"fall back to LlmCostTracker.track_stream for manual capture."
|
|
95
|
-
)
|
|
96
|
-
return nil
|
|
94
|
+
Logging.warn(capture_warning(request_url, stream_buffer))
|
|
95
|
+
return parser.parse_stream(request_url, request_body, response_env.status, [])
|
|
97
96
|
end
|
|
98
97
|
|
|
99
98
|
events = Parsers::SSE.parse(body)
|
|
@@ -106,12 +105,21 @@ module LlmCostTracker
|
|
|
106
105
|
original = request_env.request.on_data
|
|
107
106
|
return nil unless original
|
|
108
107
|
|
|
109
|
-
|
|
108
|
+
state = { buffer: StringIO.new, bytes: 0, overflowed: false }
|
|
110
109
|
request_env.request.on_data = proc do |chunk, size, env|
|
|
111
|
-
|
|
110
|
+
chunk = chunk.to_s
|
|
111
|
+
unless state[:overflowed]
|
|
112
|
+
if state[:bytes] + chunk.bytesize <= STREAM_CAPTURE_LIMIT_BYTES
|
|
113
|
+
state[:buffer] << chunk
|
|
114
|
+
state[:bytes] += chunk.bytesize
|
|
115
|
+
else
|
|
116
|
+
state[:overflowed] = true
|
|
117
|
+
state[:buffer] = nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
112
120
|
original.call(chunk, size, env)
|
|
113
121
|
end
|
|
114
|
-
|
|
122
|
+
state
|
|
115
123
|
rescue StandardError => e
|
|
116
124
|
Logging.warn("Unable to install streaming tap: #{e.class}: #{e.message}")
|
|
117
125
|
nil
|
|
@@ -145,6 +153,16 @@ module LlmCostTracker
|
|
|
145
153
|
def elapsed_ms(started_at)
|
|
146
154
|
((monotonic_time - started_at) * 1000).round
|
|
147
155
|
end
|
|
156
|
+
|
|
157
|
+
def capture_warning(request_url, stream_buffer)
|
|
158
|
+
unless stream_buffer&.dig(:overflowed)
|
|
159
|
+
return "Unable to capture streaming response for #{request_url}; " \
|
|
160
|
+
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
"Streaming response for #{request_url} exceeded #{STREAM_CAPTURE_LIMIT_BYTES} bytes; " \
|
|
164
|
+
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
165
|
+
end
|
|
148
166
|
end
|
|
149
167
|
end
|
|
150
168
|
end
|
|
@@ -23,7 +23,8 @@ module LlmCostTracker
|
|
|
23
23
|
body = request_body.to_s
|
|
24
24
|
return false if body.empty?
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
request = safe_json_parse(body)
|
|
27
|
+
request.is_a?(Hash) && request["stream"] == true
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def parse_stream(_request_url, _request_body, _response_status, _events)
|
|
@@ -3,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.
|
|
33
|
+
LlmCostTracker::LlmApiCall.transaction do
|
|
34
|
+
call = LlmCostTracker::LlmApiCall.create!(attributes)
|
|
35
|
+
increment_monthly_total(event)
|
|
36
|
+
call
|
|
37
|
+
end
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
def monthly_total(time: Time.now.utc)
|
|
33
|
-
|
|
34
|
-
.where(
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
if monthly_totals_enabled?
|
|
42
|
+
monthly_total_model.where(month_start: month_start_for(time)).pick(:total_cost).to_f
|
|
43
|
+
else
|
|
44
|
+
LlmCostTracker::LlmApiCall
|
|
45
|
+
.where(tracked_at: time.beginning_of_month..time)
|
|
46
|
+
.sum(:total_cost)
|
|
47
|
+
.to_f
|
|
48
|
+
end
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
private
|
|
40
52
|
|
|
53
|
+
def increment_monthly_total(event)
|
|
54
|
+
return unless monthly_totals_enabled?
|
|
55
|
+
return unless event.cost&.total_cost
|
|
56
|
+
|
|
57
|
+
monthly_total_model.upsert_all(
|
|
58
|
+
[
|
|
59
|
+
{
|
|
60
|
+
month_start: month_start_for(event.tracked_at),
|
|
61
|
+
total_cost: event.cost.total_cost
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
on_duplicate: monthly_total_upsert_sql,
|
|
65
|
+
record_timestamps: true,
|
|
66
|
+
unique_by: monthly_total_unique_by
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def monthly_totals_enabled?
|
|
71
|
+
return @monthly_totals_enabled unless @monthly_totals_enabled.nil?
|
|
72
|
+
|
|
73
|
+
@monthly_totals_enabled =
|
|
74
|
+
LlmCostTracker::LlmApiCall.connection.data_source_exists?("llm_cost_tracker_monthly_totals")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def monthly_total_model
|
|
78
|
+
require_relative "../monthly_total" unless defined?(LlmCostTracker::MonthlyTotal)
|
|
79
|
+
|
|
80
|
+
LlmCostTracker::MonthlyTotal
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def month_start_for(time)
|
|
84
|
+
time.to_time.utc.beginning_of_month.to_date
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def monthly_total_unique_by
|
|
88
|
+
return unless monthly_total_model.connection.supports_insert_conflict_target?
|
|
89
|
+
|
|
90
|
+
:month_start
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def monthly_total_upsert_sql
|
|
94
|
+
Arel.sql(case monthly_total_model.connection.adapter_name
|
|
95
|
+
when /mysql/i
|
|
96
|
+
mysql_upsert_sql
|
|
97
|
+
else
|
|
98
|
+
"total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at"
|
|
99
|
+
end)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def mysql_upsert_sql
|
|
103
|
+
connection = monthly_total_model.connection
|
|
104
|
+
if connection.respond_to?(:supports_insert_raw_alias_syntax?, true) &&
|
|
105
|
+
connection.send(:supports_insert_raw_alias_syntax?)
|
|
106
|
+
values_reference = connection.quote_table_name("#{monthly_total_model.table_name}_values")
|
|
107
|
+
"total_cost = total_cost + #{values_reference}.total_cost, updated_at = #{values_reference}.updated_at"
|
|
108
|
+
else
|
|
109
|
+
"total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
41
113
|
def stringify_tags(tags)
|
|
42
114
|
tags.transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
|
|
43
115
|
end
|
|
@@ -10,11 +10,15 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
12
|
def enforce_budget!
|
|
13
|
+
return unless LlmCostTracker.configuration.enabled
|
|
14
|
+
|
|
13
15
|
Budget.enforce!
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def record(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false,
|
|
17
19
|
usage_source: nil, provider_response_id: nil, metadata: {})
|
|
20
|
+
return unless LlmCostTracker.configuration.enabled
|
|
21
|
+
|
|
18
22
|
usage = EventMetadata.usage_data(input_tokens, output_tokens, metadata)
|
|
19
23
|
|
|
20
24
|
cost_data = Pricing.cost_for(
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
3
5
|
require_relative "logging"
|
|
4
6
|
|
|
5
7
|
module LlmCostTracker
|
|
6
8
|
class UnknownPricing
|
|
9
|
+
MUTEX = Monitor.new
|
|
10
|
+
|
|
7
11
|
class << self
|
|
8
12
|
def handle!(model)
|
|
9
13
|
model = normalized_model_name(model)
|
|
@@ -18,6 +22,10 @@ module LlmCostTracker
|
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
def reset!
|
|
26
|
+
MUTEX.synchronize { @warned_models = Set.new }
|
|
27
|
+
end
|
|
28
|
+
|
|
21
29
|
private
|
|
22
30
|
|
|
23
31
|
def normalized_model_name(model)
|
|
@@ -25,6 +33,12 @@ module LlmCostTracker
|
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
def warn_missing(model)
|
|
36
|
+
should_warn = MUTEX.synchronize do
|
|
37
|
+
@warned_models ||= Set.new
|
|
38
|
+
@warned_models.add?(model)
|
|
39
|
+
end
|
|
40
|
+
return unless should_warn
|
|
41
|
+
|
|
28
42
|
Logging.warn(
|
|
29
43
|
"No pricing configured for model #{model.inspect}. " \
|
|
30
44
|
"Cost and budget guardrails will be skipped for this event. " \
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -60,6 +60,8 @@ module LlmCostTracker
|
|
|
60
60
|
|
|
61
61
|
def reset_configuration!
|
|
62
62
|
CONFIGURATION_MUTEX.synchronize { @configuration = Configuration.new }
|
|
63
|
+
UnknownPricing.reset! if defined?(UnknownPricing)
|
|
64
|
+
Storage::ActiveRecordStore.reset! if defined?(Storage::ActiveRecordStore)
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
def enforce_budget!
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llm_cost_tracker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergii Khomenko
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -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.
|
|
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
|
data/llm_cost_tracker.gemspec
DELETED
|
@@ -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
|