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,281 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "json"
6
+ require "timeout"
7
+ require "uri"
8
+
9
+ require_relative "../../../lib/openai"
10
+ require_relative "../sideband"
11
+
12
+ module OpenAI::Examples::Realtime
13
+ # Single-operator, loopback-only application. This is not SDK runtime code.
14
+ class BrowserApp
15
+ class Rejected < StandardError
16
+ attr_reader :status
17
+ def initialize(status)
18
+ @status = status
19
+ super("Request rejected")
20
+ end
21
+ end
22
+
23
+ ASSETS = {
24
+ "/" => ["index.html", "text/html"],
25
+ "/direct" => ["index.html", "text/html"],
26
+ "/backend" => ["index.html", "text/html"],
27
+ "/peer.js" => ["peer.js", "text/javascript"],
28
+ "/page.js" => ["page.js", "text/javascript"]
29
+ }.freeze
30
+
31
+ def initialize(
32
+ client:,
33
+ token:,
34
+ port: 9292,
35
+ model: "gpt-realtime-2.1",
36
+ sideband: false,
37
+ manual_turns: false,
38
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
39
+ output: $stderr
40
+ )
41
+ raise ArgumentError, "Use an application token of at least 32 characters" if token.bytesize < 32
42
+ @client, @token, @model, @sideband = client, token, model, sideband
43
+ @manual_turns = manual_turns
44
+ @origin = URI::HTTP.build(host: "127.0.0.1", port: port).to_s
45
+ @authority = @origin.delete_prefix("http://")
46
+ @clock, @output = clock, output
47
+ @mutex = Mutex.new
48
+ @call = nil
49
+ @cancelled = {}
50
+ @next_create = 0
51
+ @closing = false
52
+ end
53
+
54
+ def service(request, response)
55
+ response["Cache-Control"] = "no-store"
56
+ response["Referrer-Policy"] = "no-referrer"
57
+ response["X-Content-Type-Options"] = "nosniff"
58
+ response["Cross-Origin-Resource-Policy"] = "same-origin"
59
+ response["Permissions-Policy"] = "microphone=(self), camera=()"
60
+ response["Content-Security-Policy"] = "default-src 'none'; script-src 'self'; connect-src 'self' https://api.openai.com; media-src 'self' blob:; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"
61
+ raise Rejected.new(403) unless request["host"] == @authority
62
+
63
+ if request.request_method == "GET" && ASSETS.key?(request.path)
64
+ file, content_type = ASSETS.fetch(request.path)
65
+ response["Content-Type"] = content_type
66
+ response.body = File.read(File.join(__dir__, file))
67
+ return
68
+ end
69
+
70
+ authenticate!(request)
71
+ @mutex.synchronize do
72
+ raise Rejected.new(503) if @closing
73
+ dispatch(request, response)
74
+ end
75
+
76
+ rescue Rejected => e
77
+ response.status = e.status
78
+ response.body = "Request rejected."
79
+ rescue StandardError
80
+ response.status = 502
81
+ response.body = "Signaling failed."
82
+ @output.puts("[browser] signaling failed")
83
+ end
84
+
85
+ # Called by the server timer even when the browser disappears without Stop.
86
+ def reap
87
+ @mutex.synchronize { cleanup if @call && @clock.call >= @call.fetch(:deadline) }
88
+ end
89
+
90
+ def shutdown
91
+ @mutex.synchronize do
92
+ @closing = true
93
+ cleanup if @call
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def authenticate!(request)
100
+ raise Rejected.new(405) unless request.request_method == "POST"
101
+ raise Rejected.new(403) unless request["origin"] == @origin
102
+ expected = Digest::SHA256.digest("Bearer #{@token}")
103
+ supplied = Digest::SHA256.digest(request["authorization"].to_s)
104
+ difference = expected.bytes.zip(supplied.bytes).reduce(0) { |memo, (a, b)| memo | (a ^ b) }
105
+ raise Rejected.new(401) unless difference.zero?
106
+ end
107
+
108
+ def dispatch(request, response)
109
+ response["Content-Type"] = "application/json"
110
+ case request.path
111
+ when "/api/secret"
112
+ rate_limit!
113
+ secret = @client.realtime.client_secrets.create(
114
+ expires_after: {anchor: :created_at, seconds: 30},
115
+ session: session,
116
+ request_options: creation_options
117
+ )
118
+ # No call exists on this backend. Failed delivery leaves a secret which
119
+ # expires; there is no public secret revocation endpoint to invent here.
120
+ response.body = JSON.generate({value: secret.value})
121
+ when "/api/calls"
122
+ raise Rejected.new(415) unless request["content-type"] == "application/sdp"
123
+ id = operation_id(request)
124
+ raise Rejected.new(409) if @call || @cancelled.include?(id)
125
+ rate_limit!
126
+ create_call(id, request.body.to_s, response)
127
+ when "/api/ack", "/api/renew"
128
+ owned_call!(request)
129
+ raise Rejected.new(409) if @call[:ending] || @clock.call >= @call[:deadline]
130
+ raise Rejected.new(409) if request.path == "/api/renew" && !@call[:acknowledged]
131
+ @call[:acknowledged] = true
132
+ @call[:deadline] = @clock.call + 60
133
+ response.body = "{}"
134
+ when "/api/stop"
135
+ # Only application operation IDs are accepted, never browser call IDs.
136
+ id = operation_id(request)
137
+ # A Stop can overtake its create on another HTTP connection. Retain the
138
+ # cancellation for this process lifetime; never allocate that ID later.
139
+ @cancelled[id] = true
140
+ if @call && @call[:id] == id
141
+ cleanup
142
+ raise Rejected.new(503) if @call
143
+ end
144
+
145
+ response.body = "{}"
146
+ else
147
+ raise Rejected.new(404)
148
+ end
149
+ end
150
+
151
+ def session
152
+ config = {type: :realtime, model: @model, instructions: "Keep answers short.", max_output_tokens: 128, tools: []}
153
+ config[:audio] = {input: {turn_detection: nil}} if @manual_turns
154
+ config
155
+ end
156
+
157
+ def creation_options
158
+ {
159
+ timeout: 15,
160
+ max_retries: 0,
161
+ extra_headers: {"OpenAI-Safety-Identifier" => Digest::SHA256.hexdigest(@token)}
162
+ }
163
+ end
164
+
165
+ def rate_limit!
166
+ raise Rejected.new(429) if @clock.call < @next_create
167
+ @next_create = @clock.call + 5
168
+ end
169
+
170
+ def operation_id(request)
171
+ id = request["x-operation-id"].to_s
172
+ raise Rejected.new(400) unless /\A[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\z/.match?(id)
173
+ id
174
+ end
175
+
176
+ def owned_call!(request)
177
+ raise Rejected.new(404) unless @call && @call[:id] == operation_id(request)
178
+ end
179
+
180
+ def create_call(id, offer, response)
181
+ # Shipped SDK owns allocation/response buffering until this returns. Do not
182
+ # duplicate its interrupted-creation cleanup or retry uncertain creates.
183
+ result = @client.realtime.calls.create(sdp: offer, session: session, request_options: creation_options)
184
+ call_id = call_id_from_location(result.headers["location"])
185
+ raise "Creation returned no usable call identifier" unless call_id
186
+ @call = {id: id, call_id: call_id, deadline: @clock.call + 20}
187
+ begin
188
+ answer = result.body.to_a.join
189
+ if @sideband
190
+ Timeout.timeout(15) do
191
+ Sideband.run(client: @client, call_id: call_id, instructions: "Keep answers short.", output: @output)
192
+ end
193
+ end
194
+
195
+ payload = JSON.generate({sdp: answer})
196
+ @call[:deadline] = @clock.call + 20
197
+ response.chunked = true
198
+ response.body = proc do |socket|
199
+ begin
200
+ socket.write(payload)
201
+ rescue StandardError
202
+ @mutex.synchronize { cleanup if @call && @call[:id] == id }
203
+ raise
204
+ end
205
+ end
206
+ rescue StandardError
207
+ cleanup
208
+ raise
209
+ end
210
+ end
211
+
212
+ def call_id_from_location(location)
213
+ return unless location.is_a?(String)
214
+ url = URI.join("https://api.openai.com/v1/realtime/calls", location)
215
+ return unless url.scheme == "https" && url.host == "api.openai.com" && url.port == 443
216
+ return if url.userinfo || url.query || url.fragment
217
+ %r{\A/v1/realtime/calls/([A-Za-z0-9_-]+)\z}.match(url.path)&.captures&.first
218
+ rescue URI::InvalidURIError
219
+ nil
220
+ end
221
+
222
+ def cleanup
223
+ @call[:ending] = true
224
+ Timeout.timeout(5) do
225
+ @client.realtime.calls.hangup(@call.fetch(:call_id), request_options: {timeout: 5, max_retries: 0})
226
+ end
227
+
228
+ @call = nil
229
+ @output.puts("[browser] call released")
230
+ rescue OpenAI::Errors::NotFoundError
231
+ @call = nil
232
+ @output.puts("[browser] call released")
233
+ rescue StandardError
234
+ @call[:deadline] = @clock.call + 5
235
+ @output.puts("[browser] hangup failed; retry pending")
236
+ end
237
+ end
238
+ end
239
+
240
+ if $PROGRAM_NAME == __FILE__
241
+ require "webrick"
242
+
243
+ port = Integer(ENV.fetch("PORT", "9292"))
244
+ app = OpenAI::Examples::Realtime::BrowserApp.new(
245
+ client: OpenAI::Client.new(
246
+ api_key: ENV.fetch("OPENAI_API_KEY"),
247
+ base_url: "https://api.openai.com/v1",
248
+ max_retries: 0
249
+ ),
250
+ token: ENV.fetch("BROWSER_APP_TOKEN"),
251
+ port: port,
252
+ model: ENV.fetch("OPENAI_REALTIME_MODEL", "gpt-realtime-2.1"),
253
+ sideband: ENV["BROWSER_SIDEBAND"] == "1",
254
+ manual_turns: ENV["BROWSER_MANUAL_TURNS"] == "1"
255
+ )
256
+ # Suppress HTTP logs: they can contain bearer credentials, SDP, or paths.
257
+ server = WEBrick::HTTPServer.new(
258
+ BindAddress: "127.0.0.1",
259
+ Port: port,
260
+ AccessLog: [],
261
+ Logger: WEBrick::Log.new(File::NULL),
262
+ RequestTimeout: 20
263
+ )
264
+ server.mount_proc("/") { |request, response| app.service(request, response) }
265
+ %w[INT TERM].each { |signal| Signal.trap(signal) { server.shutdown } }
266
+ sweeper = Thread.new do
267
+ loop do
268
+ sleep(1)
269
+ app.reap
270
+ end
271
+ end
272
+
273
+ begin
274
+ warn("[browser] ready http://127.0.0.1:#{port}/direct and /backend")
275
+ server.start
276
+ ensure
277
+ sweeper.kill
278
+ sweeper.join
279
+ app.shutdown
280
+ end
281
+ end
@@ -68,19 +68,79 @@ module OpenAI
68
68
  end
69
69
  end
70
70
 
71
+ def session_limits
72
+ # Application budgets for these short text examples, not service limits.
73
+ OpenAI::Responses::SessionLimits.new(
74
+ max_lanes: 8,
75
+ max_events_per_lane: 128,
76
+ max_events: 512,
77
+ max_bytes_per_lane: 16 * 1024 * 1024,
78
+ max_bytes: 32 * 1024 * 1024,
79
+ max_response_bytes: 64 * 1024 * 1024
80
+ )
81
+ end
82
+
71
83
  def multiplex(client:, model:)
72
- client.responses.connect do |connection|
73
- %w[planner critic].each do |lane|
74
- connection.response.create(
84
+ OpenAI::Responses::Session.open(client: client, limits: session_limits) do |session|
85
+ lanes = %w[planner critic].to_h { |name| [name, session.lane(name)] }
86
+ lanes.each_value do |lane|
87
+ lane.send_event(
88
+ type: :"response.create",
75
89
  model: model,
76
- input: "Say hello as the #{lane}.",
77
- stream_id: lane,
90
+ input: "Say hello as the #{lane.stream_id}.",
78
91
  store: false
79
92
  )
80
93
  end
81
94
 
82
- completed_responses(connection, lanes: %w[planner critic])
95
+ results = Thread::Queue.new
96
+ readers = []
97
+ begin
98
+ readers <<
99
+ Async::Task.current.async do
100
+ loop do
101
+ event = session.default.receive
102
+ next unless event.type.to_s == "error"
103
+
104
+ results.push(OpenAI::Responses::RequestError.new(event))
105
+ break
106
+ end
107
+
108
+ rescue StandardError
109
+ # Named readers report connection failure, or drain terminal
110
+ # responses already queued before a normal connection close.
111
+ nil
112
+ end
113
+ lanes.each do |name, lane|
114
+ readers <<
115
+ Async::Task.current.async do
116
+ response = lane.get_final_response
117
+ unless response.status.to_s == "completed"
118
+ raise("Responses WebSocket operation did not complete.")
119
+ end
120
+
121
+ results.push([name, response])
122
+ rescue StandardError => error
123
+ results.push(error)
124
+ end
125
+ end
126
+
127
+ lanes.length.times.to_h do
128
+ result = results.pop
129
+ raise result if result.is_a?(StandardError)
130
+
131
+ result
132
+ end
133
+
134
+ ensure
135
+ readers.each(&:stop)
136
+ readers.each(&:wait)
137
+ end
83
138
  end
139
+
140
+ rescue OpenAI::Responses::RequestError
141
+ raise "Responses WebSocket operation did not complete."
142
+ rescue OpenAI::Responses::SessionError
143
+ raise "Responses WebSocket closed with unfinished work."
84
144
  end
85
145
 
86
146
  # Deliberately rotate a healthy connection after a completed turn. This does
@@ -30,7 +30,11 @@ module OpenAI
30
30
  @identity_provider_id = identity_provider_id.to_s
31
31
  @service_account_id = service_account_id.to_s
32
32
  @provider = provider
33
- @refresh_buffer_seconds = refresh_buffer_seconds.to_i
33
+ @refresh_buffer_seconds = if refresh_buffer_seconds.is_a?(String)
34
+ Integer(refresh_buffer_seconds, 10)
35
+ else
36
+ Integer(refresh_buffer_seconds)
37
+ end
34
38
  end
35
39
  end
36
40
  end
@@ -353,7 +353,7 @@ module OpenAI
353
353
  in Net::HTTPBadRequest | Net::HTTPUnauthorized | Net::HTTPForbidden
354
354
  raise(
355
355
  OpenAI::Errors::OAuthError.new(
356
- status: response.code.to_i,
356
+ status: Integer(response.code, 10),
357
357
  body: body,
358
358
  headers: response.each_header.to_h
359
359
  )
@@ -367,7 +367,7 @@ module OpenAI
367
367
  raise(
368
368
  OpenAI::Errors::APIError.new(
369
369
  url: @token_exchange_url,
370
- status: response.code.to_i,
370
+ status: Integer(response.code, 10),
371
371
  headers: response.each_header.to_h,
372
372
  body: body,
373
373
  message: "Token exchange failed with status #{response.code}"
@@ -149,7 +149,7 @@ module OpenAI
149
149
  raise invalid_response("expires_in must be greater than zero and at most one hour", response: response)
150
150
  end
151
151
 
152
- {id: token, expires_in: expires_in.to_f}
152
+ {id: token, expires_in: Float(expires_in)}
153
153
  end
154
154
 
155
155
  private def raise_error(response, body)
@@ -33,7 +33,7 @@ module OpenAI
33
33
  raise ArgumentError, "#{name} must be a positive finite number of seconds"
34
34
  end
35
35
 
36
- result = value.to_f
36
+ result = Float(value)
37
37
  raise ArgumentError, "#{name} is not representable" unless result.finite?
38
38
  result
39
39
  end
@@ -154,7 +154,8 @@ module OpenAI
154
154
  data
155
155
  end
156
156
 
157
- private def encode_client_event(event)
157
+ # @api private
158
+ def encode_client_event(event)
158
159
  validate_event_tree!(event)
159
160
  payload = if event.is_a?(Hash)
160
161
  OpenAI::Internal::Type::Unknown.dump(event, state: {can_retry: true})