Skip to content

Providers

A provider is the adapter between Protege and a language-model API. It's a subclass of Protege::Provider, and it does exactly one thing: take a normalized request, call a model, and return a normalized response. Everything else in the engine speaks that normalized shape, so swapping models is a provider change and nothing else.

Most apps never write one. The bundled Protege::OpenRouterProvider reaches many vendors through a single OpenAI-compatible API — point config.provider_id at :openrouter, set a model, and you're done. Write a provider only when you need a model behind an API OpenRouter doesn't cover, or your own gateway.

The contract

A provider is hash in, hash out. It never touches Protege's internal value types — it receives a plain request hash and returns a plain response hash:

ruby
# app/providers/scoop_llm_provider.rb
class ScoopLLMProvider < Protege::Provider
  protege_id :scoop_llm            # required — how config.provider_id selects this provider

  def initialize
    options   = Protege.configuration.provider_options(:scoop_llm)
    @api_key  = options.fetch(:api_key)
    @base_url = options.fetch(:base_url)
  end

  # request  => { messages: [...], tools: [...] }
  # returns  => { content:, tool_calls:, finish_reason: }
  def generate(request)
    response = post("#{@base_url}/chat/completions", body: request.merge(model: model))
    {
      content:       response.dig("choices", 0, "message", "content"),
      tool_calls:    response.dig("choices", 0, "message", "tool_calls"),
      finish_reason: response.dig("choices", 0, "finish_reason")
    }
  rescue Faraday::TooManyRequestsError => e
    raise Protege::RateLimitedError, e.message
  rescue Faraday::TimeoutError => e
    raise Protege::TimeoutError, e.message
  end
end

Three rules:

  • Declare an id with protege_id. It's required; a provider without one raises when the engine tries to resolve it. The id is what config.provider_id references.
  • Read your own settings from config. The model, credentials, and any base URL live in the provider's own slice of config.providers, read via provider_options(:scoop_llm). The engine treats those values as opaque — put whatever keys you need under your id.
  • Raise a Protege::Error on failure. Map transport, auth, and parse failures to the engine's error classes so the job layer can retry the transient ones and discard the permanent ones — RateLimitedError, TimeoutError, UnavailableError (transient); BadRequestError, UnauthorizedError (permanent), among others.

Streaming, model, and settings

Three optional overrides tune behavior; all have sensible defaults:

  • generate_stream(request, &block) — yield incremental chunks as they arrive ({ type: :text, content: "..." }). The default doesn't truly stream: it calls generate and yields the whole text once. Streaming only matters for the live console; SMTP replies don't need it. The bundled OpenRouter provider streams natively over SSE.
  • model — the model id this provider targets. The default reads :model from your config slice, which is what you want unless you're doing something dynamic.
  • settings — the sampling knobs (temperature, max tokens, …) that shaped a generation. The default is empty. These are captured alongside the model in a trace, so overriding this makes your fine-tuning data reproducible.

Adapting a non-OpenAI API

The request and response hashes are OpenAI-shaped, but nothing requires the upstream API to be. A provider is free to translate in both directions — reshape the request into the vendor's format before the call, and reshape the vendor's reply back into { content:, tool_calls:, finish_reason: } afterward. For an Anthropic-style API, that means mapping the message list and tool schema on the way out and normalizing the content blocks and stop_reason on the way back. As long as generate honours the hash-in/hash-out contract, the rest of the engine neither knows nor cares which vendor is behind it.

The bundled Protege::OpenRouterProvider is the reference implementation — read it when you write your own: it shows native SSE streaming, error mapping by HTTP status, and tool-call normalization.

Next

  • Configurationprovider_id and the providers settings hash.
  • Tracing — how model and settings are captured per turn.