Module: Smith::Providers::OpenAI::Responses

Defined in:
lib/smith/providers/openai/responses.rb

Overview

Responses API adapter consumed by Smith::Providers::OpenAI::Routing when Smith::Models::Normalizer flags a request for routing via OpenAI /v1/responses (typically: gpt-5 family + tools + thinking).

Constant Summary collapse

RESPONSE_REASONING_TEXT_TYPES =

—- Vendored verbatim from PR #770 responses.rb —————–

%w[summary_text output_text].freeze

Class Method Summary collapse

Class Method Details

.apply_response_schema(payload, schema) ⇒ Object



191
192
193
194
195
196
197
198
199
200
# File 'lib/smith/providers/openai/responses.rb', line 191

def self.apply_response_schema(payload, schema)
  payload[:text] = {
    format: {
      type: "json_schema",
      name: schema[:name],
      schema: schema[:schema],
      strict: schema[:strict]
    }
  }
end

.apply_response_thinking(payload, thinking) ⇒ Object



202
203
204
205
# File 'lib/smith/providers/openai/responses.rb', line 202

def self.apply_response_thinking(payload, thinking)
  effort = resolve_effort(thinking)
  payload[:reasoning] = { effort: effort } if effort
end

.apply_response_tools(payload, tools, native_tools, tool_prefs) ⇒ Object



181
182
183
184
185
186
187
188
189
# File 'lib/smith/providers/openai/responses.rb', line 181

def self.apply_response_tools(payload, tools, native_tools, tool_prefs)
  response_tools = tools.map { |_, tool| ToolsExtensions.response_tool_for(tool) }
  response_tools.concat(::RubyLLM::Utils.to_safe_array(native_tools))
  payload[:tools] = response_tools if response_tools.any?
  unless tool_prefs[:choice].nil?
    payload[:tool_choice] = ToolsExtensions.build_response_tool_choice(tool_prefs[:choice])
  end
  payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
end

.complete(provider, messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil, tool_prefs: nil, &block) ⇒ Object

SMITH-AUTHORED entry point. Routing prepend calls this with the OpenAI provider instance + the same kwargs ‘complete` would receive. Renders the /v1/responses payload using the vendored helpers, POSTs via the provider’s Faraday connection, parses the response back into a RubyLLM::Message.

Streaming is intentionally NOT supported in this initial vendor because Smith’s workflow execution path doesn’t use it. Block-given calls raise NotImplementedError with a clear message so the host can either disable streaming or fall back to ‘openai_api_mode = :off`.



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/smith/providers/openai/responses.rb', line 59

def self.complete(provider, messages, tools:, temperature:, model:, params: {}, headers: {},
                  schema: nil, thinking: nil, tool_prefs: nil, &block)
  if block
    raise NotImplementedError,
          "Smith::Providers::OpenAI::Responses does not yet support streaming. " \
          "Streaming over /v1/responses needs a separate stream_response port from PR #770. " \
          "Workaround: pass no block (sync only), or set Smith.config.openai_api_mode = :off " \
          "to route via chat-completions with graceful tool-dropping."
  end

  payload = render_response_payload(
    messages,
    tools: tools,
    temperature: temperature,
    model: model,
    stream: false,
    schema: schema,
    thinking: thinking,
    tool_prefs: tool_prefs
  )
  payload = ::RubyLLM::Utils.deep_merge(payload, params) unless params.empty?

  connection = provider.instance_variable_get(:@connection)
  provider_headers = provider.send(:headers)
  merged_headers = provider_headers.merge(headers)

  http_response = connection.post(responses_url, payload) do |req|
    merged_headers.each { |k, v| req.headers[k] = v }
  end

  parse_response_response(http_response, provider: provider)
end

.format_response_attachment(attachment) ⇒ Object



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/smith/providers/openai/responses.rb', line 222

