openai 0.105.0 → 0.107.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +1 -1
  4. data/lib/openai/helpers/live/websocket.rb +12 -141
  5. data/lib/openai/helpers/local_audio/process.rb +13 -3
  6. data/lib/openai/helpers/models/embedding.rb +42 -0
  7. data/lib/openai/helpers/realtime/connection.rb +22 -5
  8. data/lib/openai/helpers/realtime/recovery.rb +11 -5
  9. data/lib/openai/helpers/realtime/translation.rb +37 -169
  10. data/lib/openai/helpers/realtime/unknown_server_event.rb +5 -12
  11. data/lib/openai/helpers/responses_websocket/connection.rb +56 -35
  12. data/lib/openai/helpers/responses_websocket/unknown_server_event.rb +2 -14
  13. data/lib/openai/helpers/websocket/async_websocket_transport.rb +8 -6
  14. data/lib/openai/helpers/websocket/client_request.rb +1 -1
  15. data/lib/openai/helpers/websocket/connection.rb +21 -10
  16. data/lib/openai/helpers/websocket/connection_manager.rb +2 -1
  17. data/lib/openai/helpers/websocket/protocol.rb +160 -0
  18. data/lib/openai/helpers/websocket/typed_connection.rb +22 -16
  19. data/lib/openai/helpers/websocket/typed_resource.rb +15 -0
  20. data/lib/openai/internal/transport/base_client.rb +1 -1
  21. data/lib/openai/models/admin/organization/external_storage_configuration.rb +1 -0
  22. data/lib/openai/models/admin/organization/usage_audio_speeches_response.rb +12 -1
  23. data/lib/openai/models/admin/organization/usage_audio_transcriptions_response.rb +12 -1
  24. data/lib/openai/models/admin/organization/usage_code_interpreter_sessions_response.rb +12 -1
  25. data/lib/openai/models/admin/organization/usage_completions_response.rb +12 -1
  26. data/lib/openai/models/admin/organization/usage_costs_response.rb +12 -1
  27. data/lib/openai/models/admin/organization/usage_decisions_response.rb +12 -1
  28. data/lib/openai/models/admin/organization/usage_embeddings_response.rb +12 -1
  29. data/lib/openai/models/admin/organization/usage_file_search_calls_response.rb +12 -1
  30. data/lib/openai/models/admin/organization/usage_images_response.rb +12 -1
  31. data/lib/openai/models/admin/organization/usage_moderations_response.rb +12 -1
  32. data/lib/openai/models/admin/organization/usage_vector_stores_response.rb +12 -1
  33. data/lib/openai/models/admin/organization/usage_web_search_calls_response.rb +12 -1
  34. data/lib/openai/models/beta/agent_create_params.rb +3 -0
  35. data/lib/openai/models/beta/agent_update_params.rb +3 -0
  36. data/lib/openai/models/beta/agents/session_create_params.rb +3 -0
  37. data/lib/openai/models/beta/agents/session_update_params.rb +3 -0
  38. data/lib/openai/models/beta/assistant_update_params.rb +12 -0
  39. data/lib/openai/models/beta/beta_response.rb +12 -0
  40. data/lib/openai/models/beta/beta_response_input_item.rb +10 -1
  41. data/lib/openai/models/beta/beta_responses_client_event.rb +12 -0
  42. data/lib/openai/models/beta/response_compact_params.rb +12 -0
  43. data/lib/openai/models/beta/response_create_params.rb +12 -0
  44. data/lib/openai/models/chat_model.rb +12 -0
  45. data/lib/openai/models/decision_create_params.rb +112 -1
  46. data/lib/openai/models/decision_input_image.rb +14 -1
  47. data/lib/openai/models/decision_input_text.rb +15 -1
  48. data/lib/openai/models/decision_prompt_cache_breakpoint.rb +21 -0
  49. data/lib/openai/models/live/sideband_server_event.rb +243 -3
  50. data/lib/openai/models/responses/response_compact_params.rb +12 -0
  51. data/lib/openai/models/responses/response_input_item.rb +10 -1
  52. data/lib/openai/models.rb +2 -0
  53. data/lib/openai/resources/beta/responses.rb +19 -0
  54. data/lib/openai/resources/decisions.rb +15 -1
  55. data/lib/openai/version.rb +1 -1
  56. data/lib/openai.rb +2 -0
  57. data/rbi/openai/client.rbi +1 -8
  58. data/rbi/openai/helpers/realtime/translation.rbi +0 -5
  59. data/rbi/openai/helpers/realtime/unknown_server_event.rbi +6 -0
  60. data/rbi/openai/helpers/websocket.rbi +44 -6
  61. data/rbi/openai/internal/transport/base_client.rbi +2 -6
  62. data/rbi/openai/models/admin/organization/external_storage_configuration.rbi +1 -0
  63. data/rbi/openai/models/admin/organization/usage_audio_speeches_response.rbi +12 -0
  64. data/rbi/openai/models/admin/organization/usage_audio_transcriptions_response.rbi +12 -0
  65. data/rbi/openai/models/admin/organization/usage_code_interpreter_sessions_response.rbi +12 -0
  66. data/rbi/openai/models/admin/organization/usage_completions_response.rbi +12 -0
  67. data/rbi/openai/models/admin/organization/usage_costs_response.rbi +12 -0
  68. data/rbi/openai/models/admin/organization/usage_decisions_response.rbi +12 -0
  69. data/rbi/openai/models/admin/organization/usage_embeddings_response.rbi +12 -0
  70. data/rbi/openai/models/admin/organization/usage_file_search_calls_response.rbi +12 -0
  71. data/rbi/openai/models/admin/organization/usage_images_response.rbi +12 -0
  72. data/rbi/openai/models/admin/organization/usage_moderations_response.rbi +12 -0
  73. data/rbi/openai/models/admin/organization/usage_vector_stores_response.rbi +12 -0
  74. data/rbi/openai/models/admin/organization/usage_web_search_calls_response.rbi +12 -0
  75. data/rbi/openai/models/beta/agent_create_params.rbi +3 -0
  76. data/rbi/openai/models/beta/agent_update_params.rbi +3 -0
  77. data/rbi/openai/models/beta/agents/session_create_params.rbi +3 -0
  78. data/rbi/openai/models/beta/agents/session_update_params.rbi +3 -0
  79. data/rbi/openai/models/beta/beta_response_input_item.rbi +14 -1
  80. data/rbi/openai/models/decision_create_params.rbi +146 -0
  81. data/rbi/openai/models/decision_input_image.rbi +22 -1
  82. data/rbi/openai/models/decision_input_text.rbi +21 -1
  83. data/rbi/openai/models/decision_prompt_cache_breakpoint.rbi +48 -0
  84. data/rbi/openai/models/live/output_audio_delta_event.rbi +1 -1
  85. data/rbi/openai/models/live/sideband_server_event.rbi +406 -3
  86. data/rbi/openai/models/responses/response_input_item.rbi +10 -1
  87. data/rbi/openai/models.rbi +2 -0
  88. data/rbi/openai/resources/beta/responses.rbi +17 -0
  89. data/rbi/openai/resources/decisions.rbi +14 -0
  90. data/realtime.md +22 -3
  91. data/sig/openai/client.rbs +1 -1
  92. data/sig/openai/helpers/realtime/translation.rbs +0 -2
  93. data/sig/openai/helpers/realtime/unknown_server_event.rbs +2 -0
  94. data/sig/openai/helpers/websocket.rbs +23 -6
  95. data/sig/openai/models/admin/organization/external_storage_configuration.rbs +2 -1
  96. data/sig/openai/models/admin/organization/usage_audio_speeches_response.rbs +10 -0
  97. data/sig/openai/models/admin/organization/usage_audio_transcriptions_response.rbs +10 -0
  98. data/sig/openai/models/admin/organization/usage_code_interpreter_sessions_response.rbs +10 -0
  99. data/sig/openai/models/admin/organization/usage_completions_response.rbs +10 -0
  100. data/sig/openai/models/admin/organization/usage_costs_response.rbs +10 -0
  101. data/sig/openai/models/admin/organization/usage_decisions_response.rbs +10 -0
  102. data/sig/openai/models/admin/organization/usage_embeddings_response.rbs +10 -0
  103. data/sig/openai/models/admin/organization/usage_file_search_calls_response.rbs +10 -0
  104. data/sig/openai/models/admin/organization/usage_images_response.rbs +10 -0
  105. data/sig/openai/models/admin/organization/usage_moderations_response.rbs +10 -0
  106. data/sig/openai/models/admin/organization/usage_vector_stores_response.rbs +10 -0
  107. data/sig/openai/models/admin/organization/usage_web_search_calls_response.rbs +10 -0
  108. data/sig/openai/models/beta/agent_create_params.rbs +5 -1
  109. data/sig/openai/models/beta/agent_update_params.rbs +5 -1
  110. data/sig/openai/models/beta/agents/session_create_params.rbs +5 -1
  111. data/sig/openai/models/beta/agents/session_update_params.rbs +5 -1
  112. data/sig/openai/models/beta/beta_response_input_item.rbs +8 -0
  113. data/sig/openai/models/decision_create_params.rbs +106 -0
  114. data/sig/openai/models/decision_input_image.rbs +18 -2
  115. data/sig/openai/models/decision_input_text.rbs +29 -3
  116. data/sig/openai/models/decision_prompt_cache_breakpoint.rbs +16 -0
  117. data/sig/openai/models/live/sideband_server_event.rbs +225 -0
  118. data/sig/openai/models/responses/response_input_item.rbs +8 -3
  119. data/sig/openai/models.rbs +2 -0
  120. data/sig/openai/resources/beta/responses.rbs +7 -0
  121. data/sig/openai/resources/decisions.rbs +3 -0
  122. metadata +6 -1
