Skip to content

Switch to Responses API for OpenAI #325

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
18025f9
Introduce support of Responses API
redox Jul 23, 2025
0a1c980
useless
redox Jul 23, 2025
44ab739
Merge branch 'main' into responses-api
tpaulshippy Aug 5, 2025
ab2ac42
Start simplifying by moving responses into openai provider only
tpaulshippy Aug 5, 2025
79649c6
Introduce new module to hold chat completions API stuff
tpaulshippy Aug 5, 2025
fc3945b
Add support for attaching media
tpaulshippy Aug 5, 2025
99e4d9e
Refactor a bit to support audio inputs with fallback
tpaulshippy Aug 5, 2025
a693991
Restore use of complete
tpaulshippy Aug 6, 2025
d8ff718
Setup response schema for responses API
tpaulshippy Aug 6, 2025
447100a
Update with params spec
tpaulshippy Aug 6, 2025
f8375f5
Update cassettes for chat with_schema
tpaulshippy Aug 6, 2025
f48ba3f
Remove some extra params
tpaulshippy Aug 6, 2025
c0ae2aa
OpenAI responses API does not seem to provide token counts on chunks …
tpaulshippy Aug 6, 2025
d00cc38
Update error handling with responses
tpaulshippy Aug 6, 2025
928b1c1
Handle chunks from responses when streaming
tpaulshippy Aug 6, 2025
049c8ea
Rubocop fixes
tpaulshippy Aug 6, 2025
eb53d58
Update spec for responses
tpaulshippy Aug 6, 2025
4f68ebd
One more rubocop
tpaulshippy Aug 6, 2025
f50cb23
Clean up some methods we don't need to rename or don't need
tpaulshippy Aug 6, 2025
bf2c549
Remove extra namespaces
tpaulshippy Aug 6, 2025
40e17be
Merge branch 'main' into responses-api
tpaulshippy Aug 7, 2025
b0dace8
Update some cassettes
tpaulshippy Aug 7, 2025
367f341
Rubocop -A
tpaulshippy Aug 7, 2025
f25ffdd
Merge branch 'main' into responses-api
tpaulshippy Aug 8, 2025
ea03496
Merge main into responses-api
tpaulshippy Aug 13, 2025
371e3d2
Update some cassettes
tpaulshippy Aug 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/ruby_llm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
'ruby_llm' => 'RubyLLM',
'llm' => 'LLM',
'openai' => 'OpenAI',
'openai_base' => 'OpenAIBase',
'api' => 'API',
'deepseek' => 'DeepSeek',
'perplexity' => 'Perplexity',
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/deepseek.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module RubyLLM
module Providers
# DeepSeek API integration.
class DeepSeek < OpenAI
class DeepSeek < OpenAIBase
include DeepSeek::Chat

def api_base
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/gpustack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module RubyLLM
module Providers
# GPUStack API integration based on Ollama.
class GPUStack < OpenAI
class GPUStack < OpenAIBase
include GPUStack::Chat
include GPUStack::Models

Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/mistral.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module RubyLLM
module Providers
# Mistral API integration.
class Mistral < OpenAI
class Mistral < OpenAIBase
include Mistral::Chat
include Mistral::Models
include Mistral::Embeddings
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/ollama.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module RubyLLM
module Providers
# Ollama API integration.
class Ollama < OpenAI
class Ollama < OpenAIBase
include Ollama::Chat
include Ollama::Media

Expand Down
54 changes: 27 additions & 27 deletions lib/ruby_llm/providers/openai.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,41 @@

module RubyLLM
module Providers
# OpenAI API integration. Handles chat completion, function calling,
# and OpenAI's unique streaming format. Supports GPT-4, GPT-3.5,
# OpenAI API integration using the new Responses API. Handles response generation,
# function calling, and OpenAI's unique streaming format. Supports GPT-4, GPT-3.5,
# and other OpenAI models.
class OpenAI < Provider
include OpenAI::Chat
include OpenAI::Embeddings
include OpenAI::Models
include OpenAI::Streaming
include OpenAI::Tools
include OpenAI::Images
include OpenAI::Media
class OpenAI < OpenAIBase
include OpenAI::Response
include OpenAI::ResponseMedia

def api_base
@config.openai_api_base || 'https://api.openai.com/v1'
end
def audio_input?(messages)
messages.any? do |message|
next false unless message.respond_to?(:content) && message.content.respond_to?(:attachments)

def headers
{
'Authorization' => "Bearer #{@config.openai_api_key}",
'OpenAI-Organization' => @config.openai_organization_id,
'OpenAI-Project' => @config.openai_project_id
}.compact
message.content.attachments.any? { |attachment| attachment.type == :audio }
end
end

def maybe_normalize_temperature(temperature, model_id)
OpenAI::Capabilities.normalize_temperature(temperature, model_id)
end
def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Metrics/ParameterLists
@using_responses_api = !audio_input?(messages)

class << self
def capabilities
OpenAI::Capabilities
if @using_responses_api
render_response_payload(messages, tools: tools, temperature: temperature, model: model, stream: stream,
schema: schema)
else
super
end
end

def completion_url
@using_responses_api ? responses_url : super
end

def configuration_requirements
%i[openai_api_key]
def parse_completion_response(response)
if @using_responses_api
parse_respond_response(response)
else
super
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/openai/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def render_payload(messages, tools:, temperature:, model:, stream: false, schema
# Only include temperature if it's not nil (some models don't accept it)
payload[:temperature] = temperature unless temperature.nil?

payload[:tools] = tools.map { |_, tool| tool_for(tool) } if tools.any?
payload[:tools] = tools.map { |_, tool| chat_tool_for(tool) } if tools.any?

if schema
# Use strict mode from schema if specified, default to true
Expand Down
115 changes: 115 additions & 0 deletions lib/ruby_llm/providers/openai/response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# frozen_string_literal: true

module RubyLLM
module Providers
class OpenAI
# Response methods of the OpenAI API integration
module Response
def responses_url
'responses'
end

module_function

def render_response_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Metrics/ParameterLists
payload = {
model: model,
input: format_input(messages),
stream: stream
}

# Only include temperature if it's not nil (some models don't accept it)
payload[:temperature] = temperature unless temperature.nil?

payload[:tools] = tools.map { |_, tool| response_tool_for(tool) } if tools.any?

if schema
# Use strict mode from schema if specified, default to true
strict = schema[:strict] != false

payload[:text] = {
format: {
type: 'json_schema',
name: 'response',
schema: schema,
strict: strict
}
}
end

payload
end

def format_input(messages) # rubocop:disable Metrics/PerceivedComplexity
all_tool_calls = messages.flat_map do |m|
m.tool_calls&.values || []
end
messages.flat_map do |msg|
if msg.tool_call?
msg.tool_calls.map do |_, tc|
{
type: 'function_call',
call_id: tc.id,
name: tc.name,
arguments: JSON.generate(tc.arguments),
status: 'completed'
}
end
elsif msg.role == :tool
{
type: 'function_call_output',
call_id: all_tool_calls.detect { |tc| tc.id == msg.tool_call_id }&.id,
output: msg.content,
status: 'completed'
}
else
{
type: 'message',
role: format_role(msg.role),
content: ResponseMedia.format_content(msg.content),
status: 'completed'
}.compact
end
end
end

def format_role(role)
case role
when :system
'developer'
else
role.to_s
end
end

def parse_respond_response(response)
data = response.body
return if data.empty?

raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message')

outputs = data['output']
return unless outputs.any?

Message.new(
role: :assistant,
content: all_output_text(outputs),
tool_calls: parse_response_tool_calls(outputs),
input_tokens: data['usage']['input_tokens'],
output_tokens: data['usage']['output_tokens'],
model_id: data['model'],
raw: response
)
end

def all_output_text(outputs)
outputs.select { |o| o['type'] == 'message' }.flat_map do |o|
o['content'].filter_map do |c|
c['type'] == 'output_text' && c['text']
end
end.join("\n")
end
end
end
end
end
76 changes: 76 additions & 0 deletions lib/ruby_llm/providers/openai/response_media.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true

module RubyLLM
module Providers
class OpenAI
# Handles formatting of media content (images, audio) for OpenAI APIs
module ResponseMedia
module_function

def format_content(content)
return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
return content unless content.is_a?(Content)

parts = []
parts << format_text(content.text) if content.text

content.attachments.each do |attachment|
case attachment.type
when :image
parts << format_image(attachment)
when :pdf
parts << format_pdf(attachment)
when :audio
parts << format_audio(attachment)
when :text
parts << format_text_file(attachment)
else
raise UnsupportedAttachmentError, attachment.type
end
end

parts
end

def format_image(image)
{
type: 'input_image',
image_url: image.url? ? image.source : "data:#{image.mime_type};base64,#{image.encoded}"
}
end

def format_pdf(pdf)
{
type: 'input_file',
filename: pdf.filename,
file_data: "data:#{pdf.mime_type};base64,#{pdf.encoded}"
}
end

def format_text_file(text_file)
{
type: 'input_text',
text: Utils.format_text_file_for_llm(text_file)
}
end

def format_audio(audio)
{
type: 'input_audio',
input_audio: {
data: audio.encoded,
format: audio.mime_type.split('/').last
}
}
end

def format_text(text)
{
type: 'input_text',
text: text
}
end
end
end
end
end
Loading