llm_cost_tracker 0.12.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3bb624cf9437e2ab972021128ab552b48b16c9b8d209429fb264062837e8547
4
- data.tar.gz: 8785221213ed888a592b312e5a734193637653930ef9652ece73f650cb920eb5
3
+ metadata.gz: ec6d42366ce33ab5b2d4847d4ad425dd4d4262a302212427be2a3fc55db53fcc
4
+ data.tar.gz: 57f6eaa22546be19cc3b17afeed107fb35782ad5de4b6ed9646a4d7955cab589
5
5
  SHA512:
6
- metadata.gz: c223c14dbfe3e2ebf61930175ae7607c2a4a05f502962963312c4ec929965242fccab115eda9d1426d6e331d7fb23ad811f73c9ba8a795cb3262c3d49a60eb45
7
- data.tar.gz: 6b8e3ef019f41907909bb9f07eb58085dd6355a442699d52440de564c97fb6fb65979ee01271e4741bd9411ce7ef6a3b69102accd764fdf419d42db1bdb2f6e8
6
+ metadata.gz: 716c326903ac571988211ee34f2ff2a43d6cec3d6b8d00a1682df5d245877e7884f8b3e3b3e9d86e37b0089a73f97f3dc1b4c7cbbf1c40a1365702acef7e5f6b
7
+ data.tar.gz: ef41e457bafea063396e7ab08d2c7f6c051c1831ec7476071e36b08af7812853c7e1501fc4d7986658de726316c136e562b747eec26316ffd97a7ee6ee7eda2a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.13.0] - 2026-06-26
8
+
9
+ ### Added
10
+
11
+ - The Data Quality page shows quarantined async-inbox rows (count and cost) when `ingestion: :async` is configured, so cost stuck outside the ledger is visible instead of silently missing from totals.
12
+
13
+ ### Changed
14
+
15
+ - BREAKING: `LlmCostTracker.track(tokens:)` and `stream.usage` (inside `track_stream`) now raise `ArgumentError` on unrecognized token keys instead of dropping them, so a typo like `outpt_tokens:` surfaces immediately rather than undercounting the ledger.
16
+ - OpenAI's image-generation, computer-use, and MCP tool calls no longer add `$0` line items that marked a call's pricing `partial`. Their cost is already captured in the model's tokens, so these tool calls are no longer recorded as separate rows and the call reflects complete pricing.
17
+
18
+ ### Fixed
19
+
20
+ - The mounted dashboard no longer returns 404 — the engine now registers its routes during Rails boot.
21
+ - Async ingestion (`config.ingestion = :async`) no longer permanently loses cost data when a transient database error (deadlock, lock timeout, dropped connection) interrupts the worker mid-drain — affected inbox rows are retried instead of counting toward quarantine.
22
+ - With `config.cache_rollups`, a failed rollup-cache update no longer fails or retries the async ingestion batch — calls land in the ledger, the failure is logged, and `bin/rails llm_cost_tracker:rebuild_rollups` recovers the cached totals.
23
+ - The dashboard labels non-USD amounts with their currency code (e.g. `1.23 EUR`) instead of always rendering `$`, on the pricing table and per-line call costs.
24
+
7
25
  ## [0.12.0] - 2026-06-04
8
26
 
9
27
  ### Added
@@ -20,6 +20,7 @@ module LlmCostTracker
20
20
  scope,
21
21
  total_streaming: @summary.streaming_count
22
22
  )
23
+ @quarantined_inbox = Dashboard::DataQuality.quarantined_inbox
23
24
  end
24
25
  end
25
26
  end
@@ -32,15 +32,17 @@ module LlmCostTracker
32
32
  (numerator.to_f / denominator) * 100.0
33
33
  end
34
34
 
35
- def money(value)
35
+ def money(value, currency: LlmCostTracker::DEFAULT_CURRENCY)
36
36
  value = value.to_f
37
37
  precision = value.abs < 0.01 && value != 0.0 ? 6 : 2
38
+ formatted = format("%.#{precision}f", value)
39
+ code = currency.to_s.upcase.presence || LlmCostTracker::DEFAULT_CURRENCY
38
40
 
39
- "$#{format("%.#{precision}f", value)}"
41
+ code == LlmCostTracker::DEFAULT_CURRENCY ? "$#{formatted}" : "#{formatted} #{code}"
40
42
  end
41
43
 
