Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}

addUsedChannel(c, channel.Id)
if billingErr := service.PrepareTieredBillingForSelectedGroup(c, relayInfo); billingErr != nil {
maxAPIError = billingErr
break
}
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
// Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path)
Expand Down Expand Up @@ -567,6 +571,10 @@ func RelayTask(c *gin.Context) {
}

addUsedChannel(c, channel.Id)
if billingErr := service.PrepareTieredBillingForSelectedGroup(c, relayInfo); billingErr != nil {
taskErr = service.TaskErrorWrapperLocal(billingErr.Err, "prepare_tiered_billing_failed", billingErr.StatusCode)
break
}
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) {
Expand Down
26 changes: 26 additions & 0 deletions dto/openai_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,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
Expand All @@ -108,6 +109,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 common.Marshal((*Alias)(&r))
}

func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
var tokenCountMeta types.TokenCountMeta
var texts = make([]string, 0)
Expand Down Expand Up @@ -223,6 +232,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") {
Expand Down Expand Up @@ -882,10 +899,19 @@ type OpenAIResponsesRequest struct {
Prompt json.RawMessage `json:"prompt,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 common.Marshal((*Alias)(&r))
}

func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
var fileMeta = make([]*types.FileMeta, 0)
var texts = make([]string, 0)
Expand Down
76 changes: 76 additions & 0 deletions dto/openai_request_zero_value_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dto

import (
"encoding/json"
"testing"

"github.com/MAX-API-Next/MAX-API/common"
Expand Down Expand Up @@ -50,6 +51,55 @@ 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 := common.Unmarshal(raw, &req)
require.NoError(t, err)

encoded, err := common.Marshal(req)
require.NoError(t, err)

value := gjson.GetBytes(encoded, "thinking_budget")
require.True(t, value.Exists())
require.Equal(t, int64(0), value.Int())
}

func TestGeneralOpenAIRequestDropsThinkingBudgetForNonQwenModel(t *testing.T) {
req := GeneralOpenAIRequest{
Model: "gpt-4.1",
ThinkingBudget: json.RawMessage(`128`),
}

encoded, err := common.Marshal(req)
require.NoError(t, err)

require.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: "QWQ-32B", want: true},
{model: "accounts/fireworks/models/qwen3-235b-a22b", want: true},
{model: "deepseek-r1", want: false},
{model: "gpt-4.1", want: false},
}

for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
require.Equal(t, tt.want, IsQwenThinkingBudgetModel(tt.model))
})
}
}

func TestResolveMaxTokens(t *testing.T) {
legacy := uint(64)
completion := uint(128)
Expand Down Expand Up @@ -116,6 +166,32 @@ 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 := common.Marshal(req)
require.NoError(t, err)

value := gjson.GetBytes(encoded, "thinking_budget")
require.True(t, value.Exists())
require.Equal(t, int64(0), value.Int())
}

func TestOpenAIResponsesRequestDropsThinkingBudgetForNonQwenModel(t *testing.T) {
req := OpenAIResponsesRequest{
Model: "gpt-4.1",
ThinkingBudget: json.RawMessage(`128`),
}

encoded, err := common.Marshal(req)
require.NoError(t, err)

require.False(t, gjson.GetBytes(encoded, "thinking_budget").Exists())
}

func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) {
tests := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion model/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ func formatUserLogOther(other string, stripAuditContent bool) string {
delete(otherMap, "admin_info")
delete(otherMap, "audit_info")
// delete(otherMap, "reject_reason")
delete(otherMap, "stream_status")
// Keep stream_status visible so users can diagnose interrupted streams.
if auditInfo != nil {
if stripAuditContent {
stripAuditContentFields(auditInfo)
Expand Down
4 changes: 3 additions & 1 deletion model/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1093,7 +1093,9 @@ func TestFormatUserLogDetailExposesOnlyUserAuditContent(t *testing.T) {
require.Equal(t, 42, log.Id)
require.Empty(t, log.ChannelName)
require.NotContains(t, other, "admin_info")
require.NotContains(t, other, "stream_status")
streamStatus, ok := other["stream_status"].(map[string]interface{})
require.True(t, ok)
require.Equal(t, "error", streamStatus["status"])

auditInfo, ok := other["audit_info"].(map[string]interface{})
require.True(t, ok)
Expand Down
32 changes: 29 additions & 3 deletions relay/channel/deepseek/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode {
case constant.RelayModeCompletions:
return fmt.Sprintf("%s/completions", fimBaseUrl), nil
case constant.RelayModeResponses:
return fmt.Sprintf("%s/responses", info.ChannelBaseUrl), nil
default:
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
}
Expand Down Expand Up @@ -158,9 +160,33 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
return nil, errors.New("not implemented")
}

func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// TODO implement me
return nil, errors.New("not implemented")
func (a *Adaptor) ConvertOpenAIResponsesRequest(_ *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
applyDeepSeekV4ResponsesThinkingSuffix(info, &request)
return request, nil
}

func applyDeepSeekV4ResponsesThinkingSuffix(info *relaycommon.RelayInfo, request *dto.OpenAIResponsesRequest) {
modelName := request.Model
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
if ok {
if thinkingType == "disabled" {
effort = "none"
}
request.Model = baseModel
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{}
}
request.Reasoning.Effort = effort
if info != nil && info.ChannelMeta != nil {
info.UpstreamModelName = baseModel
}
}
if info != nil && request.Reasoning != nil {
info.ReasoningEffort = request.Reasoning.Effort
}
}

func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
Expand Down
53 changes: 53 additions & 0 deletions relay/channel/deepseek/adaptor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package deepseek

import (
"testing"

"github.com/MAX-API-Next/MAX-API/dto"
relaycommon "github.com/MAX-API-Next/MAX-API/relay/common"
"github.com/MAX-API-Next/MAX-API/relay/constant"
"github.com/stretchr/testify/require"
)

func TestGetRequestURLSupportsResponses(t *testing.T) {
adaptor := &Adaptor{}
url, err := adaptor.GetRequestURL(&relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
ChannelBaseUrl: "https://api.deepseek.com",
},
RelayMode: constant.RelayModeResponses,
})

require.NoError(t, err)
require.Equal(t, "https://api.deepseek.com/responses", url)
}

func TestConvertOpenAIResponsesRequestAppliesDeepSeekV4ThinkingSuffix(t *testing.T) {
info := &relaycommon.RelayInfo{}
converted, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, dto.OpenAIResponsesRequest{
Model: "deepseek-v4-pro-none",
})

require.NoError(t, err)
request, ok := converted.(dto.OpenAIResponsesRequest)
require.True(t, ok)
require.Equal(t, "deepseek-v4-pro", request.Model)
require.NotNil(t, request.Reasoning)
require.Equal(t, "none", request.Reasoning.Effort)
require.Equal(t, "none", info.ReasoningEffort)
}

func TestConvertOpenAIResponsesRequestKeepsPlainModel(t *testing.T) {
info := &relaycommon.RelayInfo{}
converted, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, dto.OpenAIResponsesRequest{
Model: "deepseek-chat",
Reasoning: &dto.Reasoning{Effort: "low"},
})

require.NoError(t, err)
request, ok := converted.(dto.OpenAIResponsesRequest)
require.True(t, ok)
require.Equal(t, "deepseek-chat", request.Model)
require.Equal(t, "low", request.Reasoning.Effort)
require.Equal(t, "low", info.ReasoningEffort)
}
2 changes: 2 additions & 0 deletions service/openaicompat/chat_to_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions service/openaicompat/responses_compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,32 @@ func TestResponsesRequestToChatCompletionsRequestInstructionsScalarInputAndZeroT
assert.Equal(t, "medium", got.ReasoningEffort)
}

func TestChatCompletionsRequestToResponsesRequestPreservesQwenThinkingBudget(t *testing.T) {
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "qwen-plus",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
EnableThinking: mustCompatRawMessage(t, false),
ThinkingBudget: mustCompatRawMessage(t, 0),
})
require.NoError(t, err)

assert.Equal(t, "false", string(got.EnableThinking))
assert.Equal(t, "0", string(got.ThinkingBudget))
}

func TestResponsesRequestToChatCompletionsRequestPreservesQwenThinkingBudget(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "qwen-plus",
Input: mustCompatRawMessage(t, "hello"),
EnableThinking: mustCompatRawMessage(t, true),
ThinkingBudget: mustCompatRawMessage(t, 0),
})
require.NoError(t, err)

assert.Equal(t, "true", string(got.EnableThinking))
assert.Equal(t, "0", string(got.ThinkingBudget))
}

func TestResponsesRequestToChatCompletionsRequestFunctionCallConversation(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Expand Down
1 change: 1 addition & 0 deletions service/openaicompat/responses_request_to_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (
SafetyIdentifier: req.SafetyIdentifier,
PromptCacheRetention: req.PromptCacheRetention,
EnableThinking: req.EnableThinking,
ThinkingBudget: req.ThinkingBudget,
}
if req.Reasoning != nil {
out.ReasoningEffort = req.Reasoning.Effort
Expand Down
Loading
Loading