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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/LICENSE.txt +28 -0
- data/README.md +33 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
- data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
- data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
- data/app/models/llm_cost_tracker/call.rb +6 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +19 -157
- data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
- data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
- data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +23 -20
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +22 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +20 -35
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +65 -81
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
- data/lib/llm_cost_tracker/ledger/store.rb +17 -35
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
- data/lib/llm_cost_tracker/parsers.rb +12 -0
- data/lib/llm_cost_tracker/prices.json +1179 -208
- data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
- data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
- data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +32 -26
- data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +35 -5
- data/llm_cost_tracker.gemspec +6 -5
- metadata +58 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3fbd251f06208de2aaeb887d9e8603fe7614e365c1d5d5e9aef0c27dba5f54d2
|
|
4
|
+
data.tar.gz: 78600f3f5844d367c4dd9030c3af8b024937f90f501b9bab51a792df085ab9a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
3
|
+
LLM spend tracking and budgets for Rails: by user, feature, or any tag, in your own database, no proxy.
|
|
4
4
|
|
|
5
5
|
[](https://rubygems.org/gems/llm_cost_tracker) [](https://github.com/sergey-homenko/llm_cost_tracker/actions) [](https://codecov.io/gh/sergey-homenko/llm_cost_tracker)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
No prompts, no traces, no replay. Spend attribution only.
|
|
8
8
|
|
|
9
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
62
|
-
- **Tags.** Whatever attribution you pass
|
|
63
|
-
- **Provider IDs.** Response, project, API key
|
|
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
|
-
##
|
|
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
|
-
|
|
64
|
+
## Supported clients
|
|
65
|
+
|
|
66
|
+
| Client | Captured through |
|
|
69
67
|
| --- | --- |
|
|
70
|
-
| RubyLLM |
|
|
71
|
-
| OpenAI | Official SDK or Faraday |
|
|
72
|
-
|
|
|
73
|
-
|
|
|
74
|
-
|
|
|
75
|
-
|
|
|
76
|
-
|
|
|
77
|
-
|
|
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
|
-
|
|
78
|
+
## Pricing
|
|
80
79
|
|
|
81
80
|
Captured does not always mean priced:
|
|
82
81
|
|
|
83
82
|
| Cost comes from | Calls |
|
|
84
83
|
| --- | --- |
|
|
85
|
-
| The
|
|
86
|
-
| Bundled [`prices.json`](lib/llm_cost_tracker/prices.json) | The OpenAI, Anthropic, Gemini, Groq, OpenRouter, xAI, and
|
|
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` |
|
|
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
|
-
|
|
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
|
|
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
|
|
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: :
|
|
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 =
|
|
11
|
+
ordered_scope = Dashboard::CallOrder.call(scope, sort: params[:sort], direction: params[:dir])
|
|
22
12
|
|
|
23
13
|
respond_to do |format|
|
|
24
|
-
format.html
|
|
25
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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:
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|