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:

  1. A configured client — whatever global_client returns: a client instance, or a no-argument Proc resolved on every call. Override that hook to read the client off your own configuration; it defaults to nil.
  2. 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