ruby_llm-mongoid
Drop-in Mongoid persistence for ruby_llm. Use MongoDB as your Rails model layer instead of ActiveRecord — same acts_as_chat API, same feel.
Requirements
- Ruby >= 3.3
- Mongoid >= 8.0
- ruby_llm >= 1.16
Installation
Add to your Gemfile:
gem "ruby_llm-mongoid"
Quick start
1. Generate models
bin/rails g ruby_llm:mongoid:install
This creates four model files (Chat, Message, ToolCall, LlmModel) and config/initializers/ruby_llm.rb. No migrations — Mongoid is schemaless; fields are declared in the model files.
2. Create indexes
bin/rails db:mongoid:create_indexes
3. Configure API keys
Edit config/initializers/ruby_llm.rb:
RubyLLM.configure do |config|
config.openai_api_key = ENV["OPENAI_API_KEY"]
config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
end
4. Seed the model registry
bin/rails runner "LlmModel.save_to_database"
This populates MongoDB with the bundled model list so Chat.create!(model: "gpt-4o-mini") can resolve the model record.
5. Start chatting
chat = Chat.create!(model: "gpt-4o-mini")
chat.ask("What is the capital of France?")
# => saves user + assistant messages to MongoDB
Model setup
Default (generated) layout
class Chat
include Mongoid::Document
include Mongoid::Timestamps
acts_as_chat model: :llm_model, model_class: "LlmModel"
end
class Message
include Mongoid::Document
include Mongoid::Timestamps
field :role, type: String
field :content, type: String
field :input_tokens, type: Integer
field :output_tokens, type: Integer
# ...additional token/thinking fields
model: :llm_model, model_class: "LlmModel"
end
class ToolCall
include Mongoid::Document
include Mongoid::Timestamps
field :tool_call_id, type: String
field :name, type: String
field :arguments, type: Hash, default: {}
acts_as_tool_call
end
class LlmModel
include Mongoid::Document
include Mongoid::Timestamps
field :model_id, type: String
field :name, type: String
field :provider, type: String
# ...pricing/capability fields
acts_as_model
end
Custom class / association names
class Conversation
include Mongoid::Document
acts_as_chat messages: :turns,
message_class: "Turn",
model: :ai_model,
model_class: "AiModel"
end
API reference
Chat
chat = Chat.create!(model: "gpt-4o-mini")
chat = Chat.create!(model: "claude-opus-4-7", provider: "anthropic")
chat.ask("Hello!")
chat.say("Hello!") # alias for ask
chat.with_instructions("You are a helpful assistant.")
chat.with_instructions("More context.", append: true)
chat.with_runtime_instructions("Only reply in French.") # not persisted to DB
chat.with_model("claude-opus-4-7")
chat.with_tool(MyTool)
chat.with_tools(ToolA, ToolB)
chat.with_temperature(0.7)
chat.with_thinking(budget: 5000)
chat. { |msg| puts msg.content }
chat. { |msg| broadcast(msg) }
chat.cost # => RubyLLM::Cost
Message
msg = chat..last
msg.to_llm # => RubyLLM::Message
msg.tokens # => RubyLLM::Tokens
msg.cost # => RubyLLM::Cost
msg.to_partial_path # => "messages/assistant"
Model registry
acts_as_model automatically registers a MongoidSource with RubyLLM.config.model_registry_source, so RubyLLM.models reads from MongoDB on boot instead of the bundled JSON file.
LlmModel.save_to_database # seed MongoDB from the bundled model registry (run once after install)
LlmModel.refresh! # fetch the latest list from all providers, then persist to MongoDB
Both methods are idempotent — refresh! is a good candidate for a periodic background job or a deploy hook.
Differences from ActiveRecord integration
| Concern | ActiveRecord | Mongoid (this gem) |
|---|---|---|
| Primary key type | integer | BSON::ObjectId |
| Tool-call result FK field | tool_call_id (integer) |
parent_tool_call_id (ObjectId) |
| Transactions | native | requires replica set; auto no-op on standalone |
| File attachments | ActiveStorage | GridFS via GridFsAttachment concern |
has_many :through |
supported | replaced by direct queries |
parent_tool_call_id field name
In the ActiveRecord integration the FK column linking a tool-result message back to its ToolCall is named tool_call_id (integer). Mongoid uses parent_tool_call_id (BSON::ObjectId) to avoid a type collision with the string tool_call_id field that stores the provider-issued call ID. This is handled automatically — you don't need to think about it unless you are writing raw queries.
Transactions
Multi-document transactions require a MongoDB replica set or Atlas cluster. On a standalone mongod the transaction helper automatically falls back to running the block without a transaction — useful for development and testing.
For production, run at minimum a single-node replica set:
# mongod.conf: add replication.replSetName: "rs0"
mongosh --eval "rs.initiate()"
File attachments (GridFS)
Include RubyLLM::Mongoid::GridFsAttachment in your message model to store attachments in MongoDB's native GridFS bucket instead of ActiveStorage:
class Message
include Mongoid::Document
include Mongoid::Timestamps
include RubyLLM::Mongoid::GridFsAttachment
field :role, type: String
field :content, type: String
# ...other fields
model: :llm_model, model_class: "LlmModel"
end
Then pass files to ask the same way you would with the ActiveRecord integration:
chat.ask("What's in this image?", with: [params[:file]])
chat.ask("Summarise this PDF.", with: ["/path/to/doc.pdf"])
Files are stored in a GridFS bucket named "attachments" by default. Change it per model:
class Message
include RubyLLM::Mongoid::GridFsAttachment
use_gridfs_bucket :llm_files
end
GridFS files are automatically deleted when the owning message or its parent chat is destroyed.
Testing
bundle exec rspec
The spec_helper checks whether MongoDB is reachable on localhost:27017. If nothing is listening it starts a mongo:8.0 Docker container automatically and stops it when the suite exits. You don't need to start MongoDB manually.
The suite uses database_cleaner-mongoid for collection-level isolation between examples. LLM HTTP calls are stubbed with WebMock — no real API keys required.
Contributing
Bug reports and pull requests welcome at github.com/SalScotto/ruby_llm-mongoid.
License
MIT — see LICENSE.txt.