Class: AiUsageLog

Inherits:
ApplicationRecord show all
Defined in:
app/models/ai_usage_log.rb

Overview

Audit trail for every AI API call made by the application.

== Intended callers

AiUsageLog.log!(
provider: 'google',
model_id: 'gemini-2.5-flash-image',
feature: 'image_generation',
input_tokens: 420,
output_tokens: 8,
subject: generated_image, # optional polymorphic
account_id: current_account.id, # optional
metadata: { finish_reason: 'STOP' }
)

== Cost calculation

Pricing is looked up from the +LlmModel+ registry table (populated by
rails ruby_llm:load_models). Costs are stored as integer microdollars
(1_000_000 = $1.00) to avoid floating-point rounding errors.

If no pricing data is found for the model, +cost_microdollars+ is nil.

== Schema Information

Table name: ai_usage_logs
Database name: primary

id :bigint not null, primary key
cache_write_tokens :integer default(0), not null
cached_tokens :integer default(0)
cost_microdollars :integer
feature :string not null
input_tokens :integer
metadata :jsonb not null
output_tokens :integer
provider :string not null
subject_type :string
created_at :datetime not null
updated_at :datetime not null
account_id :bigint
model_id :string not null
subject_id :bigint

Indexes

index_ai_usage_logs_on_account_id (account_id)
index_ai_usage_logs_on_created_at (created_at)
index_ai_usage_logs_on_feature (feature)
index_ai_usage_logs_on_provider_and_model_id (provider,model_id)
index_ai_usage_logs_on_subject_type_and_subject_id (subject_type,subject_id)

Foreign Keys

fk_rails_... (account_id => accounts.id)

Constant Summary collapse

MICRODOLLARS_PER_DOLLAR =

Microdollars per dollar.

1_000_000
FALLBACK_PRICING =

Per-million-token fallback pricing for model aliases that may not be in
the LlmModel registry. Keyed by exact model_id.
Updated from provider pricing pages — Jun 2026 (Gemini 3.5 Flash / 3.1 Pro).
cache_write is the Anthropic cache-creation rate (1.25× input);
OpenAI/Gemini don't separately bill cache writes (kept at 0).

{
  'claude-sonnet'  => { input: 3.0,  output: 15.0, cached_input: 0.3,    cache_write: 3.75 },
  'claude-haiku'   => { input: 1.0,  output: 5.0,  cached_input: 0.1,    cache_write: 1.25 },
  'claude-opus'    => { input: 5.0,  output: 25.0, cached_input: 0.5,    cache_write: 6.25 },
  'gpt-5'          => { input: 1.25, output: 10.0, cached_input: 0.3125, cache_write: 0.0 },
  'gpt-5-mini'     => { input: 0.25, output: 2.0,  cached_input: 0.0625, cache_write: 0.0 },
  'gpt-5.5'        => { input: 5.0,  output: 30.0, cached_input: 0.5,    cache_write: 0.0 },
  'gemini-flash'   => { input: 1.5,  output: 9.0,  cached_input: 0.15,  cache_write: 0.0 },
  'gemini-pro'     => { input: 2.0,  output: 12.0, cached_input: 0.20,  cache_write: 0.0 }
}.freeze

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#featureObject (readonly)



66
# File 'app/models/ai_usage_log.rb', line 66

validates :provider, :model_id, :feature, presence: true

#model_idObject (readonly)



66
# File 'app/models/ai_usage_log.rb', line 66

validates :provider, :model_id, :feature, presence: true

#providerObject (readonly)



66
# File 'app/models/ai_usage_log.rb', line 66

validates :provider, :model_id, :feature, presence: true

Class Method Details

.for_featureActiveRecord::Relation<AiUsageLog>

A relation of AiUsageLogs that are for feature. Active Record Scope

Returns:

See Also:



134
# File 'app/models/ai_usage_log.rb', line 134

scope :for_feature, ->(f) { where(feature: f) }

.for_providerActiveRecord::Relation<AiUsageLog>

A relation of AiUsageLogs that are for provider. Active Record Scope

Returns:

See Also:



135
# File 'app/models/ai_usage_log.rb', line 135

scope :for_provider, ->(p) { where(provider: p) }

