llm_cost_tracker 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/CODE_OF_CONDUCT.md +23 -0
- data/README.md +86 -8
- data/SECURITY.md +36 -0
- data/app/assets/llm_cost_tracker/application.css +1 -4
- data/app/controllers/llm_cost_tracker/assets_controller.rb +0 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
- data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +10 -10
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -26
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
- data/app/services/llm_cost_tracker/pagination.rb +1 -9
- data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
- data/app/views/llm_cost_tracker/calls/index.html.erb +13 -13
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +36 -14
- data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +0 -1
- data/lib/llm_cost_tracker/event.rb +1 -0
- data/lib/llm_cost_tracker/event_metadata.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +2 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +6 -2
- data/lib/llm_cost_tracker/middleware/faraday.rb +1 -0
- data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
- data/lib/llm_cost_tracker/parsed_usage.rb +14 -3
- data/lib/llm_cost_tracker/parsers/anthropic.rb +47 -28
- data/lib/llm_cost_tracker/parsers/gemini.rb +28 -4
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -6
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +14 -0
- data/lib/llm_cost_tracker/price_registry.rb +22 -7
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
- data/lib/llm_cost_tracker/price_sync.rb +16 -184
- data/lib/llm_cost_tracker/pricing.rb +0 -11
- data/lib/llm_cost_tracker/railtie.rb +2 -1
- data/lib/llm_cost_tracker/report.rb +0 -5
- data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -11
- data/lib/llm_cost_tracker/stream_collector.rb +17 -13
- data/lib/llm_cost_tracker/tags_column.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +10 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +6 -14
- data/llm_cost_tracker.gemspec +3 -1
- metadata +37 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6952282e6f93b4e5658ef9d2b9527d2a332cb2d6f483da25540c3a0d6672ed9b
|
|
4
|
+
data.tar.gz: e66eaaeb99698abf9c0ff9e3f1305e6bb27a8b6c25355e94bce4baec5f5d3a50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '078d695498ed6f254a700ccb3381ace3feaa1f4880691a1a69be4bb435097202ecf28f2cbf065202a543186bdd3894aaedcc44bfacc2d2ffa25a54ddf6d1cc76'
|
|
7
|
+
data.tar.gz: 2638ae3c579bd2c0f71a73b06719eb0a35d7b82e8614a74c5100f41b69693babfd3b613ecf18e7f6e7ea33210222432efa5a7ca718325c4c8fd12be9b8ab806e
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.2] - 2026-04-22
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Test coverage reporting via SimpleCov with LCOV upload to Codecov from CI.
|
|
12
|
+
- Repository governance files: `CODE_OF_CONDUCT.md`, `SECURITY.md`, `CONTRIBUTING.md`, and GitHub issue templates.
|
|
13
|
+
|
|
14
|
+
## [0.3.1] - 2026-04-22
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `provider_response_id` persistence, parser extraction, and Data Quality coverage for provider-issued response object IDs.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Simplified dashboard helpers, filter normalization, and view templates without changing dashboard behavior.
|
|
23
|
+
- Split `PriceSync` internals into smaller components and removed redundant internal wrapper layers.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Removed inline dashboard JavaScript to keep the engine server-rendered.
|
|
28
|
+
- Reset ActiveRecord model column information in storage specs to avoid stale schema state across recreated tables.
|
|
29
|
+
|
|
7
30
|
## [0.3.0] - 2026-04-22
|
|
8
31
|
|
|
9
32
|
### 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
|
+

