Providers · 04

OpenAI Provider

The OpenAI provider connects to OpenAI’s API for GPT models.

Installation

Add the OpenAI gem to your Gemfile:

gem 'openai'

Configuration

Set your API key globally:

Riffer.configure do |config|
  config.openai.api_key = ENV['OPENAI_API_KEY']
end

Both api_key and base_url resolve in order: Riffer.config.openai.* → the OpenAI SDK’s own OPENAI_API_KEY / OPENAI_BASE_URL lookup. Leaving one unset in riffer means the SDK resolves it, so an OPENAI_BASE_URL gateway is honored without any riffer configuration.

For anything beyond the API key — timeouts, retries, proxies — supply your own OpenAI::Client:

Riffer.configure do |config|
  config.openai.client = OpenAI::Client.new(
    api_key: ENV['OPENAI_API_KEY'],
    timeout: 30,
    max_retries: 4
  )
end

The setting accepts a client instance or a no-argument Proc, resolved on every LLM call — see Configuration → Provider Clients.

For OpenAI-compatible servers (LiteLLM, vLLM, corporate gateways), configure a base_url:

Riffer.configure do |config|
  config.openai.api_key = ENV['GATEWAY_KEY']
  config.openai.base_url = 'http://localhost:4000/v1'
end

Supported Models

Use any OpenAI model in the openai/model format:

model 'openai/gpt-5.4'
model 'openai/gpt-5-mini'

Model Options

temperature

Controls randomness (0.0-2.0):

model_options temperature: 0.7

max_tokens

Maximum tokens in response:

model_options max_tokens: 4096

reasoning

Enables extended thinking (for supported models):

model_options reasoning: 'medium'  # 'low', 'medium', or 'high'

When reasoning is enabled, you’ll receive ReasoningDelta and ReasoningDone events during streaming.

Reasoning Replay

Each reasoning item in a Responses API output becomes reasoning parts on the assistant message, on both generate_text and stream_text, all tagged format: "openai-v1" and carrying the item’s id:

Reasoning item field ReasoningPart
each summary entry a :summary part with its text
each content entry a :text part with its text
encrypted_content one :encrypted part with it as data

The :encrypted part is always present, even when the response carried no encrypted_content, so the item’s id is kept. When streaming, the parts are yielded as ReasoningDone events once the item completes; summary text still arrives as ReasoningDelta events before that.

On the next request, parts sharing an id go back as one reasoning item, ahead of the assistant’s text and function calls, in their original order and unchanged. This lets reasoning models carry their reasoning across turns and tool-call loops. You don’t have to do anything as long as the assistant messages stay in the history; if you persist sessions, keep the reasoning key when you store them.

With store left on (the API default), OpenAI can resolve a replayed item from its id alone. With model_options store: false, the item can only be replayed from its encrypted payload, so request it explicitly:

model_options reasoning: 'medium', store: false, include: ['reasoning.encrypted_content']

Only parts tagged openai-v1 are sent. Reasoning from other providers stays on the messages but is left out, including OpenRouter’s openai-responses-v1 parts, which are encrypted under OpenRouter’s organization rather than yours.

Enable server-side web search using OpenAI’s web_search_preview tool. Pass true to use defaults or a hash to merge with the tool definition:

# Enable with defaults
model_options web_search: true

# With custom configuration
model_options web_search: {search_context_size: "medium"}

Example

Riffer.configure do |config|
  config.openai.api_key = ENV['OPENAI_API_KEY']
end

class CodeReviewAgent < Riffer::Agent
  model 'openai/gpt-5-mini'
  instructions 'You are a code reviewer. Provide constructive feedback.'
  model_options temperature: 0.3, reasoning: 'medium'
end

agent = CodeReviewAgent.new
puts agent.generate("Review this code: def add(a,b); a+b; end")

Streaming

agent.stream("Explain Ruby blocks").each do |event|
  case event
  when Riffer::StreamEvents::TextDelta
    print event.content
  when Riffer::StreamEvents::ReasoningDelta
    # Extended thinking content
    print "[thinking] #{event.content}"
  when Riffer::StreamEvents::ReasoningDone
    puts "\n[reasoning complete]"
  end
end

Tool Calling

OpenAI provider converts tools to function calling format with strict mode:

class CalculatorTool < Riffer::Tool
  description "Performs basic math operations"

  params do
    required :operation, String, enum: ["add", "subtract", "multiply", "divide"]
    required :a, Float, description: "First number"
    required :b, Float, description: "Second number"
  end

  def call(context:, operation:, a:, b:)
    result = case operation
    when "add" then a + b
    when "subtract" then a - b
    when "multiply" then a * b
    when "divide" then a / b
    end
    text(result.to_s)
  end
end

class MathAgent < Riffer::Agent
  model 'openai/gpt-5-mini'
  uses_tools [CalculatorTool]
end

Web search allows the model to search the web for up-to-date information. When enabled, the provider injects the web_search_preview tool into the request.

class SearchAgent < Riffer::Agent
  model 'openai/gpt-5-mini'
  model_options web_search: true
end

agent = SearchAgent.new
agent.stream("What's the latest Ruby release?").each do |event|
  case event
  when Riffer::StreamEvents::WebSearchStatus
    # OpenAI emits a full status sequence:
    # "in_progress" → "searching" → "open_page" → "completed"
    puts "[search: #{event.status}]"
    puts "  query: #{event.query}" if event.query
    puts "  url: #{event.url}" if event.url
  when Riffer::StreamEvents::WebSearchDone
    puts "[search complete: #{event.query}]"
  when Riffer::StreamEvents::TextDelta
    print event.content
  end
end

Note: OpenAI sources include url but not title. Each source in the sources array will have title: nil.

Message Format

The provider converts Riffer messages to OpenAI format:

Riffer Message OpenAI Role
System developer
User user
Assistant assistant
Tool function_call_output

Direct Provider Usage

provider = Riffer::Providers::OpenAI.new

response = provider.generate_text(
  prompt: "Hello!",
  model: "gpt-5-mini",
  temperature: 0.7
)

puts response.content