llm_cost_tracker 0.13.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -16
- data/README.md +16 -28
- data/app/controllers/llm_cost_tracker/application_controller.rb +3 -2
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +11 -0
- data/app/models/llm_cost_tracker/call.rb +9 -3
- data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
- data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
- data/lib/llm_cost_tracker/budget/per_tag.rb +159 -0
- data/lib/llm_cost_tracker/budget.rb +120 -32
- data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
- data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
- data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
- data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
- data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
- data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
- data/lib/llm_cost_tracker/configuration/section.rb +59 -0
- data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
- data/lib/llm_cost_tracker/configuration.rb +69 -125
- data/lib/llm_cost_tracker/deprecator.rb +9 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +4 -4
- data/lib/llm_cost_tracker/engine.rb +4 -0
- data/lib/llm_cost_tracker/errors.rb +12 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +3 -1
- data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
- data/lib/llm_cost_tracker/ingestion.rb +3 -7
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +10 -12
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +34 -4
- data/lib/llm_cost_tracker/ledger/store.rb +9 -2
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
- data/lib/llm_cost_tracker/logging.rb +3 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +1291 -313
- data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
- data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
- data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +5 -5
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -1
- data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +5 -2
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +5 -4
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +2 -2
- data/lib/llm_cost_tracker/railtie.rb +3 -7
- data/lib/llm_cost_tracker/report/data.rb +2 -2
- data/lib/llm_cost_tracker/retention.rb +22 -10
- data/lib/llm_cost_tracker/tags/context.rb +3 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +4 -4
- data/lib/llm_cost_tracker/tracker.rb +14 -23
- data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +6 -1
- data/lib/tasks/llm_cost_tracker.rake +23 -10
- metadata +22 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ffc5fef0c3c18c6926b6c414d56fb0b7c9c4afef6db315139f8bc4626864611a
|
|
4
|
+
data.tar.gz: 3b012c1014c7246dd0eebb5413e13c02023cce056312e719a96328e86de749a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b28d7d695a1d6c45fe6c9d77d4f5caebbbde84682b7a261e25123d280181d00361baae80e9ddd65def0b9602c385d48813467ae741a5cde4bc378e4652a45829
|
|
7
|
+
data.tar.gz: 617cafb3be05250fd7c9c0b049a37474e1e0a2737d72cfd2744110cd531b68c362e7d15928f54d9a9c2d810da3f451516ff9ae747103bca2d618b77159359a13
|
data/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,54 @@
|
|
|
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
|
-
## [
|
|
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.
|
|
6
53
|
|
|
7
54
|
## [0.13.0] - 2026-06-26
|
|
8
55
|
|
|
@@ -30,7 +77,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
30
77
|
|
|
31
78
|
### Removed
|
|
32
79
|
|
|
33
|
-
- 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
|
|
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).
|
|
34
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.
|
|
35
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`.
|
|
36
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.
|
|
@@ -40,7 +87,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
40
87
|
|
|
41
88
|
- The RubyLLM SDK integration now requires `ruby_llm >= 1.15.0` (was `>= 1.14.1`).
|
|
42
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.
|
|
43
|
-
- 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
|
|
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).
|
|
44
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.
|
|
45
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).
|
|
46
93
|
|
|
@@ -134,17 +181,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
134
181
|
|
|
135
182
|
## [0.9.0] - 2026-05-12
|
|
136
183
|
|
|
137
|
-
0.9 leans the default install: only `calls`, `call_line_items`, and `call_tags`
|
|
138
|
-
are mandatory. Durable ingestion, rollup-cached budget reads, and provider
|
|
139
|
-
invoice reconciliation are opt-in behind config flags and dedicated generators.
|
|
140
|
-
Plus expanded SDK capture (OpenAI embeddings/audio/images/moderation, RubyLLM
|
|
141
|
-
paint/moderate), correct handling of Anthropic data residency and Priority
|
|
142
|
-
Tier, and a security-hardened dashboard. Existing installs need a migration —
|
|
143
|
-
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).
|
|
144
185
|
|
|
145
186
|
### Added
|
|
146
187
|
|
|
147
|
-
- **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.
|
|
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.
|
|
148
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.
|
|
149
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.
|
|
150
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.
|
|
@@ -240,11 +281,7 @@ see [Upgrading](docs/upgrading.md).
|
|
|
240
281
|
|
|
241
282
|
## [0.8.0] - 2026-05-07
|
|
242
283
|
|
|
243
|
-
0.8 is a storage rebuild. Tokens and tool/runtime charges share one shape
|
|
244
|
-
(`Billing::LineItem`) and live in a dedicated line items table. Per-component
|
|
245
|
-
cost columns and the standalone service charges table are gone. Several tables
|
|
246
|
-
were also renamed during the cycle. See [Upgrading](docs/upgrading.md) for the
|
|
247
|
-
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.
|
|
248
285
|
|
|
249
286
|
### Added
|
|
250
287
|
|
data/README.md
CHANGED
|
@@ -2,53 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
Self-hosted LLM cost tracking for Rails.
|
|
4
4
|
|
|
5
|
-
[](https://rubygems.org/gems/llm_cost_tracker)
|
|
6
|
-
[](https://github.com/sergey-homenko/llm_cost_tracker/actions)
|
|
7
|
-
[](https://codecov.io/gh/sergey-homenko/llm_cost_tracker)
|
|
5
|
+
[](https://rubygems.org/gems/llm_cost_tracker) [](https://github.com/sergey-homenko/llm_cost_tracker/actions) [](https://codecov.io/gh/sergey-homenko/llm_cost_tracker)
|
|
8
6
|
|
|
9
|
-
Every call your app makes
|
|
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 "
|
|
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`.
|
|
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.
|
|
40
|
-
config.instrument :
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
|
@@ -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:
|
|
12
|
+
limit: MAX_ROWS,
|
|
11
13
|
sort: @sort,
|
|
12
14
|
direction: @dir
|
|
13
15
|
)
|
|
@@ -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(
|
|
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(
|
|
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.
|
|
13
|
-
where(
|
|
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)
|
|
@@ -5,6 +5,7 @@ module LlmCostTracker
|
|
|
5
5
|
module DataQuality
|
|
6
6
|
UnknownPricingRow = ::Data.define(:provider, :model, :calls, :share_percent)
|
|
7
7
|
QuarantinedInbox = ::Data.define(:count, :total_cost)
|
|
8
|
+
UnseenBudgetTags = ::Data.define(:keys)
|
|
8
9
|
StreamingHealthRow = ::Data.define(:provider, :streams, :with_usage, :unknown, :unknown_share)
|
|
9
10
|
Summary = ::Data.define(:total,
|
|
10
11
|
:unknown_pricing_count,
|
|
@@ -45,6 +46,18 @@ module LlmCostTracker
|
|
|
45
46
|
QuarantinedInbox.new(count: row.quarantined_count.to_i, total_cost: row.quarantined_cost.to_d)
|
|
46
47
|
end
|
|
47
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
|
+
|
|
48
61
|
def summary(stats)
|
|
49
62
|
total = stats.total_calls.to_i
|
|
50
63
|
unknown_pricing_count = stats.unknown_pricing_count.to_i
|
|
@@ -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.
|
|
50
|
-
when :file then LlmCostTracker.configuration.
|
|
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.
|
|
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
|
|
10
|
+
return if @healthy
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
state = compute
|
|
13
|
+
@healthy = state.nil?
|
|
14
|
+
state
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def reset!
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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>
|
|
@@ -21,6 +21,17 @@
|
|
|
21
21
|
</div>
|
|
22
22
|
<% end %>
|
|
23
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
|
+
|
|
24
35
|
<% if @summary.total.zero? %>
|
|
25
36
|
<section class="lct-panel lct-empty">
|
|
26
37
|
<h2 class="lct-state-title">No data yet</h2>
|
|
@@ -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
|
-
|
|
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>
|
|
@@ -10,9 +10,7 @@
|
|
|
10
10
|
</summary>
|
|
11
11
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
12
12
|
<% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
|
|
13
|
-
|
|
14
|
-
<% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
|
|
15
|
-
<% end %>
|
|
13
|
+
<%= hidden_query_fields(current_query.except(:model, :page, :per, *extra_except)) %>
|
|
16
14
|
<div class="lct-filter-pop-field">
|
|
17
15
|
<label for="lct-filter-model">Model</label>
|
|
18
16
|
<%= select_tag :model, options_for_select(filter_options_for(:model), active), include_blank: "All models", id: "lct-filter-model" %>
|
|
@@ -10,9 +10,7 @@
|
|
|
10
10
|
</summary>
|
|
11
11
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
12
12
|
<% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
|
|
13
|
-
|
|
14
|
-
<% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
|
|
15
|
-
<% end %>
|
|
13
|
+
<%= hidden_query_fields(current_query.except(:provider, :page, :per, *extra_except)) %>
|
|
16
14
|
<div class="lct-filter-pop-field">
|
|
17
15
|
<label for="lct-filter-provider">Provider</label>
|
|
18
16
|
<%= select_tag :provider, options_for_select(filter_options_for(:provider), active), include_blank: "All providers", id: "lct-filter-provider" %>
|
|
@@ -11,9 +11,7 @@
|
|
|
11
11
|
</summary>
|
|
12
12
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
13
13
|
<% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
|
|
14
|
-
|
|
15
|
-
<% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
|
|
16
|
-
<% end %>
|
|
14
|
+
<%= hidden_query_fields(current_query.except(:stream, :page, :per, *extra_except)) %>
|
|
17
15
|
<div class="lct-filter-pop-field">
|
|
18
16
|
<label for="lct-filter-stream">Stream</label>
|
|
19
17
|
<%= select_tag :stream, options_for_select(LlmCostTracker::Dashboard::Filter::STREAM_FILTER_OPTIONS, active), include_blank: "All calls", id: "lct-filter-stream" %>
|