Module: RubyLLM::Protocols::ChatCompletions::Chat

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

Overview

Chat methods of the OpenAI API integration

Constant Summary collapse

FINISH_REASONS =
{
  'stop' => :stop, 'length' => :max_tokens, 'tool_calls' => :tool_calls,
  'function_call' => :tool_calls, 'content_filter' => :content_filter
}.freeze
OPENAI_INLINE_FILE_LIMIT =
50 * 1024 * 1024
OPENAI_FILE_UPLOAD_LIMIT =
512 * 1024 * 1024
PROMPT_CACHE_OPTIONS =
%i[key ttl mode retention].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.annotated_text(content, start_index, end_index) ⇒ Object



224
225
226
227
228
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 224

def annotated_text(content, start_index, end_index)
  return nil unless content.is_a?(String) && start_index && end_index

  content[start_index...end_index]
end

.apply_end_user(payload, identifier) ⇒ Object

safety_identifier is an OpenAI parameter; the other services on this wire format reject or ignore it, so only OpenAI's own endpoints receive it.



393
394
395
396
397
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 393

def apply_end_user(payload, identifier)
  return super unless %w[openai azure].include?(@provider.slug)

  payload.merge(safety_identifier: identifier)
end

.apply_prompt_cache_params(payload, caching) ⇒ Object



253
254
255
256
257
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 253

def apply_prompt_cache_params(payload, caching)
  return unless openai_prompt_caching?

  payload.merge!(prompt_cache_params(caching)) if caching
end

.build_prompt_cache_options(options) ⇒ Object



273
274
275
276
277
278
279
280
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 273

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



359
360
361
362
363
364
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 359

def cache_breakpoint_parts(content)
  case content
  when Array then content.dup
  when String then [Media.format_text(content)] unless content.empty?
  end
end

.cache_read_tokens(usage) ⇒ Object



185
186
187
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 185

def cache_read_tokens(usage)
  usage.dig('prompt_tokens_details', 'cached_tokens') || usage['prompt_cache_hit_tokens']
end

.cache_write_tokens(usage) ⇒ Object



189
190
191
192
193
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 189

def cache_write_tokens(usage)
  usage.dig('prompt_tokens_details', 'cache_write_tokens') ||
    usage.dig('input_tokens_details', 'cache_write_tokens') ||
    0
end

.default_large_file_upload_thresholdObject



403
404
405
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 403

def default_large_file_upload_threshold
  OPENAI_INLINE_FILE_LIMIT
end

.extract_citations(message_data, data, content) ⇒ Object



199
200
201
202
203
204
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 199

def extract_citations(message_data, data, content)
  annotations = parse_annotations(message_data['annotations'], content)
  return annotations if annotations.any?

  parse_root_citations(data)
end

.extract_content_and_thinking(content) ⇒ Object



448
449
450
451
452
453
454
455
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 448

def extract_content_and_thinking(content)
  return [content, nil] unless content.is_a?(Array)

  text = extract_text_from_blocks(content)
  thinking = extract_thinking_from_blocks(content)

  [text.empty? ? nil : text, thinking.empty? ? nil : thinking]
end

.extract_raw_reasoning(_message_data) ⇒ Object



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

def extract_raw_reasoning(_message_data)
  nil
end

.extract_text_from_blocks(blocks) ⇒ Object



457
458
459
460
461
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 457

def extract_text_from_blocks(blocks)
  blocks.filter_map do |block|
    block['text'] if block['type'] == 'text' && block['text'].is_a?(String)
  end.join
end

.extract_thinking_from_blocks(blocks) ⇒ Object



463
464
465
466
467
468
469
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 463

def extract_thinking_from_blocks(blocks)
  blocks.filter_map do |block|
    next unless block['type'] == 'thinking'

    extract_thinking_text_from_block(block)
  end.join
end

.extract_thinking_signature(message_data) ⇒ Object



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

def extract_thinking_signature(message_data)
  candidate = message_data['reasoning_signature'] || message_data['signature']
  candidate.is_a?(String) ? candidate : nil
end

.extract_thinking_text(message_data) ⇒ Object



434
435
436
437
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 434

def extract_thinking_text(message_data)
  candidate = message_data['reasoning_content'] || message_data['reasoning'] || message_data['thinking']
  candidate.is_a?(String) ? candidate : nil
end

.extract_thinking_text_from_block(block) ⇒ Object



471
472
473
474
475
476
477
478
479
480
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 471

def extract_thinking_text_from_block(block)
  thinking_block = block['thinking']
  return thinking_block if thinking_block.is_a?(String)

  if thinking_block.is_a?(Array)
    return thinking_block.filter_map { |item| item['text'] if item['type'] == 'text' }.join
  end

  block['text'] if block['text'].is_a?(String)
end

.finish_reasonsObject



23
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 23

def finish_reasons = FINISH_REASONS

.format_cache_option_keys(keys) ⇒ Object



302
303
304
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 302

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

.format_content(content, attachments = []) ⇒ Object



370
371
372
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 370

def format_content(content, attachments = [])
  Media.format_content(content, attachments)
end

.format_message(msg, caching: nil) ⇒ Object



321
322
323
324
325
326
327
328
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 321

def format_message(msg, caching: nil)
  {
    role: format_role(msg.role),
    content: format_message_content(msg, caching: caching),
    tool_calls: format_tool_calls(msg.tool_calls),
    tool_call_id: msg.tool_call_id
  }.compact.merge(format_thinking(msg))
end

.format_message_content(msg, caching: nil) ⇒ Object



343
344
345
346
347
348
349
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 343

def format_message_content(msg, caching: nil, **)
  content = format_content(msg.content, msg.tool_result? ? [] : msg.attachments)
  return '' if content.nil? && thinking_only_assistant_message?(msg)
  return inject_cache_breakpoint(content) if caching != false && msg.cache_until_here? && openai_prompt_caching?

  content
end

.format_message_group(group, caching: nil) ⇒ Object

An assistant turn's tool results must stay consecutive, so the attachment carriers of a parallel round follow the whole run.



314
315
316
317
318
319
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 314

def format_message_group(group, caching: nil)
  formatted = group.map { |msg| format_message(msg, caching: caching) }
  carriers = group.select { |msg| msg.tool_result? && msg.attachments.any? }

  formatted + carriers.map { |msg| tool_attachment_message(msg) }
end

.format_messages(messages, caching: nil) ⇒ Object



306
307
308
309
310
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 306

def format_messages(messages, caching: nil)
  messages_for_provider(messages)
    .chunk_while { |previous, current| previous.tool_result? && current.tool_result? }
    .flat_map { |group| format_message_group(group, caching: caching) }
end

.format_role(role) ⇒ Object



374
375
376
377
378
379
380
381
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 374

def format_role(role)
  case role
  when :system
    @config.openai_use_system_role ? 'system' : 'developer'
  else
    role.to_s
  end
end

.format_thinking(msg) ⇒ Object



419
420
421
422
423
424
425
426
427
428
429
430
431
432
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 419

def format_thinking(msg)
  return {} unless msg.role == :assistant

  thinking = msg.thinking
  return {} unless thinking

  payload = {}
  if thinking.text
    payload[:reasoning] = thinking.text
    payload[:reasoning_content] = thinking.text
  end
  payload[:reasoning_signature] = thinking.signature if thinking.signature
  payload
end

.generated_tokens_from_total(usage) ⇒ Object



177
178
179
180
181
182
183
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 177

def generated_tokens_from_total(usage)
  prompt_tokens = usage['prompt_tokens']
  total_tokens = usage['total_tokens']
  return unless prompt_tokens && total_tokens

  [total_tokens.to_i - prompt_tokens.to_i, 0].max
end

.inject_cache_breakpoint(content) ⇒ Object



351
352
353
354
355
356
357
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 351

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

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

.input_tokens(usage) ⇒ Object



157
158
159
160
161
162
163
164
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 157

def input_tokens(usage)
  return usage['prompt_cache_miss_tokens'] if usage['prompt_cache_miss_tokens']

  prompt_tokens = usage['prompt_tokens']
  return unless prompt_tokens

  [prompt_tokens.to_i - cache_read_tokens(usage).to_i - cache_write_tokens(usage).to_i, 0].max
end

.max_output_tokens_field(_model) ⇒ Object

OpenAI and Azure reject max_tokens on their reasoning models and accept max_completion_tokens on every model; the rest of the wire format only knows max_tokens.



97
98
99
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 97

def max_output_tokens_field(_model)
  %w[openai azure].include?(@provider.slug) ? :max_completion_tokens : :max_tokens
end

.messages_for_provider(messages) ⇒ Object



338
339
340
341
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 338

def messages_for_provider(messages)
  system_messages, other_messages = messages.partition { |msg| msg.role == :system }
  system_messages + other_messages
end

.no_completion_message_error(data, raw) ⇒ Object



150
151
152
153
154
155
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 150

def no_completion_message_error(data, raw)
  finish_reason = data.dig('choices', 0, 'finish_reason')
  message = 'Provider returned no completion message'
  message = "#{message} (finish_reason: #{finish_reason})" if finish_reason
  Error.new(message, response: raw)
end

.normalize_finish_reason(reason) ⇒ Object



25
26
27
28
29
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 25

def normalize_finish_reason(reason)
  return nil if reason.nil?

  finish_reasons.fetch(reason.to_s) { reason.to_s.to_sym }
end

.openai_prompt_caching?Boolean

Returns:



259
260
261
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 259

def openai_prompt_caching?
  true
end

.optional_properties?(node) ⇒ Boolean

Returns:



49
50
51
52
53
54
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 49

def optional_properties?(node)
  properties = node[:properties]
  return false unless properties.is_a?(Hash)

  (properties.keys.map(&:to_s) - Array(node[:required]).map(&:to_s)).any?
end

.output_tokens(usage) ⇒ Object



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

def output_tokens(usage)
  completion_tokens = usage['completion_tokens']
  return unless completion_tokens

  completion_tokens = completion_tokens.to_i
  generated_tokens = generated_tokens_from_total(usage)
  return completion_tokens unless generated_tokens && generated_tokens > completion_tokens

  generated_tokens
end

.parse_annotations(annotations, content) ⇒ Object



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 206

def parse_annotations(annotations, content)
  Array(annotations).filter_map do |annotation|
    details = annotation['url_citation']
    next unless details.is_a?(Hash)

    start_index = details['start_index']
    end_index = details['end_index']

    Citation.new(
      url: details['url'],
      title: details['title'],
      text: annotated_text(content, start_index, end_index),
      start_index: start_index,
      end_index: end_index
    )
  end
end

.parse_completion_body(data, raw:) ⇒ Object

Raises:



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
137
138
139
140
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 108

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

  message_data = data.dig('choices', 0, 'message')
  raise no_completion_message_error(data, raw) unless message_data

  usage = data['usage'] || {}
  thinking_tokens = thinking_tokens(usage)
  content, thinking_from_blocks = extract_content_and_thinking(message_data['content'])
  thinking_text = thinking_from_blocks || extract_thinking_text(message_data)
  thinking_signature = extract_thinking_signature(message_data)

  finish_reason = normalize_finish_reason(data.dig('choices', 0, 'finish_reason'))

  Message.new(
    role: :assistant,
    content: content,
    citations: extract_citations(message_data, data, content),
    thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
    raw_reasoning: extract_raw_reasoning(message_data),
    tool_calls: parse_tool_calls(message_data['tool_calls'], response: raw, finish_reason: finish_reason),
    input_tokens: input_tokens(usage),
    output_tokens: output_tokens(usage),
    cache_read_tokens: cache_read_tokens(usage),
    cache_write_tokens: cache_write_tokens(usage),
    thinking_tokens: thinking_tokens,
    server_tool_use: server_tool_use(usage),
    reported_cost: reported_cost(usage),
    finish_reason: finish_reason,
    model: data['model'],
    raw: raw
  )
end

.parse_root_citations(data) ⇒ Object

Perplexity and xAI return search citations at the root of the response.



231
232
233
234
235
236
237
238
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 231

def parse_root_citations(data)
  search_results = data['search_results']
  return parse_search_results(search_results) if search_results.is_a?(Array) && search_results.any?

  Array(data['citations']).each_with_index.filter_map do |url, index|
    Citation.new(url: url, source_index: index) if url.is_a?(String)
  end
end

.parse_search_results(results) ⇒ Object



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

def parse_search_results(results)
  results.each_with_index.filter_map do |result, index|
    next unless result.is_a?(Hash)

    Citation.new(
      url: result['url'],
      title: result['title'],
      cited_text: result['snippet'],
      source_index: index
    )
  end
