We moved a year-old AI client from RubyLLM 1.x to 2.0 and deleted most of it. What 2.0 actually changed, what 1.x had already added and we never picked up, how we moved in steps, and the circuit breaker we deleted on day one and had to bring back.
Last October we wrote about building a resilient AI client on top of ruby_llm and Stoplight. That client lived in one of our products and kept growing for a year. Some of it covered things RubyLLM 1.x couldn't do at all. Some of it was written at a time when the gem couldn't do something yet, and stayed after the gem caught up. As 2.0 was being released, we moved to it and deleted most of that code.
This is what we were carrying and why, what 2.0 actually changed (and what it didn't), how we moved in steps, and the two pieces we kept.
Two gems for one job
The product started on OpenAI alone, through the
ruby-openai gem, and within its first month moved to OpenAI's Responses API, which is where OpenAI's hosted web search tool, reasoning summaries and background mode live. A couple of months later we needed Claude and Gemini as well, and added RubyLLM for them, with Stoplight on top to fail over from one vendor to the next. OpenAI stayed on ruby-openai, and for a simple reason: for chat, RubyLLM 1.x sent every OpenAI request to Chat Completions (chat/completions). That stayed true for the whole 1.x line.Two clients meant two tool formats. We didn't want to write every tool twice, so we wrote every tool as a
RubyLLM::Tool class and taught it to describe itself in the Responses format too. RubyLLM had no method for that, so we added one to the gem (simplified):# config/initializers/ruby_llm_openai_responses_patch.rb # Monkey patch: Responses API requires a flat tool format, not a nested one module RubyLLM::Providers::OpenAI::Tools module_function def tool_for_responses(tool) { name: tool.name, type: "function", description: tool.description, parameters: parameters_schema_for(tool) } end end class ApplicationTool < RubyLLM::Tool def to_openai RubyLLM::Providers::OpenAI::Tools.tool_for_responses(self) end end
Web search on the RubyLLM side went through
with_params as a raw provider hash. with_params deep-merges into the payload, and arrays aren't merged: one tools array replaced the other, so our own tools and web search couldn't live in the same request. A second patch changed RubyLLM::Utils.deep_merge to concatenate arrays instead.Then someone had to run those tools on the
ruby-openai side, where there was no agent loop. So we wrote one: a message builder, a stream handler, a tool extractor that mapped function calls back to tool classes, a response parser, a recursion depth limit, and a special case for o3 streams that kept the connection open after the final text. On top of both clients sat a universal AIClient with task-based model selection, the Stoplight circuit breaker and a retry manager.Prompts predate RubyLLM in the project entirely. They were YAML files from the first month, rendered by a hand-rolled ERB loader:
def render_prompt(namespace, key, context = {}) template = @prompts.dig(namespace.to_sym, key.to_sym)["prompt"] binding_obj = Struct.new(*context.keys).new(*context.values).instance_eval { binding } ERB.new(template).result(binding_obj) end
Structured output came through tools. RubyLLM already had schemas when we started, but support across providers was uneven: it worked for some vendors and not yet for others, and every chain we used it in included Claude, where RubyLLM didn't apply schemas yet. Tool calling, on the other hand, worked the same everywhere. So we gave the model a tool, let it "call" the tool with the data as arguments, and stopped the loop from inside:
class Reports::ResultTool < ApplicationTool description "Return the structured report items." param :items, type: :array, desc: "The report items" def execute(items:) halt({ "items" => items }) end end
By the end we had one for nearly every structured answer. Apart from one that tidied up stray JSON strings, none of them did any work.
All told, the layer between our business code and the providers had grown into a small library of its own, plus two patches to a gem we didn't own.
What 2.0 changed, and what it didn't
It's tempting to credit 2.0 with everything we deleted. That wouldn't be accurate. Over that year RubyLLM 1.x grew a lot of what we'd built ourselves. We just kept upgrading the gem without revisiting our own layer.
What was only possible with 2.0:
-
OpenAI over the Responses API. 2.0 splits providers from wire protocols, and OpenAI uses Responses by default. This is what kept
ruby-openaiin our Gemfile. With it gone, one RubyLLM client covers OpenAI, Anthropic and Gemini, the Responses patch goes, and so does our hand-written tool loop. -
Provider tools with their own API. Web search and other vendor-hosted tools are declared with
provider_tools :web_searchnext to your own tools, instead of a raw hash inwith_params. Thedeep_mergepatch goes. -
Fallbacks.
fallbacks "model-b", "model-c", on: [...]retries a failed request on the next model. In 1.x there was nothing like it; ourAIClientdid it. -
Prompt rendering outside agents.
RubyLLM.render_promptrenders any template underapp/promptswith locals. In 1.x templates belonged to an agent: rendered through its class, from its own folder. -
Tool approval and cancellation.
requires_approvalparks a tool call and persists it;cancelon anacts_as_chatrecord is persisted too, so a stop in one request reaches a job in another. -
Usage per attempt. Every physical request to a vendor, failed retries and fallbacks included, gets its own usage entry and
usage.ruby_llmevent. 1.x only saw the successful response and counted tokens and cost per message. - Responses as a reusable protocol. In 1.x an OpenAI-compatible vendor could already subclass the OpenAI provider, but only for Chat Completions. In 2.0 a vendor that speaks Responses can reuse that protocol too.
What was already in 1.x, and we'd never picked up:
- Structured output across all three vendors.
-
Agents and the
app/prompts/<agent>/instructions.txt.erbconvention. 2.0 added model blocks that read inputs,rescue_from, and agent-levelfallbacks,provider_toolsandtool_options. -
Tool choice (
with_tools(..., choice:), forcing a specific tool), cost per message and per chat, instrumentation throughActiveSupport::Notifications(chat.ruby_llm,tool_call.ruby_llm,request.ruby_llm,embedding.ruby_llm). -
Provider registration with
RubyLLM::Provider.register, and OpenAI-compatible providers built by subclassing the OpenAI one.
So the migration had two halves. One was forced: OpenAI's Responses API and provider tools, which nothing before 2.0 could replace. The other was catching up on a year of the gem's own releases while our layer stood still, and using the upgrade as the moment to do it.
How we moved
We did it in one branch that ships as one piece, because a process loads one version of the gem, so there's no running 1.x and 2.0 side by side. Inside the branch, though, it went in steps.
Step 1: freeze what "working" means
The first commit didn't touch the migration. It was a contract test written against the old implementation: what each call returns, which models each kind of work tries and in what order, how streaming chunks arrive, that embeddings have 1,536 dimensions. Only one test helper was meant to know which transport was underneath, though the swap still touched a few assertions that described the old stack.
Step 2: swap the transport
With the contract in place we bumped the gem and deleted the OpenAI adapter, its helper classes, the Claude and Gemini adapters, the Stoplight circuit breaker with its retry manager (the breaker, as it turned out, only for now), and both monkey patches. At that point we still expected to keep a layer of our own.
AIClient stayed, for the moment, as a seam: callers kept calling it, and behind it we built a provider-neutral chat builder, a runner with its own agent loop, a result wrapper and a set of task profiles, all on top of 2.0.Step 3: prompts
The YAML prompts became
.txt.erb files byte for byte, with only the YAML envelope removed. A test that checks the prompt inventory in both directions found one template that nothing had rendered for a year.Later, once every caller was an agent, each prompt was split in two.
instructions.txt.erb is the standing part: role, rules, the shape of a good answer. question.txt.erb is the data for one request, rendered by the caller. On the old stack the one-shot calls sent the whole thing, data included, as one message: about half as a system message, the other half as a user message.Step 4: result tools become schemas
This step didn't strictly need 2.0: by then RubyLLM honored schemas on Claude and Gemini, and OpenAI had them natively. On 1.x the result tools kept working, so there had been no reason to touch them. Then 2.0 removed
halt, which they depended on, and we were rewriting every call site anyway.class Documents::ClassificationSchema < Schematist::Schema array :labels, description: "One label per category in the input" do object do integer :category_id, description: "ID of the category, copied verbatim from the input" string :relevance, enum: %w[high medium low none] end end end
Before switching we checked that every model in every chain now supports structured output, and that a schema and tools can travel in one request on all three vendors.
Step 5: callers become agents
This is where the plan changed. The original plan retired
AIClient but kept the builder and runner from step 2, and listed rewriting every workflow as an agent as a non-goal. Then we looked at what we had: a facade, a request builder, an agent loop, a result object and a profile DSL. RubyLLM has all five and calls them agents, and had most of it well before 2.0. We just hadn't looked. All of it was deleted the same day, together with AIClient, and rewriting every workflow as an agent became the actual plan.Before, a caller described its request in our own vocabulary:
class Documents::Classifier include LLMConfigurable llm_profile :classify, task: :documents, timeout: 5.minutes.to_i, tools: [ Documents::ClassificationResultTool ] def request_labels(client: AIClient) client.ask(messages: [ { role: "system", content: build_prompt } ], **classify_llm_params) end end
After, the agent says the same thing in the library's vocabulary:
class Documents::ClassifierAgent < ApplicationAgent runs_on ModelChains::CAREFUL instructions { prompt("instructions") } schema Documents::ClassificationSchema context RubyLLM.context { |config| config.request_timeout = 5.minutes.to_i } end
and the caller just asks:
def request_labels(agent: Documents::ClassifierAgent) prompt = RubyLLM.render_prompt("documents/classifier_agent/question", document:, categories:) agent.new.ask(prompt).parsed end
Moving the one-shot calls onto agents left one test without its fake, and it reached OpenAI for real. So we made RubyLLM's connection raise in tests:
module NoProviderRequests def post(url, _payload, **, &) raise AttemptedRequest, "a test tried to POST #{url}, give it a fake agent" end end RubyLLM::Transport::Connection.prepend(NoProviderRequests)
Step 6: check it against the old version
Tests tell you the code runs, not that answers stayed as good. So we ran the old and new versions side by side: two copies of the app on identical seeded databases, the same scenarios, model calls logged on both sides. The first run was a no-go because of six major differences, on top of a blocker a live probe had caught before the run started. The blocker and the most visible of the majors came from the same place: the old stack filled gaps for you without saying so, mostly in our own layer, and 2.0 does exactly what you tell it.
Two examples. RubyLLM 1.x quietly rewrote the temperature for some OpenAI models (o-series and gpt-5 were forced to 1.0,
-search models had it removed), though that never touched our OpenAI traffic, which went through ruby-openai. What covered us was our own provider layer: it checked the model registry and left temperature out for any model that refused it, on all three vendors. 2.0 sends what you set. The blocker was exactly that: Anthropic's Claude Opus 4.8 answers any non-default temperature with a 400, the list of such models we had written on the branch named only o3, and a 400 doesn't trigger a fallback. Every agent that set a temperature and ran on Opus failed every time. We now drop the parameter in a before_request hook for models the RubyLLM registry marks as not accepting the parameter. The other example: AIClient appended three house rules to every request without attachments (answer in English, format lists as Markdown, don't mention tools). Agents didn't know about them. We expected it from reading the code, and the run confirmed it: a document in another language came back in that language in the fields we extract from it. The rules are now one prompt template that our base agent appends to every agent except the ones that transcribe images, which must keep the source language.The rule we settled on for fixing findings: restore the old behavior, don't invent a better one. A few fixes that "improved" things were reverted, because once behavior changes there's nothing left to compare against.
Agents, even small ones
The change we like most isn't a feature. It's that every piece of work where a model reasons, with one exception, is now a class in
app/agents, and the directory listing is nearly the list of everything the app asks a model to do. (The exception is the Perplexity search that runs queries an earlier step already chose. Embeddings stay outside too.)That holds even when the agent is almost empty. Here's one with a single tool:
class Reports::SummaryAgent < ApplicationAgent inputs :account runs_on ModelChains::FAST instructions { prompt("instructions") } tools { [ DocumentSearchTool.new(account) ] } temperature 0.3 end
It looks like too little to deserve a class. But everything specific to this agent is in those five lines: what it's given, which models in what order, what it stands on, what it can reach, how creative it may be. Before, the same facts were spread across a profile hash, a model table, a YAML file, a tool list built in a lambda, and defaults inside
AIClient. To answer "which model writes summaries?" you had to trace a call.Small agents also inherit everything from one place. Our
ApplicationAgent is the only place that sets the default temperature and the round limit, adds the house rules and the temperature guard, counts fallbacks, reports provider errors, and wires in the circuit breaker. A new agent gets all of it by existing.And they're easy to fake. Callers take the agent as an argument, so a test passes in a fake that answers with fixed data:
classifier.request_labels(agent: FakeAgent.answering("labels" => []))
Combined with the connection that raises (the real guard also refuses GET requests and the bare connection RubyLLM fetches URLs through), no test can reach a provider through RubyLLM by accident.
What we kept: the circuit breaker
We deleted Stoplight on day one, and the decision record we wrote that morning said it could go: RubyLLM 2.0 has fallbacks, and two systems deciding which model answers seemed like one too many.
It came back three weeks later, before the branch was merged. RubyLLM's fallbacks are per request. When a vendor is down, every request still starts there, waits out the timeout and the retries, and only then moves on. With our timeouts and a single retry (more on that below), that meant up to two minutes per chat question and ten per background job, for as long as the outage lasted. That's the problem a circuit breaker solves: remember across requests that a model is down or turning us away, and skip it for a while.
What changed is the shape. On 1.x Stoplight wrapped the call. Now RubyLLM makes the call and handles the fallback, and Stoplight only keeps score:
module AI::CircuitBreaker THRESHOLD = 3 COOL_OFF = 30.minutes.to_i # The vendor is down or turned us away. A 400 is about the request, not the model, and never trips a light. TRIPPED_BY = [ *RubyLLM::Fallback::DEFAULT_ERRORS, RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError, RubyLLM::PaymentRequiredError ].freeze # The chain without the models sitting out; all of it if every one is. def self.usable(chain) closed = chain.reject { |model| light(model).color == Stoplight::Color::RED } closed.presence || chain end # RubyLLM already hit the error; raise it once more inside the light so Stoplight counts it. def self.failed(model, error) light(model).run { raise error } rescue StandardError nil end def self.light(model) Stoplight("ai_models:#{model}", threshold: THRESHOLD, cool_off_time: COOL_OFF, tracked_errors: TRIPPED_BY) end end
The base agent wires it in with RubyLLM's public hooks. It starts each request on the first model in its chain whose light isn't red (or on the whole chain if every light is, since trying beats failing outright), and reports failures to the breaker. The version below is simplified: the real one also counts failures that reach
rescue_from, and closes a light that has gone yellow once its model answers again.class ApplicationAgent < RubyLLM::Agent class_attribute :chain, default: [] def self.runs_on(chain) self.chain = chain model chain.first fallbacks(*chain.drop(1), on: ModelChains::FALLBACK_ERRORS) end def initialize(**) super usable = AI::CircuitBreaker.usable(chain) chat.with_model(usable.first) chat.with_fallbacks(*usable.drop(1), on: ModelChains::FALLBACK_ERRORS) chat.after_fallback do |fallback| AI::CircuitBreaker.failed(fallback.from.id, fallback.error) end end end
We also dropped RubyLLM's retries from three to one. With a five-minute timeout, the first try plus three retries is twenty minutes on one model while the others in the chain wait.
A custom provider, five days before the deadline
The migration also showed where our own code still belongs: a vendor RubyLLM doesn't ship a provider for, even when it speaks a protocol RubyLLM already knows. Our web research runs on Perplexity, and Perplexity was retiring its Sonar API on September 27. The replacement, the Agent API, speaks the same Responses protocol as OpenAI, but at its own canonical endpoint, and it takes a preset (a model, search setup, prompt and tools tuned together) where a model id would go. RubyLLM 2.0's Perplexity provider speaks Chat Completions, not the Agent API, so there was nothing to switch to.
1.x let you register a provider too, but there was no Responses protocol to build it on, so we'd have had to write that part ourselves. In 2.0 the provider inherits the stock protocol and changes three things: the endpoint, the headers, and where the preset goes (simplified):
module RubyLLM::Providers class PerplexityAgent < Provider class Responses < Protocols::Responses PRESETS = %w[fast low medium high xhigh].freeze # the Agent API presets we accept, sent in place of a model def completion_url = "agent" private def render_payload(messages, model:, **) payload = super payload[:preset] = payload.delete(:model) if PRESETS.include?(model.id) payload end end protocol :responses, Responses def api_base = "https://api.perplexity.ai/v1" def headers = { "Authorization" => "Bearer #{@config.perplexity_api_key}" } def self.configuration_requirements = %i[perplexity_api_key] end end RubyLLM::Provider.register :perplexity_agent, RubyLLM::Providers::PerplexityAgent
Calling it looks like any other chat, with a preset in place of the model:
RubyLLM.chat(model: "low", provider: :perplexity_agent, assume_model_exists: true) .with_provider_tools(:web_search)
It was working on the migration branch on September 22, five days before the Sonar cutoff.
What went away
Before and after
Our code shrank to what only we can know: which model answers which work, what each job asks, and a few hooks. Almost everything between that and the vendor moved into the library.
- The whole custom transport: the provider adapters, the OpenAI wire protocol with its helpers, the retry manager, both monkey patches.
-
AIClient, the profile DSL, the prompt store, and every result tool. -
ruby-openai, plus the webhook integration for deep research, a feature we removed rather than port, since RubyLLM 2.0 has no public API for background Responses. - The tests that covered all of that, which went with the code.
In their place, mostly built from things that were already in the gem: small agents on a few chain base classes (each one line:
runs_on a chain), schemas, one file of model chains, a base agent with a handful of hooks, and no patches to the gem in the app. The only prepend on RubyLLM left is the test guard.If you're considering the same move
Go through 2.0.0. On September 23 the upgrade generator from 1.16 was removed from RubyLLM's main branch, and from now on the gem only ships an upgrade from the previous release.
Before porting a wrapper you wrote around RubyLLM, check what 2.0 already does. We ported ours first and deleted it the same day.
Read the Behavior Changes section of the Upgrade to 2.0 guide, not just the renames. Replacing
with_tool with with_tools takes minutes. The expensive changes were the places where 1.x made a choice for you and 2.0 doesn't anymore.Write down what "working" means before the first change, and hold the new version to the old one. And if you had a circuit breaker, keep it. Fallbacks pick the next model; a circuit breaker remembers across requests that a model is unusable. We deleted ours on day one and had to put it back.
Happy Coding!
Share: