openai 0.86.0 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +10 -1
  4. data/examples/audio/record_and_play.rb +19 -0
  5. data/examples/responses/websocket_workflows.rb +159 -0
  6. data/lib/openai/helpers/local_audio/errors.rb +27 -0
  7. data/lib/openai/helpers/local_audio/process.rb +158 -0
  8. data/lib/openai/helpers/local_audio.rb +157 -0
  9. data/lib/openai/helpers/realtime/connection.rb +20 -2
  10. data/lib/openai/helpers/realtime/errors.rb +18 -0
  11. data/lib/openai/helpers/realtime/recovery.rb +489 -0
  12. data/lib/openai/helpers/realtime/resources/realtime_extension.rb +26 -1
  13. data/lib/openai/helpers/realtime.rb +1 -0
  14. data/lib/openai/models/beta/beta_response_input_item.rb +84 -1
  15. data/lib/openai/models/beta/beta_response_item.rb +72 -1
  16. data/lib/openai/models/beta/beta_response_output_item.rb +84 -1
  17. data/lib/openai/models/beta/beta_responses_client_event.rb +3 -0
  18. data/lib/openai/models/beta/beta_tool.rb +69 -37
  19. data/lib/openai/models/beta/response_create_params.rb +3 -0
  20. data/lib/openai/models/conversations/conversation_item.rb +82 -1
  21. data/lib/openai/models/image_edit_completed_event.rb +34 -8
  22. data/lib/openai/models/image_edit_params.rb +64 -44
  23. data/lib/openai/models/image_edit_partial_image_event.rb +34 -8
  24. data/lib/openai/models/image_gen_completed_event.rb +34 -8
  25. data/lib/openai/models/image_gen_partial_image_event.rb +34 -8
  26. data/lib/openai/models/image_generate_params.rb +51 -35
  27. data/lib/openai/models/image_model.rb +4 -0
  28. data/lib/openai/models/images_response.rb +37 -13
  29. data/lib/openai/models/responses/response_create_params.rb +3 -0
  30. data/lib/openai/models/responses/response_input_item.rb +84 -1
  31. data/lib/openai/models/responses/response_item.rb +72 -1
  32. data/lib/openai/models/responses/response_output_item.rb +84 -1
  33. data/lib/openai/models/responses/responses_client_event.rb +3 -0
  34. data/lib/openai/models/responses/tool.rb +69 -37
  35. data/lib/openai/models/webhooks/live_call_incoming_webhook_event.rb +3 -3
  36. data/lib/openai/models/webhooks/realtime_call_incoming_webhook_event.rb +5 -3
  37. data/lib/openai/resources/images.rb +12 -14
  38. data/lib/openai/version.rb +1 -1
  39. data/local-audio.md +146 -0
  40. data/rbi/openai/helpers/local_audio/interface.rbi +110 -0
  41. data/rbi/openai/helpers/realtime/connection.rbi +18 -2
  42. data/rbi/openai/helpers/realtime/extensions.rbi +8 -0
  43. data/rbi/openai/helpers/realtime/recovery.rbi +27 -0
  44. data/rbi/openai/models/beta/beta_response_input_item.rbi +86 -1
  45. data/rbi/openai/models/beta/beta_response_item.rbi +78 -1
  46. data/rbi/openai/models/beta/beta_response_output_item.rbi +84 -1
  47. data/rbi/openai/models/beta/beta_responses_client_event.rbi +6 -0
  48. data/rbi/openai/models/beta/beta_tool.rbi +90 -51
  49. data/rbi/openai/models/beta/response_create_params.rbi +6 -0
  50. data/rbi/openai/models/conversations/conversation_item.rbi +88 -1
  51. data/rbi/openai/models/image_edit_completed_event.rbi +20 -9
  52. data/rbi/openai/models/image_edit_params.rbi +89 -60
  53. data/rbi/openai/models/image_edit_partial_image_event.rbi +20 -9
  54. data/rbi/openai/models/image_gen_completed_event.rbi +20 -9
  55. data/rbi/openai/models/image_gen_partial_image_event.rbi +20 -9
  56. data/rbi/openai/models/image_generate_params.rbi +71 -48
  57. data/rbi/openai/models/image_model.rbi +4 -0
  58. data/rbi/openai/models/images_response.rbi +27 -16
  59. data/rbi/openai/models/responses/response_create_params.rbi +6 -0
  60. data/rbi/openai/models/responses/response_input_item.rbi +88 -1
  61. data/rbi/openai/models/responses/response_item.rbi +80 -1
  62. data/rbi/openai/models/responses/response_output_item.rbi +84 -1
  63. data/rbi/openai/models/responses/responses_client_event.rbi +6 -0
  64. data/rbi/openai/models/responses/tool.rbi +90 -51
  65. data/rbi/openai/models/webhooks/live_call_incoming_webhook_event.rbi +4 -4
  66. data/rbi/openai/models/webhooks/realtime_call_incoming_webhook_event.rbi +8 -4
  67. data/rbi/openai/resources/beta/responses.rbi +6 -2
  68. data/rbi/openai/resources/images.rbi +106 -76
  69. data/rbi/openai/resources/responses.rbi +6 -0
  70. data/realtime.md +101 -0
  71. data/responses-websocket.md +182 -0
  72. data/sig/openai/helpers/local_audio/interface.rbs +62 -0
  73. data/sig/openai/helpers/realtime/connection.rbs +9 -1
  74. data/sig/openai/helpers/realtime/extensions.rbs +4 -0
  75. data/sig/openai/helpers/realtime/recovery.rbs +19 -0
  76. data/sig/openai/models/beta/beta_response_input_item.rbs +39 -2
  77. data/sig/openai/models/beta/beta_response_item.rbs +39 -2
  78. data/sig/openai/models/beta/beta_response_output_item.rbs +39 -2
  79. data/sig/openai/models/beta/beta_tool.rbs +11 -1
  80. data/sig/openai/models/conversations/conversation_item.rbs +39 -2
  81. data/sig/openai/models/image_edit_completed_event.rbs +11 -4
  82. data/sig/openai/models/image_edit_params.rbs +3 -1
  83. data/sig/openai/models/image_edit_partial_image_event.rbs +11 -4
  84. data/sig/openai/models/image_gen_completed_event.rbs +11 -4
  85. data/sig/openai/models/image_gen_partial_image_event.rbs +11 -4
  86. data/sig/openai/models/image_generate_params.rbs +4 -1
  87. data/sig/openai/models/image_model.rbs +8 -0
  88. data/sig/openai/models/images_response.rbs +10 -4
  89. data/sig/openai/models/responses/response_input_item.rbs +39 -2
  90. data/sig/openai/models/responses/response_item.rbs +39 -2
  91. data/sig/openai/models/responses/response_output_item.rbs +39 -2
  92. data/sig/openai/models/responses/tool.rbs +11 -1
  93. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e2fc3cef6cd029385cb528885096cbf0640df90c238752af2ef76186c578578
4
- data.tar.gz: 5ab2eac3ec679b9d43a141ec43ef70b294ed09a5e4f72f9a3c272cd407cbe3dc
3
+ metadata.gz: e2541f0eac1ef08259b89e4baa93434e632c2c46a17b1a67e02fd25d167b77d4
4
+ data.tar.gz: 2d27951debe6e1fd0a42e2817cf59c02dc01d4c8ca8682070cbdd2d14d7e46db
5
5
  SHA512:
6
- metadata.gz: e80dd1fe954a867a454f08d6349689d71f731f1908add8fc5d48e25605a431202cd6f9366e0c30070ae5273195b37116e33a606f6a26de89957d23bea6dd855b
7
- data.tar.gz: b87d683d9a0cbc8b59fcb6ddc31b9b1a1737455d7353bf7466736a375955018946f18ba753c79362f94fec9ef9b52f70dd90405cfd94d600e407aaf0b4d64ad5
6
+ metadata.gz: 94c5255cec9b29f277dabe60b6825dd4a1edb7be86ce25162e25f2c6f3bf072c38a19297b047c0177c6553e2ff8bcd06a935d8a841e4b6d2e480377fd5bf667b
7
+ data.tar.gz: 9a638b78473f54fa9238a46e6ad8ec419bc892e94fe1303292268174d6203566f095e778631b2bb65da61191950cfedac2c31f4c1eea287a13d46b3d632ba05f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.87.0](https://github.com/openai/openai-ruby/compare/v0.86.0...v0.87.0) (2026-09-08)
4
+
5
+
6
+ ### Features
7
+
8
+ * add FFmpeg local recording and playback helpers ([#685](https://github.com/openai/openai-ruby/issues/685)) ([4d05097](https://github.com/openai/openai-ruby/commit/4d05097f10408452aaf0efa48c499bada62846bb))
9
+ * add opt-in Realtime reconnect and send queue ([#681](https://github.com/openai/openai-ruby/issues/681)) ([21b8a70](https://github.com/openai/openai-ruby/commit/21b8a70d48e2623e32aa01f95503adcf3fa2ba75))
10
+ * **api:** add GPT Image 2.5 models and image options ([#688](https://github.com/openai/openai-ruby/issues/688)) ([02f4724](https://github.com/openai/openai-ruby/commit/02f4724eb69eebe8c46b8d51010bee364e1197ad))
11
+
3
12
  ## [0.86.0](https://github.com/openai/openai-ruby/compare/v0.85.0...v0.86.0) (2026-09-08)
4
13
 
5
14
 
data/README.md CHANGED
@@ -15,7 +15,7 @@ To use this gem, install via Bundler by adding the following to your application
15
15
  <!-- x-release-please-start-version -->
16
16
 
17
17
  ```ruby
18
- gem "openai", "~> 0.86.0"
18
+ gem "openai", "~> 0.87.0"
19
19
  ```
20
20
 
21
21
  <!-- x-release-please-end -->
@@ -50,6 +50,11 @@ stream.each do |event|
50
50
  end
51
51
  ```
52
52
 
53
+ ### Local audio
54
+
55
+ Record a clip and play generated speech with optional FFmpeg/FFplay helpers.
56
+ See the [local audio guide](local-audio.md) for setup, device selection and examples.
57
+
53
58
  ### Realtime WebSockets
54
59
 
55
60
  The SDK supports block-scoped, typed Realtime WebSocket sessions for
@@ -112,6 +117,10 @@ does not automatically reconnect or replay an ambiguous write. When the server
112
117
  closes a connection, open a new connection and continue with
113
118
  `previous_response_id` when the response was stored.
114
119
 
120
+ See the [Responses WebSocket guide](responses-websocket.md) and
121
+ [runnable workflows](examples/responses/websocket_workflows.rb) for tool turns,
122
+ multiplexing, stored continuation, stateless replay, and operation deadlines.
123
+
115
124
  ### Pagination
116
125
 
117
126
  List methods in the OpenAI API are paginated.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # typed: strict
4
+
5
+ require "openai"
6
+ require "openai/helpers/local_audio"
7
+
8
+ # Running this example explicitly records five seconds from the selected local
9
+ # microphone and sends that recording to OpenAI. FFmpeg and FFplay are required.
10
+ TRANSCRIPTION_MODEL = "gpt-transcribe"
11
+ SPEECH_MODEL = "gpt-4o-mini-tts"
12
+ VOICE = "marin"
13
+
14
+ client = OpenAI::Client.new
15
+ clip = OpenAI::LocalAudio.record(duration: 5, timeout: 10)
16
+ transcription = client.audio.transcriptions.create(model: TRANSCRIPTION_MODEL, file: clip)
17
+ puts(transcription.text)
18
+ speech = client.audio.speech.create(model: SPEECH_MODEL, voice: VOICE, input: "Recording complete.")
19
+ OpenAI::LocalAudio.play(speech, timeout: 30)
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "async"
5
+
6
+ require_relative "../../lib/openai"
7
+
8
+ module OpenAI
9
+ module Examples
10
+ module ResponsesWebSocketWorkflows
11
+ module_function
12
+
13
+ # One reader drains all requested lanes. This example fails the entire
14
+ # operation on an error; applications can instead isolate lane-scoped errors.
15
+ def completed_responses(connection, lanes: [nil])
16
+ pending = lanes.dup
17
+ responses = {}
18
+ connection.each do |event|
19
+ case event.type.to_s
20
+ when "response.completed"
21
+ next unless pending.include?(event.stream_id)
22
+
23
+ responses[event.stream_id] = event.response
24
+ pending.delete(event.stream_id)
25
+ break if pending.empty?
26
+ when "response.failed", "response.incomplete", "error"
27
+ raise "Responses WebSocket operation did not complete."
28
+ end
29
+ end
30
+
31
+ raise "Responses WebSocket closed with unfinished work." unless pending.empty?
32
+
33
+ responses
34
+ end
35
+
36
+ def tools(client:, model:)
37
+ client.responses.connect do |connection|
38
+ connection.response.create(
39
+ model: model,
40
+ input: "What day will my example delivery arrive?",
41
+ tools: [
42
+ {
43
+ type: :function,
44
+ name: "get_delivery_day",
45
+ description: "Return the day of the example delivery.",
46
+ parameters: {type: :object, properties: {}, required: [], additionalProperties: false},
47
+ strict: true
48
+ }
49
+ ],
50
+ tool_choice: {type: :function, name: "get_delivery_day"},
51
+ parallel_tool_calls: false,
52
+ store: false
53
+ )
54
+ first = completed_responses(connection).fetch(nil)
55
+ calls = first.output.select { |item| item.type.to_s == "function_call" }
56
+ unless calls.length == 1 && calls.first.name == "get_delivery_day"
57
+ raise "Expected the example delivery tool call."
58
+ end
59
+
60
+ # Execute only this application's known tool. Never eval model output.
61
+ connection.response.create(
62
+ model: model,
63
+ previous_response_id: first.id,
64
+ input: [{type: :function_call_output, call_id: calls.first.call_id, output: "Tuesday"}],
65
+ store: false
66
+ )
67
+ completed_responses(connection)
68
+ end
69
+ end
70
+
71
+ def multiplex(client:, model:)
72
+ client.responses.connect do |connection|
73
+ %w[planner critic].each do |lane|
74
+ connection.response.create(
75
+ model: model,
76
+ input: "Say hello as the #{lane}.",
77
+ stream_id: lane,
78
+ store: false
79
+ )
80
+ end
81
+
82
+ completed_responses(connection, lanes: %w[planner critic])
83
+ end
84
+ end
85
+
86
+ # Deliberately rotate a healthy connection after a completed turn. This does
87
+ # not retry a failed write or assume an unfinished response was accepted.
88
+ def reconnect(client:, model:, store:)
89
+ input = [{role: :user, content: "Remember the example delivery day: Tuesday."}]
90
+ first = client.responses.connect do |connection|
91
+ connection.response.create(
92
+ model: model,
93
+ input: input,
94
+ store: store,
95
+ include: ["reasoning.encrypted_content"]
96
+ )
97
+ completed_responses(connection).fetch(nil)
98
+ end
99
+
100
+ follow_up = {role: :user, content: "What is the example delivery day?"}
101
+ client.responses.connect do |connection|
102
+ if store
103
+ connection.response.create(
104
+ model: model,
105
+ input: [follow_up],
106
+ previous_response_id: first.id,
107
+ store: true
108
+ )
109
+ else
110
+ # Preserve every output item, including encrypted reasoning. The new
111
+ # connection cannot use the old connection's store=false cache.
112
+ connection.response.create(
113
+ model: model,
114
+ input: input + first.output + [follow_up],
115
+ store: false
116
+ )
117
+ end
118
+
119
+ completed_responses(connection)
120
+ end
121
+ end
122
+
123
+ def run(client:, model:, workflow:, timeout: 60, output: $stdout)
124
+ Sync do |task|
125
+ task.with_timeout(timeout) do
126
+ case workflow
127
+ when "tools"
128
+ tools(client: client, model: model)
129
+ when "multiplex"
130
+ multiplex(client: client, model: model)
131
+ when "stored"
132
+ reconnect(client: client, model: model, store: true)
133
+ when "stateless"
134
+ reconnect(client: client, model: model, store: false)
135
+ else
136
+ raise ArgumentError, "Choose tools, multiplex, stored, or stateless."
137
+ end
138
+ end
139
+ end
140
+
141
+ output.puts("Responses WebSocket workflow completed.")
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ if $PROGRAM_NAME == __FILE__
148
+ begin
149
+ OpenAI::Examples::ResponsesWebSocketWorkflows.run(
150
+ client: OpenAI::Client.new,
151
+ model: ENV.fetch("OPENAI_RESPONSES_MODEL", "gpt-5.2"),
152
+ workflow: ARGV.fetch(0, "multiplex"),
153
+ timeout: Float(ENV.fetch("OPENAI_RESPONSES_TIMEOUT", "60"))
154
+ )
155
+ rescue StandardError
156
+ warn("Responses WebSocket workflow failed; no request or response contents were logged.")
157
+ exit(1)
158
+ end
159
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAI
4
+ module LocalAudio
5
+ class Error < OpenAI::Errors::Error
6
+ end
7
+
8
+ class DependencyError < Error
9
+ end
10
+
11
+ class UnsupportedPlatformError < Error
12
+ end
13
+
14
+ class DeviceError < Error
15
+ end
16
+
17
+ class PlaybackError < Error
18
+ end
19
+
20
+ class TimeoutError < Error
21
+ end
22
+
23
+ class FormatError < Error
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+
5
+ module OpenAI
6
+ module LocalAudio
7
+ # An owned media child. No credentials, shell, inherited descriptors or diagnostics.
8
+ # @api private
9
+ class MediaProcess
10
+ ENVIRONMENT_KEYS = %w[
11
+ PATH
12
+ HOME
13
+ TMPDIR
14
+ LANG
15
+ LC_ALL
16
+ SystemRoot
17
+ WINDIR
18
+ USERPROFILE
19
+ APPDATA
20
+ LOCALAPPDATA
21
+ XDG_RUNTIME_DIR
22
+ PULSE_SERVER
23
+ SDL_AUDIODRIVER
24
+ ]
25
+ .freeze
26
+ private_constant :ENVIRONMENT_KEYS
27
+
28
+ def self.environment = ENV.to_h.slice(*ENVIRONMENT_KEYS)
29
+ def self.clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
30
+
31
+ def self.duration(value, name:)
32
+ unless value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
33
+ raise ArgumentError, "#{name} must be a positive finite number of seconds"
34
+ end
35
+
36
+ result = value.to_f
37
+ raise ArgumentError, "#{name} is not representable" unless result.finite?
38
+ result
39
+ end
40
+
41
+ def initialize(argv, input:, output:, timeout:, capture_errors: false)
42
+ @deadline = self.class.clock + self.class.duration(timeout, name: :timeout) unless timeout.nil?
43
+ @error_reader, error_writer = IO.pipe(binmode: true) if capture_errors
44
+ @pid = Process.spawn(
45
+ self.class.environment,
46
+ *argv,
47
+ in: input,
48
+ out: output,
49
+ err: error_writer || File::NULL,
50
+ close_others: true,
51
+ unsetenv_others: true
52
+ )
53
+ rescue Errno::ENOENT
54
+ @error_reader&.close
55
+ raise DependencyError.new("Install the required FFmpeg/FFplay executable on PATH."), cause: nil
56
+ rescue SystemCallError
57
+ @error_reader&.close
58
+ raise DeviceError.new("Cannot start the local audio executable."), cause: nil
59
+ ensure
60
+ error_writer&.close
61
+ end
62
+
63
+ def check_deadline
64
+ raise TimeoutError, "Local audio operation timed out." if @deadline && self.class.clock >= @deadline
65
+ errors?
66
+ end
67
+
68
+ # Drain at most one chunk per polling step, retaining no diagnostic content.
69
+ def errors?
70
+ if @error_reader && !@error_reader.closed?
71
+ chunk = @error_reader.read_nonblock(65_536, exception: false)
72
+ @errors = true if chunk.is_a?(String) && !chunk.empty?
73
+ end
74
+
75
+ !!@errors
76
+ end
77
+
78
+ def read(io, stop_on_exit: false)
79
+ loop do
80
+ check_deadline
81
+ return io.read(65_536) if io.is_a?(StringIO)
82
+ result = io.read_nonblock(65_536, exception: false)
83
+ return result unless result == :wait_readable
84
+ if stop_on_exit
85
+ reap
86
+ raise PlaybackError, "Audio player exited before the input ended." if @status
87
+ end
88
+
89
+ io.wait_readable(0.05)
90
+ end
91
+ end
92
+
93
+ def write(io, bytes)
94
+ offset = 0
95
+ while offset < bytes.bytesize
96
+ check_deadline
97
+ result = io.write_nonblock(bytes.byteslice(offset, 65_536), exception: false)
98
+ if result == :wait_writable
99
+ io.wait_writable(0.05)
100
+ else
101
+ offset += result
102
+ end
103
+ end
104
+
105
+ rescue Errno::EPIPE
106
+ raise PlaybackError.new("Audio player closed before receiving the input."), cause: nil
107
+ end
108
+
109
+ def wait
110
+ until @status
111
+ check_deadline
112
+ reap
113
+ sleep(0.01) unless @status
114
+ end
115
+
116
+ @status
117
+ end
118
+
119
+ def close
120
+ return unless @pid
121
+ reap
122
+ unless @status
123
+ signal("TERM")
124
+ deadline = self.class.clock + 0.25
125
+ until @status || self.class.clock >= deadline
126
+ reap
127
+ sleep(0.01) unless @status
128
+ end
129
+
130
+ unless @status
131
+ signal("KILL")
132
+ _, @status = Process.waitpid2(@pid)
133
+ end
134
+ end
135
+
136
+ ensure
137
+ @pid = nil
138
+ @error_reader&.close unless @error_reader&.closed?
139
+ end
140
+
141
+ private def reap
142
+ return if @status
143
+ result = Process.waitpid2(@pid, Process::WNOHANG)
144
+ @status = result.last if result
145
+ end
146
+
147
+ private def signal(name)
148
+ Process.kill(name, @pid)
149
+ rescue Errno::EINVAL
150
+ # Windows rejects TERM for another process; KILL still terminates it.
151
+ raise unless name == "TERM"
152
+ signal("KILL")
153
+ rescue Errno::ESRCH
154
+ nil
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../openai"
4
+ require_relative "local_audio/errors"
5
+ require_relative "local_audio/process"
6
+
7
+ module OpenAI
8
+ # Optional local microphone and speaker helpers. Loading this file opens no devices.
9
+ module LocalAudio
10
+ class << self
11
+ # Record a finite mono 24 kHz WAV, ready for a multipart audio upload.
12
+ # @return [OpenAI::FilePart]
13
+ def record(duration:, device: nil, timeout: nil)
14
+ seconds = MediaProcess.duration(duration, name: :duration)
15
+ MediaProcess.duration(timeout, name: :timeout) unless timeout.nil?
16
+ format, source = capture_device(device)
17
+ reader, writer = IO.pipe(binmode: true)
18
+ child = nil
19
+ begin
20
+ child = MediaProcess.new(
21
+ [
22
+ "ffmpeg",
23
+ "-nostdin",
24
+ "-hide_banner",
25
+ "-loglevel",
26
+ "quiet",
27
+ "-f",
28
+ format,
29
+ "-i",
30
+ source,
31
+ "-t",
32
+ seconds.to_s,
33
+ "-ar",
34
+ "24000",
35
+ "-ac",
36
+ "1",
37
+ "-acodec",
38
+ "pcm_s16le",
39
+ "-f",
40
+ "s16le",
41
+ "pipe:1"
42
+ ],
43
+ input: File::NULL,
44
+ output: writer,
45
+ timeout: timeout
46
+ )
47
+ writer.close
48
+ pcm = +"".b
49
+ while (chunk = child.read(reader))
50
+ pcm << chunk
51
+ end
52
+
53
+ raise DeviceError, "Microphone capture failed." unless child.wait.success?
54
+ raise DeviceError, "Microphone returned no complete audio samples." if pcm.empty? || pcm.bytesize.odd?
55
+ audio = StringIO.new(wav_header(pcm.bytesize) << pcm)
56
+ OpenAI::FilePart.new(audio, filename: "audio.wav", content_type: "audio/wav")
57
+ ensure
58
+ reader.close unless reader.closed?
59
+ writer.close unless writer.closed?
60
+ child&.close
61
+ end
62
+ end
63
+
64
+ # Play encoded audio, or explicitly selected mono 24 kHz raw PCM16.
65
+ # The source is consumed from its current position and remains caller-owned.
66
+ # @return [nil]
67
+ def play(source, format: :auto, timeout: nil)
68
+ raise ArgumentError, "format must be :auto or :pcm" unless [:auto, :pcm].include?(format)
69
+ MediaProcess.duration(timeout, name: :timeout) unless timeout.nil?
70
+ io = playback_source(source)
71
+ reader, writer = IO.pipe(binmode: true)
72
+ child = nil
73
+ begin
74
+ # FFplay can exit zero on decoder/device failure. Its owner drains
75
+ # error output while feeding input, retaining only a boolean.
76
+ args = ["ffplay", "-autoexit", "-nodisp", "-nostats", "-loglevel", "error", "-protocol_whitelist", "pipe"]
77
+ args.concat(
78
+ format == :pcm ? ["-f", "s16le", "-ar", "24000", "-ch_layout", "mono"] : [
79
+ "-format_whitelist",
80
+ "wav,mp3,ogg,aac,flac"
81
+ ]
82
+ )
83
+ child = MediaProcess.new(
84
+ args + ["-i", "pipe:0"],
85
+ input: reader,
86
+ output: File::NULL,
87
+ capture_errors: true,
88
+ timeout: timeout
89
+ )
90
+ reader.close
91
+ bytes = 0
92
+ while (chunk = child.read(io, stop_on_exit: true))
93
+ break if chunk.empty?
94
+ bytes += chunk.bytesize
95
+ child.write(writer, chunk)
96
+ end
97
+
98
+ raise PlaybackError, "Audio input is empty." if bytes.zero?
99
+ raise FormatError, "PCM input ends with an incomplete sample." if format == :pcm && bytes.odd?
100
+ writer.close
101
+ status = child.wait
102
+ raise PlaybackError, "Audio playback failed." unless status.success? && !child.errors?
103
+ nil
104
+ ensure
105
+ reader.close unless reader.closed?
106
+ writer.close unless writer.closed?
107
+ child&.close
108
+ end
109
+ end
110
+
111
+ # @api private
112
+ def playback_source(source)
113
+ content = source.is_a?(OpenAI::FilePart) ? source.content : source
114
+ return content if content.is_a?(IO) || content.is_a?(StringIO)
115
+ return StringIO.new(content) if source.is_a?(OpenAI::FilePart) && content.is_a?(String)
116
+ raise ArgumentError, "source must be IO, StringIO, or a FilePart containing IO or bytes"
117
+ end
118
+
119
+ # @api private
120
+ def capture_device(device, platform: RUBY_PLATFORM)
121
+ unless device.nil? || device.is_a?(String) || (device.is_a?(Integer) && device >= 0)
122
+ raise ArgumentError, "device must be a device name or nonnegative index"
123
+ end
124
+
125
+ raise ArgumentError, "invalid device identifier" if device.to_s.include?("\0") || device == ""
126
+ case platform
127
+ when /darwin/
128
+ ["avfoundation", "none:#{device || "default"}"]
129
+ when /linux/
130
+ ["alsa", device.is_a?(Integer) ? "hw:#{device}" : (device || "default")]
131
+ when /mswin|mingw|cygwin/
132
+ raise ArgumentError, "Windows capture requires an explicit audio device name" unless device.is_a?(String)
133
+ ["dshow", "audio=#{device}"]
134
+ else
135
+ raise UnsupportedPlatformError, "Local capture is not supported on this platform."
136
+ end
137
+ end
138
+
139
+ # @api private
140
+ def wav_header(bytes)
141
+ format = "fmt " + [16, 1, 1, 24_000, 48_000, 2, 16].pack("VvvVVvv")
142
+ if bytes <= 0xFFFFFFFF - 36
143
+ "RIFF" + [bytes + 36].pack("V") + "WAVE" + format + "data" + [bytes].pack("V")
144
+ else
145
+ "RF64" +
146
+ [0xFFFFFFFF].pack("V") +
147
+ "WAVEds64" +
148
+ [28].pack("V") +
149
+ [bytes + 72, bytes, bytes / 2, 0].pack("Q<Q<Q<V") +
150
+ format +
151
+ "data" +
152
+ [0xFFFFFFFF].pack("V")
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -19,8 +19,9 @@ module OpenAI
19
19
  attr_reader :input_audio_buffer
20
20
 
21
21
  # @api private
22
- def initialize(socket:, url:)
23
- super
22
+ def initialize(socket:, url:, recovery: nil)
23
+ super(socket: socket, url: url)
24
+ @recovery = recovery
24
25
  @server_event_names = discriminator_values(OpenAI::Realtime::RealtimeServerEvent)
25
26
  @client_event_names = discriminator_values(OpenAI::Realtime::RealtimeClientEvent)
26
27
  @session = OpenAI::Realtime::ConnectionResources::Session.new(self)
@@ -60,6 +61,23 @@ module OpenAI
60
61
  send_raw(encode_client_event(event))
61
62
  end
62
63
 
64
+ # Whether automatic recovery is opening or preparing a replacement socket.
65
+ def reconnecting? = @recovery ? @recovery.reconnecting? : false
66
+
67
+ # Snapshot of retained events, including the current event during a flush.
68
+ # These may contain sensitive application data. Normal connections return [].
69
+ def pending_messages = @recovery ? @recovery.pending_messages : []
70
+
71
+ # Remove and return retained events without sending them.
72
+ def take_pending_messages = @recovery ? @recovery.take_pending_messages : []
73
+
74
+ # Explicitly send retained events in order on the current session. Restore
75
+ # any required session state first. Does not retry uncertain failed writes.
76
+ def flush_pending
77
+ @recovery&.flush_pending
78
+ nil
79
+ end
80
+
63
81
  private def encode_client_event(event)
64
82
  if event.is_a?(Hash)
65
83
  validate_discriminator!(event, @client_event_names, kind: "client")
@@ -2,6 +2,24 @@
2
2
 
3
3
  module OpenAI
4
4
  module Errors
5
+ # A disconnected send could not be retained within the caller's byte budget.
6
+ class RealtimeQueueFullError < OpenAI::Errors::Error
7
+ def initialize = super("Realtime send queue is full; event was not accepted.")
8
+ end
9
+
10
+ # Terminal recovery failure. Payloads are available only through explicit
11
+ # accessors, never included in the message or default exception rendering.
12
+ class RealtimeReconnectError < OpenAI::Errors::Error
13
+ attr_reader :unsent_messages, :uncertain_message
14
+
15
+ # @api private
16
+ def initialize(message:, unsent_messages: [], uncertain_message: nil)
17
+ @unsent_messages = unsent_messages.dup.freeze
18
+ @uncertain_message = uncertain_message
19
+ super(message)
20
+ end
21
+ end
22
+
5
23
  # Raised when a Realtime WebSocket cannot be opened or used.
6
24
  class RealtimeConnectionError < OpenAI::Errors::WebSocketConnectionError
7
25
  private def default_message = "Realtime WebSocket connection error."