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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +10 -1
- data/examples/audio/record_and_play.rb +19 -0
- data/examples/responses/websocket_workflows.rb +159 -0
- data/lib/openai/helpers/local_audio/errors.rb +27 -0
- data/lib/openai/helpers/local_audio/process.rb +158 -0
- data/lib/openai/helpers/local_audio.rb +157 -0
- data/lib/openai/helpers/realtime/connection.rb +20 -2
- data/lib/openai/helpers/realtime/errors.rb +18 -0
- data/lib/openai/helpers/realtime/recovery.rb +489 -0
- data/lib/openai/helpers/realtime/resources/realtime_extension.rb +26 -1
- data/lib/openai/helpers/realtime.rb +1 -0
- data/lib/openai/models/beta/beta_response_input_item.rb +84 -1
- data/lib/openai/models/beta/beta_response_item.rb +72 -1
- data/lib/openai/models/beta/beta_response_output_item.rb +84 -1
- data/lib/openai/models/beta/beta_responses_client_event.rb +3 -0
- data/lib/openai/models/beta/beta_tool.rb +69 -37
- data/lib/openai/models/beta/response_create_params.rb +3 -0
- data/lib/openai/models/conversations/conversation_item.rb +82 -1
- data/lib/openai/models/image_edit_completed_event.rb +34 -8
- data/lib/openai/models/image_edit_params.rb +64 -44
- data/lib/openai/models/image_edit_partial_image_event.rb +34 -8
- data/lib/openai/models/image_gen_completed_event.rb +34 -8
- data/lib/openai/models/image_gen_partial_image_event.rb +34 -8
- data/lib/openai/models/image_generate_params.rb +51 -35
- data/lib/openai/models/image_model.rb +4 -0
- data/lib/openai/models/images_response.rb +37 -13
- data/lib/openai/models/responses/response_create_params.rb +3 -0
- data/lib/openai/models/responses/response_input_item.rb +84 -1
- data/lib/openai/models/responses/response_item.rb +72 -1
- data/lib/openai/models/responses/response_output_item.rb +84 -1
- data/lib/openai/models/responses/responses_client_event.rb +3 -0
- data/lib/openai/models/responses/tool.rb +69 -37
- data/lib/openai/models/webhooks/live_call_incoming_webhook_event.rb +3 -3
- data/lib/openai/models/webhooks/realtime_call_incoming_webhook_event.rb +5 -3
- data/lib/openai/resources/images.rb +12 -14
- data/lib/openai/version.rb +1 -1
- data/local-audio.md +146 -0
- data/rbi/openai/helpers/local_audio/interface.rbi +110 -0
- data/rbi/openai/helpers/realtime/connection.rbi +18 -2
- data/rbi/openai/helpers/realtime/extensions.rbi +8 -0
- data/rbi/openai/helpers/realtime/recovery.rbi +27 -0
- data/rbi/openai/models/beta/beta_response_input_item.rbi +86 -1
- data/rbi/openai/models/beta/beta_response_item.rbi +78 -1
- data/rbi/openai/models/beta/beta_response_output_item.rbi +84 -1
- data/rbi/openai/models/beta/beta_responses_client_event.rbi +6 -0
- data/rbi/openai/models/beta/beta_tool.rbi +90 -51
- data/rbi/openai/models/beta/response_create_params.rbi +6 -0
- data/rbi/openai/models/conversations/conversation_item.rbi +88 -1
- data/rbi/openai/models/image_edit_completed_event.rbi +20 -9
- data/rbi/openai/models/image_edit_params.rbi +89 -60
- data/rbi/openai/models/image_edit_partial_image_event.rbi +20 -9
- data/rbi/openai/models/image_gen_completed_event.rbi +20 -9
- data/rbi/openai/models/image_gen_partial_image_event.rbi +20 -9
- data/rbi/openai/models/image_generate_params.rbi +71 -48
- data/rbi/openai/models/image_model.rbi +4 -0
- data/rbi/openai/models/images_response.rbi +27 -16
- data/rbi/openai/models/responses/response_create_params.rbi +6 -0
- data/rbi/openai/models/responses/response_input_item.rbi +88 -1
- data/rbi/openai/models/responses/response_item.rbi +80 -1
- data/rbi/openai/models/responses/response_output_item.rbi +84 -1
- data/rbi/openai/models/responses/responses_client_event.rbi +6 -0
- data/rbi/openai/models/responses/tool.rbi +90 -51
- data/rbi/openai/models/webhooks/live_call_incoming_webhook_event.rbi +4 -4
- data/rbi/openai/models/webhooks/realtime_call_incoming_webhook_event.rbi +8 -4
- data/rbi/openai/resources/beta/responses.rbi +6 -2
- data/rbi/openai/resources/images.rbi +106 -76
- data/rbi/openai/resources/responses.rbi +6 -0
- data/realtime.md +101 -0
- data/responses-websocket.md +182 -0
- data/sig/openai/helpers/local_audio/interface.rbs +62 -0
- data/sig/openai/helpers/realtime/connection.rbs +9 -1
- data/sig/openai/helpers/realtime/extensions.rbs +4 -0
- data/sig/openai/helpers/realtime/recovery.rbs +19 -0
- data/sig/openai/models/beta/beta_response_input_item.rbs +39 -2
- data/sig/openai/models/beta/beta_response_item.rbs +39 -2
- data/sig/openai/models/beta/beta_response_output_item.rbs +39 -2
- data/sig/openai/models/beta/beta_tool.rbs +11 -1
- data/sig/openai/models/conversations/conversation_item.rbs +39 -2
- data/sig/openai/models/image_edit_completed_event.rbs +11 -4
- data/sig/openai/models/image_edit_params.rbs +3 -1
- data/sig/openai/models/image_edit_partial_image_event.rbs +11 -4
- data/sig/openai/models/image_gen_completed_event.rbs +11 -4
- data/sig/openai/models/image_gen_partial_image_event.rbs +11 -4
- data/sig/openai/models/image_generate_params.rbs +4 -1
- data/sig/openai/models/image_model.rbs +8 -0
- data/sig/openai/models/images_response.rbs +10 -4
- data/sig/openai/models/responses/response_input_item.rbs +39 -2
- data/sig/openai/models/responses/response_item.rbs +39 -2
- data/sig/openai/models/responses/response_output_item.rbs +39 -2
- data/sig/openai/models/responses/tool.rbs +11 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2541f0eac1ef08259b89e4baa93434e632c2c46a17b1a67e02fd25d167b77d4
|
|
4
|
+
data.tar.gz: 2d27951debe6e1fd0a42e2817cf59c02dc01d4c8ca8682070cbdd2d14d7e46db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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."
|