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 =

Signature:

  • 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
23
# File 'lib/riffer/providers/open_ai.rb', line 17

def initialize(**options)
  super()
  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:



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

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])


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
79
# File 'lib/riffer/providers/open_ai.rb', line 29

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:



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

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]])


355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/riffer/providers/open_ai.rb', line 355

def convert_assistant_to_openai_format(message)
  if message.tool_calls.empty?
    { role: "assistant", content: message.content }
  else
    items = [] #: Array[Hash[Symbol, untyped]]
    if message.content && !message.content.empty?
      items << { type: "message", role: "assistant",
                 content: message.content, }
    end
    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])


378
379
380
381
382
383
384
385
386
387
388
# File 'lib/riffer/providers/open_ai.rb', line 378

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]])


326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/riffer/providers/open_ai.rb', line 326

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])


392
393
394
395
396
397
398
399
400
# File 'lib/riffer/providers/open_ai.rb', line 392

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)


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

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:



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
227
228
229
230
231
# File 'lib/riffer/providers/open_ai.rb', line 188

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"
        if event.item&.type == :function_call
          handle_output_item_added_function_call(event, state: current_state, yielder: yielder)
        end
      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)


153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/riffer/providers/open_ai.rb', line 153

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:



111
112
113
# File 'lib/riffer/providers/open_ai.rb', line 111

def extract_finish_reason(response)
  build_finish_reason(response)
end

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

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

Parameters:

  • (Object)

Returns:



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

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:



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

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:



268
269
270
271
272
273
274
275
# File 'lib/riffer/providers/open_ai.rb', line 268

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:



279
280
281
282
283
284
285
286
287
# File 'lib/riffer/providers/open_ai.rb', line 279

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:



235
236
237
238
239
240
# File 'lib/riffer/providers/open_ai.rb', line 235

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:



311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/riffer/providers/open_ai.rb', line 311

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:



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

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:



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

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:



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

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:



262
263
264
# File 'lib/riffer/providers/open_ai.rb', line 262

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:



291
292
293
294
295
296
297
298
299
300
301
# File 'lib/riffer/providers/open_ai.rb', line 291

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:



305
306
307
# File 'lib/riffer/providers/open_ai.rb', line 305

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:



138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/riffer/providers/open_ai.rb', line 138

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