Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
46 changes: 41 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,17 +611,21 @@ Error codes are as follows:
### Request IDs

OpenAI recommends logging request IDs in production so requests can be traced
during troubleshooting. Successful typed responses expose `_request_id`, which
is populated from the `x-request-id` response header:
during troubleshooting. Models and pages returned directly by a successful HTTP
request expose `_request_id`, which is populated from the `x-request-id`
response header:

```ruby
response = openai.responses.create(model: "gpt-5.2", input: "Say 'this is a test'.")
puts(response._request_id) # req_123
```

The `_request_id` property is only populated on the top-level response object
and is not included in `to_h`, JSON, or YAML output. Unlike other properties
that begin with an underscore, `_request_id` is public.
The `_request_id` property is public, but is only populated on the top-level
model or page parsed from an HTTP response. Nested models, models constructed in
user code, and final snapshots assembled by streaming helpers have a `nil`
`_request_id`. It is not included in `to_h`, JSON, or YAML output. Responses
that parse to primitives, binary data, or `nil` do not have this property; use
[`with_raw_response`](#raw-responses) to inspect their request IDs.

For failed HTTP requests, catch `OpenAI::Errors::APIStatusError` and use
`request_id`:
Expand All @@ -638,6 +642,38 @@ end
See the [official OpenAI request debugging documentation](https://developers.openai.com/api/reference/overview#debugging-requests)
for more information.

### Raw responses

Call a generated HTTP operation through its resource's `with_raw_response`
modifier to inspect the status, normalized headers, or undecoded body without
changing the operation's arguments:

```ruby
raw = openai.models.with_raw_response.retrieve("gpt-5.2")

puts(raw.status)
puts(raw.headers["openai-processing-ms"])
puts(raw.request_id)
puts(raw.read)

model = raw.parse
puts(model._request_id == raw.request_id) # true
```

Raw responses use the SDK's normal authentication, request encoding, redirects,
retries, error handling, and response conversion. Their bodies are buffered so
`read` and `parse` can both be used; the parsed result is cached. Header names
are lowercase, header values are strings, and the headers hash is immutable.
The wrapper exposes generated resource navigation and HTTP operations, but not
SDK-only helpers. Its RBI and RBS signatures preserve each operation's
arguments and parsed return type.

Higher-level `stream` helpers expose `status` and `headers` directly on their
stream objects, so use those helpers without `with_raw_response`. Transport
metadata is not copied onto nested models or final streaming snapshots. When a
raw response parses to a top-level model or page, its `_request_id` is the same
value as `raw.request_id`.

### Retries

Certain errors will be automatically retried 2 times by default, with a short exponential backoff.
Expand Down
3 changes: 3 additions & 0 deletions lib/openai.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,15 @@
require_relative "openai/file_part"
require_relative "openai/errors"
require_relative "openai/http_client"
require_relative "openai/raw_response"
require_relative "openai/net_http_client"
require_relative "openai/provider"
require_relative "openai/internal/provider"
require_relative "openai/providers/azure"
require_relative "openai/providers/bedrock"
require_relative "openai/internal/transport/request_client"
require_relative "openai/internal/transport/base_client"
require_relative "openai/internal/transport/raw_response_client"
require_relative "openai/client"
require_relative "openai/internal/stream"
require_relative "openai/internal/conversation_cursor_page"
Expand Down
4 changes: 3 additions & 1 deletion lib/openai/helpers/streaming/chat_completion_stream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ class ChatCompletionStream

def initialize(raw_stream:, response_format: nil, input_tools: nil)
@raw_stream = raw_stream
@headers = raw_stream.headers
@status = raw_stream.status
@state = ChatCompletionStreamState.new(
response_format: response_format,
input_tools: input_tools
Expand All @@ -33,7 +35,7 @@ def get_output_text
end

def until_done
each {} # rubocop:disable Lint/EmptyBlock
each { |_event| next }
self
end

Expand Down
4 changes: 3 additions & 1 deletion lib/openai/helpers/streaming/response_stream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ def initialize(raw_stream:, text_format: nil, starting_after: nil)
@text_format = text_format
@starting_after = starting_after
@raw_stream = raw_stream
@headers = raw_stream.headers
@status = raw_stream.status
@iterator = iterator
@state = ResponseStreamState.new(
text_format: text_format
)
end

def until_done
each {} # rubocop:disable Lint/EmptyBlock
each { |_event| next }
self
end

Expand Down
92 changes: 59 additions & 33 deletions lib/openai/internal/transport/base_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module Transport
#
# @abstract
class BaseClient
include OpenAI::Internal::Transport::RequestClient
extend OpenAI::Internal::Util::SorbetRuntimeSupport

# from whatwg fetch spec
Expand Down Expand Up @@ -606,10 +607,53 @@ def send_request(request, redirect_count:, retry_count:, send_retry_header:)
# @raise [OpenAI::Errors::APIError]
# @return [Object]
def request(req)
url, response = perform_request(req)
parse_response(req, url: url, response: response)
end

# Execute the request specified by `req` and return its undecoded HTTP
# response. Resource raw-response wrappers call this method so they use
# the same authentication, encoding, redirect, retry, error, and parsing
# behavior as ordinary resource calls.
#
# @api private
#
# @param req [Hash{Symbol=>Object}]
# @raise [OpenAI::Errors::APIError]
# @return [OpenAI::RawResponse]
def raw_request(req)
url, response = perform_request(req)
status = response.status
headers = response.headers.to_h do |name, value|
[name.dup.freeze, value.dup.freeze]
end.freeze
body = response.body.each_with_object(+"") do |chunk, buffer|
buffer << chunk
end.freeze
parser_req = req[:page] ? req : req.slice(:model, :stream, :unwrap)

OpenAI::RawResponse.new(
status: status,
headers: headers,
body: body,
parser: -> do
replay = OpenAI::HTTPClient::Response.new(
status: status,
headers: headers,
body: body
)
parse_response(parser_req, url: url, response: replay)
end
)
end

# @api private
#
# @param req [Hash{Symbol=>Object}]
# @return [Array(URI::Generic, OpenAI::HTTPClient::Response)]
private def perform_request(req)
self.class.validate!(req)
model = req.fetch(:model) { OpenAI::Internal::Type::Unknown }
opts = req[:options].to_h
unwrap = req[:unwrap]
OpenAI::RequestOptions.validate!(opts)
request = build_request(req.except(:options), opts)
url = request.fetch(:url)
Expand All @@ -622,6 +666,18 @@ def request(req)
retry_count: 0,
send_retry_header: send_retry_header
)
[url, response]
end

# @api private
#
# @param req [Hash{Symbol=>Object}]
# @param url [URI::Generic]
# @param response [OpenAI::HTTPClient::Response]
# @return [Object]
private def parse_response(req, url:, response:)
model = req.fetch(:model) { OpenAI::Internal::Type::Unknown }
unwrap = req[:unwrap]

decoded = OpenAI::Internal::Util.decode_content(response.headers, stream: response.body)
case req
Expand Down Expand Up @@ -657,37 +713,7 @@ def inspect
end

define_sorbet_constant!(:RequestComponents) do
T.type_alias do
{
method: Symbol,
path: T.any(String, T::Array[String]),
query: T.nilable(T::Hash[String, T.nilable(T.any(T::Array[String], String))]),
headers: T.nilable(
T::Hash[String,
T.nilable(
T.any(
String,
Integer,
T::Array[T.nilable(T.any(String, Integer))]
)
)]
),
body: T.nilable(T.anything),
unwrap: T.nilable(
T.any(
Symbol,
Integer,
T::Array[T.any(Symbol, Integer)],
T.proc.params(arg0: T.anything).returns(T.anything)
)
),
page: T.nilable(T::Class[OpenAI::Internal::Type::BasePage[OpenAI::Internal::Type::BaseModel]]),
stream: T.nilable(T::Class[OpenAI::Internal::Type::BaseStream[T.anything, OpenAI::Internal::Type::BaseModel]]),
model: T.nilable(OpenAI::Internal::Type::Converter::Input),
security: T.nilable({bearer_auth?: T::Boolean, admin_api_key_auth?: T::Boolean}),
options: T.nilable(OpenAI::RequestOptions::OrHash)
}
end
T.type_alias { T::Hash[Symbol, T.anything] }
end
define_sorbet_constant!(:RequestInput) do
T.type_alias do
Expand Down
24 changes: 24 additions & 0 deletions lib/openai/internal/transport/raw_response_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module OpenAI
module Internal
module Transport
# Converts ordinary generated resource requests into raw responses while
# preserving the resource method's request construction.
#
# @api private
class RawResponseClient
include OpenAI::Internal::Transport::RequestClient

# @param client [OpenAI::Internal::Transport::BaseClient]
def initialize(client)
@client = client
end

# @param req [Hash{Symbol=>Object}]
# @return [OpenAI::RawResponse]
def request(req) = @client.raw_request(req)
end
end
end
end
13 changes: 13 additions & 0 deletions lib/openai/internal/transport/request_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module OpenAI
module Internal
module Transport
# The minimal client contract consumed by generated API resources.
#
# @api private
module RequestClient
end
end
end
end
77 changes: 77 additions & 0 deletions lib/openai/raw_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

module OpenAI
# A successful HTTP response whose body has not yet been decoded into an SDK
# return type.
#
# Raw responses are buffered so callers can inspect the response body and
# still parse it through the SDK's normal response conversion path.
#
# @generic Elem
class RawResponse
UNPARSED = Object.new.freeze
private_constant :UNPARSED

# @return [Integer]
attr_reader :status

# Normalized, immutable HTTP response headers. Header names are lowercase
# and values are strings.
#
# @return [Hash{String=>String}]
attr_reader :headers

# The undecoded response body.
#
# @return [String]
attr_reader :body

# @return [String, nil]
def request_id = @headers["x-request-id"]

# Returns a mutable copy of the undecoded response body.
#
# @return [String]
def read = @body.dup

# Decodes the response through the same conversion path as the ordinary
# resource method. The result is cached after the first successful parse.
#
# @return [Object]
def parse
parsed = @parsed
return parsed unless parsed.equal?(UNPARSED)

@parse_mutex.synchronize do
return @parsed unless @parsed.equal?(UNPARSED)

parsed = @parser.call
@parsed = parsed
@parser = nil
parsed
end
end

# @return [String]
def inspect
"#<#{self.class.name}:0x#{object_id.to_s(16)} status=#{@status} body_bytes=#{@body.bytesize}>"
end

# @api private
#
# @param status [Integer]
# @param headers [Hash{String=>String}]
# @param body [String]
# @param parser [Proc]
def initialize(status:, headers:, body:, parser:)
@status = Integer(status)
@headers = headers.to_h do |name, value|
[name.to_s.downcase.freeze, value.to_s.dup.freeze]
end.freeze
@body = body.frozen? ? body : body.dup.freeze
@parser = parser
@parsed = UNPARSED
@parse_mutex = Mutex.new
end
end
end
Loading
Loading