openai 0.106.0 → 0.108.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +49 -1
  4. data/examples/chat/tool_runner.rb +33 -0
  5. data/lib/openai/client.rb +2 -2
  6. data/lib/openai/helpers/chat/tool_runner.rb +259 -0
  7. data/lib/openai/helpers/live/websocket.rb +12 -141
  8. data/lib/openai/helpers/local_audio/process.rb +13 -3
  9. data/lib/openai/helpers/models/embedding.rb +42 -0
  10. data/lib/openai/helpers/realtime/connection.rb +22 -5
  11. data/lib/openai/helpers/realtime/recovery.rb +19 -7
  12. data/lib/openai/helpers/realtime/translation.rb +37 -169
  13. data/lib/openai/helpers/realtime/unknown_server_event.rb +5 -12
  14. data/lib/openai/helpers/responses_websocket/connection.rb +56 -35
  15. data/lib/openai/helpers/responses_websocket/incremental.rb +5 -1
  16. data/lib/openai/helpers/responses_websocket/session.rb +11 -4
  17. data/lib/openai/helpers/responses_websocket/unknown_server_event.rb +2 -14
  18. data/lib/openai/helpers/streaming/chat_completion_resource.rb +52 -0
  19. data/lib/openai/helpers/websocket/async_websocket_transport.rb +8 -6
  20. data/lib/openai/helpers/websocket/client_request.rb +1 -1
  21. data/lib/openai/helpers/websocket/connection.rb +21 -10
  22. data/lib/openai/helpers/websocket/connection_manager.rb +2 -1
  23. data/lib/openai/helpers/websocket/protocol.rb +160 -0
  24. data/lib/openai/helpers/websocket/typed_connection.rb +22 -16
  25. data/lib/openai/helpers/websocket/typed_resource.rb +15 -0
  26. data/lib/openai/internal/transport/base_client.rb +1 -1
  27. data/lib/openai/models/admin/organization/usage_audio_speeches_response.rb +16 -5
  28. data/lib/openai/models/admin/organization/usage_audio_transcriptions_response.rb +16 -5
  29. data/lib/openai/models/admin/organization/usage_code_interpreter_sessions_response.rb +16 -5
  30. data/lib/openai/models/admin/organization/usage_completions_response.rb +16 -5
  31. data/lib/openai/models/admin/organization/usage_costs_response.rb +16 -5
  32. data/lib/openai/models/admin/organization/usage_decisions_response.rb +14 -3
  33. data/lib/openai/models/admin/organization/usage_embeddings_response.rb +16 -5
  34. data/lib/openai/models/admin/organization/usage_file_search_calls_response.rb +16 -5
  35. data/lib/openai/models/admin/organization/usage_images_response.rb +16 -5
  36. data/lib/openai/models/admin/organization/usage_moderations_response.rb +16 -5
  37. data/lib/openai/models/admin/organization/usage_vector_stores_response.rb +16 -5
  38. data/lib/openai/models/admin/organization/usage_web_search_calls_response.rb +16 -5
  39. data/lib/openai/models/audio/voice_consent.rb +58 -0
  40. data/lib/openai/models/audio/voice_consent_create_params.rb +57 -0
  41. data/lib/openai/models/audio/voice_consent_delete_params.rb +22 -0
  42. data/lib/openai/models/audio/voice_consent_deleted.rb +34 -0
  43. data/lib/openai/models/audio/voice_consent_list_params.rb +42 -0
  44. data/lib/openai/models/audio/voice_consent_retrieve_params.rb +22 -0
  45. data/lib/openai/models/audio/voice_consent_update_params.rb +32 -0
  46. data/lib/openai/models/beta/beta_response_input_item.rb +10 -1
  47. data/lib/openai/models/responses/response_input_item.rb +10 -1
  48. data/lib/openai/resources/audio/voice_consents.rb +163 -0
  49. data/lib/openai/resources/audio.rb +5 -0
  50. data/lib/openai/version.rb +1 -1
  51. data/lib/openai.rb +9 -0
  52. data/rbi/openai/client.rbi +1 -8
  53. data/rbi/openai/helpers/chat/tool_runner.rbi +74 -0
  54. data/rbi/openai/helpers/realtime/translation.rbi +0 -5
  55. data/rbi/openai/helpers/realtime/unknown_server_event.rbi +6 -0
  56. data/rbi/openai/helpers/websocket.rbi +44 -6
  57. data/rbi/openai/internal/transport/base_client.rbi +2 -6
  58. data/rbi/openai/models/admin/organization/usage_audio_speeches_response.rbi +14 -2
  59. data/rbi/openai/models/admin/organization/usage_audio_transcriptions_response.rbi +14 -2
  60. data/rbi/openai/models/admin/organization/usage_code_interpreter_sessions_response.rbi +14 -2
  61. data/rbi/openai/models/admin/organization/usage_completions_response.rbi +14 -2
  62. data/rbi/openai/models/admin/organization/usage_costs_response.rbi +14 -2
  63. data/rbi/openai/models/admin/organization/usage_decisions_response.rbi +12 -0
  64. data/rbi/openai/models/admin/organization/usage_embeddings_response.rbi +14 -2
  65. data/rbi/openai/models/admin/organization/usage_file_search_calls_response.rbi +14 -2
  66. data/rbi/openai/models/admin/organization/usage_images_response.rbi +14 -2
  67. data/rbi/openai/models/admin/organization/usage_moderations_response.rbi +14 -2
  68. data/rbi/openai/models/admin/organization/usage_vector_stores_response.rbi +14 -2
  69. data/rbi/openai/models/admin/organization/usage_web_search_calls_response.rbi +14 -2
  70. data/rbi/openai/models/audio/voice_consent.rbi +86 -0
  71. data/rbi/openai/models/audio/voice_consent_create_params.rbi +92 -0
  72. data/rbi/openai/models/audio/voice_consent_delete_params.rbi +53 -0
  73. data/rbi/openai/models/audio/voice_consent_deleted.rbi +62 -0
  74. data/rbi/openai/models/audio/voice_consent_list_params.rbi +78 -0
  75. data/rbi/openai/models/audio/voice_consent_retrieve_params.rbi +53 -0
  76. data/rbi/openai/models/audio/voice_consent_update_params.rbi +62 -0
  77. data/rbi/openai/models/beta/beta_response_input_item.rbi +14 -1
  78. data/rbi/openai/models/response_format_json_schema.rbi +13 -11
  79. data/rbi/openai/models/responses/response_input_item.rbi +10 -1
  80. data/rbi/openai/resources/audio/voice_consents.rbi +108 -0
  81. data/rbi/openai/resources/audio.rbi +4 -0
  82. data/realtime.md +22 -3
  83. data/sig/openai/client.rbs +1 -1
  84. data/sig/openai/helpers/chat/tool_runner.rbs +48 -0
  85. data/sig/openai/helpers/realtime/translation.rbs +0 -2
  86. data/sig/openai/helpers/realtime/unknown_server_event.rbs +2 -0
  87. data/sig/openai/helpers/websocket.rbs +23 -6
  88. data/sig/openai/models/admin/organization/usage_audio_speeches_response.rbs +15 -5
  89. data/sig/openai/models/admin/organization/usage_audio_transcriptions_response.rbs +15 -5
  90. data/sig/openai/models/admin/organization/usage_code_interpreter_sessions_response.rbs +15 -5
  91. data/sig/openai/models/admin/organization/usage_completions_response.rbs +15 -5
  92. data/sig/openai/models/admin/organization/usage_costs_response.rbs +15 -5
  93. data/sig/openai/models/admin/organization/usage_decisions_response.rbs +11 -1
  94. data/sig/openai/models/admin/organization/usage_embeddings_response.rbs +15 -5
  95. data/sig/openai/models/admin/organization/usage_file_search_calls_response.rbs +15 -5
  96. data/sig/openai/models/admin/organization/usage_images_response.rbs +15 -5
  97. data/sig/openai/models/admin/organization/usage_moderations_response.rbs +15 -5
  98. data/sig/openai/models/admin/organization/usage_vector_stores_response.rbs +15 -5
  99. data/sig/openai/models/admin/organization/usage_web_search_calls_response.rbs +15 -5
  100. data/sig/openai/models/audio/voice_consent.rbs +57 -0
  101. data/sig/openai/models/audio/voice_consent_create_params.rbs +61 -0
  102. data/sig/openai/models/audio/voice_consent_delete_params.rbs +25 -0
  103. data/sig/openai/models/audio/voice_consent_deleted.rbs +31 -0
  104. data/sig/openai/models/audio/voice_consent_list_params.rbs +57 -0
  105. data/sig/openai/models/audio/voice_consent_retrieve_params.rbs +25 -0
  106. data/sig/openai/models/audio/voice_consent_update_params.rbs +36 -0
  107. data/sig/openai/models/beta/beta_response_input_item.rbs +8 -0
  108. data/sig/openai/models/responses/response_input_item.rbs +8 -3
  109. data/sig/openai/resources/audio/voice_consents.rbs +38 -0
  110. data/sig/openai/resources/audio.rbs +2 -0
  111. metadata +31 -1
