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)
}
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)
Riffer::StreamEvents::ReasoningDelta.new("thinking...")
Riffer::StreamEvents::ReasoningDone.new("complete reasoning")
# 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, :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")
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)
}
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