@@ -25,36 +25,11 @@ module OpenAI
25
25
  module Realtime
26
26
  # Future event data is accessible explicitly without leaking in diagnostics.
27
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
28
  end
51
29
 
52
30
  # Opening does not send any events. session.close flushes remaining output;
53
31
  # read through session.closed before leaving the block to consume it.
54
- class TranslationConnection < OpenAI::WebSocket::Connection
55
- include OpenAI::WebSocket::Protocol
56
- include OpenAI::WebSocket::GuardedConnection
57
-
32
+ class TranslationConnection < OpenAI::WebSocket::TypedConnection
58
33
  # Timeout implementations before Ruby 4 raise StandardError through the
59
34
  # scheduler. Cross transport error wrappers without changing raw callers.
60
35
  # rubocop:disable Lint/InheritException
@@ -66,21 +41,17 @@ module OpenAI
66
41
 
67
42
  # @api private
68
43
  def initialize(socket:, url:)
69
- super
44
+ super(
45
+ socket: socket,
46
+ url: url,
47
+ client_event: OpenAI::Realtime::RealtimeTranslationClientEvent,
48
+ server_event: OpenAI::Realtime::RealtimeTranslationServerEvent
49
+ )
70
50
  @poisoned = false
71
51
  @closed = false
72
52
  @write_mutex = Mutex.new
73
53
  @read_state_mutex = Mutex.new
74
54
  @active_reads = 0
75
- @server_event_types = OpenAI::Realtime::RealtimeTranslationServerEvent.variants.to_h do |variant|
76
- [variant.fields.fetch(:type).fetch(:const).to_s, variant]
77
- end
78
-
79
- @client_event_names = discriminator_values(OpenAI::Realtime::RealtimeTranslationClientEvent)
80
- end
81
-
82
- def send_event(event)
83
- send_raw(encode_client_event(event))
84
55
  end
85
56
 
86
57
  # Request a final flush and yield every trailing event, including
@@ -101,10 +72,21 @@ module OpenAI
101
72
  require "timeout"
102
73
 
103
74
  @read_state_mutex.synchronize do
75
+ if @finish_owner
76
+ raise(
77
+ connection_error("Translation finish must run on the existing receive owner, between reads."),
78
+ cause: nil
79
+ )
80
+ end
81
+
82
+ # SDK .cause accessors can expose diagnostics hidden from native rendering.
83
+ raise @finish_error, cause: Exception.instance_method(:cause).bind_call(@finish_error) if @finish_error
104
84
  return @finish_terminal if @finish_terminal
105
- raise @finish_error if @finish_error
106
- if @active_reads.positive? || @finish_requested || @finish_owner
107
- raise connection_error("Translation finish must run on the existing receive owner, between reads.")
85
+ if @active_reads.positive? || @finish_requested
86
+ raise(
87
+ connection_error("Translation finish must run on the existing receive owner, between reads."),
88
+ cause: nil
89
+ )
108
90
  end
109
91
 
110
92
  @finish_owner = Fiber.current
@@ -114,14 +96,13 @@ module OpenAI
114
96
  Timeout.timeout(seconds, FinishTimeout) do
115
97
  write_text(encode_client_event(type: "session.close"), finish: true)
116
98
  while (event = receive)
99
+ terminal = event.is_a?(OpenAI::Realtime::RealtimeTranslationSessionClosedEvent)
100
+ @read_state_mutex.synchronize { @finish_terminal = event } if terminal
117
101
  yield(event)
118
- if event.is_a?(OpenAI::Realtime::RealtimeTranslationSessionClosedEvent)
119
- @read_state_mutex.synchronize { @finish_terminal = event }
120
- return event
121
- end
102
+ return event if terminal
122
103
  end
123
104
 
124
- raise connection_error("Translation socket closed before session.closed.")
105
+ raise connection_error("Translation socket closed before session.closed."), cause: nil
125
106
  end
126
107
 
127
108
  rescue FinishTimeout
@@ -139,10 +120,10 @@ module OpenAI
139
120
  private def write_text(text, finish: false)
140
121
  @write_mutex.synchronize do
141
122
  if @finish_requested || (@finish_owner && !finish)
142
- raise connection_error("Cannot send after Translation finish was requested.")
123
+ raise connection_error("Cannot send after Translation finish was requested."), cause: nil
143
124
  end
144
125
 
145
- raise connection_error("Cannot send on a closed Realtime Translation WebSocket.") if closed?
126
+ raise connection_error("Cannot send on a closed Realtime Translation WebSocket."), cause: nil if closed?
146
127
  @finish_requested = true if finish
147
128
  super(text)
148
129
  end
@@ -151,7 +132,10 @@ module OpenAI
151
132
  private def read_raw_message
152
133
  @read_state_mutex.synchronize do
153
134
  if @finish_owner && @finish_owner != Fiber.current
154
- raise connection_error("Translation finish must run on the existing receive owner, between reads.")
135
+ raise(
136
+ connection_error("Translation finish must run on the existing receive owner, between reads."),
137
+ cause: nil
138
+ )
155
139
  end
156
140
 
157
141
  @active_reads += 1
@@ -164,133 +148,17 @@ module OpenAI
164
148
  end
165
149
  end
166
150
 
167
- private def encode_client_event(event)
168
- validate_client_tree!(event)
169
- union = OpenAI::Realtime::RealtimeTranslationClientEvent
170
- payload = OpenAI::Internal::Type::Converter.dump(union, event)
171
- type = payload[:type] || payload["type"] if payload.is_a?(Hash)
172
- raise ArgumentError unless (type.is_a?(String) || type.is_a?(Symbol)) && @client_event_names.key?(type.to_s)
173
-
174
- coerced = coerce_event(union, payload, outbound: true)
175
- serialized = OpenAI::Internal::Type::Converter.dump(union, coerced)
176
- validate_client_tree!(serialized, json_only: true)
177
- JSON.generate(serialized, max_nesting: false)
178
- rescue StandardError, SystemStackError
179
- raise ArgumentError, "Invalid Realtime Translation client event.", cause: nil
180
- end
181
-
182
- # A merged string-keyed JSON event and symbol-keyed override must not
183
- # silently change the command or the configuration that is sent.
184
- private def validate_client_tree!(event, json_only: false)
185
- visited = {}.compare_by_identity
186
- pending = [event]
187
- until pending.empty?
188
- item = pending.pop
189
- next if visited.key?(item)
190
- visited[item] = true
191
- case item
192
- when OpenAI::Internal::Type::BaseModel
193
- raise ArgumentError if json_only
194
- validate_model_constants!(item)
195
- pending << item.to_h
196
- when Hash
197
- names = {}
198
- item.each do |key, value|
199
- raise ArgumentError unless key.is_a?(String) || key.is_a?(Symbol)
200
- name = key.to_s
201
- raise ArgumentError if names.key?(name)
202
- names[name] = true
203
- pending << value
204
- end
205
-
206
- when Array
207
- pending.concat(item)
208
- when NilClass, TrueClass, FalseClass, String, Integer, Float, Symbol
209
- next
210
- else
211
- raise ArgumentError if json_only
212
- end
213
- end
214
- end
215
-
216
- private def validate_model_constants!(model)
217
- model.to_h.each do |name, actual|
218
- key = name.is_a?(String) ? name.to_sym : name
219
- field = model.class.known_fields[key]
220
- next unless field
221
- const = field.fetch(:const)
222
- next if const == OpenAI::Internal::OMIT
223
- if const.is_a?(Symbol)
224
- raise ArgumentError unless (actual.is_a?(String) || actual.is_a?(Symbol)) && actual.to_s == const.to_s
225
- elsif actual != const
226
- raise ArgumentError
227
- end
228
- end
229
- end
151
+ private :parse_event
230
152
 
231
- private def parse_event(data)
232
- parsed = JSON.parse(data, symbolize_names: true, max_nesting: false)
233
- type = event_type(parsed, message: "Realtime Translation server event must be an object with a string type")
234
- model = @server_event_types[type.to_s]
235
- unless model
236
- return OpenAI::Realtime::UnknownTranslationServerEvent.new(data: parsed)
237
- end
153
+ private def coerce_client_event(payload) = coerce_event(@client_event, payload, outbound: true)
238
154
 
239
- # The converter copies unknown wire fields after known ones. A wire
240
- # key spelled like a renamed Ruby field must not overwrite typed data.
241
- model.fields.each do |name, field|
242
- raise ArgumentError if name != field.fetch(:api_name) && parsed.key?(name)
243
- end
155
+ private def coerce(type, value) = coerce_event(type, value)
244
156
 
245
- coerce_event(OpenAI::Realtime::RealtimeTranslationServerEvent, parsed)
246
- rescue StandardError, SystemStackError
247
- raise OpenAI::Errors::TranslationProtocolError.new, cause: nil
248
- end
157
+ private def client_event_error_message = "Invalid Realtime Translation client event."
249
158
 
250
- private def coerce_event(union, payload, outbound: false)
251
- state = OpenAI::Internal::Type::Converter.new_coerce_state(request_only: outbound)
252
- event = OpenAI::Internal::Type::Converter.coerce(union, payload, state: state)
253
- raise ArgumentError if state[:error] || !state.fetch(:exactness).fetch(:no).zero?
254
- ancestors = {}.compare_by_identity
255
- pending = [[event, false]]
256
- until pending.empty?
257
- value, exiting = pending.pop
258
- if exiting
259
- ancestors.delete(value)
260
- next
261
- end
159
+ private def unknown_server_event(payload) = OpenAI::Realtime::UnknownTranslationServerEvent.new(data: payload)
262
160
 
263
- children = case value
264
- when OpenAI::Internal::Type::BaseModel
265
- data = value.to_h
266
- validate_model_constants!(value)
267
- value.class.fields.each do |name, field|
268
- const = field.fetch(:const)
269
- if field.fetch(:required) &&
270
- field.fetch(:mode) != (outbound ? :coerce : :dump) &&
271
- (!outbound || const == OpenAI::Internal::OMIT) &&
272
- !data.key?(name)
273
- raise ArgumentError
274
- end
275
- end
276
-
277
- data.values
278
- when Hash
279
- value.values
280
- when Array
281
- value
282
- else
283
- next
284
- end
285
-
286
- raise ArgumentError if ancestors.key?(value)
287
- ancestors[value] = true
288
- pending << [value, true]
289
- children.each { |child| pending << [child, false] }
290
- end
291
-
292
- event
293
- end
161
+ private def protocol_error = OpenAI::Errors::TranslationProtocolError.new
294
162
 
295
163
  private def transport_error = OpenAI::Errors::TranslationConnectionError.new(url: @url)
296
164
  private def failed_read_message = "Cannot read from a failed Realtime Translation WebSocket."
@@ -4,6 +4,8 @@ module OpenAI
4
4
  module Realtime
5
5
  # A valid JSON event whose discriminator is newer than this SDK version.
6
6
  class UnknownServerEvent
7
+ include OpenAI::WebSocket::Protocol
8
+
7
9
  # @return [Symbol]
8
10
  attr_reader :type
9
11
 
@@ -25,20 +27,11 @@ module OpenAI
25
27
  # @return [Hash{Symbol=>Object}]
26
28
  def to_h = @data
27
29
 
