llm_cost_tracker 0.14.2 → 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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c1f13f9f919518298bf4aa5bb9bf33f6fb48851a963d132d48000c2c9ab2477
4
- data.tar.gz: 1de730cdb5061df2588c74dd0c56ec29aa59ec0fe812188e2ae30df4e206c682
3
+ metadata.gz: 3fbd251f06208de2aaeb887d9e8603fe7614e365c1d5d5e9aef0c27dba5f54d2
4
+ data.tar.gz: 78600f3f5844d367c4dd9030c3af8b024937f90f501b9bab51a792df085ab9a7
5
5
  SHA512:
6
- metadata.gz: ee3ac1c8844c4bc478f85899d9e74734d67040d4b4f124c1751a970924a6d4ccf6fd2d680a2ed2a0d0e51ea79d7e985ad623ff49ed88148129df0276ba6c0925
7
- data.tar.gz: 2aaa9cad0cd45a6e7df0920f054c258d12f03b2edc99c1c3bcf846d4ba33f4ce62972419cc92c5014711e417cb5f12eee63df2ac2f3c3e691acf47c8c620fdb8
6
+ metadata.gz: 11e337b545c34c40b5ce468278a3a7d15f562bd016269b1dd59c41053fd0eb7602b2025aaa286c9dd603dd2740502b37afca51689ba843d5d039e1d8385490f7
7
+ data.tar.gz: b918914d2b8b7671d02597d8f08000818f38d890609ae2a81286fa279da5ab3efaec230690c31ec5e060d2b02dcab6d4bc93bf129804233158c8681cf4575aa8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,122 @@
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
+
5
121
  ## [0.14.2] - 2026-09-28
6
122
 
7
123
  ### 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
- Per-tenant LLM spend attribution and budgets for Rails — in your database, no proxy.
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 through RubyLLM, the official OpenAI and Anthropic SDKs, Gemini, or any OpenAI-compatible API is logged with tokens, cost, and your tags. Budgets can block a tenant's next call before it is sent.
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 8.0+, 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,52 +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, 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; xAI, Mistral, and other gateways (LiteLLM etc.) once their host is added to `config.capture.openai_compatible_providers` | OpenAI-compatible Faraday, or the official OpenAI SDK with `base_url` on that host |
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
80
  Captured does not always mean priced:
82
81
 
83
82
  | Cost comes from | Calls |
84
83
  | --- | --- |
85
- | The amount billed, from `usage.cost` in the response or final stream chunk | OpenRouter through Faraday, the official OpenAI SDK, or `track_stream`, and any OpenAI-compatible gateway that returns `usage.cost`; bundled prices apply when it is missing or the call comes through RubyLLM, other than an OpenRouter chat |
86
- | Bundled [`prices.json`](lib/llm_cost_tracker/prices.json) | The OpenAI, Anthropic, Gemini, Groq, OpenRouter, xAI, and Mistral models it lists, and the same Claude models on Bedrock through RubyLLM |
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 |
87
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 |
88
- | Nothing: recorded with `cost_status: unknown` | DeepSeek, and through RubyLLM also Perplexity, Ollama, other Bedrock models, and Claude on GovCloud (`us-gov.` profiles) |
89
-
90
- 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.
91
-
92
- ## What it isn't
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) |
93
88
 
94
- - No proxy. Direct calls only.
95
- - No prompts. Token counts and metadata only.
96
- - No traces, evals, or prompt management. Different product, different gem.
97
- - 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.
98
90
 
99
91
  ## Manual tracking
100
92
 
101
- 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:
102
94
 
