Providers · 09
Creating Custom Providers
You can create custom providers to connect Riffer to other LLM services.
Basic Structure
Extend Riffer::Providers::Base and implement the five required hook methods:
class Riffer::Providers::MyProvider < Riffer::Providers::Base
private
# Client hook — see "Client resolution" below.
def build_client
MyProviderClient.new(api_key: ENV['MY_PROVIDER_API_KEY'])
end
# Hook methods (matching base.rb order)
def build_request_params(messages, model, options)
tools = options[:tools]
params = {
model: model,
messages: convert_messages(messages),
**options.except(:tools, :tags)
}
if tools && !tools.empty?
params[:tools] = tools.map { |t| convert_tool(t) }
end
params
end
def execute_generate(params)
client.generate(**params)
end
def execute_stream(params, yielder)
client.stream(**params) do |chunk|
case chunk.type
when :text
yielder << Riffer::StreamEvents::TextDelta.new(chunk.content)
when :text_done
yielder << Riffer::StreamEvents::TextDone.new(chunk.content)
when :tool_call
yielder << Riffer::StreamEvents::ToolCallDone.new(
item_id: chunk.id,
call_id: chunk.id,
name: chunk.name,
arguments: chunk.arguments
)
end
end
end
def extract_token_usage(response)
usage = response.usage
return nil unless usage
Riffer::Providers::TokenUsage.new(
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens
)
end
def extract_assistant_message(response, token_usage = nil)
text = response.text
tool_calls = extract_tool_calls(response)
Riffer::Messages::Assistant.new(
text,
tool_calls: tool_calls,
token_usage: token_usage
)
end
# Helper methods (provider-specific)
def convert_messages(messages)
messages.map do |msg|
case msg
when Riffer::Messages::System
{role: "system", content: msg.content}
when Riffer::Messages::User
{role: "user", content: msg.content}
when Riffer::Messages::Assistant
convert_assistant(msg)
when Riffer::Messages::Tool
{role: "tool", tool_call_id: msg.tool_call_id, content: msg.content}
end
end
end
def convert_assistant(msg)
{role: "assistant", content: msg.content, tool_calls: msg.tool_calls}
end
def convert_tool(tool)
{
name: tool.name,
description: tool.description,
parameters: tool.parameters_schema
}
end
def extract_tool_calls(response)
return [] unless response.tool_calls
response.tool_calls.map do |tc|
Riffer::Messages::Assistant::ToolCall.new(
id: tc.id,
call_id: tc.id,
name: tc.name,
arguments: tc.arguments
)
end
end
end
Client resolution
Riffer constructs providers with provider_class.new, so initialize takes no arguments; read credentials from configuration inside build_client. Riffer::Providers::Base provides a private client method for your execute_generate/execute_stream to call. It resolves, in order:
- A configured client — whatever
global_clientreturns: a client instance, or a no-argumentProcresolved on every call. Override that hook to read the client off your own configuration; it defaults tonil. - A memoized client from
build_client— implement this hook to build your SDK client from configured credentials.
This gives your provider the same “works out of the box, bring your own client in production” behavior as the built-ins. See Configuration → Provider Clients.
Using depends_on
For lazy loading of external gems:
class Riffer::Providers::MyProvider < Riffer::Providers::Base
def initialize
super
depends_on "my_provider_gem" # Only loaded when provider is used
end
private
def build_client
::MyProviderGem::Client.new(api_key: ENV["MY_PROVIDER_API_KEY"])
end
end
Registering Your Provider
Register your provider under an identifier.
Riffer::Providers::Repository.register(:my_provider) { Riffer::Providers::MyProvider }
The block resolves the provider class lazily, so it need not be loaded at registration time. Registration is idempotent — re-registering the same identifier replaces the previous factory — and a custom registration takes precedence over a built-in sharing the identifier, which lets you route an existing prefix (e.g. openai) through your own backend. Both find and key_for (used for pricing and observability keys) honor the registration. Remove one with Riffer::Providers::Repository.unregister(:my_provider).
Register during application boot — from a Rails initializer or equivalent — before you start handling requests. The registry is not synchronized for concurrent mutation, so treat registration as a one-time setup step rather than something you do from a live request path.
Using Your Provider
class MyAgent < Riffer::Agent
model 'my_provider/model-name'
end
Tool Support
Tools are converted in build_request_params and passed through to both execute_generate and execute_stream:
def build_request_params(messages, model, options)
tools = options[:tools]
params = {
model: model,
messages: convert_messages(messages)
}
if tools && !tools.empty?
params[:tools] = tools.map { |t| convert_tool(t) }
end
params
end
def convert_tool(tool)
{
name: tool.name,
description: tool.description,
parameters: tool.parameters_schema
}
end
Stream Events
Use the appropriate stream event classes in execute_stream:
# Text streaming
Riffer::StreamEvents::TextDelta.new("chunk of text")
Riffer::StreamEvents::TextDone.new("complete text")
# Tool calls
Riffer::StreamEvents::ToolCallDelta.new(
item_id: "id",
name: "tool_name",
arguments_delta: '{"partial":'
)
Riffer::StreamEvents::ToolCallDone.new(
item_id: "id",
call_id: "call_id",
name: "tool_name",
arguments: '{"complete":"args"}'
)
# Reasoning (if supported); see the Reasoning section for building the part
Riffer::StreamEvents::ReasoningDelta.new("thinking...")
Riffer::StreamEvents::ReasoningDone.new(part)
# Web search (if supported)
Riffer::StreamEvents::WebSearchStatus.new("searching", query: "search query")
Riffer::StreamEvents::WebSearchDone.new(
"search query",
sources: [{title: "Result", url: "https://example.com"}]
)
# Token usage (emit at end of stream)
Riffer::StreamEvents::TokenUsageDone.new(
token_usage: Riffer::Providers::TokenUsage.new(
input_tokens: 100,
output_tokens: 50
)
)
# Finish reason (emit at end of stream)
Riffer::StreamEvents::FinishReasonDone.new(
finish_reason: :stop,
raw_finish_reason: "done"
)
Token Usage Semantics
Riffer::Providers::TokenUsage is a normalized contract — map your provider’s raw usage into the bucket meanings defined in Messages — Token Usage Semantics rather than passing fields through untouched.
Finish Reasons
Riffer::Providers::FinishReason is the same kind of normalized contract — map your provider’s raw finish/stop value into the vocabulary defined in Messages — Finish Reasons (:stop, :length, :tool_calls, :content_filter, :context_window, :malformed_output, :error, :other), keeping the raw wire value alongside:
def extract_finish_reason(response)
raw = response.stop_reason
return nil unless raw
Riffer::Providers::FinishReason.new(
reason: {"done" => :stop, "max_len" => :length}.fetch(raw, :other),
raw: raw
)
end
The hook is optional — the base class defaults to nil (no finish reason reported). Map unmapped values to :other, never raise on a novel wire value.
For streaming, emit a FinishReasonDone event near the end of execute_stream:
yielder << Riffer::StreamEvents::FinishReasonDone.new(finish_reason: :stop, raw_finish_reason: "done")
Also have execute_stream raise Riffer::IncompleteStreamError when the stream ends without the provider’s terminal event, rather than returning normally. Otherwise a connection that drops mid-response looks identical to a finished one, and the agent loop accepts a truncated message as complete.
Reasoning
extract_reasoning is the optional hook for reasoning models — return the response’s thinking blocks as Riffer::Messages::Assistant::ReasoningParts and the base class attaches them to the assistant message, where your application can persist them and hand them back on the next turn:
def extract_reasoning(response)
response.thinking_blocks.map do |block|
Riffer::Messages::Assistant::ReasoningPart.new(
type: :encrypted,
data: block.data,
signature: block.signature,
format: "my-provider-v1"
)
end
end
The base class defaults to [], so a provider without reasoning stays valid.
Your adapter owns its format string: pick one value per wire shape, replay only the parts carrying a value you recognize, and skip the rest — history that travelled through another provider must never make a request fail. Never reorder or edit a part; the provider’s signature covers its exact bytes.
For streaming, emit one ReasoningDone per block, carrying the part so the agent loop can accumulate it:
part = Riffer::Messages::Assistant::ReasoningPart.new(type: :text, text: "complete reasoning", format: "my-provider-v1")
yielder << Riffer::StreamEvents::ReasoningDelta.new("thinking...")
yielder << Riffer::StreamEvents::ReasoningDone.new(part)
If your adapter surfaces reasoning text but cannot yet replay it, call yield_reasoning_done(yielder, text) instead. It wraps the text in a :text part with no format, which persists for display and is skipped on replay.
Tags
Every agent and judge call passes a :tags option: a flat String => String hash of the caller’s per-call tags plus the default tags. kind ("agent" or "judge") and agent (the agent or evaluator identifier) tell you who the call is on behalf of:
def build_request_params(messages, model, options)
tags = options[:tags] || {}
{
agent: tags["agent"],
user: tags["user_id"],
messages: convert_messages(messages),
model: model,
**options.except(:tools, :tags),
}
end
Map tags to your service’s native request field, or drop them, but don’t pass :tags on to an SDK verbatim.
Trace Provider Name
LLM-call and agent-run spans stamp gen_ai.provider.name from the semconv_provider_name class method. The default is your snake_cased class name; override it when a GenAI semconv well-known value exists for your provider:
def self.semconv_provider_name
"my_provider"
end
Error Handling
Raise appropriate Riffer errors:
def extract_assistant_message(response, token_usage = nil)
content = response.content
raise Riffer::Error, "No content returned from provider" if content.nil? || content.empty?
Riffer::Messages::Assistant.new(content, token_usage: token_usage)
rescue MyProviderGem::AuthError => e
raise Riffer::ArgumentError, "Authentication failed: #{e.message}"
end
Complete Example
# lib/riffer/providers/my_provider.rb
class Riffer::Providers::MyProvider < Riffer::Providers::Base
def initialize
super
depends_on "my_provider_gem"
end
private
# Hook methods
def build_client
::MyProviderGem::Client.new(api_key: ENV["MY_PROVIDER_API_KEY"])
end
def build_request_params(messages, model, options)
system_message = extract_system(messages)
conversation = messages.reject { |m| m.is_a?(Riffer::Messages::System) }
tools = options[:tools]
params = {
model: model,
messages: convert_messages(conversation),
system: system_message,
max_tokens: options[:max_tokens] || 4096,
**options.except(:tools, :max_tokens, :tags)
}
if tools && !tools.empty?
params[:tools] = tools.map { |t| convert_tool(t) }
end
params
end
def execute_generate(params)
client.create(**params)
end
def execute_stream(params, yielder)
accumulated_text = ""
client.stream(**params) do |event|
case event.type
when :text_delta
accumulated_text += event.text
yielder << Riffer::StreamEvents::TextDelta.new(event.text)
when :message_stop
yielder << Riffer::StreamEvents::TextDone.new(accumulated_text)
when :usage
yielder << Riffer::StreamEvents::TokenUsageDone.new(
token_usage: Riffer::Providers::TokenUsage.new(
input_tokens: event.usage.input_tokens,
output_tokens: event.usage.output_tokens
)
)
end
end
end
def extract_token_usage(response)
usage = response.usage
return nil unless usage
Riffer::Providers::TokenUsage.new(
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens
)
end
def extract_assistant_message(response, token_usage = nil)
text = ""
tool_calls = []
response.content.each do |block|
case block.type
when "text"
text = block.text
when "tool_use"
tool_calls << Riffer::Messages::Assistant::ToolCall.new(
id: block.id,
call_id: block.id,
name: block.name,
arguments: block.input.to_json
)
end
end
raise Riffer::Error, "No content returned from provider" if text.empty? && tool_calls.empty?
Riffer::Messages::Assistant.new(text, tool_calls: tool_calls, token_usage: token_usage)
end
# Helper methods
def extract_system(messages)
system_msg = messages.find { |m| m.is_a?(Riffer::Messages::System) }
system_msg&.content
end
def convert_messages(messages)
messages.map do |msg|
case msg
when Riffer::Messages::User
{role: "user", content: msg.content}
when Riffer::Messages::Assistant
{role: "assistant", content: msg.content}
when Riffer::Messages::Tool
{role: "user", content: [{type: "tool_result", tool_use_id: msg.tool_call_id, content: msg.content}]}
end
end
end
def convert_tool(tool)
{
name: tool.name,
description: tool.description,
input_schema: tool.parameters_schema
}
end
end