|
|
29
|
+
|
|
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
|
+
```
|
|
13
42
|
|
|
14
|
-
|
|
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
|
|
|
@@ -78,6 +134,8 @@ Anthropic emits usage in `message_start` + `message_delta` events. Gemini's `:st
|
|
|
78
134
|
|
|
79
135
|
Streamed calls are stored with `stream: true` and `usage_source: "stream_final"`. If the provider never sends final usage, the call is still recorded with `usage_source: "unknown"` so those calls surface on the Data Quality page.
|
|
80
136
|
|
|
137
|
+
When the provider emits a stable response object ID, LLM Cost Tracker stores it as `provider_response_id`. OpenAI and Anthropic are covered end-to-end; Gemini is best effort and may vary by endpoint or API version.
|
|
138
|
+
|
|
81
139
|
For non-Faraday clients (raw `Net::HTTP`, custom SSE code, Azure OpenAI), use the explicit helper:
|
|
82
140
|
|
|
83
141
|
```ruby
|
|
@@ -92,7 +150,16 @@ LlmCostTracker.track_stream(provider: "openai", model: "gpt-4o") do |stream|
|
|
|
92
150
|
end
|
|
93
151
|
```
|
|
94
152
|
|
|
95
|
-
|
|
153
|
+
If your custom streaming client exposes the provider's response object ID after the stream starts, set it explicitly:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
LlmCostTracker.track_stream(provider: "anthropic", model: "claude-sonnet-4-6") do |stream|
|
|
157
|
+
stream.provider_response_id = response.id
|
|
158
|
+
stream.usage(input_tokens: 120, output_tokens: 45)
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Run `bin/rails g llm_cost_tracker:add_streaming` once on existing installs to add the `stream` and `usage_source` columns. Run `bin/rails g llm_cost_tracker:add_provider_response_id` to persist provider-issued response IDs.
|
|
96
163
|
|
|
97
164
|
### Manual tracking
|
|
98
165
|
|
|
@@ -102,6 +169,7 @@ LlmCostTracker.track(
|
|
|
102
169
|
model: "claude-sonnet-4-6",
|
|
103
170
|
input_tokens: 1500,
|
|
104
171
|
output_tokens: 320,
|
|
172
|
+
provider_response_id: "msg_01XFDUDYJgAACzvnptvVoYEL",
|
|
105
173
|
cache_read_input_tokens: 1200,
|
|
106
174
|
feature: "summarizer",
|
|
107
175
|
user_id: current_user.id
|
|
@@ -281,7 +349,7 @@ bin/rails generate llm_cost_tracker:add_latency_ms
|
|
|
281
349
|
bin/rails db:migrate
|
|
282
350
|
```
|
|
283
351
|
|
|
284
|
-
##
|
|
352
|
+
## Mounting the dashboard
|
|
285
353
|
|
|
286
354
|
Optional Rails Engine. Plain ERB, no JavaScript framework, no asset pipeline required. Requires Rails 7.1+; the core middleware works without Rails.
|
|
287
355
|
|
|
@@ -303,7 +371,7 @@ Routes (GET-only; CSV export included):
|
|
|
303
371
|
- `/llm-costs/tags/:key` — breakdown by values of a given tag key
|
|
304
372
|
- `/llm-costs/data_quality` — unknown pricing share, untagged calls, missing latency
|
|
305
373
|
|
|
306
|
-
|
|
374
|
+
No built-in auth is included. Tags carry whatever your app puts in them, so protect the mount point with your application's authentication.
|
|
307
375
|
|
|
308
376
|
### Basic auth
|
|
309
377
|
|
|
@@ -369,20 +437,26 @@ Configured hosts are parsed using the OpenAI-compatible usage shape (`prompt_tok
|
|
|
369
437
|
For providers with a non-OpenAI usage shape:
|
|
370
438
|
|
|
371
439
|
```ruby
|
|
440
|
+
require "uri"
|
|
441
|
+
|
|
372
442
|
class AcmeParser < LlmCostTracker::Parsers::Base
|
|
373
443
|
def match?(url)
|
|
374
|
-
url.to_s
|
|
444
|
+
uri = URI.parse(url.to_s)
|
|
445
|
+
uri.host == "api.acme-llm.example" && uri.path == "/v1/generate"
|
|
446
|
+
rescue URI::InvalidURIError
|
|
447
|
+
false
|
|
375
448
|
end
|
|
376
449
|
|
|
377
450
|
def parse(request_url, request_body, response_status, response_body)
|
|
378
451
|
return nil unless response_status == 200
|
|
379
452
|
|
|
380
|
-
|
|
453
|
+
payload = safe_json_parse(response_body)
|
|
454
|
+
usage = payload&.dig("usage")
|
|
381
455
|
return nil unless usage
|
|
382
456
|
|
|
383
457
|
LlmCostTracker::ParsedUsage.build(
|
|
384
458
|
provider: "acme",
|
|
385
|
-
model:
|
|
459
|
+
model: payload["model"],
|
|
386
460
|
input_tokens: usage["input"] || 0,
|
|
387
461
|
output_tokens: usage["output"] || 0
|
|
388
462
|
)
|
|
@@ -408,9 +482,12 @@ Endpoints: OpenAI Chat Completions / Responses / Completions / Embeddings; OpenA
|
|
|
408
482
|
|
|
409
483
|
## Safety
|
|
410
484
|
|
|
485
|
+
**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.
|
|
486
|
+
|
|
411
487
|
- No external HTTP calls at request-tracking time.
|
|
412
488
|
- No prompt or response bodies stored.
|
|
413
489
|
- Faraday responses not modified.
|
|
490
|
+
- Authorization headers and API keys are never stored or logged.
|
|
414
491
|
- Storage failures non-fatal by default (`storage_error_behavior = :warn`).
|
|
415
492
|
- Budget and unknown-pricing errors are raised only when you opt in.
|
|
416
493
|
|
|
@@ -430,6 +507,7 @@ The gem is designed for multi-threaded hosts — Puma with `max_threads > 1` and
|
|
|
430
507
|
|
|
431
508
|
- `:block_requests` is a best-effort guardrail, not a hard cap. Concurrent workers can pass preflight simultaneously and collectively overshoot the budget. Use an external quota system if you need a transactional cap.
|
|
432
509
|
- Streaming capture relies on the provider emitting a final-usage event (OpenAI needs `stream_options: { include_usage: true }`); missing events are recorded with `usage_source: "unknown"` so they surface on the Data Quality page.
|
|
510
|
+
- `provider_response_id` is stored only when the provider exposes a stable response object ID. Missing IDs stay `nil` and surface on the Data Quality page.
|
|
433
511
|
- Anthropic cache TTL variants (1h vs 5min writes) not modeled separately.
|
|
434
512
|
- OpenAI reasoning tokens included in output totals; separate reasoning-token attribution not stored.
|
|
435
513
|
|
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.
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
.lct-body { margin: 0; }
|
|
38
38
|
|
|
39
39
|
.lct-app {
|
|
40
40
|
background: var(--lct-bg);
|
|
@@ -183,7 +183,6 @@
|
|
|
183
183
|
|
|
184
184
|
.lct-stat-sub { color: var(--lct-muted); font-size: var(--fs-xs); margin: 4px 0 0; }
|
|
185
185
|
|
|
186
|
-
/* Shared "small uppercase-ish label" recipe */
|
|
187
186
|
.lct-stat-label,
|
|
188
187
|
.lct-field label,
|
|
189
188
|
.lct-dl dt,
|
|
@@ -201,7 +200,6 @@
|
|
|
201
200
|
.lct-chip-label { color: var(--lct-accent); font-weight: 700; }
|
|
202
201
|
.lct-field label { color: var(--lct-text); font-size: var(--fs-md); font-weight: 500; }
|
|
203
202
|
|
|
204
|
-
/* Shared "muted body copy" recipe */
|
|
205
203
|
.lct-section-copy,
|
|
206
204
|
.lct-stat-copy,
|
|
207
205
|
.lct-banner-copy,
|
|
@@ -285,7 +283,6 @@
|
|
|
285
283
|
.lct-calls-table td:last-child,
|
|
286
284
|
.lct-calls-table th:last-child { text-align: right; }
|
|
287
285
|
|
|
288
|
-
/* Track + fill primitives — shared by bar / budget / stack */
|
|
289
286
|
.lct-bar-track,
|
|
290
287
|
.lct-budget-track,
|
|
291
288
|
.lct-stack-track {
|
|
@@ -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"
|
|
@@ -6,6 +6,7 @@ module LlmCostTracker
|
|
|
6
6
|
class CallsController < ApplicationController
|
|
7
7
|
CSV_EXPORT_LIMIT = 10_000
|
|
8
8
|
CSV_FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
|
|
9
|
+
DEFAULT_ORDER = "tracked_at DESC, id DESC"
|
|
9
10
|
|
|
10
11
|
def index
|
|
11
12
|
@sort = params[:sort].to_s
|
|
@@ -30,9 +31,6 @@ module LlmCostTracker
|
|
|
30
31
|
|
|
31
32
|
def show
|
|
32
33
|
@call = LlmApiCall.find(params[:id])
|
|
33
|
-
@tags = @call.parsed_tags
|
|
34
|
-
@metadata_available = @call.has_attribute?("metadata")
|
|
35
|
-
@metadata = @call.read_attribute("metadata") if @metadata_available
|
|
36
34
|
@latency_available = LlmApiCall.latency_column?
|
|
37
35
|
end
|
|
38
36
|
|
|
@@ -41,29 +39,26 @@ module LlmCostTracker
|
|
|
41
39
|
def calls_order(sort)
|
|
42
40
|
case sort
|
|
43
41
|
when "expensive"
|
|
44
|
-
"CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC, total_cost DESC, #{
|
|
42
|
+
"CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC, total_cost DESC, #{DEFAULT_ORDER}"
|
|
45
43
|
when "input"
|
|
46
|
-
"input_tokens DESC, #{
|
|
44
|
+
"input_tokens DESC, #{DEFAULT_ORDER}"
|
|
47
45
|
when "output"
|
|
48
|
-
"output_tokens DESC, #{
|
|
46
|
+
"output_tokens DESC, #{DEFAULT_ORDER}"
|
|
49
47
|
when "slow"
|
|
50
|
-
return
|
|
48
|
+
return DEFAULT_ORDER unless LlmApiCall.latency_column?
|
|
51
49
|
|
|
52
|
-
"CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{
|
|
50
|
+
"CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{DEFAULT_ORDER}"
|
|
53
51
|
else
|
|
54
|
-
|
|
52
|
+
DEFAULT_ORDER
|
|
55
53
|
end
|
|
56
54
|
end
|
|
57
55
|
|
|
58
|
-
def default_order
|
|
59
|
-
"tracked_at DESC, id DESC"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
56
|
def render_csv(relation)
|
|
63
57
|
latency = LlmApiCall.latency_column?
|
|
64
58
|
CSV.generate do |csv|
|
|
65
59
|
headers = %w[tracked_at provider model input_tokens output_tokens total_tokens total_cost]
|
|
66
60
|
headers << "latency_ms" if latency
|
|
61
|
+
headers << "provider_response_id" if LlmApiCall.provider_response_id_column?
|
|
67
62
|
headers << "tags"
|
|
68
63
|
csv << headers
|
|
69
64
|
|
|
@@ -78,6 +73,7 @@ module LlmCostTracker
|
|
|
78
73
|
call.total_cost
|
|
79
74
|
]
|
|
80
75
|
row << call.latency_ms if latency
|
|
76
|
+
row << csv_safe(call.provider_response_id) if LlmApiCall.provider_response_id_column?
|
|
81
77
|
row << csv_safe(call.parsed_tags.to_json)
|
|
82
78
|
csv << row
|
|
83
79
|
end
|
|
@@ -5,15 +5,19 @@ module LlmCostTracker
|
|
|
5
5
|
def index
|
|
6
6
|
@from_date, @to_date = overview_range
|
|
7
7
|
prev_from, prev_to = previous_range
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
filter_params = LlmCostTracker::ParameterHash.to_hash(params)
|
|
9
|
+
scope = Dashboard::Filter.call(
|
|
10
|
+
params: filter_params.merge("from" => @from_date.iso8601, "to" => @to_date.iso8601)
|
|
11
|
+
)
|
|
12
|
+
previous_scope = Dashboard::Filter.call(
|
|
13
|
+
params: filter_params.merge("from" => prev_from.iso8601, "to" => prev_to.iso8601)
|
|
14
|
+
)
|
|
11
15
|
|
|
12
16
|
@stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
|
|
13
17
|
@time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
|
|
14
18
|
@comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
|
|
15
19
|
@spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
|
|
16
|
-
@top_models =
|
|
20
|
+
@top_models = Dashboard::TopModels.call(scope: scope)
|
|
17
21
|
@providers = Dashboard::ProviderBreakdown.call(scope: scope)
|
|
18
22
|
end
|
|
19
23
|
|
|
@@ -32,21 +36,6 @@ module LlmCostTracker
|
|
|
32
36
|
[prev_from, prev_to]
|
|
33
37
|
end
|
|
34
38
|
|
|
35
|
-
def overview_filter_params
|
|
36
|
-
params.to_unsafe_h.merge(
|
|
37
|
-
"from" => @from_date.iso8601,
|
|
38
|
-
"to" => @to_date.iso8601
|
|
39
|
-
)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def previous_filter_params
|
|
43
|
-
prev_from, prev_to = previous_range
|
|
44
|
-
params.to_unsafe_h.merge(
|
|
45
|
-
"from" => prev_from.iso8601,
|
|
46
|
-
"to" => prev_to.iso8601
|
|
47
|
-
)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
39
|
def parsed_date(value)
|
|
51
40
|
return nil if value.to_s.strip.empty?
|
|
52
41
|
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class DataQualityController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
|
|
7
|
-
@stats = Dashboard::DataQuality.call(scope: scope)
|
|
6
|
+
@stats = Dashboard::DataQuality.call(scope: Dashboard::Filter.call(params: params))
|
|
8
7
|
end
|
|
9
8
|
end
|
|
10
9
|
end
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class ModelsController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
scope = Dashboard::Filter.call(params: params)
|
|
7
6
|
@sort = params[:sort].to_s
|
|
8
|
-
@rows = Dashboard::TopModels.call(
|
|
7
|
+
@rows = Dashboard::TopModels.call(
|
|
8
|
+
scope: Dashboard::Filter.call(params: params),
|
|
9
|
+
limit: nil,
|
|
10
|
+
sort: @sort
|
|
11
|
+
)
|
|
9
12
|
@latency_available = LlmApiCall.latency_column?
|
|
10
13
|
end
|
|
11
14
|
end
|
|
@@ -3,14 +3,12 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class TagsController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
|
|
7
|
-
@rows = Dashboard::TagKeyExplorer.call(scope: scope)
|
|
6
|
+
@rows = Dashboard::TagKeyExplorer.call(scope: Dashboard::Filter.call(params: params))
|
|
8
7
|
end
|
|
9
8
|
|
|
10
9
|
def show
|
|
11
10
|
@tag_key = params[:key]
|
|
12
|
-
|
|
13
|
-
@rows = Dashboard::TagBreakdown.call(scope: scope, key: @tag_key)
|
|
11
|
+
@rows = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: @tag_key)
|
|
14
12
|
@total_calls = @rows.sum(&:calls)
|
|
15
13
|
|
|
16
14
|
tagged_rows = @rows.reject { |r| r.value == "(untagged)" }
|
|
@@ -13,7 +13,7 @@ module LlmCostTracker
|
|
|
13
13
|
private
|
|
14
14
|
|
|
15
15
|
def filter_options_for(column, filter_params:)
|
|
16
|
-
source =
|
|
16
|
+
source = LlmCostTracker::ParameterHash.to_hash(filter_params)
|
|
17
17
|
scope_params = source.stringify_keys.merge(
|
|
18
18
|
column.to_s => nil, "format" => nil, "page" => nil, "per" => nil, "sort" => nil
|
|
19
19
|
)
|
|
@@ -24,11 +24,5 @@ module LlmCostTracker
|
|
|
24
24
|
values.unshift(current) if current && !values.include?(current)
|
|
25
25
|
values
|
|
26
26
|
end
|
|
27
|
-
|
|
28
|
-
def filter_source_hash(filter_params)
|
|
29
|
-
return filter_params.to_unsafe_h if filter_params.respond_to?(:to_unsafe_h)
|
|
30
|
-
|
|
31
|
-
filter_params.to_h
|
|
32
|
-
end
|
|
33
27
|
end
|
|
34
28
|
end
|
|
@@ -19,18 +19,14 @@ module LlmCostTracker
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def normalized_query_tags(tags)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
tags = tags.to_unsafe_h if tags.respond_to?(:to_unsafe_h)
|
|
25
|
-
tags = tags.to_h if tags.respond_to?(:to_h)
|
|
26
|
-
return {} unless tags.is_a?(Hash)
|
|
27
|
-
|
|
28
|
-
tags.transform_keys(&:to_s).transform_values(&:to_s)
|
|
22
|
+
LlmCostTracker::ParameterHash.to_hash(tags).transform_keys(&:to_s).transform_values(&:to_s)
|
|
29
23
|
end
|
|
30
24
|
|
|
31
25
|
def clean_dashboard_query(value)
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
if LlmCostTracker::ParameterHash.hash_like?(value)
|
|
27
|
+
return clean_dashboard_hash(LlmCostTracker::ParameterHash.to_hash(value))
|
|
28
|
+
end
|
|
29
|
+
|
|
34
30
|
return clean_dashboard_array(value) if value.is_a?(Array)
|
|
35
31
|
return clean_dashboard_string(value) if value.is_a?(String)
|
|
36
32
|
|
|
@@ -11,6 +11,8 @@ module LlmCostTracker
|
|
|
11
11
|
:streaming_count,
|
|
12
12
|
:streaming_missing_usage_count,
|
|
13
13
|
:stream_column_present,
|
|
14
|
+
:missing_provider_response_id_count,
|
|
15
|
+
:provider_response_id_column_present,
|
|
14
16
|
:unknown_pricing_by_model
|
|
15
17
|
)
|
|
16
18
|
|
|
@@ -20,6 +22,7 @@ module LlmCostTracker
|
|
|
20
22
|
total = scope.count
|
|
21
23
|
latency_present = LlmCostTracker::LlmApiCall.latency_column?
|
|
22
24
|
stream_present = LlmCostTracker::LlmApiCall.stream_column?
|
|
25
|
+
provider_response_id_present = LlmCostTracker::LlmApiCall.provider_response_id_column?
|
|
23
26
|
|
|
24
27
|
DataQualityStats.new(
|
|
25
28
|
total_calls: total,
|
|
@@ -28,8 +31,14 @@ module LlmCostTracker
|
|
|
28
31
|
missing_latency_count: latency_present ? scope.where(latency_ms: nil).count : nil,
|
|
29
32
|
latency_column_present: latency_present,
|
|
30
33
|
streaming_count: stream_present ? scope.streaming.count : nil,
|
|
31
|
-
streaming_missing_usage_count:
|
|
34
|
+
streaming_missing_usage_count: if stream_present && LlmCostTracker::LlmApiCall.usage_source_column?
|
|
35
|
+
scope.streaming_missing_usage.count
|
|
36
|
+
end,
|
|
32
37
|
stream_column_present: stream_present,
|
|
38
|
+
missing_provider_response_id_count: (
|
|
39
|
+
provider_response_id_present ? scope.missing_provider_response_id.count : nil
|
|
40
|
+
),
|
|
41
|
+
provider_response_id_column_present: provider_response_id_present,
|
|
33
42
|
unknown_pricing_by_model: scope.unknown_pricing
|
|
34
43
|
.group(:model)
|
|
35
44
|
.order(Arel.sql("COUNT(*) DESC"))
|
|
@@ -38,15 +47,6 @@ module LlmCostTracker
|
|
|
38
47
|
.to_h
|
|
39
48
|
)
|
|
40
49
|
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
def streaming_missing_usage_count(scope, stream_present)
|
|
45
|
-
return nil unless stream_present
|
|
46
|
-
return nil unless LlmCostTracker::LlmApiCall.usage_source_column?
|
|
47
|
-
|
|
48
|
-
scope.streaming_missing_usage.count
|
|
49
|
-
end
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
end
|
|
@@ -4,10 +4,6 @@ require "date"
|
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Dashboard
|
|
7
|
-
# Parses dashboard params into an ActiveRecord relation.
|
|
8
|
-
#
|
|
9
|
-
# Invalid dates are ignored, pagination is handled elsewhere, and invalid
|
|
10
|
-
# tag keys raise InvalidFilterError so controllers can fail closed with HTTP 400.
|
|
11
7
|
class Filter
|
|
12
8
|
class << self
|
|
13
9
|
def call(scope: LlmCostTracker::LlmApiCall.all, params: {})
|
|
@@ -17,7 +13,7 @@ module LlmCostTracker
|
|
|
17
13
|
|
|
18
14
|
def initialize(scope:, params:)
|
|
19
15
|
@scope = scope
|
|
20
|
-
@params =
|
|
16
|
+
@params = LlmCostTracker::ParameterHash.with_indifferent_access(params)
|
|
21
17
|
end
|
|
22
18
|
|
|
23
19
|
def relation
|
|
@@ -34,15 +30,6 @@ module LlmCostTracker
|
|
|
34
30
|
|
|
35
31
|
attr_reader :scope, :params
|
|
36
32
|
|
|
37
|
-
def normalize_params(params)
|
|
38
|
-
return {}.with_indifferent_access if params.nil?
|
|
39
|
-
|
|
40
|
-
raw = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
|
|
41
|
-
raw.with_indifferent_access
|
|
42
|
-
rescue NoMethodError
|
|
43
|
-
{}.with_indifferent_access
|
|
44
|
-
end
|
|
45
|
-
|
|
46
33
|
def apply_date_filters(relation)
|
|
47
34
|
from = parse_date(:from)&.beginning_of_day
|
|
48
35
|
to = parse_date(:to)&.end_of_day
|
|
@@ -53,7 +40,7 @@ module LlmCostTracker
|
|
|
53
40
|
end
|
|
54
41
|
|
|
55
42
|
def apply_exact_filter(relation, key)
|
|
56
|
-
value =
|
|
43
|
+
value = normalized_string(params[key])
|
|
57
44
|
return relation if value.nil?
|
|
58
45
|
|
|
59
46
|
relation.where(key => value)
|
|
@@ -67,7 +54,7 @@ module LlmCostTracker
|
|
|
67
54
|
end
|
|
68
55
|
|
|
69
56
|
def apply_stream_filter(relation)
|
|
70
|
-
value =
|
|
57
|
+
value = normalized_string(params[:stream])
|
|
71
58
|
return relation if value.nil?
|
|
72
59
|
return relation unless relation.klass.stream_column?
|
|
73
60
|
|
|
@@ -79,7 +66,7 @@ module LlmCostTracker
|
|
|
79
66
|
end
|
|
80
67
|
|
|
81
68
|
def apply_usage_source_filter(relation)
|
|
82
|
-
value =
|
|
69
|
+
value = normalized_string(params[:usage_source])
|
|
83
70
|
return relation if value.nil?
|
|
84
71
|
return relation unless relation.klass.usage_source_column?
|
|
85
72
|
|
|
@@ -98,14 +85,11 @@ module LlmCostTracker
|
|
|
98
85
|
end
|
|
99
86
|
|
|
100
87
|
def hash_param(key)
|
|
101
|
-
|
|
102
|
-
raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
|
|
103
|
-
raw = raw.to_h if raw.respond_to?(:to_h)
|
|
104
|
-
raw.is_a?(Hash) ? raw : {}
|
|
88
|
+
LlmCostTracker::ParameterHash.to_hash(params[key])
|
|
105
89
|
end
|
|
106
90
|
|
|
107
91
|
def parse_date(key)
|
|
108
|
-
value =
|
|
92
|
+
value = normalized_string(params[key])
|
|
109
93
|
return nil if value.nil?
|
|
110
94
|
|
|
111
95
|
Date.iso8601(value)
|
|
@@ -113,10 +97,6 @@ module LlmCostTracker
|
|
|
113
97
|
nil
|
|
114
98
|
end
|
|
115
99
|
|
|
116
|
-
def string_param(key)
|
|
117
|
-
normalized_string(params[key])
|
|
118
|
-
end
|
|
119
|
-
|
|
120
100
|
def normalized_string(value)
|
|
121
101
|
return nil if value.nil?
|
|
122
102
|
|