def self.format_response_attachment(attachment)
  case attachment.type
  when :image
    {
      type: "input_image",
      image_url: attachment.url? ? attachment.source.to_s : attachment.for_llm
    }
  when :pdf
    {
      type: "input_file",
      filename: attachment.filename,
      file_data: attachment.for_llm
    }
  when :text
    format_response_text(attachment.for_llm)
  when :audio
    raise ::RubyLLM::UnsupportedAttachmentError, "OpenAI Responses API does not support audio inputs yet"
  else
    raise ::RubyLLM::UnsupportedAttachmentError, attachment.type
  end
end

.format_response_content(content) ⇒ Object



207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/smith/providers/openai/responses.rb', line 207

def self.format_response_content(content)
  return content.value if content.is_a?(::RubyLLM::Content::Raw)
  return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
  return content unless content.is_a?(::RubyLLM::Content)

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

  content.attachments.each do |attachment|
    parts << format_response_attachment(attachment)
  end

  parts
end

.format_response_input(messages) ⇒ Object

rubocop:enable Metrics/ParameterLists



111
112
113
114
115
116
117
118
119
120
121
# File 'lib/smith/providers/openai/responses.rb', line 111

def self.format_response_input(messages)
  messages.flat_map do |message|
    if message.tool_call?
      format_response_tool_calls(message.tool_calls)
    elsif message.role == :tool
      format_response_tool_result(message)
    else
      format_response_message(message)
    end
  end
end

.format_response_message(message, provider: nil) ⇒ Object



154
155
156
157
158
159
160
# File 'lib/smith/providers/openai/responses.rb', line 154

def self.format_response_message(message, provider: nil)
  {
    type: "message",
    role: format_role(message.role, provider: provider),
    content: format_response_content(message.content)
  }.compact
end

.format_response_text(text) ⇒ Object



244
245
246
247
248
249
# File 'lib/smith/providers/openai/responses.rb', line 244

def self.format_response_text(text)
  {
    type: "input_text",
    text: text
  }
end

.format_response_tool_calls(tool_calls) ⇒ Object



162
163
164
165
166
167
168
169
170
171
# File 'lib/smith/providers/openai/responses.rb', line 162

def self.format_response_tool_calls(tool_calls)
  tool_calls.map do |_, tool_call|
    {
      type: "function_call",
      call_id: tool_call.id,
      name: tool_call.name,
      arguments: JSON.generate(tool_call.arguments || {})
    }
  end
end

.format_response_tool_result(message) ⇒ Object



173
174
175
176
177
178
179
# File 'lib/smith/providers/openai/responses.rb', line 173

def self.format_response_tool_result(message)
  {
    type: "function_call_output",
    call_id: message.tool_call_id,
    output: response_tool_output(message.content)
  }
end

.format_role(role, provider: nil) ⇒ Object

—- SMITH-AUTHORED helpers (inlined from PR #770 chat.rb) ——-

Upstream PR #770 keeps these on the chat module (which is mixed into the provider class so they’re available as instance methods with access to @config). Smith’s vendored Responses module is standalone (it can’t read @config), so these helpers are inlined as class methods with the provider passed in where



297
298
299
300
301
302
303
304
305
# File 'lib/smith/providers/openai/responses.rb', line 297

def self.format_role(role, provider: nil)
  case role
  when :system
    config = provider&.instance_variable_get(:@config)
    (config && config.respond_to?(:openai_use_system_role) && config.openai_use_system_role) ? "system" : "developer"
  else
    role.to_s
  end
end

.parse_response_response(response, provider: nil) ⇒ Object

SMITH-AUTHORED kwarg addition: ‘provider:` is passed in so this standalone module can read `@config.openai_use_system_role` for `format_role`. Upstream method lives on the provider instance and reads `@config` directly; Smith’s standalone module needs the indirection.

Raises:

  • (::RubyLLM::Error)


128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/smith/providers/openai/responses.rb', line 128

def self.parse_response_response(response, provider: nil) # rubocop:disable Lint/UnusedMethodArgument
  data = response.body
  return if data.empty?

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

  outputs = data["output"] || []
  return if outputs.empty?

  usage = data["usage"] || {}

  ::RubyLLM::Message.new(
    role: :assistant,
    content: response_output_text(data),
    thinking: ::RubyLLM::Thinking.build(text: response_reasoning_text(outputs)),
    tool_calls: ToolsExtensions.parse_response_tool_calls(outputs),
    input_tokens: usage["input_tokens"],
    output_tokens: usage["output_tokens"],
    cached_tokens: usage.dig("input_tokens_details", "cached_tokens"),
    cache_creation_tokens: usage.dig("input_tokens_details", "cache_write_tokens") || 0,
    thinking_tokens: usage.dig("output_tokens_details", "reasoning_tokens"),
    model_id: data["model"],
    raw: response
  )
end

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

rubocop:disable Metrics/ParameterLists



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/smith/providers/openai/responses.rb', line 93

def self.render_response_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
                                 thinking: nil, tool_prefs: nil, native_tools: nil)
  tool_prefs ||= {}
  payload = {
    model: model.id,
    input: format_response_input(messages),
    stream: stream,
    store: false
  }

  payload[:temperature] = temperature unless temperature.nil?
  apply_response_tools(payload, tools, native_tools, tool_prefs)
  apply_response_schema(payload, schema) if schema
  apply_response_thinking(payload, thinking)
  payload
end

.resolve_effort(thinking) ⇒ Object



307
308
309
310
311
# File 'lib/smith/providers/openai/responses.rb', line 307

def self.resolve_effort(thinking)
  return nil unless thinking

  thinking.respond_to?(:effort) ? thinking.effort : thinking
end

.response_output_text(data) ⇒ Object



260
261
262
263
264
265
266
# File 'lib/smith/providers/openai/responses.rb', line 260

def self.response_output_text(data)
  output_text = data["output_text"]
  return output_text if output_text.is_a?(String) && !output_text.empty?

  text = response_output_text_parts(data["output"]).join
  text.empty? ? nil : text
end

.response_output_text_parts(outputs) ⇒ Object



268
269
270
271
272
273
274
# File 'lib/smith/providers/openai/responses.rb', line 268

def self.response_output_text_parts(outputs)
  ::RubyLLM::Utils.to_safe_array(outputs).select { |output| output["type"] == "message" }.flat_map do |output|
    ::RubyLLM::Utils.to_safe_array(output["content"]).filter_map do |content|
      content["text"] if content["type"] == "output_text" && content["text"].is_a?(String)
    end
  end
end

.response_reasoning_text(outputs) ⇒ Object



276
277
278
279
280
281
282
283
284
285
286
# File 'lib/smith/providers/openai/responses.rb', line 276

def self.response_reasoning_text(outputs)
  text = outputs.select { |output| output["type"] == "reasoning" }.flat_map do |output|
    ::RubyLLM::Utils.to_safe_array(output["summary"] || output["content"]).filter_map do |content|
      if RESPONSE_REASONING_TEXT_TYPES.include?(content["type"]) && content["text"].is_a?(String)
        content["text"]
      end
    end
  end.join

  text.empty? ? nil : text
end

.response_tool_output(content) ⇒ Object



251
252
253
254
255
256
257
258
# File 'lib/smith/providers/openai/responses.rb', line 251

def self.response_tool_output(content)
  return JSON.generate(content.value) if content.is_a?(::RubyLLM::Content::Raw)
  return content.text.to_s if content.is_a?(::RubyLLM::Content) && content.text
  return JSON.generate(content.to_h) if content.is_a?(::RubyLLM::Content)
  return JSON.generate(content) if content.is_a?(Hash) || content.is_a?(Array)

  content.to_s
end

.responses_urlObject



44
45
46
# File 'lib/smith/providers/openai/responses.rb', line 44

def self.responses_url
  "responses"
end