openai 0.90.0 → 0.91.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +1 -1
  4. data/examples/realtime/README.md +8 -0
  5. data/examples/realtime/browser/README.md +167 -0
  6. data/examples/realtime/browser/index.html +19 -0
  7. data/examples/realtime/browser/page.js +21 -0
  8. data/examples/realtime/browser/peer.js +180 -0
  9. data/examples/realtime/browser/server.rb +281 -0
  10. data/examples/responses/websocket_workflows.rb +66 -6
  11. data/lib/openai/auth/workload_identity.rb +5 -1
  12. data/lib/openai/auth/workload_identity_auth.rb +2 -2
  13. data/lib/openai/auth/x509_token_exchange.rb +1 -1
  14. data/lib/openai/helpers/local_audio/process.rb +1 -1
  15. data/lib/openai/helpers/responses_websocket/connection.rb +2 -1
  16. data/lib/openai/helpers/responses_websocket/session.rb +551 -0
  17. data/lib/openai/helpers/responses_websocket.rb +1 -0
  18. data/lib/openai/helpers/sorbet.rb +1 -1
  19. data/lib/openai/helpers/websocket/async_websocket_transport.rb +6 -1
  20. data/lib/openai/internal/poller.rb +2 -2
  21. data/lib/openai/internal/transport/base_client.rb +1 -1
  22. data/lib/openai/internal/util.rb +1 -1
  23. data/lib/openai/models/beta/agents/session_update_params.rb +108 -1
  24. data/lib/openai/models/beta/beta_response.rb +9 -3
  25. data/lib/openai/models/beta/beta_response_compaction_compacting_event.rb +75 -0
  26. data/lib/openai/models/beta/beta_response_stream_event.rb +4 -1
  27. data/lib/openai/models/beta/beta_responses_client_event.rb +25 -4
  28. data/lib/openai/models/beta/beta_responses_server_event.rb +39 -1
  29. data/lib/openai/models/beta/response_compact_params.rb +9 -3
  30. data/lib/openai/models/beta/response_create_params.rb +21 -4
  31. data/lib/openai/models/beta/session_turn_error.rb +3 -0
  32. data/lib/openai/models/chat_model.rb +3 -1
  33. data/lib/openai/models/responses/response_compact_params.rb +9 -3
  34. data/lib/openai/models/responses/response_compaction_compacting_event.rb +49 -0
  35. data/lib/openai/models/responses/response_create_params.rb +12 -1
  36. data/lib/openai/models/responses/response_stream_event.rb +4 -1
  37. data/lib/openai/models/responses/responses_client_event.rb +12 -1
  38. data/lib/openai/models/responses/responses_server_event.rb +36 -1
  39. data/lib/openai/models/webhooks/realtime_call_incoming_webhook_event.rb +6 -8
  40. data/lib/openai/resources/beta/agents/sessions.rb +6 -2
  41. data/lib/openai/resources/beta/responses.rb +2 -2
  42. data/lib/openai/resources/responses.rb +2 -2
  43. data/lib/openai/version.rb +1 -1
  44. data/lib/openai.rb +2 -0
  45. data/rbi/openai/helpers/responses_websocket/connection.rbi +5 -0
  46. data/rbi/openai/helpers/responses_websocket/session.rbi +206 -0
  47. data/rbi/openai/models/beta/agents/session_update_params.rbi +178 -0
  48. data/rbi/openai/models/beta/beta_response.rbi +6 -1
  49. data/rbi/openai/models/beta/beta_response_compaction_compacting_event.rbi +136 -0
  50. data/rbi/openai/models/beta/beta_response_stream_event.rbi +1 -0
  51. data/rbi/openai/models/beta/beta_responses_client_event.rbi +27 -4
  52. data/rbi/openai/models/beta/beta_responses_server_event.rbi +77 -0
  53. data/rbi/openai/models/beta/response_compact_params.rbi +6 -1
  54. data/rbi/openai/models/beta/response_create_params.rbi +21 -1
  55. data/rbi/openai/models/beta/session_turn_error.rbi +6 -0
  56. data/rbi/openai/models/chat_model.rbi +3 -1
  57. data/rbi/openai/models/responses/response_compact_params.rbi +6 -1
  58. data/rbi/openai/models/responses/response_compaction_compacting_event.rbi +78 -0
  59. data/rbi/openai/models/responses/response_create_params.rbi +15 -0
  60. data/rbi/openai/models/responses/response_stream_event.rbi +1 -0
  61. data/rbi/openai/models/responses/responses_client_event.rbi +15 -0
  62. data/rbi/openai/models/responses/responses_server_event.rbi +71 -0
  63. data/rbi/openai/models/webhooks/realtime_call_incoming_webhook_event.rbi +6 -8
  64. data/rbi/openai/resources/beta/agents/sessions.rbi +5 -1
  65. data/responses-websocket.md +117 -10
  66. data/sig/openai/helpers/responses_websocket/connection.rbs +5 -0
  67. data/sig/openai/helpers/responses_websocket/session.rbs +123 -0
  68. data/sig/openai/models/beta/agents/session_update_params.rbs +99 -0
  69. data/sig/openai/models/beta/beta_response.rbs +6 -2
  70. data/sig/openai/models/beta/beta_response_compaction_compacting_event.rbs +58 -0
  71. data/sig/openai/models/beta/beta_response_stream_event.rbs +1 -0
  72. data/sig/openai/models/beta/beta_responses_client_event.rbs +13 -2
  73. data/sig/openai/models/beta/beta_responses_server_event.rbs +35 -0
  74. data/sig/openai/models/beta/response_compact_params.rbs +6 -2
  75. data/sig/openai/models/beta/response_create_params.rbs +13 -2
  76. data/sig/openai/models/beta/session_turn_error.rbs +4 -0
  77. data/sig/openai/models/chat_model.rbs +6 -2
  78. data/sig/openai/models/responses/response_compact_params.rbs +6 -2
  79. data/sig/openai/models/responses/response_compaction_compacting_event.rbs +37 -0
  80. data/sig/openai/models/responses/response_create_params.rbs +7 -0
  81. data/sig/openai/models/responses/response_stream_event.rbs +1 -0
  82. data/sig/openai/models/responses/responses_client_event.rbs +7 -0
  83. data/sig/openai/models/responses/responses_server_event.rbs +32 -0
  84. data/sig/openai/resources/beta/agents/sessions.rbs +1 -0
  85. metadata +16 -2
