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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +1 -1
- data/examples/realtime/README.md +8 -0
- data/examples/realtime/browser/README.md +167 -0
- data/examples/realtime/browser/index.html +19 -0
- data/examples/realtime/browser/page.js +21 -0
- data/examples/realtime/browser/peer.js +180 -0
- data/examples/realtime/browser/server.rb +281 -0
- data/examples/responses/websocket_workflows.rb +66 -6
- data/lib/openai/auth/workload_identity.rb +5 -1
- data/lib/openai/auth/workload_identity_auth.rb +2 -2
- data/lib/openai/auth/x509_token_exchange.rb +1 -1
- data/lib/openai/helpers/local_audio/process.rb +1 -1
- data/lib/openai/helpers/responses_websocket/connection.rb +2 -1
- data/lib/openai/helpers/responses_websocket/session.rb +551 -0
- data/lib/openai/helpers/responses_websocket.rb +1 -0
- data/lib/openai/helpers/sorbet.rb +1 -1
- data/lib/openai/helpers/websocket/async_websocket_transport.rb +6 -1
- data/lib/openai/internal/poller.rb +2 -2
- data/lib/openai/internal/transport/base_client.rb +1 -1
- data/lib/openai/internal/util.rb +1 -1
- data/lib/openai/models/beta/agents/session_update_params.rb +108 -1
- data/lib/openai/models/beta/beta_response.rb +9 -3
- data/lib/openai/models/beta/beta_response_compaction_compacting_event.rb +75 -0
- data/lib/openai/models/beta/beta_response_stream_event.rb +4 -1
- data/lib/openai/models/beta/beta_responses_client_event.rb +25 -4
- data/lib/openai/models/beta/beta_responses_server_event.rb +39 -1
- data/lib/openai/models/beta/response_compact_params.rb +9 -3
- data/lib/openai/models/beta/response_create_params.rb +21 -4
- data/lib/openai/models/beta/session_turn_error.rb +3 -0
- data/lib/openai/models/chat_model.rb +3 -1
- data/lib/openai/models/responses/response_compact_params.rb +9 -3
- data/lib/openai/models/responses/response_compaction_compacting_event.rb +49 -0
- data/lib/openai/models/responses/response_create_params.rb +12 -1
- data/lib/openai/models/responses/response_stream_event.rb +4 -1
- data/lib/openai/models/responses/responses_client_event.rb +12 -1
- data/lib/openai/models/responses/responses_server_event.rb +36 -1
- data/lib/openai/models/webhooks/realtime_call_incoming_webhook_event.rb +6 -8
- data/lib/openai/resources/beta/agents/sessions.rb +6 -2
- data/lib/openai/resources/beta/responses.rb +2 -2
- data/lib/openai/resources/responses.rb +2 -2
- data/lib/openai/version.rb +1 -1
- data/lib/openai.rb +2 -0
- data/rbi/openai/helpers/responses_websocket/connection.rbi +5 -0
- data/rbi/openai/helpers/responses_websocket/session.rbi +206 -0
- data/rbi/openai/models/beta/agents/session_update_params.rbi +178 -0
- data/rbi/openai/models/beta/beta_response.rbi +6 -1
- data/rbi/openai/models/beta/beta_response_compaction_compacting_event.rbi +136 -0
- data/rbi/openai/models/beta/beta_response_stream_event.rbi +1 -0
- data/rbi/openai/models/beta/beta_responses_client_event.rbi +27 -4
- data/rbi/openai/models/beta/beta_responses_server_event.rbi +77 -0
- data/rbi/openai/models/beta/response_compact_params.rbi +6 -1
- data/rbi/openai/models/beta/response_create_params.rbi +21 -1
- data/rbi/openai/models/beta/session_turn_error.rbi +6 -0
- data/rbi/openai/models/chat_model.rbi +3 -1
- data/rbi/openai/models/responses/response_compact_params.rbi +6 -1
- data/rbi/openai/models/responses/response_compaction_compacting_event.rbi +78 -0
- data/rbi/openai/models/responses/response_create_params.rbi +15 -0
- data/rbi/openai/models/responses/response_stream_event.rbi +1 -0
- data/rbi/openai/models/responses/responses_client_event.rbi +15 -0
- data/rbi/openai/models/responses/responses_server_event.rbi +71 -0
- data/rbi/openai/models/webhooks/realtime_call_incoming_webhook_event.rbi +6 -8
- data/rbi/openai/resources/beta/agents/sessions.rbi +5 -1
- data/responses-websocket.md +117 -10
- data/sig/openai/helpers/responses_websocket/connection.rbs +5 -0
- data/sig/openai/helpers/responses_websocket/session.rbs +123 -0
- data/sig/openai/models/beta/agents/session_update_params.rbs +99 -0
- data/sig/openai/models/beta/beta_response.rbs +6 -2
- data/sig/openai/models/beta/beta_response_compaction_compacting_event.rbs +58 -0
- data/sig/openai/models/beta/beta_response_stream_event.rbs +1 -0
- data/sig/openai/models/beta/beta_responses_client_event.rbs +13 -2
- data/sig/openai/models/beta/beta_responses_server_event.rbs +35 -0
- data/sig/openai/models/beta/response_compact_params.rbs +6 -2
- data/sig/openai/models/beta/response_create_params.rbs +13 -2
- data/sig/openai/models/beta/session_turn_error.rbs +4 -0
- data/sig/openai/models/chat_model.rbs +6 -2
- data/sig/openai/models/responses/response_compact_params.rbs +6 -2
- data/sig/openai/models/responses/response_compaction_compacting_event.rbs +37 -0
- data/sig/openai/models/responses/response_create_params.rbs +7 -0
- data/sig/openai/models/responses/response_stream_event.rbs +1 -0
- data/sig/openai/models/responses/responses_client_event.rbs +7 -0
- data/sig/openai/models/responses/responses_server_event.rbs +32 -0
- data/sig/openai/resources/beta/agents/sessions.rbs +1 -0
- 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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
data/lib/openai/internal/util.rb
CHANGED
|
@@ -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
|
|
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)
|