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:
# 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
endThree 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 whatconfig.provider_idreferences. - Read your own settings from config. The model, credentials, and any base URL live in the provider's own slice of
config.providers, read viaprovider_options(:scoop_llm). The engine treats those values as opaque — put whatever keys you need under your id. - Raise a
Protege::Erroron 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 callsgenerateand 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:modelfrom 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
- Configuration —
provider_idand theproviderssettings hash. - Tracing — how
modelandsettingsare captured per turn.

