From b262215f170bab6c452a65b09e73bda66cb94dea Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:11:36 +0100 Subject: [PATCH 1/7] fix(apiform): handle typed nil readers before interface dispatch --- internal/apiform/encoder.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go index 8b93f178..9f4b376a 100644 --- a/internal/apiform/encoder.go +++ b/internal/apiform/encoder.go @@ -44,6 +44,9 @@ func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.W if !val.IsValid() { return writer.WriteField(key, "") } + if (val.Kind() == reflect.Pointer || val.Kind() == reflect.Interface) && val.IsNil() { + return writer.WriteField(key, "") + } t := val.Type() From 6dec6c7afd11101f8dbc94a9bf4f783efb2d07f6 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:12:13 +0100 Subject: [PATCH 2/7] test(apiform): cover typed nil readers --- internal/apiform/typed_nil_reader_test.go | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 internal/apiform/typed_nil_reader_test.go diff --git a/internal/apiform/typed_nil_reader_test.go b/internal/apiform/typed_nil_reader_test.go new file mode 100644 index 00000000..fca5e15e --- /dev/null +++ b/internal/apiform/typed_nil_reader_test.go @@ -0,0 +1,34 @@ +package apiform + +import ( + "bytes" + "mime/multipart" + "testing" + + "github.com/stretchr/testify/require" +) + +type panicOnRead struct{} + +func (*panicOnRead) Read([]byte) (int, error) { + panic("Read called on typed nil receiver") +} + +func TestMarshalTreatsTypedNilReaderAsEmptyField(t *testing.T) { + t.Parallel() + + var reader *panicOnRead + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.SetBoundary("xxx")) + + require.NotPanics(t, func() { + require.NoError(t, Marshal(map[string]any{"file": reader}, writer)) + require.NoError(t, writer.Close()) + }) + + require.Equal(t, + "--xxx\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\n\r\n--xxx--\r\n", + buf.String(), + ) +} From e6eeec6040e7a48bfa689f8d71e57b8665bc0b1c Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 24 Aug 2026 16:18:34 +0000 Subject: [PATCH 3/7] fix(apiform): unwrap reader interfaces before nil checks --- internal/apiform/encoder.go | 21 ++++++------ internal/apiform/typed_nil_reader_test.go | 42 +++++++++++++++-------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go index 9f4b376a..0e3d4e7f 100644 --- a/internal/apiform/encoder.go +++ b/internal/apiform/encoder.go @@ -44,7 +44,17 @@ func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.W if !val.IsValid() { return writer.WriteField(key, "") } - if (val.Kind() == reflect.Pointer || val.Kind() == reflect.Interface) && val.IsNil() { + + // Unwrap interfaces before detecting io.Reader. A non-nil interface can + // contain a typed nil pointer, which must retain the encoder's empty-field + // semantics instead of being passed to io.Copy. + for val.Kind() == reflect.Interface { + if val.IsNil() { + return writer.WriteField(key, "") + } + val = val.Elem() + } + if val.Kind() == reflect.Pointer && val.IsNil() { return writer.WriteField(key, "") } @@ -56,9 +66,6 @@ func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.W switch t.Kind() { case reflect.Pointer: - if val.IsNil() || !val.IsValid() { - return writer.WriteField(key, "") - } return e.encodeValue(key, val.Elem(), writer) case reflect.Slice, reflect.Array: @@ -67,12 +74,6 @@ func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.W case reflect.Map: return e.encodeMap(key, val, writer) - case reflect.Interface: - if val.IsNil() { - return writer.WriteField(key, "") - } - return e.encodeValue(key, val.Elem(), writer) - case reflect.String: return writer.WriteField(key, val.String()) diff --git a/internal/apiform/typed_nil_reader_test.go b/internal/apiform/typed_nil_reader_test.go index fca5e15e..df243ad3 100644 --- a/internal/apiform/typed_nil_reader_test.go +++ b/internal/apiform/typed_nil_reader_test.go @@ -2,6 +2,7 @@ package apiform import ( "bytes" + "io" "mime/multipart" "testing" @@ -17,18 +18,31 @@ func (*panicOnRead) Read([]byte) (int, error) { func TestMarshalTreatsTypedNilReaderAsEmptyField(t *testing.T) { t.Parallel() - var reader *panicOnRead - var buf bytes.Buffer - writer := multipart.NewWriter(&buf) - require.NoError(t, writer.SetBoundary("xxx")) - - require.NotPanics(t, func() { - require.NoError(t, Marshal(map[string]any{"file": reader}, writer)) - require.NoError(t, writer.Close()) - }) - - require.Equal(t, - "--xxx\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\n\r\n--xxx--\r\n", - buf.String(), - ) + var concrete *panicOnRead + var reader io.Reader = concrete + tests := map[string]any{ + "concrete pointer in any map": map[string]any{"file": concrete}, + "pointer in reader map": map[string]io.Reader{"file": reader}, + "nil reader interface": map[string]io.Reader{"file": nil}, + } + + for name, value := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.SetBoundary("xxx")) + + require.NotPanics(t, func() { + require.NoError(t, Marshal(value, writer)) + require.NoError(t, writer.Close()) + }) + + require.Equal(t, + "--xxx\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\n\r\n--xxx--\r\n", + buf.String(), + ) + }) + } } From 4673ec55f63824692c8689e6e3a9010f0c90651f Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:03:01 +0100 Subject: [PATCH 4/7] fix: classify typed nil readers as scalar multipart fields --- pkg/cmd/multipartbody.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/multipartbody.go b/pkg/cmd/multipartbody.go index a9983e60..9ecbfb7a 100644 --- a/pkg/cmd/multipartbody.go +++ b/pkg/cmd/multipartbody.go @@ -106,7 +106,6 @@ func (b *multipartRequestBody) start() { func (b *multipartRequestBody) encode() { defer close(b.done) - if err := apiform.MarshalWithSettings(b.bodyMap, b.multipartWriter, b.encodingFormat); err != nil { // A final boundary asserts that every part completed. Abort immediately // instead of making a truncated source look like a complete upload. @@ -219,6 +218,14 @@ type multipartBodyInfo struct { knownLength bool } +func isTypedNilReader(value any) bool { + if _, ok := value.(io.Reader); !ok { + return false + } + reflected := reflect.ValueOf(value) + return reflected.Kind() == reflect.Pointer && reflected.IsNil() +} + func inspectMultipartBody(value any) multipartBodyInfo { switch value := value.(type) { case map[string]any: @@ -240,6 +247,9 @@ func inspectMultipartBody(value any) multipartBodyInfo { case fileUpload: return multipartBodyInfo{hasUpload: true, knownLength: value.hasKnownSize()} default: + if isTypedNilReader(value) { + return multipartBodyInfo{knownLength: true} + } _, isReader := value.(io.Reader) return multipartBodyInfo{hasUpload: isReader, knownLength: !isReader} } @@ -311,6 +321,9 @@ func transformFileUploads( } return result, nil default: + if isTypedNilReader(value) { + return value, nil + } if _, isReader := value.(io.Reader); isReader { return nil, errors.New("multipart body contains an unknown-size reader") } From 1875918a6b36017468da7bdb219683c8527606d1 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:03:32 +0100 Subject: [PATCH 5/7] test: preserve retries and redirects for typed nil multipart fields --- pkg/cmd/multipartbody_typed_nil_test.go | 107 ++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 pkg/cmd/multipartbody_typed_nil_test.go diff --git a/pkg/cmd/multipartbody_typed_nil_test.go b/pkg/cmd/multipartbody_typed_nil_test.go new file mode 100644 index 00000000..75c8ca28 --- /dev/null +++ b/pkg/cmd/multipartbody_typed_nil_test.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/openai/openai-cli/internal/apiform" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func typedNilMultipartReader() io.Reader { + var reader *strings.Reader + return reader +} + +func TestInspectMultipartBodyTreatsTypedNilReaderAsScalar(t *testing.T) { + info := inspectMultipartBody(map[string]any{"file": typedNilMultipartReader()}) + + require.False(t, info.hasUpload) + require.True(t, info.knownLength) +} + +func TestMultipartRequestOptionsRetryTypedNilReaderAsScalar(t *testing.T) { + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + if !assert.NoError(t, r.ParseMultipartForm(1<<20)) { + http.Error(w, "invalid multipart form", http.StatusBadRequest) + return + } + assert.Equal(t, []string{""}, r.MultipartForm.Value["file"]) + assert.Empty(t, r.MultipartForm.File["file"]) + assert.Positive(t, r.ContentLength) + + w.Header().Set("Content-Type", "application/json") + if requestCount.Load() == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, `{"error":{"message":"retry me","type":"rate_limit_error"}}`) + return + } + _, _ = io.WriteString(w, `{"id":"video_123","object":"video","status":"queued"}`) + })) + t.Cleanup(server.Close) + + options, err := multipartRequestOptions(map[string]any{ + "file": typedNilMultipartReader(), + "prompt": "hello", + }, apiform.FormatBrackets) + require.NoError(t, err) + client := openai.NewClient( + option.WithAPIKey("test-key"), + option.WithBaseURL(server.URL+"/"), + ) + + _, err = client.Videos.New(context.Background(), openai.VideoNewParams{}, options...) + require.NoError(t, err) + require.Equal(t, int32(2), requestCount.Load()) +} + +func TestMultipartRequestOptionsReplayTypedNilReaderAcrossRedirects(t *testing.T) { + for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(http.StatusText(status), func(t *testing.T) { + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + if r.URL.Path != "/redirected" { + http.Redirect(w, r, "/redirected", status) + return + } + + if !assert.NoError(t, r.ParseMultipartForm(1<<20)) { + http.Error(w, "invalid multipart form", http.StatusBadRequest) + return + } + assert.Equal(t, []string{""}, r.MultipartForm.Value["file"]) + assert.Empty(t, r.MultipartForm.File["file"]) + assert.Positive(t, r.ContentLength) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"video_123","object":"video","status":"queued"}`) + })) + t.Cleanup(server.Close) + + options, err := multipartRequestOptions(map[string]any{ + "file": typedNilMultipartReader(), + "prompt": "hello", + }, apiform.FormatBrackets) + require.NoError(t, err) + client := openai.NewClient( + option.WithAPIKey("test-key"), + option.WithBaseURL(server.URL+"/"), + ) + + _, err = client.Videos.New(context.Background(), openai.VideoNewParams{}, options...) + require.NoError(t, err) + require.Equal(t, int32(2), requestCount.Load()) + }) + } +} From 3dc0aae6a0a21ca26a633e92183aacc774f47f29 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:04:30 +0100 Subject: [PATCH 6/7] chore: keep multipart body diff focused --- pkg/cmd/multipartbody.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/multipartbody.go b/pkg/cmd/multipartbody.go index 9ecbfb7a..72c1b2d5 100644 --- a/pkg/cmd/multipartbody.go +++ b/pkg/cmd/multipartbody.go @@ -106,6 +106,7 @@ func (b *multipartRequestBody) start() { func (b *multipartRequestBody) encode() { defer close(b.done) + if err := apiform.MarshalWithSettings(b.bodyMap, b.multipartWriter, b.encodingFormat); err != nil { // A final boundary asserts that every part completed. Abort immediately // instead of making a truncated source look like a complete upload. From 1c59bac295e65ae8845e1d8f9e80da80ebfd0380 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:04:58 +0100 Subject: [PATCH 7/7] test: cover typed nil reader beside known upload --- pkg/cmd/multipartbody_typed_nil_test.go | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/pkg/cmd/multipartbody_typed_nil_test.go b/pkg/cmd/multipartbody_typed_nil_test.go index 75c8ca28..46df9704 100644 --- a/pkg/cmd/multipartbody_typed_nil_test.go +++ b/pkg/cmd/multipartbody_typed_nil_test.go @@ -5,6 +5,8 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync/atomic" "testing" @@ -105,3 +107,40 @@ func TestMultipartRequestOptionsReplayTypedNilReaderAcrossRedirects(t *testing.T }) } } + +func TestMultipartRequestOptionsKnownUploadAllowsTypedNilReaderField(t *testing.T) { + path := filepath.Join(t.TempDir(), "payload.txt") + require.NoError(t, os.WriteFile(path, []byte("payload"), 0o600)) + upload, err := openFileUpload(path) + require.NoError(t, err) + + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + assert.Positive(t, r.ContentLength) + if !assert.NoError(t, r.ParseMultipartForm(1<<20)) { + http.Error(w, "invalid multipart form", http.StatusBadRequest) + return + } + assert.Equal(t, []string{""}, r.MultipartForm.Value["optional"]) + assert.Len(t, r.MultipartForm.File["file"], 1) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"file_123","object":"file","bytes":7,"filename":"payload.txt","purpose":"assistants"}`) + })) + t.Cleanup(server.Close) + + options, err := multipartRequestOptions(map[string]any{ + "file": upload, + "optional": typedNilMultipartReader(), + "purpose": "assistants", + }, apiform.FormatBrackets) + require.NoError(t, err) + client := openai.NewClient( + option.WithAPIKey("test-key"), + option.WithBaseURL(server.URL+"/"), + ) + + _, err = client.Files.New(context.Background(), openai.FileNewParams{}, options...) + require.NoError(t, err) + require.Equal(t, int32(1), requestCount.Load()) +}