Class: AiUsageLog
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- AiUsageLog
- 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_writeis 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
- #feature ⇒ Object readonly
- #model_id ⇒ Object readonly
- #provider ⇒ Object readonly
Belongs to collapse
Class Method Summary collapse
-
.cache_hit_pct(input_tokens, cached_tokens, cache_write_tokens) ⇒ Float
Share of billed input tokens served from the provider's prompt cache.
-
.for_feature ⇒ ActiveRecord::Relation<AiUsageLog>
A relation of AiUsageLogs that are for feature.
-
.for_provider ⇒ ActiveRecord::Relation<AiUsageLog>
A relation of AiUsageLogs that are for provider.
-
.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.
-
.recent ⇒ ActiveRecord::Relation<AiUsageLog>
A relation of AiUsageLogs that are recent.
-
.total_cost_usd(scope = all) ⇒ Object
------------------------------------------------------------------ Class helpers ------------------------------------------------------------------.
Instance Method Summary collapse
-
#cost_usd ⇒ Object
Estimated cost in USD as a Float (nil if not known).
-
#recalculate_cost! ⇒ Object
Recalculate cost from current pricing data.
- #total_tokens ⇒ Object
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation
Methods included from Models::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#feature ⇒ Object (readonly)
66 |
# File 'app/models/ai_usage_log.rb', line 66 validates :provider, :model_id, :feature, presence: true |
#model_id ⇒ Object (readonly)
66 |
# File 'app/models/ai_usage_log.rb', line 66 validates :provider, :model_id, :feature, presence: true |
#provider ⇒ Object (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.
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_feature ⇒ ActiveRecord::Relation<AiUsageLog>
A relation of AiUsageLogs that are for feature. Active Record Scope
134 |
# File 'app/models/ai_usage_log.rb', line 134 scope :for_feature, ->(f) { where(feature: f) } |
.for_provider ⇒ ActiveRecord::Relation<AiUsageLog>
A relation of AiUsageLogs that are for provider. Active Record Scope
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: 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.}" } nil rescue StandardError => e Rails.logger.error "[AiUsageLog] Failed to record usage: #{e.}" nil end |
.recent ⇒ ActiveRecord::Relation<AiUsageLog>
A relation of AiUsageLogs that are recent. Active Record Scope
136 |
# File 'app/models/ai_usage_log.rb', line 136 scope :recent, -> { order(created_at: :desc) } |
.total_cost_usd(scope = all) ⇒ Object
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
#account ⇒ Account
64 |
# File 'app/models/ai_usage_log.rb', line 64 belongs_to :account, optional: true |
#cost_usd ⇒ Object
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 |
#subject ⇒ Subject
63 |
# File 'app/models/ai_usage_log.rb', line 63 belongs_to :subject, polymorphic: true, optional: true |
#total_tokens ⇒ Object
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 |