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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +101 -3
- data/lib/openai/client.rb +7 -10
- data/lib/openai/http_client.rb +97 -0
- data/lib/openai/internal/read_io_adapter.rb +137 -0
- data/lib/openai/internal/transport/base_client.rb +84 -25
- data/lib/openai/internal/type/base_stream.rb +1 -1
- data/lib/openai/internal/util.rb +7 -85
- data/lib/openai/net_http_client.rb +281 -0
- data/lib/openai/version.rb +1 -1
- data/lib/openai.rb +2 -1
- data/rbi/openai/client.rbi +4 -2
- data/rbi/openai/http_client.rbi +63 -0
- data/rbi/openai/internal/read_io_adapter.rbi +47 -0
- data/rbi/openai/internal/transport/base_client.rbi +21 -5
- data/rbi/openai/internal/type/base_stream.rbi +1 -1
- data/rbi/openai/internal/util.rbi +0 -40
- data/rbi/openai/net_http_client.rbi +64 -0
- data/sig/openai/client.rbs +2 -1
- data/sig/openai/http_client.rbs +41 -0
- data/sig/openai/internal/read_io_adapter.rbs +21 -0
- data/sig/openai/internal/transport/base_client.rbs +10 -3
- data/sig/openai/internal/type/base_stream.rbs +1 -1
- data/sig/openai/internal/util.rbs +0 -16
- data/sig/openai/net_http_client.rbs +34 -0
- metadata +12 -6
- data/lib/openai/internal/transport/pooled_net_requester.rb +0 -210
- data/rbi/openai/internal/transport/pooled_net_requester.rbi +0 -82
- data/sig/openai/internal/transport/pooled_net_requester.rbs +0 -48
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf3def8aaf6807cfdd1d5df8ed41e2eaf6513c703e9f8baa77db8c145637147a
|
|
4
|
+
data.tar.gz: 49a15beeb6bda9ba47e71cff822aa4eb0778c400b3a891ae15b2b7ade46f28ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
351
|
-
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 [
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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:
|
|
522
|
+
response: http_response,
|
|
464
523
|
stream: stream
|
|
465
524
|
)
|
|
466
525
|
end
|
|
467
526
|
|
|
468
527
|
case status
|
|
469
528
|
in ..299
|
|
470
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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:)
|