openai 0.77.1 → 0.78.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 297d9623dbc4b6e0f7fa0237d4e35f4fe4f147eb3b862d9b5c5d4c2ea32a12c6
4
- data.tar.gz: e9a284106c4f845bf5bf7fb15ef19479905edf8903375df61860f1eb8afe3b00
3
+ metadata.gz: cf3def8aaf6807cfdd1d5df8ed41e2eaf6513c703e9f8baa77db8c145637147a
4
+ data.tar.gz: 49a15beeb6bda9ba47e71cff822aa4eb0778c400b3a891ae15b2b7ade46f28ad
5
5
  SHA512:
6
- metadata.gz: 78fe85ca363710e01d50a2421b04fad7e5633ce8447c41ff44d39168d54994c2f273192e02f2c2fc7ba669d1f365b19d66805e4b84df07a038e6c4176fe00110
7
- data.tar.gz: 87405293563621fc6332fa479296f35015b1bc4463bf7e55f44a60e3611cf5d2c09f2ba67d2bdca96724c820369b6d27549ead881e346853adc35627a8450b73
6
+ metadata.gz: 7335e2cf29373c855d5f5866bb41ad0317ee0ba1df4ade67a9bfbddce0cd0befa3fb3910baa852bb2e95c8298b86a7c84de9a8cfe2c6324709ebf1ab47b4c17b
7
+ data.tar.gz: e596b1723939ef042457f1a9b7275cbb6f4d26fb6fe9f3a31bdbdc47d398f963da59b82217a4f626463a25596c6e0702193b95f4c3b2160c94a5340e7ef5971a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.78.0](https://github.com/openai/openai-ruby/compare/v0.77.1...v0.78.0) (2026-08-07)
4
+
5
+
6
+ ### Features
7
+
8
+ * support custom HTTP transports ([174aca9](https://github.com/openai/openai-ruby/commit/174aca90eba958f34c903e05f749051f8d8686f9))
9
+ * support custom HTTP transports ([#342](https://github.com/openai/openai-ruby/issues/342)) ([174aca9](https://github.com/openai/openai-ruby/commit/174aca90eba958f34c903e05f749051f8d8686f9))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * harden custom HTTP transports ([#345](https://github.com/openai/openai-ruby/issues/345)) ([2b12ac8](https://github.com/openai/openai-ruby/commit/2b12ac85771b32bdc5947b613fef0e605e061d9a))
15
+
16
+
17
+ ### Chores
18
+
19
+ * **deps-dev:** bump json from 2.21.1 to 2.21.2 ([#344](https://github.com/openai/openai-ruby/issues/344)) ([5043e3b](https://github.com/openai/openai-ruby/commit/5043e3b3415ccabca872db74c5673640502a513a))
20
+
3
21
  ## [0.77.1](https://github.com/openai/openai-ruby/compare/v0.77.0...v0.77.1) (2026-08-04)
4
22
 
5
23
 
data/README.md CHANGED
@@ -15,7 +15,7 @@ To use this gem, install via Bundler by adding the following to your application
15
15
  <!-- x-release-please-start-version -->
16
16
 
17
17
  ```ruby
18
- gem "openai", "~> 0.77.1"
18
+ gem "openai", "~> 0.78.0"
19
19
  ```
20
20
 
21
21
  <!-- x-release-please-end -->
@@ -107,6 +107,104 @@ puts(edited.data.first)
107
107
 
108
108
  Note that you can also pass a raw `IO` descriptor, but this disables retries, as the library can't be sure if the descriptor is a file or pipe (which cannot be rewound).
109
109
 
110
+ ### Custom HTTP clients
111
+
112
+ `OpenAI::Client` accepts an `http_client` for advanced transport requirements.
113
+ Provide an object that implements `execute(request)` and returns an
114
+ `OpenAI::HTTPClient::Response`; subclassing `OpenAI::HTTPClient` is the easiest
115
+ way to make that contract explicit. The request exposes the SDK-prepared HTTP
116
+ method, URL, headers, encoded body, and timeout. Response bodies are enumerable
117
+ byte-string chunks (or a single buffered string), so large and streaming
118
+ responses do not need to be buffered.
119
+
120
+ The OpenAI client owns API authentication, redirects, and API-level retries.
121
+ The custom HTTP client owns connection pooling and lifecycle, must enforce
122
+ `request.timeout`, and should raise `OpenAI::Errors::APIConnectionError` or
123
+ `OpenAI::Errors::APITimeoutError` for retryable transport failures. Other
124
+ exceptions propagate without an SDK retry. The SDK does not close an injected
125
+ HTTP client.
126
+
127
+ The default `OpenAI::NetHTTPClient` implements this contract with pooled
128
+ `Net::HTTP` connections. It accepts an optional block for native connection
129
+ configuration, as shown below. Call `close` to retire its current pools; the
130
+ HTTP client remains reusable and creates fresh connections on its next request.
131
+
132
+ ### Mutual TLS with a custom HTTP client
133
+
134
+ To opt in and activate mTLS for an organization or project, follow the
135
+ [OpenAI Mutual TLS Beta Program
136
+ guide](https://help.openai.com/en/articles/10876024-openai-mutual-tls-beta-program).
137
+ If you use an intermediate chain, confirm that certificate-chain support is
138
+ enabled for your organization.
139
+
140
+ For API-key requests that also require mutual TLS (mTLS), set the mTLS endpoint
141
+ explicitly and pass a configured `OpenAI::NetHTTPClient` as `http_client`.
142
+ `OpenAI::Client` accepts an HTTP client object so advanced transport behavior
143
+ does not require a separate SDK option for every use case. The default
144
+ `OpenAI::NetHTTPClient` accepts a block that configures each native
145
+ `Net::HTTP` connection before it is pooled and started.
146
+
147
+ Ruby's native TLS properties configure the client identity. The certificate
148
+ file must contain the leaf certificate first, followed by any intermediate
149
+ certificates needed when certificate-chain support is enabled for your
150
+ organization:
151
+
152
+ ```ruby
153
+ require "openai"
154
+
155
+ mtls_endpoint = URI("https://mtls.api.openai.com/v1")
156
+ certificates = OpenSSL::X509::Certificate.load(
157
+ File.binread(ENV.fetch("OPENAI_CLIENT_CERTIFICATE_CHAIN"))
158
+ )
159
+ raise "Expected a client certificate" if certificates.empty?
160
+
161
+ leaf_certificate, *intermediates = certificates
162
+ private_key = OpenSSL::PKey.read(
163
+ File.binread(ENV.fetch("OPENAI_CLIENT_KEY")),
164
+ ENV["OPENAI_CLIENT_KEY_PASSPHRASE"]
165
+ )
166
+ raise "Certificate and key do not match" unless leaf_certificate.check_private_key(private_key)
167
+
168
+ now = Time.now
169
+ raise "Certificate is not yet valid" if now < leaf_certificate.not_before
170
+ raise "Certificate has expired" if now > leaf_certificate.not_after
171
+
172
+ mtls_destination = [mtls_endpoint.host, mtls_endpoint.port]
173
+ http_client = OpenAI::NetHTTPClient.new do |http|
174
+ unless http.use_ssl? && mtls_destination == [http.address, http.port]
175
+ raise "Refusing to present the client certificate to an unexpected origin"
176
+ end
177
+
178
+ http.cert = leaf_certificate
179
+ http.extra_chain_cert = intermediates
180
+ http.key = private_key
181
+ end
182
+
183
+ client = OpenAI::Client.new(
184
+ api_key: ENV.fetch("OPENAI_API_KEY"),
185
+ base_url: mtls_endpoint.to_s,
186
+ http_client: http_client
187
+ )
188
+ ```
189
+
190
+ The SDK cannot infer whether custom HTTP configuration uses mTLS, so it does not
191
+ automatically change `base_url`. An explicit `base_url`, including an EU or
192
+ custom endpoint, is always preserved. Scope client certificates to the expected
193
+ origin, as above, so they cannot be presented elsewhere. Use the
194
+ `OpenAI::NetHTTPClient` block for TLS and other connection-level configuration.
195
+ Server trust remains separate from the client identity and can be customized
196
+ with `Net::HTTP` properties such as `cert_store` or `ca_file`.
197
+
198
+ Each `OpenAI::NetHTTPClient` owns its connection pool, so separate HTTP client
199
+ instances do not share connections or credentials.
200
+
201
+ After a process forks, create new OpenAI and HTTP clients in the child. Keep the
202
+ certificate configuration captured by an HTTP client immutable. For certificate
203
+ rotation, build and atomically swap in new `OpenAI::NetHTTPClient` and
204
+ `OpenAI::Client` instances, then call `close` on the retired HTTP client after
205
+ in-flight work finishes. See the complete [custom HTTP client mTLS
206
+ example](examples/mtls_custom_http_client.rb).
207
+
110
208
  ## Amazon Bedrock
111
209
 
112
210
  Use the standard client with the Bedrock provider to call OpenAI models through Amazon Bedrock's OpenAI-compatible API. Add `aws-sdk-core` to your application for AWS credential discovery and SigV4 signing:
@@ -568,9 +666,9 @@ response = client.request(
568
666
 
569
667
  ### Concurrency & connection pooling
570
668
 
571
- The `OpenAI::Client` instances are threadsafe, but are only fork-safe when there are no in-flight HTTP requests.
669
+ `OpenAI::Client` instances using the default `OpenAI::NetHTTPClient` are threadsafe, but are only fork-safe when there are no in-flight HTTP requests. Injected HTTP clients are responsible for documenting and enforcing their own concurrency guarantees.
572
670
 
573
- Each instance of `OpenAI::Client` has its own HTTP connection pool with a default size of 99. As such, we recommend instantiating the client once per application in most settings.
671
+ By default, each `OpenAI::Client` creates its own HTTP connection pool with a size of at least 99 connections. As such, we recommend instantiating the client once per application in most settings. An injected HTTP client may instead share its pool across multiple SDK clients; the caller owns that HTTP client's lifecycle.
574
672
 
575
673
  When all available connections from the pool are checked out, requests wait for a new connection to become available, with queue time counting towards the request timeout.
576
674
 
data/lib/openai/client.rb CHANGED
@@ -163,14 +163,6 @@ module OpenAI
163
163
  # Mutually exclusive with `workload_identity`.
164
164
  #
165
165
 
166
- # @api private
167
- private def request_replayable?(request)
168
- body = request[:body]
169
- return true if body.nil? || body.is_a?(String)
170
- return false if body.respond_to?(:read)
171
- true
172
- end
173
-
174
166
  # @api private
175
167
  private def prepare_request(request, redirect_count:, retry_count:)
176
168
  preparer = @provider_runtime&.prepare_request
@@ -244,6 +236,9 @@ module OpenAI
244
236
  # @param initial_retry_delay [Float]
245
237
  #
246
238
  # @param max_retry_delay [Float]
239
+ #
240
+ # @param http_client [#execute, nil] The HTTP client used to
241
+ # execute SDK requests. Defaults to {OpenAI::NetHTTPClient}.
247
242
  def initialize(
248
243
  api_key: OpenAI::Internal::OMIT,
249
244
  admin_api_key: OpenAI::Internal::OMIT,
@@ -256,7 +251,8 @@ module OpenAI
256
251
  max_retries: self.class::DEFAULT_MAX_RETRIES,
257
252
  timeout: self.class::DEFAULT_TIMEOUT_IN_SECONDS,
258
253
  initial_retry_delay: self.class::DEFAULT_INITIAL_RETRY_DELAY,
259
- max_retry_delay: self.class::DEFAULT_MAX_RETRY_DELAY
254
+ max_retry_delay: self.class::DEFAULT_MAX_RETRY_DELAY,
255
+ http_client: nil
260
256
  )
261
257
  provider_runtime = nil
262
258
  unless provider.nil?
@@ -344,7 +340,8 @@ module OpenAI
344
340
  max_retries: max_retries,
345
341
  initial_retry_delay: initial_retry_delay,
346
342
  max_retry_delay: max_retry_delay,
347
- headers: headers
343
+ headers: headers,
344
+ http_client: http_client
348
345
  )
349
346
 
350
347
  @completions = OpenAI::Resources::Completions.new(client: self)
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAI
4
+ # The transport boundary used by {OpenAI::Client}.
5
+ #
6
+ # Implement {#execute} to replace the SDK's HTTP transport. Subclassing this
7
+ # class is optional; any object that implements the same method is accepted.
8
+ # Most applications should use {OpenAI::NetHTTPClient}.
9
+ class HTTPClient
10
+ # An HTTP request prepared by the SDK.
11
+ class Request
12
+ # @return [Symbol]
13
+ attr_reader :method
14
+
15
+ # @return [URI::Generic]
16
+ attr_reader :url
17
+
18
+ # @return [Hash{String=>String}]
19
+ attr_reader :headers
20
+
21
+ # @return [Object, nil]
22
+ attr_reader :body
23
+
24
+ # @return [Float]
25
+ attr_reader :timeout
26
+
27
+ # @param method [Symbol]
28
+ # @param url [URI::Generic]
29
+ # @param headers [Hash{String=>String}]
30
+ # @param body [Object, nil]
31
+ # @param timeout [Float]
32
+ def initialize(method:, url:, headers:, body:, timeout:)
33
+ @method = method
34
+ @url = url
35
+ @headers = headers.dup.freeze
36
+ @body = body
37
+ @timeout = timeout
38
+ freeze
39
+ end
40
+ end
41
+
42
+ # An HTTP response returned to the SDK.
43
+ class Response
44
+ # @return [Integer]
45
+ attr_reader :status
46
+
47
+ # @return [Hash{String=>String}]
48
+ attr_reader :headers
49
+
50
+ # The body is normalized to a one-shot enumerable. If the supplied body
51
+ # responds to `close`, the SDK calls it when the body is consumed or
52
+ # discarded.
53
+ #
54
+ # @return [Enumerable<String>]
55
+ attr_reader :body
56
+
57
+ # @param status [Integer]
58
+ # @param headers [Hash{String=>String}]
59
+ # @param body [String, Enumerable<String>]
60
+ def initialize(status:, headers:, body:)
61
+ unless body.is_a?(String) || body.respond_to?(:each)
62
+ raise ArgumentError, "`body` must be a String or respond to `each`"
63
+ end
64
+
65
+ @status = Integer(status)
66
+ @headers =
67
+ headers.to_h do |name, value|
68
+ [name.to_s.downcase, value.to_s]
69
+ end.freeze
70
+ source = body.is_a?(String) ? [body].freeze : body
71
+ @body = OpenAI::Internal::Util.fused_enum(source) do
72
+ if source.respond_to?(:close)
73
+ source.close
74
+ else
75
+ OpenAI::Internal::Util.close_fused!(source)
76
+ end
77
+ end
78
+ freeze
79
+ end
80
+ end
81
+
82
+ # Executes one SDK-prepared HTTP request.
83
+ #
84
+ # Implementations must return an {OpenAI::HTTPClient::Response}. Connection
85
+ # pooling and transport-specific retries belong to the implementation; the
86
+ # SDK remains responsible for API-level retries and redirects. Transport
87
+ # failures should be raised as {OpenAI::Errors::APIConnectionError} or
88
+ # {OpenAI::Errors::APITimeoutError} so the SDK can apply its retry policy;
89
+ # other exceptions propagate without an SDK retry.
90
+ #
91
+ # @param request [OpenAI::HTTPClient::Request]
92
+ # @return [OpenAI::HTTPClient::Response]
93
+ def execute(request)
94
+ raise NotImplementedError, "#{self.class} must implement #execute for #{request.class}"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAI
4
+ module Internal
5
+ module Util
6
+ # @api private
7
+ #
8
+ # An adapter that satisfies the IO interface required by `::IO.copy_stream`
9
+ class ReadIOAdapter
10
+ class InterruptibleEnumerator
11
+ # Cancellation must bypass application rescues so a suspended source cannot
12
+ # swallow it and keep the request teardown blocked.
13
+ class Interrupt < Exception # rubocop:disable Lint/InheritException
14
+ end
15
+ private_constant :Interrupt
16
+
17
+ def initialize(source)
18
+ @started = false
19
+ @fiber = Fiber.new do
20
+ source.each { Fiber.yield(_1) }
21
+ rescue Interrupt
22
+ nil
23
+ end
24
+ end
25
+
26
+ def next
27
+ raise StopIteration unless @fiber&.alive?
28
+
29
+ @started = true
30
+ value = @fiber.resume
31
+ raise StopIteration unless @fiber.alive?
32
+
33
+ value
34
+ end
35
+
36
+ def to_a
37
+ values = []
38
+ loop { values << self.next }
39
+ rescue StopIteration
40
+ values
41
+ end
42
+
43
+ def close
44
+ @fiber&.raise(Interrupt) if @started && @fiber&.alive?
45
+ rescue Interrupt
46
+ nil
47
+ ensure
48
+ @fiber = nil
49
+ end
50
+ end
51
+ private_constant :InterruptibleEnumerator
52
+
53
+ # @api private
54
+ #
55
+ # @return [Boolean, nil]
56
+ def close? = @closing
57
+
58
+ # @api private
59
+ def close
60
+ case @stream
61
+ in Enumerator
62
+ OpenAI::Internal::Util.close_fused!(@stream)
63
+ in InterruptibleEnumerator | IO if close?
64
+ @stream.close
65
+ else
66
+ end
67
+ end
68
+
69
+ # @api private
70
+ #
71
+ # @param max_len [Integer, nil]
72
+ #
73
+ # @return [String]
74
+ private def read_enum(max_len)
75
+ case max_len
76
+ in nil
77
+ @stream.to_a.join
78
+ in Integer
79
+ @buf << @stream.next while @buf.length < max_len
80
+ @buf.slice!(..max_len)
81
+ end
82
+ rescue StopIteration
83
+ @stream = nil
84
+ @buf.slice!(0..)
85
+ end
86
+
87
+ # @api private
88
+ #
89
+ # @param max_len [Integer, nil]
90
+ # @param out_string [String, nil]
91
+ #
92
+ # @return [String, nil]
93
+ def read(max_len = nil, out_string = nil)
94
+ case @stream
95
+ in nil
96
+ nil
97
+ in IO | StringIO
98
+ @stream.read(max_len, out_string)
99
+ in Enumerator | InterruptibleEnumerator
100
+ read = read_enum(max_len)
101
+ case out_string
102
+ in String
103
+ out_string.replace(read)
104
+ in nil
105
+ read
106
+ end
107
+ end
108
+ .tap(&@blk)
109
+ end
110
+
111
+ # @api private
112
+ #
113
+ # @param src [String, Pathname, StringIO, Enumerable<String>]
114
+ # @param blk [Proc]
115
+ #
116
+ # @yieldparam [String]
117
+ def initialize(src, &blk)
118
+ @stream =
119
+ case src
120
+ in String
121
+ StringIO.new(src)
122
+ in Pathname
123
+ @closing = true
124
+ src.open(binmode: true)
125
+ in Enumerator
126
+ @closing = true
127
+ InterruptibleEnumerator.new(src)
128
+ else
129
+ src
130
+ end
131
+ @buf = String.new
132
+ @blk = blk
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -79,6 +79,28 @@ module OpenAI
79
79
  end
80
80
  end
81
81
 
82
+ # @api private
83
+ #
84
+ # @param body [Object]
85
+ #
86
+ # @return [Boolean]
87
+ def request_body_replayable?(body)
88
+ case body
89
+ in nil | String | StringIO | Pathname
90
+ true
91
+ in OpenAI::FilePart
92
+ request_body_replayable?(body.content)
93
+ in Hash
94
+ body.each_value.all? { request_body_replayable?(_1) }
95
+ in Array
96
+ body.all? { request_body_replayable?(_1) }
97
+ in IO | Enumerable
98
+ false
99
+ else
100
+ !body.respond_to?(:read)
101
+ end
102
+ end
103
+
82
104
  # @api private
83
105
  #
84
106
  # @param request [Hash{Symbol=>Object}] .
@@ -147,6 +169,15 @@ module OpenAI
147
169
  request = {**request, headers: request.fetch(:headers).except(*drop)}
148
170
  end
149
171
 
172
+ unless request_body_replayable?(request[:body])
173
+ message = "Cannot follow a body-preserving redirect with a non-replayable request body."
174
+ raise OpenAI::Errors::APIConnectionError.new(
175
+ url: location,
176
+ response: response_headers,
177
+ message: message
178
+ )
179
+ end
180
+
150
181
  request
151
182
  end
152
183
 
@@ -187,7 +218,7 @@ module OpenAI
187
218
  attr_reader :idempotency_header
188
219
 
189
220
  # @api private
190
- # @return [OpenAI::Internal::Transport::PooledNetRequester]
221
+ # @return [#execute]
191
222
  attr_reader :requester
192
223
 
193
224
  # @api private
@@ -199,6 +230,7 @@ module OpenAI
199
230
  # @param max_retry_delay [Float]
200
231
  # @param headers [Hash{String=>String, Integer, Array<String, Integer, nil>, nil}]
201
232
  # @param idempotency_header [String, nil]
233
+ # @param http_client [#execute, nil]
202
234
  def initialize(
203
235
  base_url:,
204
236
  timeout: 0.0,
@@ -206,9 +238,14 @@ module OpenAI
206
238
  initial_retry_delay: 0.0,
207
239
  max_retry_delay: 0.0,
208
240
  headers: {},
209
- idempotency_header: nil
241
+ idempotency_header: nil,
242
+ http_client: nil
210
243
  )
211
- @requester = OpenAI::Internal::Transport::PooledNetRequester.new
244
+ unless http_client.nil? || http_client.respond_to?(:execute)
245
+ raise ArgumentError, "`http_client` must respond to `execute`"
246
+ end
247
+
248
+ @requester = http_client || OpenAI::NetHTTPClient.new
212
249
  @headers = OpenAI::Internal::Util.normalized_headers(
213
250
  self.class::PLATFORM_HEADERS,
214
251
  {
@@ -342,17 +379,23 @@ module OpenAI
342
379
  @base_url_components,
343
380
  {**req, path: path, query: query}
344
381
  )
345
- headers, encoded = OpenAI::Internal::Util.encode_content(headers, body)
382
+ max_retries = opts.fetch(:max_retries, @max_retries)
383
+ max_retries = 0 unless self.class.request_body_replayable?(body)
346
384
  {
347
385
  method: method,
348
386
  url: url,
349
387
  headers: headers,
350
- body: encoded,
351
- max_retries: opts.fetch(:max_retries, @max_retries),
388
+ body: body,
389
+ max_retries: max_retries,
352
390
  timeout: timeout
353
391
  }
354
392
  end
355
393
 
394
+ # @api private
395
+ private def request_replayable?(request)
396
+ self.class.request_body_replayable?(request[:body])
397
+ end
398
+
356
399
  # @api private
357
400
  #
358
401
  # @param headers [Hash{String=>String}]
@@ -420,7 +463,7 @@ module OpenAI
420
463
  # @param send_retry_header [Boolean]
421
464
  #
422
465
  # @raise [OpenAI::Errors::APIError]
423
- # @return [Array(Integer, Net::HTTPResponse, Enumerable<String>)]
466
+ # @return [OpenAI::HTTPClient::Response]
424
467
  def send_request(request, redirect_count:, retry_count:, send_retry_header:)
425
468
  if send_retry_header
426
469
  request = request.merge(
@@ -428,23 +471,39 @@ module OpenAI
428
471
  )
429
472
  end
430
473
 
474
+ encoded_headers, encoded_body = OpenAI::Internal::Util.encode_content(
475
+ request.fetch(:headers),
476
+ request[:body]
477
+ )
478
+ attempt_request = request.merge(headers: encoded_headers, body: encoded_body)
431
479
  prepared_request = prepare_request(
432
- request,
480
+ attempt_request,
433
481
  redirect_count: redirect_count,
434
482
  retry_count: retry_count
435
483
  )
436
484
  url, max_retries, timeout = prepared_request.fetch_values(:url, :max_retries, :timeout)
437
- input = {
438
- **prepared_request.except(:timeout, :follow_redirects, :provider_auth),
439
- deadline: OpenAI::Internal::Util.monotonic_secs + timeout
440
- }
485
+ input = OpenAI::HTTPClient::Request.new(
486
+ method: prepared_request.fetch(:method),
487
+ url: url,
488
+ headers: prepared_request.fetch(:headers),
489
+ body: prepared_request[:body],
490
+ timeout: timeout
491
+ )
441
492
 
442
493
  begin
443
- status, response, stream = @requester.execute(input)
494
+ http_response = @requester.execute(input)
495
+ unless http_response.is_a?(OpenAI::HTTPClient::Response)
496
+ raise TypeError, "`http_client#execute` must return an OpenAI::HTTPClient::Response"
497
+ end
498
+
499
+ status = http_response.status
500
+ stream = http_response.body
501
+ headers = http_response.headers
444
502
  rescue OpenAI::Errors::APIConnectionError => e
445
503
  status = e
504
+ stream = nil
505
+ headers = {}
446
506
  end
447
- headers = OpenAI::Internal::Util.normalized_headers(response&.each_header&.to_h)
448
507
 
449
508
  terminal_status =
450
509
  case status
@@ -460,24 +519,25 @@ module OpenAI
460
519
  url: url,
461
520
  status: status,
462
521
  headers: headers,
463
- response: response,
522
+ response: http_response,
464
523
  stream: stream
465
524
  )
466
525
  end
467
526
 
468
527
  case status
469
528
  in ..299
470
- [status, response, stream]
529
+ http_response
471
530
  in 300..399 if redirect_count >= self.class::MAX_REDIRECTS
472
531
  self.class.reap_connection!(status, stream: stream)
473
532
 
474
533
  message = "Failed to complete the request within #{self.class::MAX_REDIRECTS} redirects."
475
- raise OpenAI::Errors::APIConnectionError.new(url: url, response: response, message: message)
534
+ raise OpenAI::Errors::APIConnectionError.new(url: url, response: http_response, message: message)
476
535
  in 300..399
477
536
  self.class.reap_connection!(status, stream: stream)
478
537
 
538
+ redirect_source = request.merge(url: prepared_request.fetch(:url))
479
539
  redirected_request = self.class.follow_redirect(
480
- prepared_request,
540
+ redirect_source,
481
541
  status: status,
482
542
  response_headers: headers
483
543
  )
@@ -492,7 +552,7 @@ module OpenAI
492
552
  in (400..) | OpenAI::Errors::APIConnectionError
493
553
  self.class.reap_connection!(status, stream: stream)
494
554
 
495
- delay = retry_delay(response || {}, retry_count: retry_count)
555
+ delay = retry_delay(headers, retry_count: retry_count)
496
556
  sleep(delay)
497
557
 
498
558
  send_request(
@@ -556,28 +616,27 @@ module OpenAI
556
616
 
557
617
  # Don't send the current retry count in the headers if the caller modified the header defaults.
558
618
  send_retry_header = request.fetch(:headers)["x-stainless-retry-count"] == "0"
559
- status, response, stream = send_request(
619
+ response = send_request(
560
620
  request,
561
621
  redirect_count: 0,
562
622
  retry_count: 0,
563
623
  send_retry_header: send_retry_header
564
624
  )
565
625
 
566
- headers = OpenAI::Internal::Util.normalized_headers(response.each_header.to_h)
567
- decoded = OpenAI::Internal::Util.decode_content(headers, stream: stream)
626
+ decoded = OpenAI::Internal::Util.decode_content(response.headers, stream: response.body)
568
627
  case req
569
628
  in {stream: Class => st}
570
629
  st.new(
571
630
  model: model,
572
631
  url: url,
573
- status: status,
574
- headers: headers,
632
+ status: response.status,
633
+ headers: response.headers,
575
634
  response: response,
576
635
  unwrap: unwrap,
577
636
  stream: decoded
578
637
  )
579
638
  in {page: Class => page}
580
- page.new(client: self, req: req, headers: headers, page_data: decoded)
639
+ page.new(client: self, req: req, headers: response.headers, page_data: decoded)
581
640
  else
582
641
  unwrapped = OpenAI::Internal::Util.dig(decoded, unwrap)
583
642
  OpenAI::Internal::Type::Converter.coerce(model, unwrapped)
@@ -55,7 +55,7 @@ module OpenAI
55
55
  # @param url [URI::Generic]
56
56
  # @param status [Integer]
57
57
  # @param headers [Hash{String=>String}]
58
- # @param response [Net::HTTPResponse]
58
+ # @param response [OpenAI::HTTPClient::Response]
59
59
  # @param unwrap [Symbol, Integer, Array<Symbol, Integer>, Proc]
60
60
  # @param stream [Enumerable<Object>]
61
61
  def initialize(model:, url:, status:, headers:, response:, unwrap:, stream:)