103
95
  ```ruby
104
96
  LlmCostTracker.track(
@@ -135,4 +127,4 @@ bin/check
135
127
 
136
128
  ## License
137
129
 
138
- MIT — see [LICENSE.txt](LICENSE.txt).
130
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -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,37 +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
9
  scope = Dashboard::Filter.call(params: params)
20
10
  scope = scope.unknown_pricing if params[:cost_status].to_s == "incomplete"
21
- ordered_scope = scope.order(*calls_order(params[:sort].to_s, params[:dir].to_s))
11
+ ordered_scope = Dashboard::CallOrder.call(scope, sort: params[:sort], direction: params[:dir])
22
12
 
23
13
  respond_to do |format|
24
- format.html do
25
- @page = Dashboard::Pagination.call(params)
26
- @calls_count, @calls_total_cost = scope.pick(Arel.sql("COUNT(*), COALESCE(SUM(total_cost), 0)"))
27
- @calls = ordered_scope.includes(:tag_records).limit(@page.per).offset(@page.offset).to_a
28
- end
29
- format.csv do
30
- response.headers["Cache-Control"] = "no-store"
31
- send_data render_csv(ordered_scope),
32
- type: "text/csv",
33
- disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
34
- end
14
+ format.html { assign_page(scope, ordered_scope) }
15
+ format.csv { send_csv(ordered_scope) }
35
16
  end
36
17
  end
37
18
 
@@ -41,80 +22,17 @@ module LlmCostTracker
41
22
 
42
23
  private
43
24
 
44
- def calls_order(sort, dir)
45
- column = SORT_OPTIONS.include?(sort) ? sort.to_sym : :tracked_at
46
- natural = %i[provider model].include?(column) ? :asc : :desc
47
- direction = Dashboard::Sort::DIRECTIONS.include?(dir.downcase) ? dir.downcase.to_sym : natural
48
-
49
- case column
50
- when :tracked_at then [{ tracked_at: direction, id: direction }]
51
- when :provider then [{ provider: direction, model: :asc, **DEFAULT_TIEBREAKER }]
52
- when :model then [{ model: direction, **DEFAULT_TIEBREAKER }]
53
- when :input then [{ input_tokens: direction, **DEFAULT_TIEBREAKER }]
54
- when :output then [{ output_tokens: direction, **DEFAULT_TIEBREAKER }]
55
- when :cost then [NULLS_LAST_GUARD[:total_cost], { total_cost: direction, **DEFAULT_TIEBREAKER }]
56
- when :latency then [NULLS_LAST_GUARD[:latency_ms], { latency_ms: direction, **DEFAULT_TIEBREAKER }]
57
- end
58
- end
59
-
60
- def render_csv(relation)
61
- fields = csv_fields
62
- CSV.generate do |csv|
63
- csv << fields.map(&:to_s)
64
- each_export_batch(relation) do |call|
65
- csv << fields.map { |field| csv_value(field, call) }
66
- end
67
- end
68
- end
69
-
70
- def each_export_batch(relation, &)
71
- offset = 0
72
- while offset < CSV_EXPORT_LIMIT
73
- batch_size = [CSV_EXPORT_BATCH_SIZE, CSV_EXPORT_LIMIT - offset].min
74
- batch = relation.limit(batch_size).offset(offset).preload(:tag_records).to_a
75
- break if batch.empty?
76
-
77
- batch.each(&)
78
- offset += batch.size
79
- end
80
- end
81
-
82
- def csv_fields
83
- %i[tracked_at provider model] +
84
- Usage::TokenUsage.members +
85
- %i[
86
- total_cost cost_status pricing_snapshot latency_ms provider_response_id provider_project_id
87
- provider_api_key_id provider_workspace_id batch tags
88
- ]
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
89
29
  end
90
30
 
91
- def csv_value(field, call)
92
- case field
93
- when :tracked_at
94
- call.tracked_at.utc.iso8601
95
- when :provider_api_key_id, :provider_workspace_id, :provider_project_id
96
- csv_safe(LlmCostTracker::Dashboard::Masking.mask_value(field, call[field]))
97
- when :provider, :model, :provider_response_id, :cost_status
98
- csv_safe(call[field])
99
- when :pricing_snapshot
100
- csv_safe(csv_json(call.pricing_snapshot))
101
- when :tags
102
- csv_safe(call.tag_pairs.to_json)
103
- else
104
- call[field]
105
- end
106
- end
107
-
108
- def csv_json(value)
109
- Hash(value).deep_stringify_keys.to_json
110
- end
111
-
112
- def csv_safe(value)
113
- return value if value.nil?
114
-
115
- string = value.to_s
116
- stripped = string.lstrip
117
- 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")
118
36
  end
119
37
  end
120
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
@@ -4,13 +4,24 @@ module LlmCostTracker
4
4
  class PricingController < ApplicationController
5
5
  def index
6
6
  @overview = Dashboard::PricingOverview.call
7
- requested = params[:source].to_s.to_sym
8
- @active_source = @overview.fetch(:sources).key?(requested) ? requested : @overview.fetch(:effective_source)
7
+ @active_source = requested_source || @overview.fetch(:effective_source)
9
8
  @source_data = @overview.fetch(:sources).fetch(@active_source)
10
9
  @provider_filter = Dashboard::Params.scalar(params[:provider], :provider).presence
11
- @rows = @source_data.fetch(:rows)
12
- @rows = @rows.select { |row| row.provider == @provider_filter } if @provider_filter
13
10
  @providers = @source_data.fetch(:rows).map(&:provider).compact.uniq.sort
11
+ @rows = provider_rows(@source_data.fetch(:rows))
12
+ end
13
+
14
+ private
15
+
16
+ def requested_source
17
+ source = params[:source].to_s.to_sym
18
+ source if @overview.fetch(:sources).key?(source)
19
+ end
20
+
21
+ def provider_rows(rows)
22
+ return rows unless @provider_filter
23
+
24
+ rows.select { |row| row.provider == @provider_filter }
14
25
  end
15
26
  end
16
27
  end
@@ -10,22 +10,29 @@ module LlmCostTracker
10
10
  @value = Dashboard::Params.scalar(params[:tag_value], :tag_value)
11
11
 
12
12
  if @value.empty?
13
- @breakdown = Dashboard::TagBreakdown.call(
14
- scope: Dashboard::Filter.call(params: params),
15
- key: params[:key],
16
- sort: params[:sort].to_s,
17
- direction: params[:dir].to_s
18
- )
13
+ assign_breakdown
19
14
  else
20
- @key = LlmCostTracker::Tags::Key.validate!(
21
- params[:key],
22
- error_class: LlmCostTracker::InvalidFilterError
23
- )
24
- value_scope = Dashboard::Filter.call(params: params, tags: { @key => @value })
25
- @value_total_cost = value_scope.sum(:total_cost).to_f
26
- @value_calls = value_scope.count
27
- @value_points = Dashboard::TimeSeries.call(scope: value_scope, from: @from_date, to: @to_date)
15
+ assign_value_trend
28
16
  end
29
17
  end
18
+
19
+ private
20
+
21
+ def assign_breakdown
22
+ @breakdown = Dashboard::TagBreakdown.call(
23
+ scope: Dashboard::Filter.call(params: params),
24
+ key: params[:key],
25
+ sort: params[:sort].to_s,
26
+ direction: params[:dir].to_s
27
+ )
28
+ end
29
+
30
+ def assign_value_trend
31
+ @key = LlmCostTracker::Tags::Key.validate!(params[:key], error_class: LlmCostTracker::InvalidFilterError)
32
+ value_scope = Dashboard::Filter.call(params: params, tags: { @key => @value })
33
+ @value_total_cost = value_scope.sum(:total_cost).to_f
34
+ @value_calls = value_scope.count
35
+ @value_points = Dashboard::TimeSeries.call(scope: value_scope, from: @from_date, to: @to_date)
36
+ end
30
37
  end
31
38
  end