fix(task-plugin): set multipart content type for upstream requests (#7655)

* fix(task-plugin): set multipart content type for upstream requests

* refactor(task-plugin): copy the rebuilt multipart Content-Type as is

BuildRequestBody stores writer.FormDataContentType() on the client
request right before BuildRequestHeader runs, so the media type and
boundary checks could never fail. Copy the value directly, as the sora
adaptor did before task plugins.

The test plugin now forwards the client's Content-Type, whose boundary
differs from the rebuilt body, so the test also fails if a plugin header
wins over the host value.

---------

Co-authored-by: CaIon <i@caion.me>
This commit is contained in:
指短琴长andCaIon authored and GitHub committed 2026-10-08 23:52:03 +08:00
1 parent 6370b29424
commit 7aa3531ef4
2 files changed
+16 -3

No files matched your search

+6 -1
View File
@@ -262,13 +262,18 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
return a.submit.URL, pluginruntime.ValidateRequestURL(a.submit.URL, info.ChannelBaseUrl, a.plugin.Meta.AllowedHosts)
}
func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
if a.submit == nil {
return fmt.Errorf("plugin submit request was not built")
}
for name, value := range a.submit.Headers {
req.Header.Set(name, value)
}
// BuildRequestBody rebuilt the multipart body with a new boundary and put its
// Content-Type on the client request; a plugin's own Content-Type cannot match it.
if a.submit.BodyType == "multipart" {
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
}
return nil
}
+10 -2
View File
@@ -8,6 +8,7 @@ import (
"errors"
"io"
"math"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -83,9 +84,10 @@ func TestTaskAdaptorRejectsDeprecatedClientResponse(t *testing.T) {
}
func TestTaskAdaptorBuildsMultipartFromOpaqueFileReference(t *testing.T) {
// The plugin forwards the client's Content-Type, whose boundary is not the one of the rebuilt body.
source := `
export const meta = {apiVersion:1,key:"multipart",name:"Multipart",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
export function buildSubmitRequest(ctx) { return {url:ctx.baseUrl+"/submit",bodyType:"multipart",parts:[{name:"model",value:"m"},{name:"input_reference",fileRef:ctx.files[0].ref}]}; }
export function buildSubmitRequest(ctx) { return {url:ctx.baseUrl+"/submit",headers:{"Content-Type":ctx.requestHeaders["Content-Type"]},bodyType:"multipart",parts:[{name:"model",value:"m"},{name:"input_reference",fileRef:ctx.files[0].ref}]}; }
export function parseSubmitResponse(ctx,r){return {taskId:"1"}} export function buildQueryRequest(){return {url:"https://example.com"}} export function parseTaskResult(){return {status:"SUCCESS"}}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
@@ -108,7 +110,13 @@ export function parseSubmitResponse(ctx,r){return {taskId:"1"}} export function
require.NoError(t, err)
requestBytes, err := io.ReadAll(body)
require.NoError(t, err)
reader := multipart.NewReader(bytes.NewReader(requestBytes), strings.TrimPrefix(c.GetHeader("Content-Type"), "multipart/form-data; boundary="))
upstreamRequest := httptest.NewRequest(http.MethodPost, "https://provider.example/submit", bytes.NewReader(requestBytes))
require.NoError(t, adaptor.BuildRequestHeader(c, upstreamRequest, info))
mediaType, params, err := mime.ParseMediaType(upstreamRequest.Header.Get("Content-Type"))
require.NoError(t, err)
assert.Equal(t, "multipart/form-data", mediaType)
require.NotEmpty(t, params["boundary"])
reader := multipart.NewReader(bytes.NewReader(requestBytes), params["boundary"])
form, err := reader.ReadForm(1024)
require.NoError(t, err)
assert.Equal(t, []string{"m"}, form.Value["model"])