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

#initializeOpenAI

-- : () -> void



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

def initialize
  super
  depends_on "openai"
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_clientObject

Compacted so an unset value stays absent: the SDK reads OPENAI_API_KEY / OPENAI_BASE_URL only for a missing argument, and an explicit nil would suppress that fallback.

: () -> untyped

Returns:

  • (Object)


35
36
37
38
39
40
# File 'lib/riffer/providers/open_ai.rb', line 35

def build_client
  ::OpenAI::Client.new(**{
    api_key: Riffer.config.openai.api_key,
    base_url: Riffer.config.openai.base_url,
  }.compact)
end

#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:



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

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


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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/riffer/providers/open_ai.rb', line 44

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:



114
115
116
117
118
119
120
121
122
# File 'lib/riffer/providers/open_ai.rb', line 114

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

#client::OpenAI::Client

Returns:

  • (::OpenAI::Client)


5
# File 'sig/_private/riffer/providers/open_ai.rbs', line 5

def client: () -> ::OpenAI::Client

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


370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/riffer/providers/open_ai.rb', line 370

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


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

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


341
342
343
344
345
346
347
348
349
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 341

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


407
408
409
410
411
412
413
414
415
# File 'lib/riffer/providers/open_ai.rb', line 407

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)


98
99
100
# File 'lib/riffer/providers/open_ai.rb', line 98

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:



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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/riffer/providers/open_ai.rb', line 203

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)


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

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:



126
127
128
# File 'lib/riffer/providers/open_ai.rb', line 126

def extract_finish_reason(response)
  build_finish_reason(response)
end

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

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

Parameters:

  • (Object)

Returns:



104
105
106
107
108
109
110
# File 'lib/riffer/providers/open_ai.rb', line 104

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:



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/riffer/providers/open_ai.rb', line 184

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

#global_clientObject

-- : () -> untyped

Returns:

  • (Object)


26
27
28
# File 'lib/riffer/providers/open_ai.rb', line 26

def global_client
  Riffer.config.openai.client
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:



283
284
285
286
287
288
289
290
# File 'lib/riffer/providers/open_ai.rb', line 283

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:



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

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:



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

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:



326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/riffer/providers/open_ai.rb', line 326

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:



259
260
261
# File 'lib/riffer/providers/open_ai.rb', line 259

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:



265
266
267
# File 'lib/riffer/providers/open_ai.rb', line 265

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:



271
272
273
# File 'lib/riffer/providers/open_ai.rb', line 271

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:



277
278
279
# File 'lib/riffer/providers/open_ai.rb', line 277

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:



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

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:



320
321
322
# File 'lib/riffer/providers/open_ai.rb', line 320

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:



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

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