@@ -0,0 +1,551 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAI
4
+ module Responses
5
+ class SessionError < OpenAI::Errors::Error
6
+ def cause = nil
7
+ end
8
+
9
+ class BufferError < SessionError
10
+ end
11
+
12
+ class StateLostError < SessionError
13
+ end
14
+
15
+ class RequestError < SessionError
16
+ attr_reader :event
17
+
18
+ # @api private
19
+ def initialize(event)
20
+ @event = event
21
+ super("The Responses WebSocket request returned an error event.")
22
+ end
23
+ end
24
+
25
+ # Explicit application budgets for an opt-in session, not service limits.
26
+ # Bytes count serialized event data, not Ruby object or transport overhead.
27
+ # max_lanes counts all registered IDs, including detached IDs, until reconnect.
28
+ class SessionLimits
29
+ attr_reader(
30
+ :max_lanes,
31
+ :max_events_per_lane,
32
+ :max_events,
33
+ :max_bytes_per_lane,
34
+ :max_bytes,
35
+ :max_response_bytes
36
+ )
37
+
38
+ def initialize(
39
+ max_lanes:,
40
+ max_events_per_lane:,
41
+ max_events:,
42
+ max_bytes_per_lane:,
43
+ max_bytes:,
44
+ max_response_bytes:
45
+ )
46
+ values = [max_lanes, max_events_per_lane, max_events, max_bytes_per_lane, max_bytes, max_response_bytes]
47
+ unless values.all? { |value| value.is_a?(Integer) && value.positive? }
48
+ raise ArgumentError, "Responses session limits must be positive integers."
49
+ end
50
+
51
+ @max_lanes, @max_events_per_lane, @max_events, @max_bytes_per_lane, @max_bytes, @max_response_bytes = values
52
+ freeze
53
+ end
54
+ end
55
+
56
+ # @api private
57
+ class SessionAccumulator
58
+ attr_reader :final_response, :error
59
+
60
+ def initialize(limit)
61
+ @limit = limit
62
+ @bytes = 0
63
+ @items = {}
64
+ @state = OpenAI::Helpers::Streaming::ResponseStreamState.new(text_format: nil, starting_after: 0)
65
+ @decoder = OpenAI::Helpers::Streaming::ResponseEventDecoder.new(model: OpenAI::Responses::ResponseStreamEvent)
66
+ end
67
+
68
+ def add(event, bytes)
69
+ type = event.type.to_s
70
+ if type == "response.created"
71
+ @bytes = 0
72
+ @items.clear
73
+ @final_response = nil
74
+ @error = nil
75
+ @state = OpenAI::Helpers::Streaming::ResponseStreamState.new(text_format: nil, starting_after: 0)
76
+ end
77
+
78
+ if type == "error"
79
+ @error = RequestError.new(event)
80
+ clear
81
+ return
82
+ end
83
+
84
+ return if @error
85
+ unless %w[response.created response.output_item.done response.completed response.failed response.incomplete]
86
+ .include?(type)
87
+ return
88
+ end
89
+
90
+ @bytes += bytes
91
+ raise BufferError, "Response collection exceeded max_response_bytes." if @bytes > @limit
92
+
93
+ if type == "response.output_item.done"
94
+ # Retain wire data independently of the mutable event returned by receive.
95
+ completed_item = OpenAI::Internal::Type::BaseModel.recursively_to_h(event.item, convert: false)
96
+ @items[event.output_index] = JSON.parse(JSON.generate(completed_item), symbolize_names: true)
97
+ elsif %w[response.completed response.failed response.incomplete].include?(type)
98
+ data = OpenAI::Internal::Type::BaseModel.recursively_to_h(event, convert: false)
99
+ data = JSON.parse(JSON.generate(data), symbolize_names: true)
100
+ response = data.fetch(:response)
101
+ if response[:output].nil?
102
+ response = response.merge(output: @items.sort.map { |_index, item| item.to_h })
103
+ data = data.merge(response: response)
104
+ end
105
+
106
+ decoded = @decoder.decode(data)
107
+ if type == "response.completed"
108
+ @state.handle_event(decoded)
109
+ @final_response = @state.completed_response
110
+ else
111
+ @final_response = decoded.response
112
+ end
113
+
114
+ clear
115
+ else
116
+ data = OpenAI::Internal::Type::BaseModel.recursively_to_h(event, convert: false)
117
+ @state.handle_event(@decoder.decode(data))
118
+ end
119
+
120
+ rescue StandardError => error
121
+ @error = error.is_a?(SessionError) ? error : SessionError.new("Could not collect the Responses final response.")
122
+ clear
123
+ end
124
+
125
+ private def clear
126
+ @state = nil
127
+ @items.clear
128
+ @bytes = 0
129
+ end
130
+ end
131
+
132
+ # One routed consumer. Canceling its wait never cancels the socket reader.
133
+ class SessionLane
134
+ attr_reader :stream_id
135
+
136
+ # @api private
137
+ def initialize(session, stream_id, pending_steers)
138
+ @session = session
139
+ @stream_id = stream_id
140
+ @queue = []
141
+ @bytes = 0
142
+ @closed = false
143
+ @receiving = false
144
+ @in_flight = false
145
+ @pending_steers = pending_steers
146
+ @response_id = nil
147
+ @changed = Async::Condition.new
148
+ @accumulator = SessionAccumulator.new(session.limits.max_response_bytes)
149
+ end
150
+
151
+ def send_event(event)
152
+ @session.assert_owner!
153
+ raise @detached_error if @detached_error
154
+ raise SessionError, "The Responses lane is detached." if @closed
155
+
156
+ data = @session.encode_client_event(event)
157
+ type = data[:type]
158
+ if type.to_s == "response.create"
159
+ key = data.key?("stream_id") ? "stream_id" : :stream_id
160
+ if data.key?(key) && data[key] != @stream_id
161
+ raise ArgumentError, "The command stream_id does not match its lane."
162
+ end
163
+
164
+ data[key] = @stream_id unless @stream_id.nil?
165
+ queued_response = @queue.any? do |queued_event, _bytes|
166
+ queued_event.type.to_s == "response.created" && queued_event.to_h[:stream_id] == @stream_id
167
+ end
168
+
169
+ pending = @pending_steers[@response_id]
170
+ if @in_flight || queued_response || (pending && pending[:count].positive?)
171
+ raise SessionError, "Consume the current response before sending another create on this lane."
172
+ end
173
+
174
+ @accumulator = SessionAccumulator.new(@session.limits.max_response_bytes)
175
+ @in_flight = true
176
+ elsif type.to_s == "response.steer" &&
177
+ (steer_parent = data[:previous_response_id]).is_a?(String) &&
178
+ !steer_parent.empty?
179
+ pending = (@pending_steers[steer_parent] ||= {count: 0, outcomes: {}})
180
+ pending[:count] += 1
181
+ end
182
+
183
+ @session.send_event(data)
184
+ rescue OpenAI::Errors::ResponsesClientEventError
185
+ @in_flight = false if type.to_s == "response.create"
186
+ if type.to_s == "response.steer" && pending
187
+ pending[:count] -= 1
188
+ @pending_steers.delete(steer_parent) if pending[:count].zero? && pending[:outcomes].empty?
189
+ end
190
+
191
+ raise
192
+ end
193
+
194
+ def receive
195
+ @session.assert_owner!
196
+ raise SessionError, "This Responses lane already has a consumer." if @receiving
197
+
198
+ @receiving = true
199
+ begin
200
+ loop do
201
+ raise @detached_error if @detached_error
202
+ raise SessionError, "The Responses lane is detached." if @closed
203
+
204
+ unless @queue.empty?
205
+ event, bytes, raw_command_sent = @queue.shift
206
+ @bytes -= bytes
207
+ @session.consumed(bytes)
208
+ if %w[response.steer.pending response.steer.failed].include?(event.type.to_s)
209
+ parent = event.steer.previous_response_id
210
+ pending = @pending_steers[parent]
211
+ id = event.steer.id
212
+ if pending
213
+ previously_pending = pending[:outcomes].delete(id)
214
+ if event.type.to_s == "response.steer.pending" && !id.nil?
215
+ pending[:outcomes][id] = true
216
+ end
217
+
218
+ unless previously_pending
219
+ pending[:count] -= 1
220
+ end
221
+
222
+ @pending_steers.delete(parent) if pending[:count].zero? && pending[:outcomes].empty?
223
+ end
224
+ end
225
+ # Unregistered named streams remain available for raw inspection,
226
+ # without changing the default lane's response state.
227
+ if event.to_h[:stream_id] == @stream_id
228
+ # Prior raw commands leave generic errors uncorrelated. Use the
229
+ # receipt-time snapshot, so later commands cannot change attribution.
230
+ if event.type.to_s == "error" &&
231
+ @stream_id.nil? &&
232
+ @in_flight &&
233
+ event.to_h[:sequence_number].nil? &&
234
+ raw_command_sent
235
+ discard(RequestError.new(event))
236
+ @session.detach(self)
237
+ return event
238
+ end
239
+
240
+ @accumulator.add(event, bytes)
241
+ if event.type.to_s == "response.created"
242
+ @in_flight = true
243
+ @pending_steers.delete(@response_id) if @response_id != event.response.id
244
+ @response_id = event.response.id.dup.freeze
245
+ elsif %w[response.completed response.failed response.incomplete error].include?(event.type.to_s)
246
+ @in_flight = false
247
+ @response_id = event.response.id.dup.freeze unless event.type.to_s == "error"
248
+ end
249
+ end
250
+
251
+ return event
252
+ end
253
+
254
+ @session.check_reader!
255
+ @changed.wait
256
+ end
257
+
258
+ ensure
259
+ @receiving = false
260
+ end
261
+ end
262
+
263
+ def get_final_response
264
+ @session.assert_owner!
265
+ loop do
266
+ raise @accumulator.error if @accumulator.error
267
+ if (response = @accumulator.final_response)
268
+ @accumulator = SessionAccumulator.new(@session.limits.max_response_bytes)
269
+ return response
270
+ end
271
+
272
+ receive
273
+ end
274
+ end
275
+
276
+ def close
277
+ @session.assert_owner!
278
+ @session.detach(self)
279
+ nil
280
+ end
281
+
282
+ # @api private
283
+ def enqueue(event, bytes, raw_command_sent = false)
284
+ return if @closed
285
+
286
+ limits = @session.limits
287
+ if @queue.length >= limits.max_events_per_lane || @bytes + bytes > limits.max_bytes_per_lane
288
+ raise BufferError, "The Responses lane exceeded its buffered event budget."
289
+ end
290
+
291
+ @queue << [event, bytes, raw_command_sent]
292
+ @bytes += bytes
293
+ @changed.signal
294
+ true
295
+ end
296
+
297
+ # @api private
298
+ def discard(error = nil)
299
+ return if @closed
300
+
301
+ @closed = true
302
+ @detached_error = error
303
+ @queue.each { |_event, bytes| @session.consumed(bytes) }
304
+ @queue.clear
305
+ @bytes = 0
306
+ @accumulator = SessionAccumulator.new(@session.limits.max_response_bytes)
307
+ @changed.signal
308
+ end
309
+
310
+ # @api private
311
+ def wake = @changed.signal
312
+ end
313
+
314
+ # Owns one connection and one Async reader. Use only its lanes to receive.
315
+ class Session
316
+ attr_reader :limits, :default
317
+
318
+ def self.open(
319
+ client:,
320
+ limits:,
321
+ request_options: nil,
322
+ websocket_base_url: nil,
323
+ transport: nil,
324
+ transport_options: {}
325
+ )
326
+ raise ArgumentError, "A block is required to open a Responses session." unless block_given?
327
+
328
+ begin
329
+ require "async"
330
+ require "async/queue"
331
+ require "async/condition"
332
+
333
+ rescue LoadError
334
+ raise OpenAI::Errors::Error, "Responses sessions require the async gem.", cause: nil
335
+ end
336
+
337
+ Sync do |task|
338
+ session = new(
339
+ task: task,
340
+ client: client,
341
+ limits: limits,
342
+ request_options: request_options,
343
+ websocket_base_url: websocket_base_url,
344
+ transport: transport,
345
+ transport_options: transport_options
346
+ )
347
+ begin
348
+ session.start
349
+ yield(session)
350
+ ensure
351
+ pending_error = $ERROR_INFO
352
+ begin
353
+ session.close
354
+ rescue StandardError
355
+ raise if pending_error.nil?
356
+ end
357
+ end
358
+ end
359
+ end
360
+
361
+ # @api private
362
+ def initialize(task:, client:, limits:, request_options:, websocket_base_url:, transport:, transport_options:)
363
+ @task = task
364
+ @client = client
365
+ @limits = limits
366
+ @request_options = request_options
367
+ @websocket_base_url = websocket_base_url
368
+ @transport = transport
369
+ @transport_options = transport_options
370
+ @owner = Thread.current
371
+ @lanes = {}
372
+ @lane_ids = {}
373
+ # Steering targets a response ID, independently of the lane sending the command.
374
+ @pending_steers = {}
375
+ @events = 0
376
+ @bytes = 0
377
+ @closed = false
378
+ @default = lane(nil)
379
+ end
380
+
381
+ def lane(stream_id)
382
+ assert_owner!
383
+ raise SessionError, "The Responses session is closed." if @closed
384
+
385
+ unless stream_id.nil? || (stream_id.is_a?(String) && /\A[A-Za-z0-9_.-]{1,256}\z/.match?(stream_id))
386
+ raise ArgumentError, "Invalid Responses WebSocket stream ID."
387
+ end
388
+
389
+ stream_id = stream_id&.dup&.freeze
390
+ raise ArgumentError, "The Responses lane is already registered." if @lane_ids.key?(stream_id)
391
+ raise BufferError, "The Responses session exceeded max_lanes." if @lane_ids.length >= @limits.max_lanes
392
+
393
+ value = SessionLane.new(self, stream_id, @pending_steers)
394
+ @lane_ids[stream_id] = true
395
+ @lanes[stream_id] = value
396
+ end
397
+
398
+ # Replace the transport explicitly; old lane state is lost, never replayed.
399
+ # The restore callback registers new lanes and supplies application state.
400
+ def reconnect(restore:, client: @client, request_options: @request_options)
401
+ assert_owner!
402
+ raise SessionError, "The Responses session is closed." if @closed
403
+ raise SessionError, "The Responses session is already reconnecting." if @reconnecting
404
+
405
+ @reconnecting = true
406
+ restored = false
407
+ begin
408
+ @lanes.each_value { |value|
409
+ value.discard(
410
+ StateLostError.new("The Responses connection was replaced; restore application state explicitly.")
411
+ )
412
+ }
413
+ stop_reader
414
+ @lanes.clear
415
+ @lane_ids.clear
416
+ @pending_steers.clear
417
+ @default = lane(nil)
418
+ @client = client
419
+ @request_options = request_options
420
+ start
421
+ restore.call(self)
422
+ restored = true
423
+ self
424
+ ensure
425
+ close unless restored
426
+ @reconnecting = false
427
+ end
428
+ end
429
+
430
+ def close
431
+ assert_owner!
432
+ return if @closed
433
+
434
+ @closed = true
435
+ stop_reader
436
+ @lanes.each_value(&:discard)
437
+ @lanes.clear
438
+ @lane_ids.clear
439
+ @pending_steers.clear
440
+ nil
441
+ end
442
+
443
+ # @api private
444
+ def start
445
+ @ended = false
446
+ @error = nil
447
+ @raw_command_sent = false
448
+ generation = @generation = Object.new
449
+ ready = Async::Queue.new
450
+ opened = false
451
+ @reader = @task.async do
452
+ begin
453
+ @client
454
+ .responses
455
+ .connect(
456
+ request_options: @request_options,
457
+ websocket_base_url: @websocket_base_url,
458
+ transport: @transport,
459
+ transport_options: @transport_options
460
+ ) do |connection|
461
+ @connection = connection
462
+ opened = true
463
+ ready.enqueue(true)
464
+ connection.each { |event| dispatch(event) }
465
+ end
466
+
467
+ rescue StandardError => error
468
+ @error = error if @generation.equal?(generation)
469
+ ensure
470
+ @ended = true if @generation.equal?(generation)
471
+ ready.enqueue(false) unless opened
472
+ @lanes.each_value(&:wake)
473
+ end
474
+ end
475
+
476
+ raise @error || SessionError.new("The Responses connection did not open.") unless ready.dequeue
477
+ nil
478
+ end
479
+
480
+ # @api private
481
+ def encode_client_event(event)
482
+ assert_owner!
483
+ @connection.encode_client_event(event)
484
+ end
485
+
486
+ # @api private
487
+ def send_event(event)
488
+ assert_owner!
489
+ check_reader!
490
+ prior_raw_command_sent = @raw_command_sent
491
+ @raw_command_sent = true unless event[:type].to_s == "response.create"
492
+ @connection.send_event(event)
493
+ rescue OpenAI::Errors::ResponsesClientEventError
494
+ @raw_command_sent = prior_raw_command_sent
495
+ raise
496
+ rescue OpenAI::Errors::ResponsesSendError => error
497
+ @error = error
498
+ stop_reader
499
+ raise
500
+ end
501
+
502
+ # @api private
503
+ def check_reader!
504
+ raise @error if @error
505
+ raise SessionError, "The Responses connection closed before another event arrived." if @closed || @ended
506
+ end
507
+
508
+ # @api private
509
+ def assert_owner!
510
+ raise SessionError, "Responses sessions are single-thread owned." unless Thread.current.equal?(@owner)
511
+ end
512
+
513
+ # @api private
514
+ def consumed(bytes)
515
+ @events -= 1
516
+ @bytes -= bytes
517
+ end
518
+
519
+ # @api private
520
+ def detach(value)
521
+ @lanes.delete(value.stream_id) if @lanes[value.stream_id].equal?(value)
522
+ value.discard
523
+ end
524
+
525
+ private def dispatch(event)
526
+ data = event.to_h
527
+ value = @lanes.fetch(data[:stream_id], @lanes[nil])
528
+ return unless value
529
+
530
+ bytes = JSON.generate(data).bytesize
531
+ if @events >= @limits.max_events || @bytes + bytes > @limits.max_bytes
532
+ raise BufferError, "The Responses session exceeded its buffered event budget."
533
+ end
534
+
535
+ @events += 1
536
+ @bytes += bytes
537
+ begin
538
+ consumed(bytes) unless value.enqueue(event, bytes, @raw_command_sent)
539
+ rescue StandardError
540
+ consumed(bytes)
541
+ raise
542
+ end
543
+ end
544
+
545
+ private def stop_reader
546
+ @reader&.stop
547
+ @reader&.wait
548
+ end
549
+ end
550
+ end
551
+ end
@@ -10,3 +10,4 @@ require_relative "responses_websocket/connection_manager"
10
10
  require_relative "responses_websocket/transports/async_websocket"
11
11
  require_relative "responses_websocket/client_extension"
12
12
  require_relative "responses_websocket/resources/responses_extension"
13
+ require_relative "responses_websocket/session"
@@ -202,7 +202,7 @@ module OpenAI
202
202
 
203
203
  value
204
204
  in :scalar
205
- value = value.to_f if node.value == Float && value.is_a?(Integer)
205
+ value = Float(value) if node.value == Float && value.is_a?(Integer)
206
206
 
207
207
  unless value.is_a?(node.value) && (node.value != Float || value.finite?)
208
208
  fail_hydration(path, expected: node.value.name, actual: value)
@@ -343,7 +343,12 @@ module OpenAI
343
343
 
344
344
  private def close_resources(connection, client, proxy_client, connection_aborted:, pending_error:)
345
345
  cleanup_error = nil
346
- resources = [client, proxy_client]
346
+ resources = [client]
347
+ # These tunnels belong to this connection. A peer may keep its read side
348
+ # open after a failed TLS handshake, so retire them before draining the
349
+ # proxy pool instead of waiting indefinitely for a remote EOF.
350
+ resources.concat(proxy_client.pool.resources.keys) if proxy_client
351
+ resources << proxy_client
347
352
  resources.unshift(connection) unless connection_aborted
348
353
  resources.compact.each do |resource|
349
354
  resource.close
@@ -85,7 +85,7 @@ module OpenAI
85
85
  bounded = {**options, extra_headers: headers}
86
86
  unless remaining.nil?
87
87
  request_timeout = options[:timeout]
88
- request_timeout = request_timeout.to_f.clamp(0..) unless request_timeout.nil?
88
+ request_timeout = Float(request_timeout).clamp(0..) unless request_timeout.nil?
89
89
  bounded[:timeout] = [request_timeout, remaining].compact.min
90
90
 
91
91
  # The transport applies `timeout` to every retry attempt independently.
@@ -147,7 +147,7 @@ module OpenAI
147
147
  return invalid_duration!(name: name, allow_zero: allow_zero)
148
148
  end
149
149
 
150
- duration = value.to_f
150
+ duration = Float(value)
151
151
  valid = duration.finite? && (allow_zero ? duration >= 0 : duration.positive?)
152
152
  return duration if valid
153
153
 
@@ -466,7 +466,7 @@ module OpenAI
466
466
  end
467
467
 
468
468
  timeout = opts.fetch(:timeout, @timeout)
469
- timeout = timeout.to_f.clamp(0..) unless timeout.nil?
469
+ timeout = Float(timeout).clamp(0..) unless timeout.nil?
470
470
  unless headers.key?("x-stainless-timeout") || timeout.nil? || timeout.zero?
471
471
  headers["x-stainless-timeout"] = timeout.to_s
472
472
  end
@@ -891,7 +891,7 @@ module OpenAI
891
891
  encoding = row.encoding if !row.empty? && (encoding.nil? || encoding == Encoding::US_ASCII)
892
892
  offset = buffer.bytesize
893
893
  buffer << (row.encoding == Encoding::BINARY ? row : row.b)
894
- while (match = re.match(buffer, cr_seen&.to_i || offset))
894
+ while (match = re.match(buffer, cr_seen || offset))
895
895
  case [match.captures.first, cr_seen]
896
896
  in ["\r", nil]
897
897
  cr_seen = match.end(1)