@@ -70,6 +70,7 @@ module OpenAI
70
70
  if @error_reader && !@error_reader.closed?
71
71
  chunk = @error_reader.read_nonblock(65_536, exception: false)
72
72
  @errors = true if chunk.is_a?(String) && !chunk.empty?
73
+ @error_reader.close if chunk.nil?
73
74
  end
74
75
 
75
76
  !!@errors
@@ -86,7 +87,9 @@ module OpenAI
86
87
  raise PlaybackError, "Audio player exited before the input ended." if @status
87
88
  end
88
89
 
89
- io.wait_readable(0.05)
90
+ readers = [io]
91
+ readers << @error_reader if @error_reader && !@error_reader.closed?
92
+ IO.select(readers, nil, nil, 0.05)
90
93
  end
91
94
  end
92
95
 
@@ -96,7 +99,8 @@ module OpenAI
96
99
  check_deadline
97
100
  result = io.write_nonblock(bytes.byteslice(offset, 65_536), exception: false)
98
101
  if result == :wait_writable
99
- io.wait_writable(0.05)
102
+ readers = @error_reader && !@error_reader.closed? ? [@error_reader] : []
103
+ IO.select(readers, [io], nil, 0.05)
100
104
  else
101
105
  offset += result
102
106
  end
@@ -110,7 +114,13 @@ module OpenAI
110
114
  until @status
