Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9d13ce8
fix(openai): support streaming image relay
May 4, 2026
9cc9542
fix(openai): keep image edit multipart body reusable
May 4, 2026
47ba6ee
test(openai): cover image stream usage details
May 4, 2026
71a66dc
test(openai): cover image edit fallback stream field
May 5, 2026
26b87a1
Merge branch 'QuantumNous:main' into pr/openai-image-stream-keepalive…
gaoren002 May 8, 2026
4ee48d7
Merge branch 'QuantumNous:main' into pr/openai-image-stream-keepalive…
gaoren002 May 10, 2026
e79107a
fix(openai): wrap image json fallback as stream
May 10, 2026
9a9609d
fix(relay): support OpenAI image streaming
May 11, 2026
0fb42be
fix(openai): record image stream upstream error events
May 12, 2026
2817964
fix(openai): harden image stream relay
May 12, 2026
377faa7
Merge branch 'QuantumNous:main' into pr/openai-image-stream-keepalive…
gaoren002 May 12, 2026
2abb10a
Merge branch 'QuantumNous:main' into pr/openai-image-stream-keepalive…
gaoren002 May 14, 2026
5d8d6c2
fix(openai): return image JSON errors
May 17, 2026
d7bdad7
Merge branch 'QuantumNous:main' into pr/openai-image-stream-keepalive…
gaoren002 May 17, 2026
fedad96
Merge branch 'QuantumNous:main' into pr/openai-image-stream-keepalive…
gaoren002 May 20, 2026
26f81a2
Merge branch 'QuantumNous:main' into pr/openai-image-stream-keepalive…
gaoren002 May 24, 2026
cb8bc18
Merge branch 'QuantumNous:main' into pr/openai-image-stream-keepalive…
gaoren002 May 29, 2026
01dd22d
fix(relay): reset stream status per scanner run
May 29, 2026
d8a0088
fix(relay): drop upstream credit passthrough
May 29, 2026
518f804
fix(openai): keep image errors minimal
May 29, 2026
5284fe7
fix(openai): keep image error status from response
Calcium-Ion May 31, 2026
8097a54
Merge branch 'QuantumNous:main' into pr/openai-image-stream-keepalive…
gaoren002 Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions dto/openai_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ type ImageRequest struct {
OutputFormat json.RawMessage `json:"output_format,omitempty"`
OutputCompression json.RawMessage `json:"output_compression,omitempty"`
PartialImages json.RawMessage `json:"partial_images,omitempty"`
// Stream bool `json:"stream,omitempty"`
Images json.RawMessage `json:"images,omitempty"`
Mask json.RawMessage `json:"mask,omitempty"`
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
Stream bool `json:"stream,omitempty"`
Images json.RawMessage `json:"images,omitempty"`
Mask json.RawMessage `json:"mask,omitempty"`
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
// zhipu 4v
WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"`
UserId json.RawMessage `json:"user_id,omitempty"`
Expand Down Expand Up @@ -163,7 +163,7 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
}

func (i *ImageRequest) IsStream(c *gin.Context) bool {
return false
return i.Stream
}

