llm_cost_tracker 0.9.0 → 0.10.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 +29 -1
- data/README.md +2 -1
- data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +28 -6
- data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +31 -28
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor.rb +6 -17
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +23 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -24
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +28 -42
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
- data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +20 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +72 -27
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -1
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +1 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +18 -7
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e84eab476358c65154ce99e1c1b95d91406094a1221e3724253b3a2efb471ed5
|
|
4
|
+
data.tar.gz: 8793c30b7cdbd161cc9ea4b242969f8c3ba61e164aa5779c84e33e4d5ac38db5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b377e608cf26ae425ac6eda48213878b14d108aa559c56035279e83f0414370db21fc05f8115d27094a92beaa380959b50a21c46730f7cc4ee8b63f8db7f2ad8
|
|
7
|
+
data.tar.gz: b53b1587f4080f74140cc73854edd1d1916b66a6e8e4b520a33b4897a0d5b428798c9cd6f248e189628d5fd6f71460727306523de0ce7268ced2c0a183b9b5bd
|
data/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,35 @@
|
|
|
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
|
-
## [
|
|
5
|
+
## [0.10.0] - 2026-05-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `budget_exceeded_behavior = :block_requests` now also blocks before send when an estimate of the call's input cost plus prior spend would cross the daily / monthly limit, or when the estimate alone crosses `per_call_budget`. `BudgetExceededError` and `on_budget_exceeded` payloads gain a `stage` field (`:pre_send` or `:post_spend`). Calls to models with no pricing match skip the pre-send check. See [Budgets](docs/budgets.md).
|
|
10
|
+
- `bin/rails llm_cost_tracker:backfill_unknown_pricing` rake task recomputes cost, pricing snapshot, line-item costs, and rollup buckets for calls that landed with no pricing (e.g. a new model recorded before the next scraper refresh added its rates). The Data Quality dashboard's "Unknown pricing by model" panel points at this task. Idempotent — re-running only touches calls still missing a cost.
|
|
11
|
+
- Azure OpenAI Service is captured out of the box across both Azure OpenAI (`*.openai.azure.com`) and Microsoft Foundry (`*.services.ai.azure.com`) hostnames, and on both the classic `/openai/deployments/{name}/{operation}` path and the v1 `/openai/v1/{operation}` path. OpenAI Ruby SDK calls made with either base URL also tag as `provider: "azure_openai"`. Pricing resolves to the matching `openai/<model>` entry; regional / Data Zone uplifts are configurable via `config.pricing_overrides` with the `azure_openai/<model>` prefix. See [Configuration → Azure OpenAI Service](docs/configuration.md#azure-openai-service).
|
|
12
|
+
- `bin/rails generate llm_cost_tracker:upgrade_provider_invoice_imports_provider` writes the migration so two reconciliation importers sharing a `source` (e.g. `csv/openai` and `csv/anthropic`) no longer cross-pollute resume cursors.
|
|
13
|
+
- `bin/rails generate llm_cost_tracker:upgrade_provider_invoices_metadata_index` writes a migration adding a GIN index on `llm_cost_tracker_provider_invoices.metadata` (PostgreSQL only) so reconciliation metadata lookups don't seq-scan on large invoice sets. No-op on MySQL.
|
|
14
|
+
- A `prices_file` with `metadata.currency: "EUR"` (or any non-USD code) now flows through to the call's `pricing_snapshot.currency`, the `call_rollups.currency` bucket, every `call_line_items.currency` row (token and service-charge alike), and the header `cost.currency` instead of being hardcoded to USD. The bundled price snapshot and `pricing_overrides` still default to USD; mixed-currency line items continue to drop from the header total with a warning.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- OpenAI Chat Completions calls to the specialized search models (`gpt-4o-search-preview`, `gpt-4o-mini-search-preview`, `gpt-5-search-api`) record the per-call web-search fee as a line item at OpenAI's "Web search preview" rate (non-reasoning $25/1k, reasoning $10/1k). These models always search before responding, so the fee applies on every non-streaming call.
|
|
19
|
+
- Async ingestion writes through its own dedicated connection pool instead of competing with request-handling threads, so tracking an LLM call from inside a caller transaction no longer deadlocks under burst load and the inbox row still persists when the caller transaction rolls back. Tune via `config.ingestion_pool_size` if your PG / PgBouncer budget is tight.
|
|
20
|
+
- `mount LlmCostTracker::Engine => "/llm-costs"` works without adding `require "llm_cost_tracker/engine"` to `config/application.rb` — the engine is autoloaded.
|
|
21
|
+
- Upgrade migrations for the call_rollups and call_tags indexes build concurrently on PostgreSQL, so the upgrade no longer takes a long table-write lock on installs with millions of rows. The rollups upgrade keeps pre-upgrade aggregates (bucketed under empty provider) instead of wiping them.
|
|
22
|
+
- CSV exports stream in 500-row batches so peak memory stays flat at large export sizes while the user-selected sort order is preserved (`?sort=expensive`, `?sort=slow`, etc.).
|
|
23
|
+
- `bin/rails generate llm_cost_tracker:install` skips `config/initializers/llm_cost_tracker.rb` when it already exists instead of prompting Thor's "overwrite?" dialog, so re-running the generator in CI no longer hangs waiting on stdin.
|
|
24
|
+
- Async inbox entries no longer truncate on MySQL — large pricing snapshots and stack traces stay intact (MEDIUMTEXT instead of TEXT cap at 64 KB). PostgreSQL is unaffected.
|
|
25
|
+
- Logs no longer warn `unknown pricing for model X` when the call has no token pricing but at least one service charge was successfully priced. Misleading false-positive is gone for Anthropic web-search-style calls on models missing from the token-pricing table.
|
|
26
|
+
- `llm_cost_tracker:prune` rake task also prunes async inbox entries and finished provider invoice imports past the `DAYS` cutoff, so pending or quarantined rows no longer flush retroactively and old import-cursor rows don't linger forever.
|
|
27
|
+
- Azure OpenAI deployments using `audio/speech`, `images/edits`, `images/variations`, `moderations`, or `responses` endpoints are now captured by the Faraday parser; previously only `chat/completions`, `completions`, `embeddings`, `audio/transcriptions`, `audio/translations`, and `images/generations` matched.
|
|
28
|
+
- `prices:refresh` drops service-charge keys when a scraper stops emitting them, so stale charges no longer linger in the local price snapshot. Scrapers that don't parse a charges section (groq, gemini) preserve existing entries.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Schema: `llm_cost_tracker_provider_invoice_imports` gains a `provider` column and replaces the `(source, started_at)` index with `(source, provider, started_at)`. Existing installs run `bin/rails generate llm_cost_tracker:upgrade_provider_invoice_imports_provider && bin/rails db:migrate`.
|
|
33
|
+
- BREAKING: `config.durable_ingestion = true/false` is replaced by `config.ingestion = :inline | :async` (default `:inline`). `config.durable_ingestion_pool_size` is renamed to `config.ingestion_pool_size`, and the install generator is now `bin/rails generate llm_cost_tracker:async_ingestion`. Update `config/initializers/llm_cost_tracker.rb` accordingly.
|
|
6
34
|
|
|
7
35
|
## [0.9.0] - 2026-05-12
|
|
8
36
|
|
data/README.md
CHANGED
|
@@ -39,7 +39,7 @@ LlmCostTracker.configure do |config|
|
|
|
39
39
|
end
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
Tag your calls
|
|
42
|
+
Tag your calls to attribute spend:
|
|
43
43
|
|
|
44
44
|
```ruby
|
|
45
45
|
LlmCostTracker.with_tags(user_id: Current.user&.id, feature: "chat") do
|
|
@@ -74,6 +74,7 @@ The engine ships without authentication on purpose.
|
|
|
74
74
|
| --- | --- |
|
|
75
75
|
| OpenAI | Official SDK or Faraday |
|
|
76
76
|
| Anthropic | Official SDK or Faraday |
|
|
77
|
+
| Azure OpenAI | Faraday or official SDK (auto-detected on `*.openai.azure.com` and Foundry `*.services.ai.azure.com`, both deployments and `/openai/v1/...`) |
|
|
77
78
|
| Google Gemini | Faraday |
|
|
78
79
|
| RubyLLM | Provider layer |
|
|
79
80
|
| `ruby-openai` | Faraday |
|
|
@@ -6,6 +6,7 @@ require "json"
|
|
|
6
6
|
module LlmCostTracker
|
|
7
7
|
class CallsController < ApplicationController
|
|
8
8
|
CSV_EXPORT_LIMIT = 10_000
|
|
9
|
+
CSV_EXPORT_BATCH_SIZE = 500
|
|
9
10
|
CSV_FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
|
|
10
11
|
DEFAULT_ORDER = "tracked_at DESC, id DESC"
|
|
11
12
|
|
|
@@ -23,7 +24,7 @@ module LlmCostTracker
|
|
|
23
24
|
end
|
|
24
25
|
format.csv do
|
|
25
26
|
response.headers["Cache-Control"] = "no-store"
|
|
26
|
-
send_data render_csv(ordered_scope
|
|
27
|
+
send_data render_csv(ordered_scope),
|
|
27
28
|
type: "text/csv",
|
|
28
29
|
disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
|
|
29
30
|
end
|
|
@@ -31,7 +32,7 @@ module LlmCostTracker
|
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def show
|
|
34
|
-
@call = LlmCostTracker::Call.find(params[:id])
|
|
35
|
+
@call = LlmCostTracker::Call.includes(:line_items, :tag_records).find(params[:id])
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
private
|
|
@@ -55,13 +56,24 @@ module LlmCostTracker
|
|
|
55
56
|
fields = csv_fields
|
|
56
57
|
CSV.generate do |csv|
|
|
57
58
|
csv << fields.map(&:to_s)
|
|
58
|
-
|
|
59
|
-
relation.includes(:tag_records).each do |call|
|
|
59
|
+
each_export_batch(relation) do |call|
|
|
60
60
|
csv << fields.map { |field| csv_value(field, call) }
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
def each_export_batch(relation, &)
|
|
66
|
+
offset = 0
|
|
67
|
+
while offset < CSV_EXPORT_LIMIT
|
|
68
|
+
batch_size = [CSV_EXPORT_BATCH_SIZE, CSV_EXPORT_LIMIT - offset].min
|
|
69
|
+
batch = relation.limit(batch_size).offset(offset).preload(:tag_records).to_a
|
|
70
|
+
break if batch.empty?
|
|
71
|
+
|
|
72
|
+
batch.each(&)
|
|
73
|
+
offset += batch.size
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
65
77
|
def csv_fields
|
|
66
78
|
%i[tracked_at provider model] +
|
|
67
79
|
TokenUsage.members +
|
|
@@ -8,17 +8,22 @@ module LlmCostTracker
|
|
|
8
8
|
STATES = [STATE_RUNNING, STATE_COMPLETED, STATE_FAILED].freeze
|
|
9
9
|
|
|
10
10
|
scope :for_source, ->(source) { where(source: source.to_s) }
|
|
11
|
+
scope :for_provider, ->(provider) { where(provider: provider.to_s) }
|
|
11
12
|
scope :running, -> { where(state: STATE_RUNNING) }
|
|
12
13
|
scope :completed, -> { where(state: STATE_COMPLETED) }
|
|
13
14
|
scope :failed, -> { where(state: STATE_FAILED) }
|
|
14
15
|
scope :latest, -> { order(started_at: :desc, id: :desc) }
|
|
15
16
|
|
|
16
|
-
def self.resume_cursor_for(source)
|
|
17
|
-
for_source(source)
|
|
17
|
+
def self.resume_cursor_for(source, provider: nil)
|
|
18
|
+
scope = for_source(source)
|
|
19
|
+
scope = scope.for_provider(provider) if provider
|
|
20
|
+
scope.latest.limit(1).pick(:cursor)
|
|
18
21
|
end
|
|
19
22
|
|
|
20
|
-
def self.last_completed_window_for(source)
|
|
21
|
-
for_source(source)
|
|
23
|
+
def self.last_completed_window_for(source, provider: nil)
|
|
24
|
+
scope = for_source(source)
|
|
25
|
+
scope = scope.for_provider(provider) if provider
|
|
26
|
+
scope.completed.latest.limit(1).pick(:window_start, :window_end)
|
|
22
27
|
end
|
|
23
28
|
end
|
|
24
29
|
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "llm_cost_tracker/ledger/schema/calls"
|
|
4
|
+
require "llm_cost_tracker/ledger/schema/call_line_items"
|
|
5
|
+
require "llm_cost_tracker/ledger/schema/call_tags"
|
|
6
|
+
require "llm_cost_tracker/ledger/schema/call_rollups"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Dashboard
|
|
10
|
+
module SetupState
|
|
11
|
+
SetupRequired = Data.define(:message, :details)
|
|
12
|
+
DOCS_HINT = "See docs/upgrading.md for the migration path."
|
|
13
|
+
MUTEX = Mutex.new
|
|
14
|
+
|
|
15
|
+
CORE_SCHEMA_CHECKS = [
|
|
16
|
+
[
|
|
17
|
+
LlmCostTracker::Ledger::Schema::Calls,
|
|
18
|
+
"The llm_cost_tracker_calls table does not match the current LLM Cost Tracker schema."
|
|
19
|
+
],
|
|
20
|
+
[
|
|
21
|
+
LlmCostTracker::Ledger::Schema::CallLineItems,
|
|
22
|
+
"The llm_cost_tracker_call_line_items table does not match the current LLM Cost Tracker schema."
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
LlmCostTracker::Ledger::Schema::CallTags,
|
|
26
|
+
"The llm_cost_tracker_call_tags table does not match the current LLM Cost Tracker schema."
|
|
27
|
+
]
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
OPTIONAL_CALL_ROLLUPS_CHECK = [
|
|
31
|
+
LlmCostTracker::Ledger::Schema::CallRollups,
|
|
32
|
+
"The llm_cost_tracker_call_rollups table does not match the current LLM Cost Tracker schema."
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
private_constant :MUTEX, :CORE_SCHEMA_CHECKS, :OPTIONAL_CALL_ROLLUPS_CHECK, :DOCS_HINT
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def current
|
|
39
|
+
return @cached if defined?(@cached)
|
|
40
|
+
|
|
41
|
+
MUTEX.synchronize do
|
|
42
|
+
@cached = compute unless defined?(@cached)
|
|
43
|
+
end
|
|
44
|
+
@cached
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reset!
|
|
48
|
+
MUTEX.synchronize do
|
|
49
|
+
remove_instance_variable(:@cached) if defined?(@cached)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def compute
|
|
56
|
+
LlmCostTracker::Logging.debug("Dashboard::SetupState recomputing")
|
|
57
|
+
return calls_table_missing unless LlmCostTracker::Call.table_exists?
|
|
58
|
+
|
|
59
|
+
core_drift = drift_in(schema_checks_for_current_config)
|
|
60
|
+
return core_drift if core_drift
|
|
61
|
+
return nil unless LlmCostTracker.reconciliation_enabled?
|
|
62
|
+
|
|
63
|
+
reconciliation_drift
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def schema_checks_for_current_config
|
|
67
|
+
return CORE_SCHEMA_CHECKS unless LlmCostTracker.configuration.cache_rollups
|
|
68
|
+
|
|
69
|
+
CORE_SCHEMA_CHECKS + [OPTIONAL_CALL_ROLLUPS_CHECK]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def drift_in(checks)
|
|
73
|
+
checks.each do |schema, message|
|
|
74
|
+
errors = schema.current_schema_errors
|
|
75
|
+
next if errors.empty?
|
|
76
|
+
|
|
77
|
+
return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reconciliation_drift
|
|
83
|
+
connection = ActiveRecord::Base.connection
|
|
84
|
+
LlmCostTracker::Reconciliation::SCHEMA_TABLES.each do |schema, table|
|
|
85
|
+
unless connection.data_source_exists?(table)
|
|
86
|
+
return SetupRequired.new(
|
|
87
|
+
message: "The #{table} table is required when reconciliation is enabled.",
|
|
88
|
+
details: ["run bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate", DOCS_HINT]
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
errors = schema.current_schema_errors
|
|
93
|
+
next if errors.empty?
|
|
94
|
+
|
|
95
|
+
message = "The #{table} table does not match the current LLM Cost Tracker schema."
|
|
96
|
+
return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
|
|
97
|
+
end
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def calls_table_missing
|
|
102
|
+
SetupRequired.new(
|
|
103
|
+
message: "The llm_cost_tracker_calls table is not available yet.",
|
|
104
|
+
details: nil
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -113,7 +113,7 @@ end %>
|
|
|
113
113
|
</div>
|
|
114
114
|
</section>
|
|
115
115
|
|
|
116
|
-
<% service_line_items = @call.line_items.
|
|
116
|
+
<% service_line_items = @call.line_items.reject { |li| li.unit == "token" }.sort_by(&:position) %>
|
|
117
117
|
<% if service_line_items.any? %>
|
|
118
118
|
<section class="lct-panel">
|
|
119
119
|
<h2 class="lct-section-title">Service Charges</h2>
|
|
@@ -300,7 +300,7 @@
|
|
|
300
300
|
<div class="lct-section-head">
|
|
301
301
|
<div>
|
|
302
302
|
<h2 class="lct-section-title">Unknown pricing by model</h2>
|
|
303
|
-
<p class="lct-section-copy">These models have token counts but no configured rates, so cost stays unknown.</p>
|
|
303
|
+
<p class="lct-section-copy">These models have token counts but no configured rates, so cost stays unknown. After the next price refresh or a <code class="lct-code">pricing_overrides</code> update, run <code class="lct-code">bin/rails llm_cost_tracker:backfill_unknown_pricing</code> to recompute these calls.</p>
|
|
304
304
|
</div>
|
|
305
305
|
<%= link_to "Calls", calls_path(current_query(sort: "unknown_pricing")), class: "lct-button lct-button-secondary" %>
|
|
306
306
|
</div>
|
|
@@ -10,36 +10,32 @@ module LlmCostTracker
|
|
|
10
10
|
PARTIAL = "partial"
|
|
11
11
|
UNKNOWN = "unknown"
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return UNKNOWN if usage_source == :unknown
|
|
13
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
14
|
+
def self.call(token_usage:, usage_source:, token_cost:, service_line_items:, total_cost:,
|
|
15
|
+
token_pricing_partial: false)
|
|
16
|
+
return UNKNOWN if usage_source == :unknown
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
service_line_items.each do |line_item|
|
|
26
|
-
next unless line_item.billable?
|
|
18
|
+
token_billable = token_usage.priced_quantities.any? { |_key, quantity| quantity.positive? }
|
|
19
|
+
service_billable = false
|
|
20
|
+
service_priced = false
|
|
21
|
+
service_unpriced = false
|
|
22
|
+
service_line_items.each do |line_item|
|
|
23
|
+
next unless line_item.billable?
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
service_billable = true
|
|
26
|
+
service_priced ||= line_item.priced?
|
|
27
|
+
service_unpriced ||= line_item.unpriced?
|
|
28
|
+
break if service_priced && service_unpriced
|
|
29
|
+
end
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
priced = (token_billable && !token_cost.nil?) || service_priced || (!token_billable && !service_billable)
|
|
32
|
+
unpriced = (token_billable && (token_cost.nil? || token_pricing_partial)) || service_unpriced
|
|
33
|
+
return UNKNOWN if unpriced && !priced
|
|
34
|
+
return PARTIAL if unpriced
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
end
|
|
41
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
36
|
+
total_cost.nil? || total_cost.zero? ? FREE : COMPLETE
|
|
42
37
|
end
|
|
38
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
43
39
|
end
|
|
44
40
|
end
|
|
45
41
|
end
|
|
@@ -30,28 +30,11 @@ module LlmCostTracker
|
|
|
30
30
|
|
|
31
31
|
class LineItem
|
|
32
32
|
USD = "USD"
|
|
33
|
-
OPTIONAL_ATTRIBUTES = %i[
|
|
34
|
-
pricing_basis
|
|
35
|
-
price_key
|
|
36
|
-
price_source
|
|
37
|
-
price_source_version
|
|
38
|
-
provider_field
|
|
39
|
-
provider_item_id
|
|
40
|
-
].freeze
|
|
41
|
-
SYMBOL_ATTRIBUTES = %i[
|
|
42
|
-
kind
|
|
43
|
-
direction
|
|
44
|
-
modality
|
|
45
|
-
cache_state
|
|
46
|
-
unit
|
|
47
|
-
pricing_basis
|
|
48
|
-
price_source
|
|
49
|
-
].freeze
|
|
50
33
|
|
|
51
34
|
def self.build(attributes)
|
|
52
35
|
attributes = attributes.to_h
|
|
53
36
|
component = component_for(attributes)
|
|
54
|
-
|
|
37
|
+
new(
|
|
55
38
|
kind: symbol_or_nil(attributes[:kind]) || component&.kind,
|
|
56
39
|
direction: symbol_or_nil(attributes[:direction]) || component&.direction,
|
|
57
40
|
modality: symbol_or_nil(attributes[:modality]) || component&.modality,
|
|
@@ -63,38 +46,30 @@ module LlmCostTracker
|
|
|
63
46
|
cost: decimal_or_nil(attributes[:cost]),
|
|
64
47
|
currency: attributes[:currency] || USD,
|
|
65
48
|
cost_status: cost_status_for(attributes),
|
|
49
|
+
pricing_basis: symbol_or_nil(attributes[:pricing_basis]),
|
|
50
|
+
price_key: attributes[:price_key],
|
|
51
|
+
price_source: symbol_or_nil(attributes[:price_source]),
|
|
52
|
+
price_source_version: attributes[:price_source_version],
|
|
53
|
+
provider_field: attributes[:provider_field],
|
|
54
|
+
provider_item_id: attributes[:provider_item_id],
|
|
66
55
|
details: attributes[:details] || {}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
new(**normalized)
|
|
56
|
+
)
|
|
70
57
|
end
|
|
71
58
|
|
|
72
59
|
def self.from_token_usage(token_usage)
|
|
73
60
|
return [] unless token_usage
|
|
74
61
|
|
|
75
|
-
|
|
76
|
-
quantity = token_usage.public_send(component.token_key)
|
|
62
|
+
token_usage.priced_quantities.filter_map do |key, quantity|
|
|
77
63
|
next unless quantity.positive?
|
|
78
64
|
|
|
79
|
-
|
|
65
|
+
component = Components::BY_KEY.fetch(key)
|
|
66
|
+
build(
|
|
80
67
|
kind: component.kind,
|
|
81
68
|
direction: component.direction,
|
|
82
69
|
modality: component.modality,
|
|
83
70
|
cache_state: component.cache_state,
|
|
84
|
-
quantity:
|
|
85
|
-
unit: component.unit
|
|
86
|
-
rate_amount: nil,
|
|
87
|
-
rate_quantity: BigDecimal("1"),
|
|
88
|
-
cost: nil,
|
|
89
|
-
currency: USD,
|
|
90
|
-
cost_status: CostStatus::UNKNOWN,
|
|
91
|
-
pricing_basis: nil,
|
|
92
|
-
price_key: nil,
|
|
93
|
-
price_source: nil,
|
|
94
|
-
price_source_version: nil,
|
|
95
|
-
provider_field: nil,
|
|
96
|
-
provider_item_id: nil,
|
|
97
|
-
details: {}
|
|
71
|
+
quantity: quantity,
|
|
72
|
+
unit: component.unit
|
|
98
73
|
)
|
|
99
74
|
end
|
|
100
75
|
end
|
|
@@ -132,16 +107,7 @@ module LlmCostTracker
|
|
|
132
107
|
decimal_or_nil(value) || BigDecimal("0")
|
|
133
108
|
end
|
|
134
109
|
|
|
135
|
-
|
|
136
|
-
OPTIONAL_ATTRIBUTES.to_h do |key|
|
|
137
|
-
value = attributes[key]
|
|
138
|
-
value = value.to_sym if value.is_a?(String) && SYMBOL_ATTRIBUTES.include?(key)
|
|
139
|
-
[key, value]
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
private_class_method :cost_status_for, :component_for, :symbol_or_nil, :decimal_or_nil, :decimal_or_zero,
|
|
144
|
-
:optional_attributes_for
|
|
110
|
+
private_class_method :cost_status_for, :component_for, :symbol_or_nil, :decimal_or_nil, :decimal_or_zero
|
|
145
111
|
|
|
146
112
|
def billable?
|
|
147
113
|
quantity.positive?
|
|
@@ -163,7 +129,7 @@ module LlmCostTracker
|
|
|
163
129
|
cost || BigDecimal("0")
|
|
164
130
|
end
|
|
165
131
|
|
|
166
|
-
def
|
|
132
|
+
def with_rate(rate)
|
|
167
133
|
rate_amount = rate.fetch(:amount)
|
|
168
134
|
rate_quantity = rate.fetch(:quantity)
|
|
169
135
|
applied_cost = (quantity / rate_quantity) * rate_amount
|
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
3
5
|
require_relative "logging"
|
|
4
6
|
require_relative "ledger"
|
|
7
|
+
require_relative "pricing/estimator"
|
|
5
8
|
|
|
6
9
|
module LlmCostTracker
|
|
7
10
|
class Budget
|
|
8
11
|
BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
|
|
9
12
|
|
|
10
13
|
class << self
|
|
11
|
-
def enforce!
|
|
14
|
+
def enforce!(provider: nil, model: nil, request: nil)
|
|
12
15
|
config = LlmCostTracker.configuration
|
|
13
16
|
return unless config.budget_exceeded_behavior == :block_requests
|
|
14
17
|
|
|
18
|
+
estimate = estimate_cost(provider: provider, model: model, request: request)
|
|
19
|
+
raise_per_call_pre_send(estimate, config.per_call_budget) if config.per_call_budget && estimate.positive?
|
|
20
|
+
|
|
15
21
|
budgets = { monthly: config.monthly_budget, daily: config.daily_budget }.compact
|
|
16
22
|
return if budgets.empty?
|
|
17
23
|
|
|
18
24
|
totals = totals_for(budgets.keys, time: Time.now.utc)
|
|
19
25
|
|
|
20
26
|
budgets.each do |budget_type, budget|
|
|
21
|
-
total = totals.fetch(budget_type)
|
|
27
|
+
total = totals.fetch(budget_type) + estimate
|
|
22
28
|
next unless total >= budget
|
|
23
29
|
|
|
24
30
|
raise BudgetExceededError.new(**budget_payload(
|
|
25
|
-
budget_type: budget_type, total: total, budget: budget, last_event: nil
|
|
31
|
+
budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send
|
|
26
32
|
))
|
|
27
33
|
end
|
|
28
34
|
end
|
|
@@ -44,6 +50,20 @@ module LlmCostTracker
|
|
|
44
50
|
|
|
45
51
|
private
|
|
46
52
|
|
|
53
|
+
def estimate_cost(provider:, model:, request:)
|
|
54
|
+
return BigDecimal("0") unless provider && model && request
|
|
55
|
+
|
|
56
|
+
Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def raise_per_call_pre_send(estimate, budget)
|
|
60
|
+
return unless estimate >= budget
|
|
61
|
+
|
|
62
|
+
raise BudgetExceededError.new(**budget_payload(
|
|
63
|
+
budget_type: :per_call, total: estimate, budget: budget, last_event: nil, stage: :pre_send
|
|
64
|
+
))
|
|
65
|
+
end
|
|
66
|
+
|
|
47
67
|
def check_per_call_budget(event, config)
|
|
48
68
|
budget = config.per_call_budget
|
|
49
69
|
return unless budget
|
|
@@ -70,7 +90,8 @@ module LlmCostTracker
|
|
|
70
90
|
budget_type: budget_type,
|
|
71
91
|
total: total,
|
|
72
92
|
budget: budget,
|
|
73
|
-
last_event: last_event
|
|
93
|
+
last_event: last_event,
|
|
94
|
+
stage: :post_spend
|
|
74
95
|
)
|
|
75
96
|
|
|
76
97
|
if notify_exceeded?(config, budget_type: budget_type, total: total, budget: budget, last_event: last_event)
|
|
@@ -79,12 +100,13 @@ module LlmCostTracker
|
|
|
79
100
|
raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(config.budget_exceeded_behavior)
|
|
80
101
|
end
|
|
81
102
|
|
|
82
|
-
def budget_payload(budget_type:, total:, budget:, last_event:)
|
|
103
|
+
def budget_payload(budget_type:, total:, budget:, last_event:, stage:)
|
|
83
104
|
{
|
|
84
105
|
budget_type: budget_type,
|
|
85
106
|
total: total,
|
|
86
107
|
budget: budget,
|
|
87
|
-
last_event: last_event
|
|
108
|
+
last_event: last_event,
|
|
109
|
+
stage: stage
|
|
88
110
|
}
|
|
89
111
|
end
|
|
90
112
|
|