From 38f956abb1dc7518367b4c376b5a6239897ab9d2 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 4 Sep 2026 10:51:41 -0700 Subject: [PATCH] fix: honor raw StringIO request cursors --- lib/openai/internal/util.rb | 5 +- test/openai/raw_stringio_request_test.rb | 99 ++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 test/openai/raw_stringio_request_test.rb diff --git a/lib/openai/internal/util.rb b/lib/openai/internal/util.rb index cbeccdf10..0ba02b110 100644 --- a/lib/openai/internal/util.rb +++ b/lib/openai/internal/util.rb @@ -638,7 +638,10 @@ def encode_content(headers, body) in [_, Symbol | Numeric] [headers, body.to_s] in [_, StringIO] - [headers, body.string] + [headers, body.string.byteslice(body.pos..) || body.string.byteslice(0, 0)] + in [_, OpenAI::FilePart] if body.content.is_a?(StringIO) + content = body.content + [headers, content.string.byteslice(content.pos..) || content.string.byteslice(0, 0)] in [_, OpenAI::FilePart] [headers, body.content] else diff --git a/test/openai/raw_stringio_request_test.rb b/test/openai/raw_stringio_request_test.rb new file mode 100644 index 000000000..8a44b1d5a --- /dev/null +++ b/test/openai/raw_stringio_request_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class OpenAI::Test::RawStringIORequestTest < Minitest::Test + extend Minitest::Serial + + include WebMock::API + + REQUEST_URL = "https://example.test/probe" + + def before_all + super + WebMock.enable! + end + + def teardown + WebMock.reset! + super + end + + def after_all + WebMock.disable! + super + end + + def test_request_sends_bytes_remaining_at_current_cursor_without_consuming + [ + [0, "abcdef"], + [3, "def"], + [6, ""], + [10, ""] + ].each do |position, expected| + raw_forms.each do |wrap| + source = StringIO.new("abcdef") + source.pos = position + + assert_equal([expected], request_bodies(wrap.call(source)), "byte position #{position}") + assert_equal(position, source.pos) + assert_equal("abcdef", source.string) + assert_equal(Encoding::UTF_8, source.string.encoding) + refute_predicate(source, :closed?) + end + end + end + + def test_request_slices_at_multibyte_byte_cursor_without_consuming + raw_forms.each do |wrap| + source = StringIO.new("éx") + source.pos = "é".bytesize + + assert_equal(["x"], request_bodies(wrap.call(source))) + assert_equal("é".bytesize, source.pos) + assert_equal(Encoding::UTF_8, source.string.encoding) + refute_predicate(source, :closed?) + end + end + + def test_retry_replays_identical_remaining_bytes_for_each_raw_form + raw_forms.each do |wrap| + source = StringIO.new("abcdef") + source.pos = 3 + + assert_equal(%w[def def], request_bodies(wrap.call(source), retry_once: true)) + assert_equal(3, source.pos) + assert_equal("abcdef", source.string) + assert_equal(Encoding::UTF_8, source.string.encoding) + refute_predicate(source, :closed?) + end + end + + private def raw_forms + [-> (source) { source }, -> (source) { OpenAI::FilePart.new(source) }] + end + + private def request_bodies(body, retry_once: false) + bodies = [] + stub_request(:post, REQUEST_URL).to_return do |request| + bodies << request.body + status = retry_once && bodies.one? ? 500 : 200 + {status: status, headers: {"content-type" => "application/json"}, body: "{}"} + end + + client = OpenAI::Client.new( + base_url: "https://example.test", + api_key: "fake-test-key", + max_retries: retry_once ? 1 : 0, + initial_retry_delay: 0, + max_retry_delay: 0 + ) + client.request( + method: :post, + path: "probe", + headers: {"content-type" => "application/octet-stream"}, + body: body + ) + bodies + end +end