Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions docs/_core_features/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,22 @@ response = chat.ask "What is the square root of 64? Answer with a JSON object wi
puts JSON.parse(response.content)
```

OpenAI can route chat requests through either Chat Completions or Responses while keeping the same `RubyLLM.chat` interface. The default `:auto` mode keeps existing Chat Completions behavior unless the request uses a Responses-only feature, such as a native `web_search` tool, a deep-research model, GPT-5 tool calls with reasoning enabled, or params like `previous_response_id`, `include`, `background`, `conversation`, `max_tool_calls`, `truncation`, or `text`.

```ruby
RubyLLM.configure do |config|
config.openai_api_mode = :auto # :auto, :chat_completions, or :responses
end

chat = RubyLLM.chat(model: "gpt-5.5")
.with_params(
openai_api_mode: :responses,
tools: [{ type: "web_search", search_context_size: "low" }]
)
```

The `openai_api_mode` key is consumed by RubyLLM and is not sent to the API. See OpenAI's [migration guide](https://platform.openai.com/docs/guides/migrate-to-responses), [web search guide](https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses), and [streaming guide](https://platform.openai.com/docs/guides/streaming-responses) for provider-specific behavior.

> **With great power comes great responsibility:** The `with_params` method can override any part of the request payload, including critical parameters like model, max_tokens, or tools. Use it carefully to avoid unintended behavior. Always verify that your overrides are compatible with the provider's API. To debug and see the exact request being sent, set the environment variable `RUBYLLM_DEBUG=true`.
{: .warning }

Expand Down
16 changes: 16 additions & 0 deletions docs/_core_features/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,22 @@ end

Provider metadata is passed through verbatim—turn on `RUBYLLM_DEBUG=true` if you want to inspect the final payload while experimenting.

### OpenAI Native Tools

For OpenAI Responses features such as hosted web search, pass the native tool definitions through `with_params(tools: ...)`. RubyLLM appends those native tools to any Ruby tool classes registered with `with_tool` when the request routes through Responses.

```ruby
chat = RubyLLM.chat(model: "gpt-5.5")
.with_tool(Weather)
.with_params(
tools: [{ type: "web_search", search_context_size: "low" }]
)

response = chat.ask("Find today's weather context and compare it with our local forecast.")
```

Ruby tool classes still use `with_tool`; `with_params(tools: ...)` is only for OpenAI-native tools such as `web_search`, `file_search`, or `code_interpreter`. See OpenAI's [Responses migration guide](https://platform.openai.com/docs/guides/migrate-to-responses) and [web search guide](https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses) for the provider-specific tool shapes.

## Advanced: Halting Tool Continuation

After a tool executes, the LLM normally continues the conversation to explain what happened. In rare cases, you might want to skip this and return the tool result directly.
Expand Down
13 changes: 13 additions & 0 deletions lib/ruby_llm/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
module RubyLLM
# Global configuration for RubyLLM
class Configuration
OPENAI_API_MODES = %i[auto chat_completions responses].freeze

class << self
# Declare a single configuration option.
def option(key, default = nil)
Expand Down Expand Up @@ -37,6 +39,7 @@ def defaults = @defaults ||= {}
option :default_moderation_model, 'omni-moderation-latest'
option :default_image_model, 'gpt-image-1.5'
option :default_transcription_model, 'whisper-1'
option :openai_api_mode, :auto

option :model_registry_file, -> { File.expand_path('models.json', __dir__) }
option :model_registry_class, 'Model'
Expand Down Expand Up @@ -77,5 +80,15 @@ def log_regexp_timeout=(value)
@log_regexp_timeout = value
end
end

def openai_api_mode=(value)
mode = (value || :auto).to_sym
unless OPENAI_API_MODES.include?(mode)
raise ArgumentError,
"Invalid openai_api_mode: #{value.inspect}. Valid values are: #{OPENAI_API_MODES.join(', ')}"
end

@openai_api_mode = mode
end
end
end
184 changes: 184 additions & 0 deletions lib/ruby_llm/providers/openai.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,29 @@ module RubyLLM
module Providers
# OpenAI API integration.
class OpenAI < Provider
RESPONSE_ONLY_PARAMS = %i[
previous_response_id
include
background
conversation
max_tool_calls
truncation
text
].freeze

RESPONSE_TOOL_TYPES = %w[
code_interpreter
computer_use_preview
file_search
image_generation
local_shell
mcp
web_search
web_search_preview
].freeze

include OpenAI::Chat
include OpenAI::Responses
include OpenAI::Embeddings
include OpenAI::Models
include OpenAI::Moderation
Expand All @@ -30,6 +52,83 @@ def maybe_normalize_temperature(temperature, model)
OpenAI::Temperature.normalize(temperature, model.id)
end

# rubocop:disable Metrics/ParameterLists
def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
tool_prefs: nil, &)
request_params = params.dup
requested_mode = extract_openai_api_mode!(request_params)
routing_context = { messages:, model:, params: request_params, tools:, thinking: }
@using_responses_api = native_openai_provider? && use_responses_api?(requested_mode, routing_context)
validate_responses_attachments!(messages, requested_mode)

native_tools = @using_responses_api ? extract_native_response_tools!(request_params) : nil
normalized_temperature = maybe_normalize_temperature(temperature, model)
payload_options = {
tools: tools,
tool_prefs: tool_prefs,
temperature: normalized_temperature,
model: model,
stream: block_given?,
schema: schema,
thinking: thinking
}
payload_options[:native_tools] = native_tools if @using_responses_api

payload = Utils.deep_merge(
render_payload(messages, **payload_options),
request_params
)

if block_given?
stream_response @connection, payload, headers, &
else
sync_response @connection, payload, headers
end
end
# rubocop:enable Metrics/ParameterLists

def completion_url
@using_responses_api ? responses_url : chat_completions_url
end

# rubocop:disable Metrics/ParameterLists
def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil,
tool_prefs: nil, native_tools: nil)
if @using_responses_api
render_response_payload(
messages,
tools: tools,
native_tools: native_tools,
tool_prefs: tool_prefs,
temperature: temperature,
model: model,
stream: stream,
schema: schema,
thinking: thinking
)
else
render_chat_payload(
messages,
tools: tools,
tool_prefs: tool_prefs,
temperature: temperature,
model: model,
stream: stream,
schema: schema,
thinking: thinking
)
end
end
# rubocop:enable Metrics/ParameterLists

def parse_completion_response(response)
if @using_responses_api
parse_response_response(response)
else
parse_chat_completion_response(response)
end
end

class << self
def capabilities
OpenAI::Capabilities
Expand All @@ -39,6 +138,7 @@ def configuration_options
%i[
openai_api_key
openai_api_base
openai_api_mode
openai_organization_id
openai_project_id
openai_use_system_role
Expand All @@ -49,6 +149,90 @@ def configuration_requirements
%i[openai_api_key]
end
end

private

def extract_openai_api_mode!(params)
value = params.delete(:openai_api_mode) || params.delete('openai_api_mode') || @config.openai_api_mode
normalize_openai_api_mode(value)
end

def normalize_openai_api_mode(value)
mode = (value || :auto).to_sym
return mode if Configuration::OPENAI_API_MODES.include?(mode)

raise ArgumentError,
"Invalid openai_api_mode: #{value.inspect}. " \
"Valid values are: #{Configuration::OPENAI_API_MODES.join(', ')}"
end

def use_responses_api?(requested_mode, routing_context)
return true if requested_mode == :responses
return false if requested_mode == :chat_completions
return false if audio_input?(routing_context[:messages])

responses_model?(routing_context[:model]) ||
native_response_tools?(routing_context[:params]) ||
responses_only_params?(routing_context[:params]) ||
responses_required_for_reasoning_tools?(
routing_context[:model],
routing_context[:tools],
routing_context[:thinking]
)
end

def native_openai_provider?
instance_of?(OpenAI)
end

def responses_model?(model)
model.id.to_s.include?('deep-research')
end

def responses_required_for_reasoning_tools?(model, tools, thinking)
return false unless tools.any? && resolve_effort(thinking)

model.id.to_s.start_with?('gpt-5')
end

def responses_only_params?(params)
RESPONSE_ONLY_PARAMS.any? { |key| params.key?(key) || params.key?(key.to_s) }
end

def native_response_tools?(params)
tools = params[:tools] || params['tools']
Utils.to_safe_array(tools).any? { |tool| native_response_tool?(tool) }
end

def native_response_tool?(tool)
return false unless tool.is_a?(Hash)

type = (tool[:type] || tool['type']).to_s
return true if RESPONSE_TOOL_TYPES.include?(type)

type == 'function' &&
(tool.key?(:name) || tool.key?('name')) &&
!(tool.key?(:function) || tool.key?('function'))
end

def extract_native_response_tools!(params)
tools = params.delete(:tools) || params.delete('tools')
Utils.to_safe_array(tools).select { |tool| native_response_tool?(tool) }
end

def validate_responses_attachments!(messages, requested_mode)
return unless @using_responses_api && audio_input?(messages)
return unless requested_mode == :responses

raise UnsupportedAttachmentError, 'OpenAI Responses API does not support audio inputs yet'
end

def audio_input?(messages)
messages.any? do |message|
content = message.content
content.respond_to?(:attachments) && content.attachments.any? { |attachment| attachment.type == :audio }
end
end
end
end
end
26 changes: 22 additions & 4 deletions lib/ruby_llm/providers/openai/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,29 @@ module Providers
class OpenAI
# Chat methods of the OpenAI API integration
module Chat
def completion_url
def chat_completions_url
'chat/completions'
end

module_function

# rubocop:disable Metrics/ParameterLists,Metrics/PerceivedComplexity
# rubocop:disable Metrics/ParameterLists,Metrics/PerceivedComplexity,Lint/UnusedMethodArgument
def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
thinking: nil, tool_prefs: nil)
thinking: nil, tool_prefs: nil, native_tools: nil)
render_chat_payload(
messages,
tools: tools,
temperature: temperature,
model: model,
stream: stream,
schema: schema,
thinking: thinking,
tool_prefs: tool_prefs
)
end

def render_chat_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
thinking: nil, tool_prefs: nil)
tool_prefs ||= {}
payload = {
model: model.id,
Expand Down Expand Up @@ -49,9 +63,13 @@ def render_payload(messages, tools:, temperature:, model:, stream: false, schema
payload[:stream_options] = { include_usage: true } if stream
payload
end
# rubocop:enable Metrics/ParameterLists,Metrics/PerceivedComplexity
# rubocop:enable Metrics/ParameterLists,Metrics/PerceivedComplexity,Lint/UnusedMethodArgument

def parse_completion_response(response)
parse_chat_completion_response(response)
end

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

Expand Down
Loading
Loading