llm_cost_tracker 0.5.1 → 0.5.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 +43 -0
- data/README.md +18 -9
- data/app/controllers/llm_cost_tracker/calls_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +3 -15
- data/app/controllers/llm_cost_tracker/tags_controller.rb +7 -6
- data/app/helpers/llm_cost_tracker/application_helper.rb +21 -6
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +42 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -8
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +6 -5
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +74 -18
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +15 -4
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +4 -0
- data/docs/architecture.md +28 -0
- data/docs/budgets.md +45 -0
- data/docs/configuration.md +65 -0
- data/docs/cookbook.md +185 -0
- data/docs/dashboard-overview.png +0 -0
- data/docs/dashboard.md +38 -0
- data/docs/extending.md +32 -0
- data/docs/operations.md +44 -0
- data/docs/pricing.md +94 -0
- data/docs/querying.md +36 -0
- data/docs/streaming.md +70 -0
- data/docs/technical/README.md +10 -0
- data/docs/technical/data-flow.md +67 -0
- data/docs/technical/extension-points.md +111 -0
- data/docs/technical/module-map.md +197 -0
- data/docs/technical/operational-notes.md +77 -0
- data/docs/upgrading.md +46 -0
- data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
- data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
- data/lib/llm_cost_tracker/configuration.rb +24 -17
- data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
- data/lib/llm_cost_tracker/doctor.rb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +7 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +51 -3
- data/lib/llm_cost_tracker/integrations/base.rb +77 -6
- data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
- data/lib/llm_cost_tracker/integrations/openai.rb +78 -5
- data/lib/llm_cost_tracker/integrations/registry.rb +36 -4
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
- data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +2 -77
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +8 -4
- data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +12 -3
- data/lib/llm_cost_tracker/price_registry.rb +3 -0
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +41 -12
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
- data/lib/llm_cost_tracker/pricing/lookup.rb +110 -0
- data/lib/llm_cost_tracker/pricing.rb +25 -108
- data/lib/llm_cost_tracker/report.rb +8 -1
- data/lib/llm_cost_tracker/report_data.rb +25 -9
- data/lib/llm_cost_tracker/retention.rb +33 -16
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +115 -0
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +42 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +26 -0
- data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
- data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
- data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
- data/lib/llm_cost_tracker/storage/registry.rb +63 -0
- data/lib/llm_cost_tracker/stream_capture.rb +7 -0
- data/lib/llm_cost_tracker/stream_collector.rb +25 -1
- data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
- data/lib/llm_cost_tracker/tag_sql.rb +34 -0
- data/lib/llm_cost_tracker/tracker.rb +6 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +4 -0
- data/lib/tasks/llm_cost_tracker.rake +49 -0
- metadata +40 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3fe81fdf7b12f977d7dfc4aa86f629e8b5ad6e099100bfbfe1b18507db17fff
|
|
4
|
+
data.tar.gz: dffe0c0ebeb30fa111273b654141ee06e1275426846796b1749435429da3414f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4849f0d0b09d640ed3a902bbc19a975e576b0539d2c71eac3165199eed196ecd88f249ff1d571c43b2686d59466dde26a0467f984c2cbb7804a5f771e184df31
|
|
7
|
+
data.tar.gz: 8aa17efc9dd68b1489cdc69351a67fe2398c35720750cf1440f215bceeef0392c9f3af1d41ebd444ddcd9876c2c1e612ea50d6403fb7f650735fc9d9161a2a85
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.3] - 2026-04-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Official OpenAI SDK streaming capture for Responses streams, Responses raw streams, Responses retrieve streams, and Chat Completions raw streams.
|
|
12
|
+
- Official Anthropic SDK streaming capture for Messages streams and raw streams.
|
|
13
|
+
- Capture verification via `llm_cost_tracker:verify_capture` and expanded doctor capture diagnostics.
|
|
14
|
+
- Pricing explanation via `LlmCostTracker::Pricing.explain` and `llm_cost_tracker:prices:explain`.
|
|
15
|
+
- Extensible storage and SDK integration registries via `Storage.register` and `Integrations.register`.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- OpenAI Responses stream parsing now reads final usage from completed response events.
|
|
20
|
+
- Incomplete price entries now return unknown pricing instead of raising `TypeError`.
|
|
21
|
+
- Retention pruning now keeps ActiveRecord period rollups in sync when deleting rows inside active budget windows.
|
|
22
|
+
|
|
23
|
+
## [0.5.2] - 2026-04-27
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- RubyLLM SDK integration for chat, embedding, and transcription calls.
|
|
28
|
+
- Tag guardrails for redacted tag keys, maximum tag count, and maximum tag value byte size.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- SDK integrations now validate minimum versions and method contracts before installing wrappers.
|
|
33
|
+
- `config.instrument :all` now includes RubyLLM.
|
|
34
|
+
- Dashboard date filters now reject one-sided, reversed, and over-366-day ranges.
|
|
35
|
+
- Dashboard provider/model/tag option lists and tag value breakdowns now cap rendered rows.
|
|
36
|
+
- Reports now cap rendered breakdown groups while keeping complete structured report data available.
|
|
37
|
+
- Stream capture now enforces a shared 1 MiB buffer cap and records unknown usage on overflow.
|
|
38
|
+
- Price refresh, price scrape, and local price registry reads now enforce response or file size caps.
|
|
39
|
+
- Retention pruning now rejects non-positive batch sizes and invalid cutoffs before deleting rows.
|
|
40
|
+
- The install generator now warns to mount the dashboard behind host-app admin authentication.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- OpenAI SDK integration now separates cached input tokens from regular input tokens.
|
|
45
|
+
- OpenAI and Gemini parsers now compute total tokens when provider responses omit totals.
|
|
46
|
+
- CSV export now prefixes formula-like values even when they have leading whitespace.
|
|
47
|
+
- Tag chips now truncate oversized values and tooltips.
|
|
48
|
+
- Report tag breakdown keys are validated at configuration time.
|
|
49
|
+
|
|
7
50
|
## [0.5.1] - 2026-04-27
|
|
8
51
|
|
|
9
52
|
### Changed
|
data/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Add to your Gemfile alongside whatever LLM client you already use:
|
|
|
20
20
|
|
|
21
21
|
```ruby
|
|
22
22
|
gem "llm_cost_tracker"
|
|
23
|
-
gem "openai" # or "anthropic", or your existing client
|
|
23
|
+
gem "openai" # or "anthropic", "ruby_llm", or your existing client
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
Install, migrate, verify:
|
|
@@ -55,7 +55,7 @@ Visit `/llm-costs` for the dashboard. **Mount it behind your app's auth before d
|
|
|
55
55
|
## What you get
|
|
56
56
|
|
|
57
57
|
- Local ActiveRecord ledger of every call: provider, model, token breakdown, cost, latency, tags, response IDs
|
|
58
|
-
- Auto-capture for the official `openai` and `anthropic` Ruby SDKs, plus Faraday middleware for `ruby-openai`, the Gemini REST API, and any client you can inject middleware into
|
|
58
|
+
- Auto-capture for RubyLLM and the official `openai` and `anthropic` Ruby SDKs, plus Faraday middleware for `ruby-openai`, the Gemini REST API, and any client you can inject middleware into
|
|
59
59
|
- Server-rendered dashboard (plain ERB, zero JavaScript) with overview, models, calls, tags, CSV export, and a data-quality page
|
|
60
60
|
- Local pricing snapshots refreshed daily from the official provider pricing pages, applied with `bin/rails llm_cost_tracker:prices:refresh`
|
|
61
61
|
- Monthly / daily / per-call budget guardrails with notify, raise, or block-requests behaviour
|
|
@@ -73,13 +73,13 @@ Visit `/llm-costs` for the dashboard. **Mount it behind your app's auth before d
|
|
|
73
73
|
|
|
74
74
|
Three paths, in order of preference. Use the first one that fits your stack.
|
|
75
75
|
|
|
76
|
-
### 1.
|
|
76
|
+
### 1. SDK integrations
|
|
77
77
|
|
|
78
|
-
Drop-in for the official `openai` and `anthropic` gems. `config.instrument` patches
|
|
78
|
+
Drop-in for RubyLLM and the official `openai` and `anthropic` gems. `config.instrument` patches tested SDK methods so you don't change a single call site:
|
|
79
79
|
|
|
80
80
|
```ruby
|
|
81
81
|
LlmCostTracker.configure do |config|
|
|
82
|
-
config.instrument :openai # or :anthropic
|
|
82
|
+
config.instrument :openai # or :anthropic / :ruby_llm
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
LlmCostTracker.with_tags(feature: "support_chat") do
|
|
@@ -93,7 +93,9 @@ end
|
|
|
93
93
|
|
|
94
94
|
Captures usage, model, latency, response ID, cache tokens, and reasoning tokens whenever the SDK exposes them. Provider SDKs are not added as gem dependencies — you install whichever you actually use.
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
Enabled integrations are checked at boot: the client gem must be loaded, meet the minimum supported version, and expose the expected classes and methods. If the contract check fails, boot raises instead of silently missing spend.
|
|
97
|
+
|
|
98
|
+
This patches **only** RubyLLM and the official Ruby SDKs. `ruby-openai` (alexrudall) and any custom client go through Faraday middleware below.
|
|
97
99
|
|
|
98
100
|
### 2. Faraday middleware
|
|
99
101
|
|
|
@@ -165,6 +167,12 @@ Refresh on demand from the maintained snapshot:
|
|
|
165
167
|
bin/rails llm_cost_tracker:prices:refresh
|
|
166
168
|
```
|
|
167
169
|
|
|
170
|
+
Explain why a model is priced or unknown:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
PROVIDER=openai MODEL=gpt-4o bin/rails llm_cost_tracker:prices:explain
|
|
174
|
+
```
|
|
175
|
+
|
|
168
176
|
Precedence is `pricing_overrides` → `prices_file` → bundled. Provider-qualified keys like `openai/gpt-4o-mini` win over model-only keys. Full pricing reference: [`docs/pricing.md`](docs/pricing.md).
|
|
169
177
|
|
|
170
178
|
## Budgets
|
|
@@ -227,7 +235,9 @@ Auth is your job. Examples for basic auth and Devise: [`docs/dashboard.md`](docs
|
|
|
227
235
|
| Other OpenAI-compatible hosts | Configurable | Register the host via `config.openai_compatible_providers` |
|
|
228
236
|
| Anything else | Configurable | Custom parser — see [`docs/extending.md`](docs/extending.md) |
|
|
229
237
|
|
|
230
|
-
|
|
238
|
+
RubyLLM chat, embedding, and transcription calls are captured through RubyLLM's provider layer when `config.instrument :ruby_llm` is enabled.
|
|
239
|
+
|
|
240
|
+
Endpoints covered end-to-end: OpenAI Chat Completions / Responses / Completions / Embeddings, Anthropic Messages, Gemini `generateContent` and `streamGenerateContent`, plus their OpenAI-compatible equivalents. Streaming is captured for Faraday paths and official OpenAI / Anthropic SDK stream helpers whenever the provider emits final-usage events.
|
|
231
241
|
|
|
232
242
|
## Privacy
|
|
233
243
|
|
|
@@ -256,7 +266,6 @@ is still brief.
|
|
|
256
266
|
## Known limitations
|
|
257
267
|
|
|
258
268
|
- `:block_requests` is best-effort under concurrency, not a transactional cap.
|
|
259
|
-
- Official SDK integrations cover non-streaming calls; streaming via the SDKs falls back to Faraday middleware or `track_stream`.
|
|
260
269
|
- Streaming usage capture relies on the provider emitting a final-usage event. Missing events are stored with `usage_source: "unknown"` so they appear on the data-quality page rather than vanishing.
|
|
261
270
|
- `provider_response_id` is stored only when the provider exposes a stable ID. Gemini is best-effort and varies by endpoint.
|
|
262
271
|
- Cache write TTL variants on Anthropic (1h vs 5min writes) are not modeled separately yet.
|
|
@@ -265,7 +274,7 @@ is still brief.
|
|
|
265
274
|
|
|
266
275
|
```bash
|
|
267
276
|
bundle install
|
|
268
|
-
bin/check # rubocop + rspec
|
|
277
|
+
bin/check # rubocop + rspec + coverage gate
|
|
269
278
|
```
|
|
270
279
|
|
|
271
280
|
Architecture rules and conventions for contributions live in [`AGENTS.md`](AGENTS.md) and [`docs/architecture.md`](docs/architecture.md).
|
|
@@ -84,7 +84,8 @@ module LlmCostTracker
|
|
|
84
84
|
return value if value.nil?
|
|
85
85
|
|
|
86
86
|
string = value.to_s
|
|
87
|
-
|
|
87
|
+
stripped = string.lstrip
|
|
88
|
+
CSV_FORMULA_PREFIXES.include?(stripped[0]) ? "'#{string}" : string
|
|
88
89
|
end
|
|
89
90
|
end
|
|
90
91
|
end
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class DashboardController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
|
|
6
|
+
range = Dashboard::DateRange.call(params: params)
|
|
7
|
+
@from_date = range.from
|
|
8
|
+
@to_date = range.to
|
|
7
9
|
prev_from, prev_to = previous_range
|
|
8
10
|
filter_params = LlmCostTracker::ParameterHash.to_hash(params)
|
|
9
11
|
scope = Dashboard::Filter.call(
|
|
@@ -23,25 +25,11 @@ module LlmCostTracker
|
|
|
23
25
|
|
|
24
26
|
private
|
|
25
27
|
|
|
26
|
-
def overview_range
|
|
27
|
-
to_date = parsed_date(params[:to]) || Date.current
|
|
28
|
-
from_date = parsed_date(params[:from]) || (to_date - 29)
|
|
29
|
-
[from_date, to_date]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
28
|
def previous_range
|
|
33
29
|
span_days = (@to_date - @from_date).to_i + 1
|
|
34
30
|
prev_to = @from_date - 1
|
|
35
31
|
prev_from = prev_to - (span_days - 1)
|
|
36
32
|
[prev_from, prev_to]
|
|
37
33
|
end
|
|
38
|
-
|
|
39
|
-
def parsed_date(value)
|
|
40
|
-
return nil if value.to_s.strip.empty?
|
|
41
|
-
|
|
42
|
-
Date.iso8601(value.to_s)
|
|
43
|
-
rescue ArgumentError
|
|
44
|
-
nil
|
|
45
|
-
end
|
|
46
34
|
end
|
|
47
35
|
end
|
|
@@ -8,12 +8,13 @@ module LlmCostTracker
|
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
10
|
@tag_key = params[:key]
|
|
11
|
-
|
|
12
|
-
@
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@
|
|
16
|
-
@
|
|
11
|
+
breakdown = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: @tag_key)
|
|
12
|
+
@rows = breakdown.rows
|
|
13
|
+
@total_calls = breakdown.total_calls
|
|
14
|
+
@tagged_calls = breakdown.tagged_calls
|
|
15
|
+
@distinct_values = breakdown.distinct_values
|
|
16
|
+
@tag_value_limit = breakdown.limit
|
|
17
|
+
@tag_values_limited = breakdown.limited?
|
|
17
18
|
end
|
|
18
19
|
end
|
|
19
20
|
end
|
|
@@ -4,6 +4,9 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module ApplicationHelper
|
|
7
|
+
TAG_VALUE_SUMMARY_BYTES = 80
|
|
8
|
+
TAG_TOOLTIP_BYTES = 512
|
|
9
|
+
|
|
7
10
|
include DashboardFilterHelper
|
|
8
11
|
include DashboardFilterOptionsHelper
|
|
9
12
|
include DashboardQueryHelper
|
|
@@ -116,6 +119,10 @@ module LlmCostTracker
|
|
|
116
119
|
visible
|
|
117
120
|
end
|
|
118
121
|
|
|
122
|
+
def tag_chips_title(tags)
|
|
123
|
+
truncate_text(safe_json(tags), TAG_TOOLTIP_BYTES)
|
|
124
|
+
end
|
|
125
|
+
|
|
119
126
|
def budget_fill_modifier(percent)
|
|
120
127
|
percent = percent.to_f
|
|
121
128
|
return "lct-budget-fill--over" if percent >= 100.0
|
|
@@ -143,12 +150,20 @@ module LlmCostTracker
|
|
|
143
150
|
end
|
|
144
151
|
|
|
145
152
|
def tag_value_summary(value)
|
|
146
|
-
case value
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
string = case value
|
|
154
|
+
when Hash, Array
|
|
155
|
+
JSON.generate(value)
|
|
156
|
+
else
|
|
157
|
+
value.to_s
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
truncate_text(string, TAG_VALUE_SUMMARY_BYTES)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def truncate_text(string, limit)
|
|
164
|
+
return string if string.bytesize <= limit
|
|
165
|
+
|
|
166
|
+
"#{string.byteslice(0, limit).to_s.encode('UTF-8', invalid: :replace, undef: :replace)}..."
|
|
152
167
|
end
|
|
153
168
|
end
|
|
154
169
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module DashboardFilterOptionsHelper
|
|
5
|
+
MAX_FILTER_OPTIONS = 100
|
|
6
|
+
|
|
5
7
|
def provider_filter_options(filter_params: params)
|
|
6
8
|
filter_options_for(:provider, filter_params: filter_params)
|
|
7
9
|
end
|
|
@@ -19,7 +21,7 @@ module LlmCostTracker
|
|
|
19
21
|
)
|
|
20
22
|
values = LlmCostTracker::Dashboard::Filter.call(params: scope_params)
|
|
21
23
|
.where.not(column => [nil, ""])
|
|
22
|
-
.distinct.order(column).pluck(column)
|
|
24
|
+
.distinct.order(column).limit(MAX_FILTER_OPTIONS).pluck(column)
|
|
23
25
|
current = source[column.to_s].presence || source[column].presence
|
|
24
26
|
values.unshift(current) if current && !values.include?(current)
|
|
25
27
|
values
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Dashboard
|
|
5
|
+
class DateRange
|
|
6
|
+
DEFAULT_DAYS = 30
|
|
7
|
+
MAX_DAYS = 366
|
|
8
|
+
|
|
9
|
+
attr_reader :from, :to
|
|
10
|
+
|
|
11
|
+
def self.call(params:, today: Date.current)
|
|
12
|
+
new(params: params, today: today)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.parse(params, key)
|
|
16
|
+
value = LlmCostTracker::ParameterHash.with_indifferent_access(params)[key].to_s.strip
|
|
17
|
+
return nil if value.empty?
|
|
18
|
+
|
|
19
|
+
Date.iso8601(value)
|
|
20
|
+
rescue ArgumentError
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.validate!(from:, to:)
|
|
25
|
+
return if from.nil? && to.nil?
|
|
26
|
+
|
|
27
|
+
raise InvalidFilterError, "from and to dates must be provided together" if from.nil? || to.nil?
|
|
28
|
+
raise InvalidFilterError, "from date must be on or before to date" if from > to
|
|
29
|
+
return if ((to - from).to_i + 1) <= MAX_DAYS
|
|
30
|
+
|
|
31
|
+
raise InvalidFilterError, "date range cannot exceed #{MAX_DAYS} days"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(params:, today:)
|
|
35
|
+
@to = self.class.parse(params, :to) || today
|
|
36
|
+
@from = self.class.parse(params, :from) || (@to - (DEFAULT_DAYS - 1))
|
|
37
|
+
self.class.validate!(from: @from, to: @to)
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -31,9 +31,12 @@ module LlmCostTracker
|
|
|
31
31
|
attr_reader :scope, :params
|
|
32
32
|
|
|
33
33
|
def apply_date_filters(relation)
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
from_date = parse_date(:from)
|
|
35
|
+
to_date = parse_date(:to)
|
|
36
|
+
Dashboard::DateRange.validate!(from: from_date, to: to_date)
|
|
36
37
|
|
|
38
|
+
from = from_date&.beginning_of_day
|
|
39
|
+
to = to_date&.end_of_day
|
|
37
40
|
relation = relation.where(tracked_at: from..) if from
|
|
38
41
|
relation = relation.where(tracked_at: ..to) if to
|
|
39
42
|
relation
|
|
@@ -89,12 +92,7 @@ module LlmCostTracker
|
|
|
89
92
|
end
|
|
90
93
|
|
|
91
94
|
def parse_date(key)
|
|
92
|
-
|
|
93
|
-
return nil if value.nil?
|
|
94
|
-
|
|
95
|
-
Date.iso8601(value)
|
|
96
|
-
rescue ArgumentError
|
|
97
|
-
nil
|
|
95
|
+
Dashboard::DateRange.parse(params, key)
|
|
98
96
|
end
|
|
99
97
|
|
|
100
98
|
def normalized_string(value)
|
|
@@ -65,11 +65,12 @@ module LlmCostTracker
|
|
|
65
65
|
|
|
66
66
|
scope
|
|
67
67
|
.where(tracked_at: window)
|
|
68
|
-
.
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
.where.not(total_cost: nil)
|
|
69
|
+
.group(:provider, :model)
|
|
70
|
+
.group_by_period(:day)
|
|
71
|
+
.sum(:total_cost)
|
|
72
|
+
.each do |(provider, model, day), total_cost|
|
|
73
|
+
grouped[[provider, model]][Date.iso8601(day.to_s)] += total_cost.to_f
|
|
73
74
|
end
|
|
74
75
|
|
|
75
76
|
grouped
|
|
@@ -9,28 +9,54 @@ module LlmCostTracker
|
|
|
9
9
|
:average_cost_per_call
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
+
TagBreakdownResult = Data.define(
|
|
13
|
+
:rows,
|
|
14
|
+
:total_calls,
|
|
15
|
+
:tagged_calls,
|
|
16
|
+
:distinct_values,
|
|
17
|
+
:limit
|
|
18
|
+
) do
|
|
19
|
+
def limited? = distinct_values > rows.size
|
|
20
|
+
end
|
|
21
|
+
|
|
12
22
|
class TagBreakdown
|
|
23
|
+
DEFAULT_LIMIT = 100
|
|
24
|
+
|
|
13
25
|
class << self
|
|
14
|
-
def call(key:, scope: LlmCostTracker::LlmApiCall.all)
|
|
15
|
-
new(scope: scope, key: key).
|
|
26
|
+
def call(key:, scope: LlmCostTracker::LlmApiCall.all, limit: DEFAULT_LIMIT)
|
|
27
|
+
new(scope: scope, key: key, limit: limit).result
|
|
16
28
|
end
|
|
17
29
|
end
|
|
18
30
|
|
|
19
|
-
def initialize(scope:, key:)
|
|
31
|
+
def initialize(scope:, key:, limit:)
|
|
20
32
|
@scope = scope
|
|
21
33
|
@key = LlmCostTracker::TagKey.validate!(key, error_class: LlmCostTracker::InvalidFilterError)
|
|
34
|
+
@limit = normalized_limit(limit)
|
|
35
|
+
@connection = LlmCostTracker::LlmApiCall.connection
|
|
22
36
|
end
|
|
23
37
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
counts = counts_by_tag
|
|
38
|
+
def result
|
|
39
|
+
counts = summary_counts
|
|
27
40
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
41
|
+
TagBreakdownResult.new(
|
|
42
|
+
rows: rows,
|
|
43
|
+
total_calls: counts.fetch(:total_calls),
|
|
44
|
+
tagged_calls: counts.fetch(:tagged_calls),
|
|
45
|
+
distinct_values: counts.fetch(:distinct_values),
|
|
46
|
+
limit: limit
|
|
47
|
+
)
|
|
48
|
+
end
|
|
31
49
|
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :scope, :key, :limit, :connection
|
|
53
|
+
|
|
54
|
+
def rows
|
|
55
|
+
connection.select_all(rows_sql).map do |row|
|
|
56
|
+
calls = row["calls_count"].to_i
|
|
57
|
+
total_cost = row["total_cost_sum"].to_f
|
|
32
58
|
TagBreakdownRow.new(
|
|
33
|
-
value:
|
|
59
|
+
value: LlmCostTracker::LlmApiCall.tag_value_label(row["tag_value"]),
|
|
34
60
|
calls: calls,
|
|
35
61
|
total_cost: total_cost,
|
|
36
62
|
average_cost_per_call: calls.positive? ? total_cost / calls : 0.0
|
|
@@ -38,18 +64,48 @@ module LlmCostTracker
|
|
|
38
64
|
end
|
|
39
65
|
end
|
|
40
66
|
|
|
41
|
-
|
|
67
|
+
def summary_counts
|
|
68
|
+
row = connection.select_one(summary_sql) || {}
|
|
69
|
+
{
|
|
70
|
+
total_calls: row["total_calls"].to_i,
|
|
71
|
+
tagged_calls: row["tagged_calls"].to_i,
|
|
72
|
+
distinct_values: row["distinct_values"].to_i
|
|
73
|
+
}
|
|
74
|
+
end
|
|
42
75
|
|
|
43
|
-
|
|
76
|
+
def rows_sql
|
|
77
|
+
<<~SQL.squish
|
|
78
|
+
SELECT #{tag_expression} AS tag_value,
|
|
79
|
+
COUNT(*) AS calls_count,
|
|
80
|
+
COALESCE(SUM(sub.total_cost), 0) AS total_cost_sum
|
|
81
|
+
FROM (#{scope.to_sql}) AS sub
|
|
82
|
+
WHERE #{tag_present_predicate}
|
|
83
|
+
GROUP BY #{tag_expression}
|
|
84
|
+
ORDER BY total_cost_sum DESC, calls_count DESC, tag_value ASC
|
|
85
|
+
LIMIT #{limit}
|
|
86
|
+
SQL
|
|
87
|
+
end
|
|
44
88
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
89
|
+
def summary_sql
|
|
90
|
+
<<~SQL.squish
|
|
91
|
+
SELECT COUNT(*) AS total_calls,
|
|
92
|
+
COALESCE(SUM(CASE WHEN #{tag_present_predicate} THEN 1 ELSE 0 END), 0) AS tagged_calls,
|
|
93
|
+
COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{tag_expression} END) AS distinct_values
|
|
94
|
+
FROM (#{scope.to_sql}) AS sub
|
|
95
|
+
SQL
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def tag_present_predicate
|
|
99
|
+
"#{tag_expression} IS NOT NULL AND #{tag_expression} != ''"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def tag_expression
|
|
103
|
+
@tag_expression ||= LlmCostTracker::LlmApiCall.tag_value_expression(key, table_name: "sub")
|
|
49
104
|
end
|
|
50
105
|
|
|
51
|
-
def
|
|
52
|
-
value
|
|
106
|
+
def normalized_limit(value)
|
|
107
|
+
value = value.to_i
|
|
108
|
+
value.positive? ? [value, DEFAULT_LIMIT].min : DEFAULT_LIMIT
|
|
53
109
|
end
|
|
54
110
|
end
|
|
55
111
|
end
|
|
@@ -5,15 +5,18 @@ module LlmCostTracker
|
|
|
5
5
|
TagKeyRow = Data.define(:key, :calls_count, :distinct_values)
|
|
6
6
|
|
|
7
7
|
class TagKeyExplorer
|
|
8
|
+
DEFAULT_LIMIT = 100
|
|
9
|
+
|
|
8
10
|
class << self
|
|
9
|
-
def call(scope: LlmCostTracker::LlmApiCall.all)
|
|
10
|
-
new(scope: scope).rows
|
|
11
|
+
def call(scope: LlmCostTracker::LlmApiCall.all, limit: DEFAULT_LIMIT)
|
|
12
|
+
new(scope: scope, limit: limit).rows
|
|
11
13
|
end
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
def initialize(scope:)
|
|
16
|
+
def initialize(scope:, limit:)
|
|
15
17
|
@scope = scope
|
|
16
18
|
@connection = LlmCostTracker::LlmApiCall.connection
|
|
19
|
+
@limit = normalized_limit(limit)
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def rows
|
|
@@ -32,7 +35,7 @@ module LlmCostTracker
|
|
|
32
35
|
|
|
33
36
|
private
|
|
34
37
|
|
|
35
|
-
attr_reader :scope, :connection
|
|
38
|
+
attr_reader :scope, :connection, :limit
|
|
36
39
|
|
|
37
40
|
def subquery
|
|
38
41
|
scope.to_sql
|
|
@@ -62,6 +65,7 @@ module LlmCostTracker
|
|
|
62
65
|
AND sub.tags != ''
|
|
63
66
|
GROUP BY jt.key
|
|
64
67
|
ORDER BY calls_count DESC
|
|
68
|
+
LIMIT #{limit}
|
|
65
69
|
SQL
|
|
66
70
|
end
|
|
67
71
|
|
|
@@ -76,6 +80,7 @@ module LlmCostTracker
|
|
|
76
80
|
AND sub.tags::jsonb <> '{}'::jsonb
|
|
77
81
|
GROUP BY key
|
|
78
82
|
ORDER BY calls_count DESC
|
|
83
|
+
LIMIT #{limit}
|
|
79
84
|
SQL
|
|
80
85
|
end
|
|
81
86
|
|
|
@@ -91,8 +96,14 @@ module LlmCostTracker
|
|
|
91
96
|
AND sub.tags != ''
|
|
92
97
|
GROUP BY je.key
|
|
93
98
|
ORDER BY calls_count DESC
|
|
99
|
+
LIMIT #{limit}
|
|
94
100
|
SQL
|
|
95
101
|
end
|
|
102
|
+
|
|
103
|
+
def normalized_limit(value)
|
|
104
|
+
value = value.to_i
|
|
105
|
+
value.positive? ? [value, DEFAULT_LIMIT].min : DEFAULT_LIMIT
|
|
106
|
+
end
|
|
96
107
|
end
|
|
97
108
|
end
|
|
98
109
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<% if entries.empty? %>
|
|
3
3
|
<span class="lct-tag-empty">(untagged)</span>
|
|
4
4
|
<% else %>
|
|
5
|
-
<span class="lct-tag-chips" title="<%=
|
|
5
|
+
<span class="lct-tag-chips" title="<%= tag_chips_title(tags) %>">
|
|
6
6
|
<% entries.each do |entry| %>
|
|
7
7
|
<% if entry[:more] %>
|
|
8
8
|
<span class="lct-tag-chip lct-tag-chip-more">+<%= entry[:more] %></span>
|
|
@@ -50,6 +50,10 @@
|
|
|
50
50
|
<span><strong><%= percent(coverage_percent(@tagged_calls, @total_calls)) %></strong> coverage</span>
|
|
51
51
|
<span><strong><%= number(@distinct_values) %></strong> distinct values</span>
|
|
52
52
|
</p>
|
|
53
|
+
|
|
54
|
+
<% if @tag_values_limited %>
|
|
55
|
+
<p class="lct-toolbar-note">Showing top <%= number(@tag_value_limit) %> values by spend.</p>
|
|
56
|
+
<% end %>
|
|
53
57
|
</section>
|
|
54
58
|
|
|
55
59
|
<% if @rows.empty? %>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
LLM Cost Tracker is a provider-agnostic billing ledger. Core code should model durable billing concepts, not the naming quirks of one provider or one model family.
|
|
4
|
+
|
|
5
|
+
Core vocabulary belongs in provider-neutral terms:
|
|
6
|
+
|
|
7
|
+
- `input_tokens`
|
|
8
|
+
- `cache_read_input_tokens`
|
|
9
|
+
- `cache_write_input_tokens`
|
|
10
|
+
- `output_tokens`
|
|
11
|
+
- `hidden_output_tokens`
|
|
12
|
+
- `pricing_mode`
|
|
13
|
+
- `provider_response_id`
|
|
14
|
+
|
|
15
|
+
Provider-specific names belong only at ingestion boundaries: parsers and stream adapters. Those adapters translate raw fields into the canonical ledger vocabulary before data reaches `Tracker`, `Pricing`, storage, dashboard services, or reports.
|
|
16
|
+
|
|
17
|
+
Pricing logic should prefer generic mechanisms over provider branches. Use provider/model price entries only for lookup and rate selection. Use `pricing_mode` plus mode-prefixed price keys for alternate billing modes instead of adding model-specific conditionals.
|
|
18
|
+
|
|
19
|
+
Tags remain the extension point for app-specific attribution such as tenant, user, feature, trace, job, workflow, or agent session. Do not promote those dimensions into first-class columns unless the ledger itself needs them for provider-agnostic billing behavior.
|
|
20
|
+
|
|
21
|
+
Hot-path guardrails must not aggregate over the growing call ledger. ActiveRecord period budgets should read maintained rows in `llm_cost_tracker_period_totals`; dashboard analytics may run grouped queries because they are user-initiated reporting paths.
|
|
22
|
+
|
|
23
|
+
## Technical Docs
|
|
24
|
+
|
|
25
|
+
- [Module map](technical/module-map.md)
|
|
26
|
+
- [Data flow](technical/data-flow.md)
|
|
27
|
+
- [Extension points](technical/extension-points.md)
|
|
28
|
+
- [Operational notes](technical/operational-notes.md)
|
data/docs/budgets.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Budgets and Guardrails
|
|
2
|
+
|
|
3
|
+
Budgets are safety rails for a Rails app using LLMs in production. They are not
|
|
4
|
+
invoice reconciliation and they are not a transactional quota system.
|
|
5
|
+
|
|
6
|
+
The full behavior reference is moving here from the README: monthly, daily, and
|
|
7
|
+
per-call budgets; notification payloads; preflight behavior; and failure modes.
|
|
8
|
+
|
|
9
|
+
## Canonical Sources
|
|
10
|
+
|
|
11
|
+
Until this page is expanded, use:
|
|
12
|
+
|
|
13
|
+
- [Budgets](../README.md#budgets)
|
|
14
|
+
- [Known limitations](../README.md#known-limitations)
|
|
15
|
+
- [Operations](operations.md)
|
|
16
|
+
|
|
17
|
+
## Behaviors
|
|
18
|
+
|
|
19
|
+
- `:notify`: call `on_budget_exceeded` after a priced event crosses a limit.
|
|
20
|
+
- `:raise`: record the event, then raise `BudgetExceededError`.
|
|
21
|
+
- `:block_requests`: preflight future calls when stored period totals are
|
|
22
|
+
already over budget.
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
config.monthly_budget = 500.00
|
|
26
|
+
config.daily_budget = 50.00
|
|
27
|
+
config.per_call_budget = 2.00
|
|
28
|
+
config.budget_exceeded_behavior = :block_requests
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`:block_requests` needs ActiveRecord storage for shared period totals. Under
|
|
32
|
+
concurrency it stops the next request after overspend is visible; it does not
|
|
33
|
+
make provider spend transactional.
|
|
34
|
+
|
|
35
|
+
## Error Payload
|
|
36
|
+
|
|
37
|
+
`BudgetExceededError` exposes:
|
|
38
|
+
|
|
39
|
+
- `budget_type`
|
|
40
|
+
- `total`
|
|
41
|
+
- `budget`
|
|
42
|
+
- `monthly_total`
|
|
43
|
+
- `daily_total`
|
|
44
|
+
- `call_cost`
|
|
45
|
+
- `last_event`
|