mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 16:46:57 +08:00
313 lines
15 KiB
Go
313 lines
15 KiB
Go
package controller
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/relay/channel/ali"
|
|
"github.com/QuantumNous/new-api/relay/channel/cloudflare"
|
|
"github.com/QuantumNous/new-api/relay/channel/openai"
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
|
"github.com/QuantumNous/new-api/relay/helper"
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/QuantumNous/new-api/relaykit/types"
|
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/samber/lo"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func convertChatCompatibilityRequest(t *testing.T, request *dto.GeneralOpenAIRequest, channelType int, mapping map[string]string) []byte {
|
|
t.Helper()
|
|
oldMode := gin.Mode()
|
|
gin.SetMode(gin.TestMode)
|
|
t.Cleanup(func() { gin.SetMode(oldMode) })
|
|
settings := model_setting.GetGlobalSettings()
|
|
oldPassThrough, oldBlacklist := settings.PassThroughRequestEnabled, settings.ThinkingModelBlacklist
|
|
oldEffortTailModels := settings.EffortTailModelIDs
|
|
settings.PassThroughRequestEnabled = false
|
|
settings.ThinkingModelBlacklist = nil
|
|
settings.EffortTailModelIDs = []string{"gpt-5.1-codex-max"}
|
|
t.Cleanup(func() {
|
|
settings.PassThroughRequestEnabled = oldPassThrough
|
|
settings.ThinkingModelBlacklist = oldBlacklist
|
|
settings.EffortTailModelIDs = oldEffortTailModels
|
|
})
|
|
|
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
|
c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
|
|
if mapping != nil {
|
|
encoded, err := common.Marshal(mapping)
|
|
require.NoError(t, err)
|
|
c.Set("model_mapping", string(encoded))
|
|
}
|
|
info := &relaycommon.RelayInfo{
|
|
OriginModelName: request.Model,
|
|
Request: request,
|
|
RelayFormat: types.RelayFormatOpenAI,
|
|
IsStream: request.IsStream(nil),
|
|
ChannelMeta: &relaycommon.ChannelMeta{
|
|
ChannelType: channelType,
|
|
UpstreamModelName: request.Model,
|
|
SupportStreamOptions: true,
|
|
},
|
|
}
|
|
require.NoError(t, helper.ModelMappedHelper(c, info, request))
|
|
require.NoError(t, helper.ApplyReasoningModelSuffix(c, info, request))
|
|
var converted any
|
|
var err error
|
|
if channelType == constant.ChannelTypeAli {
|
|
converted, err = (&ali.Adaptor{}).ConvertOpenAIRequest(c, info, request)
|
|
} else {
|
|
converted, err = (&openai.Adaptor{}).ConvertOpenAIRequest(c, info, request)
|
|
}
|
|
require.NoError(t, err)
|
|
encoded, err := common.Marshal(converted)
|
|
require.NoError(t, err)
|
|
return encoded
|
|
}
|
|
|
|
func TestChannelTestOpenAIChatCompatibility(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
model string
|
|
upstream string
|
|
endpoint string
|
|
channelType int
|
|
stream bool
|
|
wantLimit string
|
|
}{
|
|
{name: "GPT6 automatic", model: "gpt-6-astra", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
|
|
{name: "GPT6 explicit Azure stream", model: "gpt-6-astra", upstream: "gpt-6-astra", endpoint: string(constant.EndpointTypeOpenAI), channelType: constant.ChannelTypeAzure, stream: true, wantLimit: "max_completion_tokens"},
|
|
{name: "GPT6 sol", model: "gpt-6-sol", upstream: "gpt-6-sol", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
|
|
{name: "GPT6 luna", model: "gpt-6-luna", upstream: "gpt-6-luna", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
|
|
{name: "GPT6.1 sol", model: "gpt-6.1-sol", upstream: "gpt-6.1-sol", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
|
|
{name: "GPT6.1 terra", model: "gpt-6.1-terra", upstream: "gpt-6.1-terra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
|
|
{name: "alias maps to GPT6", model: "customer-model", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
|
|
{name: "GPT5 alias maps to Qwen", model: "gpt-5.6-luna", upstream: "qwen-turbo", channelType: constant.ChannelTypeAli, wantLimit: "max_tokens"},
|
|
{name: "GPT5 stream", model: "gpt-5.6-luna", upstream: "gpt-5.6-luna", channelType: constant.ChannelTypeOpenAI, stream: true, wantLimit: "max_completion_tokens"},
|
|
{name: "GPT4 explicit", model: "gpt-4.1", upstream: "gpt-4.1", endpoint: string(constant.EndpointTypeOpenAI), channelType: constant.ChannelTypeOpenAI, wantLimit: "max_tokens"},
|
|
{name: "o series", model: "o3-mini", upstream: "o3-mini", channelType: constant.ChannelTypeAzure, wantLimit: "max_completion_tokens"},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
request, ok := buildTestRequest(tt.model, tt.endpoint, &model.Channel{}, tt.stream).(*dto.GeneralOpenAIRequest)
|
|
require.True(t, ok)
|
|
encoded := convertChatCompatibilityRequest(t, request, tt.channelType, map[string]string{tt.model: tt.upstream})
|
|
want := map[string]any{
|
|
"model": tt.upstream,
|
|
"messages": []dto.Message{{Role: "user", Content: "hi"}},
|
|
"stream": tt.stream,
|
|
tt.wantLimit: 16,
|
|
}
|
|
if tt.stream {
|
|
want["stream_options"] = map[string]any{"include_usage": true}
|
|
}
|
|
wantJSON, err := common.Marshal(want)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, string(wantJSON), string(encoded))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAIChatSamplingCompatibility(t *testing.T) {
|
|
const sampling = `{"temperature":0.2,"top_p":0.8,"logprobs":true,"top_logprobs":5}`
|
|
for _, tt := range []struct {
|
|
name string
|
|
model string
|
|
effort string
|
|
reasoning string
|
|
mapping map[string]string
|
|
zeroValues bool
|
|
wantModel string
|
|
wantEffort string
|
|
wantRole string
|
|
wantParams string
|
|
}{
|
|
{name: "GPT5.1 explicit none", model: "gpt-5.1", effort: "none", wantEffort: "none", wantRole: "developer", wantParams: sampling},
|
|
{name: "GPT5.2 default none", model: "gpt-5.2", wantRole: "developer", wantParams: sampling},
|
|
{name: "GPT5.2 dated snapshot", model: "gpt-5.2-2025-12-11", wantRole: "developer", wantParams: sampling},
|
|
{name: "GPT5.4 reasoning", model: "gpt-5.4", effort: "high", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
|
|
{name: "GPT5.4 snapshot none", model: "gpt-5.4-2026-03-05", effort: "none", wantEffort: "none", wantRole: "developer", wantParams: sampling},
|
|
{name: "explicit zero values", model: "gpt-5.4", effort: "none", zeroValues: true, wantEffort: "none", wantRole: "developer", wantParams: `{"temperature":0,"top_p":0,"logprobs":false}`},
|
|
{name: "GPT5 original", model: "gpt-5", wantRole: "developer", wantParams: `{}`},
|
|
{name: "GPT5.6 existing policy", model: "gpt-5.6-luna", wantRole: "developer", wantParams: `{}`},
|
|
{name: "pro variant", model: "gpt-5.2-pro-2025-12-11", wantRole: "developer", wantParams: `{}`},
|
|
{name: "chat variant", model: "gpt-5.2-chat-latest", wantRole: "developer", wantParams: `{}`},
|
|
{name: "codex model name keeps max", model: "gpt-5.1-codex-max", wantRole: "developer", wantParams: `{}`},
|
|
{name: "GPT6", model: "gpt-6-astra", wantRole: "developer", wantParams: `{}`},
|
|
{name: "GPT6 snapshot", model: "gpt-6-astra-2026-09-03", wantRole: "developer", wantParams: `{}`},
|
|
{name: "GPT6 effort suffix", model: "gpt-6-astra-high", wantModel: "gpt-6-astra", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
|
|
{name: "GPT6 sol none keeps sampling", model: "gpt-6-sol", wantRole: "developer", wantParams: sampling},
|
|
{name: "GPT6 luna effort drops sampling", model: "gpt-6-luna-high", wantModel: "gpt-6-luna", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
|
|
{name: "GPT6.1 sol drops sampling", model: "gpt-6.1-sol", wantRole: "developer", wantParams: `{}`},
|
|
{name: "GPT6.1 terra default drops sampling", model: "gpt-6.1-terra", wantRole: "developer", wantParams: `{}`},
|
|
{name: "GPT6.1 terra none keeps sampling", model: "gpt-6.1-terra", effort: "none", wantEffort: "none", wantRole: "developer", wantParams: sampling},
|
|
{name: "none effort suffix", model: "gpt-5.2-none", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling},
|
|
{name: "modifier overrides explicit effort", model: "gpt-5.2@thinking:off", effort: "high", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling},
|
|
{name: "mapped modifier wins", model: "customer-model@thinking:off", mapping: map[string]string{"customer-model": "gpt-5.2@effort:high"}, wantModel: "gpt-5.2", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
|
|
{name: "nested reasoning disabled", model: "gpt-5.2", reasoning: `{"enabled":false}`, wantEffort: "none", wantRole: "developer", wantParams: sampling},
|
|
{name: "o1 mini role exception", model: "o1-mini", wantRole: "system", wantParams: `{"top_p":0.8,"logprobs":true,"top_logprobs":5}`},
|
|
{name: "GPT4 unchanged", model: "gpt-4.1", wantRole: "system", wantParams: sampling},
|
|
{name: "future model unchanged", model: "gpt-7", wantRole: "system", wantParams: sampling},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
request := &dto.GeneralOpenAIRequest{
|
|
Model: tt.model,
|
|
Messages: []dto.Message{
|
|
{Role: "system", Content: "first instruction"},
|
|
{Role: "system", Content: "second instruction"},
|
|
{Role: "user", Content: "hi"},
|
|
},
|
|
ReasoningEffort: tt.effort,
|
|
}
|
|
require.NoError(t, common.UnmarshalJsonStr(sampling, request))
|
|
if tt.reasoning != "" {
|
|
request.Reasoning = []byte(tt.reasoning)
|
|
}
|
|
if tt.zeroValues {
|
|
request.Temperature = lo.ToPtr(0.0)
|
|
request.TopP = lo.ToPtr(0.0)
|
|
request.LogProbs = lo.ToPtr(false)
|
|
request.TopLogProbs = nil
|
|
}
|
|
encoded := convertChatCompatibilityRequest(t, request, constant.ChannelTypeOpenAI, tt.mapping)
|
|
var want map[string]any
|
|
require.NoError(t, common.UnmarshalJsonStr(tt.wantParams, &want))
|
|
want["model"] = tt.model
|
|
if tt.wantModel != "" {
|
|
want["model"] = tt.wantModel
|
|
}
|
|
want["messages"] = []dto.Message{
|
|
{Role: tt.wantRole, Content: "first instruction"},
|
|
{Role: "system", Content: "second instruction"},
|
|
{Role: "user", Content: "hi"},
|
|
}
|
|
if tt.wantEffort != "" {
|
|
want["reasoning_effort"] = tt.wantEffort
|
|
}
|
|
wantJSON, err := common.Marshal(want)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, string(wantJSON), string(encoded))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAIChatTokenLimitCompatibility(t *testing.T) {
|
|
for _, modelName := range []string{"gpt-5", "o3-mini", "gpt-6-astra"} {
|
|
for _, tt := range []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{name: "omitted", input: `{}`, want: `{}`},
|
|
{name: "legacy only", input: `{"max_tokens":100}`, want: `{"max_completion_tokens":100}`},
|
|
{name: "completion only", input: `{"max_completion_tokens":50}`, want: `{"max_completion_tokens":50}`},
|
|
{name: "both positive stay present", input: `{"max_tokens":100,"max_completion_tokens":50}`, want: `{"max_tokens":100,"max_completion_tokens":50}`},
|
|
{name: "zero completion falls back", input: `{"max_tokens":100,"max_completion_tokens":0}`, want: `{"max_completion_tokens":100}`},
|
|
{name: "legacy zero stays present", input: `{"max_tokens":0}`, want: `{"max_tokens":0}`},
|
|
{name: "completion zero stays present", input: `{"max_completion_tokens":0}`, want: `{"max_completion_tokens":0}`},
|
|
{name: "both zero stay present", input: `{"max_tokens":0,"max_completion_tokens":0}`, want: `{"max_tokens":0,"max_completion_tokens":0}`},
|
|
} {
|
|
t.Run(modelName+"/"+tt.name, func(t *testing.T) {
|
|
request := &dto.GeneralOpenAIRequest{Model: modelName, Messages: []dto.Message{{Role: "user", Content: "hi"}}}
|
|
require.NoError(t, common.UnmarshalJsonStr(tt.input, request))
|
|
encoded := convertChatCompatibilityRequest(t, request, constant.ChannelTypeOpenAI, nil)
|
|
want := dto.GeneralOpenAIRequest{Model: modelName, Messages: []dto.Message{{Role: "user", Content: "hi"}}}
|
|
require.NoError(t, common.UnmarshalJsonStr(tt.want, &want))
|
|
wantJSON, err := common.Marshal(want)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, string(wantJSON), string(encoded))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDirectOpenAIResponsesKeepsExistingParameters(t *testing.T) {
|
|
const body = `{"model":"gpt-6-astra","input":"hi","max_output_tokens":100,"temperature":0.2,"top_p":0.8,"top_logprobs":5,"include":["message.output_text.logprobs"],"reasoning":{"effort":"high"}}`
|
|
var request dto.OpenAIResponsesRequest
|
|
require.NoError(t, common.UnmarshalJsonStr(body, &request))
|
|
info := &relaycommon.RelayInfo{
|
|
OriginModelName: "gpt-6-astra",
|
|
RelayFormat: types.RelayFormatOpenAIResponses,
|
|
ChannelMeta: &relaycommon.ChannelMeta{
|
|
ChannelType: constant.ChannelTypeOpenAI,
|
|
UpstreamModelName: "gpt-6-astra",
|
|
},
|
|
}
|
|
converted, err := (&openai.Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, request)
|
|
require.NoError(t, err)
|
|
encoded, err := common.Marshal(converted)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, body, string(encoded))
|
|
}
|
|
|
|
func TestCloudflareEmbeddingResponseCompatibility(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
forceFormat bool
|
|
wantTokens int
|
|
}{
|
|
{
|
|
name: "single embedding preserves upstream usage",
|
|
body: `{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,-0.2]}],"model":"@cf/baai/bge-large-en-v1.5","usage":{"prompt_tokens":8,"total_tokens":8}}`,
|
|
wantTokens: 8,
|
|
},
|
|
{
|
|
name: "batch embeddings survive force format",
|
|
body: `{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,-0.2]},{"object":"embedding","index":1,"embedding":[0.3,0.4]}],"model":"@cf/baai/bge-large-en-v1.5","usage":{"prompt_tokens":16,"total_tokens":16},"extra":"preserved"}`,
|
|
forceFormat: true,
|
|
wantTokens: 16,
|
|
},
|
|
{
|
|
name: "base64 embedding is forwarded unchanged",
|
|
body: `{"object":"list","data":[{"object":"embedding","index":0,"embedding":"zczMPc3MTL4="}],"model":"@cf/baai/bge-large-en-v1.5","usage":{"prompt_tokens":5,"total_tokens":5}}`,
|
|
wantTokens: 5,
|
|
},
|
|
{
|
|
name: "missing usage falls back to input estimate",
|
|
body: `{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,-0.2]}],"model":"@cf/baai/bge-large-en-v1.5"}`,
|
|
forceFormat: true,
|
|
wantTokens: 99,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/embeddings", nil)
|
|
info := &relaycommon.RelayInfo{
|
|
RelayMode: relayconstant.RelayModeEmbeddings,
|
|
RelayFormat: types.RelayFormatEmbedding,
|
|
ChannelMeta: &relaycommon.ChannelMeta{
|
|
ChannelType: constant.ChannelCloudflare,
|
|
UpstreamModelName: "@cf/baai/bge-large-en-v1.5",
|
|
ChannelSetting: dto.ChannelSettings{ForceFormat: tt.forceFormat},
|
|
},
|
|
}
|
|
info.SetEstimatePromptTokens(99)
|
|
resp := &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(tt.body)),
|
|
}
|
|
|
|
usage, apiErr := (&cloudflare.Adaptor{}).DoResponse(c, resp, info)
|
|
require.Nil(t, apiErr)
|
|
require.IsType(t, &dto.Usage{}, usage)
|
|
assert.Equal(t, &dto.Usage{PromptTokens: tt.wantTokens, TotalTokens: tt.wantTokens}, usage)
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
|
assert.JSONEq(t, tt.body, w.Body.String())
|
|
})
|
|
}
|
|
}
|