llm_cost_tracker 0.12.0 → 0.14.0

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -16
  3. data/README.md +16 -28
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +3 -2
  5. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +2 -0
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  7. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -4
  8. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +11 -0
  9. data/app/models/llm_cost_tracker/call.rb +9 -3
  10. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  11. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +1 -0
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +25 -0
  13. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  14. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  16. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  17. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -10
  18. data/app/views/llm_cost_tracker/data_quality/index.html.erb +22 -0
  19. data/app/views/llm_cost_tracker/pricing/index.html.erb +1 -1
  20. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  21. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  22. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  23. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  24. data/lib/llm_cost_tracker/budget/per_tag.rb +159 -0
  25. data/lib/llm_cost_tracker/budget.rb +120 -32
  26. data/lib/llm_cost_tracker/capture/stream_collector.rb +5 -4
  27. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  28. data/lib/llm_cost_tracker/charges/line_item.rb +3 -2
  29. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  30. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  31. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  32. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  33. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  34. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  35. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  36. data/lib/llm_cost_tracker/configuration.rb +69 -125
  37. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  38. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  39. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  40. data/lib/llm_cost_tracker/doctor.rb +4 -4
  41. data/lib/llm_cost_tracker/engine.rb +4 -0
  42. data/lib/llm_cost_tracker/errors.rb +12 -3
  43. data/lib/llm_cost_tracker/event.rb +3 -1
  44. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  49. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  53. data/lib/llm_cost_tracker/ingestion/batch.rb +27 -7
  54. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  55. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  56. data/lib/llm_cost_tracker/integrations/anthropic.rb +8 -7
  57. data/lib/llm_cost_tracker/integrations/base.rb +1 -1
  58. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +10 -12
  59. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  60. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -4
  61. data/lib/llm_cost_tracker/ledger/store.rb +16 -2
  62. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  63. data/lib/llm_cost_tracker/logging.rb +3 -5
  64. data/lib/llm_cost_tracker/middleware/faraday.rb +1 -1
  65. data/lib/llm_cost_tracker/prices.json +1384 -304
  66. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  67. data/lib/llm_cost_tracker/pricing/calculation.rb +44 -25
  68. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  69. data/lib/llm_cost_tracker/pricing/matcher.rb +24 -3
  70. data/lib/llm_cost_tracker/pricing/mode.rb +7 -7
  71. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  72. data/lib/llm_cost_tracker/pricing/registry.rb +5 -5
  73. data/lib/llm_cost_tracker/pricing/sync.rb +1 -1
  74. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  75. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  76. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  77. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +5 -2
  78. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +5 -4
  79. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +2 -2
  80. data/lib/llm_cost_tracker/railtie.rb +3 -7
  81. data/lib/llm_cost_tracker/report/data.rb +2 -2
  82. data/lib/llm_cost_tracker/retention.rb +22 -10
  83. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  84. data/lib/llm_cost_tracker/tags/sanitizer.rb +4 -4
  85. data/lib/llm_cost_tracker/tracker.rb +16 -26
  86. data/lib/llm_cost_tracker/usage/catalog.rb +12 -2
  87. data/lib/llm_cost_tracker/usage/dimensions.yml +0 -24
  88. data/lib/llm_cost_tracker/usage/token_usage.rb +20 -75
  89. data/lib/llm_cost_tracker/version.rb +1 -1
  90. data/lib/llm_cost_tracker.rb +7 -2
  91. data/lib/tasks/llm_cost_tracker.rake +23 -10
  92. metadata +22 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3bb624cf9437e2ab972021128ab552b48b16c9b8d209429fb264062837e8547
4
- data.tar.gz: 8785221213ed888a592b312e5a734193637653930ef9652ece73f650cb920eb5
3
+ metadata.gz: ffc5fef0c3c18c6926b6c414d56fb0b7c9c4afef6db315139f8bc4626864611a
4
+ data.tar.gz: 3b012c1014c7246dd0eebb5413e13c02023cce056312e719a96328e86de749a8
5
5
  SHA512:
6
- metadata.gz: c223c14dbfe3e2ebf61930175ae7607c2a4a05f502962963312c4ec929965242fccab115eda9d1426d6e331d7fb23ad811f73c9ba8a795cb3262c3d49a60eb45
7
- data.tar.gz: 6b8e3ef019f41907909bb9f07eb58085dd6355a442699d52440de564c97fb6fb65979ee01271e4741bd9411ce7ef6a3b69102accd764fdf419d42db1bdb2f6e8
6
+ metadata.gz: b28d7d695a1d6c45fe6c9d77d4f5caebbbde84682b7a261e25123d280181d00361baae80e9ddd65def0b9602c385d48813467ae741a5cde4bc378e4652a45829
7
+ data.tar.gz: 617cafb3be05250fd7c9c0b049a37474e1e0a2737d72cfd2744110cd531b68c362e7d15928f54d9a9c2d810da3f451516ff9ae747103bca2d618b77159359a13
data/CHANGELOG.md CHANGED
@@ -2,7 +2,72 @@
2
2
 
3
3
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/spec/v2.0.0.html).
4
4
 