111
115
  check_deadline
112
116
  reap
113
- sleep(0.01) unless @status
117
+ unless @status
118
+ if @error_reader && !@error_reader.closed?
119
+ @error_reader.wait_readable(0.01)
120
+ else
121
+ sleep(0.01)
122
+ end
123
+ end
114
124
  end
115
125
 
116
126
  @status
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../models/embedding"
4
+
5
+ module OpenAI
6
+ module Models
7
+ class Embedding
8
+ alias_method :embedding_without_base64, :embedding
9
+ private :embedding_without_base64
10
+
11
+ def embedding
12
+ value = self[:embedding]
13
+ return embedding_without_base64 unless value.is_a?(String)
14
+
15
+ # The generated setter replaces this per-field state on assignment.
16
+ cached = @coerced[:embedding]
17
+ return cached if cached.is_a?(Array)
18
+
19
+ # Base64 embedding values contain little-endian float32 samples.
20
+ decoded = begin
21
+ value.unpack1("m0")
22
+ rescue ArgumentError
23
+ nil
24
+ end
25
+
26
+ return embedding_without_base64 unless decoded && (decoded.bytesize % 4).zero?
27
+
28
+ @coerced[:embedding] = decoded.unpack("e*")
29
+ end
30
+
31
+ # Equal embeddings still have equal raw data, so the inherited hash remains valid.
32
+ def ==(other)
33
+ return false unless super
34
+ return true unless self[:embedding].is_a?(String)
35
+
36
+ embedding == other.embedding
37
+ rescue OpenAI::Errors::ConversionError
38
+ true
39
+ end
40
+ end
41
+ end
42
+ end
@@ -33,7 +33,7 @@ module OpenAI
33
33
  # Parse raw JSON as a typed server event. Valid events that are newer than this
34
34
  # SDK remain observable as {UnknownServerEvent} values.
35
35
  def parse_event(data)
36
- parsed = JSON.parse(data, symbolize_names: true)
36
+ parsed = parse_json(data)
37
37
  type = event_type(parsed)
38
38
  unless @server_event_names.key?(type.to_s)
39
39
  return OpenAI::Realtime::UnknownServerEvent.new(data: parsed)
@@ -46,14 +46,16 @@ module OpenAI
46
46
  state: state
47
47
  )
48
48
  if (cause = coercion_error(state))
49
- raise OpenAI::Errors::RealtimeProtocolError.new(data: data, cause: cause)
49
+ raise OpenAI::Errors::RealtimeProtocolError.new(data: data, cause: cause), cause: nil
50
50
  end
51
51
 
52
- event
52
+ validate_wire_field_names!(event, parsed)
53
53
  rescue OpenAI::Errors::RealtimeProtocolError
54
54
  raise
55
+ rescue SystemStackError
56
+ raise OpenAI::Errors::RealtimeProtocolError.new(data: data), cause: nil
55
57
  rescue StandardError => e
56
- raise OpenAI::Errors::RealtimeProtocolError.new(data: data, cause: e)
58
+ raise OpenAI::Errors::RealtimeProtocolError.new(data: data, cause: e), cause: nil
57
59
  end
58
60
 
59
61
  # Validate, encode, and send a typed client event.
@@ -61,6 +63,15 @@ module OpenAI
61
63
  send_raw(encode_client_event(event))
62
64
  end
63
65
 
66
+ private def write_text(text)
67
+ return super unless @recovery
68
+
69
+ # Recovery owns admission and uncertain sends; rejecting a full queue
70
+ # has not attempted a physical write and must leave this connection usable.
71
+ @recovery.write(text)
72
+ nil
73
+ end
74
+
64
75
  # Whether automatic recovery is opening or preparing a replacement socket.
65
76
  def reconnecting? = @recovery ? @recovery.reconnecting? : false
66
77
 
@@ -83,6 +94,10 @@ module OpenAI
83
94
  validate_discriminator!(event, @client_event_names, kind: "client")