func (i *ImageRequest) SetModelName(modelName string) {
Expand Down
16 changes: 16 additions & 0 deletions dto/openai_image_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package dto

import (
"testing"

"github.com/stretchr/testify/require"
)

// TestImageRequestStreamJSON verifies that image requests preserve stream=true.
func TestImageRequestStreamJSON(t *testing.T) {
var req ImageRequest
require.NoError(t, req.UnmarshalJSON([]byte(`{"model":"gpt-image-1","prompt":"draw a cat","stream":true}`)))

require.True(t, req.Stream)
require.True(t, req.IsStream(nil))
}
16 changes: 12 additions & 4 deletions relay/channel/openai/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"path/filepath"
"strings"

Expand Down Expand Up @@ -437,10 +438,13 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
// 使用已解析的 multipart 表单,避免重复解析
mf := c.Request.MultipartForm
if mf == nil {
if _, err := c.MultipartForm(); err != nil {
return nil, errors.New("failed to parse multipart form")
form, err := common.ParseMultipartFormReusable(c)
if err != nil {
return nil, fmt.Errorf("failed to parse multipart form: %w", err)
}
mf = c.Request.MultipartForm
c.Request.MultipartForm = form
c.Request.PostForm = url.Values(form.Value)
mf = form
}

// 写入所有非文件字段
Expand Down Expand Up @@ -623,7 +627,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
case relayconstant.RelayModeAudioTranscription:
err, usage = OpenaiSTTHandler(c, resp, info, a.ResponseFormat)
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
usage, err = OpenaiHandlerWithUsage(c, info, resp)
if info.IsStream {
usage, err = OpenaiImageStreamHandler(c, info, resp)
} else {
usage, err = OpenaiHandlerWithUsage(c, info, resp)
}
case relayconstant.RelayModeRerank:
usage, err = common_handler.RerankHandler(c, info, resp)
case relayconstant.RelayModeResponses:
Expand Down
121 changes: 121 additions & 0 deletions relay/channel/openai/image_edit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package openai

import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

// TestConvertImageEditRequestKeepsValidMultipartStreamFields verifies multipart replay.
func TestConvertImageEditRequestKeepsValidMultipartStreamFields(t *testing.T) {
gin.SetMode(gin.TestMode)

var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
require.NoError(t, writer.WriteField("prompt", "edit this image"))
require.NoError(t, writer.WriteField("stream", "true"))
require.NoError(t, writer.WriteField("partial_images", "3"))
part, err := writer.CreateFormFile("image", "input.png")
require.NoError(t, err)
_, err = part.Write([]byte("fake image"))
require.NoError(t, err)
require.NoError(t, writer.Close())

recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
require.NoError(t, c.Request.ParseMultipartForm(32<<20))

info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesEdits,
}
request := dto.ImageRequest{
Model: "gpt-image-1",
Prompt: "edit this image",
Stream: true,
}

converted, err := (&Adaptor{}).ConvertImageRequest(c, info, request)
require.NoError(t, err)

convertedBody, ok := converted.(*bytes.Buffer)
require.True(t, ok)

contentType := c.Request.Header.Get("Content-Type")
replayedRequest := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(convertedBody.Bytes()))
replayedRequest.Header.Set("Content-Type", contentType)
require.NoError(t, replayedRequest.ParseMultipartForm(32<<20))

require.Equal(t, "gpt-image-1", replayedRequest.PostForm.Get("model"))
require.Equal(t, "edit this image", replayedRequest.PostForm.Get("prompt"))
require.Equal(t, "true", replayedRequest.PostForm.Get("stream"))
require.Equal(t, "3", replayedRequest.PostForm.Get("partial_images"))
require.Len(t, replayedRequest.MultipartForm.File["image"], 1)

file, err := replayedRequest.MultipartForm.File["image"][0].Open()
require.NoError(t, err)
defer file.Close()
fileBytes, err := io.ReadAll(file)
require.NoError(t, err)
require.Equal(t, []byte("fake image"), fileBytes)
}

// TestConvertImageEditRequestParsesReusableMultipartWhenFormIsMissing verifies fallback parsing.
func TestConvertImageEditRequestParsesReusableMultipartWhenFormIsMissing(t *testing.T) {
gin.SetMode(gin.TestMode)

var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
require.NoError(t, writer.WriteField("prompt", "edit without pre-parsed form"))
require.NoError(t, writer.WriteField("stream", "true"))
part, err := writer.CreateFormFile("image", "input.png")
require.NoError(t, err)
_, err = part.Write([]byte("fake image"))
require.NoError(t, err)
require.NoError(t, writer.Close())

recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
c.Request.Header.Set("Content-Type", writer.FormDataContentType())

storage, err := common.GetBodyStorage(c)
require.NoError(t, err)
c.Request.Body = io.NopCloser(storage)
c.Request.MultipartForm = nil
c.Request.PostForm = nil

info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesEdits,
}
request := dto.ImageRequest{
Model: "gpt-image-1",
Prompt: "edit without pre-parsed form",
Stream: true,
}

converted, err := (&Adaptor{}).ConvertImageRequest(c, info, request)
require.NoError(t, err)

convertedBody, ok := converted.(*bytes.Buffer)
require.True(t, ok)
replayedRequest := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(convertedBody.Bytes()))
replayedRequest.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
require.NoError(t, replayedRequest.ParseMultipartForm(32<<20))
require.Equal(t, "edit without pre-parsed form", replayedRequest.PostForm.Get("prompt"))
require.Equal(t, "true", replayedRequest.PostForm.Get("stream"))
require.Len(t, replayedRequest.MultipartForm.File["image"], 1)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading