Class: Riffer::Providers::OpenAI

Inherits:
Base
  • Object
show all
Defined in:
lib/riffer/providers/open_ai.rb,
sig/_private/riffer/providers/open_ai.rbs,
sig/generated/riffer/providers/open_ai.rbs

Overview

OpenAI provider for GPT models. Requires the openai gem.

Direct Known Subclasses

AzureOpenAI

Constant Summary collapse

WEB_SEARCH_TOOL_TYPE =

: String

Returns:

  • (String)
"web_search_preview"

Constants inherited from Base

Base::REQUEST_PARAM_ATTRIBUTES, Base::WIRE_SEPARATOR

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#apply_pricing, #capture_input, #capture_messages?, #capture_output, #chat_span_attributes, #decode_tool_name, #depends_on, #encode_tool_name, #generate_text, #in_chat_span, #merge_consecutive_messages, #normalize_messages, #parse_structured_output, #parse_tool_arguments, #pricing_rates, #record_finish_reason, #record_stream_outcome, skills_adapter, #stream_text, #tag_attributes, #validate_input!, #validate_normalized_messages!, #yield_finish_reason

Constructor Details

#initialize(**options) ⇒ OpenAI

-- : (**untyped) -> void

Parameters:

  • (Object)


17
18
19
20
21
22
# File 'lib/riffer/providers/open_ai.rb', line 17

def initialize(**options)
  depends_on "openai"

  api_key = options.fetch(:api_key, Riffer.config.openai.api_key)
  @client = ::OpenAI::Client.new(api_key: api_key, **options.except(:api_key))
end

Class Method Details

.semconv_provider_nameString

The GenAI semconv well-known provider name.

: () -> String

Returns:

  • (String)


11
12
13
# File 'lib/riffer/providers/open_ai.rb', line 11

def self.semconv_provider_name
  "openai"
end

Instance Method Details

#build_finish_reason(response) ⇒ Riffer::Providers::FinishReason?

The Responses API reports no finish_reason field, so one is derived.

: (untyped) -> Riffer::Providers::FinishReason?

Parameters:

  • (Object)

Returns:



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/riffer/providers/open_ai.rb', line 115

def build_finish_reason(response)
  typed_response = response #: OpenAI::Models::Responses::Response
  status = typed_response.status
  return nil unless status

  case status.to_sym
  when :completed
    reason = extract_tool_calls(typed_response).empty? ? :stop : :tool_calls
    Riffer::Providers::FinishReason.new(reason: reason, raw: "completed")
  when :incomplete
    incomplete_finish_reason(typed_response)
  when :failed
    Riffer::Providers::FinishReason.new(reason: :error, raw: "failed")
  else
    Riffer::Providers::FinishReason.new(reason: :other, raw: status.to_s)
  end
end

#build_request_params(messages, model, options) ⇒ Hash[Symbol, untyped]

-- : (Array, String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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/riffer/providers/open_ai.rb', line 28

def build_request_params(messages, model, options)
  reasoning = options[:reasoning]
  tools = options[:tools]
  structured_output = options[:structured_output]
  web_search = options[:web_search]
  tags = options[:tags] || {}

  params = {
    input: convert_messages_to_openai_format(messages),
    model: model,
    reasoning: reasoning && {
      effort: reasoning,
      summary: "auto"
    },
    **options.except(:reasoning, :tools, :structured_output, :web_search, :tags)
  } #: Hash[Symbol, untyped]

  unless tags.empty?
    params[:metadata] = tags
    # The reserved user_id also maps to the native safety identifier while
    # staying in metadata as an ordinary tag.
    user_id = tags["user_id"]
    params[:safety_identifier] = user_id if user_id
  end

  openai_tools = [] #: Array[Hash[Symbol, untyped]]
  openai_tools.concat(tools.map { |t| convert_tool_to_openai_format(t) }) if tools && !tools.empty?

  if web_search
    web_search_tool = {type: WEB_SEARCH_TOOL_TYPE}
    web_search_tool.merge!(web_search) if web_search.is_a?(Hash)
    openai_tools << web_search_tool
  end

  if structured_output
    # OpenAI requires strict mode schemas.
    # https://platform.openai.com/docs/guides/structured-outputs#all-fields-must-be-required
    params[:text] = {
      format: {
        type: "json_schema",
        name: "response",
        schema: structured_output.json_schema(strict: true),
        strict: true
      }
    }
  end

  params[:tools] = openai_tools unless openai_tools.empty?

  params.compact
end

#build_token_usage(usage) ⇒ Riffer::Providers::TokenUsage

-- : (untyped) -> Riffer::Providers::TokenUsage

Parameters:

  • (Object)

Returns:



98
99
100
101
102
103
104
# File 'lib/riffer/providers/open_ai.rb', line 98

def build_token_usage(usage)
  apply_pricing(Riffer::Providers::TokenUsage.new(
    input_tokens: usage.input_tokens,
    output_tokens: usage.output_tokens,
    cache_read_tokens: usage.input_tokens_details&.cached_tokens
  ))
end

#convert_assistant_to_openai_format(message) ⇒ Hash[Symbol, untyped], Array[Hash[Symbol, untyped]]

-- : (Riffer::Messages::Assistant) -> (Hash[Symbol, untyped] | Array[Hash[Symbol, untyped]])

Parameters:

Returns:

  • (Hash[Symbol, untyped], Array[Hash[Symbol, untyped]])


350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# File 'lib/riffer/providers/open_ai.rb', line 350

def convert_assistant_to_openai_format(message)
  if message.tool_calls.empty?
    {role: "assistant", content: message.content}
  else
    items = [] #: Array[Hash[Symbol, untyped]]
    items << {type: "message", role: "assistant", content: message.content} if message.content && !message.content.empty?
    message.tool_calls.each do |tc|
      items << {
        type: "function_call",
        call_id: tc.call_id,
        name: encode_tool_name(tc.name),
        arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
      }
    end
    items
  end
end

#convert_file_part_to_openai_format(file) ⇒ Hash[Symbol, untyped]

-- : (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


370
371
372
373
374
375
376
377
378
379
380
# File 'lib/riffer/providers/open_ai.rb', line 370

def convert_file_part_to_openai_format(file)
  if file.image?
    image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
    {type: "input_image", image_url: image_url}
  else
    data_uri = "data:#{file.media_type};base64,#{file.data}"
    block = {type: "input_file", file_data: data_uri}
    block[:filename] = file.filename if file.filename
    block
  end
end

#convert_messages_to_openai_format(messages) ⇒ Array[Hash[Symbol, untyped]]

-- : (Array) -> Array[Hash[Symbol, untyped]]

Parameters:

Returns:

  • (Array[Hash[Symbol, untyped]])


321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/riffer/providers/open_ai.rb', line 321

def convert_messages_to_openai_format(messages)
  messages.flat_map do |message|
    case message
    when Riffer::Messages::System
      {role: "developer", content: message.content}
    when Riffer::Messages::User
      if message.files.empty?
        {role: "user", content: message.content}
      else
        content = [{type: "input_text", text: message.content}]
        message.files.each { |file| content << convert_file_part_to_openai_format(file) }
        {role: "user", content: content}
      end
    when Riffer::Messages::Assistant
      convert_assistant_to_openai_format(message)
    when Riffer::Messages::Tool
      {
        type: "function_call_output",
        call_id: message.tool_call_id,
        output: message.content
      }
    else
      raise Riffer::ArgumentError, "unsupported message type: #{message.class}"
    end
  end
end

#convert_tool_to_openai_format(tool) ⇒ Hash[Symbol, untyped]

-- : (singleton(Riffer::Tool)) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


384
385
386
387
388
389
390
391
392
# File 'lib/riffer/providers/open_ai.rb', line 384

def convert_tool_to_openai_format(tool)
  {
    type: "function",
    name: encode_tool_name(tool.name),
    description: tool.description,
    parameters: tool.parameters_schema(strict: true),
    strict: true
  }
end

#execute_generate(params) ⇒ Object

-- : (Hash[Symbol, untyped]) -> untyped

Parameters:

  • (Hash[Symbol, untyped])

Returns:

  • (Object)


82
83
84
# File 'lib/riffer/providers/open_ai.rb', line 82

def execute_generate(params)
  @client.responses.create(params)
end

#execute_stream(params, yielder) ⇒ void

This method returns an undefined value.

-- : (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void

Parameters:



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
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/riffer/providers/open_ai.rb', line 185

def execute_stream(params, yielder)
  current_state = {
    tool_info: {}
  } #: Hash[Symbol, untyped]

  stream = @client.responses.stream(params)
  begin
    stream.each do |event|
      case event.type
      when :"response.output_item.added"
        handle_output_item_added_function_call(event, state: current_state, yielder: yielder) if event.item&.type == :function_call
      when :"response.output_text.delta"
        handle_output_text_delta(event, state: current_state, yielder: yielder)
      when :"response.output_text.done"
        handle_output_text_done(event, state: current_state, yielder: yielder)
      when :"response.reasoning_summary_text.delta"
        handle_reasoning_summary_text_delta(event, state: current_state, yielder: yielder)
      when :"response.reasoning_summary_text.done"
        handle_reasoning_summary_text_done(event, state: current_state, yielder: yielder)
      when :"response.function_call_arguments.delta"
        handle_function_call_arguments_delta(event, state: current_state, yielder: yielder)
      when :"response.function_call_arguments.done"
        handle_function_call_arguments_done(event, state: current_state, yielder: yielder)
      when :"response.web_search_call.in_progress"
        handle_web_search_status(event, status: "in_progress", yielder: yielder)
      when :"response.web_search_call.searching"
        handle_web_search_status(event, status: "searching", yielder: yielder)
      when :"response.web_search_call.completed"
        handle_web_search_status(event, status: "completed", yielder: yielder)
      when :"response.output_item.done"
        handle_output_item_done_web_search(event, yielder: yielder) if event.item&.type == :web_search_call
      when :"response.completed", :"response.incomplete", :"response.failed"
        handle_response_finished(event, state: current_state, yielder: yielder)
      end
    end
  ensure
    # OpenAI SDK does not auto-close the underlying HTTP stream when
    # iteration is interrupted (raise / fiber cancellation), so the SSE
    # socket leaks until GC. close is idempotent and a no-op after EOF.
    stream.close
  end
end

#extract_content(response) ⇒ String

-- : (untyped) -> String

Parameters:

  • (Object)

Returns:

  • (String)


150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/riffer/providers/open_ai.rb', line 150

def extract_content(response)
  typed_response = response #: OpenAI::Models::Responses::Response
  text_content = ""

  typed_response.output.each do |item|
    next unless item.is_a?(::OpenAI::Models::Responses::ResponseOutputMessage)

    text_block = item.content.find { |c| c.is_a?(::OpenAI::Models::Responses::ResponseOutputText) }
    text_content = text_block.text if text_block.is_a?(::OpenAI::Models::Responses::ResponseOutputText)
  end

  text_content
end

#extract_finish_reason(response) ⇒ Riffer::Providers::FinishReason?

-- : (untyped) -> Riffer::Providers::FinishReason?

Parameters:

  • (Object)

Returns:



108
109
110
# File 'lib/riffer/providers/open_ai.rb', line 108

def extract_finish_reason(response)
  build_finish_reason(response)
end

#extract_token_usage(response) ⇒ Riffer::Providers::TokenUsage?

-- : (untyped) -> Riffer::Providers::TokenUsage?

Parameters:

  • (Object)

Returns:



88
89
90
91
92
93
94
# File 'lib/riffer/providers/open_ai.rb', line 88

def extract_token_usage(response)
  typed_response = response #: OpenAI::Models::Responses::Response
  usage = typed_response.usage
  return nil unless usage

  build_token_usage(usage)
end

#extract_tool_calls(response) ⇒ Array[Riffer::Messages::Assistant::ToolCall]

-- : (untyped) -> Array

Parameters:

  • (Object)

Returns:



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/riffer/providers/open_ai.rb', line 166

def extract_tool_calls(response)
  typed_response = response #: OpenAI::Models::Responses::Response
  tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]

  typed_response.output.each do |item|
    next unless item.is_a?(::OpenAI::Models::Responses::ResponseFunctionToolCall)

    tool_calls << Riffer::Messages::Assistant::ToolCall.new(
      call_id: item.call_id,
      name: decode_tool_name(item.name, tools: @current_tools),
      arguments: item.arguments
    )
  end

  tool_calls
end

#handle_function_call_arguments_delta(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



263
264
265
266
267
268
269
270
# File 'lib/riffer/providers/open_ai.rb', line 263

def handle_function_call_arguments_delta(event, state:, yielder:)
  tracked = state[:tool_info][event.item_id] || {}
  yielder << Riffer::StreamEvents::ToolCallDelta.new(
    item_id: event.item_id,
    name: tracked[:name],
    arguments_delta: event.delta
  )
end

#handle_function_call_arguments_done(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



274
275
276
277
278
279
280
281
282
# File 'lib/riffer/providers/open_ai.rb', line 274

def handle_function_call_arguments_done(event, state:, yielder:)
  tracked = state[:tool_info][event.item_id] || {}
  yielder << Riffer::StreamEvents::ToolCallDone.new(
    item_id: event.item_id,
    call_id: tracked[:call_id] || event.item_id,
    name: tracked[:name],
    arguments: event.arguments
  )
end

#handle_output_item_added_function_call(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



230
231
232
233
234
235
# File 'lib/riffer/providers/open_ai.rb', line 230

def handle_output_item_added_function_call(event, state:, yielder:)
  state[:tool_info][event.item.id] = {
    name: decode_tool_name(event.item.name, tools: @current_tools),
    call_id: event.item.call_id
  }
end

#handle_output_item_done_web_search(event, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, yielder: Riffer::Providers::_EventSink) -> void

Parameters:



306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/riffer/providers/open_ai.rb', line 306

def handle_output_item_done_web_search(event, yielder:)
  action = event.item.action
  case action
  when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::OpenPage
    # OpenPage carries a url but no query or sources, so it doesn't fit
    # WebSearchDone — emit as a status notification instead.
    yielder << Riffer::StreamEvents::WebSearchStatus.new("open_page", url: action.url)
  when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
    sources = (action.sources || []).map { |s| {title: nil, url: s.url} }
    yielder << Riffer::StreamEvents::WebSearchDone.new(action.query || "", sources: sources)
  end
end

#handle_output_text_delta(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



239
240
241
# File 'lib/riffer/providers/open_ai.rb', line 239

def handle_output_text_delta(event, state:, yielder:)
  yielder << Riffer::StreamEvents::TextDelta.new(event.delta)
end

#handle_output_text_done(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



245
246
247
# File 'lib/riffer/providers/open_ai.rb', line 245

def handle_output_text_done(event, state:, yielder:)
  yielder << Riffer::StreamEvents::TextDone.new(event.text)
end

#handle_reasoning_summary_text_delta(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



251
252
253
# File 'lib/riffer/providers/open_ai.rb', line 251

def handle_reasoning_summary_text_delta(event, state:, yielder:)
  yielder << Riffer::StreamEvents::ReasoningDelta.new(event.delta)
end

#handle_reasoning_summary_text_done(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



257
258
259
# File 'lib/riffer/providers/open_ai.rb', line 257

def handle_reasoning_summary_text_done(event, state:, yielder:)
  yielder << Riffer::StreamEvents::ReasoningDone.new(event.text)
end

#handle_response_finished(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



286
287
288
289
290
291
292
293
294
295
296
# File 'lib/riffer/providers/open_ai.rb', line 286

def handle_response_finished(event, state:, yielder:)
  response = event.response
  return unless response

  yield_finish_reason(yielder, build_finish_reason(response))

  usage = response.usage
  return unless usage

  yielder << Riffer::StreamEvents::TokenUsageDone.new(token_usage: build_token_usage(usage))
end

#handle_web_search_status(_event, status:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, status: String, yielder: Riffer::Providers::_EventSink) -> void

Parameters:



300
301
302
# File 'lib/riffer/providers/open_ai.rb', line 300

def handle_web_search_status(_event, status:, yielder:)
  yielder << Riffer::StreamEvents::WebSearchStatus.new(status)
end

#incomplete_finish_reason(response) ⇒ Riffer::Providers::FinishReason

-- : (untyped) -> Riffer::Providers::FinishReason

Parameters:

  • (Object)

Returns:



135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/riffer/providers/open_ai.rb', line 135

def incomplete_finish_reason(response)
  typed_response = response #: OpenAI::Models::Responses::Response
  raw = typed_response.incomplete_details&.reason&.to_s

  reason = case raw
  when "max_output_tokens" then :length
  when "content_filter" then :content_filter
  else :other
  end

  Riffer::Providers::FinishReason.new(reason: reason, raw: raw || "incomplete")
end