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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/README.md +2 -1
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
  6. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  7. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  8. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  9. data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
  10. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  11. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  12. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  13. data/lib/llm_cost_tracker/budget.rb +28 -6
  14. data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
  15. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
  16. data/lib/llm_cost_tracker/configuration.rb +31 -28
  17. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  18. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  19. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  20. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  21. data/lib/llm_cost_tracker/doctor.rb +6 -17
  22. data/lib/llm_cost_tracker/engine.rb +1 -2
  23. data/lib/llm_cost_tracker/errors.rb +3 -2
  24. data/lib/llm_cost_tracker/event.rb +47 -0
  25. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  26. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  27. 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
  28. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  29. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  30. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +23 -8
  31. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  32. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  37. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  38. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -24
  39. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  40. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  41. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  42. data/lib/llm_cost_tracker/integrations/anthropic.rb +28 -42
  43. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  44. data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
  45. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
  46. data/lib/llm_cost_tracker/integrations.rb +14 -13
  47. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  48. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  49. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  50. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  51. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  52. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  53. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  54. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  55. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  56. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  57. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  58. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  59. data/lib/llm_cost_tracker/logging.rb +0 -4
  60. data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
  61. data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
  62. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  63. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  64. data/lib/llm_cost_tracker/parsers/gemini.rb +20 -22
  65. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  66. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
  67. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
  68. data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
  69. data/lib/llm_cost_tracker/parsers.rb +31 -4
  70. data/lib/llm_cost_tracker/prices.json +567 -579
  71. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  72. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  73. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  74. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  75. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  76. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  77. data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
  78. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  79. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  80. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  81. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  82. data/lib/llm_cost_tracker/pricing.rb +72 -27
  83. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  84. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  85. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  86. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  87. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  88. data/lib/llm_cost_tracker/railtie.rb +3 -1
  89. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  90. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  91. data/lib/llm_cost_tracker/reconciliation/importer.rb +1 -0
  92. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
  93. data/lib/llm_cost_tracker/report.rb +0 -4
  94. data/lib/llm_cost_tracker/retention.rb +20 -8
  95. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  96. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  97. data/lib/llm_cost_tracker/tracker.rb +33 -74
  98. data/lib/llm_cost_tracker/version.rb +1 -1
  99. data/lib/llm_cost_tracker.rb +11 -15
  100. data/lib/tasks/llm_cost_tracker.rake +16 -2
  101. metadata +18 -7
  102. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  103. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  104. 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: f17f618b28473afa871c9961a443a34152f4de81f7026d676d62b7e2bd1396d8
4
- data.tar.gz: d024b23f0ca6cd117afa5d10faa0a0b96374391a4741ea330b924b5091f665f7
3
+ metadata.gz: e84eab476358c65154ce99e1c1b95d91406094a1221e3724253b3a2efb471ed5
4
+ data.tar.gz: 8793c30b7cdbd161cc9ea4b242969f8c3ba61e164aa5779c84e33e4d5ac38db5
5
5
  SHA512:
6
- metadata.gz: 0abf684c595b7bc84dfda26ffc62eaabc0c6d91d0b93f1065bf6e824c7867326b7978875d845d3df8be25bfa04ff9091150e0a4cac7f84d835ceaf2f1e2996bb
7
- data.tar.gz: 5b9405bf332b2e9e1eae05f0e7d107d4bb76ea71a6602846a16198540f3e0f315f48dbb316bbda24812d4d66c56ba837a42bfe81aeea502238f09e1f0202c6b4
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
- ## [Unreleased]
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 that's how you find out who burned the money:
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 |
@@ -22,7 +22,7 @@ module LlmCostTracker
22
22
  private
23
23
 
24
24
  def ensure_current_schema
25
- drift = LlmCostTracker::DashboardSetupState.current
25
+ drift = LlmCostTracker::Dashboard::SetupState.current
26
26
  return unless drift
27
27
 
28
28
  @setup_message = drift.message
@@ -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.limit(CSV_EXPORT_LIMIT)),
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 +
@@ -38,7 +38,7 @@ module LlmCostTracker
38
38
  end
39
39
 
40
40
  def number(value)
41
- number_with_delimiter(value.to_i)
41
+ number_with_delimiter(value)
42
42
  end
43
43
 
44
44
  def format_date(value)
@@ -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).latest.limit(1).pick(:cursor)
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).completed.latest.limit(1).pick(:window_start, :window_end)
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.where.not(unit: "token").order(:position).to_a %>
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
- class << self
14
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
15
- def call(token_usage:, usage_source:, token_cost:, service_line_items:, total_cost:,
16
- token_pricing_partial: false)
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
- token_billable = Components::TOKEN_PRICED.any? do |component|
20
- token_usage.public_send(component.token_key).positive?
21
- end
22
- service_billable = false
23
- service_priced = false
24
- service_unpriced = false
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
- service_billable = true
29
- service_priced ||= line_item.priced?
30
- service_unpriced ||= line_item.unpriced?
31
- break if service_priced && service_unpriced
32
- end
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
- priced = (token_billable && !token_cost.nil?) || service_priced || (!token_billable && !service_billable)
35
- unpriced = (token_billable && (token_cost.nil? || token_pricing_partial)) || service_unpriced
36
- return UNKNOWN if unpriced && !priced
37
- return PARTIAL if unpriced
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
- total_cost.nil? || total_cost.zero? ? FREE : COMPLETE
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
- normalized = {
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
- }.merge(optional_attributes_for(attributes))
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
- Components::TOKEN_PRICED.filter_map do |component|
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
- new(
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: BigDecimal(quantity.to_s),
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
- def self.optional_attributes_for(attributes)
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 apply_rate(rate)
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