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 — Aug 2026. Gemini 3.7 Flash's
promotional rates through 2026-12-31 are $0.75/$3.75 with $0.075 cached
input; rates double on 2027-01-01. Both aliases resolve to 3.7 Flash.
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: 0.75, output: 3.75, cached_input: 0.075, cache_write: 0.0 },
  'gemini-pro'     => { input: 0.75, output: 3.75, cached_input: 0.075, cache_write: 0.0 },
  # OpenRouter open-weight models, keyed by the full gateway id we send as
  # model_id. Rates read off the live /api/v1/models pricing block (public,
  # unauthenticated) on 2026-08-12 — that endpoint is the authority, not the
  # gem registry, which carried a stale $0.95/$4 for Kimi. These are list
  # rates and OpenRouter routes to whichever host wins the request, so treat
  # the row as an estimate; the exact charge comes back as usage.cost.
  'z-ai/glm-5.2'         => { input: 0.5,    output: 3.15, cached_input: 0.1,    cache_write: 0.0 },
  'qwen/qwen3.7-plus'    => { input: 0.32,   output: 1.28, cached_input: 0.064,  cache_write: 0.4 },
  'moonshotai/kimi-k2.6' => { input: 0.5795, output: 2.44, cached_input: 0.0976, cache_write: 0.0 },
  # xAI direct. Rates from models.dev on 2026-08-13, matching x.ai's own
  # release note ($2/$6); no separate cache-write charge is published.
  'grok-4.6'             => { input: 2.0,    output: 6.0,  cached_input: 0.5,    cache_write: 0.0 }
}.freeze

Constants included from Models::Schedulable

Models::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 Models::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

.cache_hit_pct(input_tokens, cached_tokens, cache_write_tokens) ⇒ Float

Share of billed input tokens served from the provider's prompt cache.

RubyLLM 1.15+ semantics: the three buckets are additive, not nested, so the
denominator is every billed input token — fresh, cached-read, and the
separately-billed cache write. Higher is better; a cache write costs 1.25×
fresh input on Anthropic, so a rising write share with a flat read share
means the cache is being rebuilt rather than hit.

Returns:

  • (Float)

    percentage 0.0..100.0, or 0.0 when nothing was billed



156
157
158
159
160
161
# File 'app/models/ai_usage_log.rb', line 156

def self.cache_hit_pct(input_tokens, cached_tokens, cache_write_tokens)
  billed = input_tokens.to_i + cached_tokens.to_i + cache_write_tokens.to_i
  return 0.0 unless billed.positive?

  cached_tokens.to_i * 100.0 / billed
end

.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.



165
166
167
168
169
170
171
# File 'app/models/ai_usage_log.rb', line 165

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