end

.prompt_cache_options(caching) ⇒ Object

Raises:



292
293
294
295
296
297
298
299
300
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 292

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,
        'Chat Completions prompt caching accepts :key, :ttl, and :mode, ' \
        "got #{format_cache_option_keys(unsupported)}"
end

.prompt_cache_params(caching) ⇒ Object



263
264
265
266
267
268
269
270
271
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 263

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:



411
412
413
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 411

def provider_file_attachable?(attachment)
  attachment.pdf?
end

.provider_file_upload_limitObject



407
408
409
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 407

def provider_file_upload_limit
  OPENAI_FILE_UPLOAD_LIMIT
end

.provider_file_upload_options(_attachment) ⇒ Object



415
416
417
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 415

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



57
58
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
91
92
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 57

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 ||= {}
  payload = {
    model: model.id,
    messages: format_messages(messages, caching: caching),
    stream: stream
  }

  payload[:temperature] = temperature unless temperature.nil?
  payload[max_output_tokens_field(model)] = 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

  if schema
    payload[:response_format] = {
      type: 'json_schema',
      json_schema: {
        name: schema[:name],
        schema: schema[:schema],
        strict: schema_strict(schema)
      }
    }
  end

  effort = resolve_effort(thinking)
  payload[:reasoning_effort] = effort if effort

  payload[:stream_options] = { include_usage: true } if stream
  apply_prompt_cache_params(payload, caching)
  payload
end

.reported_cost(_usage) ⇒ Object



142
143
144
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 142

def reported_cost(_usage)
  nil
end

.resolve_effort(thinking) ⇒ Object



383
384
385
386
387
388
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 383

def resolve_effort(thinking)
  return nil unless thinking

  effort = thinking.respond_to?(:effort) ? thinking.effort : thinking
  effort&.to_s
end

.retention_ttl(retention) ⇒ Object



282
283
284
285
286
287
288
289
290
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 282

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_strict(schema) ⇒ Object

OpenAI strict mode rejects an object whose properties are not all required, so a schema with optional properties goes out non-strict unless the caller asked for strict explicitly.



34
35
36
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 34

def schema_strict(schema)
  schema.key?(:strict) ? schema[:strict] : strict_schema?(schema[:schema])
end

.server_tool_use(usage) ⇒ Object



146
147
148
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 146

def server_tool_use(usage)
  usage['server_tool_use'] || usage['server_tool_use_details']
end

.strict_schema?(node) ⇒ Boolean

Returns:



38
39
40
41
42
43
44
45
46
47
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 38

def strict_schema?(node)
  case node
  when Hash
    return false if optional_properties?(node)

    node.values.all? { |value| strict_schema?(value) }
  when Array then node.all? { |value| strict_schema?(value) }
  else true
  end
end

.supports_provider_file_references?Boolean

Returns:



399
400
401
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 399

def supports_provider_file_references?
  @provider.slug == 'openai'
end

.thinking_only_assistant_message?(msg) ⇒ Boolean

Returns:



366
367
368
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 366

def thinking_only_assistant_message?(msg)
  msg.role == :assistant && msg.thinking && !msg.tool_call?
end

.thinking_tokens(usage) ⇒ Object



195
196
197
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 195

def thinking_tokens(usage)
  usage.dig('completion_tokens_details', 'reasoning_tokens') || usage['reasoning_tokens']
end

.tool_attachment_message(msg) ⇒ Object

Chat Completions tool messages are text-only on the wire, so tool attachments ride a user message spliced in after the results.



332
333
334
335
336
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 332

def tool_attachment_message(msg)
  parts = [Media.format_text("Attachments from tool call #{msg.tool_call_id}:")]
  parts.concat(format_content(nil, msg.attachments))
  { role: 'user', content: parts }
end

.warn_unsupported_citations(model) ⇒ Object



101
102
103
104
105
106
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 101

def warn_unsupported_citations(model)
  RubyLLM.logger.warn(
    "#{model.id} does not support citations according to the model registry. " \
    'with_citations may have no effect.'
  )
end

Instance Method Details

#completion_urlObject



17
18
19
# File 'lib/ruby_llm/protocols/chat_completions/chat.rb', line 17

def completion_url
  'chat/completions'
end