Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
330 lines
12 KiB
Go
330 lines
12 KiB
Go
package dto
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestGeneralOpenAIRequestPreserveExplicitZeroValues(t *testing.T) {
|
|
raw := []byte(`{
|
|
"model":"gpt-4.1",
|
|
"stream":false,
|
|
"max_tokens":0,
|
|
"max_completion_tokens":0,
|
|
"top_p":0,
|
|
"top_k":0,
|
|
"n":0,
|
|
"frequency_penalty":0,
|
|
"presence_penalty":0,
|
|
"seed":0,
|
|
"logprobs":false,
|
|
"top_logprobs":0,
|
|
"dimensions":0,
|
|
"return_images":false,
|
|
"return_related_questions":false
|
|
}`)
|
|
|
|
var req GeneralOpenAIRequest
|
|
err := kitutil.Unmarshal(raw, &req)
|
|
require.NoError(t, err)
|
|
|
|
encoded, err := kitutil.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "max_tokens").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "max_completion_tokens").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "top_p").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "top_k").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "n").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "frequency_penalty").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "presence_penalty").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "seed").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "logprobs").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "top_logprobs").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "dimensions").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "return_images").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "return_related_questions").Exists())
|
|
}
|
|
|
|
func TestGeneralOpenAIRequestPreserveQwenThinkingBudget(t *testing.T) {
|
|
raw := []byte(`{
|
|
"model":"qwen-plus",
|
|
"thinking_budget":0
|
|
}`)
|
|
|
|
var req GeneralOpenAIRequest
|
|
err := kitutil.Unmarshal(raw, &req)
|
|
require.NoError(t, err)
|
|
|
|
encoded, err := kitutil.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
value := gjson.GetBytes(encoded, "thinking_budget")
|
|
assert.True(t, value.Exists())
|
|
assert.Equal(t, int64(0), value.Int())
|
|
}
|
|
|
|
func TestGeneralOpenAIRequestPreserveQwQThinkingBudget(t *testing.T) {
|
|
req := GeneralOpenAIRequest{
|
|
Model: "QwQ-32B",
|
|
ThinkingBudget: json.RawMessage(`128`),
|
|
}
|
|
|
|
encoded, err := kitutil.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
value := gjson.GetBytes(encoded, "thinking_budget")
|
|
assert.True(t, value.Exists())
|
|
assert.Equal(t, int64(128), value.Int())
|
|
}
|
|
|
|
func TestGeneralOpenAIRequestDropsThinkingBudgetForNonQwenModel(t *testing.T) {
|
|
req := GeneralOpenAIRequest{
|
|
Model: "gpt-4.1",
|
|
ThinkingBudget: json.RawMessage(`128`),
|
|
}
|
|
|
|
encoded, err := kitutil.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
assert.False(t, gjson.GetBytes(encoded, "thinking_budget").Exists())
|
|
}
|
|
|
|
func TestIsQwenThinkingBudgetModel(t *testing.T) {
|
|
tests := []struct {
|
|
model string
|
|
want bool
|
|
}{
|
|
{model: "qwen-plus", want: true},
|
|
{model: "Qwen/Qwen3-235B-A22B-Thinking-2507", want: true},
|
|
{model: "qwq-32b", want: true},
|
|
{model: "provider/qwen-plus", want: true},
|
|
{model: "provider/qwq-32b", want: true},
|
|
{model: "gpt-4.1", want: false},
|
|
{model: "deepseek-r1", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.model, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, IsQwenThinkingBudgetModel(tt.model))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAIResponsesRequestPreserveExplicitZeroValues(t *testing.T) {
|
|
raw := []byte(`{
|
|
"model":"gpt-4.1",
|
|
"max_output_tokens":0,
|
|
"max_tool_calls":0,
|
|
"stream":false,
|
|
"top_p":0,
|
|
"frequency_penalty":0,
|
|
"presence_penalty":0
|
|
}`)
|
|
|
|
var req OpenAIResponsesRequest
|
|
err := kitutil.Unmarshal(raw, &req)
|
|
require.NoError(t, err)
|
|
|
|
encoded, err := kitutil.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
require.True(t, gjson.GetBytes(encoded, "max_output_tokens").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "max_tool_calls").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "top_p").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "frequency_penalty").Exists())
|
|
require.True(t, gjson.GetBytes(encoded, "presence_penalty").Exists())
|
|
}
|
|
|
|
func TestOpenAIResponsesRequestPreserveQwenThinkingBudget(t *testing.T) {
|
|
req := OpenAIResponsesRequest{
|
|
Model: "qwen-plus",
|
|
ThinkingBudget: json.RawMessage(`0`),
|
|
}
|
|
|
|
encoded, err := kitutil.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
value := gjson.GetBytes(encoded, "thinking_budget")
|
|
assert.True(t, value.Exists())
|
|
assert.Equal(t, int64(0), value.Int())
|
|
}
|
|
|
|
func TestOpenAIResponsesRequestPreserveQwQThinkingBudget(t *testing.T) {
|
|
req := OpenAIResponsesRequest{
|
|
Model: "provider/QwQ-32B",
|
|
ThinkingBudget: json.RawMessage(`128`),
|
|
}
|
|
|
|
encoded, err := kitutil.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
value := gjson.GetBytes(encoded, "thinking_budget")
|
|
assert.True(t, value.Exists())
|
|
assert.Equal(t, int64(128), value.Int())
|
|
}
|
|
|
|
func TestOpenAIResponsesRequestDropsThinkingBudgetForNonQwenModel(t *testing.T) {
|
|
req := OpenAIResponsesRequest{
|
|
Model: "gpt-4.1",
|
|
ThinkingBudget: json.RawMessage(`128`),
|
|
}
|
|
|
|
encoded, err := kitutil.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
assert.False(t, gjson.GetBytes(encoded, "thinking_budget").Exists())
|
|
}
|
|
|
|
func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
want string
|
|
}{
|
|
{name: "o1 uses developer", model: "o1", want: "developer"},
|
|
{name: "o3 family uses developer", model: "o3-mini-high", want: "developer"},
|
|
{name: "o4 family uses developer", model: "o4-mini", want: "developer"},
|
|
{name: "o1 mini stays system", model: "o1-mini", want: "system"},
|
|
{name: "o1 preview stays system", model: "o1-preview", want: "system"},
|
|
{name: "gpt 5 uses developer", model: "gpt-5", want: "developer"},
|
|
{name: "gpt 5.6 uses developer", model: "gpt-5.6-luna", want: "developer"},
|
|
{name: "gpt 6 uses developer", model: "gpt-6-astra", want: "developer"},
|
|
{name: "gpt 6 snapshot uses developer", model: "gpt-6-astra-2026-09-03", want: "developer"},
|
|
{name: "unknown gpt 6 variant stays system", model: "gpt-6-astra-pro", want: "system"},
|
|
{name: "invalid gpt 6 snapshot stays system", model: "gpt-6-astra-2026-99-03", want: "system"},
|
|
{name: "unknown generation stays system", model: "gpt-7", want: "system"},
|
|
{name: "gpt 4.1 stays system", model: "gpt-4.1-nano", want: "system"},
|
|
{name: "omni is not o series", model: "omni-moderation-latest", want: "system"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := GeneralOpenAIRequest{Model: tt.model}
|
|
|
|
assert.Equal(t, tt.want, req.GetSystemRoleName())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsOpenAIGPT5Model(t *testing.T) {
|
|
tests := []struct {
|
|
model string
|
|
want bool
|
|
}{
|
|
{model: "gpt-5", want: true},
|
|
{model: "gpt-5-mini", want: true},
|
|
{model: "gpt-5-chat-latest", want: true},
|
|
{model: "gpt-5.6-luna", want: true},
|
|
{model: "gpt-5.4-nano", want: true},
|
|
{model: "gpt-5.2-2025-12-11", want: true},
|
|
{model: "gpt-6-astra", want: false},
|
|
{model: "gpt-50", want: false},
|
|
{model: "gpt-5custom", want: false},
|
|
{model: " GPT-5 ", want: false},
|
|
{model: "gpt-4.1", want: false},
|
|
{model: "gpt-4.1-nano", want: false},
|
|
{model: "gpt-4o", want: false},
|
|
{model: "gpt-4.5-preview", want: false},
|
|
{model: "gpt-oss-120b", want: false},
|
|
{model: "gpt-image-2", want: false},
|
|
{model: "gpt-realtime-2.1", want: false},
|
|
{model: "chatgpt-4o-latest", want: false},
|
|
{model: "o3-mini", want: false},
|
|
{model: "gpt-", want: false},
|
|
{model: "", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.model, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, IsOpenAIGPT5Model(tt.model))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGeneralOpenAIRequestPreserveMessageLevelTools(t *testing.T) {
|
|
raw := []byte(`{
|
|
"model":"kimi-k3",
|
|
"tool_choice":"required",
|
|
"tools":[{"type":"function","function":{"name":"get_weather","description":"Get the weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}],
|
|
"messages":[
|
|
{"role":"system","content":"You are Kimi."},
|
|
{"role":"user","content":"What time is it in Beijing?"},
|
|
{"role":"system","tools":[{"type":"function","function":{"name":"get_current_time","description":"Get the current time of a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]},
|
|
{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_current_time","arguments":"{\"city\":\"Beijing\"}"}}]},
|
|
{"role":"system","content":"","tools":[{"type":"function","function":{"name":"lookup_order","parameters":{"type":"object"}}}]}
|
|
]
|
|
}`)
|
|
|
|
var req GeneralOpenAIRequest
|
|
require.NoError(t, kitutil.Unmarshal(raw, &req))
|
|
require.Len(t, req.Messages, 5)
|
|
|
|
encoded, err := kitutil.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
messages := gjson.GetBytes(encoded, "messages").Array()
|
|
require.Len(t, messages, 5)
|
|
assert.Equal(t, "required", gjson.GetBytes(encoded, "tool_choice").String())
|
|
assert.JSONEq(t, gjson.GetBytes(raw, "tools").Raw, gjson.GetBytes(encoded, "tools").Raw)
|
|
|
|
// Regular messages keep their content untouched.
|
|
assert.Equal(t, "You are Kimi.", messages[0].Get("content").String())
|
|
assert.False(t, messages[0].Get("tools").Exists())
|
|
assert.Equal(t, "What time is it in Beijing?", messages[1].Get("content").String())
|
|
|
|
// Kimi K3 dynamic tool loading message: tools preserved byte-for-byte, no content key at all.
|
|
assert.JSONEq(t, gjson.GetBytes(raw, "messages.2.tools").Raw, messages[2].Get("tools").Raw)
|
|
assert.False(t, messages[2].Get("content").Exists())
|
|
assert.Equal(t, "system", messages[2].Get("role").String())
|
|
|
|
// Assistant tool call replay still emits an explicit "content": null.
|
|
assistantContent := messages[3].Get("content")
|
|
assert.True(t, assistantContent.Exists())
|
|
assert.Equal(t, gjson.Null, assistantContent.Type)
|
|
assert.JSONEq(t, gjson.GetBytes(raw, "messages.3.tool_calls").Raw, messages[3].Get("tool_calls").Raw)
|
|
|
|
// Explicit empty content next to tools is forwarded as-is for the upstream to judge.
|
|
emptyContent := messages[4].Get("content")
|
|
assert.True(t, emptyContent.Exists())
|
|
assert.Equal(t, gjson.String, emptyContent.Type)
|
|
assert.Equal(t, "", emptyContent.String())
|
|
assert.JSONEq(t, gjson.GetBytes(raw, "messages.4.tools").Raw, messages[4].Get("tools").Raw)
|
|
|
|
// Token estimation sees message-level tools alongside the top-level ones.
|
|
meta := req.GetTokenCountMeta()
|
|
assert.Equal(t, 3, meta.ToolsCount)
|
|
assert.Equal(t, 5, meta.MessagesCount)
|
|
assert.Contains(t, meta.CombineText, "get_weather")
|
|
assert.Contains(t, meta.CombineText, "get_current_time")
|
|
assert.Contains(t, meta.CombineText, "Get the current time of a city")
|
|
assert.Contains(t, meta.CombineText, "lookup_order")
|
|
}
|
|
|
|
func TestSGLangRequestExtensionsRoundTrip(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
raw string
|
|
request any
|
|
}{
|
|
{"chat", `{"model":"served-model","min_tokens":0,"top_k":-1,"min_p":0,"repetition_penalty":1,"separate_reasoning":false,"stream_reasoning":false,"regex":"[0-9]+","ebnf":"root ::= \"yes\"","stop_token_ids":[0],"stop_regex":["END$"],"no_stop_trim":false,"ignore_eos":false,"skip_special_tokens":false,"continue_final_message":false,"cache_salt":"tenant","chat_template_kwargs":{"enable_thinking":false}}`, &GeneralOpenAIRequest{}},
|
|
{"responses", `{"model":"served-model","input":"hi","top_k":-1,"min_p":0,"repetition_penalty":1,"frequency_penalty":0,"presence_penalty":0,"stop":["END"],"cache_salt":"tenant","chat_template_kwargs":{"enable_thinking":false}}`, &OpenAIResponsesRequest{}},
|
|
{"chat omitted", `{"model":"served-model"}`, &GeneralOpenAIRequest{}},
|
|
{"responses omitted", `{"model":"served-model"}`, &OpenAIResponsesRequest{}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
require.NoError(t, kitutil.Unmarshal([]byte(tc.raw), tc.request))
|
|
encoded, err := kitutil.Marshal(tc.request)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, tc.raw, string(encoded))
|
|
})
|
|
}
|
|
}
|