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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/CODE_OF_CONDUCT.md +23 -0
  4. data/README.md +86 -8
  5. data/SECURITY.md +36 -0
  6. data/app/assets/llm_cost_tracker/application.css +1 -4
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +0 -2
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
  9. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
  10. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
  11. data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
  12. data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +10 -10
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -26
  17. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
  18. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
  19. data/app/services/llm_cost_tracker/pagination.rb +1 -9
  20. data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
  21. data/app/views/llm_cost_tracker/calls/index.html.erb +13 -13
  22. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
  23. data/app/views/llm_cost_tracker/dashboard/index.html.erb +1 -1
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +36 -14
  25. data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
  26. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
  27. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
  28. data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
  29. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  30. data/lib/llm_cost_tracker/configuration.rb +0 -1
  31. data/lib/llm_cost_tracker/event.rb +1 -0
  32. data/lib/llm_cost_tracker/event_metadata.rb +1 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +2 -0
  36. data/lib/llm_cost_tracker/llm_api_call.rb +6 -2
  37. data/lib/llm_cost_tracker/middleware/faraday.rb +1 -0
  38. data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
  39. data/lib/llm_cost_tracker/parsed_usage.rb +14 -3
  40. data/lib/llm_cost_tracker/parsers/anthropic.rb +47 -28
  41. data/lib/llm_cost_tracker/parsers/gemini.rb +28 -4
  42. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -6
  43. data/lib/llm_cost_tracker/parsers/openai_usage.rb +14 -0
  44. data/lib/llm_cost_tracker/price_registry.rb +22 -7
  45. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
  46. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
  47. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
  48. data/lib/llm_cost_tracker/price_sync.rb +16 -184
  49. data/lib/llm_cost_tracker/pricing.rb +0 -11
  50. data/lib/llm_cost_tracker/railtie.rb +2 -1
  51. data/lib/llm_cost_tracker/report.rb +0 -5
  52. data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -11
  53. data/lib/llm_cost_tracker/stream_collector.rb +17 -13
  54. data/lib/llm_cost_tracker/tags_column.rb +4 -0
  55. data/lib/llm_cost_tracker/tracker.rb +10 -2
  56. data/lib/llm_cost_tracker/version.rb +1 -1
  57. data/lib/llm_cost_tracker.rb +6 -14
  58. data/llm_cost_tracker.gemspec +3 -1
  59. metadata +37 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b20da957651521f022866af9d4735a4ef53d52a2dc3c278b8b2a90e1d7a7f98
4
- data.tar.gz: ea98b2a7505d99c5f78d7756d0adc50224c4fdc88000fa5ec81be4450c9200f1
3
+ metadata.gz: 6952282e6f93b4e5658ef9d2b9527d2a332cb2d6f483da25540c3a0d6672ed9b
4
+ data.tar.gz: e66eaaeb99698abf9c0ff9e3f1305e6bb27a8b6c25355e94bce4baec5f5d3a50
5
5
  SHA512:
6
- metadata.gz: 9ca709080d46395ac32b9a2931b4b3cb7d4df6016b73bad3579cb1decdd046be21a2fb67c06e96876013a754a113e9ce5987ed0e27792b312716324bdb5f9adb
7
- data.tar.gz: 445b77222180802f208246a2e25b30e5e0a5679d2d5b84a2ba00d1e2fc97a5cf3127521be13f415c6d76bbcc056dd0bdfe6ade937eb9d67d737ce6b6548665fa
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
@@ -0,0 +1,23 @@
1
+ # Code of Conduct
2
+
3
+ This project adopts the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1, as its code of conduct. The full text is available at:
4
+
5
+ <https://www.contributor-covenant.org/version/2/1/code_of_conduct/>
6
+
7
+ ## Our pledge
8
+
9
+ We as contributors and maintainers pledge to make participation in this project a welcoming, respectful, and inclusive experience for everyone.
10
+
11
+ ## Scope
12
+
13
+ This Code of Conduct applies within all project spaces (issues, pull requests, discussions, commit messages, review comments) and in public spaces when an individual is representing the project.
14
+
15
+ ## Reporting
16
+
17
+ Instances of unacceptable behavior may be reported to the project maintainer at **sergey@mm.st**. All reports will be reviewed and investigated promptly and fairly. The maintainer is obligated to respect the privacy and safety of the reporter of any incident.
18
+
19
+ ## Enforcement
20
+
21
+ Maintainers are responsible for clarifying and enforcing the standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, or harmful. This may include warnings, temporary bans, or permanent removal from project participation.
22
+
23
+ For the full set of community standards, enforcement guidelines, and attribution, see the canonical document linked above.
data/README.md CHANGED
@@ -4,14 +4,70 @@
4
4
 
