llm_cost_tracker 0.13.0 → 0.14.1
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 +99 -16
- data/README.md +17 -29
- data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
- 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/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
- data/app/models/llm_cost_tracker/call.rb +10 -4
- 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/filter.rb +24 -21
- 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/params.rb +10 -0
- 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/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
- data/lib/llm_cost_tracker/budget.rb +120 -32
- data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
- 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 +2 -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 +25 -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 +38 -7
- 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/anthropic.rb +7 -1
- data/lib/llm_cost_tracker/integrations/base.rb +17 -1
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
- data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +21 -13
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +5 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
- data/lib/llm_cost_tracker/parsers.rb +5 -1
- data/lib/llm_cost_tracker/prices.json +2089 -372
- 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 +17 -6
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
- 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/azure/parser.rb +19 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
- data/lib/llm_cost_tracker/railtie.rb +3 -7
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- 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 +14 -40
- data/lib/llm_cost_tracker/tracker.rb +16 -26
- 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 +10 -3
- data/lib/tasks/llm_cost_tracker.rake +29 -14
- metadata +37 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af338b0d20bfcbdfd9b95a88f903fa2a6b22dab16c72857a48bb2ff6ac0e9ac6
|
|
4
|
+
data.tar.gz: e2688d3cc3d29795787602ccf79b70dbe541347884644b3d5d234d1635f3f4b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d4e06cd0bab7dd941fb958ca5fff5b6844ba17879f04bd3370258864e7ebd390088e091d2e0bd7f6fd839fcf05ecf3bc79f954b918c5b0b1cea471e1d14a413b
|
|
7
|
+
data.tar.gz: 107d59de744aabfbf946cbc38ce9ad705b4562a516446163dfb4834102ea263065d67e72c302c75f90ac887c12dfe11c404e85256e46300cd5a2ebe73b61b3af
|
data/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,100 @@
|
|
|
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.1] - 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `bin/rails llm_cost_tracker:doctor` and the boot log warn when an instrumented SDK is newer than its tested range (RubyLLM 3.0 and later), instead of the integration reporting installed while it records nothing.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Ruby 3.3 is supported; the minimum drops from 3.4.
|
|
14
|
+
- The Faraday middleware adds `stream_options: { include_usage: true }` only for OpenAI, OpenRouter, DeepSeek, Groq, and Azure OpenAI on the v1 API or `api-version` 2024-06-01 and later without On Your Data or image input, since other servers can reject it and fail the request. Hosts you add to `config.capture.openai_compatible_providers` are left untouched: set the flag yourself if they support it, or their streams record `usage_source: unknown` with a warning.
|
|
15
|
+
- `bin/rails llm_cost_tracker:prices:refresh` refuses a snapshot that zeroes an existing price or charges for a free one, drops a model's `input` or `output` rate, moves a price 100-fold or more, or switches currency, and leaves your pricing file untouched, so one bad commit to the snapshot can no longer silence budgets or make `:block_requests` block every call. `PREVIEW=1` and `prices:check` list the changes; re-run with `FORCE=1` (or `force: true`) to accept them. New models and smaller changes are not checked, so keep reviewing the refreshed file.
|
|
16
|
+
- `pricing.unknown_model_behavior = :raise` records the call, with `cost_status: unknown`, before it raises `LlmCostTracker::UnknownPricingError`, so a call the provider already billed is no longer missing from the ledger.
|
|
17
|
+
- A malformed `pricing.file` fails `LlmCostTracker.configure` at boot instead of every LLM call. A missing one is logged at boot and reported as an error by `doctor`, and calls use bundled prices until `prices:refresh` creates it; before, every call raised and nothing was recorded.
|
|
18
|
+
|
|
19
|
+
### Removed
|
|
20
|
+
|
|
21
|
+
- An OpenAI or Anthropic SDK stream that your code never iterates is no longer recorded at garbage collection; the finalizer that did it kept every stream alive until process exit.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Per-tag `:block_requests` budgets check tags passed to the Faraday middleware (`f.use :llm_cost_tracker, tags: ...`) before the call is sent; they saw only `with_tags` tags, so an over-budget tenant still reached the provider.
|
|
26
|
+
- RubyLLM transcriptions are priced at the model's audio input rate when it has one; they were priced as text, at a third to a half of the real cost.
|
|
27
|
+
- `RubyLLM.transcribe` with a Gemini or Vertex AI model on RubyLLM 1.x is recorded; RubyLLM 1.x sent it through its own Gemini method, bypassing the wrapped `RubyLLM::Provider#transcribe`.
|
|
28
|
+
- Calls made through RubyLLM 2.x are recorded. RubyLLM 2.0 moved token counts behind `response.tokens`, so every call was dropped with a warning while `doctor` reported the integration installed. A 2.x `paint(count:)` returning several images is recorded once, as RubyLLM bills it.
|
|
29
|
+
- An automatically captured LLM call no longer fails, losing the provider's response, when the gem cannot record it, such as when the async inbox pool times out. Only `BudgetExceededError`, `UnknownPricingError` under `:raise`, and `TransactionAbortedError` reach your code; other recording failures are logged. `LlmCostTracker.track` and `track_stream` still raise them.
|
|
30
|
+
- A recording error no longer replaces an exception your code raised while iterating an OpenAI or Anthropic SDK stream or inside `track_stream`, such as `Sidekiq::Shutdown`; the stream is still recorded, tagged `stream_errored`, and the recording error is logged. `TransactionAbortedError` still wins, with your exception as its cause.
|
|
31
|
+
- An OpenAI or Anthropic batch with an unpriced model under `:raise`, or one crossing a budget, records every result before raising once; results after the failing one were lost and `batches.retrieve` raised on every poll.
|
|
32
|
+
- With `ingestion.mode = :async`, one inbox row the database rejects no longer fails its whole batch, which after five attempts quarantined up to 99 other calls and dropped them from the ledger and budget totals. A failed batch is retried one row at a time and only rejected rows are marked failed. Rows already quarantined this way can be requeued as described in `docs/operations.md`.
|
|
33
|
+
- A NUL byte in a stored string, which PostgreSQL rejects, or invalid UTF-8 in a tag value no longer loses the call, and `LlmCostTracker.with_tags` no longer raises on invalid UTF-8. NUL bytes are removed on write, including from inbox rows written by earlier releases, and invalid UTF-8 in tag values becomes U+FFFD.
|
|
34
|
+
- An interrupted Gemini or Azure stream records its model from the request URL instead of `unknown`.
|
|
35
|
+
- The async inbox's `last_error` is no longer empty when the 1,000-byte cut splits a multibyte character.
|
|
36
|
+
- An LLM call inside your own database transaction no longer breaks it when the ledger write fails; on PostgreSQL your app's writes were lost with `PG::InFailedSqlTransaction`. Ledger writes, budget reads, and batch de-duplication run in a savepoint inside an open transaction. On MySQL, where a deadlock rolls back your whole transaction, the gem raises `LlmCostTracker::TransactionAbortedError` instead of swallowing it.
|
|
37
|
+
- With `config.budgets.totals_source = :cache`, the rollup increment inside your transaction waits for its commit on Rails 7.2 and later, so an open transaction no longer holds the rollup row lock and stalls every request recording spend for the same provider. On Rails 7.1 and in non-joinable transactions such as transactional fixtures it still runs immediately, and it is never retried inside a transaction.
|
|
38
|
+
- Long streams record their real usage instead of zero tokens and `$0`. Streams were buffered up to 1 MB, dropping the final usage event (past about 3,000 output tokens through Faraday); they are now decoded as they arrive, keeping only the first and last events plus billable tool-call and grounding events, so memory stays flat at any length. Image streams through Faraday keep their final usage too.
|
|
39
|
+
- Streams captured through the OpenAI and Anthropic SDK integrations are released once your code drops them, instead of staying in memory with their buffered events and request for the life of the process.
|
|
40
|
+
- OpenAI Realtime cached audio is no longer billed twice, at the cache-read rate and again at the audio rate ($0.258 instead of $0.106 for a 10,000-token `gpt-realtime` turn with 5,000 cached audio tokens). Cached audio and image tokens are priced at the model's cache-read rate, below OpenAI's published cached-audio price on the mini models. OpenAI-compatible usage whose audio and image buckets exceed the reported input has the overlap taken out of audio input, then image input.
|
|
41
|
+
- Dashboard links and filter forms carry only the dashboard's own query parameters. They copied every parameter, and Rails reads some as link options, so a crafted link such as `?script_name=//evil.example` pointed Export CSV, pagination, filter, and tag links at another site, others rewrote links or caused a 500, and a 50 KB junk parameter grew a tag page to 10 MB. A query string over 16 KB is a bad request.
|
|
42
|
+
- A dashboard page accepts at most 10 `tag[...]` filters, counting a tag value page's own value, and answers more with a bad request. Each filter adds a subquery, so PostgreSQL planning memory grew with the square of their count, and around 700 got the backend killed for running out of memory, restarting the database. Tag breakdowns no longer offer drill-down links past the limit.
|
|
43
|
+
- Dashboard filters handle bad input without a 500. A list or hash in a tag, provider, model, stream, or usage-source filter is a bad request instead of silently matching nothing, and a NUL byte in a filter value matches nothing on PostgreSQL instead of raising. A CSV export with an invalid filter, a missing call, or a database error renders an HTML error page with the right status instead of a 500 or HTML labelled as CSV. A tag value page's value overrides a filter on the same key, and tag pages for keys containing a dot, such as `/tags/team.name`, load instead of returning 404 or 406.
|
|
44
|
+
- The Pricing page shows the active price source instead of a 500 when `source` is a list or hash, as in `/pricing?source[]=bundled`; a list or hash `provider` filter is a bad request, as on other pages.
|
|
45
|
+
|
|
46
|
+
### Security
|
|
47
|
+
|
|
48
|
+
- From v0.9.0 through v0.14.0, a provider API key sent as a URL parameter could be stored in call tags. When a middleware after `f.use :llm_cost_tracker`, such as `f.response :raise_error`, raised on a streamed Gemini `?key=` or Azure `?api-key=` request, the `stream_interrupted_error` tag stored Faraday's error message with the full URL, shown on the dashboard, in CSV exports, and in notification payloads. The tag now holds only the error class, and the HTTP status goes into a new `stream_interrupted_status` tag. Rotate any key that may have been exposed, and clear stored messages with `UPDATE llm_cost_tracker_call_tags SET value = split_part(value, ': ', 1) WHERE key = 'stream_interrupted_error'` on PostgreSQL or ``UPDATE llm_cost_tracker_call_tags SET value = SUBSTRING_INDEX(value, ': ', 1) WHERE `key` = 'stream_interrupted_error'`` on MySQL. With async ingestion, run it after the inbox drains, and delete quarantined inbox rows whose `payload` contains `stream_interrupted_error`.
|
|
49
|
+
- Provider keys, tokens, `key=`/`token=`/`sig=` URL parameters, URL user info, and `Authorization` headers inside a longer string are replaced with `[REDACTED]` in tag values, log lines, the async inbox's `last_error`, `TransactionAbortedError` messages, and the `source_url` that `llm_cost_tracker:prices:refresh` writes and prints; before, only a tag value that was entirely a key was caught.
|
|
50
|
+
|
|
51
|
+
## [0.14.0] - 2026-08-26
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- `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.
|
|
56
|
+
- 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.
|
|
57
|
+
- 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.
|
|
58
|
+
- 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.
|
|
59
|
+
- 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.
|
|
60
|
+
- 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.
|
|
61
|
+
- 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.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- 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.
|
|
66
|
+
- 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.
|
|
67
|
+
- 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.
|
|
68
|
+
- `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.
|
|
69
|
+
- `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.
|
|
70
|
+
- 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.
|
|
71
|
+
- The models page caps at 200 rows and an out-of-range `page` no longer renders a database error.
|
|
72
|
+
|
|
73
|
+
### Deprecated
|
|
74
|
+
|
|
75
|
+
- 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.
|
|
76
|
+
- `config.log_level` is dropped with no replacement — it never affected any log output.
|
|
77
|
+
|
|
78
|
+
### Removed
|
|
79
|
+
|
|
80
|
+
- 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.
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- 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`.
|
|
85
|
+
- 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`.
|
|
86
|
+
- 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.
|
|
87
|
+
- 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.
|
|
88
|
+
- 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.
|
|
89
|
+
- 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.
|
|
90
|
+
- `Call.unknown_pricing` composes with `cost_by_tag` and `group_by_tag` again instead of raising on an ambiguous `total_cost`.
|
|
91
|
+
- 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.
|
|
92
|
+
- 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.
|
|
93
|
+
- Async inbox writes work after a fork, instead of raising into the request for the life of the child process.
|
|
94
|
+
- A completed OpenAI batch is retried when its result download fails, instead of being marked captured and never recorded.
|
|
95
|
+
- `bin/rails llm_cost_tracker:doctor` reports drift in the async inbox and lease tables, not just their absence.
|
|
96
|
+
- 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.
|
|
97
|
+
- `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`.
|
|
98
|
+
- 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
99
|
|
|
7
100
|
## [0.13.0] - 2026-06-26
|
|
8
101
|
|
|
@@ -30,7 +123,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
30
123
|
|
|
31
124
|
### Removed
|
|
32
125
|
|
|
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
|
|
126
|
+
- 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
127
|
- `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
128
|
- 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
129
|
- 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 +133,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
40
133
|
|
|
41
134
|
- The RubyLLM SDK integration now requires `ruby_llm >= 1.15.0` (was `>= 1.14.1`).
|
|
42
135
|
- 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
|
|
136
|
+
- 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
137
|
- 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
138
|
- 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
139
|
|
|
@@ -134,17 +227,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
134
227
|
|
|
135
228
|
## [0.9.0] - 2026-05-12
|
|
136
229
|
|
|
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).
|
|
230
|
+
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
231
|
|
|
145
232
|
### Added
|
|
146
233
|
|
|
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.
|
|
234
|
+
- **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
235
|
- 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
236
|
- 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
237
|
- 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 +327,7 @@ see [Upgrading](docs/upgrading.md).
|
|
|
240
327
|
|
|
241
328
|
## [0.8.0] - 2026-05-07
|
|
242
329
|
|
|
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.
|
|
330
|
+
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
331
|
|
|
249
332
|
### Added
|
|
250
333
|
|
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
|
-
Requires Ruby 3.
|
|
11
|
+
Requires Ruby 3.3+, 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
|
|
|
@@ -4,11 +4,14 @@ require "securerandom"
|
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
class ApplicationController < ActionController::Base
|
|
7
|
+
MAX_QUERY_BYTES = 16 * 1024
|
|
8
|
+
|
|
7
9
|
layout "llm_cost_tracker/application"
|
|
8
10
|
|
|
9
11
|
protect_from_forgery with: :exception
|
|
10
12
|
|
|
11
13
|
before_action :set_dashboard_security_headers
|
|
14
|
+
before_action :reject_oversized_query
|
|
12
15
|
before_action :ensure_current_schema
|
|
13
16
|
before_action :assign_dashboard_date_range
|
|
14
17
|
|
|
@@ -22,12 +25,20 @@ module LlmCostTracker
|
|
|
22
25
|
|
|
23
26
|
private
|
|
24
27
|
|
|
28
|
+
def reject_oversized_query
|
|
29
|
+
return if request.query_string.bytesize <= MAX_QUERY_BYTES
|
|
30
|
+
|
|
31
|
+
raise LlmCostTracker::InvalidFilterError, "query string exceeds #{MAX_QUERY_BYTES / 1024} KB"
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
def ensure_current_schema
|
|
26
35
|
drift = LlmCostTracker::Dashboard::SetupState.current
|
|
27
36
|
return unless drift
|
|
28
37
|
|
|
29
38
|
@setup_message = drift.message
|
|
30
39
|
@setup_details = drift.details
|
|
40
|
+
return head :service_unavailable unless request.format.html?
|
|
41
|
+
|
|
31
42
|
render template: "llm_cost_tracker/shared/setup_required"
|
|
32
43
|
end
|
|
33
44
|
|
|
@@ -37,18 +48,21 @@ module LlmCostTracker
|
|
|
37
48
|
@to_date = range.to
|
|
38
49
|
end
|
|
39
50
|
|
|
40
|
-
def render_database_error(
|
|
41
|
-
|
|
42
|
-
render "llm_cost_tracker/errors/database", status: :internal_server_error
|
|
51
|
+
def render_database_error(_error)
|
|
52
|
+
render_error_page("database", :internal_server_error)
|
|
43
53
|
end
|
|
44
54
|
|
|
45
55
|
def render_invalid_filter(error)
|
|
46
56
|
@error_message = error.message
|
|
47
|
-
|
|
57
|
+
render_error_page("invalid_filter", :bad_request)
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
def render_not_found
|
|
51
|
-
|
|
61
|
+
render_error_page("not_found", :not_found)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def render_error_page(name, status)
|
|
65
|
+
render "llm_cost_tracker/errors/#{name}", status: status, formats: :html, content_type: "text/html"
|
|
52
66
|
end
|
|
53
67
|
|
|
54
68
|
def set_dashboard_security_headers
|
|
@@ -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
|
)
|
|
@@ -4,10 +4,10 @@ module LlmCostTracker
|
|
|
4
4
|
class PricingController < ApplicationController
|
|
5
5
|
def index
|
|
6
6
|
@overview = Dashboard::PricingOverview.call
|
|
7
|
-
requested = params[:source]
|
|
7
|
+
requested = params[:source].to_s.to_sym
|
|
8
8
|
@active_source = @overview.fetch(:sources).key?(requested) ? requested : @overview.fetch(:effective_source)
|
|
9
9
|
@source_data = @overview.fetch(:sources).fetch(@active_source)
|
|
10
|
-
@provider_filter = params[:provider].
|
|
10
|
+
@provider_filter = Dashboard::Params.scalar(params[:provider], :provider).presence
|
|
11
11
|
@rows = @source_data.fetch(:rows)
|
|
12
12
|
@rows = @rows.select { |row| row.provider == @provider_filter } if @provider_filter
|
|
13
13
|
@providers = @source_data.fetch(:rows).map(&:provider).compact.uniq.sort
|
|
@@ -7,19 +7,20 @@ module LlmCostTracker
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
|
-
|
|
11
|
-
@value = params[:tag_value].to_s
|
|
10
|
+
@value = Dashboard::Params.scalar(params[:tag_value], :tag_value)
|
|
12
11
|
|
|
13
12
|
if @value.empty?
|
|
14
13
|
@sort = params[:sort].to_s
|
|
15
14
|
@dir = params[:dir].to_s
|
|
16
|
-
@breakdown = Dashboard::TagBreakdown.call(
|
|
15
|
+
@breakdown = Dashboard::TagBreakdown.call(
|
|
16
|
+
scope: Dashboard::Filter.call(params: params), key: params[:key], sort: @sort, direction: @dir
|
|
17
|
+
)
|
|
17
18
|
else
|
|
18
19
|
@key = LlmCostTracker::Tags::Key.validate!(
|
|
19
20
|
params[:key],
|
|
20
21
|
error_class: LlmCostTracker::InvalidFilterError
|
|
21
22
|
)
|
|
22
|
-
value_scope =
|
|
23
|
+
value_scope = Dashboard::Filter.call(params: params, tags: { @key => @value })
|
|
23
24
|
@value_total_cost = value_scope.sum(:total_cost).to_f
|
|
24
25
|
@value_calls = value_scope.count
|
|
25
26
|
@value_points = Dashboard::TimeSeries.call(scope: value_scope)
|
|
@@ -135,7 +135,7 @@ module LlmCostTracker
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def current_query(overrides = {})
|
|
138
|
-
request.query_parameters.symbolize_keys.merge(overrides)
|
|
138
|
+
request.query_parameters.symbolize_keys.slice(*LlmCostTracker::Dashboard::Params::QUERY_KEYS).merge(overrides)
|
|
139
139
|
end
|
|
140
140
|
|
|
141
141
|
def calls_query_for_model(provider:, model:)
|
|
@@ -16,6 +16,22 @@ module LlmCostTracker
|
|
|
16
16
|
query
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def tag_drilldown_allowed?(key)
|
|
20
|
+
tags = LlmCostTracker::Dashboard::Params.tag_query(current_query[:tag])
|
|
21
|
+
tags.except(key.to_s).size < LlmCostTracker::Dashboard::Filter::MAX_TAG_FILTERS
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def hidden_query_fields(query, prefix: nil)
|
|
25
|
+
safe_join(query.flat_map do |key, value|
|
|
26
|
+
name = prefix ? "#{prefix}[#{key}]" : key.to_s
|
|
27
|
+
case value
|
|
28
|
+
when Hash then hidden_query_fields(value, prefix: name)
|
|
29
|
+
when Array then value.map { |item| hidden_field_tag("#{name}[]", item, id: nil) }
|
|
30
|
+
else hidden_field_tag(name, value, id: nil)
|
|
31
|
+
end
|
|
32
|
+
end)
|
|
33
|
+
end
|
|
34
|
+
|
|
19
35
|
private
|
|
20
36
|
|
|
21
37
|
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) }
|
|
@@ -44,7 +45,7 @@ module LlmCostTracker
|
|
|
44
45
|
def already_recorded?(provider:, provider_response_id:)
|
|
45
46
|
return false if provider_response_id.to_s.empty?
|
|
46
47
|
|
|
47
|
-
where(provider: provider, provider_response_id: provider_response_id).exists?
|
|
48
|
+
Ledger::Isolation.guard(self) { where(provider: provider, provider_response_id: provider_response_id).exists? }
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
def by_tag(key, value) = by_tags(key => value)
|
|
@@ -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
|