LaunchKit

Where ruby_llm 2.0 keeps token counts, and the dashboard that read zero

September 21, 2026

An upgrade that moves where a gem stores a number is the most dangerous kind of change, because the old column is still there and still sums to something. In ruby_llm 2.0 that number is the token count, and the something it sums to is zero.

Rails LLM cost reporting depends on reading the right table, and ruby_llm token usage changed tables between major versions without changing the columns it left behind.

What moved

Before 2.0, a message carried its own token counts and a dashboard summed them off messages. In 2.0 the gem writes a row to ruby_llm_usages for every provider attempt and never sets a token count on a message again.

The table is wider than the columns it replaced:

create_table "ruby_llm_usages" do |t|
  t.bigint  "chat_id",    null: false
  t.string  "chat_type",  null: false
  t.bigint  "message_id"
  t.string  "message_type"
  t.string  "model",      null: false
  t.string  "operation",  null: false
  t.integer "input_tokens"
  t.integer "output_tokens"
  t.integer "cache_read_tokens"
  t.integer "cache_write_tokens"
  t.decimal "input_cost",  precision: 16, scale: 10
  t.decimal "output_cost", precision: 16, scale: 10
  t.string  "status",     null: false
end

Cache tokens are broken out separately because prompt caching is billed differently from fresh input, and the cost columns carry ten decimal places because per-token prices are small enough that rounding at the row destroys the total. operation is constrained to a list that includes embeddings, images and transcription, so a single table covers every kind of call the gem can make rather than chat alone.

The regression this causes, and why it is silent

A sum over a column that exists and is always null returns zero. Nothing raises. The page renders, the tests that assert a 200 pass, and the number is simply wrong from the moment the upgrade ships.

That is exactly what happened to the admin dashboard here. It read tokens off messages, the upgrade landed, and every token figure on the screen became zero while every other number on the same page stayed correct. A missing method would have been better: it would have raised on the first visit and been fixed in minutes.

The fix is to sum the new table, and it is worth keeping the reason in the code rather than in a commit message nobody reads:

TOKENS_SQL = "COALESCE(ruby_llm_usages.input_tokens, 0) + COALESCE(ruby_llm_usages.output_tokens, 0)".freeze

COALESCE on both sides because a row can carry one and not the other, and a null anywhere in the addition makes the whole expression null rather than the other operand.

Joining a polymorphic table with no association

ruby_llm_usages belongs to its chat through chat_id and chat_type, and the application's Chat model has no has_many :usages. So a query that needs chat data, such as bucketing tokens by the month the chat was created, has no association to join through and the SQL is written by hand:

CHATS_JOIN = <<~SQL.squish.freeze
  INNER JOIN chats ON chats.id = ruby_llm_usages.chat_id
                  AND ruby_llm_usages.chat_type = 'Chat'
SQL

The type condition is the part to keep. With one acts_as_chat model the join is correct without it, and the day a second model acts as a chat, ids collide across the two tables and rows start attaching to the wrong records. A condition that costs nothing today and prevents a silent data bug later is worth the line.

The constant that breaks eager loading

Reaching the usage model has one trap worth naming, because it fails at boot rather than at runtime. RubyLLM::ActiveRecord::Usage is defined from an on_load :active_record hook, which has not necessarily fired when eager loading reaches an application class.

# Breaks eager loading:
USAGES = RubyLLM::ActiveRecord::Usage

# Works:
def usages = RubyLLM::ActiveRecord::Usage

Written as a constant in a class body, the reference is resolved while the class is being loaded and raises NameError in any environment that eager loads, which is production and the CI run and not the development server. A method defers the lookup to the moment it is called, by which time the hook has long since run.

Which tokens count

A usage row is written per attempt, and status distinguishes what happened to that attempt. A request that hit a rate limit after the provider had already processed the input still produced input tokens, and the provider charges for them.

So "how many tokens did we use" and "how many tokens produced an answer" are different questions with different filters, and a dashboard that sums every row is answering the first. That is the right default for a cost figure and the wrong one for a quality figure, and the difference is invisible on the screen unless the page says which it is showing.

The failover path makes this concrete: a primary model that fails and a backup that succeeds write two usage rows for one visible answer. The two fallbacks covers when that second attempt happens.

The quota a user sees is a different number

None of the above is what limits an individual. Ai::UsageQuota counts chats, not tokens, and does it per user:

FREE_MONTHLY_LIMIT = 20
SUBSCRIBED_MONTHLY_LIMIT = 500

def used
  @user.chats.this_month.count
end

One completion is one chat, so the allowance a user sees is a count of things they did rather than a number of tokens they cannot estimate. It is enforced in the controller before the job is enqueued, which is early enough that an over-quota user costs nothing at all.

The trade is that a user who sends 8000-character prompts and a user who sends one line get the same allowance, and the input cap in the controller is what stops the first one from being unbounded. Cost control across the whole application is the model quota instead, and the two are checked in different places for different reasons.

What this page does not cover

Turning the cost columns into a bill. The dashboard here multiplies tokens by a flat estimated rate, which is honest about being an estimate, while ruby_llm_usages already carries a real per-row cost that varies by model. Summing those instead is the accurate version and nothing in this codebase does it yet.

Nor does it cover the streaming path's usage rows, which is where the tokens come from in the first place. The job and its broadcasts are in streaming an LLM answer with Turbo Streams.

More on The AI layer in Rails

← All The AI layer in Rails articles