5
5
  [![Gem Version](https://img.shields.io/gem/v/llm_cost_tracker.svg)](https://rubygems.org/gems/llm_cost_tracker)
6
6
  [![CI](https://github.com/sergey-homenko/llm_cost_tracker/actions/workflows/ruby.yml/badge.svg)](https://github.com/sergey-homenko/llm_cost_tracker/actions)
7
+ [![codecov](https://codecov.io/gh/sergey-homenko/llm_cost_tracker/branch/main/graph/badge.svg)](https://codecov.io/gh/sergey-homenko/llm_cost_tracker)
8
+
9
+ Requires Ruby 3.3+, Rails/ActiveRecord 7.1+, and Faraday 2.0+.
10
+ Core tracking works without Rails; the mounted dashboard requires Rails 7.1+.
7
11
 
8
12
  ## Why
9
13
 
10
14
  Every Rails app with LLM integrations eventually runs into the same question: where did that invoice come from? Full observability platforms like Langfuse and Helicone solve a broader set of problems; sometimes you just need a small Rails-native ledger in your own database.
11
15
 
12
- `llm_cost_tracker` is built for that. It plugs into Faraday or lets you record usage explicitly with `track` / `track_stream`, looks up pricing locally, and writes an event. You end up with a ledger you can query with plain ActiveRecord, slice by any tag dimension, and optionally surface on a built-in dashboard. No proxy, no SaaS, no separate service to run.
16
+ ## What You Get
17
+
18
+ - A local ActiveRecord ledger of provider, model, tokens, cost, latency, tags, streaming usage, and provider response IDs
19
+ - Faraday middleware plus explicit `track` / `track_stream` helpers for non-Faraday clients
20
+ - Server-rendered Rails dashboard with overview, calls, tags, CSV export, and data-quality pages
21
+ - Local pricing snapshots, price sync tasks, and budget guardrails
22
+ - Prompt and response bodies are never persisted
23
+
24
+ ## Dashboard
25
+
26
+ LLM Cost Tracker ships with an optional server-rendered Rails Engine dashboard for spend review, attribution, and data quality checks.
27
+
28
+ ![LLM Cost Tracker dashboard](docs/dashboard-overview.png)
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
- It is not a tracing platform, prompt CMS, eval system, or gateway. The goal is to answer _"what did this app spend on LLM APIs, and where did that spend come from?"_ clearly enough to make spend review routine.
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
- Run `bin/rails g llm_cost_tracker:add_streaming` once on existing installs to add the `stream` and `usage_source` columns.
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
- ## Dashboard (optional)
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
- > ⚠️ **No built-in auth.** Tags carry whatever your app puts in them. Protect the mount point with your application's authentication.
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.include?("api.acme-llm.example")
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
- usage = safe_json_parse(response_body)&.dig("usage")
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: safe_json_parse(response_body)["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
- body:has(> .lct-app) { margin: 0; }
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, #{default_order}"
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, #{default_order}"
44
+ "input_tokens DESC, #{DEFAULT_ORDER}"
47
45
  when "output"
48
- "output_tokens DESC, #{default_order}"
46
+ "output_tokens DESC, #{DEFAULT_ORDER}"
49
47
  when "slow"
50
- return default_order unless LlmApiCall.latency_column?
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, #{default_order}"
50
+ "CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{DEFAULT_ORDER}"
53
51
  else
54
- default_order
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
- scope = Dashboard::Filter.call(params: overview_filter_params)
9
- previous_scope = Dashboard::Filter.call(params: previous_filter_params)
10
- model_rows = Dashboard::TopModels.call(scope: scope, limit: 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 = model_rows.first(5)
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
- scope = Dashboard::Filter.call(params: params)
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(scope: scope, limit: nil, sort: @sort)
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
- scope = Dashboard::Filter.call(params: params)
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
- scope = Dashboard::Filter.call(params: params)
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 = filter_source_hash(filter_params)
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
- return {} unless tags
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
- return clean_dashboard_hash(value.to_unsafe_h) if value.is_a?(ActionController::Parameters)
33
- return clean_dashboard_hash(value) if value.is_a?(Hash)
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: streaming_missing_usage_count(scope, stream_present),
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 = normalize_params(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 = string_param(key)
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 = string_param(:stream)
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 = string_param(:usage_source)
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
- raw = params[key]
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 = string_param(key)
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