llm_cost_tracker 0.11.0 → 0.12.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 +55 -0
- data/README.md +7 -4
- data/app/assets/llm_cost_tracker/application.css +8 -7
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
- data/app/models/llm_cost_tracker/call.rb +28 -63
- data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
- data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
- data/app/models/llm_cost_tracker/call_tag.rb +0 -2
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
- data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
- data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
- data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
- data/config/routes.rb +2 -3
- data/lib/llm_cost_tracker/budget.rb +24 -26
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/capture/sse.rb +1 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
- data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
- data/lib/llm_cost_tracker/charges/cost.rb +27 -0
- data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
- data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
- data/lib/llm_cost_tracker/check.rb +5 -0
- data/lib/llm_cost_tracker/configuration.rb +13 -44
- data/lib/llm_cost_tracker/currency.rb +5 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
- data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
- data/lib/llm_cost_tracker/doctor.rb +5 -69
- data/lib/llm_cost_tracker/engine.rb +4 -4
- data/lib/llm_cost_tracker/event.rb +12 -20
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
- data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
- data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
- data/lib/llm_cost_tracker/ingestion.rb +24 -36
- data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
- data/lib/llm_cost_tracker/integrations/base.rb +39 -57
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
- data/lib/llm_cost_tracker/integrations.rb +32 -25
- data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
- data/lib/llm_cost_tracker/ledger/period.rb +5 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
- data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
- data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
- data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
- data/lib/llm_cost_tracker/ledger/store.rb +18 -42
- data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
- data/lib/llm_cost_tracker/ledger.rb +8 -18
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
- data/lib/llm_cost_tracker/parsers.rb +139 -26
- data/lib/llm_cost_tracker/prices.json +1707 -1
- data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
- data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
- data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
- data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
- data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
- data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
- data/lib/llm_cost_tracker/pricing/source.rb +7 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
- data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
- data/lib/llm_cost_tracker/pricing.rb +10 -278
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
- data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
- data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
- data/lib/llm_cost_tracker/providers.rb +35 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -3
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +1 -1
- data/lib/llm_cost_tracker/report.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +6 -19
- data/lib/llm_cost_tracker/tags/context.rb +9 -6
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
- data/lib/llm_cost_tracker/timing.rb +2 -4
- data/lib/llm_cost_tracker/tracker.rb +24 -36
- data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
- data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
- data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
- data/lib/llm_cost_tracker/usage/source.rb +14 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +43 -52
- data/lib/tasks/llm_cost_tracker.rake +14 -73
- metadata +81 -55
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
- data/lib/llm_cost_tracker/billing/components.rb +0 -95
- data/lib/llm_cost_tracker/capture/stream.rb +0 -9
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
- data/lib/llm_cost_tracker/doctor/check.rb +0 -7
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
- data/lib/llm_cost_tracker/masking.rb +0 -39
- data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
- data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
- data/lib/llm_cost_tracker/parsers/base.rb +0 -131
- data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
- data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
- data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
- data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
- data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
- data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3bb624cf9437e2ab972021128ab552b48b16c9b8d209429fb264062837e8547
|
|
4
|
+
data.tar.gz: 8785221213ed888a592b312e5a734193637653930ef9652ece73f650cb920eb5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c223c14dbfe3e2ebf61930175ae7607c2a4a05f502962963312c4ec929965242fccab115eda9d1426d6e331d7fb23ad811f73c9ba8a795cb3262c3d49a60eb45
|
|
7
|
+
data.tar.gz: 6b8e3ef019f41907909bb9f07eb58085dd6355a442699d52440de564c97fb6fb65979ee01271e4741bd9411ce7ef6a3b69102accd764fdf419d42db1bdb2f6e8
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,61 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.12.0] - 2026-06-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `bin/rails llm_cost_tracker:rebuild_rollups` rebuilds the `llm_cost_tracker_call_rollups` cache from the calls ledger — populate it after turning on `config.cache_rollups` for an app with existing calls, or resync it if rollup totals ever drift from the calls.
|
|
12
|
+
|
|
13
|
+
### Removed
|
|
14
|
+
|
|
15
|
+
- BREAKING: the experimental `Reconciliation` subsystem (provider invoice import + diff, the `/reconciliation` dashboard page, `bin/rails llm_cost_tracker:reconcile:*` rake tasks, `config.reconciliation_enabled`, `config.reconciliation_importers`, the `llm_cost_tracker:reconciliation` generator, and the `llm_cost_tracker_provider_invoices` / `_provider_invoice_imports` tables) is gone. It was never finished and never billing-accurate. `calls.provider_response_id` (captured on every call) already covers invoice cross-reference; if invoice-vs-ledger reconciliation ships again it lives in a separate gem. Existing installs can drop the two tables — see [docs/upgrading.md](docs/upgrading.md#v011--v012-unreleased).
|
|
16
|
+
- `config.instrument :gemnii` (or any other typo / unknown integration name) no longer raises at config time — it now logs `Logging.warn("Unknown integration: :gemnii. Known: ...")` once when integrations install, and `bin/rails llm_cost_tracker:doctor` shows the unknown name as a `:warn` row so the typo is visible without crashing boot.
|
|
17
|
+
- Pre-call budget enforcement for Azure-hosted OpenAI calls now keys on `"azure_openai"` (matching the recorded `Call.provider`), so `pricing_overrides` for Azure rates actually gate the call. Previously it always keyed on `"openai"` regardless of the SDK client's `base_url`.
|
|
18
|
+
- BREAKING: removed the `batch:` keyword argument from `LlmCostTracker.track`, `LlmCostTracker.track_stream`, and `stream.usage` (inside `track_stream` blocks). Signal a batch-tier call via `pricing_mode: :batch` (or any pricing_mode containing the `batch` token like `:batch_flex`) — that's the single source of truth now. Previously `batch:` and `pricing_mode:` could disagree, especially after request-side pricing_mode merge inside `Tracker.record` overwrote the parser's mode but left the stored `batch` flag stale, so `calls.batch` could read `true` while `calls.pricing_mode` read `flex` (or vice versa) for the same row.
|
|
19
|
+
- The `bin/rails llm_cost_tracker:prices:explain` rake task (and `LlmCostTracker::Pricing.explain`) is removed — the dashboard's Data Quality page surfaces unknown-pricing models and their effective rates instead.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- The RubyLLM SDK integration now requires `ruby_llm >= 1.15.0` (was `>= 1.14.1`).
|
|
24
|
+
- Engine no longer adds `tag` / `tag_value` to Rails `filter_parameters` — the Symbol filter was substring-matching unrelated host-app params (`tags`, `meta_tag`, etc.) into `[FILTERED]`. `Tags::Sanitizer` continues redacting secret-shaped tag values at storage.
|
|
25
|
+
- BREAKING: the serialized event `cost` (the `llm_request.llm_cost_tracker` notification payload and the async-ingestion inbox payload) is now `{ components: {...}, total:, currency: }` (was flat with a top-level `total_cost:`). Notification subscribers should read `cost[:total]`; `ingestion: :async` rolling deploys should drain the inbox first — see [docs/upgrading.md](docs/upgrading.md#v011--v012-unreleased).
|
|
26
|
+
- BREAKING: `pricing_mode` in the `llm_request.llm_cost_tracker` notification payload is now a String (e.g. `"batch"`, `"fast_data_residency"`), not a Symbol — subscribers matching it against a Symbol must compare to the String.
|
|
27
|
+
- BREAKING: `LlmCostTracker.track(tokens:)` now takes the same `_tokens`-suffixed keys as `stream.usage` and the stored columns — `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `audio_input_tokens`, etc. (was the short `input`, `output`, `cache_read_input`, …). Update manual `track` calls. Pricing-file / `pricing_overrides` field names are unchanged — they stay `input`, `output`, … (per-component rates, a separate vocabulary).
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- RubyLLM streaming chats to Anthropic and Gemini (`chat.ask { |chunk| }`) are now recorded — previously the streamed response's raw body is the SSE text rather than the parsed hash the integration read, so an internal lookup raised and the call was silently dropped from the ledger. Blocking RubyLLM calls were unaffected.
|
|
32
|
+
- A malformed or very long `pricing_mode` (or a provider `service_tier` / `speed` with many underscore-separated tokens) no longer hangs cost calculation — the call lands `cost_status: unknown` instead of pinning a CPU.
|
|
33
|
+
- Gemini preview models dated with a four-digit year (e.g. `gemini-2.5-flash-preview-09-2025`) now fall back to the stable model's price instead of landing `cost_status: unknown`.
|
|
34
|
+
- A typo'd price-key prefix in `pricing_overrides` or a custom `prices_file` (e.g. `bath_input` for `batch_input`, or any unknown `<mode>_<component>`) now logs an `Unknown price keys` warning and is ignored, instead of being silently accepted so the override quietly never applied at the intended mode/tier.
|
|
35
|
+
- Anthropic responses with `service_tier: "priority"` now keep `:priority` as their pricing_mode instead of being silently billed at standard rates — committed-tier customers get `cost_status: unknown` (signaling to add `priority_input`/`priority_output` to `pricing_overrides`) instead of an over-counted USD figure that ignores their commitment discount.
|
|
36
|
+
- OpenAI's `scale` enterprise tier and `priority` tier are now recognized as pricing modes (no more `Logging.warn` about unknown tokens); calls land as `cost_status: unknown` when negotiated rates are absent so you can add them via `pricing_overrides`.
|
|
37
|
+
- Gemini responses echoing `usageMetadata.serviceTier: "unspecified"` (the default) now resolve to standard pricing instead of warning about an unknown token and landing as `cost_status: unknown`.
|
|
38
|
+
- Anthropic SDK batch results (`client.messages.batches.results_streaming(id).each`) land in the ledger with `pricing_mode: :batch` and the per-result `provider_response_id`, with a same-process best-effort dedup against already-ledgered `provider_response_id`s so re-iterating the stream doesn't duplicate rows (concurrent retrieves from multiple processes can still race; async-mode rows in the inbox aren't checked until they drain).
|
|
39
|
+
- OpenAI SDK batch processing auto-captures: `client.batches.retrieve(id)` on a completed batch downloads the output JSONL and emits one ledger event per response with `pricing_mode: :batch` and the per-response `provider_response_id`, with the same best-effort dedup as Anthropic batches.
|
|
40
|
+
- OpenRouter pricing is now scraped via `openrouter.ai/api/v1/models`, so RubyLLM-routed OpenRouter calls (e.g. `openrouter/openai/gpt-4o`) get a real `total_cost` from the next `prices_file` refresh instead of landing as `cost_status: unknown`. The scrape also captures `image` / `audio` per-token rates so OpenRouter calls with multimodal inputs bill against the correct bucket instead of folding image/audio tokens into the text-input rate.
|
|
41
|
+
- Misspelled `pricing_mode:` values now log a `Logging.warn` listing the unrecognized token (e.g. `:bach` for `:batch`) so the resulting `cost_status: unknown` call surfaces a typo instead of silently absorbing it; the warn fires once per unique token.
|
|
42
|
+
- Whisper-style transcriptions whose response carries `usage.type = "duration"` now emit a `transcription_minute` line item (quantity = `ceil(seconds / 60)`) across both the OpenAI Ruby SDK patch and the Faraday / RubyLLM HTTP path; the call previously recorded with zero tokens and no line item, so audio-minute usage was invisible.
|
|
43
|
+
- OpenAI Responses-API `image_generation_call` and `computer_call` output items now emit line items so per-call hosted-tool usage shows up on the dashboard alongside the existing `web_search_call` / `file_search_call` / `code_interpreter_call` coverage.
|
|
44
|
+
- `LlmCostTracker.track(..., enforce_budget: true)` now actually raises `BudgetExceededError` pre-call when the estimated cost (token cost plus priced service line items) overshoots the budget, even when `budget_exceeded_behavior: :notify` is configured — previously the kwarg silently no-op'd unless policy was already `:block_requests`.
|
|
45
|
+
- `Call#pricing_snapshot.rates` now includes per-charge rates for non-token service line items (web search, MCP calls, TTS character billing, etc.) — previously only token rates were captured, so audit/replay of service-charge pricing had no record of the rate that was actually applied.
|
|
46
|
+
- Tags with invalid keys (e.g. containing whitespace or characters outside `[\w.-]`) are now skipped at write with a `Logging.warn` instead of being silently written and then raising `InvalidFilterError` on dashboard read.
|
|
47
|
+
- A raising `default_tags` proc is now captured by `Logging.warn` and falls back to empty default tags, so a broken user callback doesn't take down every tracked call.
|
|
48
|
+
- `LlmCostTracker::Ingestion::Worker.shutdown!(drain: true)` always attempts the final inbox flush even if waking the worker thread raises, so pending inbox rows aren't left when the host process exits.
|
|
49
|
+
- Gemini preview-dated models (e.g. `gemini-2.5-flash-preview-04-17`) now resolve to the stable entry's pricing — previously the `preview-MM-DD` suffix didn't match the dated-snapshot regex so the call landed as `cost_status: unknown`.
|
|
50
|
+
- Gemini parser now reads `usageMetadata.serviceTier` from the response body in addition to the `x-gemini-service-tier` header, so tier-aware pricing applies when only the body carries the tier signal.
|
|
51
|
+
- Line-item and pricing-snapshot `currency` is now stored uppercase regardless of `prices_file` casing, so a `prices_file` with `currency: "eur"` shows up as `EUR` everywhere and service-line items don't get partitioned out of header totals on a case mismatch with cost-data currency.
|
|
52
|
+
- Async-ingestion inbox rows reaching `MAX_ATTEMPTS_BEFORE_QUARANTINE` now log a `Logging.warn` (with row ids) at the moment they quarantine, so production sees the event in `Rails.logger` instead of needing to run `bin/rails llm_cost_tracker:doctor` to discover it.
|
|
53
|
+
- Dashboard "Setup required" page now flags missing `llm_cost_tracker_ingestion_inbox_entries` and `llm_cost_tracker_ingestion_leases` tables when `ingestion: :async` is configured — previously the drift only surfaced as a worker boot crash.
|
|
54
|
+
- Gemini image-generation models (`gemini-2.5-flash-image`, `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`) and stable preview text models (`gemini-3.1-pro-preview`, `gemini-2.5-flash-lite-preview-09-2025`, etc.) are no longer dropped by the price scraper — they flow into the pricing snapshot on the next refresh cycle.
|
|
55
|
+
- Gemini parser splits `IMAGE`-modality tokens from `promptTokensDetails` / `candidatesTokensDetails` (mirroring the existing AUDIO handling), so image-output usage from Gemini calls routes to `image_output` rates instead of falling into the text-output bucket.
|
|
56
|
+
- RubyLLM SDK integration over-subtracted cache-read tokens from recorded `input_tokens` on chat completions, so the figure landed in the ledger short by the cache-read amount; the gem now passes RubyLLM's net `input_tokens` through unchanged.
|
|
57
|
+
- RubyLLM SDK integration captures `service_tier` from response bodies across Anthropic, OpenAI, and Gemini — previously the field was read from the wrong JSON path so batch and flex modes silently priced against standard rates.
|
|
58
|
+
- RubyLLM SDK integration records the provider's response id in `provider_response_id` (previously always nil), so each ledger row carries the upstream id you can cross-reference against provider invoices and logs.
|
|
59
|
+
- RubyLLM Anthropic chat completions split 1-hour and 5-minute cache writes into separate token buckets so 1h writes bill at the 2x extended rate instead of being lumped into the 5m bucket at 1.25x.
|
|
60
|
+
- Async-inbox `total_cost` now round-trips through the JSON payload without losing precision; previously the payload coerced `BigDecimal` to `Float` and dropped digits past ~15 significant figures, so high-volume aggregate billing under `ingestion: :async` came out systematically short. BREAKING for subscribers to the `llm_request.llm_cost_tracker` `ActiveSupport::Notifications` event: `payload[:cost]` numeric values are now decimal strings (was `Float`) — wrap with `BigDecimal(value)` before arithmetic.
|
|
61
|
+
|
|
7
62
|
## [0.11.0] - 2026-05-21
|
|
8
63
|
|
|
9
64
|
### Added
|
data/README.md
CHANGED
|
@@ -32,16 +32,17 @@ gem "openai"
|
|
|
32
32
|
bin/rails llm_cost_tracker:setup
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Runs the install generator, drops a price snapshot, migrates the database, and verifies via `llm_cost_tracker:doctor`.
|
|
35
|
+
Runs the install generator, drops a price snapshot, migrates the database, and verifies via `llm_cost_tracker:doctor`. The generated `config/initializers/llm_cost_tracker.rb` looks like:
|
|
36
36
|
|
|
37
37
|
```ruby
|
|
38
|
-
# config/initializers/llm_cost_tracker.rb
|
|
39
38
|
LlmCostTracker.configure do |config|
|
|
40
39
|
config.default_tags = -> { { environment: Rails.env } }
|
|
41
40
|
config.instrument :openai
|
|
42
41
|
end
|
|
43
42
|
```
|
|
44
43
|
|
|
44
|
+
Edit it in place to add tags, switch on async ingestion, etc.
|
|
45
|
+
|
|
45
46
|
Tag your calls to attribute spend:
|
|
46
47
|
|
|
47
48
|
```ruby
|
|
@@ -85,7 +86,9 @@ The engine ships without authentication on purpose.
|
|
|
85
86
|
| Anything else | `LlmCostTracker.track` |
|
|
86
87
|
|
|
87
88
|
Streams capture when the provider emits final usage. OpenAI Faraday streams
|
|
88
|
-
|
|
89
|
+
get `stream_options: { include_usage: true }` auto-injected so the final
|
|
90
|
+
usage chunk lands in the ledger (opt out via
|
|
91
|
+
`config.auto_enable_stream_usage = false`).
|
|
89
92
|
|
|
90
93
|
## What it isn't
|
|
91
94
|
|
|
@@ -102,7 +105,7 @@ For batch jobs, internal gateways, or anything without an SDK/Faraday hook:
|
|
|
102
105
|
LlmCostTracker.track(
|
|
103
106
|
provider: :anthropic,
|
|
104
107
|
model: "claude-sonnet-4-6",
|
|
105
|
-
tokens: {
|
|
108
|
+
tokens: { input_tokens: 1500, output_tokens: 320 },
|
|
106
109
|
tags: { feature: "summarizer", user_id: current_user.id }
|
|
107
110
|
)
|
|
108
111
|
```
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
--lct-surface-2: #f1f4f9;
|
|
5
5
|
--lct-surface-hover: #eef2f7;
|
|
6
6
|
--lct-panel: #ffffff;
|
|
7
|
+
--lct-panel-head-bg: #fbfcfe;
|
|
7
8
|
--lct-border: #e3e8ef;
|
|
8
9
|
--lct-border-strong: #cbd2dc;
|
|
9
10
|
--lct-text: #0d1b2a;
|
|
10
|
-
--lct-muted: #
|
|
11
|
-
--lct-subtle: #
|
|
11
|
+
--lct-muted: #475467;
|
|
12
|
+
--lct-subtle: #5a6573;
|
|
12
13
|
--lct-accent: #5b54d8;
|
|
13
14
|
--lct-accent-strong: #4540c4;
|
|
14
15
|
--lct-accent-hover: #4540c4;
|
|
@@ -27,7 +28,6 @@
|
|
|
27
28
|
--lct-danger-copy: #b91c1c;
|
|
28
29
|
--lct-danger-strong: #991b1b;
|
|
29
30
|
--lct-row-hover: #f8fafc;
|
|
30
|
-
--lct-th-bg: #f1f4f9;
|
|
31
31
|
--lct-toolbar-bg: rgba(255, 255, 255, 0.92);
|
|
32
32
|
--lct-toolbar-border: #e3e8ef;
|
|
33
33
|
--lct-chart-secondary: rgba(13, 27, 42, 0.42);
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
--lct-surface-2: #1c2229;
|
|
81
81
|
--lct-surface-hover: #232a35;
|
|
82
82
|
--lct-panel: #161b22;
|
|
83
|
+
--lct-panel-head-bg: #1f262e;
|
|
83
84
|
--lct-border: #2a313c;
|
|
84
85
|
--lct-border-strong: #3d4654;
|
|
85
86
|
--lct-text: #e6edf3;
|
|
@@ -103,7 +104,6 @@
|
|
|
103
104
|
--lct-danger-copy: #f85149;
|
|
104
105
|
--lct-danger-strong: #fecaca;
|
|
105
106
|
--lct-row-hover: #1c2229;
|
|
106
|
-
--lct-th-bg: #1c2229;
|
|
107
107
|
--lct-toolbar-bg: rgba(22, 27, 34, 0.92);
|
|
108
108
|
--lct-toolbar-border: #2a313c;
|
|
109
109
|
--lct-shadow: 0 1px 2px rgba(0, 0, 0, 0.30);
|
|
@@ -123,6 +123,7 @@
|
|
|
123
123
|
--lct-surface-2: #1c2229;
|
|
124
124
|
--lct-surface-hover: #232a35;
|
|
125
125
|
--lct-panel: #161b22;
|
|
126
|
+
--lct-panel-head-bg: #1f262e;
|
|
126
127
|
--lct-border: #2a313c;
|
|
127
128
|
--lct-border-strong: #3d4654;
|
|
128
129
|
--lct-text: #e6edf3;
|
|
@@ -146,7 +147,6 @@
|
|
|
146
147
|
--lct-danger-copy: #f85149;
|
|
147
148
|
--lct-danger-strong: #fecaca;
|
|
148
149
|
--lct-row-hover: #1c2229;
|
|
149
|
-
--lct-th-bg: #1c2229;
|
|
150
150
|
--lct-toolbar-bg: rgba(22, 27, 34, 0.92);
|
|
151
151
|
--lct-toolbar-border: #2a313c;
|
|
152
152
|
--lct-shadow: 0 1px 2px rgba(0, 0, 0, 0.30);
|
|
@@ -444,6 +444,8 @@
|
|
|
444
444
|
gap: var(--sp-3);
|
|
445
445
|
padding: 9px var(--sp-4);
|
|
446
446
|
border-bottom: 1px solid var(--lct-border);
|
|
447
|
+
background: var(--lct-panel-head-bg);
|
|
448
|
+
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
|
447
449
|
}
|
|
448
450
|
.lct-panel-title {
|
|
449
451
|
font-size: var(--fs-md);
|
|
@@ -626,8 +628,7 @@
|
|
|
626
628
|
color: var(--lct-muted);
|
|
627
629
|
text-align: left;
|
|
628
630
|
padding: 9px var(--sp-4);
|
|
629
|
-
border-bottom: 1px solid var(--lct-border);
|
|
630
|
-
background: var(--lct-th-bg);
|
|
631
|
+
border-bottom: 1px solid var(--lct-border-strong);
|
|
631
632
|
white-space: nowrap;
|
|
632
633
|
}
|
|
633
634
|
.lct-tbl td {
|
|
@@ -26,7 +26,7 @@ module LlmCostTracker
|
|
|
26
26
|
format.html do
|
|
27
27
|
@page = Dashboard::Pagination.call(params)
|
|
28
28
|
@calls_count, @calls_total_cost = scope.pick(Arel.sql("COUNT(*), COALESCE(SUM(total_cost), 0)"))
|
|
29
|
-
@calls = ordered_scope.includes(:tag_records).limit(@page.
|
|
29
|
+
@calls = ordered_scope.includes(:tag_records).limit(@page.per).offset(@page.offset).to_a
|
|
30
30
|
end
|
|
31
31
|
format.csv do
|
|
32
32
|
response.headers["Cache-Control"] = "no-store"
|
|
@@ -83,7 +83,7 @@ module LlmCostTracker
|
|
|
83
83
|
|
|
84
84
|
def csv_fields
|
|
85
85
|
%i[tracked_at provider model] +
|
|
86
|
-
TokenUsage.members +
|
|
86
|
+
Usage::TokenUsage.members +
|
|
87
87
|
%i[
|
|
88
88
|
total_cost cost_status pricing_snapshot latency_ms provider_response_id provider_project_id
|
|
89
89
|
provider_api_key_id provider_workspace_id batch tags
|
|
@@ -93,15 +93,15 @@ module LlmCostTracker
|
|
|
93
93
|
def csv_value(field, call)
|
|
94
94
|
case field
|
|
95
95
|
when :tracked_at
|
|
96
|
-
call.tracked_at
|
|
96
|
+
call.tracked_at.utc.iso8601
|
|
97
97
|
when :provider_api_key_id, :provider_workspace_id, :provider_project_id
|
|
98
|
-
csv_safe(LlmCostTracker::Masking.mask_value(field, call[field]))
|
|
98
|
+
csv_safe(LlmCostTracker::Dashboard::Masking.mask_value(field, call[field]))
|
|
99
99
|
when :provider, :model, :provider_response_id, :cost_status
|
|
100
100
|
csv_safe(call[field])
|
|
101
101
|
when :pricing_snapshot
|
|
102
102
|
csv_safe(csv_json(call.pricing_snapshot))
|
|
103
103
|
when :tags
|
|
104
|
-
csv_safe(call.
|
|
104
|
+
csv_safe(call.tag_pairs.to_json)
|
|
105
105
|
else
|
|
106
106
|
call[field]
|
|
107
107
|
end
|
|
@@ -13,7 +13,7 @@ module LlmCostTracker
|
|
|
13
13
|
)
|
|
14
14
|
|
|
15
15
|
@stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
|
|
16
|
-
@monthly_budget_status = Dashboard::
|
|
16
|
+
@monthly_budget_status = Dashboard::MonthlyBudget.status
|
|
17
17
|
@time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
|
|
18
18
|
@comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
|
|
19
19
|
@spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
|
|
@@ -4,7 +4,7 @@ module LlmCostTracker
|
|
|
4
4
|
class PricingController < ApplicationController
|
|
5
5
|
def index
|
|
6
6
|
@overview = Dashboard::PricingOverview.call
|
|
7
|
-
requested = params[:source]
|
|
7
|
+
requested = params[:source]&.to_sym
|
|
8
8
|
@active_source = @overview.fetch(:sources).key?(requested) ? requested : @overview.fetch(:effective_source)
|
|
9
9
|
@source_data = @overview.fetch(:sources).fetch(@active_source)
|
|
10
10
|
@provider_filter = params[:provider].to_s.presence
|
|
@@ -7,7 +7,6 @@ module LlmCostTracker
|
|
|
7
7
|
TAG_VALUE_SUMMARY_BYTES = 80
|
|
8
8
|
TAG_TOOLTIP_BYTES = 512
|
|
9
9
|
|
|
10
|
-
include DashboardFilterHelper
|
|
11
10
|
include DashboardFilterOptionsHelper
|
|
12
11
|
include DashboardQueryHelper
|
|
13
12
|
include ChartHelper
|
|
@@ -22,7 +21,6 @@ module LlmCostTracker
|
|
|
22
21
|
return :tags if path.start_with?(tags_path)
|
|
23
22
|
return :data_quality if path.start_with?(data_quality_path)
|
|
24
23
|
return :pricing if path.start_with?(pricing_path)
|
|
25
|
-
return :reconciliation if LlmCostTracker.reconciliation_enabled? && path.start_with?(reconciliation_path)
|
|
26
24
|
|
|
27
25
|
:overview
|
|
28
26
|
end
|
|
@@ -45,26 +43,19 @@ module LlmCostTracker
|
|
|
45
43
|
value.nil? ? "n/a" : money(value)
|
|
46
44
|
end
|
|
47
45
|
|
|
48
|
-
def optional_number(value)
|
|
49
|
-
value.nil? ? "n/a" : number(value)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def number(value)
|
|
53
|
-
number_with_delimiter(value)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
46
|
def format_date(value)
|
|
57
|
-
|
|
47
|
+
return "" if value.nil?
|
|
48
|
+
|
|
49
|
+
value.strftime("%Y-%m-%d %H:%M")
|
|
58
50
|
end
|
|
59
51
|
|
|
60
52
|
def pricing_status(call)
|
|
61
53
|
return "Unknown" if call.total_cost.nil?
|
|
62
|
-
return "Estimated" unless call.has_attribute?(:cost_status)
|
|
63
54
|
|
|
64
55
|
{
|
|
65
|
-
LlmCostTracker::
|
|
66
|
-
LlmCostTracker::
|
|
67
|
-
LlmCostTracker::
|
|
56
|
+
LlmCostTracker::Charges::CostStatus::COMPLETE => "Estimated",
|
|
57
|
+
LlmCostTracker::Charges::CostStatus::FREE => "Free",
|
|
58
|
+
LlmCostTracker::Charges::CostStatus::PARTIAL => "Partial"
|
|
68
59
|
}.fetch(call.cost_status, "Unknown")
|
|
69
60
|
end
|
|
70
61
|
|
|
@@ -4,17 +4,7 @@ module LlmCostTracker
|
|
|
4
4
|
module DashboardFilterOptionsHelper
|
|
5
5
|
MAX_FILTER_OPTIONS = 100
|
|
6
6
|
|
|
7
|
-
def
|
|
8
|
-
filter_options_for(:provider, filter_params: filter_params)
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def model_filter_options(filter_params: params)
|
|
12
|
-
filter_options_for(:model, filter_params: filter_params)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
private
|
|
16
|
-
|
|
17
|
-
def filter_options_for(column, filter_params:)
|
|
7
|
+
def filter_options_for(column, filter_params: params)
|
|
18
8
|
source = LlmCostTracker::Dashboard::Params.to_hash(filter_params).symbolize_keys
|
|
19
9
|
scope_params = source.merge(
|
|
20
10
|
column => nil, format: nil, page: nil, per: nil, sort: nil
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module SortableTableHelper
|
|
5
|
-
def sortable_header(label, column, num: false)
|
|
6
|
-
state = sortable_state(column, num: num)
|
|
5
|
+
def sortable_header(label, column, num: false, default: false)
|
|
6
|
+
state = sortable_state(column, num: num, default: default)
|
|
7
7
|
classes = ["lct-sortable"]
|
|
8
8
|
classes << "lct-num" if num
|
|
9
9
|
classes << "lct-sorted" if state[:active]
|
|
@@ -16,8 +16,8 @@ module LlmCostTracker
|
|
|
16
16
|
|
|
17
17
|
private
|
|
18
18
|
|
|
19
|
-
def sortable_state(column, num:)
|
|
20
|
-
current_sort = params[:sort].
|
|
19
|
+
def sortable_state(column, num:, default: false)
|
|
20
|
+
current_sort = params[:sort].presence || (default ? column : nil)
|
|
21
21
|
current_dir = Dashboard::Sort::DIRECTIONS.include?(params[:dir].to_s) ? params[:dir].to_s : nil
|
|
22
22
|
natural_dir = num ? "desc" : "asc"
|
|
23
23
|
active = current_sort == column
|
|
@@ -40,11 +40,9 @@ module LlmCostTracker
|
|
|
40
40
|
|
|
41
41
|
def call_line_item_costs_by_component(call)
|
|
42
42
|
call.line_items.each_with_object({}) do |line_item, accumulator|
|
|
43
|
-
component = LlmCostTracker::
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
item.cache_state.to_s == line_item.cache_state.to_s
|
|
47
|
-
end
|
|
43
|
+
component = LlmCostTracker::Usage::Catalog.token_priced_for(
|
|
44
|
+
kind: line_item.kind, direction: line_item.direction, cache_state: line_item.cache_state
|
|
45
|
+
)
|
|
48
46
|
accumulator[component.key] = line_item.cost if component && line_item.cost
|
|
49
47
|
end
|
|
50
48
|
end
|
|
@@ -52,7 +50,7 @@ module LlmCostTracker
|
|
|
52
50
|
private
|
|
53
51
|
|
|
54
52
|
def token_usage_display_components(labels:)
|
|
55
|
-
LlmCostTracker::
|
|
53
|
+
LlmCostTracker::Usage::Catalog.token_priced.map do |component|
|
|
56
54
|
token_key = component.token_key
|
|
57
55
|
{
|
|
58
56
|
token_key: token_key,
|
|
@@ -2,43 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
|
-
require "llm_cost_tracker/billing/cost_status"
|
|
6
|
-
require "llm_cost_tracker/ledger/schema/adapter"
|
|
7
|
-
require "llm_cost_tracker/ledger/tags/sql"
|
|
8
|
-
|
|
9
5
|
module LlmCostTracker
|
|
10
6
|
class Call < ActiveRecord::Base
|
|
11
7
|
before_validation :assign_event_id
|
|
12
8
|
|
|
13
|
-
PERIOD_FORMATS = {
|
|
14
|
-
day: {
|
|
15
|
-
postgres: "YYYY-MM-DD",
|
|
16
|
-
mysql: "%Y-%m-%d"
|
|
17
|
-
},
|
|
18
|
-
month: {
|
|
19
|
-
postgres: "YYYY-MM",
|
|
20
|
-
mysql: "%Y-%m"
|
|
21
|
-
}
|
|
22
|
-
}.freeze
|
|
23
|
-
|
|
24
|
-
private_constant :PERIOD_FORMATS
|
|
25
|
-
|
|
26
9
|
scope :with_cost, -> { where.not(total_cost: nil) }
|
|
27
10
|
scope :without_cost, -> { where(total_cost: nil) }
|
|
28
|
-
scope :unknown_pricing,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
11
|
+
scope :unknown_pricing,
|
|
12
|
+
lambda {
|
|
13
|
+
where(Charges::CostStatus.unknown_pricing_sql)
|
|
14
|
+
}
|
|
33
15
|
scope :with_latency, -> { where.not(latency_ms: nil) }
|
|
34
16
|
scope :streaming, -> { where(stream: true) }
|
|
35
17
|
scope :non_streaming, -> { where(stream: [false, nil]) }
|
|
36
18
|
scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
|
|
37
19
|
scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
|
|
38
20
|
scope :missing_provider_response_id, -> { where(provider_response_id: [nil, ""]) }
|
|
39
|
-
scope :streaming_missing_usage,
|
|
40
|
-
|
|
41
|
-
|
|
21
|
+
scope :streaming_missing_usage,
|
|
22
|
+
lambda {
|
|
23
|
+
where(stream: true).where(usage_source: [Usage::Source::UNKNOWN, nil])
|
|
24
|
+
}
|
|
42
25
|
|
|
43
26
|
has_many :line_items,
|
|
44
27
|
class_name: "LlmCostTracker::CallLineItem",
|
|
@@ -58,6 +41,12 @@ module LlmCostTracker
|
|
|
58
41
|
scope :between, ->(from, to) { where(tracked_at: from..to) }
|
|
59
42
|
|
|
60
43
|
class << self
|
|
44
|
+
def already_recorded?(provider:, provider_response_id:)
|
|
45
|
+
return false if provider_response_id.to_s.empty?
|
|
46
|
+
|
|
47
|
+
where(provider: provider, provider_response_id: provider_response_id).exists?
|
|
48
|
+
end
|
|
49
|
+
|
|
61
50
|
def by_tag(key, value) = by_tags(key => value)
|
|
62
51
|
|
|
63
52
|
def by_tags(tags) = Ledger::Tags::Query.apply(tags)
|
|
@@ -71,20 +60,20 @@ module LlmCostTracker
|
|
|
71
60
|
def cost_by_provider(limit: nil) = cost_by_column(:provider, limit: limit)
|
|
72
61
|
|
|
73
62
|
def group_by_tag(key)
|
|
74
|
-
Ledger::Tags::
|
|
63
|
+
Ledger::Tags::Breakdown.join_relation(self, key).group(Ledger::Tags::Breakdown.value_arel)
|
|
75
64
|
end
|
|
76
65
|
|
|
77
66
|
def cost_by_tag(key, limit: nil)
|
|
78
|
-
label = Ledger::Tags::
|
|
79
|
-
raw_value = Ledger::Tags::
|
|
80
|
-
relation = Ledger::Tags::
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
67
|
+
label = Ledger::Tags::Breakdown.label_sql(connection)
|
|
68
|
+
raw_value = Ledger::Tags::Breakdown.raw_value_sql(connection)
|
|
69
|
+
relation = Ledger::Tags::Breakdown.join_relation(self, key)
|
|
70
|
+
.select("#{label} AS name", "COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
71
|
+
.group(Arel.sql(label))
|
|
72
|
+
.order(
|
|
73
|
+
Arel.sql("COALESCE(SUM(total_cost), 0) DESC"),
|
|
74
|
+
Arel.sql("MAX(CASE WHEN #{raw_value} IS NULL THEN 1 ELSE 0 END) ASC"),
|
|
75
|
+
Arel.sql("#{label} DESC")
|
|
76
|
+
)
|
|
88
77
|
relation = relation.limit(limit) if limit
|
|
89
78
|
relation
|
|
90
79
|
end
|
|
@@ -108,8 +97,7 @@ module LlmCostTracker
|
|
|
108
97
|
private
|
|
109
98
|
|
|
110
99
|
def cost_by_column(column, limit:)
|
|
111
|
-
|
|
112
|
-
relation = select("#{quoted_column} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
100
|
+
relation = select(arel_table[column].as("name"), "COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
113
101
|
.group(column)
|
|
114
102
|
.order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
115
103
|
relation = relation.limit(limit) if limit
|
|
@@ -117,30 +105,7 @@ module LlmCostTracker
|
|
|
117
105
|
end
|
|
118
106
|
|
|
119
107
|
def period_group_expression(period, column:)
|
|
120
|
-
period
|
|
121
|
-
column = period_column_expression(column)
|
|
122
|
-
formats = PERIOD_FORMATS.fetch(period)
|
|
123
|
-
|
|
124
|
-
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
125
|
-
postgres_period_expression(period, column, formats)
|
|
126
|
-
elsif Ledger::Schema::Adapter.mysql?(connection)
|
|
127
|
-
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
128
|
-
else
|
|
129
|
-
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def postgres_period_expression(period, column, formats)
|
|
134
|
-
"TO_CHAR(" \
|
|
135
|
-
"DATE_TRUNC(#{connection.quote(period.name)}, #{column}), " \
|
|
136
|
-
"#{connection.quote(formats.fetch(:postgres))}" \
|
|
137
|
-
")"
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def validated_period(period)
|
|
141
|
-
return period if PERIOD_FORMATS.key?(period)
|
|
142
|
-
|
|
143
|
-
raise ArgumentError, "invalid period: #{period.inspect}"
|
|
108
|
+
Ledger::Schema::Adapter.period_bucket_sql(connection, period, period_column_expression(column))
|
|
144
109
|
end
|
|
145
110
|
|
|
146
111
|
def period_column_expression(column)
|
|
@@ -151,7 +116,7 @@ module LlmCostTracker
|
|
|
151
116
|
end
|
|
152
117
|
end
|
|
153
118
|
|
|
154
|
-
def
|
|
119
|
+
def tag_pairs
|
|
155
120
|
tag_records.to_h do |record|
|
|
156
121
|
[record.key, record.value]
|
|
157
122
|
end
|
|
@@ -12,7 +12,7 @@ module LlmCostTracker
|
|
|
12
12
|
scope :by_direction, ->(direction) { where(direction: direction.to_s) }
|
|
13
13
|
scope :by_modality, ->(modality) { where(modality: modality.to_s) }
|
|
14
14
|
scope :cached, -> { where.not(cache_state: ["none", nil]) }
|
|
15
|
-
scope :priced, -> { where(cost_status:
|
|
16
|
-
scope :unpriced, -> { where(cost_status:
|
|
15
|
+
scope :priced, -> { where(cost_status: [Charges::CostStatus::COMPLETE, Charges::CostStatus::FREE]) }
|
|
16
|
+
scope :unpriced, -> { where(cost_status: Charges::CostStatus::UNKNOWN) }
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -2,5 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class CallRollup < ActiveRecord::Base
|
|
5
|
+
class << self
|
|
6
|
+
def increment_all(rows)
|
|
7
|
+
upsert_all(rows, on_duplicate: increment_on_duplicate, record_timestamps: true, unique_by: increment_unique_by)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def decrement(buckets)
|
|
11
|
+
now = Time.now.utc
|
|
12
|
+
buckets.each do |(period, period_start, currency, provider), amount|
|
|
13
|
+
where(period: period, period_start: period_start, currency: currency, provider: provider)
|
|
14
|
+
.update_all(["total_cost = GREATEST(0, total_cost - ?), updated_at = ?", amount, now])
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def increment_on_duplicate
|
|
21
|
+
return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(connection)
|
|
22
|
+
return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(connection)
|
|
23
|
+
|
|
24
|
+
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def postgres_increment_sql
|
|
28
|
+
total = connection.quote_column_name("total_cost")
|
|
29
|
+
updated = connection.quote_column_name("updated_at")
|
|
30
|
+
"#{total} = #{quoted_table_name}.#{total} + excluded.#{total}, #{updated} = excluded.#{updated}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mysql_increment_sql
|
|
34
|
+
"total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def increment_unique_by
|
|
38
|
+
return unless connection.supports_insert_conflict_target?
|
|
39
|
+
|
|
40
|
+
%i[period period_start currency provider]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
5
43
|
end
|
|
6
44
|
end
|