openai 0.95.0 → 0.96.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 +44 -0
- data/README.md +5 -2
- data/docs/architecture/security-model.md +2 -2
- data/examples/audio/record_and_play.rb +1 -1
- data/lib/openai/helpers/live/transcript_grouper.rb +264 -0
- data/lib/openai/helpers/live/transcript_grouping.rb +309 -0
- data/lib/openai/helpers/live/websocket.rb +454 -0
- data/lib/openai/helpers/realtime/translation.rb +440 -0
- data/lib/openai/helpers/realtime.rb +1 -0
- data/lib/openai/helpers/responses_websocket/incremental.rb +246 -0
- data/lib/openai/helpers/responses_websocket/transports/async_websocket.rb +183 -0
- data/lib/openai/helpers/responses_websocket.rb +1 -0
- data/lib/openai/helpers/streaming/response_stream.rb +19 -11
- data/lib/openai/helpers/structured_output/chat_completion_parser.rb +0 -2
- data/lib/openai/helpers/structured_output/response_parser.rb +1 -2
- data/lib/openai/helpers/structured_output/union_of.rb +0 -2
- data/lib/openai/helpers/websocket/async_websocket_transport.rb +11 -6
- data/lib/openai/helpers/websocket/connection_manager.rb +9 -0
- data/lib/openai/internal/read_io_adapter.rb +0 -2
- data/lib/openai/internal/stream.rb +0 -2
- data/lib/openai/internal/type/base_model.rb +4 -6
- data/lib/openai/internal/type/converter.rb +5 -3
- data/lib/openai/internal/type/enum.rb +0 -2
- data/lib/openai/internal/type/union.rb +0 -6
- data/lib/openai/internal/type/unknown.rb +34 -6
- data/lib/openai/internal/util.rb +0 -4
- data/lib/openai/models/audio/speech_create_params.rb +9 -0
- data/lib/openai/models/audio/transcription_create_response.rb +4 -1
- data/lib/openai/models/audio/translation_create_response.rb +4 -1
- data/lib/openai/models/beta/agent.rb +1 -0
- data/lib/openai/models/beta/agent_create_params.rb +3 -0
- data/lib/openai/models/beta/agent_session.rb +1 -0
- data/lib/openai/models/beta/agent_update_params.rb +3 -0
- data/lib/openai/models/beta/agents/environments/template_create_params.rb +3 -3
- data/lib/openai/models/beta/agents/environments/template_update_params.rb +6 -6
- data/lib/openai/models/beta/agents/session_create_params.rb +3 -0
- data/lib/openai/models/beta/agents/session_update_params.rb +3 -0
- data/lib/openai/models/beta/agents/vaults/credential.rb +10 -1
- data/lib/openai/models/beta/agents/vaults/credential_create_params.rb +12 -1
- data/lib/openai/models/beta/agents/vaults/credential_update_params.rb +14 -3
- data/lib/openai/models/beta/beta_response.rb +38 -1
- data/lib/openai/models/beta/beta_responses_client_event.rb +73 -1
- data/lib/openai/models/beta/beta_web_search_preview_tool.rb +9 -3
- data/lib/openai/models/beta/beta_web_search_tool.rb +9 -3
- data/lib/openai/models/beta/environment_param.rb +3 -3
- data/lib/openai/models/beta/response_compact_params.rb +3 -0
- data/lib/openai/models/beta/response_create_params.rb +68 -1
- data/lib/openai/models/beta/session_turn_error.rb +3 -0
- data/lib/openai/models/chat_model.rb +1 -0
- data/lib/openai/models/responses/response.rb +35 -1
- data/lib/openai/models/responses/response_compact_params.rb +3 -0
- data/lib/openai/models/responses/response_create_params.rb +65 -1
- data/lib/openai/models/responses/responses_client_event.rb +70 -1
- data/lib/openai/models/responses/web_search_preview_tool.rb +9 -3
- data/lib/openai/models/responses/web_search_tool.rb +9 -3
- data/lib/openai/net_http_client.rb +0 -2
- data/lib/openai/resources/audio/transcriptions.rb +5 -3
- data/lib/openai/resources/audio/translations.rb +1 -1
- data/lib/openai/resources/beta/agents/environments/templates.rb +3 -3
- data/lib/openai/resources/beta/agents/vaults/credentials.rb +12 -4
- data/lib/openai/resources/beta/responses.rb +8 -2
- data/lib/openai/resources/responses.rb +8 -1
- data/lib/openai/version.rb +1 -1
- data/lib/openai.rb +1 -0
- data/rbi/openai/helpers/live/transcript_grouper.rbi +67 -0
- data/rbi/openai/helpers/live/websocket.rbi +299 -0
- data/rbi/openai/helpers/realtime/translation.rbi +137 -0
- data/rbi/openai/helpers/responses_websocket/incremental.rbi +32 -0
- data/rbi/openai/helpers/responses_websocket/transports/async_websocket.rbi +24 -0
- data/rbi/openai/helpers/streaming/response_stream.rbi +5 -0
- data/rbi/openai/internal/type/converter.rbi +3 -2
- data/rbi/openai/models/audio/speech_create_params.rbi +3 -0
- data/rbi/openai/models/audio/transcription_create_response.rbi +2 -1
- data/rbi/openai/models/audio/translation_create_response.rbi +2 -1
- data/rbi/openai/models/beta/agent.rbi +1 -0
- data/rbi/openai/models/beta/agent_create_params.rbi +3 -0
- data/rbi/openai/models/beta/agent_session.rbi +1 -0
- data/rbi/openai/models/beta/agent_update_params.rbi +3 -0
- data/rbi/openai/models/beta/agents/environments/template_create_params.rbi +3 -3
- data/rbi/openai/models/beta/agents/environments/template_update_params.rbi +6 -6
- data/rbi/openai/models/beta/agents/session_create_params.rbi +3 -0
- data/rbi/openai/models/beta/agents/session_update_params.rbi +3 -0
- data/rbi/openai/models/beta/agents/vaults/credential.rbi +10 -0
- data/rbi/openai/models/beta/agents/vaults/credential_create_params.rbi +15 -0
- data/rbi/openai/models/beta/agents/vaults/credential_update_params.rbi +35 -6
- data/rbi/openai/models/beta/beta_response.rbi +64 -0
- data/rbi/openai/models/beta/beta_responses_client_event.rbi +128 -0
- data/rbi/openai/models/beta/beta_web_search_preview_tool.rbi +9 -3
- data/rbi/openai/models/beta/beta_web_search_tool.rbi +9 -3
- data/rbi/openai/models/beta/environment_param.rbi +3 -3
- data/rbi/openai/models/beta/response_compact_params.rbi +1 -0
- data/rbi/openai/models/beta/response_create_params.rbi +100 -0
- data/rbi/openai/models/beta/session_turn_error.rbi +6 -0
- data/rbi/openai/models/chat_model.rbi +1 -0
- data/rbi/openai/models/responses/response.rbi +63 -0
- data/rbi/openai/models/responses/response_compact_params.rbi +1 -0
- data/rbi/openai/models/responses/response_create_params.rbi +106 -0
- data/rbi/openai/models/responses/responses_client_event.rbi +125 -0
- data/rbi/openai/models/responses/web_search_preview_tool.rbi +9 -3
- data/rbi/openai/models/responses/web_search_tool.rbi +9 -3
- data/rbi/openai/resources/audio/transcriptions.rbi +4 -2
- data/rbi/openai/resources/beta/agents/environments/templates.rbi +3 -3
- data/rbi/openai/resources/beta/agents/vaults/credentials.rbi +11 -3
- data/rbi/openai/resources/beta/responses.rbi +6 -0
- data/rbi/openai/resources/responses.rbi +11 -0
- data/responses-websocket.md +55 -0
- data/sig/openai/helpers/live/transcript_grouper.rbs +42 -0
- data/sig/openai/helpers/live/websocket.rbs +152 -0
- data/sig/openai/helpers/realtime/translation.rbs +81 -0
- data/sig/openai/helpers/responses_websocket/incremental.rbs +14 -0
- data/sig/openai/helpers/responses_websocket/transports/async_websocket.rbs +21 -0
- data/sig/openai/helpers/streaming/response_stream.rbs +9 -0
- data/sig/openai/internal/type/converter.rbs +3 -1
- data/sig/openai/models/audio/speech_create_params.rbs +6 -0
- data/sig/openai/models/audio/transcription_create_response.rbs +1 -0
- data/sig/openai/models/audio/translation_create_response.rbs +3 -1
- data/sig/openai/models/beta/agent.rbs +3 -1
- data/sig/openai/models/beta/agent_create_params.rbs +5 -1
- data/sig/openai/models/beta/agent_session.rbs +3 -1
- data/sig/openai/models/beta/agent_update_params.rbs +5 -1
- data/sig/openai/models/beta/agents/environments/template_create_params.rbs +2 -2
- data/sig/openai/models/beta/agents/environments/template_update_params.rbs +4 -4
- data/sig/openai/models/beta/agents/session_create_params.rbs +5 -1
- data/sig/openai/models/beta/agents/session_update_params.rbs +5 -1
- data/sig/openai/models/beta/agents/vaults/credential.rbs +8 -0
- data/sig/openai/models/beta/agents/vaults/credential_create_params.rbs +14 -0
- data/sig/openai/models/beta/agents/vaults/credential_update_params.rbs +23 -4
- data/sig/openai/models/beta/beta_response.rbs +37 -0
- data/sig/openai/models/beta/beta_responses_client_event.rbs +79 -0
- data/sig/openai/models/beta/beta_web_search_preview_tool.rbs +6 -2
- data/sig/openai/models/beta/beta_web_search_tool.rbs +6 -2
- data/sig/openai/models/beta/environment_param.rbs +2 -2
- data/sig/openai/models/beta/response_compact_params.rbs +2 -0
- data/sig/openai/models/beta/response_create_params.rbs +79 -0
- data/sig/openai/models/beta/session_turn_error.rbs +4 -0
- data/sig/openai/models/chat_model.rbs +2 -0
- data/sig/openai/models/responses/response.rbs +35 -0
- data/sig/openai/models/responses/response_compact_params.rbs +2 -0
- data/sig/openai/models/responses/response_create_params.rbs +77 -0
- data/sig/openai/models/responses/responses_client_event.rbs +77 -0
- data/sig/openai/models/responses/web_search_preview_tool.rbs +6 -2
- data/sig/openai/models/responses/web_search_tool.rbs +6 -2
- data/sig/openai/resources/beta/agents/vaults/credentials.rbs +3 -1
- data/sig/openai/resources/beta/responses.rbs +2 -0
- data/sig/openai/resources/responses.rbs +4 -0
- metadata +14 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenAI
|
|
4
|
+
module Errors
|
|
5
|
+
class TranslationConnectionError < OpenAI::Errors::WebSocketConnectionError
|
|
6
|
+
private def default_message = "Realtime Translation WebSocket connection error."
|
|
7
|
+
|
|
8
|
+
private def sanitized_error_url(url)
|
|
9
|
+
sanitized = url.dup
|
|
10
|
+
sanitized.user = nil if sanitized.respond_to?(:user=)
|
|
11
|
+
sanitized.password = nil if sanitized.respond_to?(:password=)
|
|
12
|
+
sanitized.query = nil if sanitized.respond_to?(:query=)
|
|
13
|
+
sanitized.fragment = nil if sanitized.respond_to?(:fragment=)
|
|
14
|
+
sanitized
|
|
15
|
+
rescue ArgumentError, URI::Error
|
|
16
|
+
URI("wss://invalid")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class TranslationProtocolError < OpenAI::Errors::WebSocketProtocolError
|
|
21
|
+
def initialize = super("Invalid Realtime Translation WebSocket event.")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module Realtime
|
|
26
|
+
# Future event data is accessible explicitly without leaking in diagnostics.
|
|
27
|
+
class UnknownTranslationServerEvent < OpenAI::Realtime::UnknownServerEvent
|
|
28
|
+
def inspect = "#<#{self.class} type=#{@type.inspect}>"
|
|
29
|
+
alias to_s inspect
|
|
30
|
+
|
|
31
|
+
private def freeze_json(value)
|
|
32
|
+
pending = [value]
|
|
33
|
+
visited = {}.compare_by_identity
|
|
34
|
+
until pending.empty?
|
|
35
|
+
item = pending.pop
|
|
36
|
+
next if visited.key?(item)
|
|
37
|
+
visited[item] = true
|
|
38
|
+
case item
|
|
39
|
+
when Hash
|
|
40
|
+
item.each { |key, val| pending.push(key, val) }
|
|
41
|
+
when Array
|
|
42
|
+
pending.concat(item)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
item.freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
value
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Opening does not send any events. session.close flushes remaining output;
|
|
53
|
+
# read through session.closed before leaving the block to consume it.
|
|
54
|
+
class TranslationConnection < OpenAI::WebSocket::Connection
|
|
55
|
+
include OpenAI::WebSocket::Protocol
|
|
56
|
+
|
|
57
|
+
# Timeout implementations before Ruby 4 raise StandardError through the
|
|
58
|
+
# scheduler. Cross transport error wrappers without changing raw callers.
|
|
59
|
+
# rubocop:disable Lint/InheritException
|
|
60
|
+
class FinishTimeout < Exception
|
|
61
|
+
end
|
|
62
|
+
# rubocop:enable Lint/InheritException
|
|
63
|
+
|
|
64
|
+
private_constant :FinishTimeout
|
|
65
|
+
|
|
66
|
+
# @api private
|
|
67
|
+
def initialize(socket:, url:)
|
|
68
|
+
super
|
|
69
|
+
@poisoned = false
|
|
70
|
+
@closed = false
|
|
71
|
+
@write_mutex = Mutex.new
|
|
72
|
+
@read_state_mutex = Mutex.new
|
|
73
|
+
@active_reads = 0
|
|
74
|
+
@server_event_types = OpenAI::Realtime::RealtimeTranslationServerEvent.variants.to_h do |variant|
|
|
75
|
+
[variant.fields.fetch(:type).fetch(:const).to_s, variant]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@client_event_names = discriminator_values(OpenAI::Realtime::RealtimeTranslationClientEvent)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def send_event(event)
|
|
82
|
+
send_raw(encode_client_event(event))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Request a final flush and yield every trailing event, including
|
|
86
|
+
# session.closed, to the existing receive owner. Call after the final
|
|
87
|
+
# admitted audio send returns. No second reader or reconnect is started.
|
|
88
|
+
# The block is required so trailing audio, text and errors cannot be lost.
|
|
89
|
+
#
|
|
90
|
+
# @param timeout [Float] maximum seconds for the close write and drain
|
|
91
|
+
# @yield [OpenAI::Realtime::RealtimeTranslationServerEvent]
|
|
92
|
+
# @return [OpenAI::Realtime::RealtimeTranslationSessionClosedEvent]
|
|
93
|
+
def finish(timeout:)
|
|
94
|
+
raise ArgumentError, "A block is required to consume trailing Translation events." unless block_given?
|
|
95
|
+
seconds = Float(timeout)
|
|
96
|
+
unless seconds.finite? && seconds.positive?
|
|
97
|
+
raise ArgumentError, "Translation finish timeout must be finite and positive."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
require "timeout"
|
|
101
|
+
|
|
102
|
+
@read_state_mutex.synchronize do
|
|
103
|
+
return @finish_terminal if @finish_terminal
|
|
104
|
+
raise @finish_error if @finish_error
|
|
105
|
+
if @active_reads.positive? || @finish_requested || @finish_owner
|
|
106
|
+
raise connection_error("Translation finish must run on the existing receive owner, between reads.")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@finish_owner = Fiber.current
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
begin
|
|
113
|
+
Timeout.timeout(seconds, FinishTimeout) do
|
|
114
|
+
write_text(encode_client_event(type: "session.close"), finish: true)
|
|
115
|
+
while (event = receive)
|
|
116
|
+
yield(event)
|
|
117
|
+
if event.is_a?(OpenAI::Realtime::RealtimeTranslationSessionClosedEvent)
|
|
118
|
+
@read_state_mutex.synchronize { @finish_terminal = event }
|
|
119
|
+
return event
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
raise connection_error("Translation socket closed before session.closed.")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
rescue FinishTimeout
|
|
127
|
+
error = Timeout::Error.new("Timed out finishing the Translation session.")
|
|
128
|
+
@read_state_mutex.synchronize { @finish_error = error }
|
|
129
|
+
raise error, cause: nil
|
|
130
|
+
rescue StandardError => error
|
|
131
|
+
@read_state_mutex.synchronize { @finish_error = error }
|
|
132
|
+
raise
|
|
133
|
+
ensure
|
|
134
|
+
@read_state_mutex.synchronize { @finish_owner = nil }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def closed? = @poisoned || @closed || super
|
|
139
|
+
|
|
140
|
+
def close(code: 1000, reason: "")
|
|
141
|
+
@poisoned || @closed ? abort : super
|
|
142
|
+
@closed = true
|
|
143
|
+
nil
|
|
144
|
+
rescue StandardError
|
|
145
|
+
@poisoned = true
|
|
146
|
+
raise OpenAI::Errors::TranslationConnectionError.new(url: @url), cause: nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @api private
|
|
150
|
+
def abort
|
|
151
|
+
super
|
|
152
|
+
@closed = true
|
|
153
|
+
nil
|
|
154
|
+
rescue StandardError
|
|
155
|
+
@poisoned = true
|
|
156
|
+
raise OpenAI::Errors::TranslationConnectionError.new(url: @url), cause: nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private def write_text(text, finish: false)
|
|
160
|
+
@write_mutex.synchronize do
|
|
161
|
+
if @finish_requested || (@finish_owner && !finish)
|
|
162
|
+
raise connection_error("Cannot send after Translation finish was requested.")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
raise connection_error("Cannot send on a closed Realtime Translation WebSocket.") if closed?
|
|
166
|
+
@finish_requested = true if finish
|
|
167
|
+
complete = false
|
|
168
|
+
begin
|
|
169
|
+
super(text)
|
|
170
|
+
complete = true
|
|
171
|
+
nil
|
|
172
|
+
rescue StandardError
|
|
173
|
+
raise OpenAI::Errors::TranslationConnectionError.new(url: @url), cause: nil
|
|
174
|
+
ensure
|
|
175
|
+
# Ruby's Timeout and task cancellation may bypass StandardError.
|
|
176
|
+
# Do not let ordinary cleanup flush an uncertain buffered write.
|
|
177
|
+
@poisoned = true unless complete
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private def read_raw_message
|
|
183
|
+
@read_state_mutex.synchronize do
|
|
184
|
+
if @finish_owner && @finish_owner != Fiber.current
|
|
185
|
+
raise connection_error("Translation finish must run on the existing receive owner, between reads.")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
@active_reads += 1
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
begin
|
|
192
|
+
raise connection_error("Cannot read from a failed Realtime Translation WebSocket.") if @poisoned
|
|
193
|
+
return nil if @closed
|
|
194
|
+
text = super
|
|
195
|
+
@closed = text.nil?
|
|
196
|
+
text
|
|
197
|
+
rescue StandardError
|
|
198
|
+
@poisoned = true
|
|
199
|
+
raise OpenAI::Errors::TranslationConnectionError.new(url: @url), cause: nil
|
|
200
|
+
ensure
|
|
201
|
+
@read_state_mutex.synchronize { @active_reads -= 1 }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private def socket_closed?
|
|
206
|
+
super
|
|
207
|
+
rescue StandardError
|
|
208
|
+
@poisoned = true
|
|
209
|
+
raise OpenAI::Errors::TranslationConnectionError.new(url: @url), cause: nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private def encode_client_event(event)
|
|
213
|
+
validate_client_tree!(event)
|
|
214
|
+
union = OpenAI::Realtime::RealtimeTranslationClientEvent
|
|
215
|
+
payload = OpenAI::Internal::Type::Converter.dump(union, event)
|
|
216
|
+
type = payload[:type] || payload["type"] if payload.is_a?(Hash)
|
|
217
|
+
raise ArgumentError unless (type.is_a?(String) || type.is_a?(Symbol)) && @client_event_names.key?(type.to_s)
|
|
218
|
+
|
|
219
|
+
coerced = coerce_event(union, payload, outbound: true)
|
|
220
|
+
serialized = OpenAI::Internal::Type::Converter.dump(union, coerced)
|
|
221
|
+
validate_client_tree!(serialized, json_only: true)
|
|
222
|
+
JSON.generate(serialized, max_nesting: false)
|
|
223
|
+
rescue StandardError, SystemStackError
|
|
224
|
+
raise ArgumentError, "Invalid Realtime Translation client event.", cause: nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A merged string-keyed JSON event and symbol-keyed override must not
|
|
228
|
+
# silently change the command or the configuration that is sent.
|
|
229
|
+
private def validate_client_tree!(event, json_only: false)
|
|
230
|
+
visited = {}.compare_by_identity
|
|
231
|
+
pending = [event]
|
|
232
|
+
until pending.empty?
|
|
233
|
+
item = pending.pop
|
|
234
|
+
next if visited.key?(item)
|
|
235
|
+
visited[item] = true
|
|
236
|
+
case item
|
|
237
|
+
when OpenAI::Internal::Type::BaseModel
|
|
238
|
+
raise ArgumentError if json_only
|
|
239
|
+
validate_model_constants!(item)
|
|
240
|
+
pending << item.to_h
|
|
241
|
+
when Hash
|
|
242
|
+
names = {}
|
|
243
|
+
item.each do |key, value|
|
|
244
|
+
raise ArgumentError unless key.is_a?(String) || key.is_a?(Symbol)
|
|
245
|
+
name = key.to_s
|
|
246
|
+
raise ArgumentError if names.key?(name)
|
|
247
|
+
names[name] = true
|
|
248
|
+
pending << value
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
when Array
|
|
252
|
+
pending.concat(item)
|
|
253
|
+
when NilClass, TrueClass, FalseClass, String, Integer, Float, Symbol
|
|
254
|
+
next
|
|
255
|
+
else
|
|
256
|
+
raise ArgumentError if json_only
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
private def validate_model_constants!(model)
|
|
262
|
+
model.to_h.each do |name, actual|
|
|
263
|
+
key = name.is_a?(String) ? name.to_sym : name
|
|
264
|
+
field = model.class.known_fields[key]
|
|
265
|
+
next unless field
|
|
266
|
+
const = field.fetch(:const)
|
|
267
|
+
next if const == OpenAI::Internal::OMIT
|
|
268
|
+
if const.is_a?(Symbol)
|
|
269
|
+
raise ArgumentError unless (actual.is_a?(String) || actual.is_a?(Symbol)) && actual.to_s == const.to_s
|
|
270
|
+
elsif actual != const
|
|
271
|
+
raise ArgumentError
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
private def parse_event(data)
|
|
277
|
+
parsed = JSON.parse(data, symbolize_names: true, max_nesting: false)
|
|
278
|
+
type = event_type(parsed, message: "Realtime Translation server event must be an object with a string type")
|
|
279
|
+
model = @server_event_types[type.to_s]
|
|
280
|
+
unless model
|
|
281
|
+
return OpenAI::Realtime::UnknownTranslationServerEvent.new(data: parsed)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The converter copies unknown wire fields after known ones. A wire
|
|
285
|
+
# key spelled like a renamed Ruby field must not overwrite typed data.
|
|
286
|
+
model.fields.each do |name, field|
|
|
287
|
+
raise ArgumentError if name != field.fetch(:api_name) && parsed.key?(name)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
coerce_event(OpenAI::Realtime::RealtimeTranslationServerEvent, parsed)
|
|
291
|
+
rescue StandardError, SystemStackError
|
|
292
|
+
raise OpenAI::Errors::TranslationProtocolError.new, cause: nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private def coerce_event(union, payload, outbound: false)
|
|
296
|
+
state = OpenAI::Internal::Type::Converter.new_coerce_state(request_only: outbound)
|
|
297
|
+
event = OpenAI::Internal::Type::Converter.coerce(union, payload, state: state)
|
|
298
|
+
raise ArgumentError if state[:error] || !state.fetch(:exactness).fetch(:no).zero?
|
|
299
|
+
ancestors = {}.compare_by_identity
|
|
300
|
+
pending = [[event, false]]
|
|
301
|
+
until pending.empty?
|
|
302
|
+
value, exiting = pending.pop
|
|
303
|
+
if exiting
|
|
304
|
+
ancestors.delete(value)
|
|
305
|
+
next
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
children = case value
|
|
309
|
+
when OpenAI::Internal::Type::BaseModel
|
|
310
|
+
data = value.to_h
|
|
311
|
+
validate_model_constants!(value)
|
|
312
|
+
value.class.fields.each do |name, field|
|
|
313
|
+
const = field.fetch(:const)
|
|
314
|
+
if field.fetch(:required) &&
|
|
315
|
+
field.fetch(:mode) != (outbound ? :coerce : :dump) &&
|
|
316
|
+
(!outbound || const == OpenAI::Internal::OMIT) &&
|
|
317
|
+
!data.key?(name)
|
|
318
|
+
raise ArgumentError
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
data.values
|
|
323
|
+
when Hash
|
|
324
|
+
value.values
|
|
325
|
+
when Array
|
|
326
|
+
value
|
|
327
|
+
else
|
|
328
|
+
next
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
raise ArgumentError if ancestors.key?(value)
|
|
332
|
+
ancestors[value] = true
|
|
333
|
+
pending << [value, true]
|
|
334
|
+
children.each { |child| pending << [child, false] }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
event
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
private def connection_error(message)
|
|
341
|
+
OpenAI::Errors::TranslationConnectionError.new(url: @url, message: message)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
module Helpers
|
|
347
|
+
module Realtime
|
|
348
|
+
module ClientExtension
|
|
349
|
+
# @api private
|
|
350
|
+
def with_translation_connection_request(model:, websocket_base_url: nil, options: nil, &block)
|
|
351
|
+
websocket_base_url = websocket_base_url&.to_s&.dup&.freeze
|
|
352
|
+
query = {"model" => model.to_s.dup.freeze}.freeze
|
|
353
|
+
build = lambda do |deadline|
|
|
354
|
+
build_shared_websocket_connection_request(
|
|
355
|
+
path: "realtime/translations",
|
|
356
|
+
query: query,
|
|
357
|
+
websocket_base_url: websocket_base_url,
|
|
358
|
+
options: options,
|
|
359
|
+
deadline: deadline,
|
|
360
|
+
validate: -> (_) { validate_translation_websocket_request! },
|
|
361
|
+
invalid_base_url_message: "websocket_base_url must be an absolute HTTP or WebSocket URL " \
|
|
362
|
+
"without credentials, query, or fragment",
|
|
363
|
+
malformed_base_url_message: "websocket_base_url is not a valid URL",
|
|
364
|
+
preserve_base_url_cause: false,
|
|
365
|
+
extra_query_message: "request_options extra_query is not supported for Realtime Translation WebSockets",
|
|
366
|
+
max_retries_message: "request_options max_retries is not supported for Realtime Translation WebSockets",
|
|
367
|
+
timeout_error: -> (url, _) { OpenAI::Errors::TranslationConnectionError.new(url: url) }
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
with_websocket_connection_retry(
|
|
372
|
+
error_class: OpenAI::Errors::TranslationConnectionError,
|
|
373
|
+
build: build,
|
|
374
|
+
&block
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
private def validate_translation_websocket_request!
|
|
379
|
+
if x509_identity?(@copy_options.fetch(:workload_identity))
|
|
380
|
+
raise OpenAI::Errors::Error, "X.509 workload identity does not support Realtime Translation WebSockets"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
if @provider_runtime
|
|
384
|
+
raise OpenAI::Errors::Error, "Realtime Translation WebSockets are not supported by providers."
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
module Connections
|
|
390
|
+
# Open a Translation socket with its own protocol. The server creates the
|
|
391
|
+
# session; session.close requests a flush without closing this reader.
|
|
392
|
+
def connect_translation(
|
|
393
|
+
model:,
|
|
394
|
+
websocket_base_url: nil,
|
|
395
|
+
request_options: nil,
|
|
396
|
+
transport: nil,
|
|
397
|
+
transport_options: {},
|
|
398
|
+
&block
|
|
399
|
+
)
|
|
400
|
+
raise ArgumentError, "A block is required to open a Realtime Translation WebSocket." unless block
|
|
401
|
+
|
|
402
|
+
request = lambda do |&request_block|
|
|
403
|
+
@client.with_translation_connection_request(
|
|
404
|
+
model: model,
|
|
405
|
+
websocket_base_url: websocket_base_url,
|
|
406
|
+
options: request_options,
|
|
407
|
+
&request_block
|
|
408
|
+
)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
default_transport = lambda do
|
|
412
|
+
OpenAI::WebSocket::AsyncWebSocketTransport.new(
|
|
413
|
+
product_name: "Realtime Translation",
|
|
414
|
+
error_class: OpenAI::Errors::TranslationConnectionError,
|
|
415
|
+
error_factory: -> (url:, message: nil, http_status: nil, **) {
|
|
416
|
+
OpenAI::Errors::TranslationConnectionError.new(url: url, message: message, http_status: http_status)
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
OpenAI::WebSocket::ConnectionManager
|
|
422
|
+
.new(
|
|
423
|
+
transport: transport,
|
|
424
|
+
transport_options: transport_options,
|
|
425
|
+
default_transport: default_transport,
|
|
426
|
+
connection_class: OpenAI::Realtime::TranslationConnection,
|
|
427
|
+
request: request,
|
|
428
|
+
block_error_message: "A block is required to open a Realtime Translation WebSocket.",
|
|
429
|
+
abort_after_block: -> (_connection, pending_error) { !pending_error.nil? },
|
|
430
|
+
transport_error_factory: -> (url:, error:) {
|
|
431
|
+
status = error.http_status if error.is_a?(OpenAI::Errors::WebSocketConnectionError)
|
|
432
|
+
OpenAI::Errors::TranslationConnectionError.new(url: url, http_status: status)
|
|
433
|
+
}
|
|
434
|
+
)
|
|
435
|
+
.open(&block)
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenAI
|
|
4
|
+
module Responses
|
|
5
|
+
# Optional, caller-fed provisional output for one WebSocket lane.
|
|
6
|
+
# Feed the events returned by lane.receive; use a separate instance per lane.
|
|
7
|
+
# This helper never reads, closes, or writes to a connection. Tools are data.
|
|
8
|
+
#
|
|
9
|
+
# Output is provisional and may be unavailable when the wire omitted its
|
|
10
|
+
# scaffolding, or a known output update cannot be represented. Terminal events
|
|
11
|
+
# are retained exactly as received, never filled from provisional output.
|
|
12
|
+
# Call reset to discard all retained state.
|
|
13
|
+
class IncrementalResponse
|
|
14
|
+
attr_reader :phase
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
reset
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A detached provisional output snapshot, or nil when unavailable/terminal.
|
|
21
|
+
#
|
|
22
|
+
# This materializes the whole output and costs time proportional to its
|
|
23
|
+
# size. Use the original event's delta for per-delta progress. Read output
|
|
24
|
+
# at boundaries such as response.output_item.done when a full, corrected
|
|
25
|
+
# view is needed; replace a displayed view instead of appending its prefix.
|
|
26
|
+
# Repeated full snapshots of a growing response necessarily copy growing
|
|
27
|
+
# amounts of data.
|
|
28
|
+
def output
|
|
29
|
+
@output&.map { |item| copy(item) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The exact consumed completion, failure, incomplete, or error event.
|
|
33
|
+
def terminal_event
|
|
34
|
+
@terminal_event && copy(@terminal_event)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reset
|
|
38
|
+
@state = OpenAI::Helpers::Streaming::ResponseStreamState.new(text_format: nil)
|
|
39
|
+
@phase = nil
|
|
40
|
+
@output = nil
|
|
41
|
+
@terminal_event = nil
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add(event)
|
|
46
|
+
case event
|
|
47
|
+
when OpenAI::Responses::UnknownServerEvent
|
|
48
|
+
return
|
|
49
|
+
when
|
|
50
|
+
OpenAI::Responses::ResponseCompletedEvent,
|
|
51
|
+
OpenAI::Responses::ResponseFailedEvent,
|
|
52
|
+
OpenAI::Responses::ResponseIncompleteEvent,
|
|
53
|
+
OpenAI::Responses::ResponsesServerEvent::ResponseWsError
|
|
54
|
+
reset
|
|
55
|
+
@terminal_event = copy(event)
|
|
56
|
+
@phase = :terminal
|
|
57
|
+
when OpenAI::Responses::ResponseCreatedEvent
|
|
58
|
+
response = copy(event)[:response]
|
|
59
|
+
unless response.is_a?(OpenAI::Responses::Response) &&
|
|
60
|
+
(response[:output].nil? || response[:output].is_a?(Array))
|
|
61
|
+
raise SessionError, "Invalid Responses WebSocket created response."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
reset
|
|
65
|
+
@output = response[:output] || []
|
|
66
|
+
@phase = :provisional
|
|
67
|
+
when
|
|
68
|
+
OpenAI::Responses::ResponseOutputItemAddedEvent,
|
|
69
|
+
OpenAI::Responses::ResponseOutputItemDoneEvent,
|
|
70
|
+
OpenAI::Responses::ResponseContentPartAddedEvent,
|
|
71
|
+
OpenAI::Responses::ResponseContentPartDoneEvent,
|
|
72
|
+
OpenAI::Responses::ResponseTextDeltaEvent,
|
|
73
|
+
OpenAI::Responses::ResponseTextDoneEvent,
|
|
74
|
+
OpenAI::Responses::ResponseFunctionCallArgumentsDeltaEvent,
|
|
75
|
+
OpenAI::Responses::ResponseFunctionCallArgumentsDoneEvent,
|
|
76
|
+
OpenAI::Responses::ResponseCustomToolCallInputDeltaEvent,
|
|
77
|
+
OpenAI::Responses::ResponseCustomToolCallInputDoneEvent,
|
|
78
|
+
OpenAI::Responses::ResponseRefusalDeltaEvent,
|
|
79
|
+
OpenAI::Responses::ResponseRefusalDoneEvent,
|
|
80
|
+
OpenAI::Responses::ResponseOutputTextAnnotationAddedEvent
|
|
81
|
+
return if @phase == :unavailable || @phase == :terminal
|
|
82
|
+
|
|
83
|
+
apply_output(copy(event))
|
|
84
|
+
else
|
|
85
|
+
# A full output snapshot must not leave stale fields after unsupported
|
|
86
|
+
# indexed updates. Unknown raw events and progress events are not output.
|
|
87
|
+
if @phase != :terminal &&
|
|
88
|
+
event.class.known_fields.key?(:output_index) &&
|
|
89
|
+
event[:type].to_s.end_with?(".added", ".delta", ".done", ".partial_image")
|
|
90
|
+
unavailable
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def apply_output(event)
|
|
100
|
+
index = event[:output_index]
|
|
101
|
+
unless @output && index.is_a?(Integer) && index >= 0 && index <= @output.length
|
|
102
|
+
return unavailable
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
case event
|
|
106
|
+
when OpenAI::Responses::ResponseOutputItemDoneEvent
|
|
107
|
+
# Replace the entire item: done may reduce its content or change its ID.
|
|
108
|
+
@output[index] = event.item
|
|
109
|
+
@state = OpenAI::Helpers::Streaming::ResponseStreamState.new(text_format: nil)
|
|
110
|
+
when OpenAI::Responses::ResponseOutputItemAddedEvent
|
|
111
|
+
return unavailable unless index == @output.length
|
|
112
|
+
|
|
113
|
+
@state.accumulate_output(event, @output)
|
|
114
|
+
when OpenAI::Responses::ResponseFunctionCallArgumentsDoneEvent
|
|
115
|
+
item = @output[index]
|
|
116
|
+
unless item.is_a?(OpenAI::Responses::ResponseFunctionToolCall) &&
|
|
117
|
+
item.id == event.item_id &&
|
|
118
|
+
event[:arguments].is_a?(String)
|
|
119
|
+
return unavailable
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
item.arguments = event[:arguments]
|
|
123
|
+
when
|
|
124
|
+
OpenAI::Responses::ResponseCustomToolCallInputDeltaEvent,
|
|
125
|
+
OpenAI::Responses::ResponseCustomToolCallInputDoneEvent
|
|
126
|
+
item = @output[index]
|
|
127
|
+
unless item.is_a?(OpenAI::Responses::ResponseCustomToolCall) && item.id == event.item_id
|
|
128
|
+
return unavailable
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if event.is_a?(OpenAI::Responses::ResponseCustomToolCallInputDeltaEvent)
|
|
132
|
+
return unavailable unless item[:input].is_a?(String) && event[:delta].is_a?(String)
|
|
133
|
+
|
|
134
|
+
item.input << event[:delta]
|
|
135
|
+
else
|
|
136
|
+
return unavailable unless event[:input].is_a?(String)
|
|
137
|
+
|
|
138
|
+
item.input = event[:input]
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
item = @output[index]
|
|
142
|
+
if event.is_a?(OpenAI::Responses::ResponseFunctionCallArgumentsDeltaEvent)
|
|
143
|
+
unless item.is_a?(OpenAI::Responses::ResponseFunctionToolCall) &&
|
|
144
|
+
item.id == event.item_id &&
|
|
145
|
+
event.delta.is_a?(String)
|
|
146
|
+
return unavailable
|
|
147
|
+
end
|
|
148
|
+
else
|
|
149
|
+
part_index = event[:content_index]
|
|
150
|
+
unless item.is_a?(OpenAI::Responses::ResponseOutputMessage) &&
|
|
151
|
+
item.id == event.item_id &&
|
|
152
|
+
item.content.is_a?(Array) &&
|
|
153
|
+
part_index.is_a?(Integer) &&
|
|
154
|
+
part_index >= 0 &&
|
|
155
|
+
part_index <= item.content.length
|
|
156
|
+
return unavailable
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
case event
|
|
160
|
+
when OpenAI::Responses::ResponseContentPartDoneEvent
|
|
161
|
+
item.content[part_index] = event.part
|
|
162
|
+
return
|
|
163
|
+
when OpenAI::Responses::ResponseContentPartAddedEvent
|
|
164
|
+
return unavailable unless part_index == item.content.length
|
|
165
|
+
when OpenAI::Responses::ResponseTextDoneEvent
|
|
166
|
+
part = item.content[part_index]
|
|
167
|
+
unless part.is_a?(OpenAI::Responses::ResponseOutputText) &&
|
|
168
|
+
event[:text].is_a?(String) &&
|
|
169
|
+
(event[:logprobs].nil? || event[:logprobs].is_a?(Array))
|
|
170
|
+
return unavailable
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
part.text = event[:text]
|
|
174
|
+
part.logprobs = event[:logprobs] unless event[:logprobs].nil?
|
|
175
|
+
return
|
|
176
|
+
when OpenAI::Responses::ResponseTextDeltaEvent
|
|
177
|
+
part = item.content[part_index]
|
|
178
|
+
unless part.is_a?(OpenAI::Responses::ResponseOutputText) &&
|
|
179
|
+
part.text.is_a?(String) &&
|
|
180
|
+
event.delta.is_a?(String) &&
|
|
181
|
+
(event[:logprobs].nil? || event[:logprobs].is_a?(Array)) &&
|
|
182
|
+
(part[:logprobs].nil? || part[:logprobs].is_a?(Array))
|
|
183
|
+
return unavailable
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
unless event[:logprobs].nil?
|
|
187
|
+
part.logprobs = [] if part[:logprobs].nil?
|
|
188
|
+
part[:logprobs].concat(event[:logprobs])
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
when OpenAI::Responses::ResponseRefusalDeltaEvent, OpenAI::Responses::ResponseRefusalDoneEvent
|
|
192
|
+
part = item.content[part_index]
|
|
193
|
+
return unavailable unless part.is_a?(OpenAI::Responses::ResponseOutputRefusal)
|
|
194
|
+
|
|
195
|
+
if event.is_a?(OpenAI::Responses::ResponseRefusalDeltaEvent)
|
|
196
|
+
return unavailable unless part[:refusal].is_a?(String) && event[:delta].is_a?(String)
|
|
197
|
+
|
|
198
|
+
part.refusal << event[:delta]
|
|
199
|
+
else
|
|
200
|
+
return unavailable unless event[:refusal].is_a?(String)
|
|
201
|
+
|
|
202
|
+
part.refusal = event[:refusal]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
return
|
|
206
|
+
when OpenAI::Responses::ResponseOutputTextAnnotationAddedEvent
|
|
207
|
+
part = item.content[part_index]
|
|
208
|
+
annotation_index = event[:annotation_index]
|
|
209
|
+
unless part.is_a?(OpenAI::Responses::ResponseOutputText) &&
|
|
210
|
+
part[:annotations].is_a?(Array) &&
|
|
211
|
+
annotation_index.is_a?(Integer) &&
|
|
212
|
+
annotation_index >= 0 &&
|
|
213
|
+
annotation_index <= part[:annotations].length &&
|
|
214
|
+
(event[:annotation].is_a?(OpenAI::Internal::Type::BaseModel) || event[:annotation].is_a?(Hash))
|
|
215
|
+
return unavailable
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The event and output use distinct annotation union types.
|
|
219
|
+
# Snapshot copying reconstructs this value as an output annotation.
|
|
220
|
+
part[:annotations][annotation_index] = event[:annotation]
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
@state.accumulate_output(event, @output)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def unavailable
|
|
230
|
+
reset
|
|
231
|
+
@phase = :unavailable
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# BaseModel's recursive representation copies containers but retains string
|
|
236
|
+
# references. Returned snapshots and incoming events must not share strings.
|
|
237
|
+
def copy(value)
|
|
238
|
+
data = OpenAI::Internal::Type::BaseModel.recursively_to_h(value, convert: false)
|
|
239
|
+
data = JSON.parse(JSON.generate(data), symbolize_names: true)
|
|
240
|
+
return data unless value.is_a?(OpenAI::Internal::Type::BaseModel)
|
|
241
|
+
|
|
242
|
+
OpenAI::Internal::Type::Converter.coerce(value.class, data)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|