Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
32 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
31fd54e
Merge branch 'main' into responses-api
tpaulshippy Aug 25, 2025
485a28b
Update cassettes
tpaulshippy Aug 25, 2025
02e1d5c
Unable to generate this cassette for some reason, just restore what w…
tpaulshippy Aug 25, 2025
9133c7c
Cleanup some comments and deprecated stuff
tpaulshippy Aug 27, 2025
bf4b381
Merge branch 'main' into responses-api
tpaulshippy Aug 27, 2025
6cda94f
Fix model id in responses API payload
tpaulshippy Aug 28, 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',
'chat_completions' => 'ChatCompletions',
'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 @@ -4,7 +4,7 @@ module RubyLLM
module Providers
# DeepSeek API integration.
module DeepSeek
extend OpenAI
extend OpenAI::ChatCompletions
extend DeepSeek::Chat

module_function
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 @@ -4,7 +4,7 @@ module RubyLLM
module Providers
# GPUStack API integration based on Ollama.
module GPUStack
extend OpenAI
extend OpenAI::ChatCompletions
extend GPUStack::Chat
extend 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 @@ -4,7 +4,7 @@ module RubyLLM
module Providers
# Mistral API integration.
module Mistral
extend OpenAI
extend OpenAI::ChatCompletions
extend Mistral::Chat
extend Mistral::Models
extend 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 @@ -4,7 +4,7 @@ module RubyLLM
module Providers
# Ollama API integration.
module Ollama
extend OpenAI
extend OpenAI::ChatCompletions
extend Ollama::Chat
extend Ollama::Media

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

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.
module OpenAI
extend Provider
extend OpenAI::Chat
extend OpenAI::Embeddings
extend OpenAI::Models
extend OpenAI::Streaming
extend OpenAI::Tools
extend OpenAI::Images
extend OpenAI::Media
extend OpenAI::ChatCompletions
extend OpenAI::Response
extend OpenAI::ResponseMedia

def self.extended(base)
base.extend(Provider)
base.extend(OpenAI::Chat)
base.extend(OpenAI::Embeddings)
base.extend(OpenAI::Models)
base.extend(OpenAI::Streaming)
base.extend(OpenAI::Tools)
base.extend(OpenAI::Images)
base.extend(OpenAI::Media)
base.extend(OpenAI::ChatCompletions)
base.extend(OpenAI::Response)
base.extend(OpenAI::ResponseMedia)
end

module_function

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

def headers(config)
{
'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 capabilities
OpenAI::Capabilities
# Override render_payload to conditionally route to chat completions or responses API
def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Metrics/ParameterLists
# Track which API we're using for later methods
@using_responses_api = !audio_input?(messages)

if @using_responses_api
# Use responses API for everything else
render_response_payload(messages, tools: tools, temperature: temperature, model: model, stream: stream,
schema: schema)
else
# Use chat completions for audio - call the original method from ChatCompletions
super
end
end

def slug
'openai'
# Override completion_url to conditionally route to the right endpoint
def completion_url
@using_responses_api ? responses_url : super
end

def configuration_requirements
%i[openai_api_key]
# Override parse_completion_response to use the right parser
def parse_completion_response(response)
if @using_responses_api
parse_respond_response(response)
else
super
end
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 @@ -22,7 +22,7 @@ def render_payload(messages, tools:, temperature:, model:, stream: false, schema
payload[:temperature] = temperature unless temperature.nil?

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

Expand Down
56 changes: 56 additions & 0 deletions lib/ruby_llm/providers/openai/chat_completions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

module RubyLLM
module Providers
module OpenAI
# OpenAI Chat Completions API integration. This module contains the original
# OpenAI chat completions functionality that is used by providers that extend
# the OpenAI-compatible API (DeepSeek, Mistral, OpenRouter, etc.)
module ChatCompletions
extend Provider
extend OpenAI::Chat
extend OpenAI::Embeddings
extend OpenAI::Models
extend OpenAI::Streaming
extend OpenAI::Tools
extend OpenAI::Images
extend OpenAI::Media

def self.extended(base)
base.extend(Provider)
base.extend(OpenAI::Chat)
base.extend(OpenAI::Embeddings)
base.extend(OpenAI::Models)
base.extend(OpenAI::Streaming)
base.extend(OpenAI::Tools)
base.extend(OpenAI::Images)
base.extend(OpenAI::Media)
end

def api_base(config)
config.openai_api_base || 'https://api.openai.com/v1'
end

def headers(config)
{
'Authorization' => "Bearer #{config.openai_api_key}",
'OpenAI-Organization' => config.openai_organization_id,
'OpenAI-Project' => config.openai_project_id
}.compact
end

def capabilities
OpenAI::Capabilities
end

def slug
'openai'
end

def configuration_requirements
%i[openai_api_key]
end
end
end
end
end
118 changes: 118 additions & 0 deletions lib/ruby_llm/providers/openai/response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# frozen_string_literal: true

module RubyLLM
module Providers
module 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?

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

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
77 changes: 77 additions & 0 deletions lib/ruby_llm/providers/openai/response_media.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

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

def format_content(content)
# Convert Hash/Array back to JSON string for API
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