.log!(provider:, model_id:, feature:, input_tokens: nil, output_tokens: nil, cached_tokens: nil, cache_write_tokens: nil, service_tier: 'standard', subject: nil, account_id: nil, metadata: {}) ⇒ Object

Creates a usage log record. Never raises — logs errors instead so
a tracking failure never interrupts the main flow.

input_tokens is the normalized "standard input only" count from
RubyLLM 1.15+. cached_tokens is cache reads, cache_write_tokens
is cache writes (separately billable on Anthropic at ~1.25× input).

service_tier defaults to 'standard'. Pass 'batch' for Anthropic
Message Batches API usage, which is billed at 50% of standard rates: the
discount is applied to the computed cost and the tier is recorded in
metadata so recalculate_cost! / the backfill task stay consistent.



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'app/models/ai_usage_log.rb', line 84

def self.log!(provider:, model_id:, feature:, # rubocop:disable Metrics/ParameterLists
              input_tokens: nil, output_tokens: nil,
              cached_tokens: nil, cache_write_tokens: nil,
              service_tier: 'standard',
              subject: nil, account_id: nil, metadata: {})
  cost = calculate_cost(model_id, input_tokens, output_tokens, cached_tokens, cache_write_tokens)
  cost = apply_service_tier(cost, service_tier)

  create!(
    provider:           provider.to_s,
    model_id:           model_id.to_s,
    feature:            feature.to_s,
    input_tokens:       input_tokens,
    output_tokens:      output_tokens,
    cached_tokens:      cached_tokens || 0,
    cache_write_tokens: cache_write_tokens || 0,
    cost_microdollars:  cost,
    subject:            subject,
    account_id:         ,
    metadata:           merge_service_tier(, service_tier)
  )
rescue ActiveRecord::RecordNotUnique => e
  # A concurrent insert already recorded this row (a unique index — e.g. the SEO
  # batch idempotency index — caught the race). The backstop worked; not an error.
  Rails.logger.debug { "[AiUsageLog] Skipped duplicate usage row: #{e.message}" }
  nil
rescue StandardError => e
  Rails.logger.error "[AiUsageLog] Failed to record usage: #{e.message}"
  nil
end

.recentActiveRecord::Relation<AiUsageLog>

A relation of AiUsageLogs that are recent. Active Record Scope

Returns:

See Also:



136
# File 'app/models/ai_usage_log.rb', line 136

scope :recent, -> { order(created_at: :desc) }

.total_cost_usd(scope = all) ⇒ Object


Class helpers



142
143
144
145
# File 'app/models/ai_usage_log.rb', line 142

def self.total_cost_usd(scope = all)
  micros = scope.sum(:cost_microdollars)
  micros.to_f / MICRODOLLARS_PER_DOLLAR
end

Instance Method Details

#accountAccount

Returns:

See Also:



64
# File 'app/models/ai_usage_log.rb', line 64

belongs_to :account, optional: true

#cost_usdObject

Estimated cost in USD as a Float (nil if not known).



120
121
122
123
124
# File 'app/models/ai_usage_log.rb', line 120

def cost_usd
  return nil if cost_microdollars.nil?

  cost_microdollars.to_f / MICRODOLLARS_PER_DOLLAR
end

#recalculate_cost!Object

Recalculate cost from current pricing data. Used by the backfill task
to fix records that were saved with nil cost due to missing pricing.



149
150
151
152
153
154
155
# File 'app/models/ai_usage_log.rb', line 149

def recalculate_cost!
  cost = self.class.send(:calculate_cost, model_id, input_tokens, output_tokens,
                         cached_tokens, cache_write_tokens)
  cost = self.class.send(:apply_service_tier, cost, ['service_tier'])
  update_column(:cost_microdollars, cost) if cost && cost != cost_microdollars
  cost
end

#subjectSubject

Returns:

  • (Subject)

See Also:



63
# File 'app/models/ai_usage_log.rb', line 63

belongs_to :subject, polymorphic: true, optional: true

#total_tokensObject



126
127
128
# File 'app/models/ai_usage_log.rb', line 126

def total_tokens
  (input_tokens || 0) + (cached_tokens || 0) + (cache_write_tokens || 0) + (output_tokens || 0)
end