Repository: sashabaranov/go-openai
Pull request #994 (2025-05-11 07:35:20)

update image api *os.File to io.Reader
The image-related API has been changed from *os.File to io.Reader to make it more general. At the same time, the default application/octet-stream has been removed to avoid errors caused by OpenAI requiring PNG format input. This is my first pull request, and I may not have done it well, so please provide feedback.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/sashabaranov/go-openai/pull/994?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Alexander+Baranov) Report
Attention: Patch coverage is `15.78947%` with `16 lines` in your changes missing coverage. Please review.
> Project coverage is 84.71%. Comparing base [(`6181fac`)](https://app.codecov.io/gh/sashabaranov/go-openai/commit/6181facea7e6e5525b6b8da42205d7cce822c249?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Alexander+Baranov) to head [(`5173f70`)](https://app.codecov.io/gh/sashabaranov/go-openai/commit/5173f700568fa0d225d1ad56a9c98cf2588fd9df?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Alexander+Baranov).

| [Files with missing lines](https://app.codecov.io/gh/sashabaranov/go-openai/pull/994?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Alexander+Baranov) | Patch % | Lines |
|---|---|---|
| [internal/form\_builder.go](https://app.codecov.io/gh/sashabaranov/go-openai/pull/994?src=pr&el=tree&filepath=internal%2Fform_builder.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Alexander+Baranov#diff-aW50ZXJuYWwvZm9ybV9idWlsZGVyLmdv) | 0.00% | [16 Missing :warning: ](https://app.codecov.io/gh/sashabaranov/go-openai/pull/994?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Alexander+Baranov) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #994      +/-   ##
==========================================
- Coverage   85.93%   84.71%   -1.23%     
==========================================
  Files          43       43              
  Lines        2268     2283      +15     
==========================================
- Hits         1949     1934      -15     
- Misses        300      325      +25     
- Partials       19       24       +5     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/sashabaranov/go-openai/pull/994?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Alexander+Baranov).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Alexander+Baranov).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
sashabaranov: @Axb12 thank you for the PR! Could you please provide an example where this generalization could help? There's also a merge conflict it seems
Axb12: > @Axb12 thank you for the PR! Could you please provide an example where this generalization could help? There's also a merge conflict it seems

Thank you for your attention. I will work on improving them
Axb12: > @Axb12 thank you for the PR! Could you please provide an example where this generalization could help? There's also a merge conflict it seems

When I use go-openai for services, the files passed by the frontend need to be stored on the server before I can use them, which is very inconvenient. And when the concurrency is high, I think doing so will affect performance unless I build my own HTTP requests.
Axb12: I have resolved the conflict and added some comments.
sashabaranov: @Axb12 thank you, makes total sense! Copilot rerun added one more meaningful comment, let's address it and merge!
Axb12: > @Axb12 thank you, makes total sense! Copilot rerun added one more meaningful comment, let's address it and merge!

I have completed it!

Patch:
diff --git a/image.go b/image.go
index d62622a..72077ce 100644
--- a/image.go
+++ b/image.go
@@ -3,8 +3,8 @@ package openai
 import (
 	"bytes"
 	"context"
+	"io"
 	"net/http"
-	"os"
 	"strconv"
 )
 
@@ -134,15 +134,15 @@ func (c *Client) CreateImage(ctx context.Context, request ImageRequest) (respons
 
 // ImageEditRequest represents the request structure for the image API.
 type ImageEditRequest struct {
-	Image          *os.File `json:"image,omitempty"`
-	Mask           *os.File `json:"mask,omitempty"`
-	Prompt         string   `json:"prompt,omitempty"`
-	Model          string   `json:"model,omitempty"`
-	N              int      `json:"n,omitempty"`
-	Size           string   `json:"size,omitempty"`
-	ResponseFormat string   `json:"response_format,omitempty"`
-	Quality        string   `json:"quality,omitempty"`
-	User           string   `json:"user,omitempty"`
+	Image          io.Reader `json:"image,omitempty"`
+	Mask           io.Reader `json:"mask,omitempty"`
+	Prompt         string    `json:"prompt,omitempty"`
+	Model          string    `json:"model,omitempty"`
+	N              int       `json:"n,omitempty"`
+	Size           string    `json:"size,omitempty"`
+	ResponseFormat string    `json:"response_format,omitempty"`
+	Quality        string    `json:"quality,omitempty"`
+	User           string    `json:"user,omitempty"`
 }
 
 // CreateEditImage - API call to create an image. This is the main endpoint of the DALL-E API.
@@ -150,15 +150,16 @@ func (c *Client) CreateEditImage(ctx context.Context, request ImageEditRequest)
 	body := &bytes.Buffer{}
 	builder := c.createFormBuilder(body)
 
-	// image
-	err = builder.CreateFormFile("image", request.Image)
+	// image, filename is not required
+	err = builder.CreateFormFileReader("image", request.Image, "")
 	if err != nil {
 		return
 	}
 
 	// mask, it is optional
 	if request.Mask != nil {
-		err = builder.CreateFormFile("mask", request.Mask)
+		// mask, filename is not required
+		err = builder.CreateFormFileReader("mask", request.Mask, "")
 		if err != nil {
 			return
 		}
@@ -206,12 +207,12 @@ func (c *Client) CreateEditImage(ctx context.Context, request ImageEditRequest)
 
 // ImageVariRequest represents the request structure for the image API.
 type ImageVariRequest struct {
-	Image          *os.File `json:"image,omitempty"`
-	Model          string   `json:"model,omitempty"`
-	N              int      `json:"n,omitempty"`
-	Size           string   `json:"size,omitempty"`
-	ResponseFormat string   `json:"response_format,omitempty"`
-	User           string   `json:"user,omitempty"`
+	Image          io.Reader `json:"image,omitempty"`
+	Model          string    `json:"model,omitempty"`
+	N              int       `json:"n,omitempty"`
+	Size           string    `json:"size,omitempty"`
+	ResponseFormat string    `json:"response_format,omitempty"`
+	User           string    `json:"user,omitempty"`
 }
 
 // CreateVariImage - API call to create an image variation. This is the main endpoint of the DALL-E API.
@@ -220,8 +221,8 @@ func (c *Client) CreateVariImage(ctx context.Context, request ImageVariRequest)
 	body := &bytes.Buffer{}
 	builder := c.createFormBuilder(body)
 
-	// image
-	err = builder.CreateFormFile("image", request.Image)
+	// image, filename is not required
+	err = builder.CreateFormFileReader("image", request.Image, "")
 	if err != nil {
 		return
 	}
diff --git a/internal/form_builder.go b/internal/form_builder.go
index 2224fad..1c2513d 100644
--- a/internal/form_builder.go
+++ b/internal/form_builder.go
@@ -4,8 +4,10 @@ import (
 	"fmt"
 	"io"
 	"mime/multipart"
+	"net/textproto"
 	"os"
-	"path"
+	"path/filepath"
+	"strings"
 )
 
 type FormBuilder interface {
@@ -30,8 +32,37 @@ func (fb *DefaultFormBuilder) CreateFormFile(fieldname string, file *os.File) er
 	return fb.createFormFile(fieldname, file, file.Name())
 }
 
+var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
+
+func escapeQuotes(s string) string {
+	return quoteEscaper.Replace(s)
+}
+
+// CreateFormFileReader creates a form field with a file reader.
+// The filename in parameters can be an empty string.
+// The filename in Content-Disposition is required, But it can be an empty string.
 func (fb *DefaultFormBuilder) CreateFormFileReader(fieldname string, r io.Reader, filename string) error {
-	return fb.createFormFile(fieldname, r, path.Base(filename))
+	h := make(textproto.MIMEHeader)
+	h.Set(
+		"Content-Disposition",
+		fmt.Sprintf(
+			`form-data; name="%s"; filename="%s"`,
+			escapeQuotes(fieldname),
+			escapeQuotes(filepath.Base(filename)),
+		),
+	)
+
+	fieldWriter, err := fb.writer.CreatePart(h)
+	if err != nil {
+		return err
+	}
+
+	_, err = io.Copy(fieldWriter, r)
+	if err != nil {
+		return err
+	}
+
+	return nil
 }
 
 func (fb *DefaultFormBuilder) createFormFile(fieldname string, r io.Reader, filename string) error {


Test patch:
diff --git a/image_test.go b/image_test.go
index 9332dd5..6440055 100644
--- a/image_test.go
+++ b/image_test.go
@@ -54,13 +54,13 @@ func TestImageFormBuilderFailures(t *testing.T) {
 	}
 
 	mockFailedErr := fmt.Errorf("mock form builder fail")
-	mockBuilder.mockCreateFormFile = func(string, *os.File) error {
+	mockBuilder.mockCreateFormFileReader = func(string, io.Reader, string) error {
 		return mockFailedErr
 	}
 	_, err := client.CreateEditImage(ctx, req)
 	checks.ErrorIs(t, err, mockFailedErr, "CreateImage should return error if form builder fails")
 
-	mockBuilder.mockCreateFormFile = func(name string, _ *os.File) error {
+	mockBuilder.mockCreateFormFileReader = func(name string, _ io.Reader, _ string) error {
 		if name == "mask" {
 			return mockFailedErr
 		}
@@ -119,13 +119,13 @@ func TestVariImageFormBuilderFailures(t *testing.T) {
 	req := ImageVariRequest{}
 
 	mockFailedErr := fmt.Errorf("mock form builder fail")
-	mockBuilder.mockCreateFormFile = func(string, *os.File) error {
+	mockBuilder.mockCreateFormFileReader = func(string, io.Reader, string) error {
 		return mockFailedErr
 	}
 	_, err := client.CreateVariImage(ctx, req)
 	checks.ErrorIs(t, err, mockFailedErr, "CreateVariImage should return error if form builder fails")
 
-	mockBuilder.mockCreateFormFile = func(string, *os.File) error {
+	mockBuilder.mockCreateFormFileReader = func(string, io.Reader, string) error {
 		return nil
 	}
 
diff --git a/internal/form_builder_test.go b/internal/form_builder_test.go
index 8df989e..76922c1 100644
--- a/internal/form_builder_test.go
+++ b/internal/form_builder_test.go
@@ -43,3 +43,32 @@ func TestFormBuilderWithClosedFile(t *testing.T) {
 	checks.HasError(t, err, "formbuilder should return error if file is closed")
 	checks.ErrorIs(t, err, os.ErrClosed, "formbuilder should return error if file is closed")
 }
+
+type failingReader struct {
+}
+
+var errMockFailingReaderError = errors.New("mock reader failed")
+
+func (*failingReader) Read([]byte) (int, error) {
+	return 0, errMockFailingReaderError
+}
+
+func TestFormBuilderWithReader(t *testing.T) {
+	file, err := os.CreateTemp(t.TempDir(), "")
+	if err != nil {
+		t.Fatalf("Error creating tmp file: %v", err)
+	}
+	defer file.Close()
+	builder := NewFormBuilder(&failingWriter{})
+	err = builder.CreateFormFileReader("file", file, file.Name())
+	checks.ErrorIs(t, err, errMockFailingWriterError, "formbuilder should return error if writer fails")
+
+	builder = NewFormBuilder(&bytes.Buffer{})
+	reader := &failingReader{}
+	err = builder.CreateFormFileReader("file", reader, "")
+	checks.ErrorIs(t, err, errMockFailingReaderError, "formbuilder should return error if copy reader fails")
+
+	successReader := &bytes.Buffer{}
+	err = builder.CreateFormFileReader("file", successReader, "")
+	checks.NoError(t, err, "formbuilder should not return error")
+}