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-openai in 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_search next to your own tools, instead of a raw hash in with_params. The deep_merge patch goes.
  • Fallbacks. fallbacks "model-b", "model-c", on: [...] retries a failed request on the next model. In 1.x there was nothing like it; our AIClient did it.
  • Prompt rendering outside agents. RubyLLM.render_prompt renders any template under app/prompts with locals. In 1.x templates belonged to an agent: rendered through its class, from its own folder.
  • Tool approval and cancellation. requires_approval parks a tool call and persists it; cancel on an acts_as_chat record 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_llm event. 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.erb convention. 2.0 added model blocks that read inputs, rescue_from, and agent-level fallbacks, provider_tools and tool_options.
  • Tool choice (with_tools(..., choice:), forcing a specific tool), cost per message and per chat, instrumentation through ActiveSupport::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
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: