Class: Assistant::CostCalculator

Inherits:
Object
  • Object
show all
Defined in:
app/services/assistant/cost_calculator.rb

Overview

Calculates the cost of AI model usage based on token counts and provider pricing.

Pricing is per million tokens (USD), sourced from provider pricing pages.
Cache pricing:
Anthropic: cache_read = 0.1x input, cache_write = 1.25x input
OpenAI: cache_read = 0.25x input
Gemini: provider-published per-model rates (not a single multiplier) —
e.g. 3.7 Flash promotional cache_read $0.075/M

Usage:
Assistant::CostCalculator.cost_for('claude-sonnet', input_tokens: 50_000, output_tokens: 2_000)

=> 0.18

Assistant::CostCalculator.pricing_for('claude-sonnet')

=> { input: 3.00, output: 15.00, cache_read: 0.30, cache_write: 3.75 }

Constant Summary collapse

MODEL_PRICING =

Per-million-token pricing (USD). Updated Aug 2026 from provider pricing
pages. Gemini 3.7 Flash promotional rates through 2026-12-31 are
$0.75/$3.75 with $0.075 cache reads; rates double on 2027-01-01. Both
gemini keys resolve to gemini-3.7-flash via
AiModelConstants (gemini_pro is the high-thinking-effort tier of the same
model), so they share flash rates. Keys match
Assistant::ChatService::MODELS keys.

{
  'claude-haiku'  => { input: 1.00,  output: 5.00,  cache_read: 0.10,   cache_write: 1.25 },
  'claude-sonnet' => { input: 3.00,  output: 15.00, cache_read: 0.30,   cache_write: 3.75 },
  'claude-opus'   => { input: 5.00,  output: 25.00, cache_read: 0.50,   cache_write: 6.25 },
  'gpt-5'         => { input: 1.25,  output: 10.00, cache_read: 0.3125, cache_write: 0.0 },
  'gpt-5.5'       => { input: 5.00,  output: 30.00, cache_read: 0.50,   cache_write: 0.0 },
  'gpt-5-mini'    => { input: 0.25,  output: 2.00,  cache_read: 0.0625, cache_write: 0.0 },
  'gemini-flash'  => { input: 0.75,  output: 3.75,  cache_read: 0.075, cache_write: 0.0 },
  'gemini-pro'    => { input: 0.75,  output: 3.75,  cache_read: 0.075, cache_write: 0.0 },
  # Grok 4.6 (2026-08-13). Same rates as the 4.5 it replaces; xAI
  # publishes no separate cache-write charge. Doubles to $4/$12 above a
  # 200k prompt, which this flat table does not model — under-reporting
  # a badge on a giant prompt beats inventing a tier the picker cannot
  # see.
  'grok-4.6'      => { input: 2.00,  output: 6.00,  cache_read: 0.50,  cache_write: 0.0 }
}.freeze

Class Method Summary collapse

Class Method Details

.cost_for(model_key, input_tokens:, output_tokens:, cached_tokens: 0, cache_creation_tokens: 0) ⇒ Float

Calculate the cost (in USD) for a single response given token counts and model key.

RubyLLM 1.15 normalized token accounting across providers: input_tokens
now means "standard input only" — prompt cache reads and writes are
reported separately as cached_tokens and cache_creation_tokens. The
three buckets are additive (no subtraction needed).

Parameters:

  • model_key (String)

    Key from ChatService::MODELS (e.g. 'claude-sonnet')

  • input_tokens (Integer)

    Standard (non-cached) input tokens

  • output_tokens (Integer)

    Output tokens

  • cached_tokens (Integer) (defaults to: 0)

    Tokens served from cache (cache-read rate)

  • cache_creation_tokens (Integer) (defaults to: 0)

    Tokens written to cache (cache-write rate)

Returns:

  • (Float)

    Cost in USD



58
59
60
61
62
63
64
65
66
# File 'app/services/assistant/cost_calculator.rb', line 58

def self.cost_for(model_key, input_tokens:, output_tokens:, cached_tokens: 0, cache_creation_tokens: 0)
  pricing = pricing_for(model_key)
  return 0.0 unless pricing

  ((input_tokens / 1_000_000.0) * pricing[:input]) +
    ((output_tokens         / 1_000_000.0) * pricing[:output]) +
    ((cached_tokens         / 1_000_000.0) * pricing[:cache_read]) +
    ((cache_creation_tokens / 1_000_000.0) * pricing[:cache_write])
end

.pricing_for(model_key) ⇒ Hash?

Look up pricing by ChatService::MODELS key OR by model id.

Callers have both. The per-turn badge passes what the response reports —
an id like gemini-3.7-flash or z-ai/glm-5.2 — while this table is
keyed by the short MODELS key (gemini-flash). The mismatch returned nil,
cost_for returned 0.0, and the badge hid the cost entirely: that is why
the grey per-turn line showed a price on some turns and not others.

Parameters:

  • model_key (String)

    a MODELS key or a model id

Returns:

  • (Hash, nil)

    { input:, output:, cache_read:, cache_write: } or nil



78
79
80
81
82
83
# File 'app/services/assistant/cost_calculator.rb', line 78

def self.pricing_for(model_key)
  MODEL_PRICING[model_key] ||
    MODEL_PRICING[models_key_for_id(model_key)] ||
    gateway_pricing_for(model_key) ||
    registry_pricing_for(model_key)
end