84
95
  end
85
96
 
97
+ # Preserve permissive raw JSON keys while checking original typed models
98
+ # before union conversion can replace their identities or renamed fields.
99
+ validate_event_tree!(event, strict_keys: false)
100
+
86
101
  normalized = OpenAI::Internal::Type::Converter.dump(
87
102
  OpenAI::Realtime::RealtimeClientEvent,
88
103
  event
@@ -99,7 +114,9 @@ module OpenAI
99
114
 
100
115
  payload = OpenAI::Internal::Type::Converter.dump(OpenAI::Realtime::RealtimeClientEvent, coerced)
101
116
  validate_discriminator!(payload, @client_event_names, kind: "client")
102
- JSON.generate(payload)
117
+ generate_json(payload)
118
+ rescue SystemStackError
119
+ raise ArgumentError, "Invalid Realtime client event.", cause: nil
103
120
  rescue StandardError => e
104
121
  raise ArgumentError.new("Invalid Realtime client event."), cause: e
105
122
  end
@@ -76,7 +76,7 @@ module OpenAI
76
76
 
77
77
  def take_pending_messages
78
78
  @mutex.synchronize do
79
- raise state_error("Cannot remove queued events while flushing.") if @flushing
79
+ raise state_error("Cannot remove queued events while flushing."), cause: nil if @flushing
80
80
 
81
81
  messages = @pending
82
82
  @pending = []
@@ -106,7 +106,7 @@ module OpenAI
106
106
 
107
107
  if @state != :open || @flushing
108
108
  if @max_queue_bytes.zero? || @pending_bytes + [text.bytesize, 1].max > @max_queue_bytes
109
- raise OpenAI::Errors::RealtimeQueueFullError
109
+ raise OpenAI::Errors::RealtimeQueueFullError, cause: nil
110
110
  end
111
111
 
112
112
  @pending << text.dup.freeze
@@ -124,13 +124,15 @@ module OpenAI
124
124
 
125
125
  def flush_pending
126
126
  if Fiber.current.equal?(@owner_fiber)
127
- raise state_error("Queue flush interrupted; remaining events were not sent.") unless flush
127
+ raise state_error("Queue flush interrupted; remaining events were not sent."), cause: nil unless flush
128
128
  return nil
129
129
  end
130
130
 
131
131
  reply = @mutex.synchronize do
132
132
  raise @error, cause: nil if @error
133
- raise state_error("Cannot flush until the replacement connection is ready.") unless @state == :open
133
+ unless @state == :open
134
+ raise state_error("Cannot flush until the replacement connection is ready."), cause: nil
135
+ end
134
136
 
135
137
  register(:flush).tap { @commands << [:flush, nil, _1, @generation] }
136
138
  end
@@ -304,8 +306,14 @@ module OpenAI
304
306
  # Shutdown must not mask the operation's result or prevent cleanup.
305
307
  nil
306
308
  ensure
307
- physical.abort
308
- @physical = nil
309
+ pending_error = $ERROR_INFO
310
+ begin
311
+ physical.abort
312
+ rescue StandardError
313
+ raise if pending_error.nil?
314
+ ensure
315
+ @physical = nil
316
+ end
309
317
  end
310
318
  end
311
319
  end
@@ -348,7 +356,11 @@ module OpenAI
348
356
  uncertain_message: @inflight
349
357
  )
350
358
  elsif @state != :closed
351
- @state = :recovering
359
+ if recoverable?(e) && @attempts < @max_attempts
360
+ @state = :recovering
361
+ else
362
+ fail_locked("Realtime reconnect stopped; the connection could not be recovered.")
363
+ end
352
364
  end
353
365
  end
354
366
 
@@ -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)
@@ -11,6 +11,8 @@ module OpenAI
11
11
  # are retained exactly as received, never filled from provisional output.
12
12
  # Call reset to discard all retained state.
13
13
  class IncrementalResponse
14
+ include OpenAI::WebSocket::Protocol
15
+
14
16
  attr_reader :phase
15
17
 
16
18
  def initialize
@@ -236,10 +238,12 @@ module OpenAI
236
238
  # references. Returned snapshots and incoming events must not share strings.
237
239
  def copy(value)
238
240
  data = OpenAI::Internal::Type::BaseModel.recursively_to_h(value, convert: false)
239
- data = JSON.parse(JSON.generate(data), symbolize_names: true)
241
+ data = parse_json(generate_json(data))
240
242
  return data unless value.is_a?(OpenAI::Internal::Type::BaseModel)
241
243
 
242
244
  OpenAI::Internal::Type::Converter.coerce(value.class, data)
245
+ rescue SystemStackError
246
+ raise SessionError, "Could not copy the Responses WebSocket event.", cause: nil
243
247
  end
244
248
  end
245
249
  end