5
- ## [Unreleased]
5
+ ## [0.14.0] - 2026-08-26
6
+
7
+ ### Added
8
+
9
+ - `config.budgets.per_tag` applies one budget to every distinct value of each declared tag — `{ tenant_id: { monthly: 1000 }, user_id: { daily: 25 } }` gives every tenant its own 1000 a month and every user its own 25 a day, for as many tags as you declare. Windows are `daily`, `weekly`, and `monthly`; a rule can set its own `behavior` and `on_exceeded` or fall back to the global ones, and the payload names the tag and value that crossed. A fresh install is ready for it; an install created before v0.14 runs `bin/rails generate llm_cost_tracker:upgrade_per_tag_budgets`, then `bin/rails llm_cost_tracker:backfill_tag_costs` to count spend recorded before the upgrade.
10
+ - The Data Quality page names any `config.budgets.per_tag` tag that no recorded call carries, so a mistyped tag name shows up as a budget that can never fire instead of silently enforcing nothing. The list clears itself as soon as a call arrives with that tag, and stays hidden until something has been tagged at all.
11
+ - Gemini calls that ground against Google Search are costed. The rate comes from each model's own published `grounding_request` price — $35 per 1,000 on Gemini 2.x, $14 on 3.x — so a grounded call lands `complete` instead of `unknown`. Google's free monthly allowance is account-level and is not modelled, so a project still inside it is over-reported.
12
+ - OpenAI's duration-billed audio models — `gpt-transcribe`, `gpt-live-transcribe`, `gpt-realtime-whisper`, `gpt-realtime-translate` — are priced per minute of audio. They publish no token price, so these calls previously recorded no cost at all.
13
+ - OpenAI gpt-5.6-sol, gpt-5.6-terra, and gpt-5.6-luna are priced, including their cache-write rates across standard, batch, flex, priority, long-context, and data-residency tiers.
14
+ - Cache writes reported in OpenAI usage (`cache_write_tokens`, GPT-5.6 and later) are captured and costed at the model's cache-write rate instead of being counted as regular input.
15
+ - Anthropic thinking tokens are counted as hidden output on the Data Quality page, so reasoning Claude already billed inside `output_tokens` is visible instead of reading as zero. Cost is unchanged — `output_tokens` stays the billable total.
16
+
17
+ ### Changed
18
+
19
+ - BREAKING: a call the gem never found a rate for records `cost_status: unknown` instead of `free`. `free` covered both "priced at zero" and "never priced", so token-billed endpoints the parser captured no quantities for — `gpt-4o-mini-tts`, Whisper transcription, any unrecognised model — reported as costing nothing. Genuinely unbilled endpoints such as moderations move to `unknown` as well and appear on the Data Quality page.
20
+ - BREAKING: `enforce_budget: true` on `LlmCostTracker.track` records the call before it raises, and the error carries `stage: :post_spend` instead of `:pre_send`. `track` reports a request the provider already served, so the old order threw away real spend — and with the ledger total never advancing, every later call raised and was dropped too. `LlmCostTracker.track_stream` still raises `:pre_send`, before your block runs.
21
+ - BREAKING: `pricing_snapshot["rates"]` is keyed by the rate actually applied, so a batch call reads `batch_input` rather than `input`. The old key named a row whose value in the price table was a different number.
22
+ - `bin/rails llm_cost_tracker:backfill_unknown_pricing` no longer scans the whole ledger on every batch — unpriced calls are found through a partial index. Existing installs pick this up with `bin/rails generate llm_cost_tracker:upgrade_indexes`, which also drops the ingestion inbox lock index the drain never uses.
23
+ - `bin/rails llm_cost_tracker:prune` warns with the count and cost when it deletes inbox rows that never reached the ledger, instead of dropping that spend silently.
24
+ - The unpriced-model warning names the tier as well, so a call at a pricing mode you have no rate for reads `model "gpt-5.5" at pricing_mode "scale"` instead of pointing at the model.
25
+ - The models page caps at 200 rows and an out-of-range `page` no longer renders a database error.
26
+
27
+ ### Deprecated
28
+
29
+ - Configuration options are grouped into `budgets`, `capture`, `ingestion`, `pricing`, and `tags` — `config.budgets.monthly` replaces `config.monthly_budget`, and so on. Flat names still work and warn with their replacement; they are removed in 1.0. See [docs/upgrading.md](docs/upgrading.md#v013--v014) for the full mapping.
30
+ - `config.log_level` is dropped with no replacement — it never affected any log output.
31
+
32
+ ### Removed
33
+
34
+ - Bundled prices for OpenAI models past their published shutdown date are dropped, so repricing a call to a retired model reports unknown pricing instead of a stale rate.
35
+
36
+ ### Fixed
37
+
38
+ - OpenAI responses that break the completion into `text_tokens` are costed on the full billed output again. Reasoning tokens were dropped from the total, so a 1,000-token completion with 800 reasoning tokens recorded $0.011 instead of $0.035 while still reporting `cost_status: complete`.
39
+ - Anthropic calls are priced from the `speed` the response reports rather than the one the request asked for, so a `fast` response no longer records at standard rates — half its real cost on `claude-opus-5`.
40
+ - OpenAI regional calls on gpt-5.6 models are billed at their data-residency rates. Eligibility now comes from the price table instead of a hard-coded model list, which had not caught up with the codename models.
41
+ - Apps that set `config.logger` to a plain `Logger` no longer take a `NoMethodError` from inside the tracker. Every warning went through `Rails.logger.tagged`, which those apps do not have, so a rollup or ingestion failure raised into the request instead of being logged, and the async worker thread died on its first warning.
42
+ - Filtering the dashboard by a tag survives the date, provider, model and stream filters. Submitting any of them flattened `tag[env]=prod` into a single `tag` value, silently dropping the filter and showing a larger total under an unchanged header.
43
+ - A call whose tag value is a large hash or array is recorded instead of failing the whole insert. The encoded value is capped to fit the tag index; the scalar cap alone did not bound a composite.
44
+ - `Call.unknown_pricing` composes with `cost_by_tag` and `group_by_tag` again instead of raising on an ambiguous `total_cost`.
45
+ - A rollup increment that may already have been applied is no longer retried, so a dropped connection cannot leave the cache — and every budget read — above the real spend.
46
+ - The dashboard recovers on its own when the schema catches up. A process that started before `db:migrate` cached "Setup required" until it was restarted.
47
+ - Async inbox writes work after a fork, instead of raising into the request for the life of the child process.
48
+ - A completed OpenAI batch is retried when its result download fails, instead of being marked captured and never recorded.
49
+ - `bin/rails llm_cost_tracker:doctor` reports drift in the async inbox and lease tables, not just their absence.
50
+ - OpenAI gpt-5.4, gpt-5.4-pro, and gpt-5.5 prompts above 272K input tokens are costed at OpenAI's published long-context premium (2x input, 1.5x output on standard, batch, and flex) instead of the flat short-context rate.
51
+ - `bin/rails llm_cost_tracker:backfill_unknown_pricing` no longer aborts on the default configuration; repricing calls with unknown pricing no longer requires opting into `config.budgets.totals_source = :cache`.
52
+ - Setting `config.budgets.totals_source = :cache` without creating `llm_cost_tracker_call_rollups` no longer breaks dashboard and budget reads; totals fall back to aggregating the calls ledger and a log warning names the missing table.
53
+
54
+ ## [0.13.0] - 2026-06-26
55
+
56
+ ### Added
57
+
58
+ - The Data Quality page shows quarantined async-inbox rows (count and cost) when `ingestion: :async` is configured, so cost stuck outside the ledger is visible instead of silently missing from totals.
59
+
60
+ ### Changed
61
+
62
+ - BREAKING: `LlmCostTracker.track(tokens:)` and `stream.usage` (inside `track_stream`) now raise `ArgumentError` on unrecognized token keys instead of dropping them, so a typo like `outpt_tokens:` surfaces immediately rather than undercounting the ledger.
63
+ - OpenAI's image-generation, computer-use, and MCP tool calls no longer add `$0` line items that marked a call's pricing `partial`. Their cost is already captured in the model's tokens, so these tool calls are no longer recorded as separate rows and the call reflects complete pricing.
64
+
65
+ ### Fixed
66
+
67
+ - The mounted dashboard no longer returns 404 — the engine now registers its routes during Rails boot.
68
+ - Async ingestion (`config.ingestion = :async`) no longer permanently loses cost data when a transient database error (deadlock, lock timeout, dropped connection) interrupts the worker mid-drain — affected inbox rows are retried instead of counting toward quarantine.
69
+ - With `config.cache_rollups`, a failed rollup-cache update no longer fails or retries the async ingestion batch — calls land in the ledger, the failure is logged, and `bin/rails llm_cost_tracker:rebuild_rollups` recovers the cached totals.
70
+ - The dashboard labels non-USD amounts with their currency code (e.g. `1.23 EUR`) instead of always rendering `$`, on the pricing table and per-line call costs.
6
71
 
7
72
  ## [0.12.0] - 2026-06-04
8
73
 
@@ -12,7 +77,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
12
77
 
13
78
  ### Removed
14
79
 
15
- - BREAKING: the experimental `Reconciliation` subsystem (provider invoice import + diff, the `/reconciliation` dashboard page, `bin/rails llm_cost_tracker:reconcile:*` rake tasks, `config.reconciliation_enabled`, `config.reconciliation_importers`, the `llm_cost_tracker:reconciliation` generator, and the `llm_cost_tracker_provider_invoices` / `_provider_invoice_imports` tables) is gone. It was never finished and never billing-accurate. `calls.provider_response_id` (captured on every call) already covers invoice cross-reference; if invoice-vs-ledger reconciliation ships again it lives in a separate gem. Existing installs can drop the two tables — see [docs/upgrading.md](docs/upgrading.md#v011--v012-unreleased).
80
+ - BREAKING: the experimental `Reconciliation` subsystem (provider invoice import + diff, the `/reconciliation` dashboard page, `bin/rails llm_cost_tracker:reconcile:*` rake tasks, `config.reconciliation_enabled`, `config.reconciliation_importers`, the `llm_cost_tracker:reconciliation` generator, and the `llm_cost_tracker_provider_invoices` / `_provider_invoice_imports` tables) is gone. It was never finished and never billing-accurate. `calls.provider_response_id` (captured on every call) already covers invoice cross-reference; if invoice-vs-ledger reconciliation ships again it lives in a separate gem. Existing installs can drop the two tables — see [docs/upgrading.md](docs/upgrading.md#v011--v012).
16
81
  - `config.instrument :gemnii` (or any other typo / unknown integration name) no longer raises at config time — it now logs `Logging.warn("Unknown integration: :gemnii. Known: ...")` once when integrations install, and `bin/rails llm_cost_tracker:doctor` shows the unknown name as a `:warn` row so the typo is visible without crashing boot.
17
82
  - Pre-call budget enforcement for Azure-hosted OpenAI calls now keys on `"azure_openai"` (matching the recorded `Call.provider`), so `pricing_overrides` for Azure rates actually gate the call. Previously it always keyed on `"openai"` regardless of the SDK client's `base_url`.
18
83
  - BREAKING: removed the `batch:` keyword argument from `LlmCostTracker.track`, `LlmCostTracker.track_stream`, and `stream.usage` (inside `track_stream` blocks). Signal a batch-tier call via `pricing_mode: :batch` (or any pricing_mode containing the `batch` token like `:batch_flex`) — that's the single source of truth now. Previously `batch:` and `pricing_mode:` could disagree, especially after request-side pricing_mode merge inside `Tracker.record` overwrote the parser's mode but left the stored `batch` flag stale, so `calls.batch` could read `true` while `calls.pricing_mode` read `flex` (or vice versa) for the same row.
@@ -22,7 +87,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
22
87
 
23
88
  - The RubyLLM SDK integration now requires `ruby_llm >= 1.15.0` (was `>= 1.14.1`).
24
89
  - Engine no longer adds `tag` / `tag_value` to Rails `filter_parameters` — the Symbol filter was substring-matching unrelated host-app params (`tags`, `meta_tag`, etc.) into `[FILTERED]`. `Tags::Sanitizer` continues redacting secret-shaped tag values at storage.
25
- - BREAKING: the serialized event `cost` (the `llm_request.llm_cost_tracker` notification payload and the async-ingestion inbox payload) is now `{ components: {...}, total:, currency: }` (was flat with a top-level `total_cost:`). Notification subscribers should read `cost[:total]`; `ingestion: :async` rolling deploys should drain the inbox first — see [docs/upgrading.md](docs/upgrading.md#v011--v012-unreleased).
90
+ - BREAKING: the serialized event `cost` (the `llm_request.llm_cost_tracker` notification payload and the async-ingestion inbox payload) is now `{ components: {...}, total:, currency: }` (was flat with a top-level `total_cost:`). Notification subscribers should read `cost[:total]`; `ingestion: :async` rolling deploys should drain the inbox first — see [docs/upgrading.md](docs/upgrading.md#v011--v012).
26
91
  - BREAKING: `pricing_mode` in the `llm_request.llm_cost_tracker` notification payload is now a String (e.g. `"batch"`, `"fast_data_residency"`), not a Symbol — subscribers matching it against a Symbol must compare to the String.
27
92
  - BREAKING: `LlmCostTracker.track(tokens:)` now takes the same `_tokens`-suffixed keys as `stream.usage` and the stored columns — `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `audio_input_tokens`, etc. (was the short `input`, `output`, `cache_read_input`, …). Update manual `track` calls. Pricing-file / `pricing_overrides` field names are unchanged — they stay `input`, `output`, … (per-component rates, a separate vocabulary).
28
93
 
@@ -116,17 +181,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
116
181
 
117
182
  ## [0.9.0] - 2026-05-12
118
183
 
119
- 0.9 leans the default install: only `calls`, `call_line_items`, and `call_tags`
120
- are mandatory. Durable ingestion, rollup-cached budget reads, and provider
121
- invoice reconciliation are opt-in behind config flags and dedicated generators.
122
- Plus expanded SDK capture (OpenAI embeddings/audio/images/moderation, RubyLLM
123
- paint/moderate), correct handling of Anthropic data residency and Priority
124
- Tier, and a security-hardened dashboard. Existing installs need a migration —
125
- see [Upgrading](docs/upgrading.md).
184
+ 0.9 leans the default install: only `calls`, `call_line_items`, and `call_tags` are mandatory. Durable ingestion, rollup-cached budget reads, and provider invoice reconciliation are opt-in behind config flags and dedicated generators. Plus expanded SDK capture (OpenAI embeddings/audio/images/moderation, RubyLLM paint/moderate), correct handling of Anthropic data residency and Priority Tier, and a security-hardened dashboard. Existing installs need a migration — see [Upgrading](docs/upgrading.md).
126
185
 
127
186
  ### Added
128
187
 
129
- - **Experimental:** opt-in provider invoice reconciliation. Set `config.reconciliation_enabled = true` and run `bin/rails generate llm_cost_tracker:reconciliation`. Public surface: `LlmCostTracker::Reconciliation.import / .diff`, `config.register_reconciliation_importer(:source) { … }`, rake tasks `llm_cost_tracker:reconcile:import` and `:reconcile:diff`. Doctor warns when drift exceeds 5% or imports go stale past 14 days. See [Configuration](docs/configuration.md#reconciliation-experimental-opt-in).
188
+ - **Experimental:** opt-in provider invoice reconciliation. Set `config.reconciliation_enabled = true` and run `bin/rails generate llm_cost_tracker:reconciliation`. Public surface: `LlmCostTracker::Reconciliation.import / .diff`, `config.register_reconciliation_importer(:source) { … }`, rake tasks `llm_cost_tracker:reconcile:import` and `:reconcile:diff`. Doctor warns when drift exceeds 5% or imports go stale past 14 days.
130
189
  - Dashboard Data Quality page now shows a "Streaming health by provider" breakdown (streams, with-usage, unknown, unknown share) so a misconfigured OpenAI-compatible host shipping streams without `stream_options.include_usage` is visible at a glance.
131
190
  - Dashboard tag detail page drills into a single value via `?tag_value=…` with total cost, call count, average per call, and a daily spend timeseries.
132
191
  - Bundled rates for OpenAI embeddings (`text-embedding-3-small` / `-3-large` / `-ada-002`, including 50% batch discount) and token-priced transcription (`gpt-4o-transcribe`, `gpt-4o-mini-transcribe`). Token-priced transcription splits audio and text inputs at their separate rates. DALL-E and Whisper still record as zero-token visibility events until their per-image / per-minute pricing components land.
@@ -222,11 +281,7 @@ see [Upgrading](docs/upgrading.md).
222
281
 
223
282
  ## [0.8.0] - 2026-05-07
224
283
 
225
- 0.8 is a storage rebuild. Tokens and tool/runtime charges share one shape
226
- (`Billing::LineItem`) and live in a dedicated line items table. Per-component
227
- cost columns and the standalone service charges table are gone. Several tables
228
- were also renamed during the cycle. See [Upgrading](docs/upgrading.md) for the
229
- migration path — there is no rolling-deploy upgrade.
284
+ 0.8 is a storage rebuild. Tokens and tool/runtime charges share one shape (`Billing::LineItem`) and live in a dedicated line items table. Per-component cost columns and the standalone service charges table are gone. Several tables were also renamed during the cycle. See [Upgrading](docs/upgrading.md) for the migration path — there is no rolling-deploy upgrade.
230
285
 
231
286
  ### Added
232
287
 
data/README.md CHANGED
@@ -2,53 +2,46 @@
2
2
 
3
3
  Self-hosted LLM cost tracking for Rails.
4
4
 
5
- [![Gem Version](https://img.shields.io/gem/v/llm_cost_tracker.svg)](https://rubygems.org/gems/llm_cost_tracker)
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)
5
+ [![Gem Version](https://img.shields.io/gem/v/llm_cost_tracker.svg)](https://rubygems.org/gems/llm_cost_tracker) [![CI](https://github.com/sergey-homenko/llm_cost_tracker/actions/workflows/ruby.yml/badge.svg)](https://github.com/sergey-homenko/llm_cost_tracker/actions) [![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
6
 
9
- Every call your app makes to OpenAI, Anthropic, Gemini, RubyLLM, or any
10
- OpenAI-compatible API gets logged: tokens, cost, latency, tags. Calls go
11
- app → provider direct. No proxy.
7
+ Every call your app makes through RubyLLM, the official OpenAI and Anthropic SDKs, Gemini, or any OpenAI-compatible API gets logged: tokens, cost, latency, tags. Calls go app → provider direct. No proxy.
12
8
 
13
- Not Langfuse, Helicone, or LiteLLM. No prompts, no traces, no replay. Spend
14
- attribution only.
9
+ Not Langfuse, Helicone, or LiteLLM. No prompts, no traces, no replay. Spend attribution only.
15
10
 
16
11
  Requires Ruby 3.4+, Rails 7.1+, PostgreSQL or MySQL.
17
12
 
18
- <picture>
19
- <source media="(prefers-color-scheme: dark)" srcset="docs/dashboard-overview-dark.png">
20
- <img alt="LLM Cost Tracker dashboard" src="docs/dashboard-overview-light.png">
21
- </picture>
13
+ <picture> <source media="(prefers-color-scheme: dark)" srcset="docs/dashboard-overview-dark.png"> <img alt="LLM Cost Tracker dashboard" src="docs/dashboard-overview-light.png"> </picture>
22
14
 
23
15
  ## Quickstart
24
16
 
17
+ Shown with RubyLLM; the flow is identical for the official OpenAI and Anthropic SDKs — swap the gem and the `instrument` name (see the [cookbook](docs/cookbook.md)).
18
+
25
19
  ```ruby
26
20
  # Gemfile
27
21
  gem "llm_cost_tracker"
28
- gem "openai"
22
+ gem "ruby_llm"
29
23
  ```
30
24
 
31
25
  ```bash
32
26
  bin/rails llm_cost_tracker:setup
33
27
  ```
34
28
 
35
- Runs the install generator, drops a price snapshot, migrates the database, and verifies via `llm_cost_tracker:doctor`. The generated `config/initializers/llm_cost_tracker.rb` looks like:
29
+ Runs the install generator, drops a price snapshot, migrates the database, and verifies via `llm_cost_tracker:doctor`. Then enable the integration in the generated `config/initializers/llm_cost_tracker.rb`:
36
30
 
37
31
  ```ruby
38
32
  LlmCostTracker.configure do |config|
39
- config.default_tags = -> { { environment: Rails.env } }
40
- config.instrument :openai
33
+ config.tags.default = -> { { environment: Rails.env } }
34
+ config.instrument :ruby_llm
41
35
  end
42
36
  ```
43
37
 
44
38
  Edit it in place to add tags, switch on async ingestion, etc.
45
39
 
46
- Tag your calls to attribute spend:
40
+ Your RubyLLM calls stay unchanged — every chat, embedding, transcription, image, and moderation call now lands in the ledger. Tag them to attribute spend:
47
41
 
48
42
  ```ruby
49
43
  LlmCostTracker.with_tags(user_id: Current.user&.id, feature: "chat") do
50
- client = OpenAI::Client.new(api_key: ENV["OPENAI_API_KEY"])
51
- client.responses.create(model: "gpt-4o", input: "Hello")
44
+ RubyLLM.chat.ask("Hello")
52
45
  end
53
46
  ```
54
47
 
@@ -65,30 +58,25 @@ The engine ships without authentication on purpose.
65
58
  ## What lands in the ledger
66
59
 
67
60
  - **Calls.** Provider, model, total tokens, total cost, latency, status.
68
- - **Line items.** Per-component breakdown — text/audio/cached tokens, tool
69
- charges (web search, code execution, grounding, container sessions).
61
+ - **Line items.** Per-component breakdown — text/audio/cached tokens, tool charges (web search, code execution, grounding, container sessions).
70
62
  - **Tags.** Whatever attribution you pass — user, feature, tenant, env.
71
- - **Provider IDs.** Response, project, API key, workspace — for downstream
72
- audits.
63
+ - **Provider IDs.** Response, project, API key, workspace — for downstream audits.
73
64
  - **Pricing snapshot.** So historical numbers don't drift when prices change.
74
65
 
75
66
  ## Capture surfaces
76
67
 
77
68
  | Surface | Path |
78
69
  | --- | --- |
70
+ | RubyLLM | Provider layer |
79
71
  | OpenAI | Official SDK or Faraday |
80
72
  | Anthropic | Official SDK or Faraday |
81
73
  | Azure OpenAI | Faraday or official SDK (auto-detected on `*.openai.azure.com` and Foundry `*.services.ai.azure.com`, both deployments and `/openai/v1/...`) |
82
74
  | Google Gemini | Faraday |
83
- | RubyLLM | Provider layer |
84
75
  | `ruby-openai` | Faraday |
85
76
  | OpenRouter, DeepSeek, Groq, LiteLLM-style gateways | OpenAI-compatible Faraday |
86
77
  | Anything else | `LlmCostTracker.track` |
87
78
 
88
- Streams capture when the provider emits final usage. OpenAI Faraday streams
89
- get `stream_options: { include_usage: true }` auto-injected so the final
90
- usage chunk lands in the ledger (opt out via
91
- `config.auto_enable_stream_usage = false`).
79
+ Streams capture when the provider emits final usage. OpenAI Faraday streams to `/chat/completions` get `stream_options: { include_usage: true }` auto-injected so the final usage chunk lands in the ledger (opt out via `config.capture.request_stream_usage = false`).
92
80
 
93
81
  ## What it isn't
94
82
 
@@ -28,6 +28,8 @@ module LlmCostTracker
28
28
 
29
29
  @setup_message = drift.message
30
30
  @setup_details = drift.details
31
+ return head :service_unavailable unless request.format.html?
32
+
31
33
  render template: "llm_cost_tracker/shared/setup_required"
32
34
  end
33
35
 
@@ -37,8 +39,7 @@ module LlmCostTracker
37
39
  @to_date = range.to
38
40
  end
39
41
 
40
- def render_database_error(error)
41
- @error = error
42
+ def render_database_error(_error)
42
43
  render "llm_cost_tracker/errors/database", status: :internal_server_error
43
44
  end
44
45
 
@@ -20,6 +20,8 @@ module LlmCostTracker
20
20
  scope,
21
21
  total_streaming: @summary.streaming_count
22
22
  )
23
+ @quarantined_inbox = Dashboard::DataQuality.quarantined_inbox
24
+ @unseen_budget_tags = Dashboard::DataQuality.unseen_budget_tags
23
25
  end
24
26
  end
25
27
  end
@@ -2,12 +2,14 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  class ModelsController < ApplicationController
5
+ MAX_ROWS = 200
6
+
5
7
  def index
6
8
  @sort = params[:sort].to_s
7
9
  @dir = params[:dir].to_s
8
10
  @rows = Dashboard::TopModels.call(
9
11
  scope: Dashboard::Filter.call(params: params),
10
- limit: nil,
12
+ limit: MAX_ROWS,
11
13
  sort: @sort,
12
14
  direction: @dir
13
15
  )
@@ -32,15 +32,17 @@ module LlmCostTracker
32
32
  (numerator.to_f / denominator) * 100.0
33
33
  end
34
34
 
35
- def money(value)
35
+ def money(value, currency: LlmCostTracker::DEFAULT_CURRENCY)
36
36
  value = value.to_f
37
37
  precision = value.abs < 0.01 && value != 0.0 ? 6 : 2
38
+ formatted = format("%.#{precision}f", value)
39
+ code = currency.to_s.upcase.presence || LlmCostTracker::DEFAULT_CURRENCY
38
40
 
39
- "$#{format("%.#{precision}f", value)}"
41
+ code == LlmCostTracker::DEFAULT_CURRENCY ? "$#{formatted}" : "#{formatted} #{code}"
40
42
  end
41
43
 
42
- def optional_money(value)
43
- value.nil? ? "n/a" : money(value)
44
+ def optional_money(value, currency: LlmCostTracker::DEFAULT_CURRENCY)
45
+ value.nil? ? "n/a" : money(value, currency: currency)
44
46
  end
45
47
 
46
48
  def format_date(value)
@@ -16,6 +16,17 @@ module LlmCostTracker
16
16
  query
17
17
  end
18
18
 
19
+ def hidden_query_fields(query, prefix: nil)
20
+ safe_join(query.flat_map do |key, value|
21
+ name = prefix ? "#{prefix}[#{key}]" : key.to_s
22
+ case value
23
+ when Hash then hidden_query_fields(value, prefix: name)
24
+ when Array then value.map { |item| hidden_field_tag("#{name}[]", item, id: nil) }
25
+ else hidden_field_tag(name, value, id: nil)
26
+ end
27
+ end)
28
+ end
29
+
19
30
  private
20
31
 
21
32
  def clean_dashboard_query(value)
@@ -10,7 +10,8 @@ module LlmCostTracker
10
10
  scope :without_cost, -> { where(total_cost: nil) }
11
11
  scope :unknown_pricing,
12
12
  lambda {
13
- where(Charges::CostStatus.unknown_pricing_sql)
13
+ where(Charges::CostStatus.unknown_pricing_sql(total_cost: qualified(:total_cost),
14
+ cost_status: qualified(:cost_status)))
14
15
  }
15
16
  scope :with_latency, -> { where.not(latency_ms: nil) }
16
17
  scope :streaming, -> { where(stream: true) }
@@ -64,13 +65,14 @@ module LlmCostTracker
64
65
  end
65
66
 
66
67
  def cost_by_tag(key, limit: nil)
68
+ cost = qualified(:total_cost)
67
69
  label = Ledger::Tags::Breakdown.label_sql(connection)
68
70
  raw_value = Ledger::Tags::Breakdown.raw_value_sql(connection)
69
71
  relation = Ledger::Tags::Breakdown.join_relation(self, key)
70
- .select("#{label} AS name", "COALESCE(SUM(total_cost), 0) AS total_cost")
72
+ .select("#{label} AS name", "COALESCE(SUM(#{cost}), 0) AS total_cost")
71
73
  .group(Arel.sql(label))
72
74
  .order(
73
- Arel.sql("COALESCE(SUM(total_cost), 0) DESC"),
75
+ Arel.sql("COALESCE(SUM(#{cost}), 0) DESC"),
74
76
  Arel.sql("MAX(CASE WHEN #{raw_value} IS NULL THEN 1 ELSE 0 END) ASC"),
75
77
  Arel.sql("#{label} DESC")
76
78
  )
@@ -94,6 +96,10 @@ module LlmCostTracker
94
96
  .sum(:total_cost)
95
97
  end
96
98
 
99
+ def qualified(column)
100
+ "#{quoted_table_name}.#{connection.quote_column_name(column)}"
101
+ end
102
+
97
103
  private
98
104
 
99
105
  def cost_by_column(column, limit:)
@@ -7,16 +7,32 @@ module LlmCostTracker
7
7
  upsert_all(rows, on_duplicate: increment_on_duplicate, record_timestamps: true, unique_by: increment_unique_by)
8
8
  end
9
9
 
10
+ DECREMENT_SLICE = 100
11
+
10
12
  def decrement(buckets)
11
13
  now = Time.now.utc
12
- buckets.each do |(period, period_start, currency, provider), amount|
13
- where(period: period, period_start: period_start, currency: currency, provider: provider)
14
- .update_all(["total_cost = GREATEST(0, total_cost - ?), updated_at = ?", amount, now])
14
+ buckets.each_slice(DECREMENT_SLICE) do |slice|
15
+ where(decrement_scope(slice)).update_all(decrement_assignment(slice, now))
15
16
  end
16
17
  end
17
18
 
18
19
  private
19
20
 
21
+ def decrement_scope(slice)
22
+ rows = Array.new(slice.size, "(?, ?, ?, ?)").join(", ")
23
+ binds = slice.flat_map { |bucket, _| bucket }
24
+ sanitize_sql_array(["(period, period_start, currency, provider) IN (#{rows})", *binds])
25
+ end
26
+
27
+ def decrement_assignment(slice, now)
28
+ branches = slice.map { "WHEN period = ? AND period_start = ? AND currency = ? AND provider = ? THEN ?" }
29
+ binds = slice.flat_map { |bucket, amount| [*bucket, amount] }
30
+ sanitize_sql_array(
31
+ ["total_cost = GREATEST(0, total_cost - CASE #{branches.join(' ')} ELSE 0 END), updated_at = ?",
32
+ *binds, now]
33
+ )
34
+ end
35
+
20
36
  def increment_on_duplicate
21
37
  return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(connection)
22
38
  return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(connection)
@@ -6,6 +6,7 @@ module LlmCostTracker
6
6
  MAX_ATTEMPTS_BEFORE_QUARANTINE = 5
7
7
 
8
8
  scope :pending, -> { where(attempts: ..(MAX_ATTEMPTS_BEFORE_QUARANTINE - 1)) }
9
+ scope :quarantined, -> { where(attempts: MAX_ATTEMPTS_BEFORE_QUARANTINE..) }
9
10
  end
10
11
  end
11
12
  end
@@ -4,6 +4,8 @@ module LlmCostTracker
4
4
  module Dashboard
5
5
  module DataQuality
6
6
  UnknownPricingRow = ::Data.define(:provider, :model, :calls, :share_percent)
7
+ QuarantinedInbox = ::Data.define(:count, :total_cost)
8
+ UnseenBudgetTags = ::Data.define(:keys)
7
9
  StreamingHealthRow = ::Data.define(:provider, :streams, :with_usage, :unknown, :unknown_share)
8
10
  Summary = ::Data.define(:total,
9
11
  :unknown_pricing_count,
@@ -33,6 +35,29 @@ module LlmCostTracker
33
35
  scope.unscope(:order).select(aggregate_selects(scope)).take
34
36
  end
35
37
 
38
+ def quarantined_inbox
39
+ return nil unless Ingestion.async?
40
+ return nil unless Ingestion::InboxEntry.table_exists?
41
+
42
+ row = Ingestion::InboxEntry
43
+ .quarantined
44
+ .select("COUNT(*) AS quarantined_count, COALESCE(SUM(total_cost), 0) AS quarantined_cost")
45
+ .take
46
+ QuarantinedInbox.new(count: row.quarantined_count.to_i, total_cost: row.quarantined_cost.to_d)
47
+ end
48
+
49
+ def unseen_budget_tags
50
+ budgeted = Budget::PerTag.configured
51
+ return nil if budgeted.empty?
52
+ return nil unless Budget::PerTag.columns?
53
+ return nil unless LlmCostTracker::CallTag.exists?
54
+
55
+ unseen = budgeted.keys.reject { |key| LlmCostTracker::CallTag.exists?(key: key) }
56
+ return nil if unseen.empty?
57
+
58
+ UnseenBudgetTags.new(keys: unseen)
59
+ end
60
+
36
61
  def summary(stats)
37
62
  total = stats.total_calls.to_i
38
63
  unknown_pricing_count = stats.unknown_pricing_count.to_i
@@ -6,7 +6,7 @@ module LlmCostTracker
6
6
  module_function
7
7
 
8
8
  def status
9
- budget = LlmCostTracker.configuration.monthly_budget
9
+ budget = LlmCostTracker.configuration.budgets.monthly
10
10
  return nil unless budget
11
11
 
12
12
  budget = budget.to_f
@@ -6,6 +6,7 @@ module LlmCostTracker
6
6
  DEFAULT_PER = 50
7
7
  MAX_PER = 200
8
8
  MIN_PAGE = 1
9
+ MAX_PAGE = 100_000
9
10
  MIN_PER = 1
10
11
 
11
12
  attr_reader :page, :per
@@ -13,7 +14,7 @@ module LlmCostTracker
13
14
  def self.call(params)
14
15
  params = Params.to_hash(params).symbolize_keys
15
16
  new(
16
- page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE),
17
+ page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE, max: MAX_PAGE),
17
18
  per: integer_param(params, :per, default: DEFAULT_PER, min: MIN_PER, max: MAX_PER)
18
19
  )
19
20
  end
@@ -46,8 +46,8 @@ module LlmCostTracker
46
46
 
47
47
  def subtitle_for(key)
48
48
  case key
49
- when :overrides then "config.pricing_overrides"
50
- when :file then LlmCostTracker.configuration.prices_file.to_s
49
+ when :overrides then "config.pricing.overrides"
50
+ when :file then LlmCostTracker.configuration.pricing.file.to_s
51
51
  when :bundled then "ships with the gem"
52
52
  end
53
53
  end
@@ -55,7 +55,7 @@ module LlmCostTracker
55
55
  def updated_at_for(key)
56
56
  case key
57
57
  when :file
58
- path = LlmCostTracker.configuration.prices_file
58
+ path = LlmCostTracker.configuration.pricing.file
59
59
  Pricing::Registry.file_metadata(path)["updated_at"] || Pricing::Registry.prices_file_mtime_iso
60
60
  when :bundled
61
61
  Pricing::Registry.metadata["updated_at"]
@@ -7,13 +7,15 @@ module LlmCostTracker
7
7
 
8
8
  class << self
9
9
  def current
10
- return @current if defined?(@current)
10
+ return if @healthy
11
11
 
12
- @current = compute
12
+ state = compute
13
+ @healthy = state.nil?
14
+ state
13
15
  end
14
16
 
15
17
  def reset!
16
- remove_instance_variable(:@current) if defined?(@current)
18
+ @healthy = false
17
19
  end
18
20
 
19
21
  private
@@ -2,7 +2,7 @@
2
2
  <% line_item_costs_by_component = call_line_item_costs_by_component(@call) %>
3
3
  <% token_segments = priced_components.map do |component|
4
4
  token_key = component.fetch(:token_key)
5
- value = @call.has_attribute?(token_key) ? @call[token_key] : 0
5
+ value = @call[token_key]
6
6
  { label: component.fetch(:label), value: value, formatted_value: number_with_delimiter(value), css_class: component.fetch(:css_class) }
7
7
  end %>
8
8
  <% cost_segments = [] %>
@@ -68,13 +68,11 @@ end %>
68
68
  <dt>Batch</dt>
69
69
  <dd><%= @call.batch? ? "yes" : "no" %></dd>
70
70
  </div>
71
- <% if @call.has_attribute?(:stream) %>
72
- <div class="lct-meta-strip-item">
73
- <dt>Stream</dt>
74
- <dd><%= @call.stream? ? "yes" : "no" %></dd>
75
- </div>
76
- <% end %>
77
- <% if @call.has_attribute?(:usage_source) && @call.usage_source.present? %>
71
+ <div class="lct-meta-strip-item">
72
+ <dt>Stream</dt>
73
+ <dd><%= @call.stream? ? "yes" : "no" %></dd>
74
+ </div>
75
+ <% if @call.usage_source.present? %>
78
76
  <div class="lct-meta-strip-item">
79
77
  <dt>Usage source</dt>
80
78
  <dd><code class="lct-code-id"><%= @call.usage_source %></code></dd>
@@ -142,9 +140,9 @@ end %>
142
140
  <td><code class="lct-code-id"><%= line_item.kind %></code></td>
143
141
  <td><%= line_item.unit %></td>
144
142
  <td class="lct-num"><%= line_item.quantity %></td>
145
- <td class="lct-num"><%= line_item.rate_amount ? "#{optional_money(line_item.rate_amount)} / #{line_item.rate_quantity}" : "n/a" %></td>
143
+ <td class="lct-num"><%= line_item.rate_amount ? "#{optional_money(line_item.rate_amount, currency: line_item.currency)} / #{line_item.rate_quantity}" : "n/a" %></td>
146
144
  <% unknown_cost = line_item.cost.nil? || line_item.cost_status.to_s == LlmCostTracker::Charges::CostStatus::UNKNOWN %>
147
- <td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(line_item.cost) %></td>
145
+ <td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(line_item.cost, currency: line_item.currency) %></td>
148
146
  <td><%= line_item.cost_status %></td>
149
147
  </tr>
150
148
  <% end %>
@@ -10,6 +10,28 @@
10
10
  <span class="lct-filter-row-meta"><%= number_with_delimiter(@summary.total) %> call<%= "s" unless @summary.total == 1 %> inspected</span>
11
11
  </div>
12
12
 
13
+ <% if @quarantined_inbox && @quarantined_inbox.count.positive? %>
14
+ <h3 class="lct-stat-section-label">Async inbox</h3>
15
+ <div class="lct-stat-grid">
16
+ <div class="lct-stat lct-stat-warn">
17
+ <div class="lct-stat-head"><p class="lct-stat-label">Quarantined inbox rows</p></div>
18
+ <p class="lct-stat-value"><%= number_with_delimiter(@quarantined_inbox.count) %></p>
19
+ <p class="lct-stat-foot"><%= money(@quarantined_inbox.total_cost) %> excluded from the ledger and totals</p>
20
+ </div>
21
+ </div>
22
+ <% end %>
23
+
24
+ <% if @unseen_budget_tags %>
25
+ <h3 class="lct-stat-section-label">Budgets</h3>
26
+ <div class="lct-stat-grid">
27
+ <div class="lct-stat lct-stat-warn">
28
+ <div class="lct-stat-head"><p class="lct-stat-label">Budgeted tags never recorded</p></div>
29
+ <p class="lct-stat-value"><%= @unseen_budget_tags.keys.join(", ") %></p>
30
+ <p class="lct-stat-foot">No call carries <%= @unseen_budget_tags.keys.one? ? "this tag" : "these tags" %> yet, so <%= @unseen_budget_tags.keys.one? ? "its budget never fires" : "their budgets never fire" %>. Expected on a young ledger; otherwise check the name against the tags you pass.</p>
31
+ </div>
32
+ </div>
33
+ <% end %>
34
+
13
35
  <% if @summary.total.zero? %>
14
36
  <section class="lct-panel lct-empty">
15
37
  <h2 class="lct-state-title">No data yet</h2>
@@ -83,7 +83,7 @@
83
83
  <td><code class="lct-code-id"><%= row.model %></code></td>
84
84
  <% LlmCostTracker::Dashboard::PricingOverview::RATE_COLUMNS.each do |key| %>
85
85
  <% value = row.rates[key] %>
86
- <td class="lct-num<%= ' lct-num-muted' if value.nil? %>"><%= value ? money(value) : "—" %></td>
86
+ <td class="lct-num<%= ' lct-num-muted' if value.nil? %>"><%= value ? money(value, currency: @source_data.fetch(:currency)) : "—" %></td>
87
87
  <% end %>
88
88
  </tr>
89
89
  <% end %>
@@ -9,9 +9,7 @@
9
9
  </summary>
10
10
  <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
11
11
  <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
12
- <% current_query.except(:from, :to, :page, :per, *extra_except).each do |key, value| %>
13
- <% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
14
- <% end %>
12
+ <%= hidden_query_fields(current_query.except(:from, :to, :page, :per, *extra_except)) %>
15
13
  <div class="lct-filter-pop-field"><label for="lct-filter-from">From</label><input type="date" name="from" id="lct-filter-from" value="<%= @from_date.iso8601 %>"></div>
16
14
  <div class="lct-filter-pop-field"><label for="lct-filter-to">To</label><input type="date" name="to" id="lct-filter-to" value="<%= @to_date.iso8601 %>"></div>
17
15
  <button type="submit" class="lct-button lct-button-primary">Apply</button>