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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -16
  3. data/README.md +17 -29
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
  5. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  8. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  10. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
  11. data/app/models/llm_cost_tracker/call.rb +10 -4
  12. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  13. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
  14. data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
  15. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  16. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  17. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  18. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  19. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  20. data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
  21. data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
  22. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  23. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  26. data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
  27. data/config/routes.rb +6 -1
  28. data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
  29. data/lib/llm_cost_tracker/budget.rb +120 -32
  30. data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
  31. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  32. data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
  33. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
  35. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  36. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  37. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  38. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  39. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  40. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  41. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  42. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  43. data/lib/llm_cost_tracker/configuration.rb +69 -125
  44. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  45. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  46. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
  47. data/lib/llm_cost_tracker/doctor.rb +4 -4
  48. data/lib/llm_cost_tracker/engine.rb +4 -0
  49. data/lib/llm_cost_tracker/errors.rb +25 -3
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  59. data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
  60. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  61. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  62. data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
  63. data/lib/llm_cost_tracker/integrations/base.rb +17 -1
  64. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  65. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
  66. data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
  67. data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
  68. data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
  69. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  70. data/lib/llm_cost_tracker/ledger/store.rb +21 -13
  71. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  72. data/lib/llm_cost_tracker/ledger.rb +1 -0
  73. data/lib/llm_cost_tracker/logging.rb +5 -5
  74. data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
  75. data/lib/llm_cost_tracker/parsers.rb +5 -1
  76. data/lib/llm_cost_tracker/prices.json +2089 -372
  77. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  78. data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
  79. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  80. data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
  81. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  82. data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
  83. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  84. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  85. data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
  86. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  87. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  88. data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
  89. data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
  90. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  91. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
  92. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
  93. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
  94. data/lib/llm_cost_tracker/railtie.rb +3 -7
  95. data/lib/llm_cost_tracker/redaction.rb +32 -0
  96. data/lib/llm_cost_tracker/report/data.rb +2 -2
  97. data/lib/llm_cost_tracker/retention.rb +22 -10
  98. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  99. data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
  100. data/lib/llm_cost_tracker/tracker.rb +16 -26
  101. data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
  102. data/lib/llm_cost_tracker/version.rb +1 -1
  103. data/lib/llm_cost_tracker.rb +10 -3
  104. data/lib/tasks/llm_cost_tracker.rake +29 -14
  105. metadata +37 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec6d42366ce33ab5b2d4847d4ad425dd4d4262a302212427be2a3fc55db53fcc
4
- data.tar.gz: 57f6eaa22546be19cc3b17afeed107fb35782ad5de4b6ed9646a4d7955cab589
3
+ metadata.gz: af338b0d20bfcbdfd9b95a88f903fa2a6b22dab16c72857a48bb2ff6ac0e9ac6
4
+ data.tar.gz: e2688d3cc3d29795787602ccf79b70dbe541347884644b3d5d234d1635f3f4b9
5
5
  SHA512:
6
- metadata.gz: 716c326903ac571988211ee34f2ff2a43d6cec3d6b8d00a1682df5d245877e7884f8b3e3b3e9d86e37b0089a73f97f3dc1b4c7cbbf1c40a1365702acef7e5f6b
7
- data.tar.gz: ef41e457bafea063396e7ab08d2c7f6c051c1831ec7476071e36b08af7812853c7e1501fc4d7986658de726316c136e562b747eec26316ffd97a7ee6ee7eda2a
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
- ## [Unreleased]
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-unreleased).
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-unreleased).
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. See [Configuration](docs/configuration.md#reconciliation-experimental-opt-in).
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
- [![Gem Version](https://img.shields.io/gem/v/llm_cost_tracker.svg)](https://rubygems.org/gems/llm_cost_tracker)
6
- [![CI](https://github.com/sergey-homenko/llm_cost_tracker/actions/workflows/ruby.yml/badge.svg)](https://github.com/sergey-homenko/llm_cost_tracker/actions)
7
- [![codecov](https://codecov.io/gh/sergey-homenko/llm_cost_tracker/branch/main/graph/badge.svg)](https://codecov.io/gh/sergey-homenko/llm_cost_tracker)
5
+ [![Gem Version](https://img.shields.io/gem/v/llm_cost_tracker.svg)](https://rubygems.org/gems/llm_cost_tracker) [![CI](https://github.com/sergey-homenko/llm_cost_tracker/actions/workflows/ruby.yml/badge.svg)](https://github.com/sergey-homenko/llm_cost_tracker/actions) [![codecov](https://codecov.io/gh/sergey-homenko/llm_cost_tracker/branch/main/graph/badge.svg)](https://codecov.io/gh/sergey-homenko/llm_cost_tracker)
8
6
 
9
- Every call your app makes to OpenAI, Anthropic, Gemini, RubyLLM, or any
10
- OpenAI-compatible API gets logged: tokens, cost, latency, tags. Calls go
11
- app → provider direct. No proxy.
7
+ Every call your app makes through RubyLLM, the official OpenAI and Anthropic SDKs, Gemini, or any OpenAI-compatible API gets logged: tokens, cost, latency, tags. Calls go app → provider direct. No proxy.
12
8
 
13
- Not Langfuse, Helicone, or LiteLLM. No prompts, no traces, no replay. Spend
14
- attribution only.
9
+ Not Langfuse, Helicone, or LiteLLM. No prompts, no traces, no replay. Spend attribution only.
15
10
 
16
- Requires Ruby 3.4+, Rails 7.1+, PostgreSQL or MySQL.
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 "openai"
22
+ gem "ruby_llm"
29
23
  ```
30
24
 
31
25
  ```bash
32
26
  bin/rails llm_cost_tracker:setup
33
27
  ```
34
28
 
35
- Runs the install generator, drops a price snapshot, migrates the database, and verifies via `llm_cost_tracker:doctor`. The generated `config/initializers/llm_cost_tracker.rb` looks like:
29
+ Runs the install generator, drops a price snapshot, migrates the database, and verifies via `llm_cost_tracker:doctor`. Then enable the integration in the generated `config/initializers/llm_cost_tracker.rb`:
36
30
 
37
31
  ```ruby
38
32
  LlmCostTracker.configure do |config|
39
- config.default_tags = -> { { environment: Rails.env } }
40
- config.instrument :openai
33
+ config.tags.default = -> { { environment: Rails.env } }
34
+ config.instrument :ruby_llm
41
35
  end
42
36
  ```
43
37
 
44
38
  Edit it in place to add tags, switch on async ingestion, etc.
45
39
 
46
- Tag your calls to attribute spend:
40
+ Your RubyLLM calls stay unchanged — every chat, embedding, transcription, image, and moderation call now lands in the ledger. Tag them to attribute spend:
47
41
 
48
42
  ```ruby
49
43
  LlmCostTracker.with_tags(user_id: Current.user&.id, feature: "chat") do
50
- client = OpenAI::Client.new(api_key: ENV["OPENAI_API_KEY"])
51
- client.responses.create(model: "gpt-4o", input: "Hello")
44
+ RubyLLM.chat.ask("Hello")
52
45
  end
53
46
  ```
54
47
 
@@ -65,30 +58,25 @@ The engine ships without authentication on purpose.
65
58
  ## What lands in the ledger
66
59
 
67
60
  - **Calls.** Provider, model, total tokens, total cost, latency, status.
68
- - **Line items.** Per-component breakdown — text/audio/cached tokens, tool
69
- charges (web search, code execution, grounding, container sessions).
61
+ - **Line items.** Per-component breakdown — text/audio/cached tokens, tool charges (web search, code execution, grounding, container sessions).
70
62
  - **Tags.** Whatever attribution you pass — user, feature, tenant, env.
71
- - **Provider IDs.** Response, project, API key, workspace — for downstream
72
- audits.
63
+ - **Provider IDs.** Response, project, API key, workspace — for downstream audits.
73
64
  - **Pricing snapshot.** So historical numbers don't drift when prices change.
74
65
 
75
66
  ## Capture surfaces
76
67
 
77
68
  | Surface | Path |
78
69
  | --- | --- |
70
+ | RubyLLM | Provider layer |
79
71
  | OpenAI | Official SDK or Faraday |
80
72
  | Anthropic | Official SDK or Faraday |
81
73
  | Azure OpenAI | Faraday or official SDK (auto-detected on `*.openai.azure.com` and Foundry `*.services.ai.azure.com`, both deployments and `/openai/v1/...`) |
82
74
  | Google Gemini | Faraday |
83
- | RubyLLM | Provider layer |
84
75
  | `ruby-openai` | Faraday |
85
76
  | OpenRouter, DeepSeek, Groq, LiteLLM-style gateways | OpenAI-compatible Faraday |
86
77
  | Anything else | `LlmCostTracker.track` |
87
78
 
88
- Streams capture when the provider emits final usage. OpenAI Faraday streams
89
- get `stream_options: { include_usage: true }` auto-injected so the final
90
- usage chunk lands in the ledger (opt out via
91
- `config.auto_enable_stream_usage = false`).
79
+ Streams capture when the provider emits final usage. OpenAI Faraday streams to `/chat/completions` get `stream_options: { include_usage: true }` auto-injected so the final usage chunk lands in the ledger (opt out via `config.capture.request_stream_usage = false`).
92
80
 
93
81
  ## What it isn't
94
82
 
@@ -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(error)
41
- @error = error
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
- render "llm_cost_tracker/errors/invalid_filter", status: :bad_request
57
+ render_error_page("invalid_filter", :bad_request)
48
58
  end
49
59
 
50
60
  def render_not_found
51
- render "llm_cost_tracker/errors/not_found", status: :not_found
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
@@ -21,6 +21,7 @@ module LlmCostTracker
21
21
  total_streaming: @summary.streaming_count
22
22
  )
23
23
  @quarantined_inbox = Dashboard::DataQuality.quarantined_inbox
24
+ @unseen_budget_tags = Dashboard::DataQuality.unseen_budget_tags
24
25
  end
25
26
  end
26
27
  end
@@ -2,12 +2,14 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  class ModelsController < ApplicationController
5
+ MAX_ROWS = 200
6
+
5
7
  def index
6
8
  @sort = params[:sort].to_s
7
9
  @dir = params[:dir].to_s
8
10
  @rows = Dashboard::TopModels.call(
9
11
  scope: Dashboard::Filter.call(params: params),
10
- limit: nil,
12
+ limit: MAX_ROWS,
11
13
  sort: @sort,
12
14
  direction: @dir
13
15
  )
@@ -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]&.to_sym
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].to_s.presence
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
- scope = Dashboard::Filter.call(params: params)
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(scope: scope, key: params[:key], sort: @sort, direction: @dir)
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 = scope.by_tag(@key, @value)
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(total_cost), 0) AS total_cost")
72
+ .select("#{label} AS name", "COALESCE(SUM(#{cost}), 0) AS total_cost")
71
73
  .group(Arel.sql(label))
72
74
  .order(
73
- Arel.sql("COALESCE(SUM(total_cost), 0) DESC"),
75
+ Arel.sql("COALESCE(SUM(#{cost}), 0) DESC"),
74
76
  Arel.sql("MAX(CASE WHEN #{raw_value} IS NULL THEN 1 ELSE 0 END) ASC"),
75
77
  Arel.sql("#{label} DESC")
76
78
  )
@@ -94,6 +96,10 @@ module LlmCostTracker
94
96
  .sum(:total_cost)
95
97
  end
96
98
 
99
+ def qualified(column)
100
+ "#{quoted_table_name}.#{connection.quote_column_name(column)}"
101
+ end
102
+
97
103
  private
98
104
 
99
105
  def cost_by_column(column, limit:)
@@ -7,16 +7,32 @@ module LlmCostTracker
7
7
  upsert_all(rows, on_duplicate: increment_on_duplicate, record_timestamps: true, unique_by: increment_unique_by)
8
8
  end
9
9
 
10
+ DECREMENT_SLICE = 100
11
+
10
12
  def decrement(buckets)
11
13
  now = Time.now.utc
12
- buckets.each do |(period, period_start, currency, provider), amount|
13
- where(period: period, period_start: period_start, currency: currency, provider: provider)
14
- .update_all(["total_cost = GREATEST(0, total_cost - ?), updated_at = ?", amount, now])
14
+ buckets.each_slice(DECREMENT_SLICE) do |slice|
15
+ where(decrement_scope(slice)).update_all(decrement_assignment(slice, now))
15
16
  end
16
17
  end
17
18
 
18
19
  private
19
20
 
21
+ def decrement_scope(slice)
22
+ rows = Array.new(slice.size, "(?, ?, ?, ?)").join(", ")
23
+ binds = slice.flat_map { |bucket, _| bucket }
24
+ sanitize_sql_array(["(period, period_start, currency, provider) IN (#{rows})", *binds])
25
+ end
26
+
27
+ def decrement_assignment(slice, now)
28
+ branches = slice.map { "WHEN period = ? AND period_start = ? AND currency = ? AND provider = ? THEN ?" }
29
+ binds = slice.flat_map { |bucket, amount| [*bucket, amount] }
30
+ sanitize_sql_array(
31
+ ["total_cost = GREATEST(0, total_cost - CASE #{branches.join(' ')} ELSE 0 END), updated_at = ?",
32
+ *binds, now]
33
+ )
34
+ end
35
+
20
36
  def increment_on_duplicate
21
37
  return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(connection)
22
38
  return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(connection)
@@ -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