42
- def optional_money(value)
43
- value.nil? ? "n/a" : money(value)
44
+ def optional_money(value, currency: LlmCostTracker::DEFAULT_CURRENCY)
45
+ value.nil? ? "n/a" : money(value, currency: currency)
44
46
  end
45
47
 
46
48
  def format_date(value)
@@ -6,6 +6,7 @@ module LlmCostTracker
6
6
  MAX_ATTEMPTS_BEFORE_QUARANTINE = 5
7
7
 
8
8
  scope :pending, -> { where(attempts: ..(MAX_ATTEMPTS_BEFORE_QUARANTINE - 1)) }
9
+ scope :quarantined, -> { where(attempts: MAX_ATTEMPTS_BEFORE_QUARANTINE..) }
9
10
  end
10
11
  end
11
12
  end
@@ -4,6 +4,7 @@ module LlmCostTracker
4
4
  module Dashboard
5
5
  module DataQuality
6
6
  UnknownPricingRow = ::Data.define(:provider, :model, :calls, :share_percent)
7
+ QuarantinedInbox = ::Data.define(:count, :total_cost)
7
8
  StreamingHealthRow = ::Data.define(:provider, :streams, :with_usage, :unknown, :unknown_share)
8
9
  Summary = ::Data.define(:total,
9
10
  :unknown_pricing_count,
@@ -33,6 +34,17 @@ module LlmCostTracker
33
34
  scope.unscope(:order).select(aggregate_selects(scope)).take
34
35
  end
35
36
 
37
+ def quarantined_inbox
38
+ return nil unless Ingestion.async?
39
+ return nil unless Ingestion::InboxEntry.table_exists?
40
+
41
+ row = Ingestion::InboxEntry
42
+ .quarantined
43
+ .select("COUNT(*) AS quarantined_count, COALESCE(SUM(total_cost), 0) AS quarantined_cost")
44
+ .take
45
+ QuarantinedInbox.new(count: row.quarantined_count.to_i, total_cost: row.quarantined_cost.to_d)
46
+ end
47
+
36
48
  def summary(stats)
37
49
  total = stats.total_calls.to_i
38
50
  unknown_pricing_count = stats.unknown_pricing_count.to_i
@@ -142,9 +142,9 @@ end %>
142
142
  <td><code class="lct-code-id"><%= line_item.kind %></code></td>
143
143
  <td><%= line_item.unit %></td>
144
144
  <td class="lct-num"><%= line_item.quantity %></td>
145
- <td class="lct-num"><%= line_item.rate_amount ? "#{optional_money(line_item.rate_amount)} / #{line_item.rate_quantity}" : "n/a" %></td>
145
+ <td class="lct-num"><%= line_item.rate_amount ? "#{optional_money(line_item.rate_amount, currency: line_item.currency)} / #{line_item.rate_quantity}" : "n/a" %></td>
146
146
  <% unknown_cost = line_item.cost.nil? || line_item.cost_status.to_s == LlmCostTracker::Charges::CostStatus::UNKNOWN %>
147
- <td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(line_item.cost) %></td>
147
+ <td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(line_item.cost, currency: line_item.currency) %></td>
148
148
  <td><%= line_item.cost_status %></td>
149
149
  </tr>
150
150
  <% end %>
@@ -10,6 +10,17 @@
10
10
  <span class="lct-filter-row-meta"><%= number_with_delimiter(@summary.total) %> call<%= "s" unless @summary.total == 1 %> inspected</span>
11
11
  </div>
12
12
 
13
+ <% if @quarantined_inbox && @quarantined_inbox.count.positive? %>
14
+ <h3 class="lct-stat-section-label">Async inbox</h3>
15
+ <div class="lct-stat-grid">
16
+ <div class="lct-stat lct-stat-warn">
17
+ <div class="lct-stat-head"><p class="lct-stat-label">Quarantined inbox rows</p></div>
18
+ <p class="lct-stat-value"><%= number_with_delimiter(@quarantined_inbox.count) %></p>
19
+ <p class="lct-stat-foot"><%= money(@quarantined_inbox.total_cost) %> excluded from the ledger and totals</p>
20
+ </div>
21
+ </div>
22
+ <% end %>
23
+
13
24
  <% if @summary.total.zero? %>
14
25
  <section class="lct-panel lct-empty">
15
26
  <h2 class="lct-state-title">No data yet</h2>
@@ -83,7 +83,7 @@
83
83
  <td><code class="lct-code-id"><%= row.model %></code></td>
84
84
  <% LlmCostTracker::Dashboard::PricingOverview::RATE_COLUMNS.each do |key| %>
85
85
  <% value = row.rates[key] %>
86
- <td class="lct-num<%= ' lct-num-muted' if value.nil? %>"><%= value ? money(value) : "—" %></td>
86
+ <td class="lct-num<%= ' lct-num-muted' if value.nil? %>"><%= value ? money(value, currency: @source_data.fetch(:currency)) : "—" %></td>
87
87
  <% end %>
88
88
  </tr>
89
89
  <% end %>
@@ -79,7 +79,7 @@ module LlmCostTracker
79
79
  @provider_api_key_id = extra.delete(:provider_api_key_id) || @provider_api_key_id
80
80
  @provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
81
81
  @explicit_usage = Usage::TokenUsage.build(
82
- **extra.slice(*Usage::TokenUsage.members),
82
+ **extra,
83
83
  input_tokens: input_tokens,
84
84
  output_tokens: output_tokens
85
85
  )
@@ -121,13 +121,14 @@ module LlmCostTracker
121
121
  save_succeeded = false
122
122
  begin
123
123
  event = build_event(snapshot)
124
- provider_response_id = event.provider_response_id || snapshot[:provider_response_id]
125
- event = event.with(provider_response_id: provider_response_id)
124
+ event = event.with(
125
+ provider_response_id: event.provider_response_id || snapshot[:provider_response_id],
126
+ pricing_mode: Pricing::Mode.merge(event.pricing_mode, snapshot[:pricing_mode])
127
+ )
126
128
 
127
129
  Tracker.record(
128
130
  event: event,
129
131
  latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
130
- pricing_mode: Pricing::Mode.merge(event.pricing_mode, snapshot[:pricing_mode]),
131
132
  metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
132
133
  context_tags: snapshot[:context_tags]
133
134
  ) { save_succeeded = true }
@@ -119,8 +119,9 @@ module LlmCostTracker
119
119
  end
120
120
 
121
121
  def dimension
122
- Usage::Catalog[price_key] ||
123
- Usage::Catalog.token_priced_for(kind: kind, direction: direction, cache_state: cache_state)
122
+ Usage::Catalog.find_by(
123
+ kind: kind, direction: direction, modality: modality, cache_state: cache_state, unit: unit
124
+ )
124
125
  end
125
126
 
126
127
  def cost_value
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "pricing/mode"
4
+
3
5
  module LlmCostTracker
4
6
  Event = Data.define(
5
7
  :event_id,
@@ -48,7 +50,7 @@ module LlmCostTracker
48
50
  end
49
51
 
50
52
  def batch?
51
- pricing_mode.to_s.split("_").include?("batch")
53
+ Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
52
54
  end
53
55
 
54
56
  def self.resolve_line_items(service_items)
@@ -13,7 +13,7 @@ LlmCostTracker.configure do |config|
13
13
  # Tag guardrails keep accidental high-cardinality or sensitive values out of the ledger.
14
14
  # config.max_tag_count = 50
15
15
  # config.max_tag_value_bytesize = 1024
16
- # config.redacted_tag_keys = %w[api_key access_token authorization credential password refresh_token secret]
16
+ # config.redacted_tag_keys = <%= LlmCostTracker::Configuration::DEFAULT_REDACTED_TAG_KEYS.inspect %>
17
17
 
18
18
  # Optional SDK integrations. Provider SDK gems are not installed by LLM Cost Tracker.
19
19
  # Enabled integrations are checked at boot, so enable only clients your app loads.
@@ -8,6 +8,12 @@ module LlmCostTracker
8
8
  class Batch
9
9
  BATCH_SIZE = 100
10
10
  LOCK_TIMEOUT_SECONDS = 30
11
+ TRANSIENT_PERSIST_ERRORS = [
12
+ ActiveRecord::Deadlocked,
13
+ ActiveRecord::LockWaitTimeout,
14
+ ActiveRecord::StatementTimeout,
15
+ ActiveRecord::ConnectionNotEstablished
16
+ ].freeze
11
17
 
12
18
  def initialize(identity:)
13
19
  @identity = identity
@@ -22,7 +28,10 @@ module LlmCostTracker
22
28
  rows.size
23
29
  rescue StandardError => e
24
30
  rows_to_mark = valid_rows&.any? ? valid_rows : rows
25
- mark_failed_with_message(rows_to_mark, error_message_for(e)) if rows_to_mark&.any?
31
+ if rows_to_mark&.any?
32
+ transient = valid_rows&.any? && TRANSIENT_PERSIST_ERRORS.any? { |klass| e.is_a?(klass) }
33
+ mark_failed_with_message(rows_to_mark, error_message_for(e), decrement_attempts: transient)
34
+ end
26
35
  raise
27
36
  end
28
37
 
@@ -34,12 +43,20 @@ module LlmCostTracker
34
43
  claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
35
44
  end
36
45
 
37
- def mark_failed_with_message(rows, message)
46
+ def mark_failed_with_message(rows, message, decrement_attempts: false)
38
47
  now = Time.now.utc
39
- Ingestion::InboxEntry
40
- .where(id: rows.map(&:id), locked_by: identity)
41
- .update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
42
- warn_on_quarantine(rows)
48
+ scope = Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity)
49
+ if decrement_attempts
50
+ scope.update_all(
51
+ Ingestion::InboxEntry.sanitize_sql_array(
52
+ ["last_error = ?, locked_at = ?, locked_by = NULL, " \
53
+ "attempts = GREATEST(attempts - 1, 0), updated_at = ?", message, now, now]
54
+ )
55
+ )
56
+ else
57
+ scope.update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
58
+ warn_on_quarantine(rows)
59
+ end
43
60
  rescue StandardError => e
44
61
  LlmCostTracker::Logging.warn(
45
62
  "Inbox mark_failed_with_message failed for #{rows.size} rows: #{e.class}: #{e.message} " \
@@ -101,9 +118,10 @@ module LlmCostTracker
101
118
 
102
119
  def persist(rows, events, retry_on_conflict: true)
103
120
  LlmCostTracker::Call.transaction do
104
- Ledger::Store.insert(events)
121
+ Ledger::Store.persist_records(events)
105
122
  Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
106
123
  end
124
+ Ledger::Rollups.increment_safely!(events) if Ingestion.cache_rollups?
107
125
  rescue ActiveRecord::RecordNotUnique
108
126
  raise unless retry_on_conflict
109
127
 
@@ -46,16 +46,17 @@ module LlmCostTracker
46
46
 
47
47
  def record_batch_result(response)
48
48
  return unless active?
49
- return unless response.respond_to?(:result) && response.result
50
49
 
51
- result = response.result
52
- return unless result.respond_to?(:type) && result.type.to_s == "succeeded"
50
+ record_safely do
51
+ next unless response.respond_to?(:result) && response.result
53
52
 
54
- message = result.respond_to?(:message) ? result.message : nil
55
- return unless message
56
- return if LlmCostTracker::Call.already_recorded?(provider: "anthropic", provider_response_id: message.id)
53
+ result = response.result
54
+ next unless result.respond_to?(:type) && result.type.to_s == "succeeded"
55
+
56
+ message = result.respond_to?(:message) ? result.message : nil
57
+ next unless message
58
+ next if LlmCostTracker::Call.already_recorded?(provider: "anthropic", provider_response_id: message.id)
57
59
 
58
- record_safely do
59
60
  usage = message.usage
60
61
  next unless usage
61
62
  next if usage.input_tokens.nil? && usage.output_tokens.nil?
@@ -83,7 +83,7 @@ module LlmCostTracker
83
83
  enforce_budget!(request: request, provider: provider)
84
84
  started_at = LlmCostTracker::Timing.now_monotonic
85
85
  response = yield
86
- record.call(response, request, LlmCostTracker::Timing.elapsed_ms(started_at))
86
+ record_safely { record.call(response, request, LlmCostTracker::Timing.elapsed_ms(started_at)) }
87
87
  response
88
88
  end
89
89
 
@@ -14,13 +14,20 @@ module LlmCostTracker
14
14
  events = Array(events)
15
15
  return if events.empty?
16
16
 
17
+ persist_records(events)
18
+ Ledger::Rollups.increment_safely!(events) if LlmCostTracker.configuration.cache_rollups
19
+ end
20
+
21
+ def persist_records(events)
22
+ events = Array(events)
23
+ return if events.empty?
24
+
17
25
  LlmCostTracker::Call.transaction do
18
26
  rows = events.map { |event| attributes_for(event) }
19
27
  call_ids = insert_calls_returning_ids(rows, events)
20
28
  insert_line_items(events, call_ids)
21
29
  insert_call_tags(events, call_ids)
22
30
  end
23
- Ledger::Rollups.increment_safely!(events) if LlmCostTracker.configuration.cache_rollups
24
31
  end
25
32
 
26
33
  private