Commit d2576ddc by gaoren002 Committed by GitHub

fix(openai): support streaming image relay and image edit for images API (#4608)

* fix(openai): support streaming image relay

* fix(openai): keep image edit multipart body reusable

* test(openai): cover image stream usage details

* test(openai): cover image edit fallback stream field

* fix(openai): wrap image json fallback as stream

* fix(relay): support OpenAI image streaming

* fix(openai): record image stream upstream error events

* fix(openai): harden image stream relay

* fix(openai): return image JSON errors

* fix(relay): reset stream status per scanner run

* fix(relay): drop upstream credit passthrough

* fix(openai): keep image errors minimal

* fix(openai): keep image error status from response

---------

Co-authored-by: CaIon <i@caion.me>
parent 4ca47ee2
......@@ -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"`
......@@ -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) {
......
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))
}
......@@ -9,6 +9,7 @@ import (
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"path/filepath"
"strings"
......@@ -439,10 +440,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
}
// 写入所有非文件字段
......@@ -625,7 +629,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:
......
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)
}
package openai
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestOpenaiImageStreamHandlerForwardsSSEAndUsage(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
body := strings.Join([]string{
`event: image_generation.partial_image`,
`data: {"type":"image_generation.partial_image","b64_json":"partial"}`,
``,
`data: {"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`,
``,
`data: [DONE]`,
``,
}, "\n")
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{},
IsStream: true,
}
usage, err := OpenaiImageStreamHandler(c, info, resp)
require.Nil(t, err)
require.Equal(t, 3, usage.PromptTokens)
require.Equal(t, 4, usage.CompletionTokens)
require.Equal(t, 7, usage.TotalTokens)
require.Equal(t, 2, usage.PromptTokensDetails.ImageTokens)
require.Equal(t, 1, usage.PromptTokensDetails.TextTokens)
require.Contains(t, recorder.Body.String(), `event: image_generation.partial_image`)
require.Contains(t, recorder.Body.String(), `data: {"type":"image_generation.partial_image","b64_json":"partial"}`)
require.Contains(t, recorder.Body.String(), `data: {"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`)
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
}
func TestOpenaiImageStreamHandlerForwardsLargeSSELine(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
payload := strings.Repeat("x", helper.DefaultMaxScannerBufferSize+1)
body := "data: " + payload + "\n\n"
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{},
IsStream: true,
}
usage, err := OpenaiImageStreamHandler(c, info, resp)
require.Nil(t, err)
require.NotNil(t, usage)
require.Contains(t, recorder.Body.String(), payload)
require.NotNil(t, info.StreamStatus)
require.Equal(t, relaycommon.StreamEndReasonEOF, info.StreamStatus.EndReason)
}
func TestOpenaiImageStreamHandlerWrapsJSONResponse(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
body := `{"created":1710000000,"data":[{"b64_json":"final","revised_prompt":"draw a cat"}],"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{},
IsStream: true,
}
usage, err := OpenaiImageStreamHandler(c, info, resp)
require.Nil(t, err)
require.Equal(t, 3, usage.PromptTokens)
require.Equal(t, 4, usage.CompletionTokens)
require.Equal(t, 7, usage.TotalTokens)
require.Equal(t, 2, usage.PromptTokensDetails.ImageTokens)
require.Equal(t, 1, usage.PromptTokensDetails.TextTokens)
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
require.Empty(t, recorder.Header().Get("Content-Length"))
require.Contains(t, recorder.Body.String(), `event: image_generation.completed`)
require.Contains(t, recorder.Body.String(), `"type":"image_generation.completed"`)
require.Contains(t, recorder.Body.String(), `"b64_json":"final"`)
require.Contains(t, recorder.Body.String(), `"revised_prompt":"draw a cat"`)
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
}
func TestOpenaiHandlerWithUsageReturnsImageJSONError(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
body := `{"error":{"message":"content moderation failed","type":"upstream_error","code":"content_moderation_failed","status":502}}`
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{},
IsStream: false,
}
usage, err := OpenaiHandlerWithUsage(c, info, resp)
require.Nil(t, usage)
require.NotNil(t, err)
require.Equal(t, http.StatusOK, err.StatusCode)
oaiError := err.ToOpenAIError()
require.Equal(t, "content moderation failed", oaiError.Message)
require.Equal(t, "upstream_error", oaiError.Type)
require.Equal(t, "content_moderation_failed", oaiError.Code)
require.Empty(t, recorder.Body.String())
}
func TestOpenaiImageStreamHandlerReturnsJSONErrorFallback(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
body := `{"error":{"message":"image edit failed","type":"upstream_error","code":"content_moderation_failed","status":502}}`
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{},
IsStream: true,
}
usage, err := OpenaiImageStreamHandler(c, info, resp)
require.Nil(t, usage)
require.NotNil(t, err)
require.Equal(t, http.StatusOK, err.StatusCode)
oaiError := err.ToOpenAIError()
require.Equal(t, "image edit failed", oaiError.Message)
require.Empty(t, recorder.Body.String())
}
func TestOpenaiImageStreamHandlerRecordsUpstreamErrorEvent(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
body := strings.Join([]string{
`event: image_generation.partial_image`,
`data: {"type":"image_generation.partial_image","b64_json":"partial"}`,
``,
`event: error`,
`data: {"type":"upstream_error","error":{"message":"stream error: stream ID 77; INTERNAL_ERROR; received from peer"}}`,
``,
}, "\n")
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{},
IsStream: true,
}
usage, err := OpenaiImageStreamHandler(c, info, resp)
require.Nil(t, err)
require.NotNil(t, usage)
require.NotNil(t, info.StreamStatus)
require.Equal(t, relaycommon.StreamEndReasonHandlerStop, info.StreamStatus.EndReason)
require.True(t, info.StreamStatus.HasErrors())
require.Equal(t, 1, info.StreamStatus.TotalErrorCount())
require.Contains(t, info.StreamStatus.Errors[0].Message, "INTERNAL_ERROR")
require.Contains(t, recorder.Body.String(), `event: error`)
require.Contains(t, recorder.Body.String(), `stream ID 77`)
}
func TestNormalizeOpenAIUsageMapsImageTokenDetailsWithoutDoubleCounting(t *testing.T) {
usage := &dto.Usage{
InputTokens: 5000,
OutputTokens: 4000,
InputTokensDetails: &dto.InputTokenDetails{
CachedCreationTokens: 200,
ImageTokens: 1000,
TextTokens: 4000,
},
}
normalizeOpenAIUsage(usage)
require.Equal(t, 5000, usage.PromptTokens)
require.Equal(t, 4000, usage.CompletionTokens)
require.Equal(t, 9000, usage.TotalTokens)
require.Equal(t, 200, usage.PromptTokensDetails.CachedCreationTokens)
require.Equal(t, 1000, usage.PromptTokensDetails.ImageTokens)
require.Equal(t, 4000, usage.PromptTokensDetails.TextTokens)
}
package helper
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/QuantumNous/new-api/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestGetAndValidOpenAIImageRequestMultipartStream verifies reusable image edit parsing.
func TestGetAndValidOpenAIImageRequestMultipartStream(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("n", "1"))
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())
originalBody := body.String()
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())
req, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
require.NoError(t, err)
require.True(t, req.Stream)
require.True(t, req.IsStream(c))
bodyAfterValidation, err := io.ReadAll(c.Request.Body)
require.NoError(t, err)
require.Equal(t, originalBody, string(bodyAfterValidation))
form, err := common.ParseMultipartFormReusable(c)
require.NoError(t, err)
require.Equal(t, "true", url.Values(form.Value).Get("stream"))
require.Len(t, form.File["image"], 1)
}
// TestGetAndValidOpenAIImageRequestMultipartStreamInvalidValue verifies stream validation.
func TestGetAndValidOpenAIImageRequestMultipartStreamInvalidValue(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("stream", "notabool"))
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())
_, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid stream value")
}
......@@ -631,7 +631,7 @@ func TestStreamScannerHandler_StreamStatus_InitializedIfNil(t *testing.T) {
assert.NotNil(t, info.StreamStatus)
}
func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) {
func TestStreamScannerHandler_StreamStatus_ReplacesPreInitialized(t *testing.T) {
t.Parallel()
body := buildSSEBody(5)
......@@ -643,7 +643,7 @@ func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
assert.Equal(t, 1, info.StreamStatus.TotalErrorCount())
assert.Equal(t, 0, info.StreamStatus.TotalErrorCount())
}
func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) {
......
......@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"math"
"net/url"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
......@@ -144,16 +146,25 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
switch relayMode {
case relayconstant.RelayModeImagesEdits:
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
_, err := c.MultipartForm()
form, err := common.ParseMultipartFormReusable(c)
if err != nil {
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
}
formData := c.Request.PostForm
formData := url.Values(form.Value)
c.Request.MultipartForm = form
c.Request.PostForm = formData
imageRequest.Prompt = formData.Get("prompt")
imageRequest.Model = formData.Get("model")
imageRequest.N = common.GetPointer(uint(common.String2Int(formData.Get("n"))))
imageRequest.Quality = formData.Get("quality")
imageRequest.Size = formData.Get("size")
if streamValue := strings.TrimSpace(formData.Get("stream")); streamValue != "" {
stream, err := strconv.ParseBool(streamValue)
if err != nil {
return nil, fmt.Errorf("invalid stream value: %w", err)
}
imageRequest.Stream = stream
}
if imageValue := formData.Get("image"); imageValue != "" {
imageRequest.Image, _ = common.Marshal(imageValue)
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment