llm_cost_tracker 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af338b0d20bfcbdfd9b95a88f903fa2a6b22dab16c72857a48bb2ff6ac0e9ac6
4
- data.tar.gz: e2688d3cc3d29795787602ccf79b70dbe541347884644b3d5d234d1635f3f4b9
3
+ metadata.gz: 3fbd251f06208de2aaeb887d9e8603fe7614e365c1d5d5e9aef0c27dba5f54d2
4
+ data.tar.gz: 78600f3f5844d367c4dd9030c3af8b024937f90f501b9bab51a792df085ab9a7
5
5
  SHA512:
6
- metadata.gz: d4e06cd0bab7dd941fb958ca5fff5b6844ba17879f04bd3370258864e7ebd390088e091d2e0bd7f6fd839fcf05ecf3bc79f954b918c5b0b1cea471e1d14a413b
7
- data.tar.gz: 107d59de744aabfbf946cbc38ce9ad705b4562a516446163dfb4834102ea263065d67e72c302c75f90ac887c12dfe11c404e85256e46300cd5a2ebe73b61b3af
6
+ metadata.gz: 11e337b545c34c40b5ce468278a3a7d15f562bd016269b1dd59c41053fd0eb7602b2025aaa286c9dd603dd2740502b37afca51689ba843d5d039e1d8385490f7
7
+ data.tar.gz: b918914d2b8b7671d02597d8f08000818f38d890609ae2a81286fa279da5ab3efaec230690c31ec5e060d2b02dcab6d4bc93bf129804233158c8681cf4575aa8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,204 @@
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
+ ## [0.15.0] - 2026-10-10
6
+
7
+ ### Added
8
+
9
+ - `budgets.per_tag` `total` window: a lifetime limit per tag value, such as one agent run's `run_id`.
10
+ - `budgets.per_tag` `calls` limit: the number of recorded calls per tag value, unpriced calls included.
11
+ - RubyLLM calls inside `RubyLLM.workflow` are tagged `run_id` with the workflow's id, also from tools RubyLLM runs concurrently and batch results collected inside it, unless the app sets `run_id`.
12
+ - RubyLLM 2.x speech, OCR, rerank, judgments and operations added in later RubyLLM releases are recorded, with unknown cost where no rate applies; video generation and research jobs are not.
13
+ - RubyLLM 2.x `RubyLLM.batch` results are recorded once each at batch rates when `Batch#messages` or `#results` returns them.
14
+ - `RubyLLM.workflow` names and steps become `workflow_name` and `workflow_step_name` tags.
15
+ - Perplexity, xAI and Mistral hosts, regional ones included, are built into `capture.openai_compatible_providers`, so Faraday, the official openai gem and `track_stream` capture them without registration.
16
+ - Perplexity Sonar and Agent API calls, background polls included, are recorded at their billed `usage.cost.total_cost`.
17
+ - Faraday captures image generation and edits, transcriptions, translations, speech and Mistral OCR on OpenAI-compatible hosts, and moderations on OpenAI and OpenAI-compatible hosts, `free` on a $0-priced model.
18
+ - `off_peak` pricing mode: a call made inside a price entry's `_off_peak_windows` (ISO weekdays, UTC hours) is priced at its `off_peak_*` rates, also when backfilled or repriced.
19
+ - `ocr_page` and `rerank_search_unit` dimensions, per 1,000 pages and search units, recorded for Mistral OCR and RubyLLM 2.x Cohere Parse and rerank.
20
+ - `_minimum_billed_seconds` price-entry field: a shorter transcription is billed for that many seconds.
21
+ - `gpt-4o-mini-tts` speech requested with `stream_format: "sse"` is priced from its usage, text input at `input` and audio at `audio_output`; binary responses carry no usage and stay unknown.
22
+ - Bundled DeepSeek prices, peak and off-peak, under its current and legacy model names; Chinese public holidays are priced at peak.
23
+ - Bundled Mistral prices for Voxtral transcription (per minute), Voxtral TTS (per character), OCR (per 1,000 pages), Codestral Embed and Mistral Moderation 2 ($0).
24
+ - Bundled Groq prices for Whisper, per minute of a `verbose_json` response's `duration` with Groq's 10-second minimum, and Orpheus, per character.
25
+ - Bundled Cohere prices from its pricing page: Command R and R7B, legacy Command models, Aya Expanse, Embed 5, Rerank 4 per 1,000 search units and Parse per 1,000 pages.
26
+ - RubyLLM 2.x Cohere chats and embeddings record billed image tokens at `image_input`, so a call with images is `partial` or `unknown` until a price has that rate.
27
+ - Bundled prices for Mistral Embed and Cohere Command A, which their providers' pages omit, taken from LiteLLM and marked `"_source": "litellm"`.
28
+ - `LICENSE.txt` carries LiteLLM's MIT notice for the bundled prices taken from its data.
29
+ - OpenAI Responses WebSocket mode (`responses.connect`, `OpenAI::Responses::Session` included) is recorded, one call per response that reports usage, and `:block_requests` checks each `response.create` before it is sent.
30
+
31
+ ### Changed
32
+
33
+ - RubyLLM 2.x is captured from its instrumentation events and documented protocol methods instead of patches to its internals; RubyLLM 1.x keeps the previous integration.
34
+ - RubyLLM 2.x records one row per provider attempt: retries, fallbacks and `pause_turn` segments each get a row, refused or unsent attempts are skipped, and a failed attempt that may have been billed is recorded with unknown cost unless its usage was read.
35
+ - RubyLLM 2.x responses in OpenAI, Anthropic or Gemini format are priced on any host, Vertex AI included.
36
+ - Under `:block_requests`, a RubyLLM 2.x call is blocked when its RubyLLM event starts; the event's other subscribers then see no finish.
37
+ - On RubyLLM 2.x, an unset `RubyLLM.config.instrumenter` is set to `ActiveSupport::Notifications`; `doctor` warns when it is anything else or a protocol method the integration reads is missing.
38
+ - RubyLLM 2.x Cohere chats record Cohere's billed units instead of token counts that include its unbilled preamble; rows recorded earlier keep the larger counts, so backfilling them overstates their cost.
39
+ - xAI calls are recorded at their billed `usage.cost_in_usd_ticks`, tool calls included, instead of a list-price estimate.
40
+ - Faraday asks xAI and Mistral streams for their usage with `stream_options.include_usage`; they report it only when asked.
41
+ - A model priced only per minute, character, page or search unit is billed by that unit when the call reports it; the token counts its response also reports stay unbilled.
42
+ - A Mistral chat with audio input gets a `transcription_minute` line item for its audio, so a model priced only per token is recorded `partial`.
43
+ - A transcription or translation without `usage` is priced by its `verbose_json` `duration`, rounded up to whole seconds, through Faraday and the official openai gem, Azure OpenAI Whisper included.
44
+ - `bin/rails llm_cost_tracker:prices:refresh` runs `backfill_unknown_pricing` after writing a new `config.pricing.file`, or prints that command when the calls ledger is not reachable.
45
+ - `backfill_unknown_pricing` and `reprice` store the pricing mode they apply, so a Bedrock regional-profile call recorded before 0.14.2 gets `data_residency`.
46
+ - Bundled prices drop the models Mistral lists as retired, with their aliases such as `magistral-medium-latest`, and any model its provider has stopped listing for 90 days.
47
+ - Vertex AI calls through RubyLLM are priced at the tier their `usageMetadata.trafficType` reports, and Bedrock Converse calls at their `serviceTier`, instead of at standard rates; Provisioned Throughput, Bedrock `reserved` and tiers no bundled price lists become `unknown`, so they no longer count toward money budgets but still count toward `calls` limits, and `provisioned_throughput_*` or `reserved_*` rates in `pricing.overrides` price them.
48
+ - Calls through the official OpenAI SDK's Bedrock provider (`OpenAI::Providers.bedrock`) are recorded as `bedrock` instead of `openai`.
49
+ - `with_tags` tags reach fibers started inside its block, and on RubyLLM 2.1 the tool threads RubyLLM starts, until the block ends; an `ActionController::Live` action's thread keeps them for the whole action, and other threads, such as a thread pool's, get none.
50
+ - The maintained price snapshot requires 0.15.0, so `prices:refresh` and `prices:check` on 0.14.x stop with "remote pricing snapshot requires llm_cost_tracker >= 0.15.0".
51
+
52
+ ### Fixed
53
+
54
+ - Anthropic compaction, advisor, fallback and refusal pricing applies to RubyLLM 2.x chats, and each blocking `pause_turn` segment is priced from its own response.
55
+ - RubyLLM 2.x Vertex AI `gemini-embedding-2` embeddings price image, audio and video tokens at their own rates instead of as text.
56
+ - RubyLLM 2.x Bedrock Converse streams price cache writes at the cache lifetimes Bedrock reports instead of the one `with_caching` requested.
57
+ - RubyLLM 1.x and 2.x images returned without usage are recorded with unknown cost instead of $0.
58
+ - Gemini Omni Flash, 3.5 Transcribe (Live included) and 2.5 Computer Use calls are priced instead of recorded with unknown cost; Omni video output uses the new `video_output` rate.
59
+ - OpenAI Ultrafast calls (`service_tier: "ultrafast"`) are priced at GPT-6 Astra's Ultrafast rates, US data residency included, instead of recorded with unknown cost.
60
+ - GPT-Realtime-2, 2.1 and 2.1 mini get `data_residency` rates.
61
+ - OpenAI `text-embedding-ada-002` embeddings, reported as `text-embedding-ada-002-v2`, and Mistral Embed called as `mistral-embed-2312` are priced instead of recorded with unknown cost.
62
+ - A Chat Completions or Responses stream is recorded under the model its last event names, so a Perplexity Agent API preset such as `fast` is recorded as the model that served it.
63
+ - Concurrent `backfill_unknown_pricing` or `reprice` runs no longer add a call's cost to the `:cache` rollups twice.
64
+ - An empty, `0` or non-numeric `BATCH_SIZE` stops `backfill_unknown_pricing`, `reprice`, `prune` and `backfill_tag_costs` with an error.
65
+ - JSON price files keep floats in their shortest form on json 2.11 and later (`0.951432`, not `0.9514320000000001`).
66
+ - The Calls CSV export sorts the matching calls once instead of re-querying each 500-row batch with `OFFSET`, which was slow on large ledgers.
67
+ - Gemini calls on a Vertex AI regional or multi-region endpoint through RubyLLM, or through the official openai gem on its OpenAI-compatible API, are priced at the non-global rates Google's Vertex AI pricing page lists from July 1, 2026, instead of its global rates.
68
+ - Claude 4.5 and later calls on a Vertex AI regional or multi-region endpoint through RubyLLM 2.x or the Anthropic SDK's Vertex client are priced at their `data_residency` rates, Anthropic's 10% premium, instead of their global rates.
69
+ - Claude Opus 4.1, Opus 4, Sonnet 4 and Haiku 3.5, retired on Anthropic's API but still served on Bedrock or Google Cloud, are priced at Anthropic's list prices instead of being recorded with unknown cost; Haiku 3.5 is priced under its API id `claude-3-5-haiku`.
70
+ - RubyLLM 2.x Vertex AI batch results from a regional or multi-region job are priced at their non-global batch rates instead of their global ones.
71
+ - Gemini models that only Google's Vertex AI pricing page prices, such as Gemini 3.8 Flash Cyber, are priced from it instead of recorded with unknown cost.
72
+ - Claude Haiku 5.5 calls are priced instead of recorded with unknown cost, prompts over 100,000 tokens at Anthropic's higher rates for them.
73
+ - Claude Sonnet 4.5 prompts over 200K tokens, which Bedrock and Vertex AI still serve, are priced at the long-context rates on Google's Vertex AI pricing page instead of standard rates.
74
+ - Mistral models on sale, such as Mistral Large 4, are priced at the sale price on Mistral's pricing page instead of recorded with unknown cost.
75
+ - Mistral Voxtral Small (`voxtral-small-2507`) chats are priced, audio input per minute at its model card's rate, instead of recorded with unknown cost.
76
+ - OpenAI Responses streams whose hosted-tool items, such as file search results, outgrow the 1 MB capture limit are recorded with their usage and tool fees instead of with unknown cost.
77
+ - OpenAI `responses.compact` calls are recorded, through the official openai gem and Faraday.
78
+ - The official openai gem's `client.beta.responses` and legacy `client.completions.create` calls are recorded.
79
+ - Azure OpenAI calls on a `*.cognitiveservices.azure.com` host are recorded as `azure_openai` instead of missed by Faraday and recorded as `openai` by the official openai gem.
80
+ - An image edit with cached input through the official openai gem counts the cached tokens once, as Faraday does, instead of billing them again as image input.
81
+ - RubyLLM 2.1 calls are priced at the `service_tier`, `speed` and `inference_geo` set with `with_provider_options`, which RubyLLM 2.1 no longer keeps on its raw responses.
82
+ - `:block_requests` stops RubyLLM 2.x batch submissions, video and research jobs, RubyLLM 2.1 judgments and `RubyLLM.cache` before they are sent, and recording a cache's storage no longer raises a budget error after the cache is created.
83
+ - `RubyLLM.cache` on Vertex AI records the cache's storage, as on the Gemini API.
84
+ - `with_tags` in fibers sharing a thread, such as RubyLLM tools under `concurrency: :fibers`, no longer tags other fibers' calls or leaves its tags on the thread.
85
+ - `:block_requests` checks batch submissions before they are sent: `batches.create` through the openai gem, `messages.batches.create` through the anthropic gem and `POST /v1/batches` through Faraday.
86
+ - Faraday no longer checks the budget before Anthropic token counting or batch cancellation, which are free.
87
+ - On MySQL, per-tag budgets read each tag value's own spend instead of merging values that differ only by case, accents or trailing spaces.
88
+ - A budget pushed over its limit by `backfill_unknown_pricing` or `reprice`, the backfill after `prices:refresh` included, fires `on_exceeded` once.
89
+ - A host app whose inflections define an `LLM` acronym boots and serves the dashboard instead of failing to load the engine.
90
+ - A tag value page for a whitespace-only value, linked from the tag breakdown, renders instead of returning a 500.
91
+ - RubyLLM 2.x Cohere embeddings record the response id, as chats do.
92
+ - Claude calls on Vertex AI under a versioned model id, such as `claude-sonnet-4-5@20250929`, are priced instead of recorded with unknown cost.
93
+ - Calls made under a read-only database role, such as a GET request with Rails' automatic role switching, are recorded through the writing role instead of lost, and the async inbox no longer binds to a replica; budget reads use the writing role too.
94
+ - A stream event spanning many network reads, such as a streamed image through Faraday, is parsed in linear instead of quadratic time.
95
+ - Recording an openai SDK response no longer walks its embedding vectors, which added up to 60% to a large `embeddings.create` call.
96
+ - Recording a call from a thread outside the Rails executor, such as a thread pool's, no longer keeps a database connection checked out until the thread exits, and the gem no longer calls `ActiveRecord::Base.connection`, so it works with `permanent_connection_checkout = :disallowed`.
97
+ - `close` on an official openai or anthropic SDK stream closes its HTTP response and records the call, instead of leaving the openai connection open and unrecorded.
98
+ - `track_stream` records the call when its block leaves with `return`, `break` or `throw`; it was not recorded.
99
+ - `doctor` reports an error when `budgets.per_tag` is set but `llm_cost_tracker_call_tags` lacks the cost columns, which leaves every per-tag budget unenforced.
100
+ - `Ingestion::Worker.flush!` returns `false` while quarantined inbox rows keep spend out of the ledger and logs when it times out, and `doctor` warns about quarantined rows and, in inline mode, inbox rows that never reached the ledger.
101
+ - On PostgreSQL, re-running an upgrade generator's migration after its concurrent index build was cancelled rebuilds the invalid index instead of keeping it and dropping the working one.
102
+ - The async ingestor treats a statement timeout, a cancelled query or a dropped connection as transient, so a slow or locked ledger no longer quarantines good inbox rows.
103
+ - On MySQL, concurrent calls for different providers under `totals_source: :cache` no longer deadlock on the rollup upsert and lose increments.
104
+ - The Faraday middleware reads a multipart request's model only from a part starting at the request's boundary, so an uploaded file that contains a `model` field can no longer change the recorded model.
105
+ - `prices:refresh` and a re-run of the prices generator keep `pricing.file` entries for models the bundled prices do not list, such as fine-tuned models, instead of deleting them unless marked `"_source": "manual"`, and the refresh change list no longer shows kept entries as removed.
106
+ - `budgets.monthly`, `daily` and `per_call` given as a numeric string, such as an `ENV` value, are used as numbers instead of failing every call or never firing; a non-numeric or negative value, or an `on_exceeded` that cannot be called, raises at `configure`.
107
+ - The Faraday middleware keeps `with_tags` and `tags.default` tags on a call when its own `tags:` proc raises; it dropped them all.
108
+ - A `tags.default` proc that returns `nil` or `false` adds no tags; `false` failed SDK streams before the request and dropped blocking calls.
109
+ - Usage that reports only `total_tokens`, with no input or output split, is recorded as `cost_status: unknown` on a priced model instead of `free` at $0.
110
+ - `prices:check` and `prices:refresh` print changed rates as plain decimals instead of forms like `0.375e1`.
111
+ - `prices:refresh` and `prices:check` write and print the snapshot URL without its credentials, query string or fragment; only known secret parameter names were masked.
112
+ - `Ingestion::Worker.flush!` inside a request, a job or `rails runner` notices inbox rows another process drained, instead of reading a cached answer until it times out.
113
+ - `llm_cost_tracker:backfill_tag_costs` no longer rescans the rows it already filled, which slowed it down on large tables.
114
+ - On MySQL, the install and `upgrade_indexes` migrations no longer create `index_llm_cost_tracker_calls_on_unpriced`, which MySQL built as a plain index on `id` that no query uses.
115
+ - The Calls page shows the CSV export's 10,000-call cap on its export link when more calls match; the cut was silent.
116
+ - `doctor` warns when an enabled SDK integration cannot find one of the SDK classes it patches, such as a renamed resource in a newer SDK, instead of reporting it installed while those calls go unrecorded.
117
+ - The Faraday middleware reads a request body only on LLM URLs, so a Hash body that is not valid JSON, such as binary form data, no longer fails requests to other hosts; an LLM request whose body cannot be read is recorded from its response.
118
+ - A dashboard query that fails, such as on a statement timeout, shows a query failure page, logs the error and reports it through `Rails.error`, instead of asking you to run the install generator; the Tags page no longer shows an empty list when its query fails.
119
+ - The warning for a response the Faraday middleware cannot process names the provider, the model and the failing line, and the async ingestor's warning names the failing line.
120
+
121
+ ## [0.14.2] - 2026-09-28
122
+
123
+ ### Added
124
+
125
+ - `bin/rails llm_cost_tracker:reprice FROM=... [TO=...]` reprices recorded calls, their rollups and per-tag costs at current prices, except provider-billed costs and costs passed to `track`.
126
+ - Bundled xAI and Mistral prices, with batch, priority and regional rates; Faraday captures both once their hosts are in `capture.openai_compatible_providers`.
127
+ - `bin/rails llm_cost_tracker:doctor` warns when `pricing.file` is older than the bundled prices or this month's `:cache` rollups do not match the calls ledger.
128
+ - Gemini Interactions API calls through the Faraday middleware, streamed and `background: true` ones included, are recorded and priced with their grounding.
129
+ - Creating an explicit Gemini context cache through Faraday or `RubyLLM.cache` on RubyLLM 2.x records its estimated storage cost until the cache expires.
130
+ - Price fields with a `_from_YYYY-MM-DD` suffix apply to calls from that date, including in `backfill_unknown_pricing` and `reprice`; bundled Gemini prices use it for announced price changes.
131
+
132
+ ### Changed
133
+
134
+ - BREAKING: Rails 8.0+ required; Rails 7.1 and 7.2 no longer receive security fixes upstream.
135
+ - Calls returning a billed `usage.cost`, such as OpenRouter's, are recorded at the billed amount instead of a list-price estimate or `pricing.overrides` rate.
136
+ - The official openai gem pointed at a host in `capture.openai_compatible_providers` records that provider instead of `openai`.
137
+ - A provider-scoped price (`<provider>/<model>`) prices another provider's calls only when that provider has no price for the model.
138
+ - With `budgets.totals_source = :cache`, monthly budgets read past days from the rollups: run `bin/rails llm_cost_tracker:rebuild_rollups` once after deploying and after switching to `:cache`, or they are under-counted.
139
+ - OpenAI Realtime cached audio and image and Gemini cached audio are priced at their own cached rates and stored as `audio_token` / `image_token` line items with `cache_state: read`.
140
+ - `bin/rails llm_cost_tracker:prices:refresh` no longer takes `PREVIEW=1` and stops when it is set; use `bin/rails llm_cost_tracker:prices:check` instead.
141
+
142
+ ### Removed
143
+
144
+ - The boot warning for `:ruby_llm` enabled together with `:openai` or `:anthropic`: RubyLLM doesn't call those SDKs, so nothing was recorded twice, and disabling one as it advised lost those calls.
145
+
146
+ ### Fixed
147
+
148
+ - Bundled prices cover more OpenAI and Gemini models that recorded unknown cost, and `omni-moderation` calls are `free` instead of `unknown`.
149
+ - `gpt-5.5-pro` prompts over 272K tokens use long-context rates (unknown on Batch and Flex), and `gpt-5.4` Batch and Flex long-context cached input is corrected.
150
+ - OpenAI cached input on Pro models and cache writes on models before GPT-5.6 are priced at the input rate; these calls were `partial`.
151
+ - OpenAI duration-billed transcriptions are priced per second instead of per started minute, and are no longer free in streams or missing through RubyLLM.
152
+ - Transcriptions returned without usage are recorded with unknown cost; they were recorded at $0 or not at all.
153
+ - OpenAI image, transcription and batch calls on regional hosts, and Anthropic batches with `inference_geo: us`, include the data-residency uplift for models with regional rates.
154
+ - The Faraday middleware reads the model from multipart uploads, which were recorded as `unknown`, and records `/v1/audio/speech` by input characters.
155
+ - OpenAI Hosted Shell calls in a hosted container are captured as `container_session` line items, like Code Interpreter.
156
+ - OpenAI Responses created with `background: true` are recorded once, when a poll through the OpenAI SDK or Faraday returns them finished.
157
+ - Responses calls using the `image_generation` tool, RubyLLM 2.x OpenAI chats included, are recorded `partial` instead of `complete` without the image charge.
158
+ - Chat Completions web search fees apply to every call to OpenAI's search models, streams included, and no longer to other models' `url_citation` annotations.
159
+ - A stream on a priced model that ends without usage and has no priced tool charges stores a `nil` total instead of `0.0`, so it shows as unpriced and is in `Call.without_cost`.
160
+ - OpenAI, Azure OpenAI and Anthropic SDK streams price the service tier and speed the provider served, not the requested `priority` or `fast`.
161
+ - OpenAI streams with logprobs record their usage instead of 0 tokens and $0, and an overflowing SDK or `track_stream` capture logs a warning.
162
+ - Groq streams whose usage arrives only in `x_groq.usage` record their tokens instead of 0 tokens and `unknown`.
163
+ - Anthropic streams price cache writes made after `message_start`.
164
+ - Anthropic compaction tokens, which the top-level usage leaves out, are counted; on-demand compactions were recorded as free (not yet through RubyLLM).
165
+ - Anthropic server-side fallback calls are recorded under the serving model, and billed fallback attempts and advisor iterations use their own model's rates; an unpriced one triggers `pricing.unknown_model_behavior` (not yet through RubyLLM).
166
+ - Refusals Anthropic does not bill are recorded at $0 (not yet through RubyLLM), and refusals `Anthropic::BetaRefusalFallbackMiddleware` retried are recorded instead of dropped.
167
+ - Gemini image and audio prompt tokens on single-rate models are priced at the input rate; they were unpriced.
168
+ - Gemini image models are priced per 1M image tokens and price text and thinking output at the text rate; `gemini-2.5-flash-image` images were priced about 770x too low and 3.x image models' images 10-20x too low. Calls already recorded keep their cost. With a local pricing file, run `bin/rails llm_cost_tracker:prices:check`, check that only Gemini image models are flagged, then run `bin/rails llm_cost_tracker:prices:refresh FORCE=1`.
169
+ - Gemini 3 grounding is priced per unique non-empty query, `track_stream` included; image search queries, Maps grounding and the 3.x image models' grounding are now priced.
170
+ - Gemini calls are recorded under the response's `modelVersion`, so `-latest` aliases are priced and `track_stream(provider: :gemini)` works without `model:`.
171
+ - `track_stream(provider: :gemini)` parses native Gemini chunks when `generativelanguage.googleapis.com` is also an OpenAI-compatible provider named `gemini`.
172
+ - Amazon Bedrock Claude ids and inference profiles price as the Anthropic model, with the regional-profile premium from Claude 4.5; GovCloud is not priced at its rate.
173
+ - Groq batch calls are priced at Groq's batch rate, cached tokens included.
174
+ - OpenAI batches that end `expired` or `cancelled` record their completed requests, and image batch results use the batch's model and batch image rates.
175
+ - OpenAI and Anthropic batch results are stored once however often or concurrently the batch is fetched; they could be recorded twice.
176
+ - With `config.enabled = false`, batch retrieval no longer downloads OpenAI output files or queries the database.
177
+ - RubyLLM prices Anthropic US inference, fast mode and OpenAI regional hosts, and its streams outside Bedrock read the service tier, 1-hour cache writes and response id.
178
+ - RubyLLM chats, streamed ones included, record Anthropic and OpenAI web search and Gemini grounding fees; they were stored `complete` without the fee.
179
+ - RubyLLM Anthropic streams count the final cumulative input and every `pause_turn` segment's input, and chats keep earlier segments' cache writes.
180
+ - RubyLLM Gemini chats, Interactions protocol included, are priced from the raw usage, keeping audio and URL-context tool tokens, tiers, grounding and response ids.
181
+ - RubyLLM `paint` prices image output at the image output rate for Gemini image models, `gpt-image-1` and `gpt-image-1-mini`; the image was unpriced.
182
+ - RubyLLM transcriptions price prompt text and audio at their own rates when the response splits them (Gemini without it as text), and streams record `stream: true`.
183
+ - RubyLLM Bedrock Converse chats with prompt caching no longer subtract cache tokens from input twice, and split cache writes into 5-minute and 1-hour writes.
184
+ - RubyLLM Gemini embeddings record their tokens instead of 0, and `gemini-embedding-2` prices image, PDF, audio and video parts by modality, adding a `video_input` rate.
185
+ - The pre-send budget estimate covers RubyLLM calls, whose estimate was $0, and ignores base64 images, PDFs and audio, which inflated it and blocked ordinary vision requests.
186
+ - `llm_cost_tracker:backfill_unknown_pricing` also reprices `partial` calls whose already-priced rates are unchanged, and adds only the difference to rollups.
187
+ - Mode rates derived from a standard cache rate are named by their mode key, such as `batch_cache_read_input`, in `pricing_snapshot` and on line items.
188
+ - With inline ingestion, a per-tag `on_exceeded` fires when several calls for one tag value cross the limit together; each call read the others' spend, so none saw itself as the crossing call and the alert never fired.
189
+ - Under `:raise` and `:block_requests`, every budget a call crosses fires its `on_exceeded` before the first error is raised.
190
+ - `budgets.per_tag` without a `:block_requests` rule no longer checks the database schema before an LLM call is sent, so a process that starts during a database outage no longer fails its LLM calls.
191
+ - A tag key given as both a Symbol and a String is stored once, with the later value, not twice; requeue any async inbox rows quarantined because of it.
192
+ - Tags with a nil or empty value count as untagged on the dashboard, in `cost_by_tag` and for `budgets.per_tag`.
193
+ - An OpenAI SDK response without usage logs a warning instead of being skipped silently.
194
+ - The async worker checks its tables through the Rails schema cache, so an idle poll runs one query instead of six.
195
+ - With `ingestion.mode = :async` and `budgets.totals_source = :cache`, a missing rollups table no longer stops the worker from draining the inbox; it warns once and budget reads use the calls ledger, as inline ingestion does.
196
+ - `track_stream` records events passed as symbol-keyed hashes, such as `to_h` of an OpenAI Realtime `response.done` event; they were ignored and the call was stored as `unknown` with 0 tokens.
197
+ - An OpenAI SDK `chat.completions.stream` without `stream_options: { include_usage: true }` logs the same warning as the Faraday path instead of being stored as `unknown` silently.
198
+ - The overview's monthly budget bar and projection marker and the Data Quality coverage bars are visible again, and the dark theme gets its missing chart colors.
199
+ - In apps whose `Time.zone` is not UTC, daily charts, the previous-period line and the spend-anomaly banner use local days when the database knows the zone name.
200
+ - A tag value page's spend chart covers the selected date range instead of the last 30 days.
201
+ - Sortable dashboard tables no longer return a 500 when the host sets `config.action_controller.include_all_helpers = false`.
202
+
5
203
  ## [0.14.1] - 2026-09-25
6
204
 
7
205
  ### Added
data/LICENSE.txt CHANGED
@@ -19,3 +19,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
21
  THE SOFTWARE.
22
+
23
+ Third-party data
24
+
25
+ lib/llm_cost_tracker/prices.json includes prices derived from LiteLLM's
26
+ model_prices_and_context_window.json (https://github.com/BerriAI/litellm),
27
+ distributed under the following license:
28
+
29
+ MIT License
30
+
31
+ Copyright (c) 2023 Berri AI
32
+
33
+ Permission is hereby granted, free of charge, to any person obtaining a copy
34
+ of this software and associated documentation files (the "Software"), to deal
35
+ in the Software without restriction, including without limitation the rights
36
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
37
+ copies of the Software, and to permit persons to whom the Software is
38
+ furnished to do so, subject to the following conditions:
39
+
40
+ The above copyright notice and this permission notice shall be included in all
41
+ copies or substantial portions of the Software.
42
+
43
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
44
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
45
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
46
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
47
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
48
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
49
+ SOFTWARE.
data/README.md CHANGED
@@ -1,20 +1,18 @@
1
1
  # LLM Cost Tracker
2
2
 
3
- Self-hosted LLM cost tracking for Rails.
3
+ LLM spend tracking and budgets for Rails: by user, feature, or any tag, in your own database, no proxy.
4
4
 
5
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)
6
6
 
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.
7
+ No prompts, no traces, no replay. Spend attribution only.
8
8
 
9
- Not Langfuse, Helicone, or LiteLLM. No prompts, no traces, no replay. Spend attribution only.
10
-
11
- Requires Ruby 3.3+, Rails 7.1+, PostgreSQL or MySQL.
9
+ Requires Ruby 3.3+, Rails 8.0+, PostgreSQL or MySQL. Built for a Rails monolith, not multiple services.
12
10
 
13
11
  <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>
14
12
 
15
13
  ## Quickstart
16
14
 
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)).
15
+ 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
16
 
19
17
  ```ruby
20
18
  # Gemfile
@@ -26,7 +24,7 @@ gem "ruby_llm"
26
24
  bin/rails llm_cost_tracker:setup
27
25
  ```
28
26
 
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`:
27
+ Runs the install generator, writes a local price file, migrates the database, and verifies via `llm_cost_tracker:doctor`. Then enable the integration in the generated `config/initializers/llm_cost_tracker.rb`:
30
28
 
31
29
  ```ruby
32
30
  LlmCostTracker.configure do |config|
@@ -35,9 +33,7 @@ LlmCostTracker.configure do |config|
35
33
  end
36
34
  ```
37
35
 
38
- Edit it in place to add tags, switch on async ingestion, etc.
39
-
40
- Your RubyLLM calls stay unchanged — every chat, embedding, transcription, image, and moderation call now lands in the ledger. Tag them to attribute spend:
36
+ Your RubyLLM calls stay unchanged, and every chat, embedding, transcription, image, and moderation call now lands in the ledger. Tag them to attribute spend:
41
37
 
42
38
  ```ruby
43
39
  LlmCostTracker.with_tags(user_id: Current.user&.id, feature: "chat") do
@@ -45,7 +41,7 @@ LlmCostTracker.with_tags(user_id: Current.user&.id, feature: "chat") do
45
41
  end
46
42
  ```
47
43
 
48
- Mount the dashboard in `config/routes.rb`, behind your auth:
44
+ Mount the dashboard in `config/routes.rb`. The engine ships without authentication, so put it behind yours:
49
45
 
50
46
  ```ruby
51
47
  authenticate :admin do
@@ -53,41 +49,48 @@ authenticate :admin do
53
49
  end
54
50
  ```
55
51
 
56
- The engine ships without authentication on purpose.
57
-
58
- ## What lands in the ledger
52
+ ## What it records
59
53
 
60
54
  - **Calls.** Provider, model, total tokens, total cost, latency, status.
61
- - **Line items.** Per-component breakdown — text/audio/cached tokens, tool charges (web search, code execution, grounding, container sessions).
62
- - **Tags.** Whatever attribution you pass — user, feature, tenant, env.
63
- - **Provider IDs.** Response, project, API key, workspace — for downstream audits.
55
+ - **Line items.** Per-component breakdown: text/audio/cached tokens, tool charges (web search, grounding, container sessions).
56
+ - **Tags.** Whatever attribution you pass: user, feature, tenant, env.
57
+ - **Provider IDs.** Response, plus project, API key and workspace when you pass them to `track` or `track_stream`, for downstream audits.
64
58
  - **Pricing snapshot.** So historical numbers don't drift when prices change.
65
59
 
66
- ## Capture surfaces
60
+ ## Budgets
61
+
62
+ Daily, monthly, and per-call limits, plus per-tag limits such as one monthly budget per `tenant_id`, or a lifetime cap in dollars or calls per agent run (`run_id`, which `RubyLLM.workflow` sets on its calls). A crossed limit calls your `on_exceeded` hook, raises, or blocks the next call before it is sent. See [Budgets](docs/budgets.md).
67
63
 
68
- | Surface | Path |
64
+ ## Supported clients
65
+
66
+ | Client | Captured through |
69
67
  | --- | --- |
70
- | RubyLLM | Provider layer |
71
- | OpenAI | Official SDK or Faraday |
72
- | Anthropic | Official SDK or Faraday |
73
- | Azure OpenAI | Faraday or official SDK (auto-detected on `*.openai.azure.com` and Foundry `*.services.ai.azure.com`, both deployments and `/openai/v1/...`) |
74
- | Google Gemini | Faraday |
75
- | `ruby-openai` | Faraday |
76
- | OpenRouter, DeepSeek, Groq, LiteLLM-style gateways | OpenAI-compatible Faraday |
77
- | Anything else | `LlmCostTracker.track` |
68
+ | RubyLLM | Instrumentation events (2.x) or the provider layer (1.x) |
69
+ | OpenAI, Anthropic | Official SDK or Faraday |
70
+ | Azure OpenAI | Official SDK or Faraday, on `*.openai.azure.com`, and Foundry `*.services.ai.azure.com` or `*.cognitiveservices.azure.com` |
71
+ | Google Gemini, `ruby-openai` | Faraday |
72
+ | OpenRouter, DeepSeek, Groq, xAI, Mistral, Perplexity | Faraday, or the official OpenAI SDK with `base_url` on that host |
73
+ | Other OpenAI-compatible gateways | The same, once the host is added to `config.capture.openai_compatible_providers` |
74
+ | Anything else | [`LlmCostTracker.track`](#manual-tracking) |
75
+
76
+ Streams are recorded from the provider's final usage; see [Streaming](docs/streaming.md).
78
77
 
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`).
78
+ ## Pricing
80
79
 
81
- ## What it isn't
80
+ Captured does not always mean priced:
81
+
82
+ | Cost comes from | Calls |
83
+ | --- | --- |
84
+ | The billed amount in the response or final stream chunk: `usage.cost`, xAI's `usage.cost_in_usd_ticks`, or Perplexity's `usage.cost.total_cost` | OpenRouter, xAI, Perplexity, and other OpenAI-compatible gateways that return one (through RubyLLM 1.x, OpenRouter, xAI, and Perplexity chats only) |
85
+ | Bundled [`prices.json`](lib/llm_cost_tracker/prices.json), read from each provider's own pricing page or API | The OpenAI, Anthropic, Gemini, Groq, OpenRouter, xAI, Mistral, DeepSeek, and Cohere models it lists, DeepSeek at its off-peak rates in its off-peak hours, and the same Claude models on Bedrock through RubyLLM |
86
+ | The OpenAI, Anthropic, or Gemini price for the same model name | Azure OpenAI (by the model in the response, not the deployment name), Vertex AI through RubyLLM, gateways that pass a listed model name through |
87
+ | Nothing: recorded with `cost_status: unknown` | Perplexity without a billed amount (its Router) when no price lists its model, calls through the official OpenAI SDK's Bedrock provider, and through RubyLLM also Ollama, Cohere models without a bundled price (such as Command A+, Embed 4, and Rerank 3.5), other Bedrock models, and Claude on GovCloud (`us-gov.` profiles) |
82
88
 
83
- - No proxy. Direct calls only.
84
- - No prompts. Token counts and metadata only.
85
- - No traces, evals, or prompt management. Different product, different gem.
86
- - Not multi-service. Built for a Rails monolith.
89
+ Add missing prices to `config.pricing.file` or `config.pricing.overrides` ([Pricing](docs/pricing.md)), then run `bin/rails llm_cost_tracker:backfill_unknown_pricing` to price the calls already recorded. `bin/rails llm_cost_tracker:prices:refresh` runs it after writing a new `config.pricing.file` if the database is reachable.
87
90
 
88
91
  ## Manual tracking
89
92
 
90
- For batch jobs, internal gateways, or anything without an SDK/Faraday hook:
93
+ For batch jobs, internal gateways, or anything without an SDK or Faraday hook:
91
94
 
92
95
  ```ruby
93
96
  LlmCostTracker.track(
@@ -124,4 +127,4 @@ bin/check
124
127
 
125
128
  ## License
126
129
 
127
- MIT — see [LICENSE.txt](LICENSE.txt).
130
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -154,6 +154,9 @@
154
154
  --lct-shadow-md: 0 2px 6px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);
155
155
  --lct-shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
156
156
  --lct-focus-ring: 0 0 0 3px rgba(124, 131, 255, 0.30);
157
+ --lct-chart-marker: rgba(230, 236, 245, 0.45);
158
+ --lct-chart-secondary: rgba(230, 236, 245, 0.32);
159
+ --lct-chip-remove: rgba(230, 236, 245, 0.58);
157
160
  }
158
161
 
159
162
  * { box-sizing: border-box; }
@@ -613,6 +616,19 @@
613
616
  .lct-stack-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
614
617
  .lct-stack-meta { color: var(--lct-muted); }
615
618
  .lct-stack-empty { color: var(--lct-muted); font-size: var(--fs-sm); margin: 0; }
619
+ .lct-bar-track,
620
+ .lct-budget-track { background: var(--lct-surface-2); border-radius: 999px; overflow: hidden; }
621
+ .lct-bar-track { height: 8px; min-width: 96px; }
622
+ .lct-budget-track { height: 10px; position: relative; }
623
+ .lct-bar-fill,
624
+ .lct-budget-fill { height: 100%; display: block; background: var(--lct-accent); }
625
+ .lct-budget-fill--warn { background: var(--lct-warning); }
626
+ .lct-budget-fill--over { background: var(--lct-danger); }
627
+ .lct-budget-marker { position: absolute; top: 0; bottom: 0; border-left: 2px dashed var(--lct-chart-marker); }
628
+ .lct-budget-projection { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: baseline; margin: 10px 0 0; color: var(--lct-muted); font-size: var(--fs-sm); }
629
+ .lct-budget-projection strong { color: var(--lct-text); }
630
+ .lct-budget-projection-status { font-weight: 600; }
631
+ .lct-budget-projection-status--over { color: var(--lct-warning-copy); }
616
632
 
617
633
  .lct-legend { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-xs); color: var(--lct-muted); }
618
634
  .lct-legend-dot { width: 9px; height: 9px; border-radius: 2px; }
@@ -20,7 +20,7 @@ module LlmCostTracker
20
20
  rescue_from ActiveRecord::ConnectionNotEstablished, with: :render_database_error
21
21
  rescue_from ActiveRecord::AdapterNotSpecified, with: :render_database_error
22
22
  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
23
- rescue_from ActiveRecord::StatementInvalid, with: :render_database_error
23
+ rescue_from ActiveRecord::StatementInvalid, with: :render_query_error
24
24
  rescue_from LlmCostTracker::InvalidFilterError, with: :render_invalid_filter
25
25
 
26
26
  private
@@ -52,6 +52,12 @@ module LlmCostTracker
52
52
  render_error_page("database", :internal_server_error)
53
53
  end
54
54
 
55
+ def render_query_error(error)
56
+ Logging.warn("Dashboard query failed: #{error.class}: #{error.message}")
57
+ Rails.error.report(error, handled: true)
58
+ render_error_page("query", :internal_server_error)
59
+ end
60
+
55
61
  def render_invalid_filter(error)
56
62
  @error_message = error.message
57
63
  render_error_page("invalid_filter", :bad_request)
@@ -1,39 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "csv"
4
- require "json"
5
-
6
3
  module LlmCostTracker
7
4
  class CallsController < ApplicationController
8
5
  CSV_EXPORT_LIMIT = 10_000
9
6
  CSV_EXPORT_BATCH_SIZE = 500
10
- CSV_FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
11
- DEFAULT_TIEBREAKER = { tracked_at: :desc, id: :desc }.freeze
12
- SORT_OPTIONS = %w[tracked_at provider model input output cost latency].freeze
13
- NULLS_LAST_GUARD = {
14
- total_cost: Arel.sql("CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC"),
15
- latency_ms: Arel.sql("CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC")
16
- }.freeze
17
7
 
18
8
  def index
19
- @sort = params[:sort].to_s
20
- @dir = params[:dir].to_s
21
9
  scope = Dashboard::Filter.call(params: params)
22
10
  scope = scope.unknown_pricing if params[:cost_status].to_s == "incomplete"
23
- ordered_scope = scope.order(*calls_order(@sort, @dir))
11
+ ordered_scope = Dashboard::CallOrder.call(scope, sort: params[:sort], direction: params[:dir])
24
12
 
25
13
  respond_to do |format|
26
- format.html do
27
- @page = Dashboard::Pagination.call(params)
28
- @calls_count, @calls_total_cost = scope.pick(Arel.sql("COUNT(*), COALESCE(SUM(total_cost), 0)"))
29
- @calls = ordered_scope.includes(:tag_records).limit(@page.per).offset(@page.offset).to_a
30
- end
31
- format.csv do
32
- response.headers["Cache-Control"] = "no-store"
33
- send_data render_csv(ordered_scope),
34
- type: "text/csv",
35
- disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
36
- end
14
+ format.html { assign_page(scope, ordered_scope) }
15
+ format.csv { send_csv(ordered_scope) }
37
16
  end
38
17
  end
39
18
 
@@ -43,80 +22,17 @@ module LlmCostTracker
43
22
 
44
23
  private
45
24
 
46
- def calls_order(sort, dir)
47
- column = SORT_OPTIONS.include?(sort) ? sort.to_sym : :tracked_at
48
- natural = %i[provider model].include?(column) ? :asc : :desc
49
- direction = Dashboard::Sort::DIRECTIONS.include?(dir.downcase) ? dir.downcase.to_sym : natural
50
-
51
- case column
52
- when :tracked_at then [{ tracked_at: direction, id: direction }]
53
- when :provider then [{ provider: direction, model: :asc, **DEFAULT_TIEBREAKER }]
54
- when :model then [{ model: direction, **DEFAULT_TIEBREAKER }]
55
- when :input then [{ input_tokens: direction, **DEFAULT_TIEBREAKER }]
56
- when :output then [{ output_tokens: direction, **DEFAULT_TIEBREAKER }]
57
- when :cost then [NULLS_LAST_GUARD[:total_cost], { total_cost: direction, **DEFAULT_TIEBREAKER }]
58
- when :latency then [NULLS_LAST_GUARD[:latency_ms], { latency_ms: direction, **DEFAULT_TIEBREAKER }]
59
- end
60
- end
61
-
62
- def render_csv(relation)
63
- fields = csv_fields
64
- CSV.generate do |csv|
65
- csv << fields.map(&:to_s)
66
- each_export_batch(relation) do |call|
67
- csv << fields.map { |field| csv_value(field, call) }
68
- end
69
- end
70
- end
71
-
72
- def each_export_batch(relation, &)
73
- offset = 0
74
- while offset < CSV_EXPORT_LIMIT
75
- batch_size = [CSV_EXPORT_BATCH_SIZE, CSV_EXPORT_LIMIT - offset].min
76
- batch = relation.limit(batch_size).offset(offset).preload(:tag_records).to_a
77
- break if batch.empty?
78
-
79
- batch.each(&)
80
- offset += batch.size
81
- end
82
- end
83
-
84
- def csv_fields
85
- %i[tracked_at provider model] +
86
- Usage::TokenUsage.members +
87
- %i[
88
- total_cost cost_status pricing_snapshot latency_ms provider_response_id provider_project_id
89
- provider_api_key_id provider_workspace_id batch tags
90
- ]
25
+ def assign_page(scope, ordered_scope)
26
+ @page = Dashboard::Pagination.call(params)
27
+ @calls_count, @calls_total_cost = scope.pick(Arel.sql("COUNT(*), COALESCE(SUM(total_cost), 0)"))
28
+ @calls = ordered_scope.includes(:tag_records).limit(@page.per).offset(@page.offset).to_a
91
29
  end
92
30
 
93
- def csv_value(field, call)
94
- case field
95
- when :tracked_at
96
- call.tracked_at.utc.iso8601
97
- when :provider_api_key_id, :provider_workspace_id, :provider_project_id
98
- csv_safe(LlmCostTracker::Dashboard::Masking.mask_value(field, call[field]))
99
- when :provider, :model, :provider_response_id, :cost_status
100
- csv_safe(call[field])
101
- when :pricing_snapshot
102
- csv_safe(csv_json(call.pricing_snapshot))
103
- when :tags
104
- csv_safe(call.tag_pairs.to_json)
105
- else
106
- call[field]
107
- end
108
- end
109
-
110
- def csv_json(value)
111
- Hash(value).deep_stringify_keys.to_json
112
- end
113
-
114
- def csv_safe(value)
115
- return value if value.nil?
116
-
117
- string = value.to_s
118
- stripped = string.lstrip
119
- CSV_FORMULA_PREFIXES.include?(stripped[0]) ? "'#{string}" : string
31
+ def send_csv(relation)
32
+ response.headers["Cache-Control"] = "no-store"
33
+ send_data Dashboard::CallsExport.call(relation, limit: CSV_EXPORT_LIMIT, batch_size: CSV_EXPORT_BATCH_SIZE),
34
+ type: "text/csv",
35
+ disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
120
36
  end
121
37
  end
122
38
  end
@@ -3,19 +3,14 @@
3
3
  module LlmCostTracker
4
4
  class DashboardController < ApplicationController
5
5
  def index
6
- prev_from, prev_to = previous_range
7
- filter_params = LlmCostTracker::Dashboard::Params.to_hash(params)
8
- scope = Dashboard::Filter.call(
9
- params: filter_params.merge("from" => @from_date.iso8601, "to" => @to_date.iso8601)
10
- )
11
- previous_scope = Dashboard::Filter.call(
12
- params: filter_params.merge("from" => prev_from.iso8601, "to" => prev_to.iso8601)
13
- )
6
+ previous_from, previous_to = previous_range
7
+ scope = scope_between(@from_date, @to_date)
8
+ previous_scope = scope_between(previous_from, previous_to)
14
9
 
15
10
  @stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
16
11
  @monthly_budget_status = Dashboard::MonthlyBudget.status
17
12
  @time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
18
- @comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
13
+ @comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: previous_from, to: previous_to)
19
14
  @spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
20
15
  @top_models = Dashboard::TopModels.call(scope: scope)
21
16
  @providers = Dashboard::ProviderBreakdown.call(scope: scope)
@@ -23,11 +18,15 @@ module LlmCostTracker
23
18
 
24
19
  private
25
20
 
21
+ def scope_between(from, to)
22
+ filter_params = Dashboard::Params.to_hash(params).merge("from" => from.iso8601, "to" => to.iso8601)
23
+ Dashboard::Filter.call(params: filter_params)
24
+ end
25
+
26
26
  def previous_range
27
27
  span_days = (@to_date - @from_date).to_i + 1
28
- prev_to = @from_date - 1
29
- prev_from = prev_to - (span_days - 1)
30
- [prev_from, prev_to]
28
+ previous_to = @from_date - 1
29
+ [previous_to - (span_days - 1), previous_to]
31
30
  end
32
31
  end
33
32
  end