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.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/README.md +5 -2
  4. data/docs/architecture/security-model.md +2 -2
  5. data/examples/audio/record_and_play.rb +1 -1
  6. data/lib/openai/helpers/live/transcript_grouper.rb +264 -0
  7. data/lib/openai/helpers/live/transcript_grouping.rb +309 -0
  8. data/lib/openai/helpers/live/websocket.rb +454 -0
  9. data/lib/openai/helpers/realtime/translation.rb +440 -0
  10. data/lib/openai/helpers/realtime.rb +1 -0
  11. data/lib/openai/helpers/responses_websocket/incremental.rb +246 -0
  12. data/lib/openai/helpers/responses_websocket/transports/async_websocket.rb +183 -0
  13. data/lib/openai/helpers/responses_websocket.rb +1 -0
  14. data/lib/openai/helpers/streaming/response_stream.rb +19 -11
  15. data/lib/openai/helpers/structured_output/chat_completion_parser.rb +0 -2
  16. data/lib/openai/helpers/structured_output/response_parser.rb +1 -2
  17. data/lib/openai/helpers/structured_output/union_of.rb +0 -2
  18. data/lib/openai/helpers/websocket/async_websocket_transport.rb +11 -6
  19. data/lib/openai/helpers/websocket/connection_manager.rb +9 -0
  20. data/lib/openai/internal/read_io_adapter.rb +0 -2
  21. data/lib/openai/internal/stream.rb +0 -2
  22. data/lib/openai/internal/type/base_model.rb +4 -6
  23. data/lib/openai/internal/type/converter.rb +5 -3
  24. data/lib/openai/internal/type/enum.rb +0 -2
  25. data/lib/openai/internal/type/union.rb +0 -6
  26. data/lib/openai/internal/type/unknown.rb +34 -6
  27. data/lib/openai/internal/util.rb +0 -4
  28. data/lib/openai/models/audio/speech_create_params.rb +9 -0
  29. data/lib/openai/models/audio/transcription_create_response.rb +4 -1
  30. data/lib/openai/models/audio/translation_create_response.rb +4 -1
  31. data/lib/openai/models/beta/agent.rb +1 -0
  32. data/lib/openai/models/beta/agent_create_params.rb +3 -0
  33. data/lib/openai/models/beta/agent_session.rb +1 -0
  34. data/lib/openai/models/beta/agent_update_params.rb +3 -0
  35. data/lib/openai/models/beta/agents/environments/template_create_params.rb +3 -3
  36. data/lib/openai/models/beta/agents/environments/template_update_params.rb +6 -6
  37. data/lib/openai/models/beta/agents/session_create_params.rb +3 -0
  38. data/lib/openai/models/beta/agents/session_update_params.rb +3 -0
  39. data/lib/openai/models/beta/agents/vaults/credential.rb +10 -1
  40. data/lib/openai/models/beta/agents/vaults/credential_create_params.rb +12 -1
  41. data/lib/openai/models/beta/agents/vaults/credential_update_params.rb +14 -3
  42. data/lib/openai/models/beta/beta_response.rb +38 -1
  43. data/lib/openai/models/beta/beta_responses_client_event.rb +73 -1
  44. data/lib/openai/models/beta/beta_web_search_preview_tool.rb +9 -3
  45. data/lib/openai/models/beta/beta_web_search_tool.rb +9 -3
  46. data/lib/openai/models/beta/environment_param.rb +3 -3
  47. data/lib/openai/models/beta/response_compact_params.rb +3 -0
  48. data/lib/openai/models/beta/response_create_params.rb +68 -1
  49. data/lib/openai/models/beta/session_turn_error.rb +3 -0
  50. data/lib/openai/models/chat_model.rb +1 -0
  51. data/lib/openai/models/responses/response.rb +35 -1
  52. data/lib/openai/models/responses/response_compact_params.rb +3 -0
  53. data/lib/openai/models/responses/response_create_params.rb +65 -1
  54. data/lib/openai/models/responses/responses_client_event.rb +70 -1
  55. data/lib/openai/models/responses/web_search_preview_tool.rb +9 -3
  56. data/lib/openai/models/responses/web_search_tool.rb +9 -3
  57. data/lib/openai/net_http_client.rb +0 -2
  58. data/lib/openai/resources/audio/transcriptions.rb +5 -3
  59. data/lib/openai/resources/audio/translations.rb +1 -1
  60. data/lib/openai/resources/beta/agents/environments/templates.rb +3 -3
  61. data/lib/openai/resources/beta/agents/vaults/credentials.rb +12 -4
  62. data/lib/openai/resources/beta/responses.rb +8 -2
  63. data/lib/openai/resources/responses.rb +8 -1
  64. data/lib/openai/version.rb +1 -1
  65. data/lib/openai.rb +1 -0
  66. data/rbi/openai/helpers/live/transcript_grouper.rbi +67 -0
  67. data/rbi/openai/helpers/live/websocket.rbi +299 -0
  68. data/rbi/openai/helpers/realtime/translation.rbi +137 -0
  69. data/rbi/openai/helpers/responses_websocket/incremental.rbi +32 -0
  70. data/rbi/openai/helpers/responses_websocket/transports/async_websocket.rbi +24 -0
  71. data/rbi/openai/helpers/streaming/response_stream.rbi +5 -0
  72. data/rbi/openai/internal/type/converter.rbi +3 -2
  73. data/rbi/openai/models/audio/speech_create_params.rbi +3 -0
  74. data/rbi/openai/models/audio/transcription_create_response.rbi +2 -1
  75. data/rbi/openai/models/audio/translation_create_response.rbi +2 -1
  76. data/rbi/openai/models/beta/agent.rbi +1 -0
  77. data/rbi/openai/models/beta/agent_create_params.rbi +3 -0
  78. data/rbi/openai/models/beta/agent_session.rbi +1 -0
  79. data/rbi/openai/models/beta/agent_update_params.rbi +3 -0
  80. data/rbi/openai/models/beta/agents/environments/template_create_params.rbi +3 -3
  81. data/rbi/openai/models/beta/agents/environments/template_update_params.rbi +6 -6
  82. data/rbi/openai/models/beta/agents/session_create_params.rbi +3 -0
  83. data/rbi/openai/models/beta/agents/session_update_params.rbi +3 -0
  84. data/rbi/openai/models/beta/agents/vaults/credential.rbi +10 -0
  85. data/rbi/openai/models/beta/agents/vaults/credential_create_params.rbi +15 -0
  86. data/rbi/openai/models/beta/agents/vaults/credential_update_params.rbi +35 -6
  87. data/rbi/openai/models/beta/beta_response.rbi +64 -0
  88. data/rbi/openai/models/beta/beta_responses_client_event.rbi +128 -0
  89. data/rbi/openai/models/beta/beta_web_search_preview_tool.rbi +9 -3
  90. data/rbi/openai/models/beta/beta_web_search_tool.rbi +9 -3
  91. data/rbi/openai/models/beta/environment_param.rbi +3 -3
  92. data/rbi/openai/models/beta/response_compact_params.rbi +1 -0
  93. data/rbi/openai/models/beta/response_create_params.rbi +100 -0
  94. data/rbi/openai/models/beta/session_turn_error.rbi +6 -0
  95. data/rbi/openai/models/chat_model.rbi +1 -0
  96. data/rbi/openai/models/responses/response.rbi +63 -0
  97. data/rbi/openai/models/responses/response_compact_params.rbi +1 -0
  98. data/rbi/openai/models/responses/response_create_params.rbi +106 -0
  99. data/rbi/openai/models/responses/responses_client_event.rbi +125 -0
  100. data/rbi/openai/models/responses/web_search_preview_tool.rbi +9 -3
  101. data/rbi/openai/models/responses/web_search_tool.rbi +9 -3
  102. data/rbi/openai/resources/audio/transcriptions.rbi +4 -2
  103. data/rbi/openai/resources/beta/agents/environments/templates.rbi +3 -3
  104. data/rbi/openai/resources/beta/agents/vaults/credentials.rbi +11 -3
  105. data/rbi/openai/resources/beta/responses.rbi +6 -0
  106. data/rbi/openai/resources/responses.rbi +11 -0
  107. data/responses-websocket.md +55 -0
  108. data/sig/openai/helpers/live/transcript_grouper.rbs +42 -0
  109. data/sig/openai/helpers/live/websocket.rbs +152 -0
  110. data/sig/openai/helpers/realtime/translation.rbs +81 -0
  111. data/sig/openai/helpers/responses_websocket/incremental.rbs +14 -0
  112. data/sig/openai/helpers/responses_websocket/transports/async_websocket.rbs +21 -0
  113. data/sig/openai/helpers/streaming/response_stream.rbs +9 -0
  114. data/sig/openai/internal/type/converter.rbs +3 -1
  115. data/sig/openai/models/audio/speech_create_params.rbs +6 -0
  116. data/sig/openai/models/audio/transcription_create_response.rbs +1 -0
  117. data/sig/openai/models/audio/translation_create_response.rbs +3 -1
  118. data/sig/openai/models/beta/agent.rbs +3 -1
  119. data/sig/openai/models/beta/agent_create_params.rbs +5 -1
  120. data/sig/openai/models/beta/agent_session.rbs +3 -1
  121. data/sig/openai/models/beta/agent_update_params.rbs +5 -1
  122. data/sig/openai/models/beta/agents/environments/template_create_params.rbs +2 -2
  123. data/sig/openai/models/beta/agents/environments/template_update_params.rbs +4 -4
  124. data/sig/openai/models/beta/agents/session_create_params.rbs +5 -1
  125. data/sig/openai/models/beta/agents/session_update_params.rbs +5 -1
  126. data/sig/openai/models/beta/agents/vaults/credential.rbs +8 -0
  127. data/sig/openai/models/beta/agents/vaults/credential_create_params.rbs +14 -0
  128. data/sig/openai/models/beta/agents/vaults/credential_update_params.rbs +23 -4
  129. data/sig/openai/models/beta/beta_response.rbs +37 -0
  130. data/sig/openai/models/beta/beta_responses_client_event.rbs +79 -0
  131. data/sig/openai/models/beta/beta_web_search_preview_tool.rbs +6 -2
  132. data/sig/openai/models/beta/beta_web_search_tool.rbs +6 -2
  133. data/sig/openai/models/beta/environment_param.rbs +2 -2
  134. data/sig/openai/models/beta/response_compact_params.rbs +2 -0
  135. data/sig/openai/models/beta/response_create_params.rbs +79 -0
  136. data/sig/openai/models/beta/session_turn_error.rbs +4 -0
  137. data/sig/openai/models/chat_model.rbs +2 -0
  138. data/sig/openai/models/responses/response.rbs +35 -0
  139. data/sig/openai/models/responses/response_compact_params.rbs +2 -0
  140. data/sig/openai/models/responses/response_create_params.rbs +77 -0
  141. data/sig/openai/models/responses/responses_client_event.rbs +77 -0
  142. data/sig/openai/models/responses/web_search_preview_tool.rbs +6 -2
  143. data/sig/openai/models/responses/web_search_tool.rbs +6 -2
  144. data/sig/openai/resources/beta/agents/vaults/credentials.rbs +3 -1
  145. data/sig/openai/resources/beta/responses.rbs +2 -0
  146. data/sig/openai/resources/responses.rbs +4 -0
  147. 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
@@ -14,3 +14,4 @@ require_relative "realtime/recovery"
14
14
  require_relative "realtime/transports/async_websocket"
15
15
  require_relative "realtime/client_extension"
16
16
  require_relative "realtime/resources/realtime_extension"
17
+ require_relative "realtime/translation"
@@ -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