307 lines
11 KiB
Go
307 lines
11 KiB
Go
package relay
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
"github.com/QuantumNous/new-api/relay/helper"
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jinzhu/copier"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestRequestDeepCopyResponses(t *testing.T) {
|
|
t.Run("nil source", func(t *testing.T) {
|
|
clone, err := common.DeepCopy[dto.OpenAIResponsesRequest](nil)
|
|
require.Error(t, err)
|
|
assert.Nil(t, clone)
|
|
})
|
|
|
|
for _, raw := range []json.RawMessage{nil, {}, []byte(`null`), []byte(`false`), []byte(`0`), []byte(`[]`), []byte(`{"x":1}`)} {
|
|
t.Run(fmt.Sprintf("raw_%q_nil_%t", raw, raw == nil), func(t *testing.T) {
|
|
src := &dto.OpenAIResponsesRequest{}
|
|
populateResponsesCloneFixture(t, reflect.ValueOf(src).Elem(), raw)
|
|
want := &dto.OpenAIResponsesRequest{}
|
|
populateResponsesCloneFixture(t, reflect.ValueOf(want).Elem(), raw)
|
|
clone, err := common.DeepCopy(src)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, src, clone, "including internal state and explicit zero pointers")
|
|
|
|
// Mutate every reachable field, including nested pointers and raw bytes.
|
|
// A later channel attempt must still see the original request.
|
|
populateResponsesCloneFixture(t, reflect.ValueOf(clone).Elem(), []byte(`true`))
|
|
assert.Equal(t, want, src)
|
|
retry, err := common.DeepCopy(src)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, retry)
|
|
})
|
|
}
|
|
|
|
t.Run("absent optional fields", func(t *testing.T) {
|
|
src := &dto.OpenAIResponsesRequest{Model: "gpt-4.1", Input: []byte(`"hello"`)}
|
|
clone, err := common.DeepCopy(src)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, src, clone)
|
|
clone.Input[1] = 'H'
|
|
assert.Equal(t, json.RawMessage(`"hello"`), src.Input)
|
|
})
|
|
|
|
t.Run("large input", func(t *testing.T) {
|
|
input := bytes.Repeat([]byte("a"), 1<<20)
|
|
input[0], input[len(input)-1] = '"', '"'
|
|
src := &dto.OpenAIResponsesRequest{Model: "gpt-4.1", Input: input}
|
|
clone, err := common.DeepCopy(src)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, src, clone)
|
|
clone.Input[len(input)/2] = 'b'
|
|
assert.Equal(t, byte('a'), src.Input[len(input)/2])
|
|
})
|
|
|
|
t.Run("model mapping on successive channel attempts", func(t *testing.T) {
|
|
src := &dto.OpenAIResponsesRequest{Model: "client-model", Input: []byte(`"hello"`)}
|
|
for _, model := range []string{"first-channel-model", "retry-channel-model"} {
|
|
request, err := common.DeepCopy(src)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, json.RawMessage(`"hello"`), request.Input)
|
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
|
c.Set("model_mapping", fmt.Sprintf(`{"client-model":%q}`, model))
|
|
info := &relaycommon.RelayInfo{OriginModelName: src.Model}
|
|
require.NoError(t, helper.ModelMappedHelper(c, info, request))
|
|
assert.Equal(t, model, request.Model)
|
|
assert.Equal(t, "client-model", src.Model)
|
|
request.Input[1] = 'H'
|
|
assert.Equal(t, json.RawMessage(`"hello"`), src.Input)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Populate all mutable fields so newly added request fields also exercise the
|
|
// retry-isolation contract, rather than silently escaping a hand-written list.
|
|
func populateResponsesCloneFixture(t *testing.T, value reflect.Value, raw json.RawMessage) {
|
|
t.Helper()
|
|
switch value.Kind() {
|
|
case reflect.Struct:
|
|
for i := 0; i < value.NumField(); i++ {
|
|
populateResponsesCloneFixture(t, value.Field(i), raw)
|
|
}
|
|
case reflect.Pointer:
|
|
if value.IsNil() {
|
|
value.Set(reflect.New(value.Type().Elem()))
|
|
}
|
|
populateResponsesCloneFixture(t, value.Elem(), raw)
|
|
case reflect.Slice:
|
|
require.Equal(t, reflect.TypeFor[json.RawMessage](), value.Type())
|
|
if value.Len() > 0 {
|
|
value.Index(0).SetUint('!')
|
|
}
|
|
value.Set(reflect.ValueOf(json.RawMessage(bytes.Clone(raw))))
|
|
case reflect.String:
|
|
value.SetString(value.String() + "gpt-4.1")
|
|
case reflect.Bool:
|
|
value.SetBool(bytes.Equal(raw, []byte(`true`)))
|
|
case reflect.Int:
|
|
if bytes.Equal(raw, []byte(`true`)) {
|
|
value.SetInt(1)
|
|
}
|
|
case reflect.Uint:
|
|
if bytes.Equal(raw, []byte(`true`)) {
|
|
value.SetUint(1)
|
|
}
|
|
case reflect.Float64:
|
|
if bytes.Equal(raw, []byte(`true`)) {
|
|
value.SetFloat(1)
|
|
}
|
|
default:
|
|
require.FailNow(t, "extend clone fixture for new field type", "%s", value.Type())
|
|
}
|
|
}
|
|
|
|
func TestRequestDeepCopyRawMutation(t *testing.T) {
|
|
t.Run("edits before cloning are included", func(t *testing.T) {
|
|
src := &dto.OpenAIResponsesRequest{Input: json.RawMessage(`"hello"`)}
|
|
src.Input[1] = 'H'
|
|
clone, err := common.DeepCopy(src)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, json.RawMessage(`"Hello"`), clone.Input)
|
|
})
|
|
|
|
t.Run("edits after cloning are isolated in both directions", func(t *testing.T) {
|
|
input := make(json.RawMessage, len(`"hello"`), 32)
|
|
copy(input, `"hello"`)
|
|
src := &dto.OpenAIResponsesRequest{Input: input}
|
|
clone, err := common.DeepCopy(src)
|
|
require.NoError(t, err)
|
|
|
|
clone.Input[1] = 'H'
|
|
assert.Equal(t, json.RawMessage(`"hello"`), src.Input)
|
|
src.Input[2] = 'A'
|
|
assert.Equal(t, json.RawMessage(`"Hello"`), clone.Input)
|
|
clone.Input = append(clone.Input, ' ')
|
|
src.Input = append(src.Input, '\n')
|
|
assert.Equal(t, json.RawMessage("\"Hello\" "), clone.Input)
|
|
assert.Equal(t, json.RawMessage("\"hAllo\"\n"), src.Input)
|
|
clone.Input = json.RawMessage(`"replacement"`)
|
|
assert.Equal(t, json.RawMessage("\"hAllo\"\n"), src.Input)
|
|
})
|
|
|
|
t.Run("raw map and slice entries retain nil and empty values", func(t *testing.T) {
|
|
src := &struct {
|
|
Fields map[string]json.RawMessage
|
|
Items []json.RawMessage
|
|
}{
|
|
Fields: map[string]json.RawMessage{"absent": nil, "empty": {}, "value": []byte(`false`)},
|
|
Items: []json.RawMessage{nil, {}, []byte(`0`)},
|
|
}
|
|
clone, err := common.DeepCopy(src)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, src, clone)
|
|
clone.Fields["value"][0] = '!'
|
|
clone.Items[2][0] = '1'
|
|
delete(clone.Fields, "absent")
|
|
assert.Contains(t, src.Fields, "absent")
|
|
assert.Equal(t, json.RawMessage(`false`), src.Fields["value"])
|
|
assert.Equal(t, json.RawMessage(`0`), src.Items[2])
|
|
})
|
|
}
|
|
|
|
func TestRequestDeepCopyOtherProtocols(t *testing.T) {
|
|
t.Run("chat nested fields remain isolated", func(t *testing.T) {
|
|
assertRequestCloneIsolation(t, `{
|
|
"model":"gpt-4.1","max_tokens":0,"stream":false,"thinking_budget":128,
|
|
"messages":[{"role":"assistant","content":[{"type":"text","text":"hello"}],"tool_calls":[{"id":"call_1"}]}],
|
|
"response_format":{"type":"json_schema","json_schema":{"strict":false}}
|
|
}`, func(clone *dto.GeneralOpenAIRequest) {
|
|
clone.Messages[0].ToolCalls[0] = '!'
|
|
clone.Messages[0].Content.([]any)[0].(map[string]any)["text"] = "changed"
|
|
clone.ResponseFormat.JsonSchema[0] = '!'
|
|
*clone.MaxTokens = 1
|
|
*clone.Stream = true
|
|
})
|
|
})
|
|
|
|
t.Run("claude dynamic tools and raw fields remain isolated", func(t *testing.T) {
|
|
assertRequestCloneIsolation(t, `{
|
|
"model":"claude-sonnet-4-5","max_tokens":1024,"temperature":0,
|
|
"tools":[{"name":"lookup","input_schema":{"type":"object"}}],
|
|
"metadata":{"user_id":"test"},"messages":[{"role":"user","content":"hello"}]
|
|
}`, func(clone *dto.ClaudeRequest) {
|
|
clone.Metadata[0] = '!'
|
|
clone.Tools.([]any)[0].(map[string]any)["input_schema"].(map[string]any)["type"] = "array"
|
|
clone.Messages[0].Content = "changed"
|
|
*clone.Temperature = 1
|
|
})
|
|
})
|
|
|
|
t.Run("gemini nested parts and configuration remain isolated", func(t *testing.T) {
|
|
assertRequestCloneIsolation(t, `{
|
|
"contents":[{"role":"model","parts":[{"text":"hello","thoughtSignature":"signature","inlineData":{"mimeType":"text/plain","data":"aGk="}}]}],
|
|
"tools":[{"googleSearch":{}}],"generationConfig":{"responseJsonSchema":{"type":"object"}}
|
|
}`, func(clone *dto.GeminiChatRequest) {
|
|
clone.Contents[0].Parts[0].ThoughtSignature[1] = '!'
|
|
clone.Contents[0].Parts[0].InlineData.Data = "changed"
|
|
clone.Tools[0] = '!'
|
|
clone.GenerationConfig.ResponseJsonSchema[0] = '!'
|
|
})
|
|
})
|
|
|
|
t.Run("image extra parameters remain isolated", func(t *testing.T) {
|
|
assertRequestCloneIsolation(t, `{
|
|
"model":"gpt-image-1","prompt":"a cat","n":1,"watermark":false,
|
|
"image":["https://example.com/image.png"],"parameters":{"seed":0}
|
|
}`, func(clone *dto.ImageRequest) {
|
|
clone.Image[0] = '!'
|
|
clone.Extra["parameters"][0] = '!'
|
|
clone.Extra["new_field"] = json.RawMessage(`true`)
|
|
*clone.N = 2
|
|
*clone.Watermark = true
|
|
})
|
|
})
|
|
|
|
t.Run("audio raw parameters and speed remain isolated", func(t *testing.T) {
|
|
assertRequestCloneIsolation(t, `{
|
|
"model":"tts-1","input":"hello","voice":"alloy","speed":1,"metadata":{"tag":"test"},"ref_audio":"sample"
|
|
}`, func(clone *dto.AudioRequest) {
|
|
clone.Metadata[0] = '!'
|
|
clone.RefAudio[1] = '!'
|
|
*clone.Speed = 2
|
|
})
|
|
})
|
|
|
|
t.Run("embedding dynamic input and dimensions remain isolated", func(t *testing.T) {
|
|
assertRequestCloneIsolation(t, `{"model":"text-embedding-3-small","input":[[1,2],[3]],"dimensions":256}`,
|
|
func(clone *dto.EmbeddingRequest) {
|
|
clone.Input.([]any)[0].([]any)[0] = float64(9)
|
|
*clone.Dimensions = 512
|
|
})
|
|
})
|
|
|
|
t.Run("rerank documents and options remain isolated", func(t *testing.T) {
|
|
assertRequestCloneIsolation(t, `{
|
|
"model":"rerank-v3.5","query":"hello","documents":["first","second"],"top_n":1,"return_documents":false
|
|
}`, func(clone *dto.RerankRequest) {
|
|
clone.Documents[0] = "changed"
|
|
*clone.TopN = 2
|
|
*clone.ReturnDocuments = true
|
|
})
|
|
})
|
|
}
|
|
|
|
// A channel attempt may mutate its clone without changing the request used by
|
|
// later attempts. Decode the expected request independently to avoid aliases.
|
|
func assertRequestCloneIsolation[T any](t *testing.T, body string, mutate func(*T)) {
|
|
t.Helper()
|
|
var src, want T
|
|
require.NoError(t, common.UnmarshalJsonStr(body, &src))
|
|
require.NoError(t, common.UnmarshalJsonStr(body, &want))
|
|
clone, err := common.DeepCopy(&src)
|
|
require.NoError(t, err)
|
|
require.Equal(t, &want, clone)
|
|
wantJSON, err := common.Marshal(&want)
|
|
require.NoError(t, err)
|
|
cloneJSON, err := common.Marshal(clone)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, string(wantJSON), string(cloneJSON), "preserve the outbound JSON")
|
|
mutate(clone)
|
|
assert.Equal(t, want, src, "a channel attempt must not mutate the original request")
|
|
retry, err := common.DeepCopy(&src)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, &want, retry)
|
|
}
|
|
|
|
func BenchmarkRequestDeepCopy(b *testing.B) {
|
|
for _, size := range []int{256, 4 << 10, 1 << 20, 10 << 20, 40 << 20} {
|
|
input := bytes.Repeat([]byte("a"), size)
|
|
input[0], input[len(input)-1] = '"', '"'
|
|
src := &dto.OpenAIResponsesRequest{Model: "gpt-4.1", Input: input}
|
|
for _, method := range []struct {
|
|
name string
|
|
copy func(*dto.OpenAIResponsesRequest) (*dto.OpenAIResponsesRequest, error)
|
|
}{
|
|
{"reflect", func(src *dto.OpenAIResponsesRequest) (*dto.OpenAIResponsesRequest, error) {
|
|
var dst dto.OpenAIResponsesRequest
|
|
err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
|
|
return &dst, err
|
|
}},
|
|
{"raw_bytes", common.DeepCopy[dto.OpenAIResponsesRequest]},
|
|
} {
|
|
b.Run(fmt.Sprintf("%dB/%s", size, method.name), func(b *testing.B) {
|
|
b.ReportAllocs()
|
|
b.SetBytes(int64(size))
|
|
for b.Loop() {
|
|
clone, err := method.copy(src)
|
|
require.NoError(b, err)
|
|
require.Len(b, clone.Input, size)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|