acts_as_chat is one line in a model and it is doing considerably more than the line suggests.
Reading what it expands to is the fastest way to understand what ruby_llm has actually put in your
application, which is three associations and six tables. Reading the Rails LLM schema it generates
is also the quickest way to find out which questions each table can answer.
That first line injects RubyLLM::ActiveRecord::ChatMethods and declares three associations. The
first is the obvious one, has_many :messages, ordered by created_at and then id so a
conversation reads in the order it happened even when two rows land in the same second.
The second is belongs_to :model, pointing at RubyLLM::ActiveRecord::Model through a
ruby_llm_model_id column, and it is optional. A chat can exist before anybody has decided which
model answers it, which is what lets a controller create the record and hand the choice to a
background job.
Declared as: :chat, so the usage table is polymorphic and a second model in the same application
can act as a chat without the two colliding. dependent: :destroy means deleting a chat deletes its
cost history with it, which is worth knowing before you write a cleanup job.
Six tables, and which two are yours
Two of them are ordinary Rails tables you own and will query constantly: chats and messages. You
can add columns, scopes and validations to both, and the models are generated into your app rather
than hidden in the gem.
The other four belong to ruby_llm, are created by one migration, and are named accordingly.
ruby_llm_models is a local catalogue of the models the gem knows about: the provider, the model
id, and what it supports. Nothing calls an API to fill it, because the gem ships a registry and
the table can be populated from it offline.
ruby_llm_usages records one row per provider attempt, which is where cost and token counts live.
ruby_llm_tool_calls records tool invocations, and stays empty in an application that uses none.
ruby_llm_batches tracks provider batch jobs, and stays empty unless you submit one.
The split matters when something looks wrong. A question about a conversation is answered in
messages. A question about money is answered in ruby_llm_usages. Looking in the wrong one is how
a working feature comes to look broken.
There is a seventh thing the generator does that the list above hides: it installs ActiveStorage,
because acts_as_message supports attachments. On an app that had none, ruby_llm:install adds
three more tables and a migration you did not ask for.
The messages table is wider than you expect
A messages row is not a role and a body. The generated schema carries thinking_text and
thinking_signature for reasoning models that return their working separately from their answer,
citations as JSONB for providers that return sources, raw_content and raw_reasoning holding
the provider's untouched payload, and finish_reason recording why generation stopped.
There is also cache_until_here, a boolean, which is prompt caching expressed as a marker on the
conversation rather than as an option on the call. Everything up to that message can be cached by
the provider and billed at the cache rate instead of the input rate.
None of these need attention on day one. They are worth knowing exist, because the first time you
wonder where a model's reasoning went, the answer is a column you never looked at.
Where the token counts went in 2.0
Before 2.0 a message carried its own token counts. In 2.0 the gem writes a ruby_llm_usages row per
provider attempt and never sets a token count on a message again.
The table is deliberately wider than what it replaced:
Cache tokens are separate because prompt caching is billed differently from fresh input. 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 covering embeddings, images, speech,
transcription and reranking, so one table accounts for every kind of call the gem can make.
status is the column people miss. A rate-limited attempt whose input the provider had already
processed still wrote tokens and will still be charged, so it gets a row reading failed. A sum
that ignores status answers "what did this cost", which is usually the right question; a sum that
filters on it answers "what produced an answer", which is a different one.
How different is easy to underestimate. Pointed at a provider answering 429 and then failing over to
one that works, a single question left five rows behind: four failed and one succeeded.
Failing over without asking twice explains where the other
three came from.
The migration path is the dangerous part, and it is dangerous because it is quiet. The token columns
on messages are not deprecated in 2.0, they are gone, and a dashboard summing them after an
upgrade returns zero and raises nothing at all.
The constant whose fate depends on the filename
RubyLLM::ActiveRecord::Usage is defined from an on_load :active_record hook, so it does not
exist until something has caused ActiveRecord to load. Name it in a class body and you get this,
during eager loading:
The part worth knowing is that the same line is fine or fatal depending on where you put it. Rails
eager loads app/controllers before app/models, and each directory in alphabetical order, and the
hook fires when the first model touches ActiveRecord::Base. So the question is only whether your
file is reached before any acts_as_chat model is.
Three runs of the same constant in a fresh Rails 8.1 app:
Where the constant sits
bin/rails zeitwerk:check
any controller
raises
app/models/a_token_report.rb, sorting before chat.rb
raises
app/models/token_report.rb, sorting after chat.rb
passes
Which is a horrible thing to depend on, and the fix costs nothing:
defusages=RubyLLM::ActiveRecord::Usage
A method resolves the constant when it is called rather than when the class is loaded, by which time
the hook has long since run. Worth doing even in the file that happens to sort late today, because
the thing that breaks it is a rename.
The reason this bites rather than annoys is where it surfaces. Development boots lazily and never
notices. Production, CI and zeitwerk:check all eager load, so the first sign is a deploy that will
not come up.
What this page does not cover
Tool calling and batches, which are two of the four gem tables. ruby_llm_tool_calls and the
has_many that acts_as_message adds for it are real and documented, and ruby_llm_batches backs
a different execution model entirely: no streaming, no chat object, results collected later.
No feature behind these pages submits a batch or exposes a tool, so both tables sit empty in the
database this page was written from. Describing how they behave would mean describing code nobody
here has run, which is the one thing these pages do not do.
acts_as_chatis one line in a model and it is doing considerably more than the line suggests. Reading what it expands to is the fastest way to understand what ruby_llm has actually put in your application, which is three associations and six tables. Reading the Rails LLM schema it generates is also the quickest way to find out which questions each table can answer.The three associations
That first line injects
RubyLLM::ActiveRecord::ChatMethodsand declares three associations. The first is the obvious one,has_many :messages, ordered bycreated_atand thenidso a conversation reads in the order it happened even when two rows land in the same second.The second is
belongs_to :model, pointing atRubyLLM::ActiveRecord::Modelthrough aruby_llm_model_idcolumn, and it is optional. A chat can exist before anybody has decided which model answers it, which is what lets a controller create the record and hand the choice to a background job.The third is the one to know about:
Declared
as: :chat, so the usage table is polymorphic and a second model in the same application can act as a chat without the two colliding.dependent: :destroymeans deleting a chat deletes its cost history with it, which is worth knowing before you write a cleanup job.Six tables, and which two are yours
Two of them are ordinary Rails tables you own and will query constantly:
chatsandmessages. You can add columns, scopes and validations to both, and the models are generated into your app rather than hidden in the gem.The other four belong to ruby_llm, are created by one migration, and are named accordingly.
ruby_llm_modelsis a local catalogue of the models the gem knows about: the provider, the model id, and what it supports. Nothing calls an API to fill it, because the gem ships a registry and the table can be populated from it offline.ruby_llm_usagesrecords one row per provider attempt, which is where cost and token counts live.ruby_llm_tool_callsrecords tool invocations, and stays empty in an application that uses none.ruby_llm_batchestracks provider batch jobs, and stays empty unless you submit one.The split matters when something looks wrong. A question about a conversation is answered in
messages. A question about money is answered inruby_llm_usages. Looking in the wrong one is how a working feature comes to look broken.There is a seventh thing the generator does that the list above hides: it installs ActiveStorage, because
acts_as_messagesupports attachments. On an app that had none,ruby_llm:installadds three more tables and a migration you did not ask for.The messages table is wider than you expect
A
messagesrow is not a role and a body. The generated schema carriesthinking_textandthinking_signaturefor reasoning models that return their working separately from their answer,citationsas JSONB for providers that return sources,raw_contentandraw_reasoningholding the provider's untouched payload, andfinish_reasonrecording why generation stopped.There is also
cache_until_here, a boolean, which is prompt caching expressed as a marker on the conversation rather than as an option on the call. Everything up to that message can be cached by the provider and billed at the cache rate instead of the input rate.None of these need attention on day one. They are worth knowing exist, because the first time you wonder where a model's reasoning went, the answer is a column you never looked at.
Where the token counts went in 2.0
Before 2.0 a message carried its own token counts. In 2.0 the gem writes a
ruby_llm_usagesrow per provider attempt and never sets a token count on a message again.The table is deliberately wider than what it replaced:
Cache tokens are separate because prompt caching is billed differently from fresh input. The cost columns carry ten decimal places because per-token prices are small enough that rounding at the row destroys the total.
operationis constrained to a list covering embeddings, images, speech, transcription and reranking, so one table accounts for every kind of call the gem can make.statusis the column people miss. A rate-limited attempt whose input the provider had already processed still wrote tokens and will still be charged, so it gets a row readingfailed. A sum that ignoresstatusanswers "what did this cost", which is usually the right question; a sum that filters on it answers "what produced an answer", which is a different one.How different is easy to underestimate. Pointed at a provider answering 429 and then failing over to one that works, a single question left five rows behind: four
failedand onesucceeded. Failing over without asking twice explains where the other three came from.The migration path is the dangerous part, and it is dangerous because it is quiet. The token columns on
messagesare not deprecated in 2.0, they are gone, and a dashboard summing them after an upgrade returns zero and raises nothing at all.The constant whose fate depends on the filename
RubyLLM::ActiveRecord::Usageis defined from anon_load :active_recordhook, so it does not exist until something has caused ActiveRecord to load. Name it in a class body and you get this, during eager loading:The part worth knowing is that the same line is fine or fatal depending on where you put it. Rails eager loads
app/controllersbeforeapp/models, and each directory in alphabetical order, and the hook fires when the first model touchesActiveRecord::Base. So the question is only whether your file is reached before anyacts_as_chatmodel is.Three runs of the same constant in a fresh Rails 8.1 app:
bin/rails zeitwerk:checkapp/models/a_token_report.rb, sorting beforechat.rbapp/models/token_report.rb, sorting afterchat.rbWhich is a horrible thing to depend on, and the fix costs nothing:
A method resolves the constant when it is called rather than when the class is loaded, by which time the hook has long since run. Worth doing even in the file that happens to sort late today, because the thing that breaks it is a rename.
The reason this bites rather than annoys is where it surfaces. Development boots lazily and never notices. Production, CI and
zeitwerk:checkall eager load, so the first sign is a deploy that will not come up.What this page does not cover
Tool calling and batches, which are two of the four gem tables.
ruby_llm_tool_callsand thehas_manythatacts_as_messageadds for it are real and documented, andruby_llm_batchesbacks a different execution model entirely: no streaming, no chat object, results collected later.No feature behind these pages submits a batch or exposes a tool, so both tables sit empty in the database this page was written from. Describing how they behave would mean describing code nobody here has run, which is the one thing these pages do not do.