Module: RubyLLM::Providers::OpenAIResponses::Chat

Included in:
RubyLLM::Providers::OpenAIResponses
Defined in:
lib/ruby_llm/providers/openai_responses/chat.rb

Overview

Chat completion methods for the OpenAI Responses API. Handles converting RubyLLM messages to Responses API format and parsing responses.

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.apply_tools(payload, tools, tool_prefs) ⇒ Object

Apply tools and tool preferences to the payload.



43
44
45
46
47
48
49
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 43

def apply_tools(payload, tools, tool_prefs)
  return unless tools.any?

  payload[:tools] = tools.map { |_, tool| Tools.tool_for(tool) }
  payload[:tool_choice] = build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
  payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
end

.build_schema_format(schema) ⇒ Object

Build the Responses API text format block from a schema. Schema arrives pre-normalized as { name:, schema:, strict: } from RubyLLM::Chat.with_schema (v1.13+), or as a raw hash (legacy).



66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 66

def build_schema_format(schema)
  schema_name = schema[:name] || 'response'
  schema_def = schema[:schema] || schema
  strict = schema.key?(:strict) ? schema[:strict] : true

  {
    format: {
      type: 'json_schema',
      name: schema_name,
      schema: schema_def,
      strict: strict
    }
  }
end

.build_tool_choice(choice) ⇒ Object

Convert a RubyLLM tool choice symbol to the Responses API format. Responses API accepts “auto”, “required”, “none”, or { type: “function”, name: “fn_name” } for a specific function.



54
55
56
57
58
59
60
61
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 54

def build_tool_choice(choice)
  case choice
  when :auto, :none, :required
    choice.to_s
  else
    { type: 'function', name: choice.to_s }
  end
end

.extract_last_response_id(messages) ⇒ Object



81
82
83
84
85
86
87
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 81

def extract_last_response_id(messages)
  messages
    .select { |m| m.role == :assistant && m.respond_to?(:response_id) }
    .map(&:response_id)
    .compact
    .last
end

.extract_output_text(output) ⇒ Object



237
238
239
240
241
242
243
244
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 237

def extract_output_text(output)
  output
    .select { |item| item['type'] == 'message' }
    .flat_map { |item| item['content'] || [] }
    .select { |c| c['type'] == 'output_text' }
    .map { |c| c['text'] }
    .join
end

.extract_text_content(content) ⇒ Object



215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 215

def extract_text_content(content)
  case content
  when String
    content
  when RubyLLM::Content
    content.text
  when Hash
    content[:text] || content['text']
  else
    content.to_s
  end
end

.extract_tool_calls(output) ⇒ Object



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 246

def extract_tool_calls(output)
  function_calls = output.select { |item| item['type'] == 'function_call' }
  return nil if function_calls.empty?

  function_calls.to_h do |fc|
    [
      fc['call_id'],
      ToolCall.new(
        id: fc['call_id'],
        name: fc['name'],
        arguments: parse_arguments(fc['arguments'])
      )
    ]
  end
end

.format_input(messages) ⇒ Object

rubocop:disable Metrics/MethodLength



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 138

def format_input(messages) # rubocop:disable Metrics/MethodLength
  result = []

  messages.each do |msg|
    if msg.tool_call_id
      # Tool result message - function_call_output type
      result << {
        type: 'function_call_output',
        call_id: msg.tool_call_id,
        output: extract_text_content(msg.content)
      }
    elsif msg.tool_calls&.any?
      # Assistant message with tool calls
      # First add any text content as a message
      text = extract_text_content(msg.content)
      if text && !text.empty?
        result << {
          type: 'message',
          role: 'assistant',
          content: text
        }
      end

      # Then add each function call as a separate item
      msg.tool_calls.each_value do |tc|
        result << {
          type: 'function_call',
          call_id: tc.id,
          name: tc.name,
          arguments: tc.arguments.is_a?(String) ? tc.arguments : JSON.generate(tc.arguments)
        }
      end
    else
      # Regular message
      result << {
        type: 'message',
        role: format_role(msg.role),
        content: format_message_content(msg.content, nil)
      }
    end
  end

  result
end

.format_message_content(content, tool_calls = nil) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 183

def format_message_content(content, tool_calls = nil)
  parts = []

  # Add text content
  text = extract_text_content(content)
  parts << { type: 'input_text', text: text } if text && !text.empty?

  # Add attachments if present
  if content.is_a?(RubyLLM::Content)
    content.attachments.each do |attachment|
      parts << Media.format_attachment(attachment)
    end
  end

  # Add tool calls if present (for assistant messages)
  if tool_calls&.any?
    tool_calls.each_value do |tc|
      parts << {
        type: 'function_call',
        call_id: tc.id,
        name: tc.name,
        arguments: tc.arguments.is_a?(String) ? tc.arguments : JSON.generate(tc.arguments)
      }
    end
  end

  # Return simple text for single text content
  return parts.first[:text] if parts.length == 1 && parts.first[:type] == 'input_text'

  parts
end

.format_role(role) ⇒ Object



228
229
230
231
232
233
234
235
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 228

def format_role(role)
  case role
  when :system then 'developer'
  when :assistant then 'assistant'
  when :tool then 'user' # Tool results come from user perspective
  else role.to_s
  end
end

.parse_arguments(arguments) ⇒ Object



262
263
264
265
266
267
268
269
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 262

def parse_arguments(arguments)
  return {} if arguments.nil? || arguments.empty?
  return arguments if arguments.is_a?(Hash)

  JSON.parse(arguments)
rescue JSON::ParserError
  { raw: arguments }
end

.parse_completion_response(response) ⇒ Object

Raises:

  • (RubyLLM::Error)


104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 104

def parse_completion_response(response)
  data = response.body
  return if data.nil? || data.empty?

  data = JSON.parse(data) if data.is_a?(String)

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

  output = data['output'] || []

  # Extract text content from output
  content = extract_output_text(output)

  # Extract tool calls from function_call outputs
  tool_calls = extract_tool_calls(output)

  usage = data['usage'] || {}
  cached_tokens = usage.dig('input_tokens_details', 'cached_tokens')

  Message.new(
    role: :assistant,
    content: content,
    tool_calls: tool_calls,
    input_tokens: usage['input_tokens'],
    output_tokens: usage['output_tokens'],
    cached_tokens: cached_tokens,
    cache_creation_tokens: 0,
    model_id: data['model'],
    response_id: data['id'],
    built_in_tool_events: BuiltInTools.extract_events(output),
    raw: response
  )
end

.render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil, tool_prefs: nil) ⇒ Object

rubocop:disable Metrics/ParameterLists



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 16

def render_payload(messages, tools:, temperature:, model:, stream: false,
                   schema: nil, thinking: nil, tool_prefs: nil) # rubocop:disable Lint/UnusedMethodArgument
  tool_prefs ||= {}
  system_messages, non_system_messages = messages.partition { |m| m.role == :system }

  instructions = system_messages.map { |m| extract_text_content(m.content) }.join("\n\n")

  last_response_id = extract_last_response_id(messages)
  input_messages = unchained_messages(non_system_messages, last_response_id)

  payload = {
    model: model.id,
    input: format_input(input_messages),
    stream: stream
  }

  payload[:instructions] = instructions unless instructions.empty?
  payload[:temperature] = temperature unless temperature.nil?
  apply_tools(payload, tools, tool_prefs)
  payload[:text] = build_schema_format(schema) if schema
  payload[:previous_response_id] = last_response_id if last_response_id

  payload
end

.unchained_messages(messages, last_response_id) ⇒ Object

When chaining via previous_response_id, the API expects only the new items in ‘input` – the rest already lives in the server-side response chain. Sending the full history every turn appends it to that chain and causes O(N^2) input_tokens growth. See issue #10.



93
94
95
96
97
98
99
100
101
102
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 93

def unchained_messages(messages, last_response_id)
  return messages unless last_response_id

  anchor = messages.rindex do |m|
    m.role == :assistant && m.respond_to?(:response_id) && m.response_id == last_response_id
  end
  return messages unless anchor

  messages[(anchor + 1)..] || []
end

Instance Method Details

#completion_urlObject



9
10
11
# File 'lib/ruby_llm/providers/openai_responses/chat.rb', line 9

def completion_url
  'responses'
end