fix: preserve Qwen thinking_budget passthrough (#5836)

* fix: preserve qwen thinking budget

* test: address qwen thinking budget review comments

* chore: remove unreachable adaptor code

* test: cover zero Qwen thinking budgets
This commit is contained in:
Scott
2026-07-29 17:45:14 +08:00
committed by GitHub
parent cb4c8c02f8
commit 66ee6b8f98
20 changed files with 345 additions and 14 deletions
+26
View File
@@ -89,6 +89,7 @@ type GeneralOpenAIRequest struct {
// Ali Qwen Params
VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`
EnableThinking json.RawMessage `json:"enable_thinking,omitempty"`
ThinkingBudget json.RawMessage `json:"thinking_budget,omitempty"`
ChatTemplateKwargs json.RawMessage `json:"chat_template_kwargs,omitempty"`
EnableSearch json.RawMessage `json:"enable_search,omitempty"`
// ollama Params
@@ -107,6 +108,14 @@ type GeneralOpenAIRequest struct {
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
}
func (r GeneralOpenAIRequest) MarshalJSON() ([]byte, error) {
type Alias GeneralOpenAIRequest
if !IsQwenThinkingBudgetModel(r.Model) {
r.ThinkingBudget = nil
}
return kitutil.Marshal((*Alias)(&r))
}
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
var tokenCountMeta types.TokenCountMeta
var texts = make([]string, 0)
@@ -222,6 +231,14 @@ func IsOpenAIGPT5Model(modelName string) bool {
return strings.HasPrefix(modelName, "gpt-5")
}
func IsQwenThinkingBudgetModel(modelName string) bool {
normalized := strings.ToLower(strings.TrimSpace(modelName))
return strings.HasPrefix(normalized, "qwen") ||
strings.Contains(normalized, "/qwen") ||
strings.HasPrefix(normalized, "qwq") ||
strings.Contains(normalized, "/qwq")
}
func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
if IsOpenAIReasoningOModel(r.Model) {
if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
@@ -880,10 +897,19 @@ type OpenAIResponsesRequest struct {
ClientMetadata json.RawMessage `json:"client_metadata,omitempty"`
// qwen
EnableThinking json.RawMessage `json:"enable_thinking,omitempty"`
ThinkingBudget json.RawMessage `json:"thinking_budget,omitempty"`
// perplexity
Preset json.RawMessage `json:"preset,omitempty"`
}
func (r OpenAIResponsesRequest) MarshalJSON() ([]byte, error) {
type Alias OpenAIResponsesRequest
if !IsQwenThinkingBudgetModel(r.Model) {
r.ThinkingBudget = nil
}
return kitutil.Marshal((*Alias)(&r))
}
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
var fileMeta = make([]*types.FileMeta, 0)
var texts = make([]string, 0)
@@ -1,9 +1,11 @@
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"
)
@@ -50,6 +52,71 @@ func TestGeneralOpenAIRequestPreserveExplicitZeroValues(t *testing.T) {
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",
@@ -72,6 +139,46 @@ func TestOpenAIResponsesRequestPreserveExplicitZeroValues(t *testing.T) {
require.True(t, gjson.GetBytes(encoded, "top_p").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
@@ -386,6 +386,8 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
ParallelToolCalls: parallelToolCallsRaw,
Store: req.Store,
Metadata: req.Metadata,
EnableThinking: req.EnableThinking,
ThinkingBudget: req.ThinkingBudget,
}
if req.MaxTokens != nil || req.MaxCompletionTokens != nil {
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
@@ -1,9 +1,11 @@
package oaichat
import (
"encoding/json"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -37,6 +39,42 @@ func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing
assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "3.type").String())
}
func TestChatCompletionsRequestToResponsesRequestPreservesQwenThinkingBudget(t *testing.T) {
tests := []struct {
name string
budget json.RawMessage
want int64
}{
{name: "positive budget", budget: json.RawMessage(`128`), want: 128},
{name: "zero budget", budget: json.RawMessage(`0`), want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &dto.GeneralOpenAIRequest{
Model: "qwen-plus",
EnableThinking: json.RawMessage(`true`),
ThinkingBudget: tt.budget,
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
}
got, err := ChatCompletionsRequestToResponsesRequest(req)
require.NoError(t, err)
assert.Equal(t, tt.budget, got.ThinkingBudget)
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.True(t, gjson.GetBytes(encoded, "enable_thinking").Bool())
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, tt.want, value.Int())
})
}
}
func TestChatCompletionsRequestToResponsesRequestRejectsMultipleChoices(t *testing.T) {
_, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
@@ -73,6 +73,7 @@ func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (
SafetyIdentifier: req.SafetyIdentifier,
PromptCacheRetention: req.PromptCacheRetention,
EnableThinking: req.EnableThinking,
ThinkingBudget: req.ThinkingBudget,
}
if req.Reasoning != nil {
@@ -1,6 +1,7 @@
package oairesponses
import (
"encoding/json"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -55,6 +56,38 @@ func TestResponsesRequestToChatCompletionsRequestInstructionsAndScalarInput(t *t
assert.Equal(t, "abc", gjson.GetBytes(got.Metadata, "trace").String())
}
func TestResponsesRequestToChatCompletionsRequestPreservesQwenThinkingBudget(t *testing.T) {
tests := []struct {
name string
budget json.RawMessage
want int64
}{
{name: "positive budget", budget: json.RawMessage(`128`), want: 128},
{name: "zero budget", budget: json.RawMessage(`0`), want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "qwen-plus",
Input: mustRawMessage(t, "hello"),
EnableThinking: json.RawMessage(`true`),
ThinkingBudget: tt.budget,
})
require.NoError(t, err)
assert.Equal(t, tt.budget, got.ThinkingBudget)
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.True(t, gjson.GetBytes(encoded, "enable_thinking").Bool())
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, tt.want, value.Int())
})
}
}
func TestResponsesRequestToChatCompletionsRequestMultimodalInput(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",