28
- private def freeze_json(value)
29
- case value
30
- when Hash
31
- value.each do |key, item|
32
- freeze_json(key)
33
- freeze_json(item)
34
- end
30
+ # Remote values, including the discriminator, require explicit access.
31
+ def inspect = "#<#{self.class}>"
35
32
 
36
- when Array
37
- value.each { |item| freeze_json(item) }
38
- end
33
+ alias to_s inspect
39
34
 
40
- value.freeze
41
- end
42
35
  end
43
36
  end
44
37
  end
@@ -51,7 +51,13 @@ module OpenAI
51
51
  assert_owner!
52
52
  raise connection_error("Cannot send on a closed Responses WebSocket.") if poisoned? || closed?
53
53
 
54
- write_encoded(JSON.generate(encode_client_event(event)))
54
+ encoded = begin
55
+ generate_json(encode_client_event(event))
56
+ rescue SystemStackError
57
+ raise OpenAI::Errors::ResponsesClientEventError.new, cause: nil
58
+ end
59
+
60
+ write_encoded(encoded)
55
61
  rescue OpenAI::Errors::ResponsesConnectionError,
56
62
  OpenAI::Errors::ResponsesClientEventError,
57
63
  OpenAI::Errors::ResponsesSendError
@@ -107,21 +113,22 @@ module OpenAI
107
113
  def closed? = @state == :closed || super
108
114
 
109
115
  private def parse_event(data)
110
- parsed = JSON.parse(data, symbolize_names: true)
116
+ parsed = parse_json(data)
111
117
  type = event_type(parsed, message: "Responses server event must be a JSON object")
112
118
  unless @server_event_names.key?(type.to_s)
113
119
  return OpenAI::Responses::UnknownServerEvent.new(data: parsed)
114
120
  end
115
121
 
116
122
  state = OpenAI::Internal::Type::Converter.new_coerce_state
