llm_cost_tracker 0.8.0 → 0.9.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 +108 -0
- data/README.md +12 -5
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -7
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +10 -0
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
- data/lib/llm_cost_tracker/budget.rb +4 -2
- data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +53 -1
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -3
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/event.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
- data/lib/llm_cost_tracker/ingestion.rb +48 -10
- data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
- data/lib/llm_cost_tracker/integrations/base.rb +22 -5
- data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
- data/lib/llm_cost_tracker/integrations.rb +19 -1
- data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
- data/lib/llm_cost_tracker/ledger/store.rb +14 -14
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
- data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
- data/lib/llm_cost_tracker/parsers/base.rb +5 -1
- data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
- data/lib/llm_cost_tracker/prices.json +110 -19
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
- data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
- data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
- data/lib/llm_cost_tracker/pricing.rb +47 -19
- data/lib/llm_cost_tracker/railtie.rb +6 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +4 -1
- data/lib/llm_cost_tracker/retention.rb +15 -2
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
- data/lib/llm_cost_tracker/token_usage.rb +10 -2
- data/lib/llm_cost_tracker/tracker.rb +45 -18
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +9 -0
- data/lib/tasks/llm_cost_tracker.rake +25 -2
- metadata +36 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f17f618b28473afa871c9961a443a34152f4de81f7026d676d62b7e2bd1396d8
|
|
4
|
+
data.tar.gz: d024b23f0ca6cd117afa5d10faa0a0b96374391a4741ea330b924b5091f665f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0abf684c595b7bc84dfda26ffc62eaabc0c6d91d0b93f1065bf6e824c7867326b7978875d845d3df8be25bfa04ff9091150e0a4cac7f84d835ceaf2f1e2996bb
|
|
7
|
+
data.tar.gz: 5b9405bf332b2e9e1eae05f0e7d107d4bb76ea71a6602846a16198540f3e0f315f48dbb316bbda24812d4d66c56ba837a42bfe81aeea502238f09e1f0202c6b4
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,114 @@
|
|
|
2
2
|
|
|
3
3
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.9.0] - 2026-05-12
|
|
8
|
+
|
|
9
|
+
0.9 leans the default install: only `calls`, `call_line_items`, and `call_tags`
|
|
10
|
+
are mandatory. Durable ingestion, rollup-cached budget reads, and provider
|
|
11
|
+
invoice reconciliation are opt-in behind config flags and dedicated generators.
|
|
12
|
+
Plus expanded SDK capture (OpenAI embeddings/audio/images/moderation, RubyLLM
|
|
13
|
+
paint/moderate), correct handling of Anthropic data residency and Priority
|
|
14
|
+
Tier, and a security-hardened dashboard. Existing installs need a migration —
|
|
15
|
+
see [Upgrading](docs/upgrading.md).
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Experimental:** opt-in provider invoice reconciliation. Set `config.reconciliation_enabled = true` and run `bin/rails generate llm_cost_tracker:reconciliation`. Public surface: `LlmCostTracker::Reconciliation.import / .diff`, `config.register_reconciliation_importer(:source) { … }`, rake tasks `llm_cost_tracker:reconcile:import` and `:reconcile:diff`. Doctor warns when drift exceeds 5% or imports go stale past 14 days. See [Configuration](docs/configuration.md#reconciliation-experimental-opt-in).
|
|
20
|
+
- Dashboard Data Quality page now shows a "Streaming health by provider" breakdown (streams, with-usage, unknown, unknown share) so a misconfigured OpenAI-compatible host shipping streams without `stream_options.include_usage` is visible at a glance.
|
|
21
|
+
- Dashboard tag detail page drills into a single value via `?tag_value=…` with total cost, call count, average per call, and a daily spend timeseries.
|
|
22
|
+
- Bundled rates for OpenAI embeddings (`text-embedding-3-small` / `-3-large` / `-ada-002`, including 50% batch discount) and token-priced transcription (`gpt-4o-transcribe`, `gpt-4o-mini-transcribe`). Token-priced transcription splits audio and text inputs at their separate rates. DALL-E and Whisper still record as zero-token visibility events until their per-image / per-minute pricing components land.
|
|
23
|
+
- OpenAI `gpt-image-1` / `gpt-image-1-mini` / `gpt-image-1.5` / `gpt-image-2` priced per image-token at their published standard rates, with `batch_*` shadow rates for the 50% batch tier. (Earlier preview snapshots stored only the batch rates, which silently halved image-generation costs.) The SDK integration extracts `usage.input_tokens_details.image_tokens` for image-as-input flows (edits / variations) and treats `usage.output_tokens` as image output. Requires the new `bin/rails generate llm_cost_tracker:upgrade_image_tokens` migration on v0.8 → v0.9 upgrades.
|
|
24
|
+
- OpenAI `tts-1` / `tts-1-hd` priced per character (request `input.length`). `gpt-4o-mini-tts` is left as a zero-cost visibility event because its tokens are not exposed to the client.
|
|
25
|
+
- OpenAI SDK integration now also patches `Embeddings#create`, `Images#generate` / `#edit` / `#create_variation`, `Audio::Transcriptions#create`, `Audio::Speech#create`, `Moderations#create`, and `Chat::Completions#stream`. Calls without provider-reported usage record as zero-token visibility events.
|
|
26
|
+
- RubyLLM SDK integration also records `Provider#paint` and `Provider#moderate`.
|
|
27
|
+
- `bin/rails generate llm_cost_tracker:upgrade_call_rollups_provider` writes the v0.8 → v0.9 migration that adds the `provider` column and swaps the unique index. Re-runs are no-ops.
|
|
28
|
+
- [EU AI Act record-keeping guide](docs/eu_ai_act.md) — maps the ledger fields and `llm_cost_tracker:prune` retention to Article 26(6) deployer obligations (≥ 6-month retention, traceability, attribution tags). Not legal advice.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Subscriber failures during `Tracker.record` no longer lose the event — the ledger write happens first; subscriber errors are caught and logged.
|
|
33
|
+
- Header `total_cost` no longer mixes currencies. Mismatched service-line costs keep their per-line currency and are excluded from the header total (with a warning).
|
|
34
|
+
- Budget reads aggregate across all rollup currencies instead of being silently scoped to USD only.
|
|
35
|
+
- `bin/rails llm_cost_tracker:setup` no longer fails with `Missing Thor class for invoke llm_cost_tracker:prices`, is idempotent on re-runs, and surfaces a friendly error when the database is unreachable.
|
|
36
|
+
- Stream events are no longer lost when finalization raises. The collector retries on the next `finish!`. Abandoned streams (wrapped but never iterated) emit a usage event instead of disappearing.
|
|
37
|
+
- Faraday streaming overflow keeps the buffer accumulated up to the limit (matching the SDK collector) instead of dropping all events.
|
|
38
|
+
- Edits to `config.prices_file` are picked up without a gem reload — the lookup cache invalidates on file mtime changes.
|
|
39
|
+
- Models flagged with `_source: "manual"` in the local prices file are preserved through `prices:refresh` when the remote registry does not claim the same key.
|
|
40
|
+
- Anthropic Priority Tier no longer falls to `cost_status: unknown`. It's a throughput commitment, not a per-token surcharge — both the SDK integration and the Faraday parser treat `service_tier: "priority"` as standard pricing.
|
|
41
|
+
- Anthropic `data_residency` mode triggers on `inference_geo: "us"` only — the documented +1.1x uplift tier. Earlier preview ranges that listed `"eu"` were incorrect; EU data residency runs through Bedrock Frankfurt or Vertex Belgium with separate pricing, not the Anthropic API.
|
|
42
|
+
- Anthropic `web_fetch_request` is recorded with a `$0` rate (Anthropic bills web fetch via standard tokens, not per fetch). The scraper picks up the "no additional charges" wording so `prices:refresh` keeps it accurate.
|
|
43
|
+
- OpenAI `web_search_call` is now priced model-aware. The legacy `web_search_preview` tool routes to `web_search_preview_request_reasoning` ($10/1k for gpt-5/o-series) or `web_search_preview_request_non_reasoning` ($25/1k for everything else), matching OpenAI's three published web-search billing paths. `gpt-5-chat-latest` and dotted variants (`gpt-5.1-chat-latest`, `gpt-5.2-chat-latest`, …) are classified as non-reasoning despite the `gpt-5` prefix.
|
|
44
|
+
- Anthropic Cost API reconciliation now ingests rows against live admin payloads (preview builds expected obsolete field names and produced zero rows). `service_tier: "batch"` and `inference_geo: "us"` are the only dimensions that promote a row's `pricing_mode`.
|
|
45
|
+
- Reconciliation diff windows are anchored in UTC; non-UTC servers no longer skew the window.
|
|
46
|
+
- Reconciliation provider totals sum only invoices fully contained in the diff window. Partially overlapping invoices no longer count at their full `billed_amount`.
|
|
47
|
+
- Reconciliation diff window upper bound is now exclusive of midnight on the day after `period_end`. Calls tracked at exactly `00:00:00.000` of the next month no longer get counted in both periods.
|
|
48
|
+
- `Reconciliation.import` / `Reconciliation.diff` accept (and require, for unmapped sources) an explicit `provider:`. Built-in mappings cover `openai`, `openai_usage`, `anthropic`, `anthropic_usage`, `gemini`. CSV and other custom sources must pass `provider:` (or be derivable from a prior import's metadata) — the previous silent fall-through summed local calls across every provider.
|
|
49
|
+
- Reconciliation import errors no longer echo the exception verbatim into the dashboard flash. The full trace goes to logs; the alert shows the exception class only.
|
|
50
|
+
- `Reconciliation::Importer` works on MySQL/Trilogy installs (adapter-aware `upsert_all`).
|
|
51
|
+
- Reconciliation `external_id` is namespaced by `source/provider` for sources that carry multiple providers (e.g. `csv/openai:row-1` vs `csv/anthropic:row-1`). The same CSV row id imported under two providers no longer collides on the unique index. Native sources keep their `openai:` / `anthropic:` / `gemini:` prefix.
|
|
52
|
+
- Reconciliation dashboard groups latest-period and drill-down by source, provider, and currency. A second provider importing under the same source no longer hides its drift in the first provider's row.
|
|
53
|
+
- OpenAI Cost API reconciliation tags the organization id under `provider_workspace_id` so org-level scope filters work.
|
|
54
|
+
- Reconciliation diff drill-down is capped at the top 100 unmatched rows by amount with totals counted separately, so the dashboard stays responsive on large monthly reconciliations. Pass `DRILLDOWN_LIMIT=all` to `rake llm_cost_tracker:reconcile:diff` to see every row.
|
|
55
|
+
- Period totals fall back to live aggregation from `llm_cost_tracker_calls` when `cache_rollups = true` but the rollups table has no row for the period. Budget reads and dashboard totals no longer read zero during a rollup rebuild window after the v0.9 upgrade migration.
|
|
56
|
+
- OpenAI hosted-tool service line items (`web_search_call`, `file_search_call`, `code_interpreter_call`, `mcp_call`) are recorded when the SDK returns the type as a Symbol. Previously these line items were silently dropped on SDK-shaped responses.
|
|
57
|
+
- Image generation streams (`gpt-image-1.5`, `gpt-image-2`) and audio streams no longer overflow on a single base64 chunk; the final usage event is captured and tokens get priced.
|
|
58
|
+
- Interrupted Anthropic and Gemini streams record the right provider name instead of `provider: "unknown"`.
|
|
59
|
+
- Tag sanitizer redacts secrets before truncating, so the leading bytes of a secret can't survive a small `max_tag_value_bytesize`. Nested `[REDACTED]` markers stay whole regardless of the byte budget.
|
|
60
|
+
- `Pricing::Registry` rejects non-finite price values (`Infinity` / `NaN`) alongside negatives.
|
|
61
|
+
- Reconciliation `ProviderInvoiceImport.started_at` is the wall-clock import time. Backfills with a historical `imported_at` no longer invert `resume_cursor_for` ordering.
|
|
62
|
+
- Reconciliation install migration is re-runnable on installs that already carry the v0.8 placeholder tables.
|
|
63
|
+
- Pre-release v0.9 deployers who imported reconciliation rows before these fixes need `LlmCostTracker::ProviderInvoice.delete_all` and a re-import — the `external_id` prefix and the OpenAI organization-id field both changed shape.
|
|
64
|
+
- Budget reads survive the v0.9 upgrade migration's rollup truncation — a partial rollup row no longer hides historical pre-migration spend in the same period.
|
|
65
|
+
- Streaming requests that hit `unknown_pricing_behavior = :raise` after the response is received raise without recording a synthetic zero-token event.
|
|
66
|
+
- Reconciliation doctor checks each `source / provider / currency` combination separately; a stale Anthropic CSV import no longer hides behind a fresh OpenAI one on the same source.
|
|
67
|
+
- Reconciliation imports normalise `currency` to upper case so `usd` and `USD` no longer split the diff.
|
|
68
|
+
- Reconciliation dashboard and CLI render `n/a` for invoice rows imported with no `billed_amount` instead of `$0.00`.
|
|
69
|
+
- Reconciliation diff drill-down shows the actual unmatched rows even when most invoices match — small-amount unmatched rows are no longer hidden by a wall of matched big-amount rows.
|
|
70
|
+
- OpenAI SDK Responses calls bill image and text tokens separately for `gpt-image-*` models, matching the Faraday parser.
|
|
71
|
+
- OpenAI SDK integration captures the request when the caller passes a typed request object (anything that responds to `to_h`) instead of dropping it.
|
|
72
|
+
- Custom prices files with `Infinity` / `NaN` service-charge rates fail to load with a clear error instead of silently corrupting cost math.
|
|
73
|
+
- High-cardinality tag filters (`Call.by_tag(:tenant_id, …)`) now hit a composite index instead of scanning. Existing installs run `bin/rails generate llm_cost_tracker:upgrade_call_tags_key_value_index && bin/rails db:migrate`.
|
|
74
|
+
- Reconciliation diff over a large invoice set uses an index scan on the new `(source, currency, period_start)` composite.
|
|
75
|
+
- Doctor warns when provider invoice rows are stored with non-uppercase currency and points at the one-line backfill SQL, instead of the dashboard silently zeroing out diffs against legacy lowercase data.
|
|
76
|
+
- A request-level `pricing_mode` no longer overrides what the provider reports back on a streamed response. Provider-reported standard wins over a request that asked for priority.
|
|
77
|
+
- The new generators (`call_rollups`, `durable_ingestion`, `reconciliation`, `upgrade_call_rollups_provider`) are reachable through `bin/rails generate llm_cost_tracker:<name>`.
|
|
78
|
+
- Faraday streaming captures no longer silently degrade to `usage_source: :unknown`.
|
|
79
|
+
- Dashboard filters apply the default 30-day range when `from`/`to` params are missing.
|
|
80
|
+
- `provider_api_key_id` and `provider_workspace_id` are masked on the call detail page and CSV export. Host apps that added a `metadata` column written as a JSON string now flow through the same masking instead of rendering the raw column.
|
|
81
|
+
- Faraday parser tracks OpenAI `/v1/images/*` and `/v1/audio/transcriptions`/`/v1/audio/translations` so raw-Faraday image generations and transcriptions land in the ledger. `/v1/audio/speech` and `/v1/moderations` are also matched so `Tracker.enforce_budget!` gates them; they do not record a row because OpenAI does not return token usage for those endpoints.
|
|
82
|
+
- OpenAI SDK `Audio::Translations#create` is now patched alongside `Audio::Transcriptions#create`.
|
|
83
|
+
- OpenAI SDK `Images#generate` / `#edit` / `#create_variation` no longer double-counts cached input tokens.
|
|
84
|
+
- OpenAI SDK `Responses.create` and Faraday parser both route output to `image_output_tokens` for `gpt-image-*` models even when the response omits `output_tokens_details.image_tokens`.
|
|
85
|
+
- OpenAI SDK `Images#generate` / `#edit` / `#create_variation` no longer drops the text-output remainder when `output_tokens_details` reports only `image_tokens`. The remainder lands as `output_tokens`, matching the Faraday parser.
|
|
86
|
+
- RubyLLM `Provider#paint` for `gpt-image-*` models records image output tokens under `image_output_tokens` so image rates apply.
|
|
87
|
+
- RubyLLM integration treats Anthropic `service_tier: "priority"` as standard pricing (Priority Tier is committed throughput, not a surcharge). Previously these calls fell to `cost_status: unknown` because the literal `"priority"` was passed through as `pricing_mode`.
|
|
88
|
+
- Reconciliation diff falls back to live `llm_cost_tracker_call_line_items` aggregation when the rollup fast path finds no row for the period. Without the fallback, past-month diffs after the v0.9 `upgrade_call_rollups_provider` migration (which truncates rollups) would report `local_total = $0` until events repopulate the new schema.
|
|
89
|
+
- Provider-invoice reconciliation falls back to `match_basis: "model"` (was `period_only`) when an invoice carries only a model identifier.
|
|
90
|
+
- `prices:refresh` bootstraps a missing local pricing file instead of failing with `Errno::ENOENT`.
|
|
91
|
+
- Doctor's durable-inbox verification no longer leaves a synthetic inbox row behind when `Tracker.track` raises `BudgetExceededError`.
|
|
92
|
+
- Install-generator snippet in [Upgrading](docs/upgrading.md) for the reconciliation table now matches the shipped index (`(source, currency, period_start)`).
|
|
93
|
+
- Doctor catches schema drift on required columns, required indexes, and the foreign key on `call_line_items` before the first row is inserted.
|
|
94
|
+
- Service-charge rows render `n/a` instead of `$0.00` when `cost_status` is `unknown`, so unpriced charges don't masquerade as zero-cost.
|
|
95
|
+
- Enabling `:ruby_llm` together with `:openai` / `:anthropic` logs a warning at install — RubyLLM routes through HTTP, so calls would otherwise be double-counted. Pick one path per provider.
|
|
96
|
+
|
|
97
|
+
### Changed
|
|
98
|
+
|
|
99
|
+
- BREAKING: `bin/rails generate llm_cost_tracker:install --dashboard` no longer writes the `mount LlmCostTracker::Engine` line into `config/routes.rb`. The CLI prints the snippet wrapped in your auth instead — leaving the dashboard auto-mounted would expose spend, tags, and provider IDs to anyone who can reach the host. Add the route under your authentication block.
|
|
100
|
+
- BREAKING: `config.durable_ingestion` defaults to `false`. Tracking writes go directly to the ledger from the request thread; the durable inbox + worker + leases tables are no longer created by the install generator. Existing installs keep their tables — set `config.durable_ingestion = true` to keep the inbox path. Fresh installs that need durability run `bin/rails generate llm_cost_tracker:durable_ingestion` and flip the flag.
|
|
101
|
+
- BREAKING: `config.cache_rollups` defaults to `false`. Budget reads aggregate live from `llm_cost_tracker_calls`; the rollup table is no longer created by the install generator. Existing installs keep their table — set `config.cache_rollups = true` to keep the rollup fast path. Fresh installs run `bin/rails generate llm_cost_tracker:call_rollups` and flip the flag.
|
|
102
|
+
- BREAKING: `llm_cost_tracker_call_rollups` gains a `provider` column; unique index moves from `(period, period_start, currency)` to `(period, period_start, currency, provider)`. See [Upgrading](docs/upgrading.md).
|
|
103
|
+
- BREAKING: `llm_cost_tracker_calls` gains `image_input_tokens` and `image_output_tokens` columns (default 0) so OpenAI `gpt-image-*` models can bill image-token rates separately from text. Run `bin/rails generate llm_cost_tracker:upgrade_image_tokens && bin/rails db:migrate`. CSV exports include the new columns between `audio_input_tokens` / `output_tokens` and `audio_output_tokens` / `total_tokens` respectively — downstream consumers indexing by header name keep working; positional consumers shift by two.
|
|
104
|
+
- BREAKING: `LlmCostTracker::Call.by_tag(key, value)` encodes Hash and Array values with `JSON.generate` to match how `Ledger::Store` writes them. The previous `value.to_s` path produced `"{:k=>v}"`-shaped strings that never matched stored JSON, so filtering by nested attribution silently returned zero rows.
|
|
105
|
+
- Faraday middleware auto-injects `stream_options: { include_usage: true }` on OpenAI and OpenAI-compatible chat-completions streaming requests when the caller hasn't set it. Disable with `config.auto_enable_stream_usage = false`.
|
|
106
|
+
- Header `total_cost` and per-line-item rates can no longer disagree on the context-tier boundary or the resolved pricing mode.
|
|
107
|
+
- OpenAI-compatible chat-completions streams without a final usage chunk log a warning instead of recording silently as `usage_source: "unknown"`.
|
|
108
|
+
- `Tags::Sanitizer` redacts tag values matching known secret patterns (OpenAI/Anthropic, GitHub, AWS, JWT, Slack, Stripe, Google API key, `Bearer …`) regardless of tag key, recurses into nested Hash/Array leaves, and on tag-count overflow keeps the most recently added tags. `Tags::Context` sanitises at block entry so raw values never reach notification subscribers, the Faraday request env, or in-flight stream collectors.
|
|
109
|
+
- Engine dashboard adds CSRF protection on the reconciliation import endpoint, sets `Cache-Control: no-store` on CSV exports, registers `tag` / `tag_value` in `config.filter_parameters`, and emits `X-Frame-Options: DENY` / `Referrer-Policy: same-origin` / a baseline `Content-Security-Policy` on every dashboard response. CSV export is capped at 10,000 rows and respects the requested sort.
|
|
110
|
+
- Dashboard schema drift check runs once per process instead of on every request, cutting per-request DB metadata load. Code reloads in development still trigger a re-check.
|
|
111
|
+
- Dashboard dynamic widths (progress bars, budget fills, stack segments) render via a per-request CSP-nonced `<style>` block instead of inline `style="…"` attributes. Strict `style-src 'self' 'nonce-…'` no longer collapses the visualisations.
|
|
112
|
+
|
|
5
113
|
## [0.8.0] - 2026-05-07
|
|
6
114
|
|
|
7
115
|
0.8 is a storage rebuild. Tokens and tool/runtime charges share one shape
|
data/README.md
CHANGED
|
@@ -29,8 +29,7 @@ gem "openai"
|
|
|
29
29
|
bin/rails llm_cost_tracker:setup
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
migrates the database, then verifies via `llm_cost_tracker:doctor`.
|
|
32
|
+
Runs the install generator, drops a price snapshot, migrates the database, and verifies via `llm_cost_tracker:doctor`.
|
|
34
33
|
|
|
35
34
|
```ruby
|
|
36
35
|
# config/initializers/llm_cost_tracker.rb
|
|
@@ -49,8 +48,15 @@ LlmCostTracker.with_tags(user_id: Current.user&.id, feature: "chat") do
|
|
|
49
48
|
end
|
|
50
49
|
```
|
|
51
50
|
|
|
52
|
-
Mount the dashboard
|
|
53
|
-
|
|
51
|
+
Mount the dashboard in `config/routes.rb`, behind your auth:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
authenticate :admin do
|
|
55
|
+
mount LlmCostTracker::Engine => "/llm-costs"
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The engine ships without authentication on purpose.
|
|
54
60
|
|
|
55
61
|
## What lands in the ledger
|
|
56
62
|
|
|
@@ -81,7 +87,7 @@ need `stream_options: { include_usage: true }`.
|
|
|
81
87
|
|
|
82
88
|
- No proxy. Direct calls only.
|
|
83
89
|
- No prompts. Token counts and metadata only.
|
|
84
|
-
-
|
|
90
|
+
- No traces, evals, or prompt management. Different product, different gem.
|
|
85
91
|
- Not multi-service. Built for a Rails monolith.
|
|
86
92
|
|
|
87
93
|
## Manual tracking
|
|
@@ -110,6 +116,7 @@ LlmCostTracker.track(
|
|
|
110
116
|
- [Extending](docs/extending.md)
|
|
111
117
|
- [Operations](docs/operations.md)
|
|
112
118
|
- [Architecture](docs/architecture.md)
|
|
119
|
+
- [EU AI Act record-keeping](docs/eu_ai_act.md)
|
|
113
120
|
- [Upgrading](docs/upgrading.md)
|
|
114
121
|
- [Changelog](CHANGELOG.md)
|
|
115
122
|
|
|
@@ -163,6 +163,31 @@
|
|
|
163
163
|
|
|
164
164
|
.lct-panel + .lct-panel,
|
|
165
165
|
.lct-stat-grid + .lct-panel { margin-top: 16px; }
|
|
166
|
+
.lct-grid > .lct-panel + .lct-panel,
|
|
167
|
+
.lct-grid > .lct-stat-grid + .lct-panel { margin-top: 0; }
|
|
168
|
+
|
|
169
|
+
.lct-breadcrumb {
|
|
170
|
+
align-items: center;
|
|
171
|
+
color: var(--lct-muted);
|
|
172
|
+
display: flex;
|
|
173
|
+
font-size: var(--fs-sm);
|
|
174
|
+
gap: 8px;
|
|
175
|
+
margin-bottom: 14px;
|
|
176
|
+
}
|
|
177
|
+
.lct-breadcrumb-link {
|
|
178
|
+
color: var(--lct-muted);
|
|
179
|
+
text-decoration: none;
|
|
180
|
+
transition: color 0.15s ease;
|
|
181
|
+
}
|
|
182
|
+
.lct-breadcrumb-link:hover { color: var(--lct-text); }
|
|
183
|
+
.lct-breadcrumb-link:focus-visible {
|
|
184
|
+
color: var(--lct-text);
|
|
185
|
+
outline: 2px solid var(--lct-accent-soft);
|
|
186
|
+
outline-offset: 2px;
|
|
187
|
+
border-radius: 2px;
|
|
188
|
+
}
|
|
189
|
+
.lct-breadcrumb-sep { color: var(--lct-border-strong); }
|
|
190
|
+
.lct-breadcrumb-current { color: var(--lct-text); font-weight: 500; }
|
|
166
191
|
|
|
167
192
|
.lct-banner {
|
|
168
193
|
align-items: center;
|
|
@@ -229,7 +254,7 @@
|
|
|
229
254
|
|
|
230
255
|
.lct-hero-side .lct-stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
231
256
|
.lct-stat-grid-spaced { margin-bottom: 16px; }
|
|
232
|
-
.lct-two-col { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
|
257
|
+
.lct-two-col { align-items: start; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
|
233
258
|
|
|
234
259
|
.lct-stat {
|
|
235
260
|
align-items: flex-start;
|
|
@@ -256,7 +281,6 @@
|
|
|
256
281
|
|
|
257
282
|
.lct-stat-label,
|
|
258
283
|
.lct-field label,
|
|
259
|
-
.lct-dl dt,
|
|
260
284
|
.lct-call-summary-label,
|
|
261
285
|
.lct-call-breakdown-title,
|
|
262
286
|
.lct-chip-label {
|
|
@@ -301,6 +325,21 @@
|
|
|
301
325
|
.lct-table-compact th,
|
|
302
326
|
.lct-table-compact td { padding: 10px 12px; }
|
|
303
327
|
|
|
328
|
+
.lct-badge {
|
|
329
|
+
align-items: center;
|
|
330
|
+
border-radius: 999px;
|
|
331
|
+
display: inline-flex;
|
|
332
|
+
font-size: var(--fs-xs);
|
|
333
|
+
font-weight: 600;
|
|
334
|
+
letter-spacing: 0.02em;
|
|
335
|
+
line-height: 1;
|
|
336
|
+
padding: 4px 10px;
|
|
337
|
+
text-transform: uppercase;
|
|
338
|
+
vertical-align: middle;
|
|
339
|
+
}
|
|
340
|
+
.lct-badge-ok { background: var(--lct-success-soft); color: var(--lct-success); }
|
|
341
|
+
.lct-badge-warn { background: var(--lct-warning-soft); color: var(--lct-warning-strong); }
|
|
342
|
+
|
|
304
343
|
.lct-delta-badge {
|
|
305
344
|
align-items: center;
|
|
306
345
|
border-radius: 999px;
|
|
@@ -387,8 +426,10 @@
|
|
|
387
426
|
.lct-stack-fill-cache-write { background: #f59e0b; }
|
|
388
427
|
.lct-stack-fill-cache-write-extended { background: #a855f7; }
|
|
389
428
|
.lct-stack-fill-audio-input { background: #ec4899; }
|
|
429
|
+
.lct-stack-fill-image-input { background: #f97316; }
|
|
390
430
|
.lct-stack-fill-output { background: #0ea5e9; }
|
|
391
431
|
.lct-stack-fill-audio-output { background: #14b8a6; }
|
|
432
|
+
.lct-stack-fill-image-output { background: #84cc16; }
|
|
392
433
|
|
|
393
434
|
.lct-budget { display: grid; gap: 10px; }
|
|
394
435
|
.lct-budget-head { align-items: baseline; display: flex; gap: 10px; justify-content: space-between; }
|
|
@@ -491,6 +532,20 @@
|
|
|
491
532
|
}
|
|
492
533
|
.lct-state-title { font-size: var(--fs-lg); font-weight: 600; margin: 0 0 6px; letter-spacing: -0.005em; }
|
|
493
534
|
.lct-state-copy { color: var(--lct-muted); margin: 0 auto; max-width: 480px; font-size: var(--fs-sm); line-height: 1.55; }
|
|
535
|
+
.lct-state-pre {
|
|
536
|
+
background: var(--lct-bg);
|
|
537
|
+
border: 1px solid var(--lct-border);
|
|
538
|
+
border-radius: 6px;
|
|
539
|
+
color: var(--lct-text);
|
|
540
|
+
font-family: var(--mono);
|
|
541
|
+
font-size: var(--fs-sm);
|
|
542
|
+
line-height: 1.55;
|
|
543
|
+
margin: 12px auto 0;
|
|
544
|
+
max-width: 560px;
|
|
545
|
+
overflow: auto;
|
|
546
|
+
padding: 12px 14px;
|
|
547
|
+
text-align: left;
|
|
548
|
+
}
|
|
494
549
|
.lct-state-actions { display: flex; gap: 8px; justify-content: center; margin-top: 20px; }
|
|
495
550
|
|
|
496
551
|
.lct-toolbar {
|
|
@@ -832,16 +887,21 @@
|
|
|
832
887
|
|
|
833
888
|
.lct-nowrap { white-space: nowrap; }
|
|
834
889
|
|
|
835
|
-
.lct-detail-grid { display: grid; gap: 16px; grid-template-columns:
|
|
890
|
+
.lct-detail-grid { align-items: start; display: grid; gap: 16px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
836
891
|
|
|
837
892
|
.lct-dl {
|
|
838
893
|
display: grid;
|
|
839
894
|
gap: 12px;
|
|
840
|
-
grid-template-columns: minmax(120px, 0.
|
|
895
|
+
grid-template-columns: minmax(120px, 0.45fr) minmax(0, 1fr);
|
|
841
896
|
margin: 0;
|
|
842
897
|
}
|
|
843
898
|
|
|
844
|
-
.lct-dl
|
|
899
|
+
.lct-dl dt {
|
|
900
|
+
color: var(--lct-muted);
|
|
901
|
+
font-size: var(--fs-sm);
|
|
902
|
+
font-weight: 500;
|
|
903
|
+
}
|
|
904
|
+
.lct-dl dd { color: var(--lct-text); font-size: var(--fs-sm); margin: 0; min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
|
|
845
905
|
|
|
846
906
|
.lct-pre {
|
|
847
907
|
background: #0f172a;
|
|
@@ -1,53 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
class ApplicationController < ActionController::Base
|
|
5
7
|
layout "llm_cost_tracker/application"
|
|
6
8
|
|
|
9
|
+
protect_from_forgery with: :exception
|
|
10
|
+
|
|
11
|
+
before_action :set_dashboard_security_headers
|
|
7
12
|
before_action :ensure_current_schema
|
|
8
13
|
|
|
14
|
+
helper_method :dashboard_csp_nonce
|
|
15
|
+
|
|
9
16
|
rescue_from ActiveRecord::ConnectionNotEstablished, with: :render_database_error
|
|
17
|
+
rescue_from ActiveRecord::AdapterNotSpecified, with: :render_database_error
|
|
10
18
|
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
11
19
|
rescue_from ActiveRecord::StatementInvalid, with: :render_database_error
|
|
12
20
|
rescue_from LlmCostTracker::InvalidFilterError, with: :render_invalid_filter
|
|
13
21
|
|
|
14
|
-
SCHEMA_CHECKS = [
|
|
15
|
-
[
|
|
16
|
-
LlmCostTracker::Ledger::Schema::Calls,
|
|
17
|
-
"The llm_cost_tracker_calls table does not match the current LLM Cost Tracker schema."
|
|
18
|
-
],
|
|
19
|
-
[
|
|
20
|
-
LlmCostTracker::Ledger::Schema::CallRollups,
|
|
21
|
-
"The llm_cost_tracker_call_rollups table does not match the current LLM Cost Tracker schema."
|
|
22
|
-
],
|
|
23
|
-
[
|
|
24
|
-
LlmCostTracker::Ledger::Schema::CallLineItems,
|
|
25
|
-
"The llm_cost_tracker_call_line_items table does not match the current LLM Cost Tracker schema."
|
|
26
|
-
],
|
|
27
|
-
[
|
|
28
|
-
LlmCostTracker::Ledger::Schema::CallTags,
|
|
29
|
-
"The llm_cost_tracker_call_tags table does not match the current LLM Cost Tracker schema."
|
|
30
|
-
]
|
|
31
|
-
].freeze
|
|
32
|
-
|
|
33
|
-
private_constant :SCHEMA_CHECKS
|
|
34
|
-
|
|
35
22
|
private
|
|
36
23
|
|
|
37
24
|
def ensure_current_schema
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return render template: "llm_cost_tracker/shared/setup_required"
|
|
41
|
-
end
|
|
25
|
+
drift = LlmCostTracker::DashboardSetupState.current
|
|
26
|
+
return unless drift
|
|
42
27
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
@setup_message = message
|
|
48
|
-
@setup_details = errors + ["See docs/upgrading.md for the migration path."]
|
|
49
|
-
return render template: "llm_cost_tracker/shared/setup_required"
|
|
50
|
-
end
|
|
28
|
+
@setup_message = drift.message
|
|
29
|
+
@setup_details = drift.details
|
|
30
|
+
render template: "llm_cost_tracker/shared/setup_required"
|
|
51
31
|
end
|
|
52
32
|
|
|
53
33
|
def render_database_error(error)
|
|
@@ -63,5 +43,17 @@ module LlmCostTracker
|
|
|
63
43
|
def render_not_found
|
|
64
44
|
render "llm_cost_tracker/errors/not_found", status: :not_found
|
|
65
45
|
end
|
|
46
|
+
|
|
47
|
+
def set_dashboard_security_headers
|
|
48
|
+
nonce = dashboard_csp_nonce
|
|
49
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
50
|
+
response.headers["Referrer-Policy"] = "same-origin"
|
|
51
|
+
response.headers["Content-Security-Policy"] =
|
|
52
|
+
"default-src 'self'; style-src 'self' 'nonce-#{nonce}'; img-src 'self' data:; frame-ancestors 'none'"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def dashboard_csp_nonce
|
|
56
|
+
request.env["llm_cost_tracker.csp_nonce"] ||= SecureRandom.base64(16)
|
|
57
|
+
end
|
|
66
58
|
end
|
|
67
59
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class AssetsController < ActionController::Base
|
|
5
5
|
def stylesheet
|
|
6
|
-
response.
|
|
6
|
+
response.headers["Cache-Control"] = cache_control_header
|
|
7
7
|
send_file LlmCostTracker::Assets::STYLESHEET_PATH, type: "text/css", disposition: "inline"
|
|
8
8
|
end
|
|
9
9
|
|
|
@@ -22,6 +22,7 @@ module LlmCostTracker
|
|
|
22
22
|
@calls = ordered_scope.includes(:tag_records).limit(@page.limit).offset(@page.offset).to_a
|
|
23
23
|
end
|
|
24
24
|
format.csv do
|
|
25
|
+
response.headers["Cache-Control"] = "no-store"
|
|
25
26
|
send_data render_csv(ordered_scope.limit(CSV_EXPORT_LIMIT)),
|
|
26
27
|
type: "text/csv",
|
|
27
28
|
disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
|
|
@@ -74,8 +75,9 @@ module LlmCostTracker
|
|
|
74
75
|
case field
|
|
75
76
|
when :tracked_at
|
|
76
77
|
call.tracked_at&.utc&.iso8601
|
|
77
|
-
when :
|
|
78
|
-
|
|
78
|
+
when :provider_api_key_id, :provider_workspace_id, :provider_project_id
|
|
79
|
+
csv_safe(LlmCostTracker::Masking.mask_value(field, call[field]))
|
|
80
|
+
when :provider, :model, :provider_response_id, :cost_status
|
|
79
81
|
csv_safe(call[field])
|
|
80
82
|
when :pricing_snapshot
|
|
81
83
|
csv_safe(csv_json(call.pricing_snapshot))
|
|
@@ -87,11 +89,7 @@ module LlmCostTracker
|
|
|
87
89
|
end
|
|
88
90
|
|
|
89
91
|
def csv_json(value)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
JSON.parse(value || "{}").to_json
|
|
93
|
-
rescue JSON::ParserError
|
|
94
|
-
"{}"
|
|
92
|
+
Hash(value).deep_stringify_keys.to_json
|
|
95
93
|
end
|
|
96
94
|
|
|
97
95
|
def csv_safe(value)
|
|
@@ -16,6 +16,10 @@ module LlmCostTracker
|
|
|
16
16
|
total_calls: @summary.total
|
|
17
17
|
)
|
|
18
18
|
@service_charge_rows = Dashboard::DataQuality.service_charge_rows(scope).to_a
|
|
19
|
+
@streaming_health_rows = Dashboard::DataQuality.streaming_health_rows(
|
|
20
|
+
scope,
|
|
21
|
+
total_streaming: @summary.streaming_count
|
|
22
|
+
)
|
|
19
23
|
end
|
|
20
24
|
end
|
|
21
25
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class ReconciliationController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@reconciliation_enabled = LlmCostTracker::Reconciliation.enabled?
|
|
7
|
+
@reconciliation_installed = LlmCostTracker::ProviderInvoice.table_exists?
|
|
8
|
+
if @reconciliation_enabled && @reconciliation_installed
|
|
9
|
+
@scopes = invoice_scopes
|
|
10
|
+
@sources = @scopes.map { |scope| scope[:source] }.uniq
|
|
11
|
+
@diffs = @scopes.filter_map { |scope| diff_for(scope) }
|
|
12
|
+
@last_imported_at = LlmCostTracker::ProviderInvoice.maximum(:imported_at)
|
|
13
|
+
else
|
|
14
|
+
@scopes = []
|
|
15
|
+
@sources = []
|
|
16
|
+
@diffs = []
|
|
17
|
+
@last_imported_at = nil
|
|
18
|
+
end
|
|
19
|
+
@threshold = LlmCostTracker::Reconciliation::DEFAULT_THRESHOLD_PERCENT
|
|
20
|
+
@configured_importers = @reconciliation_enabled ? configured_importers : {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def trigger_import
|
|
24
|
+
unless LlmCostTracker::Reconciliation.enabled?
|
|
25
|
+
return redirect_to reconciliation_path, alert: "Reconciliation is disabled"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
source = params[:source].to_s
|
|
29
|
+
importer = configured_importers[source.to_sym]
|
|
30
|
+
return redirect_to reconciliation_path, alert: "No importer configured for #{source}" if importer.nil?
|
|
31
|
+
|
|
32
|
+
result = importer.call
|
|
33
|
+
if result.respond_to?(:errors) && result.errors.any?
|
|
34
|
+
LlmCostTracker::Logging.warn(
|
|
35
|
+
"Reconciliation import for #{source} returned #{result.errors.size} row error(s)"
|
|
36
|
+
)
|
|
37
|
+
return redirect_to(
|
|
38
|
+
reconciliation_path,
|
|
39
|
+
alert: "Imported #{result.respond_to?(:total_imported) ? result.total_imported : 0} " \
|
|
40
|
+
"#{source} rows with #{result.errors.size} row error(s); see Rails logs for details."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
message = if result.respond_to?(:total_imported)
|
|
44
|
+
"Imported #{result.total_imported} #{source} rows"
|
|
45
|
+
else
|
|
46
|
+
"Triggered #{source} importer"
|
|
47
|
+
end
|
|
48
|
+
redirect_to reconciliation_path, notice: message
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
LlmCostTracker::Logging.warn("Reconciliation import failed for #{source}: #{e.class}: #{e.message}")
|
|
51
|
+
redirect_to reconciliation_path,
|
|
52
|
+
alert: "Import failed (#{e.class.name}); see Rails logs for details."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def configured_importers
|
|
58
|
+
LlmCostTracker.configuration.reconciliation_importers
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def invoice_scopes
|
|
62
|
+
connection = LlmCostTracker::ProviderInvoice.connection
|
|
63
|
+
provider_expr =
|
|
64
|
+
if LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
65
|
+
Arel.sql("metadata->>'provider'")
|
|
66
|
+
else
|
|
67
|
+
Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))")
|
|
68
|
+
end
|
|
69
|
+
LlmCostTracker::ProviderInvoice
|
|
70
|
+
.group(:source, provider_expr, :currency)
|
|
71
|
+
.order(:source, :currency)
|
|
72
|
+
.pluck(:source, provider_expr, :currency)
|
|
73
|
+
.map { |source, provider, currency| { source: source, provider: provider, currency: currency.upcase } }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def diff_for(scope)
|
|
77
|
+
window = scope_invoices(scope)
|
|
78
|
+
.order(period_end: :desc, period_start: :desc)
|
|
79
|
+
.limit(1)
|
|
80
|
+
.pick(:period_start, :period_end)
|
|
81
|
+
return nil unless window
|
|
82
|
+
|
|
83
|
+
LlmCostTracker::Reconciliation.diff(
|
|
84
|
+
source: scope[:source], provider: scope[:provider], currency: scope[:currency],
|
|
85
|
+
period_start: window[0], period_end: window[1]
|
|
86
|
+
)
|
|
87
|
+
rescue ArgumentError => e
|
|
88
|
+
LlmCostTracker::Logging.warn("Reconciliation diff skipped for #{scope.inspect}: #{e.message}")
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def scope_invoices(scope)
|
|
93
|
+
relation = LlmCostTracker::ProviderInvoice
|
|
94
|
+
.where(source: scope[:source], currency: scope[:currency])
|
|
95
|
+
connection = LlmCostTracker::ProviderInvoice.connection
|
|
96
|
+
provider = scope[:provider]
|
|
97
|
+
return relation if provider.nil? || provider.empty?
|
|
98
|
+
|
|
99
|
+
if LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
100
|
+
relation.where("metadata->>'provider' = ?", provider)
|
|
101
|
+
else
|
|
102
|
+
relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider')) = ?", provider)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -7,7 +7,21 @@ module LlmCostTracker
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
|
-
|
|
10
|
+
scope = Dashboard::Filter.call(params: params)
|
|
11
|
+
@value = params[:tag_value].to_s
|
|
12
|
+
|
|
13
|
+
if @value.empty?
|
|
14
|
+
@breakdown = Dashboard::TagBreakdown.call(scope: scope, key: params[:key])
|
|
15
|
+
else
|
|
16
|
+
@key = LlmCostTracker::Tags::Key.validate!(
|
|
17
|
+
params[:key],
|
|
18
|
+
error_class: LlmCostTracker::InvalidFilterError
|
|
19
|
+
)
|
|
20
|
+
value_scope = scope.by_tag(@key, @value)
|
|
21
|
+
@value_total_cost = value_scope.sum(:total_cost).to_f
|
|
22
|
+
@value_calls = value_scope.count
|
|
23
|
+
@value_points = Dashboard::TimeSeries.call(scope: value_scope)
|
|
24
|
+
end
|
|
11
25
|
end
|
|
12
26
|
end
|
|
13
27
|
end
|
|
@@ -13,6 +13,7 @@ module LlmCostTracker
|
|
|
13
13
|
include ChartHelper
|
|
14
14
|
include PaginationHelper
|
|
15
15
|
include TokenUsageHelper
|
|
16
|
+
include InlineStyleHelper
|
|
16
17
|
|
|
17
18
|
def coverage_percent(numerator, denominator)
|
|
18
19
|
denominator = denominator.to_f
|
|
@@ -104,6 +105,15 @@ module LlmCostTracker
|
|
|
104
105
|
value.to_s
|
|
105
106
|
end
|
|
106
107
|
|
|
108
|
+
def masked_metadata_hash(value)
|
|
109
|
+
return value if value.is_a?(Hash)
|
|
110
|
+
return {} if value.nil?
|
|
111
|
+
|
|
112
|
+
JSON.parse(value.to_s)
|
|
113
|
+
rescue JSON::ParserError, TypeError
|
|
114
|
+
{}
|
|
115
|
+
end
|
|
116
|
+
|
|
107
117
|
def tag_chip_entries(tags, limit: 3)
|
|
108
118
|
normalized = normalized_tags(tags)
|
|
109
119
|
return [] if normalized.empty?
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module InlineStyleHelper
|
|
5
|
+
UNSAFE_CSS_CHARS = /[<>{}"]/
|
|
6
|
+
|
|
7
|
+
def inline_style(declarations)
|
|
8
|
+
registry = inline_style_registry
|
|
9
|
+
token = "lct-i-#{registry.length}"
|
|
10
|
+
registry << [token, declarations.to_s.gsub(UNSAFE_CSS_CHARS, "")]
|
|
11
|
+
token
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def inline_style_block
|
|
15
|
+
registry = inline_style_registry
|
|
16
|
+
return "".html_safe if registry.empty?
|
|
17
|
+
|
|
18
|
+
rules = registry.map { |token, decl| %([data-lct-style="#{token}"]{#{decl}}) }.join("\n")
|
|
19
|
+
content_tag(:style, rules.html_safe, nonce: dashboard_csp_nonce)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def inline_style_registry
|
|
25
|
+
@inline_style_registry ||= []
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module ReconciliationHelper
|
|
5
|
+
def attribution_summary(attribution)
|
|
6
|
+
LlmCostTracker::Masking.format_attribution(attribution)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def mask_secret(value)
|
|
10
|
+
LlmCostTracker::Masking.mask_value(:provider_api_key_id, value)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|