Module: RubyLLM::Protocols::Responses::Chat

Defined in:
lib/ruby_llm/protocols/responses/chat.rb

Overview

Chat methods of the OpenAI Responses API

Constant Summary collapse

OPENAI_INLINE_FILE_LIMIT =
50 * 1024 * 1024
OPENAI_FILE_UPLOAD_LIMIT =
512 * 1024 * 1024
PROMPT_CACHE_OPTIONS =
%i[key ttl mode retention].freeze
COMPACTION_PROVIDERS =

context_management is an OpenAI parameter that only OpenAI's own endpoints serve; the other services on this wire format reject it.

%w[openai azure].freeze
COMPACTION_IGNORED_OPTIONS =
%i[instructions pause_after].freeze
CLIENT_OUTPUT_ITEM_TYPES =
%w[message reasoning function_call].freeze
SERVER_TOOL_RESULT_KEYS =

Result payloads differ by item type; compaction items carry an opaque encrypted_content instead of a readable result.

%w[result results outputs output encrypted_content].freeze
MESSAGE_TEXT_KEYS =
{ 'output_text' => 'text', 'refusal' => 'refusal' }.freeze
FINISH_REASONS =
{
  'completed' => :stop, 'max_output_tokens' => :max_tokens, 'content_filter' => :content_filter
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.apply_compaction(payload, compaction) ⇒ Object

OpenAI names the threshold compact_threshold and takes the entries as a flat array, where Anthropic nests a trigger object under context_management.edits. It writes the summary itself, so there is nothing to steer with instructions and no pausing to opt into.



89
90
91
92
93
94
95
96
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 89

def apply_compaction(payload, compaction)
  return super unless COMPACTION_PROVIDERS.include?(@provider.slug)

  warn_ignored_compaction_options(compaction)
  entry = { type: 'compaction' }
  entry[:compact_threshold] = compaction[:at] if compaction[:at]
  payload.merge(context_management: [entry])
end

.build_prompt_cache_options(options) ⇒ Object



207
208
209
210
211
212
213
214
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 207

def build_prompt_cache_options(options)
  ttl = options[:ttl] || retention_ttl(options[:retention])

  {}.tap do |cache_options|
    cache_options[:mode] = options[:mode] if options[:mode]
    cache_options[:ttl] = ttl if ttl
  end
end

.cache_breakpoint_parts(content) ⇒ Object



316
317
318
319
320
321
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 316

def cache_breakpoint_parts(content)
  case content
  when Array then content.dup
  when String then [{ type: 'input_text', text: content }] unless content.empty?
  end
end

.default_large_file_upload_thresholdObject



448
449
450
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 448

def default_large_file_upload_threshold
  OPENAI_INLINE_FILE_LIMIT
end

.empty_content?(content) ⇒ Boolean

Returns:

  • (Boolean)


378
379
380
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 378

def empty_content?(content)
  content.nil? || content.strip.empty?
end

.finish_reasonsObject



399
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 399

def finish_reasons = FINISH_REASONS

.format_assistant_items(msg) ⇒ Object



343
344
345
346
347
348
349
350
351
352
353
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 343

def format_assistant_items(msg)
  # Turns that used server tools replay their output items verbatim,
  # reasoning and tool results included, as stateless chaining expects.
  return msg.raw_content if msg.raw_content

  items = []
  items << format_reasoning_item(msg.thinking) if msg.thinking&.signature
  items << { role: 'assistant', content: format_output_content(msg) } unless empty_content?(msg.content)
  items.concat(format_function_call_items(msg.tool_calls)) if msg.tool_call?
  items
end

.format_cache_option_keys(keys) ⇒ Object



235
236
237
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 235

def format_cache_option_keys(keys)
  keys.map { |key| ":#{key}" }.join(', ')
end

.format_function_call_items(tool_calls) ⇒ Object



363
364
365
366
367
368
369
370
371
372
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 363

def format_function_call_items(tool_calls)
  tool_calls.map do |_, tc|
    {
      type: 'function_call',
      call_id: tc.id,
      name: tc.name,
      arguments: JSON.generate(tc.arguments)
    }
  end
end

.format_input(messages, caching: nil) ⇒ Object



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 273

def format_input(messages, caching: nil)
  system_items = []
  messages.each_with_object([]) do |message, input|
    next if message.role == :system && !system_input_item?(message, caching:)

    raw = message.raw_content
    if raw.is_a?(Hash) && raw['object'] == 'response.compaction'
      input.replace(system_items + raw.fetch('output'))
    else
      items = [format_item(message, caching:)].flatten(1)
      system_items.concat(items) if message.role == :system
      input.concat(items)
    end
  end
end

.format_instructions(messages, caching: nil) ⇒ Object

System messages marked as cache boundaries, or carrying attachments, ride along as input items, because the instructions parameter is a plain string and cannot carry a breakpoint marker or a file.



266
267
268
269
270
271
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 266

def format_instructions(messages, caching: nil)
  instructions = messages.select { |msg| msg.role == :system && !system_input_item?(msg, caching:) }
                         .map { |msg| msg.content.to_s }

  instructions.empty? ? nil : instructions.join("\n\n")
end

.format_item(msg, caching: nil) ⇒ Object



293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 293

def format_item(msg, caching: nil)
  case msg.role
  when :system
    item = { role: 'system', content: format_content(msg.content, msg.attachments) }
    caching != false && msg.cache_until_here? ? inject_cache_breakpoint(item) : item
  when :tool
    format_tool_items(msg)
  when :assistant
    format_assistant_items(msg)
  else
    item = { role: 'user', content: format_content(msg.content, msg.attachments) }
    caching != false && msg.cache_until_here? ? inject_cache_breakpoint(item) : item
  end
end

.format_output_content(msg) ⇒ Object



374
375
376
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 374

def format_output_content(msg)
  [{ type: 'output_text', text: msg.content }]
end

.format_reasoning_item(thinking) ⇒ Object



355
356
357
358
359
360
361
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 355

def format_reasoning_item(thinking)
  {
    type: 'reasoning',
    summary: thinking.text ? [{ type: 'summary_text', text: thinking.text }] : [],
    encrypted_content: thinking.signature
  }
end

.format_tool_items(msg) ⇒ Object

Function call outputs are text-only on the wire, so tool attachments ride a user item spliced in right after the result.



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 325

def format_tool_items(msg)
  return msg.raw_content if msg.raw_content

  items = [{
    type: 'function_call_output',
    call_id: msg.tool_call_id,
    output: format_content(msg.content)
  }]

  if msg.attachments.any?
    parts = [{ type: 'input_text', text: "Attachments from tool call #{msg.tool_call_id}:" }]
    parts.concat(Media.format_content(nil, msg.attachments))
    items << { role: 'user', content: parts }
  end

  items
end

.inject_cache_breakpoint(item) ⇒ Object



308
309
310
311
312
313
314
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 308

def inject_cache_breakpoint(item)
  parts = cache_breakpoint_parts(item[:content])
  return item unless parts&.last.is_a?(Hash)

  parts[-1] = parts.last.merge(prompt_cache_breakpoint: { mode: 'explicit' })
  item.merge(content: parts)
end

.normalize_annotation(annotation) ⇒ Object



191
192
193
194
195
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 191

def normalize_annotation(annotation)
  return annotation if annotation.key?('url_citation') || annotation['type'] != 'url_citation'

  { 'url_citation' => annotation }
end

.offset_citations(citations, offset, content) ⇒ Object



178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 178

def offset_citations(citations, offset, content)
  citations.map do |citation|
    start_index = citation.start_index && (citation.start_index + offset)
    end_index = citation.end_index && (citation.end_index + offset)

    Citation.new(citation.to_h.merge(
                   start_index: start_index,
                   end_index: end_index,
                   text: annotated_text(content, start_index, end_index)
                 ))
  end
end

.parse_annotations(annotations, content) ⇒ Object



153
154
155
156
157
158
159
160
161
162
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 153

def parse_annotations(annotations, content)
  Array(annotations).filter_map do |annotation|
    case annotation['type']
    when 'file_citation', 'container_file_citation'
      parse_file_citation(annotation, content)
    else
      super([normalize_annotation(annotation)], content).first
    end
  end
end

.parse_citations(data, output, content) ⇒ Object



134
135
136
137
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 134

def parse_citations(data, output, content)
  citations = parse_output_citations(output, content)
  citations.any? ? citations : parse_root_citations(data)
end

.parse_completion_body(data, raw:) ⇒ Object

Raises:



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 53

def parse_completion_body(data, raw:)
  raise Error.new(data.dig('error', 'message'), response: raw) if data.dig('error', 'message')

  output = data['output'] || []
  content = parse_output_text(output)
  server_tool_calls = parse_server_tool_items(output)

  finish_reason = parse_finish_reason(data)

  Message.new(
    role: :assistant,
    content: content,
    citations: parse_citations(data, output, content),
    thinking: Thinking.build(
      text: parse_reasoning_summary(output),
      signature: parse_reasoning_signature(output)
    ),
    tool_calls: parse_pending_tool_calls(output, response: raw, finish_reason: finish_reason),
    server_tool_calls: server_tool_calls,
    raw_content: server_tool_calls.any? ? output : nil,
    model: data['model'],
    raw: raw,
    finish_reason: finish_reason,
    **parse_usage(data['usage'] || {})
  )
end

.parse_file_citation(annotation, content) ⇒ Object



164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 164

def parse_file_citation(annotation, content)
  start_index = annotation['start_index']
  end_index = annotation['end_index']

  Citation.new(
    source_id: annotation['file_id'],
    title: annotation['filename'],
    source_index: annotation['index'],
    text: annotated_text(content, start_index, end_index),
    start_index: start_index,
    end_index: end_index
  )
end

.parse_finish_reason(data) ⇒ Object



401
402
403
404
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 401

def parse_finish_reason(data)
  reason = data.dig('incomplete_details', 'reason') || (data['status'] if data['status'] == 'completed')
  normalize_finish_reason(reason)
end

.parse_function_call_arguments(arguments, response: nil, finish_reason: nil) ⇒ Object



424
425
426
427
428
429
430
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 424

def parse_function_call_arguments(arguments, response: nil, finish_reason: nil)
  return {} if arguments.nil? || arguments.empty?

  JSON.parse(arguments)
rescue JSON::ParserError => e
  raise ToolCallParseError.new(response: response, finish_reason: finish_reason), cause: e
end

.parse_function_calls(output, response: nil, finish_reason: nil) ⇒ Object



406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 406

def parse_function_calls(output, response: nil, finish_reason: nil)
  calls = output.select { |item| item['type'] == 'function_call' }
  return nil if calls.empty?

  calls.to_h do |call|
    arguments = call['arguments']

    [
      call['call_id'],
      ToolCall.new(
        id: call['call_id'],
        name: call['name'],
        arguments: parse_function_call_arguments(arguments, response: response, finish_reason: finish_reason)
      )
    ]
  end
end

.parse_output_citations(output, content) ⇒ Object



139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 139

def parse_output_citations(output, content)
  offset = 0
  output.select { |item| item['type'] == 'message' }.flat_map do |message|
    Array(message['content']).flat_map do |part|
      key = MESSAGE_TEXT_KEYS[part['type']]
      next [] unless key

      citations = offset_citations(parse_annotations(part['annotations'], nil), offset, content)
      offset += part[key].to_s.length
      citations
    end
  end
end

.parse_output_text(output) ⇒ Object



384
385
386
387
388
389
390
391
392
393
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 384

def parse_output_text(output)
  texts = output.select { |item| item['type'] == 'message' }.flat_map do |message|
    Array(message['content']).filter_map do |part|
      key = MESSAGE_TEXT_KEYS[part['type']]
      part[key] if key
    end
  end

  texts.empty? ? nil : texts.join
end

.parse_reasoning_signature(output) ⇒ Object



440
441
442
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 440

def parse_reasoning_signature(output)
  output.find { |item| item['type'] == 'reasoning' }&.dig('encrypted_content')
end

.parse_reasoning_summary(output) ⇒ Object



432
433
434
435
436
437
438
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 432

def parse_reasoning_summary(output)
  texts = output.select { |item| item['type'] == 'reasoning' }.flat_map do |item|
    Array(item['summary']).filter_map { |part| part['text'] }
  end

  texts.empty? ? nil : texts.join("\n")
end

.parse_server_tool_items(output) ⇒ Object

Output items beyond text, reasoning, and function calls record provider-executed tool steps (web_search_call, code_interpreter_call, and whatever OpenAI ships next). They are kept raw and replayed.



112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 112

def parse_server_tool_items(output)
  output.reject { |item| CLIENT_OUTPUT_ITEM_TYPES.include?(item['type']) }.map do |item|
    ServerToolCall.new(
      type: item['type'],
      name: item['name'],
      id: item['id'],
      input: item['action'] || item['arguments'] || item['code'],
      result: server_tool_result(item),
      raw: item
    )
  end
end

.parse_usage(usage) ⇒ Object



239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 239

def parse_usage(usage)
  details = usage['input_tokens_details'] || usage['prompt_tokens_details'] || {}
  cached = details['cached_tokens']
  cache_writes = details['cache_write_tokens']
  input = usage['input_tokens']

  {
    input_tokens: input && [input.to_i - cached.to_i - cache_writes.to_i, 0].max,
    output_tokens: usage['output_tokens'],
    cache_read_tokens: cached,
    cache_write_tokens: cache_writes,
    thinking_tokens: usage.dig('output_tokens_details', 'reasoning_tokens')
  }
end

.prompt_cache_options(caching) ⇒ Object

Raises:

  • (ArgumentError)


226
227
228
229
230
231
232
233
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 226

def prompt_cache_options(caching)
  options = caching.to_h.transform_keys(&:to_sym)
  unsupported = options.keys - PROMPT_CACHE_OPTIONS
  return options if unsupported.empty?

  raise ArgumentError,
        "Responses prompt caching accepts :key, :ttl, and :mode, got #{format_cache_option_keys(unsupported)}"
end

.prompt_cache_params(caching) ⇒ Object



197
198
199
200
201
202
203
204
205
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 197

def prompt_cache_params(caching)
  options = prompt_cache_options(caching)
  cache_options = build_prompt_cache_options(options)

  {}.tap do |params|
    params[:prompt_cache_key] = options[:key] if options[:key]
    params[:prompt_cache_options] = cache_options unless cache_options.empty?
  end
end

.provider_file_attachable?(attachment) ⇒ Boolean

Returns:

  • (Boolean)


456
457
458
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 456

def provider_file_attachable?(attachment)
  attachment.pdf? || attachment.document?
end

.provider_file_upload_limitObject



452
453
454
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 452

def provider_file_upload_limit
  OPENAI_FILE_UPLOAD_LIMIT
end

.provider_file_upload_options(_attachment) ⇒ Object



460
461
462
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 460

def provider_file_upload_options(_attachment)
  { purpose: 'user_data' }
end

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

rubocop:disable-next Metrics/PerceivedComplexity



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 19

def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil, schema: nil,
                   thinking: nil, citations: false, caching: nil, tool_prefs: nil)
  warn_unsupported_citations(model) if citations && !model.supports?(:citations)
  tool_prefs ||= {}
  # store: false leaves the provider holding no state, so reasoning has
  # to ride back in the response. xAI only encrypts it when asked.
  payload = {
    model: model.id,
    input: format_input(messages, caching:),
    instructions: format_instructions(messages, caching:),
    stream: stream,
    store: false,
    include: ['reasoning.encrypted_content']
  }.compact

  payload[:temperature] = temperature unless temperature.nil?
  payload[:max_output_tokens] = max_output_tokens unless max_output_tokens.nil?

  if tools.any?
    payload[:tools] = tools.map { |_, tool| 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

  payload[:text] = { format: schema_format(schema) } if schema

  effort = resolve_effort(thinking)
  payload[:reasoning] = { effort: effort } if effort
  payload[:reasoning] = (payload[:reasoning] || {}).merge(summary: 'auto') if thinking&.display == :summarized
  payload.merge!(prompt_cache_params(caching)) if caching

  payload
end

.retention_ttl(retention) ⇒ Object



216
217
218
219
220
221
222
223
224
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 216

def retention_ttl(retention)
  return unless retention

  RubyLLM.logger.warn(
    'with_caching retention: is deprecated; OpenAI replaced prompt_cache_retention ' \
    'with prompt_cache_options. Use ttl: instead.'
  )
  retention
end

.schema_format(schema) ⇒ Object



254
255
256
257
258
259
260
261
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 254

def schema_format(schema)
  {
    type: 'json_schema',
    name: schema[:name],
    schema: schema[:schema],
    strict: schema_strict(schema)
  }
end

.server_tool_result(item) ⇒ Object



129
130
131
132
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 129

def server_tool_result(item)
  key = SERVER_TOOL_RESULT_KEYS.find { |candidate| item[candidate] }
  item[key] if key
end

.supports_provider_file_references?Boolean

Returns:

  • (Boolean)


444
445
446
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 444

def supports_provider_file_references?
  true
end

.system_input_item?(msg, caching: nil) ⇒ Boolean

Returns:

  • (Boolean)


289
290
291
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 289

def system_input_item?(msg, caching: nil)
  msg.role == :system && ((caching != false && msg.cache_until_here?) || msg.attachments.any?)
end

.warn_ignored_compaction_options(compaction) ⇒ Object



98
99
100
101
102
103
104
105
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 98

def warn_ignored_compaction_options(compaction)
  ignored = compaction.keys & COMPACTION_IGNORED_OPTIONS
  return if ignored.empty?

  RubyLLM.logger.debug do
    "#{@provider.name} compaction takes no #{ignored.join(', ')}, dropping"
  end
end

Instance Method Details

#completion_urlObject



8
9
10
# File 'lib/ruby_llm/protocols/responses/chat.rb', line 8

def completion_url
  'responses'
end