117
- OpenAI::Internal::Type::Converter.coerce(
123
+ event = OpenAI::Internal::Type::Converter.coerce(
118
124
  OpenAI::Responses::ResponsesServerEvent,
119
125
  parsed,
120
126
  state: state
121
127
  )
128
+ validate_wire_field_names!(event, parsed)
122
129
  rescue OpenAI::Errors::ResponsesProtocolError
123
130
  raise
124
- rescue StandardError
131
+ rescue StandardError, SystemStackError
125
132
  raise OpenAI::Errors::ResponsesProtocolError.new, cause: nil
126
133
  end
127
134
 
@@ -209,34 +216,33 @@ module OpenAI
209
216
  raise connection_error("Responses WebSocket connections are single-owner.")
210
217
  end
211
218
 
212
- private def validate_event_tree!(value, active = {}.compare_by_identity)
213
- case value
214
- when OpenAI::Internal::Type::BaseModel
215
- guard_cycle!(value, active)
216
- begin
217
- data = value.to_h
218
- reject_duplicate_serialized_model_keys!(value.class, data)
219
- validate_event_tree!(data, active)
220
- ensure
219
+ private def validate_event_tree!(value)
220
+ active = {}.compare_by_identity
221
+ pending = [[value, false]]
222
+ until pending.empty?
223
+ value, exiting = pending.pop
224
+ if exiting
221
225
  active.delete(value)
226
+ next
222
227
  end
223
228
 
224
- when Hash
225
- guard_cycle!(value, active)
226
- reject_duplicate_semantic_keys!(value)
227
- begin
228
- value.each_value { |item| validate_event_tree!(item, active) }
229
- ensure
230
- active.delete(value)
229
+ children = case value
230
+ when OpenAI::Internal::Type::BaseModel
231
+ data = value.to_h
232
+ reject_duplicate_serialized_model_keys!(value.class, data)
233
+ [data]
234
+ when Hash
235
+ reject_duplicate_semantic_keys!(value)
236
+ value.values
237
+ when Array
238
+ value
239
+ else
240
+ next
231
241
  end
232
242
 
233
- when Array
234
243
  guard_cycle!(value, active)
235
- begin
236
- value.each { |item| validate_event_tree!(item, active) }
237
- ensure
238
- active.delete(value)
239
- end
244
+ pending << [value, true]
245
+ children.reverse_each { |child| pending << [child, false] }
240
246
  end
241
247
 
242
248
  nil
@@ -272,18 +278,33 @@ module OpenAI
272
278
  end
273
279
 
274
280
  private def normalize_event_keys(value)
275
- case value
276
- when Hash
277
- value.to_h do |key, item|
281
+ return value unless value.is_a?(Hash) || value.is_a?(Array)
282
+
283
+ result = value.is_a?(Hash) ? {} : []
284
+ copies = {}.compare_by_identity
285
+ copies[value] = result
286
+ pending = [[value, result]]
287
+ until pending.empty?
288
+ source, destination = pending.pop
289
+ entries = source.is_a?(Hash) ? source.each_pair : source.each_with_index.lazy.map { |item, index|
290
+ [index, item]
291
+ }
292
+ entries.each do |key, item|
293
+ if item.is_a?(Hash) || item.is_a?(Array)
294
+ unless copies.key?(item)
295
+ copies[item] = item.is_a?(Hash) ? {} : []
296
+ pending << [item, copies.fetch(item)]
297
+ end
298
+
299
+ item = copies.fetch(item)
300
+ end
301
+
278
302
  normalized_key = key.is_a?(String) ? key.to_sym : key
279
- [normalized_key, normalize_event_keys(item)]
303
+ destination[normalized_key] = item
280
304
  end
281
-
282
- when Array
283
- value.map { |item| normalize_event_keys(item) }
284
- else
285
- value
286
305
  end
306
+
307
+ result
287
308
  end
288
309
 
289
310
  private def connection_error(message)
@@ -4,6 +4,8 @@ module OpenAI
4
4
  module Responses
5
5
  # A valid JSON event whose discriminator is newer than this SDK version.
6
6
  class UnknownServerEvent
7
+ include OpenAI::WebSocket::Protocol
8
+
7
9
  # @return [Symbol]
8
10
  attr_reader :type
9
11
 
@@ -35,20 +37,6 @@ module OpenAI
35
37
 
36
38
  alias to_s inspect
37
39
 
38
- private def freeze_json(value)
39
- case value
40
- when Hash
41
- value.each do |key, item|
42
- freeze_json(key)
43
- freeze_json(item)
44
- end
45
-
46
- when Array
47
- value.each { |item| freeze_json(item) }
48
- end
49
-
50
- value.freeze
51
- end
52
40
  end
53
41
  end
54
42
  end
@@ -171,7 +171,9 @@ module OpenAI
171
171
  @connection.read
172
172
  rescue StandardError => e
173
173
  error = @error_factory.call(url: @url, cause: e)
174
- raise error, cause: error.cause
174
+ # The factory retains explicit diagnostics; do not render them as a
175
+ # native exception chain, which can include payloads or credentials.
176
+ raise error, cause: nil
175
177
  end
176
178
 
177
179
  def write(message)
@@ -179,14 +181,14 @@ module OpenAI
179
181
  @connection.flush
180
182
  rescue StandardError => e
181
183
  error = @error_factory.call(url: @url, cause: e)
182
- raise error, cause: error.cause
184
+ raise error, cause: nil
183
185
  end
184
186
 
185
187
  def close(code: 1000, reason: "")
186
188
  @connection.close(code, reason)
187
189
  rescue StandardError => e
188
190
  error = @error_factory.call(url: @url, cause: e)
189
- raise error, cause: error.cause
191
+ raise error, cause: nil
190
192
  end
191
193
 
192
194
  # @api private
@@ -197,7 +199,7 @@ module OpenAI
197
199
  @aborted = true
198
200
  rescue StandardError => e
199
201
  error = @error_factory.call(url: @url, cause: e)
200
- raise error, cause: error.cause
202
+ raise error, cause: nil
201
203
  end
202
204
 
203
205
  # @api private
@@ -302,7 +304,7 @@ module OpenAI
302
304
  raise if e.equal?(block_error)
303
305
 
304
306
  error = @error_factory.call(url: url, cause: e, http_status: handshake_status(e))
305
- raise error, cause: error.cause
307
+ raise error, cause: nil
306
308
  end
307
309
 
308
310
  private def negotiation_options(_endpoint_options) = {}
@@ -373,7 +375,7 @@ module OpenAI
373
375
  "#{@product_name} WebSockets require the `async-websocket` gem. " \
374
376
  "Add `gem \"async-websocket\"` to your Gemfile."
375
377
  error = @error_factory.call(url: url, message: message, cause: e)
376
- raise error, cause: error.cause
378
+ raise error, cause: nil
377
379
  end
378
380
 
379
381
  private def authority(url, include_default_port: false)
@@ -140,7 +140,7 @@ module OpenAI
140
140
  [request, deadline]
141
141
  rescue Timeout::Error => e
142
142
  error = timeout_error.call(error_url, e)
143
- raise error, cause: error.cause
143
+ raise error, cause: nil
144
144
  end
145
145
 
146
146
  private def with_shared_websocket_base_url(request, path:, base_url:)
@@ -28,20 +28,13 @@ module OpenAI
28
28
  end
29
29
 
30
30
  private def write_text(text)
31
- complete = false
32
31
  super
33
- complete = true
34
- nil
35
32
  rescue StandardError
36
33
  raise transport_error, cause: nil
37
- ensure
38
- # Also retire a possibly buffered write when cancellation bypasses
39
- # StandardError, before another sender or normal cleanup can flush it.
40
- @poisoned = true unless complete
41
34
  end
42
35
 
43
36
  private def read_raw_message
44
- raise connection_error(failed_read_message) if @poisoned
37
+ raise connection_error(failed_read_message), cause: nil if @poisoned
45
38
  return nil if @closed
46
39
  text = super
47
40
  @closed = text.nil?
@@ -73,6 +66,7 @@ module OpenAI
73
66
  def initialize(socket:, url:)
74
67
  @socket = socket
75
68
  @url = url
69
+ @poisoned = false
76
70
  end
77
71
 
78
72
  # Yield server events until the remote peer closes the connection.
@@ -100,6 +94,8 @@ module OpenAI
100
94
  def receive_raw = read_raw_message
101
95
 
102
96
  private def read_raw_message
97
+ raise connection_error("Cannot read from a failed WebSocket."), cause: nil if @poisoned
98
+
103
99
  message = @socket.read
104
100
  message&.to_str
105
101
  end
@@ -108,7 +104,7 @@ module OpenAI
108
104
  #
109
105
  # @api private
110
106
  def send_raw(data)
111
- raise connection_error(closed_send_message) if closed?
107
+ raise connection_error(closed_send_message), cause: nil if closed?
112
108
 
113
109
  text = data.dup
114
110
  text.force_encoding(Encoding::UTF_8) if text.encoding == Encoding::BINARY
@@ -120,6 +116,7 @@ module OpenAI
120
116
 
121
117
  # Close the connection.
122
118
  def close(code: 1000, reason: "")
119
+ return abort if @poisoned
123
120
  return if socket_closed?
124
121
 
125
122
  @socket.close(code: code, reason: reason)
@@ -137,7 +134,7 @@ module OpenAI
137
134
  end
138
135
 
139
136
  # @return [Boolean]
140
- def closed? = socket_closed?
137
+ def closed? = @poisoned || socket_closed?
141
138
 
142
139
  private def parse_event(_data)
143
140
  raise NotImplementedError
@@ -154,8 +151,22 @@ module OpenAI
154
151
  private def socket_closed? = @socket.closed?
155
152
 
156
153
  private def write_text(text)
154
+ complete = false
157
155
  @socket.write(text)
156
+ complete = true
158
157
  nil
158
+ ensure
159
+ unless complete
160
+ # Retire the physical socket immediately: an already-running reader
161
+ # can otherwise flush buffered application bytes while replying to close.
162
+ @poisoned = true
163
+ begin
164
+ @socket.abort
165
+ rescue StandardError
166
+ # Preserve the original write failure or cancellation.
167
+ nil
168
+ end
169
+ end
159
170
  end
160
171
  end
161
172
  end
@@ -73,7 +73,8 @@ module OpenAI
73
73
 
74
74
  rescue StandardError => error
75
75
  raise if @transport_error_factory.nil?
76
- raise block_error, cause: block_error.cause if block_error
76
+ # Preserve the native chain, not an SDK accessor's explicit diagnostics.
77
+ raise block_error, cause: Exception.instance_method(:cause).bind_call(block_error) if block_error
77
78
  raise @transport_error_factory.call(url: request.fetch(:url), error: error), cause: nil
78
79
  end
79
80
  end