diff --git a/controller/channel_test_astra_request_test.go b/controller/channel_test_astra_request_test.go new file mode 100644 index 00000000..268a2f4d --- /dev/null +++ b/controller/channel_test_astra_request_test.go @@ -0,0 +1,172 @@ +package controller + +import ( + "net/http/httptest" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/relay/channel/openai" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/setting/model_setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// Exercise model mapping and outbound serialization, including the request +// generated by the console's channel test, without calling a paid provider. +func astraOutboundChat(t *testing.T, req *dto.GeneralOpenAIRequest, upstream string, channelType int) ([]byte, *relaycommon.RelayInfo) { + t.Helper() + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext(t.Context(), "POST", "/v1/chat/completions", nil) + if upstream != req.Model { + mapping, err := common.Marshal(map[string]string{req.Model: upstream}) + require.NoError(t, err) + c.Set("model_mapping", string(mapping)) + } + info := &relaycommon.RelayInfo{ + OriginModelName: req.Model, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: channelType, UpstreamModelName: req.Model, + }, + } + require.NoError(t, helper.ModelMappedHelper(c, info, req)) + converted, err := (&openai.Adaptor{}).ConvertOpenAIRequest(c, info, req) + require.NoError(t, err) + encoded, err := common.Marshal(converted) + require.NoError(t, err) + return encoded, info +} + +func TestAstraChannelTestRequest(t *testing.T) { + for _, channelType := range []int{constant.ChannelTypeOpenAI, constant.ChannelTypeAzure} { + for _, stream := range []bool{false, true} { + for _, name := range []string{"gpt-6-astra", "astra-alias"} { + req, ok := buildTestRequest(name, string(constant.EndpointTypeOpenAI), &model.Channel{}, stream).(*dto.GeneralOpenAIRequest) + require.True(t, ok) + body, _ := astraOutboundChat(t, req, "gpt-6-astra", channelType) + require.False(t, gjson.GetBytes(body, "max_tokens").Exists()) + require.Equal(t, int64(16), gjson.GetBytes(body, "max_completion_tokens").Int()) + require.Equal(t, stream, gjson.GetBytes(body, "stream").Bool()) + if stream { + require.True(t, gjson.GetBytes(body, "stream_options.include_usage").Bool()) + } + } + } + } +} + +func TestAstraTokenLimitPresence(t *testing.T) { + for _, tt := range []struct{ name, input, want string }{ + {"omitted", `{}`, `{}`}, + {"legacy", `{"max_tokens":100}`, `{"max_completion_tokens":100}`}, + {"completion", `{"max_completion_tokens":50}`, `{"max_completion_tokens":50}`}, + {"both", `{"max_tokens":100,"max_completion_tokens":50}`, `{"max_completion_tokens":50}`}, + {"legacy zero", `{"max_tokens":0}`, `{"max_completion_tokens":0}`}, + {"completion zero", `{"max_completion_tokens":0}`, `{"max_completion_tokens":0}`}, + {"explicit zero wins", `{"max_tokens":100,"max_completion_tokens":0}`, `{"max_completion_tokens":0}`}, + {"both zero", `{"max_tokens":0,"max_completion_tokens":0}`, `{"max_completion_tokens":0}`}, + } { + t.Run(tt.name, func(t *testing.T) { + req := &dto.GeneralOpenAIRequest{Model: "gpt-6-astra"} + require.NoError(t, common.UnmarshalJsonStr(tt.input, req)) + body, _ := astraOutboundChat(t, req, req.Model, constant.ChannelTypeOpenAI) + want := &dto.GeneralOpenAIRequest{Model: req.Model} + require.NoError(t, common.UnmarshalJsonStr(tt.want, want)) + wantJSON, err := common.Marshal(want) + require.NoError(t, err) + require.JSONEq(t, string(wantJSON), string(body)) + }) + } +} + +func TestAstraModelAndSamplingCompatibility(t *testing.T) { + for _, tt := range []struct { + name, source, target, wire, effort string + astra bool + }{ + {"base", "gpt-6-astra", "gpt-6-astra", "gpt-6-astra", "", true}, + {"snapshot", "gpt-6-astra-2026-09-03", "gpt-6-astra-2026-09-03", "gpt-6-astra-2026-09-03", "", true}, + {"effort alias", "gpt-6-astra-high", "gpt-6-astra-high", "gpt-6-astra", "high", true}, + {"max effort alias", "gpt-6-astra-max", "gpt-6-astra-max", "gpt-6-astra", "max", true}, + {"mapped snapshot effort", "alias", "gpt-6-astra-2026-09-03-xhigh", "gpt-6-astra-2026-09-03", "xhigh", true}, + {"unknown variant", "gpt-6-astra-pro", "gpt-6-astra-pro", "gpt-6-astra-pro", "", false}, + {"invalid date", "gpt-6-astra-2026-02-30", "gpt-6-astra-2026-02-30", "gpt-6-astra-2026-02-30", "", false}, + {"unknown effort variant", "gpt-6-astra-pro-high", "gpt-6-astra-pro-high", "gpt-6-astra-pro-high", "", false}, + {"other gpt6", "gpt-6-other", "gpt-6-other", "gpt-6-other", "", false}, + {"mapped away", "gpt-6-astra", "gpt-4.1", "gpt-4.1", "", false}, + } { + t.Run(tt.name, func(t *testing.T) { + req := &dto.GeneralOpenAIRequest{Model: tt.source, Messages: []dto.Message{{Role: "system", Content: "Be concise"}, {Role: "user", Content: "hi"}}} + require.NoError(t, common.UnmarshalJsonStr(`{"temperature":0,"top_p":0,"logprobs":false,"top_logprobs":0,"seed":0,"frequency_penalty":0,"stream":false}`, req)) + body, info := astraOutboundChat(t, req, tt.target, constant.ChannelTypeOpenAI) + require.Equal(t, tt.wire, gjson.GetBytes(body, "model").String()) + require.Equal(t, tt.effort, gjson.GetBytes(body, "reasoning_effort").String()) + require.Equal(t, tt.effort, info.ReasoningEffort) + role := "system" + if tt.astra { + role = "developer" + } + require.Equal(t, role, gjson.GetBytes(body, "messages.0.role").String()) + for _, key := range []string{"temperature", "top_p", "logprobs", "top_logprobs"} { + require.Equal(t, !tt.astra, gjson.GetBytes(body, key).Exists(), key) + } + for _, key := range []string{"seed", "frequency_penalty", "stream"} { + require.True(t, gjson.GetBytes(body, key).Exists(), key) + } + }) + } +} + +func TestGPT5NonReasoningSamplingCompatibility(t *testing.T) { + req := &dto.GeneralOpenAIRequest{Model: "gpt-5.4", ReasoningEffort: "none"} + require.NoError(t, common.UnmarshalJsonStr(`{"temperature":0.2,"top_p":0.8,"logprobs":true,"top_logprobs":5}`, req)) + body, _ := astraOutboundChat(t, req, req.Model, constant.ChannelTypeOpenAI) + for _, key := range []string{"temperature", "top_p", "logprobs"} { + require.True(t, gjson.GetBytes(body, key).Exists(), key) + } + require.Equal(t, int64(5), gjson.GetBytes(body, "top_logprobs").Int()) +} + +func TestAstraPreservesOtherRequestFields(t *testing.T) { + const payload = `{"model":"gpt-6-astra","messages":[{"role":"user","content":[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"https://example.com/image.png"}}]}],"max_completion_tokens":128,"reasoning_effort":"max","temperature":0.2,"top_p":0.8,"logprobs":true,"top_logprobs":5,"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":"auto","parallel_tool_calls":false,"seed":0}` + var req dto.GeneralOpenAIRequest + require.NoError(t, common.UnmarshalJsonStr(payload, &req)) + body, info := astraOutboundChat(t, &req, req.Model, constant.ChannelTypeOpenAI) + for _, key := range []string{"messages", "tools", "tool_choice", "parallel_tool_calls", "seed", "max_completion_tokens", "reasoning_effort"} { + require.JSONEq(t, gjson.Get(payload, key).Raw, gjson.GetBytes(body, key).Raw, key) + } + for _, key := range []string{"temperature", "top_p", "logprobs", "top_logprobs"} { + require.False(t, gjson.GetBytes(body, key).Exists(), key) + } + require.Equal(t, "max", info.ReasoningEffort) +} + +func TestAstraRespectsPreservedSuffix(t *testing.T) { + settings := model_setting.GetGlobalSettings() + before := settings.ThinkingModelBlacklist + settings.ThinkingModelBlacklist = []string{"gpt-6-astra-high"} + t.Cleanup(func() { settings.ThinkingModelBlacklist = before }) + req := &dto.GeneralOpenAIRequest{Model: "gpt-6-astra-high"} + require.NoError(t, common.UnmarshalJsonStr(`{"max_tokens":16,"temperature":0.2}`, req)) + body, _ := astraOutboundChat(t, req, req.Model, constant.ChannelTypeOpenAI) + require.Equal(t, "gpt-6-astra-high", gjson.GetBytes(body, "model").String()) + require.Equal(t, int64(16), gjson.GetBytes(body, "max_tokens").Int()) + require.Equal(t, 0.2, gjson.GetBytes(body, "temperature").Float()) + require.False(t, gjson.GetBytes(body, "reasoning_effort").Exists()) +} + +func TestAstraDirectResponsesKeepsExistingParameters(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 req dto.OpenAIResponsesRequest + require.NoError(t, common.UnmarshalJsonStr(body, &req)) + converted, err := (&openai.Adaptor{}).ConvertOpenAIResponsesRequest(nil, &relaycommon.RelayInfo{}, req) + require.NoError(t, err) + encoded, err := common.Marshal(converted) + require.NoError(t, err) + require.JSONEq(t, body, string(encoded)) +} diff --git a/controller/model_parameter_compatibility_test.go b/controller/model_parameter_compatibility_test.go new file mode 100644 index 00000000..f64a2c2a --- /dev/null +++ b/controller/model_parameter_compatibility_test.go @@ -0,0 +1,678 @@ +package controller + +import ( + "net/http/httptest" + "os" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/relay/channel/claude" + "github.com/MAX-API-Next/MAX-API/relay/channel/deepseek" + "github.com/MAX-API-Next/MAX-API/relay/channel/gemini" + "github.com/MAX-API-Next/MAX-API/relay/channel/openai" + "github.com/MAX-API-Next/MAX-API/relay/channel/xai" + "github.com/MAX-API-Next/MAX-API/relay/channel/zhipu_4v" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + relayconstant "github.com/MAX-API-Next/MAX-API/relay/constant" + "github.com/MAX-API-Next/MAX-API/relay/reasoningcompat" + "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/service/openaicompat" + "github.com/MAX-API-Next/MAX-API/setting/model_setting" + "github.com/MAX-API-Next/MAX-API/setting/reasoning" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestParameterCompatibilityGPT5Sampling(t *testing.T) { + for _, tc := range []struct { + model, effort string + sampling bool + }{ + {"gpt-5.1", "", true}, {"gpt-5.2", "none", true}, {"gpt-5.4-2026-03-05", "none", true}, + {"gpt-5.4", "high", false}, {"gpt-5.2-high", "none", false}, {"gpt-5.4-none", "high", true}, + {"gpt-5.2-pro", "none", false}, {"gpt-5.1-codex-max", "none", false}, {"gpt-5", "none", false}, + {"gpt-50", "", true}, {"gpt-5.2-2026-02-30", "none", false}, + } { + t.Run(tc.model+tc.effort, func(t *testing.T) { + req := &dto.GeneralOpenAIRequest{Model: tc.model, ReasoningEffort: tc.effort} + require.NoError(t, common.UnmarshalJsonStr(`{"temperature":0,"top_p":0,"logprobs":false,"top_logprobs":0}`, req)) + body, _ := astraOutboundChat(t, req, req.Model, constant.ChannelTypeOpenAI) + for _, key := range []string{"temperature", "top_p", "logprobs", "top_logprobs"} { + require.Equal(t, tc.sampling, gjson.GetBytes(body, key).Exists(), key) + } + }) + } +} + +func TestParameterCompatibilityMixedCaseChatModels(t *testing.T) { + for _, tc := range []struct { + model, base, effort, role string + sampling, astra bool + }{ + {"GPT-5.1-high", "GPT-5.1", "high", "developer", false, false}, + {"GpT-5.4-none", "GpT-5.4", "none", "developer", true, false}, + {"GPT-5.2-2026-03-05", "GPT-5.2-2026-03-05", "", "developer", true, false}, + {"GPT-5.2-pro", "GPT-5.2-pro", "", "developer", false, false}, + {"GPT-5.1-CODEX-max", "GPT-5.1-CODEX-max", "", "developer", false, false}, + {"O3-high", "O3", "high", "developer", false, false}, + {"O1-mini-high", "O1-mini", "high", "system", false, false}, + {"GPT-6-Astra-high", "GPT-6-Astra", "high", "developer", false, true}, + {"GPT-6-Astra-2026-09-01-high", "GPT-6-Astra-2026-09-01", "high", "developer", false, true}, + {"Vendor-high", "Vendor-high", "", "system", true, false}, + } { + t.Run(tc.model, func(t *testing.T) { + req := &dto.GeneralOpenAIRequest{Model: tc.model, Messages: []dto.Message{{Role: "system", Content: "test"}}} + require.NoError(t, common.UnmarshalJsonStr(`{"temperature":0,"top_p":0,"logprobs":false,"top_logprobs":0,"max_tokens":0}`, req)) + body, info := astraOutboundChat(t, req, req.Model, constant.ChannelTypeOpenAI) + require.Equal(t, tc.base, gjson.GetBytes(body, "model").String()) + require.Equal(t, tc.base, info.UpstreamModelName) + require.Equal(t, tc.effort, info.ReasoningEffort) + require.Equal(t, tc.effort, gjson.GetBytes(body, "reasoning_effort").String()) + require.Equal(t, tc.sampling, gjson.GetBytes(body, "temperature").Exists()) + require.Equal(t, tc.role, req.Messages[0].Role) + if tc.astra { + require.False(t, gjson.GetBytes(body, "max_tokens").Exists()) + require.True(t, gjson.GetBytes(body, "max_completion_tokens").Exists()) + require.Zero(t, gjson.GetBytes(body, "max_completion_tokens").Int()) + } + }) + } +} + +func TestParameterCompatibilityGeminiNonPrefixedAliases(t *testing.T) { + settings := model_setting.GetGeminiSettings() + before := settings.ThinkingAdapterEnabled + settings.ThinkingAdapterEnabled = true + t.Cleanup(func() { settings.ThinkingAdapterEnabled = before }) + for _, model := range []string{"nano-banana-pro-preview-nothinking", "nano-banana-pro-preview-thinking", "nano-banana-pro-preview-thinking-0", "nano-banana-pro-preview-high"} { + for _, stream := range []bool{false, true} { + t.Run(model+map[bool]string{true: "stream", false: "nonstream"}[stream], func(t *testing.T) { + c, info := parameterContext() + info.OriginModelName, info.UpstreamModelName = model, model + info.ChannelBaseUrl, info.IsStream = "https://example.test", stream + req := &dto.GeminiChatRequest{} + req.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{IncludeThoughts: common.GetPointer(false)} + require.NoError(t, gemini.ThinkingAdaptor(c, req, info)) + require.Equal(t, "high", info.ReasoningEffort) + require.Equal(t, common.GetPointer(false), req.GenerationConfig.ThinkingConfig.IncludeThoughts) + url, err := (&gemini.Adaptor{}).GetRequestURL(info) + require.NoError(t, err) + require.Equal(t, "nano-banana-pro-preview", info.UpstreamModelName) + require.Equal(t, model, info.OriginModelName) + action := "generateContent" + if stream { + action = "streamGenerateContent?alt=sse" + } + require.Contains(t, url, "/models/nano-banana-pro-preview:"+action) + }) + } + } + for _, model := range []string{"vendor-model-thinking", "nano-banana-proxy-high", "nano-banana-pro-preview"} { + _, info := parameterContext() + info.UpstreamModelName = model + _, err := (&gemini.Adaptor{}).GetRequestURL(info) + require.NoError(t, err) + require.Equal(t, model, info.UpstreamModelName) + } + for _, model := range []string{"nano-banana-pro-preview-thinking--2", "nano-banana-pro-preview-thinking-invalid"} { + c, info := parameterContext() + info.UpstreamModelName = model + require.ErrorContains(t, gemini.ThinkingAdaptor(c, &dto.GeminiChatRequest{}, info), "invalid thinking budget suffix") + _, err := (&gemini.Adaptor{}).GetRequestURL(info) + require.ErrorContains(t, err, "invalid thinking budget suffix") + require.Equal(t, model, info.UpstreamModelName) + } + c, info := parameterContext() + info.UpstreamModelName = "nano-banana-pro-preview-nothinking" + converted, err := gemini.CovertOpenAI2Gemini(c, dto.GeneralOpenAIRequest{Model: info.UpstreamModelName, IncludeReasoning: common.GetPointer(false)}, info) + require.NoError(t, err) + require.Equal(t, "high", info.ReasoningEffort) + require.Equal(t, common.GetPointer(false), converted.GenerationConfig.ThinkingConfig.IncludeThoughts) +} + +func TestParameterCompatibilityGeminiAliasPreservation(t *testing.T) { + settings := model_setting.GetGeminiSettings() + global := model_setting.GetGlobalSettings() + before, blacklist := settings.ThinkingAdapterEnabled, global.ThinkingModelBlacklist + t.Cleanup(func() { settings.ThinkingAdapterEnabled, global.ThinkingModelBlacklist = before, blacklist }) + model := "nano-banana-pro-preview-nothinking" + for _, tc := range []struct { + enabled bool + preserved string + }{{true, model}, {true, "original-alias"}, {false, ""}} { + settings.ThinkingAdapterEnabled = tc.enabled + global.ThinkingModelBlacklist = []string{tc.preserved} + c, info := parameterContext() + info.UpstreamModelName, info.OriginModelName = model, "original-alias" + req := &dto.GeminiChatRequest{} + require.NoError(t, gemini.ThinkingAdaptor(c, req, info)) + require.Nil(t, req.GenerationConfig.ThinkingConfig) + _, err := (&gemini.Adaptor{}).GetRequestURL(info) + require.NoError(t, err) + require.Equal(t, model, info.UpstreamModelName) + } +} + +func TestParameterCompatibilityZhipu(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + req := &dto.GeneralOpenAIRequest{Model: "glm-5", ReasoningEffort: "high"} + converted, err := (&zhipu_4v.Adaptor{}).ConvertOpenAIRequest(c, &relaycommon.RelayInfo{}, req) + require.NoError(t, err) + b, err := common.Marshal(converted) + require.NoError(t, err) + require.Equal(t, "high", gjson.GetBytes(b, "reasoning_effort").String()) +} + +func TestParameterCompatibilityDTOExtensions(t *testing.T) { + for _, tc := range []struct { + name, input string + target any + }{ + {"chat", `{"model":"local-model","thinking_token_budget":0,"include_reasoning":false,"min_p":0,"repetition_penalty":0,"structured_outputs":{"regex":"a"},"return_token_ids":false,"min_tokens":0,"separate_reasoning":false,"stream_reasoning":false,"regex":"a","ebnf":"root ::= 'a'","stop_token_ids":[0],"stop_regex":"end","no_stop_trim":false,"ignore_eos":false,"skip_special_tokens":false,"continue_final_message":false,"cache_salt":"test"}`, &dto.GeneralOpenAIRequest{}}, + {"responses", `{"model":"local-model","prompt_cache_options":{"ttl":"30m"},"chat_template_kwargs":{"enable_thinking":false},"top_k":0,"min_p":0,"repetition_penalty":0,"stop":["end"],"cache_salt":"test"}`, &dto.OpenAIResponsesRequest{}}, + {"compact", `{"model":"gpt-test","input":"hi","tools":[],"parallel_tool_calls":false,"reasoning":{"effort":"high"},"service_tier":"default","prompt_cache_key":"key","prompt_cache_options":{"ttl":"30m"},"prompt_cache_retention":"24h","text":{"verbosity":"low"}}`, &dto.OpenAIResponsesCompactionRequest{}}, + } { + t.Run(tc.name, func(t *testing.T) { + require.NoError(t, common.UnmarshalJsonStr(tc.input, tc.target)) + b, err := common.Marshal(tc.target) + require.NoError(t, err) + require.JSONEq(t, tc.input, string(b)) + if compact, ok := tc.target.(*dto.OpenAIResponsesCompactionRequest); ok { + converted, err := common.Marshal(compact.ToResponsesRequest()) + require.NoError(t, err) + require.JSONEq(t, tc.input, string(converted)) + } + }) + } +} + +func parameterContext() (*gin.Context, *relaycommon.RelayInfo) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil) + return c, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} +} + +func TestParameterCompatibilityClaudeReasoning(t *testing.T) { + for _, model := range []string{"claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-opus-5", "claude-sonnet-5", "claude-fable-5", "claude-mythos-5", "claude-mythos-preview"} { + t.Run(model, func(t *testing.T) { + c, info := parameterContext() + info.UpstreamModelName = model + converted, err := (&claude.Adaptor{}).ConvertOpenAIRequest(c, info, &dto.GeneralOpenAIRequest{Model: model, ReasoningEffort: "high", MaxTokens: common.GetPointer(uint(8192)), Temperature: common.GetPointer(.2), TopP: common.GetPointer(.5), TopK: common.GetPointer(20)}) + require.NoError(t, err) + req := converted.(*dto.ClaudeRequest) + require.Equal(t, "adaptive", req.Thinking.Type) + require.Nil(t, req.Thinking.BudgetTokens) + require.Equal(t, "high", info.ReasoningEffort) + require.Equal(t, uint(8192), *req.MaxTokens) + if model == "claude-opus-4-6" || model == "claude-sonnet-4-6" { + require.Equal(t, 1.0, *req.Temperature) + } else { + require.Nil(t, req.Temperature) + } + require.Nil(t, req.TopP) + require.Nil(t, req.TopK) + }) + } + c, _ := parameterContext() + for _, limit := range []uint{0, 1024} { + _, err := claude.RequestOpenAI2ClaudeMessage(c, dto.GeneralOpenAIRequest{Model: "claude-sonnet-4-5", ReasoningEffort: "high", MaxTokens: &limit}) + require.ErrorContains(t, err, "manual thinking requires") + } + out, err := claude.RequestOpenAI2ClaudeMessage(c, dto.GeneralOpenAIRequest{Model: "claude-sonnet-4-5", ReasoningEffort: "high", MaxTokens: common.GetPointer(uint(2000))}) + require.NoError(t, err) + require.Equal(t, "enabled", out.Thinking.Type) + require.Equal(t, 1600, *out.Thinking.BudgetTokens) + require.Equal(t, uint(2000), *out.MaxTokens) + out, err = claude.RequestOpenAI2ClaudeMessage(c, dto.GeneralOpenAIRequest{Model: "claude-opus-4-7", ReasoningEffort: "none"}) + require.NoError(t, err) + require.Equal(t, "disabled", out.Thinking.Type) + _, err = claude.RequestOpenAI2ClaudeMessage(c, dto.GeneralOpenAIRequest{Model: "claude-opus-4-7-high", ReasoningEffort: "low"}) + require.ErrorContains(t, err, "conflicting") + + var native dto.ClaudeRequest + raw := `{"model":"claude-opus-4-7","max_tokens":2000,"thinking":{"type":"adaptive","display":"omitted"},"output_config":{"effort":"low","format":{"type":"json_schema"}},"temperature":0}` + require.NoError(t, common.UnmarshalJsonStr(raw, &native)) + before, err := common.Marshal(native) + require.NoError(t, err) + _, _, err = reasoningcompat.ApplyClaudeSuffix(&native, native.Model) + require.NoError(t, err) + after, err := common.Marshal(native) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after)) +} + +func TestParameterCompatibilityClaudeToOpenRouter(t *testing.T) { + _, info := parameterContext() + info.ChannelType = constant.ChannelTypeOpenRouter + var native dto.ClaudeRequest + require.NoError(t, common.UnmarshalJsonStr(`{"model":"claude-opus-4-7","thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, &native)) + out, err := service.ClaudeToOpenAIRequest(native, info) + require.NoError(t, err) + b, err := common.Marshal(out) + require.NoError(t, err) + require.False(t, gjson.GetBytes(b, "verbosity").Exists()) + require.Equal(t, "high", gjson.GetBytes(b, "reasoning.effort").String()) + native.Thinking = &dto.Thinking{Type: "disabled"} + out, err = service.ClaudeToOpenAIRequest(native, info) + require.NoError(t, err) + require.JSONEq(t, `{"enabled":false}`, string(out.Reasoning)) +} + +func TestParameterCompatibilityReasoningBoundaries(t *testing.T) { + c, info := parameterContext() + for _, model := range []string{"claude-opus-4-7", "claude-sonnet-4-5", "claude-sonnet-5", "claude-mythos-preview"} { + out, err := claude.RequestOpenAI2ClaudeMessage(c, dto.GeneralOpenAIRequest{Model: model, IncludeReasoning: common.GetPointer(false)}) + require.NoError(t, err) + if model == "claude-sonnet-5" || model == "claude-mythos-preview" { + require.Equal(t, &dto.Thinking{Type: "adaptive", Display: "omitted"}, out.Thinking) + } else { + require.Nil(t, out.Thinking, "visibility alone must not enable thinking") + } + } + out, err := claude.RequestOpenAI2ClaudeMessage(nil, dto.GeneralOpenAIRequest{Model: "claude-sonnet-4-5", ReasoningEffort: "max", MaxTokens: common.GetPointer(uint(10000))}) + require.NoError(t, err) + require.Equal(t, 9500, *out.Thinking.BudgetTokens) + require.Equal(t, uint(10000), *out.MaxTokens) + _, err = claude.RequestOpenAI2ClaudeMessage(c, dto.GeneralOpenAIRequest{Model: "claude-sonnet-4-5", ReasoningEffort: "high", MaxTokens: common.GetPointer(^uint(0))}) + require.ErrorContains(t, err, "platform integer limit") + for _, raw := range []string{ + `{"reasoning":{"enabled":true,"max_tokens":0}}`, + `{"reasoning_effort":"none","reasoning":{"max_tokens":4096}}`, + `{"reasoning_effort":"high","reasoning":{"enabled":false}}`, + `{"include_reasoning":false,"reasoning":{"exclude":false}}`, + } { + var req dto.GeneralOpenAIRequest + require.NoError(t, common.UnmarshalJsonStr(raw, &req)) + _, err := reasoningcompat.FromChat(req) + require.ErrorContains(t, err, "conflicting") + } + var native dto.ClaudeRequest + require.NoError(t, common.UnmarshalJsonStr(`{"model":"claude-opus-4-7-high","max_tokens":2000,"thinking":{"type":"adaptive","display":"omitted"},"output_config":{"effort":"high","format":{"type":"json_schema"}}}`, &native)) + _, _, err = reasoningcompat.ApplyClaudeSuffix(&native, native.Model) + require.NoError(t, err) + require.Equal(t, "claude-opus-4-7", native.Model) + require.Equal(t, "omitted", native.Thinking.Display) + require.Equal(t, "json_schema", gjson.GetBytes(native.OutputConfig, "format.type").String()) + native.Model = "claude-opus-4-7-high" + native.Thinking = &dto.Thinking{Type: "enabled", BudgetTokens: common.GetPointer(-2)} + _, _, err = reasoningcompat.ApplyClaudeSuffix(&native, native.Model) + require.ErrorContains(t, err, "budget must be >= -1") + + info.UpstreamModelName = "gemini-2.5-flash" + var chat dto.GeneralOpenAIRequest + require.NoError(t, common.UnmarshalJsonStr(`{"model":"gemini-2.5-flash","max_tokens":99,"max_completion_tokens":0,"top_p":0,"seed":0,"extra_body":{"google":{"thinking_config":{"thinking_budget":-1,"include_thoughts":false}}}}`, &chat)) + gem, err := gemini.CovertOpenAI2Gemini(c, chat, info) + require.NoError(t, err) + require.Equal(t, uint(0), *gem.GenerationConfig.MaxOutputTokens) + require.Equal(t, 0.0, *gem.GenerationConfig.TopP) + require.Equal(t, int64(0), *gem.GenerationConfig.Seed) + require.Equal(t, -1, *gem.GenerationConfig.ThinkingConfig.ThinkingBudget) + require.False(t, *gem.GenerationConfig.ThinkingConfig.IncludeThoughts) + chat.IncludeReasoning = common.GetPointer(true) + _, err = gemini.CovertOpenAI2Gemini(c, chat, info) + require.ErrorContains(t, err, "conflicting reasoning visibility") + info.UpstreamModelName = "gemini-2.5-flash-image" + gem, err = gemini.CovertOpenAI2Gemini(c, dto.GeneralOpenAIRequest{Model: info.UpstreamModelName, IncludeReasoning: common.GetPointer(false)}, info) + require.NoError(t, err) + require.Nil(t, gem.GenerationConfig.ThinkingConfig) + info.UpstreamModelName = "gemini-unknown" + gem, err = gemini.CovertOpenAI2Gemini(c, dto.GeneralOpenAIRequest{Model: info.UpstreamModelName, ReasoningEffort: "high"}, info) + require.NoError(t, err) + require.Nil(t, gem.GenerationConfig.ThinkingConfig) + + var nativeGemini dto.GeminiChatRequest + require.NoError(t, common.UnmarshalJsonStr(`{"generationConfig":{"thinkingConfig":{"thinkingBudget":0,"includeThoughts":false}}}`, &nativeGemini)) + before, err := common.Marshal(nativeGemini) + require.NoError(t, err) + require.NoError(t, gemini.ThinkingAdaptor(c, &nativeGemini, info)) + after, err := common.Marshal(nativeGemini) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after)) + settings := model_setting.GetGeminiSettings() + previous := settings.ThinkingAdapterEnabled + settings.ThinkingAdapterEnabled = true + t.Cleanup(func() { settings.ThinkingAdapterEnabled = previous }) + info.UpstreamModelName = "gemini-3-pro-high" + nativeGemini.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(-2) + require.ErrorContains(t, gemini.ThinkingAdaptor(c, &nativeGemini, info), "budget must be >= -1") +} + +func TestParameterCompatibilityUnknownGeminiReasoningIsNonFatal(t *testing.T) { + for _, model := range []string{"gemini-2.0-flash", "gemini-1.5-pro", "gemma-3-27b-it", "operator-defined-model"} { + t.Run(model, func(t *testing.T) { + c, info := parameterContext() + info.UpstreamModelName = model + info.ReasoningEffort = "high" + out, err := gemini.CovertOpenAI2Gemini(c, dto.GeneralOpenAIRequest{ + Model: model, ReasoningEffort: "high", IncludeReasoning: common.GetPointer(false), + MaxCompletionTokens: common.GetPointer(uint(0)), Messages: []dto.Message{{Role: "user", Content: "hello"}}, + }, info) + require.NoError(t, err) + require.Nil(t, out.GenerationConfig.ThinkingConfig) + require.Empty(t, info.ReasoningEffort) + require.Equal(t, uint(0), *out.GenerationConfig.MaxOutputTokens) + require.Equal(t, "hello", out.Contents[0].Parts[0].Text) + config, effort, notes, err := reasoningcompat.GeminiConfig(model, reasoningcompat.Intent{Mode: "enabled", Effort: "high"}, nil, nil, .6) + require.NoError(t, err) + require.Nil(t, config) + require.Empty(t, effort) + require.Len(t, notes, 1) + require.Contains(t, notes[0], model) + _, _, _, err = reasoningcompat.GeminiConfig(model, reasoningcompat.Intent{Effort: "typo"}, nil, nil, .6) + require.Error(t, err, "invalid controls must not be treated as unknown capabilities") + }) + } +} + +func TestParameterCompatibilityGeminiWarningRequestContext(t *testing.T) { + logFile, err := os.CreateTemp(t.TempDir(), "gemini-warnings-*.log") + require.NoError(t, err) + common.LogWriterMu.Lock() + originalWriter := gin.DefaultErrorWriter + gin.DefaultErrorWriter = logFile + common.LogWriterMu.Unlock() + t.Cleanup(func() { + common.LogWriterMu.Lock() + gin.DefaultErrorWriter = originalWriter + common.LogWriterMu.Unlock() + require.NoError(t, logFile.Close()) + }) + + c, info := parameterContext() + c.Set(common.RequestIdKey, "gemini-warning-request") + c.Request.Header.Set("Authorization", "Bearer synthetic-sensitive-key") + info.UpstreamModelName = "gemini-2.0-flash" + _, err = gemini.CovertOpenAI2Gemini(c, dto.GeneralOpenAIRequest{ + Model: info.UpstreamModelName, ReasoningEffort: "high", + Messages: []dto.Message{{Role: "user", Content: "synthetic-sensitive-message"}}, + }, info) + require.NoError(t, err) + logged, err := os.ReadFile(logFile.Name()) + require.NoError(t, err) + require.Contains(t, string(logged), "| gemini-warning-request |") + require.Contains(t, string(logged), "unknown Gemini thinking capabilities") + require.NotContains(t, string(logged), "synthetic-sensitive") + + var nilContext *gin.Context + _, err = gemini.CovertOpenAI2Gemini(nilContext, dto.GeneralOpenAIRequest{ + Model: info.UpstreamModelName, ReasoningEffort: "high", + }, info) + require.NoError(t, err) + logged, err = os.ReadFile(logFile.Name()) + require.NoError(t, err) + require.Contains(t, string(logged), "| SYSTEM |") + + settings := model_setting.GetGeminiSettings() + originalEnabled := settings.ThinkingAdapterEnabled + settings.ThinkingAdapterEnabled = true + t.Cleanup(func() { settings.ThinkingAdapterEnabled = originalEnabled }) + c.Set(common.RequestIdKey, "gemini-native-warning-request") + info.UpstreamModelName = "gemini-2.0-flash-high" + require.NoError(t, gemini.ThinkingAdaptor(c, &dto.GeminiChatRequest{}, info)) + logged, err = os.ReadFile(logFile.Name()) + require.NoError(t, err) + require.Contains(t, string(logged), "| gemini-native-warning-request |") +} + +func TestParameterCompatibilityGeminiNativeConfigOnUnknownModels(t *testing.T) { + settings := model_setting.GetGeminiSettings() + originalEnabled := settings.ThinkingAdapterEnabled + settings.ThinkingAdapterEnabled = true + t.Cleanup(func() { settings.ThinkingAdapterEnabled = originalEnabled }) + for _, tc := range []struct { + name, nativeJSON, suffix string + }{ + {"zero and false", `{"thinkingBudget":0,"includeThoughts":false}`, "-thinking-0"}, + {"dynamic budget", `{"thinkingBudget":-1}`, "-thinking"}, + {"positive budget", `{"thinkingBudget":2048}`, "-thinking"}, + {"native level", `{"thinkingLevel":"high","includeThoughts":false}`, "-high"}, + {"visibility only", `{"includeThoughts":false}`, "-thinking"}, + } { + t.Run(tc.name, func(t *testing.T) { + var native dto.GeminiThinkingConfig + require.NoError(t, common.UnmarshalJsonStr(tc.nativeJSON, &native)) + c, info := parameterContext() + info.UpstreamModelName = "gemini-unknown" + tc.suffix + req := &dto.GeminiChatRequest{} + req.GenerationConfig.ThinkingConfig = &native + require.NoError(t, gemini.ThinkingAdaptor(c, req, info)) + encoded, err := common.Marshal(req.GenerationConfig.ThinkingConfig) + require.NoError(t, err) + require.JSONEq(t, tc.nativeJSON, string(encoded), "unknown capabilities must preserve native fields exactly") + + info.UpstreamModelName = "operator-defined-model" + chat := dto.GeneralOpenAIRequest{Model: info.UpstreamModelName} + if native.IncludeThoughts == nil { + chat.ReasoningEffort = "high" + chat.IncludeReasoning = common.GetPointer(true) + } + nativeFields := map[string]any{} + if native.ThinkingBudget != nil { + nativeFields["thinking_budget"] = *native.ThinkingBudget + } + if native.ThinkingLevel != "" { + nativeFields["thinking_level"] = native.ThinkingLevel + } + if native.IncludeThoughts != nil { + nativeFields["include_thoughts"] = *native.IncludeThoughts + } + chat.ExtraBody, err = common.Marshal(map[string]any{"google": map[string]any{"thinking_config": nativeFields}}) + require.NoError(t, err) + out, err := gemini.CovertOpenAI2Gemini(c, chat, info) + require.NoError(t, err) + encoded, err = common.Marshal(out.GenerationConfig.ThinkingConfig) + require.NoError(t, err) + require.JSONEq(t, tc.nativeJSON, string(encoded)) + + info.UpstreamModelName = "gemini-2.5-flash-image" + out, err = gemini.CovertOpenAI2Gemini(c, chat, info) + require.NoError(t, err) + require.Nil(t, out.GenerationConfig.ThinkingConfig, "known unsupported models retain the distinct drop policy") + }) + } +} + +func TestParameterCompatibilityGeminiReasoning(t *testing.T) { + settings := model_setting.GetGeminiSettings() + before := settings.ThinkingAdapterEnabled + settings.ThinkingAdapterEnabled = true + t.Cleanup(func() { settings.ThinkingAdapterEnabled = before }) + for _, tc := range []struct { + model, effort, level string + budget *int + }{ + {"gemini-2.5-pro-high", "", "", common.GetPointer(24576)}, + {"gemini-2.5-flash", "low", "", common.GetPointer(1024)}, + {"gemini-2.5-flash", "none", "", common.GetPointer(0)}, + {"gemini-2.5-pro", "none", "", common.GetPointer(128)}, + {"gemini-3-pro-max", "", "high", nil}, + {"gemini-3-pro", "medium", "high", nil}, + {"gemini-3.1-pro", "minimal", "low", nil}, + {"gemini-3-flash", "medium", "medium", nil}, + {"gemini-3-pro-thinking-8192", "", "high", nil}, + } { + t.Run(tc.model+tc.effort, func(t *testing.T) { + c, info := parameterContext() + info.UpstreamModelName = tc.model + out, err := gemini.CovertOpenAI2Gemini(c, dto.GeneralOpenAIRequest{Model: tc.model, ReasoningEffort: tc.effort}, info) + require.NoError(t, err) + require.NotNil(t, out.GenerationConfig.ThinkingConfig) + require.Equal(t, tc.level, out.GenerationConfig.ThinkingConfig.ThinkingLevel) + require.Equal(t, tc.budget, out.GenerationConfig.ThinkingConfig.ThinkingBudget) + }) + } + c, info := parameterContext() + info.UpstreamModelName = "gemini-2.5-flash" + var req dto.GeneralOpenAIRequest + require.NoError(t, common.UnmarshalJsonStr(`{"model":"gemini-2.5-flash","extra_body":{"google":{"thinking_config":{"thinking_budget":0,"include_thoughts":false}}}}`, &req)) + out, err := gemini.CovertOpenAI2Gemini(c, req, info) + require.NoError(t, err) + b, err := common.Marshal(out.GenerationConfig.ThinkingConfig) + require.NoError(t, err) + require.JSONEq(t, `{"thinkingBudget":0,"includeThoughts":false}`, string(b)) + require.NoError(t, common.UnmarshalJsonStr(`{"extra_body":{"google":{"thinking_config":{"thinking_budget":1.5}}}}`, &req)) + _, err = gemini.CovertOpenAI2Gemini(c, req, info) + require.Error(t, err) +} + +func TestParameterCompatibilitySuffixIdentity(t *testing.T) { + for _, tc := range []struct{ model, base, effort string }{ + {"vendor-model-high", "vendor-model-high", ""}, {"gemini-3-pro-high", "gemini-3-pro-high", ""}, + {"gpt-5.1-codex-max", "gpt-5.1-codex-max", ""}, {"gpt-5.1-codex-max-high", "gpt-5.1-codex-max", "high"}, + {"openai/gpt-5.4-max", "openai/gpt-5.4", "max"}, {"o3-high", "o3", "high"}, + {"GPT-5.1-high", "GPT-5.1", "high"}, {"O3-high", "O3", "high"}, + {"OpenAI/GpT-5.4-none", "OpenAI/GpT-5.4", "none"}, + {"GPT-5.1-CODEX-max", "GPT-5.1-CODEX-max", ""}, + {"OpenAI/GPT-5.1-CODEX-max", "OpenAI/GPT-5.1-CODEX-max", ""}, + {"GPT-5.1-CODEX-max-high", "GPT-5.1-CODEX-max", "high"}, + {"Vendor-high", "Vendor-high", ""}, {"O0-high", "O0-high", ""}, + } { + t.Run(tc.model, func(t *testing.T) { + effort, base := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(tc.model) + require.Equal(t, tc.base, base) + require.Equal(t, tc.effort, effort) + converted, err := (&openai.Adaptor{}).ConvertOpenAIResponsesRequest(nil, nil, dto.OpenAIResponsesRequest{Model: tc.model}) + require.NoError(t, err) + response := converted.(dto.OpenAIResponsesRequest) + require.Equal(t, tc.base, response.Model) + if tc.effort == "" { + require.Nil(t, response.Reasoning) + } else { + require.Equal(t, tc.effort, response.Reasoning.Effort) + } + c, info := parameterContext() + info.UpstreamModelName = tc.model + _, err = (&openai.Adaptor{}).ConvertOpenAIResponsesRequest(c, info, dto.OpenAIResponsesRequest{Model: tc.model}) + require.NoError(t, err) + require.Equal(t, tc.base, info.UpstreamModelName) + require.Equal(t, tc.effort, info.ReasoningEffort) + }) + } + settings := model_setting.GetGlobalSettings() + before := settings.ThinkingModelBlacklist + settings.ThinkingModelBlacklist = []string{"alias", "deepseek-v4-pro-max", "grok-3-mini-high", "GPT-5.1-high"} + t.Cleanup(func() { settings.ThinkingModelBlacklist = before }) + for _, model := range []string{"GPT-5.1-high", "O3-high"} { + c, info := parameterContext() + info.OriginModelName = "alias" + info.UpstreamModelName = model + out, err := (&openai.Adaptor{}).ConvertOpenAIResponsesRequest(c, info, dto.OpenAIResponsesRequest{Model: model}) + require.NoError(t, err) + require.Equal(t, model, out.(dto.OpenAIResponsesRequest).Model) + require.Nil(t, out.(dto.OpenAIResponsesRequest).Reasoning) + require.Equal(t, model, info.UpstreamModelName) + } + for _, protected := range []string{"alias", "GPT-5.1-high"} { + settings.ThinkingModelBlacklist = []string{protected} + c, info := parameterContext() + info.OriginModelName, info.UpstreamModelName = "alias", "GPT-5.1-high" + out, err := (&openai.Adaptor{}).ConvertOpenAIRequest(c, info, &dto.GeneralOpenAIRequest{Model: info.UpstreamModelName}) + require.NoError(t, err) + require.Equal(t, "GPT-5.1-high", out.(*dto.GeneralOpenAIRequest).Model) + require.Empty(t, out.(*dto.GeneralOpenAIRequest).ReasoningEffort) + require.Equal(t, "GPT-5.1-high", info.UpstreamModelName) + } + settings.ThinkingModelBlacklist = []string{"alias", "deepseek-v4-pro-max", "grok-3-mini-high", "GPT-5.1-high"} + effort, model := reasoning.ParseOpenAIReasoningEffortFromModelSuffix("GPT-5.1-high") + require.Empty(t, effort) + require.Equal(t, "GPT-5.1-high", model) + c, info := parameterContext() + info.OriginModelName = "alias" + info.UpstreamModelName = "gpt-5.4-high" + out, err := (&openai.Adaptor{}).ConvertOpenAIResponsesRequest(c, info, dto.OpenAIResponsesRequest{Model: info.UpstreamModelName}) + require.NoError(t, err) + require.Equal(t, "gpt-5.4-high", out.(dto.OpenAIResponsesRequest).Model) + info.OriginModelName = "" + info.UpstreamModelName = "deepseek-v4-pro-max" + deep, err := (&deepseek.Adaptor{}).ConvertOpenAIRequest(c, info, &dto.GeneralOpenAIRequest{Model: info.UpstreamModelName}) + require.NoError(t, err) + require.Equal(t, "deepseek-v4-pro-max", deep.(*dto.GeneralOpenAIRequest).Model) + info.UpstreamModelName = "grok-3-mini-high" + grok, err := (&xai.Adaptor{}).ConvertOpenAIRequest(c, info, &dto.GeneralOpenAIRequest{Model: info.UpstreamModelName}) + require.NoError(t, err) + require.Equal(t, "grok-3-mini-high", grok.(*dto.GeneralOpenAIRequest).Model) +} + +func TestParameterCompatibilityKimiConversionBoundary(t *testing.T) { + var req dto.GeneralOpenAIRequest + require.NoError(t, common.UnmarshalJsonStr(`{"model":"kimi-k3","messages":[{"role":"system","tools":[{"type":"function","function":{"name":"lookup"}}]}]}`, &req)) + declarations, err := relaycommon.CaptureMessageTools(&req) + require.NoError(t, err) + require.NoError(t, relaycommon.ValidateMessageToolsConversion(declarations, &req)) + require.Error(t, relaycommon.ValidateMessageToolsConversion(declarations, &dto.GeneralOpenAIRequest{Messages: []dto.Message{{Role: "system"}}})) + _, err = openaicompat.ChatCompletionsRequestToResponsesRequest(&req) + require.ErrorContains(t, err, "message-scoped tools") + c, info := parameterContext() + _, err = claude.RequestOpenAI2ClaudeMessage(c, req) + require.ErrorContains(t, err, "message-scoped tools") + _, err = gemini.CovertOpenAI2Gemini(c, req, info) + require.ErrorContains(t, err, "message-scoped tools") +} + +func TestParameterCompatibilityZhipuResponses(t *testing.T) { + _, info := parameterContext() + info.RelayMode = relayconstant.RelayModeResponses + info.ChannelBaseUrl = "https://example.test" + adaptor := &zhipu_4v.Adaptor{} + url, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + require.Equal(t, "https://example.test/api/v1/responses", url) + for _, stream := range []bool{false, true} { + req := dto.OpenAIResponsesRequest{Model: "glm-5", Stream: &stream, Reasoning: &dto.Reasoning{Effort: "high"}} + out, err := adaptor.ConvertOpenAIResponsesRequest(nil, info, req) + require.NoError(t, err) + require.Equal(t, req, out) + } + for _, plan := range []string{"glm-coding-plan", "glm-coding-plan-international"} { + info.ChannelBaseUrl = plan + url, err := adaptor.GetRequestURL(info) + require.ErrorContains(t, err, "coding-plan base aliases do not support Responses") + require.Empty(t, url, "a plan alias is not an HTTP origin and must not fall back to a separately billed endpoint") + } +} + +func TestParameterCompatibilityKimiTools(t *testing.T) { + const input = `{"model":"kimi-k3","messages":[{"role":"system","tools":[{"type":"function","function":{"name":"lookup","description":"lookup data","parameters":{"type":"object"}}}]},{"role":"assistant","content":null}]}` + req := &dto.GeneralOpenAIRequest{} + require.NoError(t, common.UnmarshalJsonStr(input, req)) + b, err := common.Marshal(req) + require.NoError(t, err) + require.JSONEq(t, input, string(b)) + meta := req.GetTokenCountMeta() + require.Equal(t, 1, meta.ToolsCount) + require.Contains(t, meta.CombineText, "lookup") + before := constant.CountToken + constant.CountToken = true + t.Cleanup(func() { constant.CountToken = before }) + c, info := parameterContext() + info.RelayFormat = types.RelayFormatOpenAI + meta.TokenType = types.TokenTypeTextNumber + tokens, err := service.EstimateRequestToken(c, meta, info) + require.NoError(t, err) + withoutTools := *meta + withoutTools.ToolsCount = 0 + base, err := service.EstimateRequestToken(c, &withoutTools, info) + require.NoError(t, err) + require.Equal(t, 8, tokens-base) // each dynamic declaration adds one standard tool overhead + require.Equal(t, 1, req.GetTokenCountMeta().ToolsCount) // repeated estimation cannot accumulate charges +} + +func TestParameterCompatibilityResponsesExtensionRoundTrip(t *testing.T) { + var req dto.GeneralOpenAIRequest + require.NoError(t, common.UnmarshalJsonStr(`{"model":"local-model","messages":[{"role":"user","content":"hi"}],"prompt_cache_options":{"ttl":"30m"},"prompt_cache_retention":"24h","chat_template_kwargs":{"enable_thinking":false},"top_k":0,"min_p":0,"repetition_penalty":0,"cache_salt":"salt","stop":["end"]}`, &req)) + response, err := openaicompat.ChatCompletionsRequestToResponsesRequest(&req) + require.NoError(t, err) + back, err := openaicompat.ResponsesRequestToChatCompletionsRequest(response) + require.NoError(t, err) + original, err := common.Marshal(req) + require.NoError(t, err) + encoded, err := common.Marshal(back) + require.NoError(t, err) + for _, key := range []string{"prompt_cache_options", "prompt_cache_retention", "chat_template_kwargs", "top_k", "min_p", "repetition_penalty", "cache_salt", "stop"} { + require.JSONEq(t, gjson.GetBytes(original, key).Raw, gjson.GetBytes(encoded, key).Raw, key) + } +} diff --git a/controller/user_batch.go b/controller/user_batch.go new file mode 100644 index 00000000..6ed912cd --- /dev/null +++ b/controller/user_batch.go @@ -0,0 +1,95 @@ +package controller + +import ( + "errors" + "net/http" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/i18n" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const maxUserStatusBatchSize = 100 + +type batchUserStatusRequest struct { + Action string `json:"action"` + Users []struct { + ID int `json:"id"` + Status int `json:"status"` + } `json:"users"` +} + +type batchUserStatusResult struct { + ID int `json:"id"` + Outcome string `json:"outcome"` + Code string `json:"code,omitempty"` + Status int `json:"status,omitempty"` +} + +// BatchManageUserStatus deliberately accepts only enable/disable. Each item has +// its own transaction and result: one protected account must not hide failures +// or undo successful changes to unrelated accounts. +func BatchManageUserStatus(c *gin.Context) { + if c.GetInt("id") <= 0 || c.GetInt("role") < common.RoleAdminUser { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "Forbidden"}) + return + } + var req batchUserStatusRequest + if err := common.DecodeJson(http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10), &req); err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + if (req.Action != "enable" && req.Action != "disable") || len(req.Users) == 0 || len(req.Users) > maxUserStatusBatchSize { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + seen := make(map[int]bool, len(req.Users)) + for _, user := range req.Users { + if user.ID <= 0 || seen[user.ID] || (user.Status != common.UserStatusEnabled && user.Status != common.UserStatusDisabled) { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + seen[user.ID] = true + } + status := common.UserStatusDisabled + if req.Action == "enable" { + status = common.UserStatusEnabled + } + results := make([]batchUserStatusResult, 0, len(req.Users)) + for _, user := range req.Users { + change, err := model.SetUserStatusByAdmin(c.Request.Context(), c.GetInt("id"), user.ID, user.Status, status) + result := batchUserStatusResult{ID: user.ID, Outcome: "unchanged", Status: change.Status} + switch { + case errors.Is(err, model.ErrUserStatusForbidden): + result.Outcome, result.Code = "rejected", "forbidden" + case errors.Is(err, gorm.ErrRecordNotFound): + result.Outcome, result.Code = "rejected", "not_found" + case errors.Is(err, model.ErrUserStatusConflict): + result.Outcome, result.Code = "rejected", "conflict" + case err != nil: + result.Outcome, result.Code = "unknown", "unknown" + common.SysError("batch user status change could not be confirmed: " + err.Error()) + case change.Changed: + result.Outcome = "updated" + } + if err == nil { + recordManageAuditFor(c, user.ID, "user.manage", map[string]interface{}{ + "action": req.Action, "id": user.ID, "username": change.Username, + "batch": true, "outcome": result.Outcome, + "previous_status": change.PreviousStatus, "status": change.Status, + }) + } else { + // Keep rejected/uncertain attempts visible without suggesting that a + // status change succeeded or disclosing another user's details. + recordManageAudit(c, "user.manage", map[string]interface{}{ + "action": req.Action + ":" + result.Outcome, "id": user.ID, + "username": "", "batch": true, "outcome": result.Outcome, "code": result.Code, + }) + } + results = append(results, result) + } + // success means the batch was processed, NOT that every account changed. + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"results": results}}) +} diff --git a/controller/user_batch_test.go b/controller/user_batch_test.go new file mode 100644 index 00000000..4f4bb026 --- /dev/null +++ b/controller/user_batch_test.go @@ -0,0 +1,147 @@ +package controller + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func setupUserBatchTest(t *testing.T) { + t.Helper() + db := setupUserSettingControllerTestDB(t) + require.NoError(t, db.AutoMigrate(&model.Log{})) + for _, user := range []model.User{ + {Id: 1, Username: "root", Role: common.RoleRootUser, Status: 1}, + {Id: 2, Username: "admin", Role: common.RoleAdminUser, Status: 1}, + {Id: 3, Username: "common", Role: common.RoleCommonUser, Status: 1, Quota: 10000}, + {Id: 4, Username: "disabled", Role: common.RoleCommonUser, Status: 2}, + {Id: 5, Username: "peer", Role: common.RoleAdminUser, Status: 1}, + } { + user.AffCode = user.Username + require.NoError(t, db.Create(&user).Error) + } +} + +func callUserStatusBatch(t *testing.T, actorID, role int, body string) (int, bool, []batchUserStatusResult) { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("id", actorID) + c.Set("role", role) + c.Set("username", "test-operator") + c.Request = httptest.NewRequest(http.MethodPost, "/api/user/manage/batch", bytes.NewBufferString(body)) + BatchManageUserStatus(c) + var response struct { + Success bool `json:"success"` + Data struct { + Results []batchUserStatusResult `json:"results"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(w.Body.Bytes(), &response)) + return w.Code, response.Success, response.Data.Results +} + +func TestBatchManageUserStatusPartialResultsAndAudit(t *testing.T) { + setupUserBatchTest(t) + body := `{"action":"disable","users":[{"id":3,"status":1},{"id":4,"status":2},{"id":1,"status":1},{"id":2,"status":1},{"id":5,"status":1},{"id":999,"status":1}]}` + code, success, results := callUserStatusBatch(t, 2, common.RoleAdminUser, body) + require.Equal(t, 200, code) + require.True(t, success) + require.Equal(t, []batchUserStatusResult{ + {ID: 3, Outcome: "updated", Status: 2}, {ID: 4, Outcome: "unchanged", Status: 2}, + {ID: 1, Outcome: "rejected", Code: "forbidden"}, {ID: 2, Outcome: "rejected", Code: "forbidden"}, + {ID: 5, Outcome: "rejected", Code: "forbidden"}, {ID: 999, Outcome: "rejected", Code: "not_found"}, + }, results) + var logs []model.Log + require.NoError(t, model.LOG_DB.Order("id").Find(&logs).Error) + require.Len(t, logs, 6) + for _, log := range logs { + require.Equal(t, model.LogTypeManage, log.Type) + require.Contains(t, log.Other, `"admin_id":2`) + require.Contains(t, log.Other, `"batch":true`) + require.Contains(t, log.Other, `"outcome":`) + } + _, success, results = callUserStatusBatch(t, 2, common.RoleAdminUser, body) + require.True(t, success) + require.Equal(t, "unchanged", results[0].Outcome) + _, success, results = callUserStatusBatch(t, 2, common.RoleAdminUser, `{"action":"enable","users":[{"id":3,"status":2}]}`) + require.True(t, success) + require.Equal(t, "updated", results[0].Outcome) + var user model.User + require.NoError(t, model.DB.First(&user, 3).Error) + require.EqualValues(t, 10000, user.Quota) + require.Equal(t, 1, user.Status) +} + +func TestBatchManageUserStatusValidatesEntireRequestBeforeWriting(t *testing.T) { + tooMany := make([]string, 101) + for i := range tooMany { + tooMany[i] = fmt.Sprintf(`{"id":%d,"status":1}`, i+3) + } + for index, body := range []string{ + `{`, `{"action":"disable","users":[]}`, `{"action":"delete","users":[{"id":3,"status":1}]}`, + `{"action":"disable","users":[{"id":3,"status":1},{"id":3,"status":1}]}`, + `{"action":"disable","users":[{"id":3,"status":1},{"id":0,"status":1}]}`, + `{"action":"disable","users":[{"id":3,"status":1},{"id":4}]}`, + `{"action":"disable","users":[{"id":3,"status":1},{"id":4,"status":-1}]}`, + `{"action":"disable","users":[` + strings.Join(tooMany, ",") + `]}`, + `{"action":"disable","padding":"` + strings.Repeat("x", 17000) + `","users":[{"id":3,"status":1}]}`, + } { + t.Run(fmt.Sprintf("case-%d", index), func(t *testing.T) { + setupUserBatchTest(t) + _, success, results := callUserStatusBatch(t, 2, common.RoleAdminUser, body) + require.False(t, success) + require.Empty(t, results) + var user model.User + require.NoError(t, model.DB.First(&user, 3).Error) + require.Equal(t, 1, user.Status) + }) + } +} + +func TestBatchManageUserStatusDoesNotTrustMiddlewareRoleSnapshot(t *testing.T) { + setupUserBatchTest(t) + body := `{"action":"disable","users":[{"id":4,"status":2}]}` + code, success, _ := callUserStatusBatch(t, 3, common.RoleCommonUser, body) + require.Equal(t, http.StatusForbidden, code) + require.False(t, success) + _, success, results := callUserStatusBatch(t, 3, common.RoleRootUser, body) + require.True(t, success) + require.Equal(t, "forbidden", results[0].Code) +} + +func TestBatchManageUserStatusAcceptsLimitAndReturnsEveryRequestedID(t *testing.T) { + setupUserBatchTest(t) + items := make([]string, maxUserStatusBatchSize) + for i := range items { + items[i] = fmt.Sprintf(`{"id":%d,"status":1}`, 1000+i) + } + _, success, results := callUserStatusBatch(t, 2, common.RoleAdminUser, + `{"action":"disable","users":[`+strings.Join(items, ",")+`]}`) + require.True(t, success) + require.Len(t, results, maxUserStatusBatchSize) + for i, result := range results { + require.Equal(t, 1000+i, result.ID) + require.Equal(t, "not_found", result.Code) + } +} + +func TestBatchManageUserStatusDatabaseErrorIsNotConfirmedFailure(t *testing.T) { + setupUserBatchTest(t) + // Simulate a persistence failure after the first account was processed. + _, success, results := callUserStatusBatch(t, 2, common.RoleAdminUser, `{"action":"disable","users":[{"id":3,"status":1},{"id":4,"status":1}]}`) + require.True(t, success) + require.Equal(t, "updated", results[0].Outcome) + require.NoError(t, model.DB.Migrator().DropTable(&model.User{})) + _, success, results = callUserStatusBatch(t, 2, common.RoleAdminUser, `{"action":"disable","users":[{"id":3,"status":1}]}`) + require.True(t, success) + require.Equal(t, "unknown", results[0].Outcome) +} diff --git a/dto/gemini.go b/dto/gemini.go index 79c75ec5..1f784dcf 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -160,8 +160,8 @@ func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) { } type GeminiThinkingConfig struct { - IncludeThoughts bool `json:"includeThoughts,omitempty"` - ThinkingBudget *int `json:"thinkingBudget,omitempty"` + IncludeThoughts *bool `json:"includeThoughts,omitempty"` + ThinkingBudget *int `json:"thinkingBudget,omitempty"` // TODO Conflict with thinkingbudget. ThinkingLevel string `json:"thinkingLevel,omitempty"` } @@ -183,7 +183,7 @@ func (c *GeminiThinkingConfig) UnmarshalJSON(data []byte) error { *c = GeminiThinkingConfig(aux.Alias) if aux.IncludeThoughtsSnake != nil { - c.IncludeThoughts = *aux.IncludeThoughtsSnake + c.IncludeThoughts = aux.IncludeThoughtsSnake } if aux.ThinkingBudgetSnake != nil { diff --git a/dto/openai_request.go b/dto/openai_request.go index 9a4701cb..8f79ce3d 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/types" @@ -75,6 +76,7 @@ type GeneralOpenAIRequest struct { // Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field PromptCacheKey string `json:"prompt_cache_key,omitempty"` PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"` + PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"` LogitBias json.RawMessage `json:"logit_bias,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` Prediction json.RawMessage `json:"prediction,omitempty"` @@ -107,6 +109,25 @@ type GeneralOpenAIRequest struct { SearchMode json.RawMessage `json:"search_mode,omitempty"` // Minimax ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"` + // Client-supplied vLLM/SGLang extensions. Never synthesize these for other providers. + ThinkingTokenBudget *int `json:"thinking_token_budget,omitempty"` + IncludeReasoning *bool `json:"include_reasoning,omitempty"` + MinP *float64 `json:"min_p,omitempty"` + RepetitionPenalty *float64 `json:"repetition_penalty,omitempty"` + StructuredOutputs json.RawMessage `json:"structured_outputs,omitempty"` + ReturnTokenIDs *bool `json:"return_token_ids,omitempty"` + MinTokens *uint `json:"min_tokens,omitempty"` + SeparateReasoning *bool `json:"separate_reasoning,omitempty"` + StreamReasoning *bool `json:"stream_reasoning,omitempty"` + Regex *string `json:"regex,omitempty"` + EBNF *string `json:"ebnf,omitempty"` + StopTokenIDs json.RawMessage `json:"stop_token_ids,omitempty"` + StopRegex json.RawMessage `json:"stop_regex,omitempty"` + NoStopTrim *bool `json:"no_stop_trim,omitempty"` + IgnoreEOS *bool `json:"ignore_eos,omitempty"` + SkipSpecialTokens *bool `json:"skip_special_tokens,omitempty"` + ContinueFinalMessage *bool `json:"continue_final_message,omitempty"` + CacheSalt *string `json:"cache_salt,omitempty"` } func (r GeneralOpenAIRequest) MarshalJSON() ([]byte, error) { @@ -114,9 +135,42 @@ func (r GeneralOpenAIRequest) MarshalJSON() ([]byte, error) { if !IsQwenThinkingBudgetModel(r.Model) { r.ThinkingBudget = nil } + if r.HasMessageTools() { + // Only tool-loading messages omit absent content; ordinary assistant + // tool-call responses must retain their existing content:null contract. + type toolLoadingMessage struct { + Message + Content any `json:"content,omitempty"` + } + messages := make([]json.RawMessage, 0, len(r.Messages)) + for _, message := range r.Messages { + var value any = message + if len(message.Tools) > 0 && message.Content == nil { + value = toolLoadingMessage{Message: message} + } + encoded, err := common.Marshal(value) + if err != nil { + return nil, err + } + messages = append(messages, encoded) + } + return common.Marshal(struct { + *Alias + Messages []json.RawMessage `json:"messages,omitempty"` + }{Alias: (*Alias)(&r), Messages: messages}) + } return common.Marshal((*Alias)(&r)) } +func (r *GeneralOpenAIRequest) HasMessageTools() bool { + for _, message := range r.Messages { + if len(message.Tools) > 0 { + return true + } + } + return false +} + func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta { var tokenCountMeta types.TokenCountMeta var texts = make([]string, 0) @@ -153,6 +207,15 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta { for _, message := range r.Messages { tokenCountMeta.MessagesCount++ texts = append(texts, message.Role) + if len(message.Tools) > 0 { + // Count declarations once, including custom schemas, even on messages + // without content. Reuse the existing per-tool estimation overhead. + texts = append(texts, string(message.Tools)) + var tools []json.RawMessage + if common.Unmarshal(message.Tools, &tools) == nil { + tokenCountMeta.ToolsCount += len(tools) + } + } if message.Content != nil { if message.Name != nil { tokenCountMeta.NameCount++ @@ -229,7 +292,41 @@ func IsOpenAIReasoningOModel(modelName string) bool { } func IsOpenAIGPT5Model(modelName string) bool { - return strings.HasPrefix(modelName, "gpt-5") + return modelName == "gpt-5" || strings.HasPrefix(modelName, "gpt-5-") || strings.HasPrefix(modelName, "gpt-5.") +} + +// Only these standard models (and valid dated snapshots) support sampling +// with reasoning disabled. Named variants do not inherit the exception. +func SupportsGPT5ChatSampling(modelName, effort string) bool { + if effort != "" && effort != "none" { + return false + } + for _, base := range []string{"gpt-5.1", "gpt-5.2", "gpt-5.4"} { + if modelName == base { + return true + } + if date, ok := strings.CutPrefix(modelName, base+"-"); ok { + if _, err := time.Parse(time.DateOnly, date); err == nil { + return true + } + } + } + return false +} + +// IsOpenAIGPT6AstraModel matches the documented model and dated snapshots, +// without applying Astra restrictions to unknown variants or generations. +func IsOpenAIGPT6AstraModel(modelName string) bool { + const model = "gpt-6-astra" + if modelName == model { + return true + } + snapshot, ok := strings.CutPrefix(modelName, model+"-") + if !ok { + return false + } + _, err := time.Parse(time.DateOnly, snapshot) + return err == nil } func IsQwenThinkingBudgetModel(modelName string) bool { @@ -245,7 +342,7 @@ func (r *GeneralOpenAIRequest) GetSystemRoleName() string { if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") { return "developer" } - } else if IsOpenAIGPT5Model(r.Model) { + } else if IsOpenAIGPT5Model(r.Model) || IsOpenAIGPT6AstraModel(r.Model) { return "developer" } return "system" @@ -316,6 +413,7 @@ type Message struct { Reasoning *string `json:"reasoning,omitempty"` ToolCalls json.RawMessage `json:"tool_calls,omitempty"` ToolCallId string `json:"tool_call_id,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` // Kimi K3 message-scoped tool declarations. parsedContent []MediaContent //parsedStringContent *string } @@ -887,6 +985,7 @@ type OpenAIResponsesRequest struct { Store json.RawMessage `json:"store,omitempty"` PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"` + PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"` // SafetyIdentifier carries client identity for policy abuse detection. // This field is filtered by default and can be enabled via channel setting allow_safety_identifier. SafetyIdentifier json.RawMessage `json:"safety_identifier,omitempty"` @@ -904,6 +1003,13 @@ type OpenAIResponsesRequest struct { // qwen EnableThinking json.RawMessage `json:"enable_thinking,omitempty"` ThinkingBudget json.RawMessage `json:"thinking_budget,omitempty"` + // Client-supplied OpenAI-compatible Responses extensions. + ChatTemplateKwargs json.RawMessage `json:"chat_template_kwargs,omitempty"` + TopK *int `json:"top_k,omitempty"` + MinP *float64 `json:"min_p,omitempty"` + RepetitionPenalty *float64 `json:"repetition_penalty,omitempty"` + Stop json.RawMessage `json:"stop,omitempty"` + CacheSalt *string `json:"cache_salt,omitempty"` // perplexity Preset json.RawMessage `json:"preset,omitempty"` } diff --git a/dto/openai_request_zero_value_test.go b/dto/openai_request_zero_value_test.go index c37c4076..b5a89635 100644 --- a/dto/openai_request_zero_value_test.go +++ b/dto/openai_request_zero_value_test.go @@ -218,6 +218,11 @@ func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) { {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: "astra uses developer", model: "gpt-6-astra", want: "developer"}, + {name: "astra snapshot uses developer", model: "gpt-6-astra-2026-09-03", want: "developer"}, + {name: "unknown astra variant stays system", model: "gpt-6-astra-pro", want: "system"}, + {name: "invalid astra date stays system", model: "gpt-6-astra-2026-02-30", want: "system"}, + {name: "other gpt 6 stays system", model: "gpt-6-other", want: "system"}, {name: "omni is not o series", model: "omni-moderation-latest", want: "system"}, } diff --git a/dto/openai_responses_compaction_request.go b/dto/openai_responses_compaction_request.go index f80e4931..9a8a0ddd 100644 --- a/dto/openai_responses_compaction_request.go +++ b/dto/openai_responses_compaction_request.go @@ -10,10 +10,28 @@ import ( ) type OpenAIResponsesCompactionRequest struct { - Model string `json:"model"` - Input json.RawMessage `json:"input,omitempty"` - Instructions json.RawMessage `json:"instructions,omitempty"` - PreviousResponseID string `json:"previous_response_id,omitempty"` + Model string `json:"model"` + Input json.RawMessage `json:"input,omitempty"` + Instructions json.RawMessage `json:"instructions,omitempty"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` + ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` + PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"` + PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"` + Text json.RawMessage `json:"text,omitempty"` +} + +// ToResponsesRequest preserves compact fields through the common adaptor and +// channel field-filter pipeline. +func (r *OpenAIResponsesCompactionRequest) ToResponsesRequest() *OpenAIResponsesRequest { + return &OpenAIResponsesRequest{ + Model: r.Model, Input: r.Input, Instructions: r.Instructions, PreviousResponseID: r.PreviousResponseID, + Tools: r.Tools, ParallelToolCalls: r.ParallelToolCalls, Reasoning: r.Reasoning, ServiceTier: r.ServiceTier, + PromptCacheKey: r.PromptCacheKey, PromptCacheOptions: r.PromptCacheOptions, PromptCacheRetention: r.PromptCacheRetention, Text: r.Text, + } } func (r *OpenAIResponsesCompactionRequest) GetTokenCountMeta() *types.TokenCountMeta { @@ -24,6 +42,9 @@ func (r *OpenAIResponsesCompactionRequest) GetTokenCountMeta() *types.TokenCount if len(r.Input) > 0 { parts = append(parts, string(r.Input)) } + if len(r.Tools) > 0 { + parts = append(parts, string(r.Tools)) + } return &types.TokenCountMeta{ CombineText: strings.Join(parts, "\n"), } diff --git a/middleware/auth_user_status_test.go b/middleware/auth_user_status_test.go new file mode 100644 index 00000000..be474139 --- /dev/null +++ b/middleware/auth_user_status_test.go @@ -0,0 +1,74 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestAdminStatusChangeBlocksExistingSessionAndTokenWithoutDeletingThem(t *testing.T) { + const actorID, userID = 2011, 2012 + for _, user := range []model.User{ + {Id: actorID, Username: "batch-status-operator", AffCode: "batch-operator", Role: common.RoleAdminUser, Status: 1}, + {Id: userID, Username: "batch-status-account", AffCode: "batch-account", Role: common.RoleCommonUser, Status: 1, Group: "default", Quota: 12345}, + } { + require.NoError(t, model.DB.Create(&user).Error) + } + t.Cleanup(func() { + model.DB.Unscoped().Where("user_id = ?", userID).Delete(&model.Token{}) + model.DB.Unscoped().Where("id IN ?", []int{actorID, userID}).Delete(&model.User{}) + }) + token := seedMiddlewareToken(t, userID, "batchstatuscontracttoken") + router := gin.New() + router.Use(sessions.Sessions("session", cookie.NewStore([]byte("batch-status-session-test-secret")))) + cookies := loginMiddlewareSession(t, router, &model.User{Id: userID, Username: "batch-status-account", Role: common.RoleCommonUser, Status: 1, Group: "default"}) + ok := func(c *gin.Context) { c.Status(http.StatusNoContent) } + router.GET("/token", TokenAuth(), ok) + router.GET("/readonly-token", TokenAuthReadOnly(), ok) + router.GET("/session", UserAuth(), ok) + assertAccess := func(allowed bool) { + t.Helper() + for _, path := range []string{"/token", "/readonly-token", "/session"} { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + if path == "/session" { + for _, cookie := range cookies { + req.AddCookie(cookie) + } + } else { + req.Header.Set("Authorization", "Bearer sk-"+token.Key) + } + router.ServeHTTP(w, req) + if allowed { + require.Equal(t, http.StatusNoContent, w.Code, "%s: %s", path, w.Body.String()) + } else if path == "/session" { + require.NotEqual(t, http.StatusNoContent, w.Code) + require.Contains(t, w.Body.String(), `"success":false`) + } else { + require.Equal(t, http.StatusForbidden, w.Code, "%s: %s", path, w.Body.String()) + } + } + } + assertAccess(true) + _, err := model.SetUserStatusByAdmin(context.Background(), actorID, userID, 1, 2) + require.NoError(t, err) + assertAccess(false) + _, err = model.SetUserStatusByAdmin(context.Background(), actorID, userID, 2, 1) + require.NoError(t, err) + assertAccess(true) + var stored model.Token + require.NoError(t, model.DB.First(&stored, token.Id).Error) + require.Equal(t, token.Status, stored.Status) + require.Equal(t, token.RemainQuota, stored.RemainQuota) + var user model.User + require.NoError(t, model.DB.First(&user, userID).Error) + require.EqualValues(t, 12345, user.Quota) +} diff --git a/model/user_status.go b/model/user_status.go new file mode 100644 index 00000000..de0192f0 --- /dev/null +++ b/model/user_status.go @@ -0,0 +1,105 @@ +package model + +import ( + "context" + "errors" + + "github.com/MAX-API-Next/MAX-API/common" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrUserStatusForbidden = errors.New("user status change not permitted") + ErrUserStatusConflict = errors.New("user status changed; refresh before retrying") +) + +type AdminUserStatusChange struct { + Username string + PreviousStatus int + Status int + Changed bool +} + +// SetUserStatusByAdmin changes only account status, never balances, credentials, +// subscriptions or task records. Re-check both identities inside the transaction; +// a controller's cached role or a target's list-page snapshot is not authority. +func SetUserStatusByAdmin(ctx context.Context, actorID, targetID, expectedStatus, status int) (AdminUserStatusChange, error) { + var result AdminUserStatusChange + if actorID <= 0 || targetID <= 0 || actorID == targetID { + return result, ErrUserStatusForbidden + } + if (status != common.UserStatusEnabled && status != common.UserStatusDisabled) || + (expectedStatus != common.UserStatusEnabled && expectedStatus != common.UserStatusDisabled) { + return result, ErrUserStatusConflict + } + var cacheTask CacheInvalidationTask + if err := ctx.Err(); err != nil { + return result, err + } + // Keep advisory-lock release independent of client cancellation. Only the + // transaction uses the request context, so a cancelled request cannot leave + // a session-level MySQL/PostgreSQL lock on a pooled connection. + err := withUserOAuthIdentityMutationLock(DB, func(conn *gorm.DB) error { + return conn.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var users []User + // Deterministic lock order also protects against concurrent role changes. + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id", "username", "role", "status"). + Where("id IN ?", []int{actorID, targetID}).Order("id").Find(&users).Error; err != nil { + return err + } + var actor, target *User + for i := range users { + if users[i].Id == actorID { + actor = &users[i] + } + if users[i].Id == targetID { + target = &users[i] + } + } + if actor == nil || actor.Status != common.UserStatusEnabled || actor.Role < common.RoleAdminUser { + return ErrUserStatusForbidden + } + if target == nil { + return gorm.ErrRecordNotFound + } + if target.Role >= common.RoleRootUser || actor.Role <= target.Role { + return ErrUserStatusForbidden + } + result = AdminUserStatusChange{Username: target.Username, PreviousStatus: target.Status, Status: target.Status} + if target.Status == status { + return nil + } + if target.Status != expectedStatus { + return ErrUserStatusConflict + } + update := tx.Model(&User{}). + Where("id = ? AND status = ? AND role = ?", targetID, expectedStatus, target.Role). + Update("status", status) + if update.Error != nil { + return update.Error + } + if update.RowsAffected != 1 { + return ErrUserStatusConflict + } + var err error + cacheTask, err = stageUserCacheInvalidationTx(tx, targetID, false) + if err != nil { + return err + } + result.Status = status + result.Changed = true + return nil + }) + }) + if err != nil { + // A commit/connection-release error can have an unknown outcome. Do not + // automatically repeat it or return a confirmed-success projection. + return AdminUserStatusChange{}, err + } + // TokenAuth and session auth check the user cache. Its durable pending-task + // fence prevents stale enabled snapshots being trusted if Redis is unavailable. + dispatchStagedCacheInvalidation(cacheTask) + return result, nil +} diff --git a/model/user_status_test.go b/model/user_status_test.go new file mode 100644 index 00000000..457433a4 --- /dev/null +++ b/model/user_status_test.go @@ -0,0 +1,162 @@ +package model + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func seedAdminStatusUsers(t *testing.T) { + t.Helper() + setupUserUpdateTestState(t) + for _, user := range []User{ + {Id: 501, Username: "status-root", Role: common.RoleRootUser, Status: common.UserStatusEnabled}, + {Id: 502, Username: "status-admin", Role: common.RoleAdminUser, Status: common.UserStatusEnabled}, + {Id: 503, Username: "status-peer", Role: common.RoleAdminUser, Status: common.UserStatusEnabled}, + {Id: 504, Username: "status-user", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Quota: 12345, UsedQuota: 678, RequestCount: 9}, + } { + user.AffCode = user.Username + require.NoError(t, DB.Create(&user).Error) + } +} + +func TestAdminUserStatusChangesOnlyStatusAndIsRepeatSafe(t *testing.T) { + seedAdminStatusUsers(t) + for _, status := range []int{common.UserStatusDisabled, common.UserStatusEnabled} { + expected := common.UserStatusEnabled + if status == common.UserStatusEnabled { + expected = common.UserStatusDisabled + } + changed, err := SetUserStatusByAdmin(context.Background(), 502, 504, expected, status) + require.NoError(t, err) + require.True(t, changed.Changed) + again, err := SetUserStatusByAdmin(context.Background(), 502, 504, expected, status) + require.NoError(t, err) + require.False(t, again.Changed) + var user User + require.NoError(t, DB.First(&user, 504).Error) + require.Equal(t, status, user.Status) + require.EqualValues(t, 12345, user.Quota) + require.EqualValues(t, 678, user.UsedQuota) + require.Equal(t, 9, user.RequestCount) + require.Equal(t, common.RoleCommonUser, user.Role) + require.False(t, user.DeletedAt.Valid) + } +} + +func TestAdminUserStatusRejectsProtectedAndStaleTargets(t *testing.T) { + for _, tc := range []struct { + name string + actor, target, expected int + want error + }{ + {"self", 502, 502, 1, ErrUserStatusForbidden}, + {"root", 501, 501, 1, ErrUserStatusForbidden}, + {"peer", 502, 503, 1, ErrUserStatusForbidden}, + {"higher", 502, 501, 1, ErrUserStatusForbidden}, + {"non-admin", 504, 503, 1, ErrUserStatusForbidden}, + {"missing-actor", 999, 504, 1, ErrUserStatusForbidden}, + {"missing-target", 502, 999, 1, gorm.ErrRecordNotFound}, + {"stale-status", 502, 504, 2, ErrUserStatusConflict}, + } { + t.Run(tc.name, func(t *testing.T) { + seedAdminStatusUsers(t) + _, err := SetUserStatusByAdmin(context.Background(), tc.actor, tc.target, tc.expected, 2) + require.ErrorIs(t, err, tc.want) + var user User + require.NoError(t, DB.First(&user, 504).Error) + require.Equal(t, common.UserStatusEnabled, user.Status) + }) + } +} + +func TestAdminUserStatusRechecksActorAndTargetFromDatabase(t *testing.T) { + seedAdminStatusUsers(t) + require.NoError(t, DB.Model(&User{}).Where("id = ?", 502).Update("status", 2).Error) + _, err := SetUserStatusByAdmin(context.Background(), 502, 504, 1, 2) + require.ErrorIs(t, err, ErrUserStatusForbidden) + require.NoError(t, DB.Model(&User{}).Where("id = ?", 504).Update("role", common.RoleRootUser).Error) + _, err = SetUserStatusByAdmin(context.Background(), 501, 504, 1, 2) + require.ErrorIs(t, err, ErrUserStatusForbidden) + require.NoError(t, DB.Delete(&User{}, 503).Error) + _, err = SetUserStatusByAdmin(context.Background(), 501, 503, 1, 2) + require.ErrorIs(t, err, gorm.ErrRecordNotFound) +} + +func TestAdminUserStatusConcurrentDuplicatesChangeOnce(t *testing.T) { + seedAdminStatusUsers(t) + var changed atomic.Int32 + var wg sync.WaitGroup + errorsCh := make(chan error, 6) + for range 6 { + wg.Add(1) + go func() { + defer wg.Done() + result, err := SetUserStatusByAdmin(context.Background(), 502, 504, 1, 2) + if result.Changed { + changed.Add(1) + } + errorsCh <- err + }() + } + wg.Wait() + close(errorsCh) + for err := range errorsCh { + require.NoError(t, err) + } + require.EqualValues(t, 1, changed.Load()) +} + +func TestAdminUserStatusAllowsRootToManageAdminAndRejectsCancelledWork(t *testing.T) { + seedAdminStatusUsers(t) + result, err := SetUserStatusByAdmin(context.Background(), 501, 502, 1, 2) + require.NoError(t, err) + require.True(t, result.Changed) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = SetUserStatusByAdmin(ctx, 501, 504, 1, 2) + require.ErrorIs(t, err, context.Canceled) + var user User + require.NoError(t, DB.First(&user, 504).Error) + require.Equal(t, common.UserStatusEnabled, user.Status) + for _, values := range [][2]int{{0, 2}, {1, -1}, {1, 100}} { + _, err = SetUserStatusByAdmin(context.Background(), 501, 504, values[0], values[1]) + require.ErrorIs(t, err, ErrUserStatusConflict) + } +} + +func TestAdminUserStatusRollsBackWhenCacheIntentFails(t *testing.T) { + seedAdminStatusUsers(t) + useFailingUserUpdateRedis(t) + const callback = "test:status-outbox-error" + require.NoError(t, DB.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) { + if tx.Statement.Table == "cache_invalidation_tasks" { + tx.AddError(errors.New("outbox unavailable")) + } + })) + t.Cleanup(func() { DB.Callback().Create().Remove(callback) }) + _, err := SetUserStatusByAdmin(context.Background(), 502, 504, 1, 2) + require.ErrorContains(t, err, "outbox unavailable") + var user User + require.NoError(t, DB.First(&user, 504).Error) + require.Equal(t, common.UserStatusEnabled, user.Status) +} + +func TestAdminUserStatusKeepsDurableCacheIntentOnRedisFailure(t *testing.T) { + seedAdminStatusUsers(t) + useFailingUserUpdateRedis(t) + result, err := SetUserStatusByAdmin(context.Background(), 502, 504, 1, 2) + require.NoError(t, err) + require.True(t, result.Changed) + var task CacheInvalidationTask + require.NoError(t, DB.Where("kind = ? AND entity_key = ?", cacheInvalidationKindUser, "504").First(&task).Error) + cached, err := GetUserCache(504) + require.NoError(t, err) + require.Equal(t, common.UserStatusDisabled, cached.Status) +} diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go index ac59aebf..f549c36a 100644 --- a/relay/channel/ali/image.go +++ b/relay/channel/ali/image.go @@ -293,7 +293,10 @@ func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, originBody [ } func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.MaxAPIError, *dto.Usage) { - responseFormat := c.GetString("response_format") + responseFormat := "" + if request, ok := info.Request.(*dto.ImageRequest); ok && request != nil { + responseFormat = request.ResponseFormat + } var aliTaskResponse AliResponse responseBody, err := io.ReadAll(resp.Body) diff --git a/relay/channel/ali/image_response_format_test.go b/relay/channel/ali/image_response_format_test.go new file mode 100644 index 00000000..b65a383c --- /dev/null +++ b/relay/channel/ali/image_response_format_test.go @@ -0,0 +1,76 @@ +package ali + +import ( + "encoding/base64" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/MAX-API-Next/MAX-API/common" + "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/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestAliImageResponseFormatUsesOriginalRequest(t *testing.T) { + var downloads atomic.Int32 + imageBytes := []byte("synthetic image fixture") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + downloads.Add(1) + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + })) + defer server.Close() + fetch := system_setting.GetFetchSetting() + previous := *fetch + t.Cleanup(func() { *fetch = previous }) + _, port, err := net.SplitHostPort(server.Listener.Addr().String()) + require.NoError(t, err) + fetch.EnableSSRFProtection = true + fetch.AllowPrivateIp = true + fetch.AllowedPorts = []string{port} + fetch.DomainList = nil + fetch.IpList = nil + fetch.DomainFilterMode = false + fetch.IpFilterMode = false + for _, shape := range []string{"results", "choices"} { + for _, format := range []string{"", "url", "b64_json"} { + t.Run(shape+format, func(t *testing.T) { + before := downloads.Load() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/images/generations", nil) + c.Set("response_format", "url") // stale context must not override the original DTO + output := map[string]any{"results": []any{map[string]any{"url": server.URL + "/image.png"}}} + if shape == "choices" { + output = map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": []any{map[string]any{"image": server.URL + "/image.png"}}}}}} + } + body, err := common.Marshal(map[string]any{"output": output, "usage": map[string]any{"image_count": 1}}) + require.NoError(t, err) + resp := &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body)))} + info := &relaycommon.RelayInfo{Request: &dto.ImageRequest{ResponseFormat: format}, StartTime: time.Unix(1700000000, 0)} + apiErr, usage := aliImageHandler(&Adaptor{IsSyncImageModel: true}, c, resp, info) + require.Nil(t, apiErr) + require.NotNil(t, usage) + var result dto.ImageResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &result)) + require.Len(t, result.Data, 1) + require.Equal(t, 1.0, info.PriceData.OtherRatios["n"]) + if format == "b64_json" { + require.Equal(t, base64.StdEncoding.EncodeToString(imageBytes), result.Data[0].B64Json) + require.Equal(t, before+1, downloads.Load()) + } else { + require.Empty(t, result.Data[0].B64Json) + require.Equal(t, before, downloads.Load()) + } + }) + } + } +} diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 860099b0..0ae63c68 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -10,6 +10,7 @@ import ( "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/relay/channel" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/relay/reasoningcompat" "github.com/MAX-API-Next/MAX-API/setting/model_setting" "github.com/MAX-API-Next/MAX-API/types" @@ -95,7 +96,18 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if request == nil { return nil, errors.New("request is nil") } - return RequestOpenAI2ClaudeMessage(c, *request) + origin := request.Model + if info != nil { + origin = info.OriginModelName + } + converted, err := RequestOpenAI2ClaudeMessage(c, *request, origin) + if err == nil && info != nil { + if info.ChannelMeta != nil { + info.UpstreamModelName = converted.Model + } + info.ReasoningEffort = reasoningcompat.ClaudeEffort(converted) + } + return converted, err } func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index ad80b1a1..4474de90 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -1,6 +1,7 @@ package claude import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -12,13 +13,12 @@ import ( "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/logger" - "github.com/MAX-API-Next/MAX-API/relay/channel/openrouter" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/relay/reasoningcompat" "github.com/MAX-API-Next/MAX-API/relay/reasonmap" "github.com/MAX-API-Next/MAX-API/service" "github.com/MAX-API-Next/MAX-API/setting/model_setting" - "github.com/MAX-API-Next/MAX-API/setting/reasoning" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" @@ -45,7 +45,10 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) { } } -func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) { +func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest, origin ...string) (*dto.ClaudeRequest, error) { + if textRequest.HasMessageTools() { + return nil, fmt.Errorf("message-scoped tools cannot be converted to Claude") + } claudeTools := make([]any, 0, len(textRequest.Tools)) for _, tool := range textRequest.Tools { @@ -129,8 +132,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe if len(claudeTools) > 0 { claudeRequest.Tools = claudeTools } - if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 { - claudeRequest.MaxTokens = common.GetPointer(maxTokens) + claudeRequest.MaxTokens = textRequest.MaxCompletionTokens + if claudeRequest.MaxTokens == nil { + claudeRequest.MaxTokens = textRequest.MaxTokens } if textRequest.TopP != nil { claudeRequest.TopP = common.GetPointer(*textRequest.TopP) @@ -150,99 +154,40 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe } } - if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 { + if claudeRequest.MaxTokens == nil { defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model)) claudeRequest.MaxTokens = &defaultMaxTokens } - if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" && - (strings.HasPrefix(textRequest.Model, "claude-opus-4-6") || - strings.HasPrefix(textRequest.Model, "claude-opus-4-7") || - strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) { - claudeRequest.Model = baseModel - claudeRequest.Thinking = &dto.Thinking{ - Type: "adaptive", - } - claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel)) - if strings.HasPrefix(baseModel, "claude-opus-4-7") || - strings.HasPrefix(baseModel, "claude-opus-4-8") { - // Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400 - // and defaults display to "omitted"; restore the 4.6 visible summary. - claudeRequest.Thinking.Display = "summarized" - claudeRequest.Temperature = nil - claudeRequest.TopP = nil - claudeRequest.TopK = nil - } else { - claudeRequest.TopP = nil - claudeRequest.Temperature = common.GetPointer[float64](1.0) - } - } else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && - strings.HasSuffix(textRequest.Model, "-thinking") { - - trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking") - if strings.HasPrefix(trimmedModel, "claude-opus-4-7") || - strings.HasPrefix(trimmedModel, "claude-opus-4-8") { - // Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort. - claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"} - claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`) - claudeRequest.Temperature = nil - claudeRequest.TopP = nil - claudeRequest.TopK = nil - } else { - // 因为BudgetTokens 必须大于1024 - if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 { - claudeRequest.MaxTokens = common.GetPointer[uint](1280) - } - - // BudgetTokens 为 max_tokens 的 80% - claudeRequest.Thinking = &dto.Thinking{ - Type: "enabled", - BudgetTokens: common.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), - } - // TODO: 临时处理 - // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking - claudeRequest.TopP = nil - claudeRequest.Temperature = common.GetPointer[float64](1.0) - } - if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) { - claudeRequest.Model = trimmedModel - } + intent, err := reasoningcompat.FromChat(textRequest) + if err != nil { + return nil, err } - - if textRequest.ReasoningEffort != "" { - switch textRequest.ReasoningEffort { - case "low": - claudeRequest.Thinking = &dto.Thinking{ - Type: "enabled", - BudgetTokens: common.GetPointer[int](1280), - } - case "medium": - claudeRequest.Thinking = &dto.Thinking{ - Type: "enabled", - BudgetTokens: common.GetPointer[int](2048), - } - case "high": - claudeRequest.Thinking = &dto.Thinking{ - Type: "enabled", - BudgetTokens: common.GetPointer[int](4096), - } - } + base, suffix, found, err := reasoningcompat.ParseSuffix(textRequest.Model, "claude", model_setting.GetClaudeSettings().ThinkingAdapterEnabled) + if len(origin) > 0 && model_setting.ShouldPreserveThinkingSuffix(origin[0]) { + found = false + err = nil } - - // 指定了 reasoning 参数,覆盖 budgetTokens - if textRequest.Reasoning != nil { - var reasoning openrouter.RequestReasoning - if err := common.Unmarshal(textRequest.Reasoning, &reasoning); err != nil { + if err != nil { + return nil, err + } + if found { + intent, err = reasoningcompat.MergeSuffix(intent, suffix) + if err != nil { return nil, err } - - budgetTokens := reasoning.MaxTokens - if budgetTokens > 0 { - claudeRequest.Thinking = &dto.Thinking{ - Type: "enabled", - BudgetTokens: &budgetTokens, - } - } + claudeRequest.Model = base + } + _, notes, err := reasoningcompat.ApplyClaude(&claudeRequest, intent) + if err != nil { + return nil, err + } + var logContext context.Context = context.Background() + if c != nil { + logContext = c + } + for _, note := range notes { + logger.LogWarn(logContext, note) } if textRequest.Stop != nil { diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index 336e9242..c001c1d6 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -94,11 +94,17 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn } func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) error { + if reasoning.PreserveModelSuffix(request.Model) || (info != nil && reasoning.PreserveModelSuffix(info.OriginModelName)) { + return nil + } modelName := request.Model if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" { modelName = info.UpstreamModelName } baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName) + if reasoning.PreserveModelSuffix(modelName) { + return nil + } if !ok { return nil } @@ -121,11 +127,17 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d } func applyDeepSeekV4ClaudeThinkingSuffix(info *relaycommon.RelayInfo, request *dto.ClaudeRequest) error { + if reasoning.PreserveModelSuffix(request.Model) || (info != nil && reasoning.PreserveModelSuffix(info.OriginModelName)) { + return nil + } modelName := request.Model if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" { modelName = info.UpstreamModelName } baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName) + if reasoning.PreserveModelSuffix(modelName) { + return nil + } if !ok { return nil } diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 6f758cbb..a502ce4d 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -14,7 +14,6 @@ import ( "github.com/MAX-API-Next/MAX-API/relay/constant" "github.com/MAX-API-Next/MAX-API/service" "github.com/MAX-API-Next/MAX-API/setting/model_setting" - "github.com/MAX-API-Next/MAX-API/setting/reasoning" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" @@ -130,18 +129,13 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - if model_setting.GetGeminiSettings().ThinkingAdapterEnabled && - !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) { - // 新增逻辑:处理 -thinking- 格式 - if strings.Contains(info.UpstreamModelName, "-thinking-") { - parts := strings.Split(info.UpstreamModelName, "-thinking-") - info.UpstreamModelName = parts[0] - } else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配 - info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") - } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { - info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") - } else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" { - info.UpstreamModelName = baseModel + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled && !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) { + base, _, found, err := parseThinkingSuffix(info.UpstreamModelName) + if err != nil { + return "", err + } + if found { + info.UpstreamModelName = base } } diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 74fe499f..95b999e5 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -19,9 +19,9 @@ import ( "github.com/MAX-API-Next/MAX-API/relay/channel/openai" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/relay/reasoningcompat" "github.com/MAX-API-Next/MAX-API/service" "github.com/MAX-API-Next/MAX-API/setting/model_setting" - "github.com/MAX-API-Next/MAX-API/setting/reasoning" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" "github.com/samber/lo" @@ -52,153 +52,95 @@ var geminiSupportedMimeTypes = map[string]bool{ const thoughtSignatureBypassValue = "context_engineering_is_the_way_to_go" -// Gemini 允许的思考预算范围 -const ( - pro25MinBudget = 128 - pro25MaxBudget = 32768 - flash25MaxBudget = 24576 - flash25LiteMinBudget = 512 - flash25LiteMaxBudget = 24576 -) - -func isNew25ProModel(modelName string) bool { - return strings.HasPrefix(modelName, "gemini-2.5-pro") && - !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") && - !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25") -} - -func is25FlashLiteModel(modelName string) bool { - return strings.HasPrefix(modelName, "gemini-2.5-flash-lite") +// The image-model alias shares Gemini thinking controls, but not its name +// prefix. Keep this exception local so other providers' opaque names survive. +func parseThinkingSuffix(model string) (string, reasoningcompat.Intent, bool, error) { + if model == "nano-banana-pro" || strings.HasPrefix(model, "nano-banana-pro-") { + return reasoningcompat.ParseSuffix(model, "nano-banana", true) + } + return reasoningcompat.ParseSuffix(model, "gemini", true) } -// clampThinkingBudget 根据模型名称将预算限制在允许的范围内 -func clampThinkingBudget(modelName string, budget int) int { - isNew25Pro := isNew25ProModel(modelName) - is25FlashLite := is25FlashLiteModel(modelName) - - if is25FlashLite { - if budget < flash25LiteMinBudget { - return flash25LiteMinBudget +func ThinkingAdaptor(c *gin.Context, req *dto.GeminiChatRequest, info *relaycommon.RelayInfo, chat ...dto.GeneralOpenAIRequest) error { + model := info.UpstreamModelName + intent := reasoningcompat.Intent{} + var err error + if len(chat) > 0 { + intent, err = reasoningcompat.FromChat(chat[0]) + if err != nil { + return err } - if budget > flash25LiteMaxBudget { - return flash25LiteMaxBudget + } + base, suffix, found := model, reasoningcompat.Intent{}, false + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled && !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) { + base, suffix, found, err = parseThinkingSuffix(model) + if err != nil { + return err + } + } + config := req.GenerationConfig.ThinkingConfig + // A native request without aliases retains exactly its own controls. + if len(chat) == 0 && !found { + return nil + } + if config != nil { + if config.ThinkingBudget != nil && config.ThinkingLevel != "" { + return fmt.Errorf("thinkingBudget and thinkingLevel are mutually exclusive") + } + native := reasoningcompat.Intent{Budget: config.ThinkingBudget, Include: config.IncludeThoughts} + if config.ThinkingLevel != "" { + native.Effort, err = reasoningcompat.NormalizeEffort(config.ThinkingLevel) + if err != nil { + return err + } } - } else if isNew25Pro { - if budget < pro25MinBudget { - return pro25MinBudget + if native.Budget != nil || native.Effort != "" { + native.Mode = "enabled" } - if budget > pro25MaxBudget { - return pro25MaxBudget + if native.Effort == "none" || (native.Budget != nil && *native.Budget == 0) { + native.Mode = "disabled" } - } else { // 其他模型 - if budget < 0 { - return 0 + intent, err = reasoningcompat.MergeSuffix(intent, native) + if err != nil { + return err } - if budget > flash25MaxBudget { - return flash25MaxBudget + if native.Include != nil { + intent.Include = native.Include } } - return budget -} - -// "effort": "high" - Allocates a large portion of tokens for reasoning (approximately 80% of max_tokens) -// "effort": "medium" - Allocates a moderate portion of tokens (approximately 50% of max_tokens) -// "effort": "low" - Allocates a smaller portion of tokens (approximately 20% of max_tokens) -// "effort": "minimal" - Allocates a minimal portion of tokens (approximately 5% of max_tokens) -func clampThinkingBudgetByEffort(modelName string, effort string) int { - isNew25Pro := isNew25ProModel(modelName) - is25FlashLite := is25FlashLiteModel(modelName) - - maxBudget := 0 - if is25FlashLite { - maxBudget = flash25LiteMaxBudget - } - if isNew25Pro { - maxBudget = pro25MaxBudget - } else { - maxBudget = flash25MaxBudget - } - switch effort { - case "high": - maxBudget = maxBudget * 80 / 100 - case "medium": - maxBudget = maxBudget * 50 / 100 - case "low": - maxBudget = maxBudget * 20 / 100 - case "minimal": - maxBudget = maxBudget * 5 / 100 - } - return clampThinkingBudget(modelName, maxBudget) -} - -func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) { - if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { - modelName := info.UpstreamModelName - isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && - !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") && - !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25") - - if strings.Contains(modelName, "-thinking-") { - parts := strings.SplitN(modelName, "-thinking-", 2) - if len(parts) == 2 && parts[1] != "" { - if budgetTokens, err := strconv.Atoi(parts[1]); err == nil { - clampedBudget := clampThinkingBudget(modelName, budgetTokens) - geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ - ThinkingBudget: common.GetPointer(clampedBudget), - IncludeThoughts: true, - } - } - } - } else if strings.HasSuffix(modelName, "-thinking") { - unsupportedModels := []string{ - "gemini-2.5-pro-preview-05-06", - "gemini-2.5-pro-preview-03-25", - } - isUnsupported := false - for _, unsupportedModel := range unsupportedModels { - if strings.HasPrefix(modelName, unsupportedModel) { - isUnsupported = true - break - } - } - - if isUnsupported { - geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ - IncludeThoughts: true, - } - } else { - geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ - IncludeThoughts: true, - } - if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 { - budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(*geminiRequest.GenerationConfig.MaxOutputTokens) - clampedBudget := clampThinkingBudget(modelName, int(budgetTokens)) - geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget) - } else { - if len(oaiRequest) > 0 { - // 如果有reasoningEffort参数,则根据其值设置思考预算 - geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampThinkingBudgetByEffort(modelName, oaiRequest[0].ReasoningEffort)) - } - } - } - } else if strings.HasSuffix(modelName, "-nothinking") { - if !isNew25Pro { - geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ - ThinkingBudget: common.GetPointer(0), - } - } - } else if _, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" { - geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ - IncludeThoughts: true, - ThinkingLevel: level, - } - info.ReasoningEffort = level + if found { + intent, err = reasoningcompat.MergeSuffix(intent, suffix) + if err != nil { + return err } } + if intent.Empty() { + return nil + } + if intent.Include == nil && intent.Mode != "disabled" { + intent.Include = common.GetPointer(true) + } + converted, effort, notes, err := reasoningcompat.GeminiConfig(base, intent, config, req.GenerationConfig.MaxOutputTokens, model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage) + if err != nil { + return err + } + req.GenerationConfig.ThinkingConfig = converted + info.ReasoningEffort = effort + var logContext context.Context = context.Background() + if c != nil { + logContext = c + } + for _, note := range notes { + logger.LogWarn(logContext, note) + } + return nil } // Setting safety to the lowest possible values since Gemini is already powerless enough func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) { + if textRequest.HasMessageTools() { + return nil, fmt.Errorf("message-scoped tools cannot be converted to Gemini") + } geminiRequest := dto.GeminiChatRequest{ Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)), @@ -207,15 +149,16 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i }, } - if textRequest.TopP != nil && *textRequest.TopP > 0 { + if textRequest.TopP != nil { geminiRequest.GenerationConfig.TopP = common.GetPointer(*textRequest.TopP) } - if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 { - geminiRequest.GenerationConfig.MaxOutputTokens = common.GetPointer(maxTokens) + geminiRequest.GenerationConfig.MaxOutputTokens = textRequest.MaxCompletionTokens + if geminiRequest.GenerationConfig.MaxOutputTokens == nil { + geminiRequest.GenerationConfig.MaxOutputTokens = textRequest.MaxTokens } - if textRequest.Seed != nil && *textRequest.Seed != 0 { + if textRequest.Seed != nil { geminiSeed := int64(lo.FromPtr(textRequest.Seed)) geminiRequest.GenerationConfig.Seed = common.GetPointer(geminiSeed) } @@ -238,8 +181,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i geminiRequest.GenerationConfig.StopSequences = stopSequences } - adaptorWithExtraBody := false - // patch extra_body if len(textRequest.ExtraBody) > 0 { var extraBody map[string]interface{} @@ -249,72 +190,33 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i // eg. {"google":{"thinking_config":{"thinking_budget":5324,"include_thoughts":true}}} if googleBody, ok := extraBody["google"].(map[string]interface{}); ok { - if !strings.HasSuffix(info.UpstreamModelName, "-nothinking") { - adaptorWithExtraBody = true - // check error param name like thinkingConfig, should be thinking_config - if _, hasErrorParam := googleBody["thinkingConfig"]; hasErrorParam { - return nil, errors.New("extra_body.google.thinkingConfig is not supported, use extra_body.google.thinking_config instead") + if _, exists := googleBody["thinkingConfig"]; exists { + return nil, errors.New("extra_body.google.thinkingConfig is not supported, use thinking_config") + } + if value, exists := googleBody["thinking_config"]; exists && value != nil { + config, ok := value.(map[string]any) + if !ok { + return nil, errors.New("extra_body.google.thinking_config must be an object") } - - if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok { - // check error param name like thinkingBudget, should be thinking_budget - if _, hasErrorParam := thinkingConfig["thinkingBudget"]; hasErrorParam { - return nil, errors.New("extra_body.google.thinking_config.thinkingBudget is not supported, use extra_body.google.thinking_config.thinking_budget instead") - } - var hasThinkingConfig bool - var tempThinkingConfig dto.GeminiThinkingConfig - - if thinkingBudget, exists := thinkingConfig["thinking_budget"]; exists { - switch v := thinkingBudget.(type) { - case float64: - budgetInt := int(v) - tempThinkingConfig.ThinkingBudget = common.GetPointer(budgetInt) - if budgetInt > 0 { - // 有正数预算 - tempThinkingConfig.IncludeThoughts = true - } else { - // 存在但为0或负数,禁用思考 - tempThinkingConfig.IncludeThoughts = false - } - hasThinkingConfig = true - default: - return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer") - } - } - - if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists { - if v, ok := includeThoughts.(bool); ok { - tempThinkingConfig.IncludeThoughts = v - hasThinkingConfig = true - } else { - return nil, errors.New("extra_body.google.thinking_config.include_thoughts must be a boolean") - } - } - if thinkingLevel, exists := thinkingConfig["thinking_level"]; exists { - if v, ok := thinkingLevel.(string); ok { - tempThinkingConfig.ThinkingLevel = v - hasThinkingConfig = true - } else { - return nil, errors.New("extra_body.google.thinking_config.thinking_level must be a string") - } - } - - if hasThinkingConfig { - // 避免 panic: 仅在获得配置时分配,防止后续赋值时空指针 - if geminiRequest.GenerationConfig.ThinkingConfig == nil { - geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig - } else { - // 如果已分配,则合并内容 - if tempThinkingConfig.ThinkingBudget != nil { - geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = tempThinkingConfig.ThinkingBudget - } - geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = tempThinkingConfig.IncludeThoughts - if tempThinkingConfig.ThinkingLevel != "" { - geminiRequest.GenerationConfig.ThinkingConfig.ThinkingLevel = tempThinkingConfig.ThinkingLevel - } - } + for _, key := range []string{"thinkingBudget", "thinkingLevel", "includeThoughts"} { + if _, exists := config[key]; exists { + return nil, fmt.Errorf("extra_body.google.thinking_config.%s must use snake_case", key) } } + data, err := common.Marshal(config) + if err != nil { + return nil, err + } + var thinking dto.GeminiThinkingConfig + if err := common.Unmarshal(data, &thinking); err != nil { + return nil, fmt.Errorf("invalid thinking_config: %w", err) + } + if thinking.ThinkingBudget != nil && *thinking.ThinkingBudget < -1 { + return nil, errors.New("thinking_budget must be >= -1") + } + if thinking.ThinkingBudget != nil || thinking.ThinkingLevel != "" || thinking.IncludeThoughts != nil { + geminiRequest.GenerationConfig.ThinkingConfig = &thinking + } } // check error param name like imageConfig, should be image_config @@ -352,8 +254,8 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i } } - if !adaptorWithExtraBody { - ThinkingAdaptor(&geminiRequest, info, textRequest) + if err := ThinkingAdaptor(c, &geminiRequest, info, textRequest); err != nil { + return nil, err } safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(SafetySettingList)) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index bc9cf272..4f8292a5 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -315,10 +315,32 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn } } - isOModel := dto.IsOpenAIReasoningOModel(info.UpstreamModelName) - isGPT5Model := dto.IsOpenAIGPT5Model(info.UpstreamModelName) - if isOModel || isGPT5Model { - if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 { + // Capability checks are case-insensitive, like suffix parsing. Preserve the + // original spelling in the actual provider model and billing identity. + modelCapabilities := strings.ToLower(info.UpstreamModelName) + isOModel := dto.IsOpenAIReasoningOModel(modelCapabilities) + isGPT5Model := dto.IsOpenAIGPT5Model(modelCapabilities) + astraEffort, astraModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName) + if model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName) { + astraEffort, astraModel = "", info.UpstreamModelName + } + isAstraModel := dto.IsOpenAIGPT6AstraModel(strings.ToLower(astraModel)) + if isOModel || isGPT5Model || isAstraModel { + // Resolve aliases before checking the effective model's capabilities. + if astraEffort != "" { + request.ReasoningEffort = astraEffort + info.UpstreamModelName = astraModel + request.Model = astraModel + } + modelCapabilities = strings.ToLower(info.UpstreamModelName) + if isAstraModel { + // Astra rejects max_tokens. Preserve an explicitly supplied new limit, + // including zero, and never send both parameter names to the provider. + if request.MaxCompletionTokens == nil { + request.MaxCompletionTokens = request.MaxTokens + } + request.MaxTokens = nil + } else if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 { request.MaxCompletionTokens = request.MaxTokens request.MaxTokens = nil } @@ -327,25 +349,17 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn request.Temperature = nil } - // gpt-5系列模型适配 归零不再支持的参数 - if isGPT5Model { + if isAstraModel || (isGPT5Model && !dto.SupportsGPT5ChatSampling(modelCapabilities, request.ReasoningEffort)) { request.Temperature = nil request.TopP = nil request.LogProbs = nil - } - - // 转换模型推理力度后缀 - effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName) - if effort != "" { - request.ReasoningEffort = effort - info.UpstreamModelName = originModel - request.Model = originModel + request.TopLogProbs = nil } info.ReasoningEffort = request.ReasoningEffort // o系列模型developer适配(o1-mini除外) - if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") { + if !strings.HasPrefix(modelCapabilities, "o1-mini") && !strings.HasPrefix(modelCapabilities, "o1-preview") { //修改第一个Message的内容,将system改为developer if len(request.Messages) > 0 && request.Messages[0].Role == "system" { request.Messages[0].Role = "developer" @@ -592,6 +606,9 @@ func openValidatedImageFile(fileHeader *multipart.FileHeader) (multipart.File, s func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { // 转换模型推理力度后缀 effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(request.Model) + if info != nil && reasoning.PreserveModelSuffix(info.OriginModelName) { + effort, originModel = "", request.Model + } if effort != "" { if request.Reasoning == nil { request.Reasoning = &dto.Reasoning{ @@ -601,6 +618,9 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo request.Reasoning.Effort = effort } request.Model = originModel + if info != nil && info.ChannelMeta != nil { + info.UpstreamModelName = originModel + } } if info != nil && request.Reasoning != nil && request.Reasoning.Effort != "" { info.ReasoningEffort = request.Reasoning.Effort diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index 6252d380..05007ac9 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -10,6 +10,7 @@ import ( "github.com/MAX-API-Next/MAX-API/relay/channel" "github.com/MAX-API-Next/MAX-API/relay/channel/openai" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/setting/reasoning" "github.com/MAX-API-Next/MAX-API/types" "github.com/MAX-API-Next/MAX-API/relay/constant" @@ -78,10 +79,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn request.MaxCompletionTokens = request.MaxTokens request.MaxTokens = nil } - if strings.HasSuffix(request.Model, "-high") { + preserve := reasoning.PreserveModelSuffix(info.OriginModelName) || reasoning.PreserveModelSuffix(request.Model) || reasoning.PreserveModelSuffix(info.UpstreamModelName) + if !preserve && strings.HasSuffix(request.Model, "-high") { request.ReasoningEffort = "high" request.Model = strings.TrimSuffix(request.Model, "-high") - } else if strings.HasSuffix(request.Model, "-low") { + } else if !preserve && strings.HasSuffix(request.Model, "-low") { request.ReasoningEffort = "low" request.Model = strings.TrimSuffix(request.Model, "-low") } diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go index fcd2593a..a39dd6dc 100644 --- a/relay/channel/zhipu_4v/adaptor.go +++ b/relay/channel/zhipu_4v/adaptor.go @@ -63,6 +63,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { return fmt.Sprintf("%s/embeddings", specialPlan.OpenAIBaseURL), nil } return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil + case relayconstant.RelayModeResponses: + if hasSpecialPlan { + return "", errors.New("coding-plan base aliases do not support Responses") + } + return fmt.Sprintf("%s/api/v1/responses", baseURL), nil case relayconstant.RelayModeImagesGenerations: if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil @@ -102,8 +107,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { - // TODO implement me - return nil, errors.New("not implemented") + return request, nil } func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { diff --git a/relay/channel/zhipu_4v/relay-zhipu_v4.go b/relay/channel/zhipu_4v/relay-zhipu_v4.go index f41d305a..fc2b55ba 100644 --- a/relay/channel/zhipu_4v/relay-zhipu_v4.go +++ b/relay/channel/zhipu_4v/relay-zhipu_v4.go @@ -42,15 +42,16 @@ func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReq Stop, _ = request.Stop.([]string) } out := &dto.GeneralOpenAIRequest{ - Model: request.Model, - Stream: request.Stream, - Messages: messages, - Temperature: request.Temperature, - TopP: request.TopP, - Stop: Stop, - Tools: request.Tools, - ToolChoice: request.ToolChoice, - THINKING: request.THINKING, + Model: request.Model, + Stream: request.Stream, + Messages: messages, + Temperature: request.Temperature, + TopP: request.TopP, + Stop: Stop, + Tools: request.Tools, + ToolChoice: request.ToolChoice, + THINKING: request.THINKING, + ReasoningEffort: request.ReasoningEffort, } if request.MaxTokens != nil || request.MaxCompletionTokens != nil { maxTokens := request.GetMaxTokens() diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index 1c277544..41a22427 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -112,6 +112,10 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad if err != nil { return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + convertedRequest, err = filterResponsesRequestFields(convertedRequest, info, adaptor) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 6bec0e69..ec93f070 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -1,7 +1,6 @@ package relay import ( - "encoding/json" "fmt" "io" "net/http" @@ -13,9 +12,9 @@ import ( "github.com/MAX-API-Next/MAX-API/logger" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/relay/reasoningcompat" "github.com/MAX-API-Next/MAX-API/service" "github.com/MAX-API-Next/MAX-API/setting/model_setting" - "github.com/MAX-API-Next/MAX-API/setting/reasoning" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" @@ -47,64 +46,23 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError *typ } adaptor.Init(info) - if request.MaxTokens == nil || *request.MaxTokens == 0 { + if request.MaxTokens == nil { defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model)) request.MaxTokens = &defaultMaxTokens } - if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" && - (strings.HasPrefix(request.Model, "claude-opus-4-6") || - strings.HasPrefix(request.Model, "claude-opus-4-7") || - strings.HasPrefix(request.Model, "claude-opus-4-8")) { - request.Model = baseModel - request.Thinking = &dto.Thinking{ - Type: "adaptive", - } - request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel)) - if strings.HasPrefix(request.Model, "claude-opus-4-7") || - strings.HasPrefix(request.Model, "claude-opus-4-8") { - // Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400 - // and defaults display to "omitted"; restore the 4.6 visible summary. - request.Thinking.Display = "summarized" - request.Temperature = nil - request.TopP = nil - request.TopK = nil - } else { - request.Temperature = common.GetPointer[float64](1.0) + if !model_setting.GetGlobalSettings().PassThroughRequestEnabled && !info.ChannelSetting.PassThroughBodyEnabled { + effort, notes, err := reasoningcompat.ApplyClaudeSuffix(request, info.OriginModelName) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } info.UpstreamModelName = request.Model - } else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && - strings.HasSuffix(request.Model, "-thinking") { - if request.Thinking == nil { - baseModel := strings.TrimSuffix(request.Model, "-thinking") - if strings.HasPrefix(baseModel, "claude-opus-4-7") || - strings.HasPrefix(baseModel, "claude-opus-4-8") { - // Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort. - request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"} - request.OutputConfig = json.RawMessage(`{"effort":"high"}`) - request.Temperature = nil - request.TopP = nil - request.TopK = nil - } else { - // 因为BudgetTokens 必须大于1024 - if request.MaxTokens == nil || *request.MaxTokens < 1280 { - request.MaxTokens = common.GetPointer[uint](1280) - } - - // BudgetTokens 为 max_tokens 的 80% - request.Thinking = &dto.Thinking{ - Type: "enabled", - BudgetTokens: common.GetPointer[int](int(float64(*request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), - } - // TODO: 临时处理 - // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking - request.Temperature = common.GetPointer[float64](1.0) - } + if effort != "" { + info.ReasoningEffort = effort } - if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) { - request.Model = strings.TrimSuffix(request.Model, "-thinking") + for _, note := range notes { + logger.LogWarn(c, note) } - info.UpstreamModelName = request.Model } if info.ChannelSetting.SystemPrompt != "" { diff --git a/relay/common/message_tools.go b/relay/common/message_tools.go new file mode 100644 index 00000000..e8a86697 --- /dev/null +++ b/relay/common/message_tools.go @@ -0,0 +1,49 @@ +package common + +import ( + "fmt" + "reflect" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" +) + +// CaptureMessageTools snapshots declarations before an adaptor can mutate the +// request. The ordinal is part of the contract: tools become available at that +// point in the conversation, not as unconditional top-level tools. +func CaptureMessageTools(request *dto.GeneralOpenAIRequest) (map[int]any, error) { + declarations := map[int]any{} + for index, message := range request.Messages { + if len(message.Tools) == 0 { + continue + } + var value any + if err := common.Unmarshal(message.Tools, &value); err != nil { + return nil, err + } + declarations[index] = value + } + return declarations, nil +} + +func ValidateMessageToolsConversion(declarations map[int]any, converted any) error { + if len(declarations) == 0 { + return nil + } + data, err := common.Marshal(converted) + if err != nil { + return err + } + var request dto.GeneralOpenAIRequest + if err := common.Unmarshal(data, &request); err != nil { + return fmt.Errorf("message-scoped tools cannot be converted to this upstream protocol") + } + outgoing, err := CaptureMessageTools(&request) + if err != nil { + return err + } + if !reflect.DeepEqual(declarations, outgoing) { + return fmt.Errorf("message-scoped tools require an OpenAI-compatible Chat Completions upstream; this conversion would lose or move tool declarations") + } + return nil +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index c60ccd68..e2e34318 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -114,10 +114,17 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError *types info.UpstreamRequestBodySize = size requestBody = body } else { + messageTools, err := relaycommon.CaptureMessageTools(request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + if err := relaycommon.ValidateMessageToolsConversion(messageTools, convertedRequest); err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) if info.ChannelSetting.SystemPrompt != "" { @@ -162,6 +169,10 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError *types } } + convertedRequest, err = filterResponsesRequestFields(convertedRequest, info, adaptor) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry()) diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 570c75b0..e6fdc519 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -83,8 +83,10 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError *typ } } } - if request.GenerationConfig.ThinkingConfig == nil { - gemini.ThinkingAdaptor(request, info) + if !model_setting.GetGlobalSettings().PassThroughRequestEnabled && !info.ChannelSetting.PassThroughBodyEnabled { + if err := gemini.ThinkingAdaptor(c, request, info); err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } } } diff --git a/relay/reasoningcompat/claude.go b/relay/reasoningcompat/claude.go new file mode 100644 index 00000000..d5f56ae5 --- /dev/null +++ b/relay/reasoningcompat/claude.go @@ -0,0 +1,267 @@ +package reasoningcompat + +import ( + "fmt" + "math" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/setting/model_setting" +) + +type claudeCapabilities struct{ adaptive, manual, defaultThinking, effort, xhigh, max, strict, disable bool } + +func ClaudeEffort(req *dto.ClaudeRequest) string { + if req.Thinking != nil && req.Thinking.Type == "disabled" { + return "none" + } + if effort := req.GetEfforts(); effort != "" { + return effort + } + if req.Thinking != nil { + if req.Thinking.BudgetTokens != nil { + return EffortFromBudget(*req.Thinking.BudgetTokens) + } + if req.Thinking.Type == "adaptive" { + return "high" + } + } + return "" +} + +func claudeModelCapabilities(model string) claudeCapabilities { + model = strings.ToLower(model) + c := claudeCapabilities{manual: true, disable: true} + switch { + case strings.HasPrefix(model, "claude-fable-5"), strings.HasPrefix(model, "claude-mythos-5"): + c.adaptive = true + c.manual = false + c.defaultThinking = true + c.disable = false + c.strict = true + c.xhigh = true + c.max = true + case strings.HasPrefix(model, "claude-mythos-preview"): + c.adaptive = true + c.defaultThinking = true + c.disable = false + c.strict = true + c.max = true + case strings.HasPrefix(model, "claude-opus-5"), strings.HasPrefix(model, "claude-sonnet-5"), strings.HasPrefix(model, "claude-opus-4-8"), strings.HasPrefix(model, "claude-opus-4-7"): + c.adaptive = true + c.manual = false + c.defaultThinking = strings.HasPrefix(model, "claude-opus-5") || strings.HasPrefix(model, "claude-sonnet-5") + c.effort = true + c.xhigh = true + c.max = true + c.strict = true + case strings.HasPrefix(model, "claude-opus-4-6"), strings.HasPrefix(model, "claude-sonnet-4-6"): + c.adaptive = true + c.effort = true + c.max = true + case strings.HasPrefix(model, "claude-opus-4-5"): + c.effort = true + } + return c +} + +func setClaudeEffort(req *dto.ClaudeRequest, effort string) error { + config := map[string]any{} + if len(req.OutputConfig) > 0 { + if err := common.Unmarshal(req.OutputConfig, &config); err != nil { + return err + } + if config == nil { + config = map[string]any{} + } + } + delete(config, "effort") + if effort != "" { + config["effort"] = effort + } + req.OutputConfig = nil + if len(config) > 0 { + b, err := common.Marshal(config) + if err != nil { + return err + } + req.OutputConfig = b + } + return nil +} + +// ApplyClaude is for translated controls. Native requests without a modifier +// never call it, so native display, effort-only, and omission semantics survive. +func ApplyClaude(req *dto.ClaudeRequest, intent Intent) (string, []string, error) { + c := claudeModelCapabilities(req.Model) + notes := []string{} + if c.strict { + req.Temperature = nil + req.TopP = nil + req.TopK = nil + } + if intent.Empty() { + return "", notes, nil + } + if intent.Mode == "" && intent.Effort == "" && intent.Budget == nil { + // Visibility alone must not enable manual thinking on a model which + // defaults to disabled. New default-adaptive families can express it. + if c.adaptive && c.defaultThinking && intent.Include != nil { + req.Thinking = &dto.Thinking{Type: "adaptive", Display: "omitted"} + if *intent.Include { + req.Thinking.Display = "summarized" + } + return "high", notes, nil + } + return "", notes, nil + } + effort, err := NormalizeEffort(intent.Effort) + if err != nil { + return "", notes, err + } + if intent.Mode == "disabled" || effort == "none" || (intent.Budget != nil && *intent.Budget == 0) { + if c.disable { + req.Thinking = &dto.Thinking{Type: "disabled"} + return "none", notes, setClaudeEffort(req, "") + } + notes = append(notes, "thinking cannot be disabled for this Claude model; using adaptive thinking") + intent.Mode = "enabled" + intent.Budget = nil + effort = "low" + } + if effort == "" && intent.Budget != nil { + effort = EffortFromBudget(*intent.Budget) + } + requestedEffort := effort + if effort == "minimal" && c.adaptive { + effort = "low" + } + if effort == "xhigh" && !c.xhigh { + effort = "high" + if c.max { + effort = "max" + } + } + if effort == "max" && !c.max { + effort = "high" + } + if effort != requestedEffort { + notes = append(notes, fmt.Sprintf("reasoning effort %s adjusted to %s", requestedEffort, effort)) + } + if c.adaptive && (!c.manual || intent.Budget == nil || intent.Mode == "adaptive") { + if intent.Budget != nil { + notes = append(notes, "thinking budget converted to adaptive effort") + } + if effort == "" { + effort = "high" + } + req.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"} + if !c.effort { + notes = append(notes, "output effort is not supported by this Claude model") + effort = "high" + } + outEffort := "" + if c.effort { + outEffort = effort + } + if err := setClaudeEffort(req, outEffort); err != nil { + return "", notes, err + } + } else { + if req.MaxTokens == nil { + req.MaxTokens = common.GetPointer(uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(req.Model))) + } + // A compatibility conversion must not increase a client's output/charge cap. + if *req.MaxTokens <= 1024 || uint64(*req.MaxTokens) > uint64(math.MaxInt) { + return "", notes, fmt.Errorf("manual thinking requires 1024 < max_tokens <= platform integer limit") + } + var budget int + if intent.Budget != nil && *intent.Budget >= 0 { + budget = *intent.Budget + } else { + percent := model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage + switch requestedEffort { + case "minimal": + percent = .05 + case "low": + percent = .2 + case "medium": + percent = .5 + case "high": + percent = .8 + case "xhigh", "max": + percent = .95 + } + if percent <= 0 || math.IsNaN(percent) || math.IsInf(percent, 0) { + percent = .8 + } + if percent > 1 { + percent = 1 + } + computed := float64(*req.MaxTokens) * percent + if computed >= float64(math.MaxInt) { + budget = int(*req.MaxTokens) - 1 + } else { + budget = int(computed) + } + } + adjusted := min(max(budget, 1024), int(*req.MaxTokens)-1) + if adjusted != budget { + notes = append(notes, "manual thinking budget adjusted to 1024 <= budget_tokens < max_tokens") + } + req.Thinking = &dto.Thinking{Type: "enabled", BudgetTokens: &adjusted} + if effort == "" { + effort = EffortFromBudget(adjusted) + } + outEffort := "" + if c.effort { + outEffort = effort + } + if err := setClaudeEffort(req, outEffort); err != nil { + return "", notes, err + } + } + if intent.Include != nil { + req.Thinking.Display = "omitted" + if *intent.Include { + req.Thinking.Display = "summarized" + } + } + if !c.strict { + req.Temperature = common.GetPointer(1.0) + req.TopP = nil + req.TopK = nil + } + return effort, notes, nil +} + +func ApplyClaudeSuffix(req *dto.ClaudeRequest, origin string) (string, []string, error) { + if model_setting.ShouldPreserveThinkingSuffix(origin) { + return "", nil, nil + } + base, intent, found, err := ParseSuffix(req.Model, "claude", model_setting.GetClaudeSettings().ThinkingAdapterEnabled) + if err != nil || !found { + return "", nil, err + } + if req.Thinking != nil || len(req.OutputConfig) > 0 { + native := Intent{Effort: req.GetEfforts()} + if req.Thinking != nil { + native.Mode = req.Thinking.Type + native.Budget = req.Thinking.BudgetTokens + if req.Thinking.Display != "" { + native.Include = common.GetPointer(req.Thinking.Display == "summarized") + } + } + // Native adaptive is an enabled control for conflict comparison. + if native.Mode == "adaptive" { + native.Mode = "enabled" + } + intent, err = MergeSuffix(native, intent) + if err != nil { + return "", nil, err + } + } + req.Model = base + return ApplyClaude(req, intent) +} diff --git a/relay/reasoningcompat/gemini.go b/relay/reasoningcompat/gemini.go new file mode 100644 index 00000000..7c6990c7 --- /dev/null +++ b/relay/reasoningcompat/gemini.go @@ -0,0 +1,157 @@ +package reasoningcompat + +import ( + "fmt" + "math" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" +) + +func geminiKind(model string) string { + switch { + case strings.HasPrefix(model, "gemini-2.5-flash-native-audio"), strings.HasPrefix(model, "gemini-live-2.5-flash-preview-native-audio"): + return "budget" + case strings.HasPrefix(model, "gemini-2.5-flash-image"), strings.Contains(model, "-tts"), strings.Contains(model, "-native-audio"), strings.Contains(model, "-live"): + return "unsupported" + case strings.HasPrefix(model, "gemini-3-pro-image"), strings.HasPrefix(model, "nano-banana-pro"): + return "include-only" + case strings.HasPrefix(model, "gemini-2.5-"): + return "budget" + case strings.HasPrefix(model, "gemini-3"), model == "gemini-pro-latest", model == "gemini-flash-latest", model == "gemini-flash-lite-latest": + return "level" + default: + return "unknown" + } +} + +func geminiLevel(model, effort string) string { + if effort == "none" { + effort = "minimal" + } + if strings.HasPrefix(model, "gemini-3.1-flash-image") || strings.HasPrefix(model, "gemini-3.1-flash-lite-image") { + if effort == "minimal" || effort == "low" { + return "minimal" + } + return "high" + } + if strings.HasPrefix(model, "gemini-3-pro") { + if effort == "minimal" || effort == "low" { + return "low" + } + return "high" + } + if (strings.HasPrefix(model, "gemini-3.1-pro") || model == "gemini-pro-latest") && effort == "minimal" { + return "low" + } + if effort == "max" || effort == "xhigh" { + return "high" + } + return effort +} + +// GeminiConfig preserves native controls when capabilities are unknown; known +// unsupported models explicitly return nil to remove thinking configuration. +func GeminiConfig(model string, intent Intent, native *dto.GeminiThinkingConfig, maxTokens *uint, percentage float64) (*dto.GeminiThinkingConfig, string, []string, error) { + model = strings.ToLower(model) + effort, err := NormalizeEffort(intent.Effort) + if err != nil { + return nil, "", nil, err + } + if intent.Empty() { + return native, "", nil, nil + } + notes := []string{} + config := &dto.GeminiThinkingConfig{IncludeThoughts: intent.Include} + kind := geminiKind(model) + if kind == "unknown" { + // Capability lookup is not a model allowlist. Keep older and custom + // model requests usable without inventing a budget or thinking level. + return native, "", []string{fmt.Sprintf("unknown Gemini thinking capabilities for model %q; compatibility reasoning controls were not applied", model)}, nil + } + if kind == "unsupported" { + return nil, "", []string{"model does not support configurable thinking"}, nil + } + if intent.Mode == "" && intent.Effort == "" && intent.Budget == nil { + return config, "", notes, nil + } + switch kind { + case "include-only": + return config, "high", []string{"model only supports includeThoughts; reasoning strength is fixed"}, nil + case "level": + if intent.Mode == "disabled" { + effort = "none" + } + if effort == "" && intent.Budget != nil { + effort = EffortFromBudget(*intent.Budget) + notes = append(notes, "thinking budget converted to level") + } + if effort == "" { + effort = "high" + switch { + case model == "gemini-flash-lite-latest", strings.HasPrefix(model, "gemini-3.5-flash-lite"), strings.HasPrefix(model, "gemini-3.1-flash-lite"): + effort = "minimal" + case model == "gemini-flash-latest", strings.HasPrefix(model, "gemini-3.5-flash"), strings.HasPrefix(model, "gemini-3.6-flash"): + effort = "medium" + } + } + level := geminiLevel(model, effort) + if level != effort { + notes = append(notes, fmt.Sprintf("thinking effort %s adjusted to supported level %s", effort, level)) + } + config.ThinkingLevel = level + return config, level, notes, nil + } + budget := -1 + minBudget, maxBudget := 0, 24576 + if strings.HasPrefix(model, "gemini-2.5-pro") { + minBudget, maxBudget = 128, 32768 + } else if strings.HasPrefix(model, "gemini-2.5-flash-lite") { + minBudget = 512 + } + switch { + case intent.Mode == "disabled" || effort == "none": + budget = 0 + case intent.Budget != nil: + budget = *intent.Budget + case effort != "": + switch effort { + case "minimal", "low": + budget = 1024 + case "medium": + budget = 8192 + default: + budget = 24576 + } + case maxTokens != nil && *maxTokens > 0: + if uint64(*maxTokens) > uint64(math.MaxInt) { + return nil, "", notes, fmt.Errorf("output limit overflows thinking budget") + } + if percentage <= 0 || math.IsNaN(percentage) || math.IsInf(percentage, 0) { + percentage = .6 + } + percentage = min(percentage, 1) + computed := float64(*maxTokens) * percentage + if computed >= float64(maxBudget) { + budget = maxBudget + } else { + budget = int(computed) + } + } + if budget < -1 { + return nil, "", notes, fmt.Errorf("thinking budget must be >= -1") + } + if budget != -1 { + adjusted := budget + if budget != 0 || strings.HasPrefix(model, "gemini-2.5-pro") { + adjusted = min(max(budget, minBudget), maxBudget) + } + if adjusted != budget { + notes = append(notes, fmt.Sprintf("thinking budget %d adjusted to supported value %d", budget, adjusted)) + } + budget = adjusted + } + config.ThinkingBudget = common.GetPointer(budget) + return config, EffortFromBudget(budget), notes, nil +} diff --git a/relay/reasoningcompat/intent.go b/relay/reasoningcompat/intent.go new file mode 100644 index 00000000..f6ae1aef --- /dev/null +++ b/relay/reasoningcompat/intent.go @@ -0,0 +1,186 @@ +// Package reasoningcompat translates request reasoning controls only. It does +// not own routing, usage, token limits, reservation, or settlement. +package reasoningcompat + +import ( + "fmt" + "strconv" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/setting/reasoning" +) + +type Intent struct { + Mode string + Effort string + Budget *int + Include *bool +} + +func (i Intent) Empty() bool { + return i.Mode == "" && i.Effort == "" && i.Budget == nil && i.Include == nil +} + +func NormalizeEffort(effort string) (string, error) { + effort = strings.ToLower(strings.TrimSpace(effort)) + switch effort { + case "", "none", "minimal", "low", "medium", "high", "xhigh", "max": + return effort, nil + } + return "", fmt.Errorf("unsupported reasoning effort %q", effort) +} + +func FromChat(req dto.GeneralOpenAIRequest) (Intent, error) { + effort, err := NormalizeEffort(req.ReasoningEffort) + if err != nil { + return Intent{}, err + } + intent := Intent{Effort: effort, Include: req.IncludeReasoning} + if effort != "" { + intent.Mode = "enabled" + if effort == "none" { + intent.Mode = "disabled" + } + } + if len(req.Reasoning) > 0 { + var raw struct { + Enabled *bool `json:"enabled"` + Effort string `json:"effort"` + MaxTokens *int `json:"max_tokens"` + Exclude *bool `json:"exclude"` + } + if err := common.Unmarshal(req.Reasoning, &raw); err != nil { + return intent, err + } + if raw.Effort != "" { + e, err := NormalizeEffort(raw.Effort) + if err != nil { + return intent, err + } + if effort != "" && e != effort { + return intent, fmt.Errorf("conflicting reasoning efforts") + } + intent.Effort = e + intent.Mode = "enabled" + if e == "none" { + intent.Mode = "disabled" + } + } + if raw.Enabled != nil { + if (!*raw.Enabled && intent.Effort != "" && intent.Effort != "none") || (*raw.Enabled && intent.Effort == "none") { + return intent, fmt.Errorf("conflicting reasoning enabled and effort") + } + if *raw.Enabled { + intent.Mode = "enabled" + } else { + intent.Mode = "disabled" + intent.Effort = "none" + } + } + if raw.MaxTokens != nil { + if raw.Enabled != nil && *raw.Enabled && *raw.MaxTokens == 0 { + return intent, fmt.Errorf("conflicting reasoning enabled and zero budget") + } + if (intent.Mode == "disabled" && *raw.MaxTokens != 0) || (*raw.MaxTokens == 0 && intent.Effort != "" && intent.Effort != "none") { + return intent, fmt.Errorf("conflicting reasoning budget and mode") + } + if *raw.MaxTokens < -1 { + return intent, fmt.Errorf("reasoning budget must be >= -1") + } + intent.Budget = raw.MaxTokens + intent.Mode = "enabled" + if *raw.MaxTokens == 0 { + intent.Mode = "disabled" + intent.Effort = "none" + } + } + if raw.Exclude != nil { + if intent.Include != nil && *intent.Include == *raw.Exclude { + return intent, fmt.Errorf("conflicting reasoning visibility") + } + intent.Include = common.GetPointer(!*raw.Exclude) + } + } + return intent, nil +} + +// ParseSuffix keeps real model IDs and explicitly configured names opaque. +func ParseSuffix(model, provider string, thinkingAliases bool) (string, Intent, bool, error) { + if reasoning.PreserveModelSuffix(model) || !strings.HasPrefix(model, provider+"-") { + return model, Intent{}, false, nil + } + if base, effort, ok := reasoning.TrimEffortSuffixWithSuffixes(model, reasoning.OpenAIEffortSuffixes); ok { + mode := "enabled" + if effort == "none" { + mode = "disabled" + } + return base, Intent{Mode: mode, Effort: effort}, true, nil + } + if !thinkingAliases { + return model, Intent{}, false, nil + } + if base, ok := strings.CutSuffix(model, "-nothinking"); ok { + return base, Intent{Mode: "disabled", Effort: "none"}, true, nil + } + if base, ok := strings.CutSuffix(model, "-thinking"); ok { + return base, Intent{Mode: "enabled"}, true, nil + } + if pos := strings.LastIndex(model, "-thinking-"); pos >= 0 { + budget, err := strconv.Atoi(model[pos+10:]) + if err != nil || budget < -1 { + return model, Intent{}, false, fmt.Errorf("invalid thinking budget suffix") + } + mode := "enabled" + if budget == 0 { + mode = "disabled" + } + return model[:pos], Intent{Mode: mode, Budget: &budget}, true, nil + } + return model, Intent{}, false, nil +} + +func MergeSuffix(body, suffix Intent) (Intent, error) { + if (body.Budget != nil && *body.Budget < -1) || (suffix.Budget != nil && *suffix.Budget < -1) { + return body, fmt.Errorf("reasoning budget must be >= -1") + } + if body.Include != nil && suffix.Include != nil && *body.Include != *suffix.Include { + return body, fmt.Errorf("conflicting reasoning visibility") + } + if body.Mode != "" && suffix.Mode != "" && body.Mode != suffix.Mode { + return body, fmt.Errorf("conflicting reasoning mode and model suffix") + } + if body.Effort != "" && suffix.Effort != "" && body.Effort != suffix.Effort { + return body, fmt.Errorf("conflicting reasoning effort and model suffix") + } + if body.Budget != nil && suffix.Budget != nil && *body.Budget != *suffix.Budget { + return body, fmt.Errorf("conflicting reasoning budget and model suffix") + } + if suffix.Mode != "" { + body.Mode = suffix.Mode + } + if suffix.Effort != "" { + body.Effort = suffix.Effort + } + if suffix.Budget != nil { + body.Budget = suffix.Budget + } + return body, nil +} + +// These ranges follow the reviewed New API conversion table, not pricing. +func EffortFromBudget(budget int) string { + switch { + case budget == 0: + return "none" + case budget < 0: + return "high" + case budget <= 1024: + return "low" + case budget <= 8192: + return "medium" + default: + return "high" + } +} diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 9a761c90..50797868 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -40,12 +40,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError * case *dto.OpenAIResponsesRequest: responsesReq = req case *dto.OpenAIResponsesCompactionRequest: - responsesReq = &dto.OpenAIResponsesRequest{ - Model: req.Model, - Input: req.Input, - Instructions: req.Instructions, - PreviousResponseID: req.PreviousResponseID, - } + responsesReq = req.ToResponsesRequest() default: return types.NewErrorWithStatusCode( fmt.Errorf("invalid request type, expected dto.OpenAIResponsesRequest or dto.OpenAIResponsesCompactionRequest, got %T", info.Request), @@ -90,6 +85,10 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError * if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + convertedRequest, err = filterResponsesRequestFields(convertedRequest, info, adaptor) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { diff --git a/relay/responses_request_compatibility.go b/relay/responses_request_compatibility.go new file mode 100644 index 00000000..5b369a03 --- /dev/null +++ b/relay/responses_request_compatibility.go @@ -0,0 +1,68 @@ +package relay + +import ( + "errors" + "net/url" + "strings" + + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/relay/channel" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" +) + +// Filter only converted Responses payloads, using the actual destination (which +// may be an absolute advanced-custom route). Shared conversion must preserve +// vendor extensions for third-party endpoints. Explicit raw pass-through and +// administrator parameter overrides retain their existing separate semantics. +func filterResponsesRequestFields(request any, info *relaycommon.RelayInfo, adaptor channel.Adaptor) (any, error) { + var filtered dto.OpenAIResponsesRequest + pointer := false + switch req := request.(type) { + case dto.OpenAIResponsesRequest: + filtered = req + case *dto.OpenAIResponsesRequest: + if req == nil { + return request, nil + } + filtered, pointer = *req, true + default: + return request, nil + } + if info == nil || info.ChannelMeta == nil { + return request, nil + } + strict := info.ChannelType == constant.ChannelTypeCodex + if !strict { + endpoint, err := adaptor.GetRequestURL(info) + if err != nil { + return nil, err + } + parsed, err := url.Parse(endpoint) + if err != nil { + return nil, errors.New("invalid Responses upstream URL") + } + host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".") + strict = host == "api.openai.com" || (host == "chatgpt.com" && strings.HasPrefix(parsed.Path, "/backend-api/codex/")) + } + if !strict { + return request, nil + } + // These are vendor-only extensions, not official Responses create fields: + // https://developers.openai.com/api/reference/typescript/resources/responses/methods/create + // Use a narrow denylist, not a whitelist that would strip future native fields. + filtered.Stop = nil + filtered.TopK = nil + filtered.MinP = nil + filtered.RepetitionPenalty = nil + filtered.CacheSalt = nil + filtered.ChatTemplateKwargs = nil + filtered.FrequencyPenalty = nil + filtered.PresencePenalty = nil + filtered.EnableThinking = nil + filtered.ThinkingBudget = nil + if pointer { + return &filtered, nil + } + return filtered, nil +} diff --git a/relay/responses_request_compatibility_test.go b/relay/responses_request_compatibility_test.go new file mode 100644 index 00000000..5632b4f0 --- /dev/null +++ b/relay/responses_request_compatibility_test.go @@ -0,0 +1,150 @@ +package relay + +import ( + "errors" + "io" + "net/http/httptest" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/relay/channel/advancedcustom" + "github.com/MAX-API-Next/MAX-API/relay/channel/openai" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + relayconstant "github.com/MAX-API-Next/MAX-API/relay/constant" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// Stop at the transport boundary: no external request or billing is performed. +type responsesCaptureAdaptor struct { + openai.Adaptor + endpoint string + body []byte +} + +func (a *responsesCaptureAdaptor) GetRequestURL(*relaycommon.RelayInfo) (string, error) { + return a.endpoint, nil +} + +func (a *responsesCaptureAdaptor) DoRequest(_ *gin.Context, _ *relaycommon.RelayInfo, body io.Reader) (any, error) { + var err error + a.body, err = io.ReadAll(body) + if err != nil { + return nil, err + } + return nil, errors.New("test transport boundary") +} + +func TestChatResponsesEndpointCompatibility(t *testing.T) { + for _, tc := range []struct { + name, endpoint string + channel int + extensions bool + }{ + {"official", "https://api.openai.com/v1/responses", constant.ChannelTypeOpenAI, false}, + {"official-case-port", "https://API.OPENAI.COM:443/v1/responses", constant.ChannelTypeOpenAI, false}, + {"official-absolute-host", "https://api.openai.com./v1/responses", constant.ChannelTypeOpenAI, false}, + {"third-party", "https://inference.example/v1/responses", constant.ChannelTypeOpenAI, true}, + {"lookalike", "https://api.openai.com.example/v1/responses", constant.ChannelTypeOpenAI, true}, + {"userinfo", "https://api.openai.com@inference.example/v1/responses", constant.ChannelTypeOpenAI, true}, + {"codex-proxy", "https://codex.example/backend-api/codex/responses", constant.ChannelTypeCodex, false}, + {"custom-official", "https://api.openai.com/v1/responses", constant.ChannelTypeAdvancedCustom, false}, + {"custom-codex", "https://chatgpt.com/backend-api/codex/responses", constant.ChannelTypeAdvancedCustom, false}, + } { + t.Run(tc.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil) + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelType: tc.channel, ChannelBaseUrl: "https://configured.example"}} + var req dto.GeneralOpenAIRequest + // Qwen retains thinking_budget in the existing DTO marshal contract. + require.NoError(t, common.UnmarshalJsonStr(`{"model":"qwen-plus","messages":[{"role":"user","content":"hello"}],"stop":["END"],"top_k":0,"min_p":0,"repetition_penalty":0,"cache_salt":"","chat_template_kwargs":{"enable_thinking":false},"frequency_penalty":0,"presence_penalty":0,"enable_thinking":false,"thinking_budget":0,"prompt_cache_key":"cache-key","prompt_cache_options":{"ttl":"30m"},"temperature":0,"top_p":0,"parallel_tool_calls":false}`, &req)) + adaptor := &responsesCaptureAdaptor{endpoint: tc.endpoint} + _, err := chatCompletionsViaResponses(c, info, adaptor, &req) + require.NotNil(t, err) + require.NotEmpty(t, adaptor.body) + for _, field := range []string{"stop", "top_k", "min_p", "repetition_penalty", "cache_salt", "chat_template_kwargs", "frequency_penalty", "presence_penalty", "enable_thinking", "thinking_budget"} { + require.Equal(t, tc.extensions, gjson.GetBytes(adaptor.body, field).Exists(), field) + } + for _, field := range []string{"temperature", "top_p", "parallel_tool_calls", "prompt_cache_key", "prompt_cache_options"} { + require.True(t, gjson.GetBytes(adaptor.body, field).Exists(), field) + } + require.Equal(t, "qwen-plus", gjson.GetBytes(adaptor.body, "model").String()) + require.Equal(t, "cache-key", gjson.GetBytes(adaptor.body, "prompt_cache_key").String()) + require.Equal(t, "30m", gjson.GetBytes(adaptor.body, "prompt_cache_options.ttl").String()) + require.False(t, gjson.GetBytes(adaptor.body, "parallel_tool_calls").Bool()) + require.NotNil(t, req.TopK, "conversion must not mutate the caller's request") + }) + } +} + +func TestResponsesFieldFilterBoundaries(t *testing.T) { + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeOpenAI}} + adaptor := &responsesCaptureAdaptor{endpoint: "https://api.openai.com/v1/responses"} + request := dto.OpenAIResponsesRequest{Model: "gpt-test", TopK: common.GetPointer(0)} + for _, input := range []any{request, &request} { + out, err := filterResponsesRequestFields(input, info, adaptor) + require.NoError(t, err) + body, err := common.Marshal(out) + require.NoError(t, err) + require.False(t, gjson.GetBytes(body, "top_k").Exists()) + require.NotNil(t, request.TopK, "filter must copy rather than mutate the input") + } + for _, input := range []any{nil, (*dto.OpenAIResponsesRequest)(nil), &dto.GeneralOpenAIRequest{TopK: common.GetPointer(0)}} { + out, err := filterResponsesRequestFields(input, info, nil) + require.NoError(t, err) + require.Equal(t, input, out) + } + out, err := filterResponsesRequestFields(&request, nil, nil) + require.NoError(t, err) + require.Same(t, &request, out) + adaptor.endpoint = "https://invalid.example/%wrong?secret=redacted-fixture" + _, err = filterResponsesRequestFields(request, info, adaptor) + require.EqualError(t, err, "invalid Responses upstream URL") +} + +func TestResponsesFieldFilterUsesAdvancedCustomDestination(t *testing.T) { + for _, tc := range []struct { + base, target string + keep bool + }{ + {"https://api.openai.com", "https://inference.example/v1/responses", true}, + {"https://inference.example", "https://api.openai.com/v1/responses", false}, + } { + t.Run(tc.target, func(t *testing.T) { + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatOpenAI, + RelayMode: relayconstant.RelayModeChatCompletions, + RequestURLPath: "/v1/chat/completions", + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeAdvancedCustom, + ChannelBaseUrl: tc.base, + ChannelOtherSettings: dto.ChannelOtherSettings{ + AdvancedCustom: &dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{{ + IncomingPath: "/v1/chat/completions", + UpstreamPath: tc.target, + Converter: dto.AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses, + }}, + }, + }, + }, + } + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil) + adaptor := &advancedcustom.Adaptor{} + adaptor.Init(info) + converted, err := adaptor.ConvertOpenAIRequest(c, info, &dto.GeneralOpenAIRequest{Model: "model", TopK: common.GetPointer(0)}) + require.NoError(t, err) + filtered, err := filterResponsesRequestFields(converted, info, adaptor) + require.NoError(t, err) + body, err := common.Marshal(filtered) + require.NoError(t, err) + require.Equal(t, tc.keep, gjson.GetBytes(body, "top_k").Exists()) + require.NotNil(t, converted.(*dto.OpenAIResponsesRequest).TopK) + }) + } +} diff --git a/router/api-router.go b/router/api-router.go index 5f1c6bad..27309590 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -154,6 +154,7 @@ func SetApiRouter(router *gin.Engine) { adminRoute.GET("/:id", controller.GetUser) adminRoute.POST("/", controller.CreateUser) adminRoute.POST("/manage", controller.ManageUser) + adminRoute.POST("/manage/batch", controller.BatchManageUserStatus) adminRoute.PUT("/", controller.UpdateUser) adminRoute.DELETE("/:id", controller.DeleteUser) adminRoute.DELETE("/:id/reset_passkey", controller.AdminResetPasskey) diff --git a/service/convert.go b/service/convert.go index 7c8c43f2..9db7f9eb 100644 --- a/service/convert.go +++ b/service/convert.go @@ -7,8 +7,8 @@ import ( "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" - "github.com/MAX-API-Next/MAX-API/relay/channel/openrouter" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/relay/reasoningcompat" "github.com/MAX-API-Next/MAX-API/relay/reasonmap" "github.com/samber/lo" ) @@ -33,24 +33,36 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re isOpenRouter := info.ChannelType == constant.ChannelTypeOpenRouter + effort := claudeRequest.GetEfforts() + if claudeRequest.Thinking != nil { + switch claudeRequest.Thinking.Type { + case "disabled": + effort = "none" + case "adaptive": + if effort == "" { + effort = "high" + } + case "enabled": + if effort == "" && claudeRequest.Thinking.BudgetTokens != nil { + effort = reasoningcompat.EffortFromBudget(*claudeRequest.Thinking.BudgetTokens) + } + } + } + openAIRequest.ReasoningEffort = effort if isOpenRouter { - if effort := claudeRequest.GetEfforts(); effort != "" { - effortBytes, _ := common.Marshal(effort) - openAIRequest.Verbosity = effortBytes + controls := map[string]any{} + if effort != "" && effort != "none" { + controls["effort"] = effort } if claudeRequest.Thinking != nil { - var reasoning openrouter.RequestReasoning - if claudeRequest.Thinking.Type == "enabled" { - reasoning = openrouter.RequestReasoning{ - Enabled: true, - MaxTokens: claudeRequest.Thinking.GetBudgetTokens(), - } - } else if claudeRequest.Thinking.Type == "adaptive" { - reasoning = openrouter.RequestReasoning{ - Enabled: true, - } + controls["enabled"] = claudeRequest.Thinking.Type != "disabled" + if claudeRequest.Thinking.Type == "enabled" && claudeRequest.Thinking.BudgetTokens != nil { + controls["max_tokens"] = *claudeRequest.Thinking.BudgetTokens + delete(controls, "effort") } - reasoningJSON, err := common.Marshal(reasoning) + } + if len(controls) > 0 { + reasoningJSON, err := common.Marshal(controls) if err != nil { return nil, fmt.Errorf("failed to marshal reasoning: %w", err) } @@ -58,9 +70,8 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re } } else { thinkingSuffix := "-thinking" - if strings.HasSuffix(info.OriginModelName, thinkingSuffix) && - !strings.HasSuffix(openAIRequest.Model, thinkingSuffix) { - openAIRequest.Model = openAIRequest.Model + thinkingSuffix + if strings.HasSuffix(info.OriginModelName, thinkingSuffix) && !strings.HasSuffix(openAIRequest.Model, thinkingSuffix) { + openAIRequest.Model += thinkingSuffix } } diff --git a/service/openaicompat/chat_to_responses.go b/service/openaicompat/chat_to_responses.go index 5b6bb3b1..2f3827fe 100644 --- a/service/openaicompat/chat_to_responses.go +++ b/service/openaicompat/chat_to_responses.go @@ -74,6 +74,9 @@ func convertChatResponseFormatToResponsesText(reqFormat *dto.ResponseFormat) jso } func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) { + if req != nil && req.HasMessageTools() { + return nil, fmt.Errorf("message-scoped tools require an OpenAI-compatible Chat Completions upstream; cannot convert to Responses") + } if req == nil { return nil, errors.New("request is nil") } @@ -380,28 +383,41 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d } out := &dto.OpenAIResponsesRequest{ - Model: req.Model, - Input: inputRaw, - Instructions: instructionsRaw, - Stream: req.Stream, - Temperature: req.Temperature, - Text: textRaw, - ToolChoice: toolChoiceRaw, - Tools: toolsRaw, - TopP: topP, - FrequencyPenalty: req.FrequencyPenalty, - PresencePenalty: req.PresencePenalty, - User: req.User, - ParallelToolCalls: parallelToolCallsRaw, - Store: req.Store, - Metadata: req.Metadata, - PromptCacheKey: promptCacheKeyRaw, - EnableThinking: req.EnableThinking, - ThinkingBudget: req.ThinkingBudget, + Model: req.Model, + Input: inputRaw, + Instructions: instructionsRaw, + Stream: req.Stream, + Temperature: req.Temperature, + Text: textRaw, + ToolChoice: toolChoiceRaw, + Tools: toolsRaw, + TopP: topP, + FrequencyPenalty: req.FrequencyPenalty, + PresencePenalty: req.PresencePenalty, + User: req.User, + ParallelToolCalls: parallelToolCallsRaw, + Store: req.Store, + Metadata: req.Metadata, + PromptCacheKey: promptCacheKeyRaw, + PromptCacheOptions: req.PromptCacheOptions, + PromptCacheRetention: req.PromptCacheRetention, + ChatTemplateKwargs: req.ChatTemplateKwargs, + TopK: req.TopK, + MinP: req.MinP, + RepetitionPenalty: req.RepetitionPenalty, + CacheSalt: req.CacheSalt, + EnableThinking: req.EnableThinking, + ThinkingBudget: req.ThinkingBudget, } if req.MaxTokens != nil || req.MaxCompletionTokens != nil { out.MaxOutputTokens = lo.ToPtr(maxOutputTokens) } + if req.Stop != nil { + out.Stop, err = common.Marshal(req.Stop) + if err != nil { + return nil, fmt.Errorf("marshal stop: %w", err) + } + } if req.ReasoningEffort != "" { out.Reasoning = &dto.Reasoning{ diff --git a/service/openaicompat/responses_request_to_chat.go b/service/openaicompat/responses_request_to_chat.go index ab024331..19ac8b59 100644 --- a/service/openaicompat/responses_request_to_chat.go +++ b/service/openaicompat/responses_request_to_chat.go @@ -62,10 +62,21 @@ func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) ( Metadata: req.Metadata, SafetyIdentifier: req.SafetyIdentifier, PromptCacheRetention: req.PromptCacheRetention, + PromptCacheOptions: req.PromptCacheOptions, + ChatTemplateKwargs: req.ChatTemplateKwargs, + TopK: req.TopK, + MinP: req.MinP, + RepetitionPenalty: req.RepetitionPenalty, + CacheSalt: req.CacheSalt, EnableThinking: req.EnableThinking, ThinkingBudget: req.ThinkingBudget, } out.FrequencyPenalty = req.FrequencyPenalty + if len(req.Stop) > 0 { + if err := common.Unmarshal(req.Stop, &out.Stop); err != nil { + return nil, fmt.Errorf("invalid stop: %w", err) + } + } out.PresencePenalty = req.PresencePenalty if req.Reasoning != nil { out.ReasoningEffort = req.Reasoning.Effort diff --git a/setting/reasoning/suffix.go b/setting/reasoning/suffix.go index 59140a7c..7a0a411a 100644 --- a/setting/reasoning/suffix.go +++ b/setting/reasoning/suffix.go @@ -1,14 +1,23 @@ package reasoning import ( + "regexp" "strings" + "github.com/MAX-API-Next/MAX-API/setting/model_setting" "github.com/samber/lo" ) var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"} -var OpenAIEffortSuffixes = []string{"-high", "-minimal", "-low", "-medium", "-none", "-xhigh"} +var OpenAIEffortSuffixes = []string{"-max", "-xhigh", "-high", "-minimal", "-low", "-medium", "-none"} + +var openAIModelPattern = regexp.MustCompile(`(?i)^(gpt-[a-z0-9][a-z0-9._-]*|o[1-9][a-z0-9._-]*)$`) + +func PreserveModelSuffix(modelName string) bool { + bare := modelName[strings.LastIndex(modelName, "/")+1:] + return model_setting.ShouldPreserveThinkingSuffix(modelName) || strings.EqualFold(bare, "gpt-5.1-codex-max") +} var DeepSeekV4EffortSuffixes = []string{"-none", "-max"} @@ -28,8 +37,11 @@ func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string, } func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string) { + if PreserveModelSuffix(modelName) { + return "", modelName + } baseModel, effort, ok := TrimEffortSuffixWithSuffixes(modelName, OpenAIEffortSuffixes) - if !ok { + if !ok || !openAIModelPattern.MatchString(baseModel[strings.LastIndex(baseModel, "/")+1:]) { return "", modelName } return effort, baseModel diff --git a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx index 366e1098..991c85bc 100644 --- a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx +++ b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx @@ -81,8 +81,8 @@ const TIME_FUNC_LABELS: Record = { hour: 'Hour', minute: 'Minute', weekday: 'Weekday', - month: 'Month', - day: 'Day', + month: 'Month number', + day: 'Day of month', } function formatTokenHint(value: string | number): string { diff --git a/web/default/src/features/pricing/components/model-details.runtime.test.tsx b/web/default/src/features/pricing/components/model-details.runtime.test.tsx index 4be1239a..71ee53d0 100644 --- a/web/default/src/features/pricing/components/model-details.runtime.test.tsx +++ b/web/default/src/features/pricing/components/model-details.runtime.test.tsx @@ -20,10 +20,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createReactTestEnvironment } from '@/test/react' import { within } from '@testing-library/react' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { after, before, describe, test } from 'node:test' +import { useSystemConfigStore } from '@/stores/system-config-store' import type { PricingModel } from '../types' import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown' import { ModelDetailsContent } from './model-details' +import { ModelTierPricing } from './model-tier-pricing' const testEnv = createReactTestEnvironment() @@ -31,6 +34,297 @@ before(() => testEnv.setup()) after(() => testEnv.teardown()) +describe('Model square expression pricing', () => { + const displayOptions = { + tokenUnit: 'M' as const, + priceRate: 1, + usdExchangeRate: 1, + showRechargePrice: false, + } + const expression = + '(hour("Asia/Shanghai") >= 9 && hour("Asia/Shanghai") < 12 && weekday("Asia/Shanghai") >= 1 && weekday("Asia/Shanghai") < 6) || (hour("Asia/Shanghai") >= 14 && hour("Asia/Shanghai") < 18 && weekday("Asia/Shanghai") >= 1 && weekday("Asia/Shanghai") < 6) ? tier("peak", p * 2 + c * 8 + cr * 0.04) : tier("off", p * 1 + c * 4 + cr * 0)' + + for (const locale of ['en', 'zh', 'fr', 'ja', 'ru', 'vi']) { + test(`provides model square display translations in ${locale}`, () => { + const resource = JSON.parse( + readFileSync( + new URL(`../../../i18n/locales/${locale}.json`, import.meta.url), + 'utf8' + ) + ).translation + for (const key of [ + 'Tier applicability', + 'Prices shown per {{unit}} tokens', + 'Tiers are checked in order; the first match applies.', + 'When no earlier tier matches', + 'All conditions in a group must match.', + 'These conditions cannot match together.', + 'OR', + 'Base tier prices, before group and request multipliers. A dash means no separate coefficient in this tier.', + ]) { + assert.ok(resource[key]?.trim(), `${locale}: ${key}`) + assert.doesNotMatch(resource[key], /\?{2,}/, `${locale}: ${key}`) + if (locale !== 'en') assert.notEqual(resource[key], key) + } + }) + } + + test('localizes the complete token-unit sentence for both display units', async () => { + const resource = JSON.parse( + readFileSync( + new URL('../../../i18n/locales/fr.json', import.meta.url), + 'utf8' + ) + ).translation + testEnv.i18n.addResourceBundle('fr', 'translation', resource) + await testEnv.i18n.changeLanguage('fr') + try { + for (const tokenUnit of ['K', 'M'] as const) { + const view = await testEnv.render( + + ) + try { + assert.ok( + within(view.container).getByText( + `Prix affichés pour 1${tokenUnit} jetons` + ) + ) + } finally { + await view.unmount() + } + } + } finally { + await testEnv.i18n.changeLanguage('en') + } + }) + + test('preserves zero, absent and tiny coefficients and follows model square unit selection', async () => { + const view = await testEnv.render( + + ) + try { + const content = within(view.container) + const table = within(content.getByRole('table')) + assert.deepEqual( + table.getAllByRole('columnheader').map((node) => node.textContent), + ['Tier', 'Input', 'Output', 'Cache Read', 'Cache Write'] + ) + assert.deepEqual( + within(table.getByRole('row', { name: /^free / })) + .getAllByRole('cell') + .map((node) => node.textContent), + ['free', '$0', '$0', '$0', '—'] + ) + assert.deepEqual( + within(table.getByRole('row', { name: /^base / })) + .getAllByRole('cell') + .map((node) => node.textContent), + ['base', '$0.002', '$0.004', '—', '$0.00001'] + ) + assert.match(view.container.textContent || '', /1K tokens/) + } finally { + await view.unmount() + } + }) + + test('identifies calendar day conditions in model pricing and usage logs', async () => { + const source = + 'day("UTC") >= 15 ? tier("late", p * 2) : tier("early", p * 1)' + for (const component of [ + , + , + ]) { + const view = await testEnv.render(component) + try { + assert.match(view.container.textContent || '', /Day of month/) + } finally { + await view.unmount() + } + } + }) + + test('keeps unsupported formulas and request multipliers available without partial pricing claims', async () => { + const source = + '(tier("custom", max(p, 1) * 3 + c * 15)) * (param("fast") == true ? 2 : 1)' + const view = await testEnv.render( + + ) + try { + assert.equal(within(view.container).queryByRole('table'), null) + assert.ok( + within(view.container).getByText('Unable to parse structured pricing') + ) + assert.ok(view.container.querySelector('details[open]')) + assert.equal( + view.container.querySelector('details code')?.textContent, + source + ) + assert.ok(within(view.container).getByText('Conditional multipliers')) + } finally { + await view.unmount() + } + }) + + test('uses model square currency and recharge-price settings consistently', async () => { + const previous = useSystemConfigStore.getState().config + useSystemConfigStore.setState({ + config: { + ...previous, + currency: { + ...previous.currency, + quotaDisplayType: 'CNY', + usdExchangeRate: 7, + }, + }, + }) + try { + for (const [showRechargePrice, expected] of [ + [false, '¥14'], + [true, '¥7'], + ] as const) { + const view = await testEnv.render( + + ) + try { + const row = within(view.container).getByRole('row', { + name: /^base /, + }) + assert.equal( + within(row).getAllByRole('cell')[1].textContent, + expected + ) + } finally { + await view.unmount() + } + } + } finally { + useSystemConfigStore.setState({ config: previous }) + } + }) + + test('shows all request multipliers without disguising an OR time condition as an AND range', async () => { + const source = + '(tier("base", p * 2)) * ((hour("UTC") >= 22 || hour("UTC") < 2) ? 0.5 : 1) * (param("fast") == true ? 2 : 1)' + const view = await testEnv.render( + + ) + try { + const content = within(view.container) + assert.ok(content.getByText('Hour ≥ 22 OR < 2 (UTC)')) + assert.ok(content.getByText('Body param fast = true')) + assert.ok(content.getByText('0.5×')) + assert.ok(content.getByText('2×')) + assert.equal( + view.container.querySelector('details code')?.textContent, + source + ) + } finally { + await view.unmount() + } + }) + + test('warns on impossible time conditions and never invents a matching tier', async () => { + const view = await testEnv.render( + + ) + try { + assert.ok( + within(view.container).getByText( + 'These conditions cannot match together.' + ) + ) + assert.doesNotMatch(view.container.textContent || '', /09:00–12:00/) + assert.equal(within(view.container).queryByText('Matched'), null) + } finally { + await view.unmount() + } + }) + + test('separates prices from conditions in the actual model square without changing the log layout', async () => { + const queryClient = new QueryClient() + const view = await testEnv.render( + + + + ) + try { + const square = within(view.container).getByRole('region', { + name: 'Tiered price table', + }) + const prices = within(square).getByRole('table') + assert.doesNotMatch(prices.textContent || '', /Asia\/Shanghai|&&|\|\|/) + assert.match(prices.textContent || '', /peak/) + const conditions = within(square).getByRole('region', { + name: 'Tier applicability', + }) + assert.match(conditions.textContent || '', /09:00–12:00/) + assert.match(conditions.textContent || '', /14:00–18:00/) + assert.match(conditions.textContent || '', /OR/) + assert.match(conditions.textContent || '', /When no earlier tier matches/) + assert.match(square.textContent || '', /1M tokens/) + assert.equal( + square.querySelector('details code')?.textContent, + expression + ) + } finally { + await view.unmount() + queryClient.clear() + } + + const log = await testEnv.render( + + ) + try { + assert.match( + within(log.container).getByRole('table').textContent || '', + /Asia\/Shanghai/ + ) + assert.equal( + within(log.container).queryByRole('region', { + name: 'Tier applicability', + }), + null + ) + } finally { + await log.unmount() + } + }) +}) + for (const expression of [ 'len < 100 && c == 5 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)', 'len < 100 ? tier("peak", p * 2 + c * 4) : max(p, 1)', diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 1eb7313b..9e4e01fe 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -84,11 +84,11 @@ import type { PricingModel, TokenUnit, } from '../types' -import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown' import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api' import { ModalityIcons } from './model-details-modalities' import { ModelDetailsPerformance } from './model-details-performance' import { ModelDetailsQuickStats } from './model-details-quick-stats' +import { ModelTierPricing } from './model-tier-pricing' // ---------------------------------------------------------------------------- // Local UI helpers @@ -1389,7 +1389,13 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { /> )} {isDynamic && ( - + )} . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useTranslation } from 'react-i18next' +import { useSystemConfigStore } from '@/stores/system-config-store' +import { cn } from '@/lib/utils' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + parseTiersFromExpr, + splitBillingExprAndRequestRules, + tryParseRequestRuleExpr, + type ParsedTier, + type RequestCondition, +} from '../lib/billing-expr' +import { getDynamicPriceEntries } from '../lib/dynamic-price' +import { + type summarizeTierConditionGroup, + summarizeTierConditionAlternatives, + TIER_CONDITION_LABELS, +} from '../lib/tier-condition-display' +import type { TokenUnit } from '../types' + +type ModelTierPricingProps = { + billingExpr: string + tokenUnit: TokenUnit + priceRate: number + usdExchangeRate: number + showRechargePrice: boolean +} + +function describeRequestCondition( + condition: RequestCondition, + t: (key: string) => string +): string { + const comparison: Record = { + eq: '=', + gt: '>', + gte: '≥', + lt: '<', + lte: '≤', + } + if (condition.source === 'time') { + const label = t(TIER_CONDITION_LABELS[condition.timeFunc]) + // The shared parser represents this form as an OR, including wraparound. + // Do not infer an AND range merely from the order of its bounds. + const value = + condition.mode === 'range' + ? `≥ ${condition.rangeStart} ${t('OR')} < ${condition.rangeEnd}` + : `${comparison[condition.mode] ?? '='} ${condition.value}` + return `${label} ${value} (${condition.timezone})` + } + const label = `${t(condition.source === 'header' ? 'Header' : 'Body param')} ${condition.path}` + if (condition.mode === 'exists') return `${label} ${t('Exists')}` + if (condition.mode === 'contains') + return `${label} ${t('Contains')} ${condition.value}` + return `${label} ${comparison[condition.mode] ?? '='} ${condition.value}` +} + +function ConditionGroup(props: { + summary: ReturnType +}) { + const { t } = useTranslation() + const summary = props.summary + const timezones = [ + ...new Set( + summary.lines.flatMap((line) => + line.timezone === undefined ? [] : [line.timezone] + ) + ), + ] + return ( +
+
    + {summary.lines.map((line, index) => ( +
  • + {t(TIER_CONDITION_LABELS[line.variable])}{' '} + {line.value} + {timezones.length > 1 && + line.timezone !== undefined && + ` (${line.timezone})`} +
  • + ))} +
+ {timezones.length === 1 && ( +

+ {t('Timezone')}: {timezones[0]} +

+ )} + {summary.impossible && ( +

+ {t('These conditions cannot match together.')} +

+ )} +
+ ) +} + +function TierApplicability(props: { tiers: ParsedTier[] }) { + const { t, i18n } = useTranslation() + return ( +
+
+

{t('Tier applicability')}

+

+ {t('Tiers are checked in order; the first match applies.')} +

+
+
+ {props.tiers.map((tier, index) => { + const groups = + tier.conditionGroups ?? + (tier.conditions.length ? [tier.conditions] : []) + const summaries = summarizeTierConditionAlternatives( + groups, + i18n.language, + t('OR') + ) + const defaultTierMessage = + index === 0 + ? t('Always matches (default tier).') + : t('When no earlier tier matches') + return ( + + + + {index + 1} + + {tier.label || t('Default')} + + + + + {groups.length === 0 ? ( +

+ {defaultTierMessage} +

+ ) : ( + <> +

+ {t('All conditions in a group must match.')} +

+ {summaries.map((summary, groupIndex) => ( +
+ {groupIndex > 0 && ( + {t('OR')} + )} + +
+ ))} + + )} +
+
+ ) + })} +
+
+ ) +} + +/** Model-square-only presentation; the admin editor and historical logs stay separate. */ +export function ModelTierPricing(props: ModelTierPricingProps) { + const { t } = useTranslation() + // Price formatters read the same config; subscribe so live currency changes rerender. + useSystemConfigStore((state) => state.config.currency) + const split = splitBillingExprAndRequestRules(props.billingExpr) + const tiers = parseTiersFromExpr(split.billingExpr) + const requestGroups = tryParseRequestRuleExpr(split.requestRuleExpr) + const prices = tiers.map((tier) => + getDynamicPriceEntries(tier, { ...props, groupRatioMultiplier: 1 }) + ) + const fields = [ + ...new Map(prices.flat().map((entry) => [entry.field, entry])).values(), + ] + + return ( +
+ {tiers.length > 0 ? ( + <> +
+
+

{t('Tiered price table')}

+ + {t('Prices shown per {{unit}} tokens', { + unit: props.tokenUnit === 'K' ? '1K' : '1M', + })} + +
+
+ 4 && 'min-w-[64rem]' + )} + > + + + + {t('Tier')} + + {fields.map((field) => ( + + {t(field.shortLabel)} + + ))} + + + + {tiers.map((tier, index) => { + const entries = new Map( + prices[index].map((entry) => [entry.field, entry]) + ) + return ( + + + + {tier.label || t('Default')} + + + {fields.map((field) => ( + + {entries.get(field.field)?.formatted ?? '—'} + + ))} + + ) + })} + +
+
+
+ {tiers.map((tier, index) => { + const entries = new Map( + prices[index].map((entry) => [entry.field, entry]) + ) + return ( +
+ + {tier.label || t('Default')} + +
+ {fields.map((field) => ( +
+
+ {t(field.shortLabel)} +
+
+ {entries.get(field.field)?.formatted ?? '—'} +
+
+ ))} +
+
+ ) + })} +
+

+ {t( + 'Base tier prices, before group and request multipliers. A dash means no separate coefficient in this tier.' + )} +

+
+ + + ) : ( +

+ {t('Unable to parse structured pricing')} +

+ )} + {split.requestRuleExpr && ( +
+

+ {t('Conditional multipliers')} +

+

+ {t( + 'When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.' + )} +

+ {requestGroups?.length ? ( +
    + {requestGroups.map((group, index) => ( +
  • +
    +

    + {t('All conditions in a group must match.')} +

    + {group.conditions.map((condition, conditionIndex) => ( +

    + {describeRequestCondition(condition, t)} +

    + ))} +
    + {group.multiplier}× +
  • + ))} +
+ ) : ( + + {split.requestRuleExpr} + + )} +
+ )} +
+ + {t('Raw expression')} + + + {props.billingExpr} + +
+
+ ) +} diff --git a/web/default/src/features/pricing/lib/tier-condition-display.test.ts b/web/default/src/features/pricing/lib/tier-condition-display.test.ts new file mode 100644 index 00000000..d5448312 --- /dev/null +++ b/web/default/src/features/pricing/lib/tier-condition-display.test.ts @@ -0,0 +1,174 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import type { TierCondition } from './billing-expr' +import { + summarizeTierConditionGroup, + summarizeTierConditionAlternatives, +} from './tier-condition-display' + +function hour( + op: TierCondition['op'], + value: number, + timezone = 'Asia/Shanghai' +): TierCondition { + return { var: 'hour', op, value, timezone } +} + +test('formats hour intervals with exact inclusive and exclusive semantics', () => { + for (const [upperOp, end] of [ + ['<', '12:00'], + ['<=', '13:00'], + ] as const) { + const source = [hour('>=', 9), hour(upperOp, 12)] + const snapshot = structuredClone(source) + assert.deepEqual(summarizeTierConditionGroup(source, 'en'), { + impossible: false, + lines: [ + { variable: 'hour', value: `09:00–${end}`, timezone: 'Asia/Shanghai' }, + ], + }) + assert.deepEqual(source, snapshot) + } + assert.equal( + summarizeTierConditionGroup([hour('>', 9.5), hour('<', 12.5)], 'en') + .lines[0].value, + '10:00–13:00' + ) + assert.equal( + summarizeTierConditionGroup([hour('>=', 22)], 'en').lines[0].value, + '22:00–24:00' + ) +}) + +test('never turns incompatible AND conditions into alternative time windows', () => { + const result = summarizeTierConditionGroup( + [hour('>=', 9), hour('<', 12), hour('>=', 14), hour('<', 18)], + 'en' + ) + assert.equal(result.impossible, true) + assert.equal(result.lines[0].value, '≥ 9 ∧ < 12 ∧ ≥ 14 ∧ < 18') +}) + +test('shares weekday restrictions only when every OR window has identical restrictions', () => { + const weekday: TierCondition = { + var: 'weekday', + op: '>=', + value: 1, + timezone: 'Asia/Shanghai', + } + const groups = [ + [hour('>=', 9), hour('<', 12), weekday], + [hour('>=', 14), hour('<', 18), weekday], + ] + const snapshot = structuredClone(groups) + const summaries = summarizeTierConditionAlternatives(groups, 'zh', '或') + assert.equal(summaries.length, 1) + assert.equal(summaries[0].lines[0].value, '09:00–12:00 或 14:00–18:00') + assert.deepEqual(groups, snapshot) + groups[1][2] = { ...weekday, value: 5 } + assert.equal(summarizeTierConditionAlternatives(groups, 'zh', '或').length, 2) + assert.equal( + summarizeTierConditionAlternatives( + [[hour('>=', 9), hour('<', 3)], groups[0]], + 'en', + 'OR' + ).length, + 2 + ) + assert.equal( + summarizeTierConditionAlternatives( + [[hour('>=', 9, 'UTC')], [hour('>=', 14)]], + 'en', + 'OR' + ).length, + 2 + ) +}) + +test('does not intersect different timezones or drop non-time conditions', () => { + const result = summarizeTierConditionGroup( + [ + hour('>=', 9), + hour('<', 3, 'UTC'), + { var: 'len', op: '<=', value: 200001 }, + ], + 'en' + ) + assert.equal(result.impossible, false) + assert.deepEqual( + result.lines.map((line) => line.value), + ['09:00–24:00', '00:00–03:00', '≤ 200001'] + ) + assert.deepEqual( + result.lines.map((line) => line.timezone), + ['Asia/Shanghai', 'UTC', undefined] + ) +}) + +test('keeps calendar months, month days, weekdays and minutes distinct', () => { + const result = summarizeTierConditionGroup( + [ + { var: 'month', op: '>=', value: 9, timezone: 'UTC' }, + { var: 'day', op: '<=', value: 15, timezone: 'UTC' }, + { var: 'minute', op: '<', value: 30, timezone: 'UTC' }, + { var: 'weekday', op: '>=', value: 1, timezone: 'UTC' }, + { var: 'weekday', op: '<', value: 6, timezone: 'UTC' }, + ], + 'zh' + ) + assert.deepEqual( + result.lines.slice(0, 3).map((line) => line.value), + ['9–12', '1–15', '0–29'] + ) + assert.equal(result.lines[3].value, '周一 · 周二 · 周三 · 周四 · 周五') + assert.equal(result.impossible, false) +}) + +test('all summarized hour ranges agree with the original integer comparisons', () => { + const evaluate = ( + value: number, + op: TierCondition['op'], + boundary: number + ): boolean => { + if (op === '<') return value < boundary + if (op === '<=') return value <= boundary + if (op === '>') return value > boundary + return value >= boundary + } + for (const op of ['<', '<=', '>', '>='] as const) { + for (const boundary of [0, 1, 9, 9.5, 12, 23, 24, 30]) { + const summary = summarizeTierConditionGroup([hour(op, boundary)], 'en') + const range = summary.lines[0].value.match(/^(\d+):00–(\d+):00$/) + for (let value = 0; value < 24; value++) { + const displayedMatch = + !summary.impossible && + range !== null && + value >= Number(range[1]) && + value < Number(range[2]) + assert.equal( + displayedMatch, + evaluate(value, op, boundary), + `${value} ${op} ${boundary}` + ) + } + } + } +}) diff --git a/web/default/src/features/pricing/lib/tier-condition-display.ts b/web/default/src/features/pricing/lib/tier-condition-display.ts new file mode 100644 index 00000000..06d84e9b --- /dev/null +++ b/web/default/src/features/pricing/lib/tier-condition-display.ts @@ -0,0 +1,164 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import type { TierCondition } from './billing-expr' +import { getTierConditionBounds } from './tier-expr' + +export const TIER_CONDITION_LABELS = { + p: 'Input', + c: 'Output', + len: 'Full input length', + hour: 'Hour', + minute: 'Minute', + weekday: 'Weekday', + month: 'Month number', + day: 'Day of month', +} as const + +type ConditionDisplayLine = { + variable: TierCondition['var'] + value: string + timezone?: string +} + +const operators = { '>=': '≥', '<=': '≤', '>': '>', '<': '<' } + +function matches(value: number, condition: TierCondition): boolean { + switch (condition.op) { + case '>=': + return value >= condition.value + case '>': + return value > condition.value + case '<=': + return value <= condition.value + case '<': + return value < condition.value + } +} + +function weekdayName(day: number, locale: string): string { + const date = new Date(Date.UTC(2024, 0, 7 + day)) // Sunday + weekday index + try { + return new Intl.DateTimeFormat(locale, { + weekday: 'short', + timeZone: 'UTC', + }).format(date) + } catch { + return new Intl.DateTimeFormat('en', { + weekday: 'short', + timeZone: 'UTC', + }).format(date) + } +} + +/** Read-only summaries of an AND group. Never merge groups or rewrite rules. */ +export function summarizeTierConditionGroup( + conditions: TierCondition[], + locale: string +): { + lines: ConditionDisplayLine[] + impossible: boolean +} { + const dimensions = new Map() + for (const condition of conditions) { + const key = JSON.stringify([condition.var, condition.timezone]) + dimensions.set(key, [...(dimensions.get(key) ?? []), condition]) + } + let impossible = false + const lines = Array.from(dimensions.values()).map( + (constraints): ConditionDisplayLine => { + const first = constraints[0] + const rawValue = constraints + .map((condition) => `${operators[condition.op]} ${condition.value}`) + .join(' ∧ ') + const line: ConditionDisplayLine = { + variable: first.var, + value: rawValue, + ...(first.timezone !== undefined ? { timezone: first.timezone } : {}), + } + const bounds = getTierConditionBounds(first.var) + if (!bounds) return line + + // Time helpers return integers. Intersect their bounded domains to preserve + // < versus <=, fractional boundaries and contradictory conditions exactly. + const allowed = Array.from( + { length: bounds.max - bounds.min + 1 }, + (_, i) => bounds.min + i + ).filter((value) => + constraints.every((condition) => matches(value, condition)) + ) + if (!allowed.length) { + impossible = true + return line + } + const start = allowed[0] + const end = allowed[allowed.length - 1] + if (first.var === 'hour') { + line.value = `${String(start).padStart(2, '0')}:00–${String(end + 1).padStart(2, '0')}:00` + } else if (first.var === 'weekday') { + line.value = allowed.map((day) => weekdayName(day, locale)).join(' · ') + } else { + line.value = start === end ? String(start) : `${start}–${end}` + } + return line + } + ) + return { lines, impossible } +} + +/** Factor identical restrictions in alternative hour windows, and nothing else. */ +export function summarizeTierConditionAlternatives( + groups: TierCondition[][], + locale: string, + orLabel: string +): ReturnType[] { + const summaries = groups.map((group) => + summarizeTierConditionGroup(group, locale) + ) + if (summaries.length < 2 || summaries.some((summary) => summary.impossible)) + return summaries + const first = summaries[0] + const hours = first.lines.filter((line) => line.variable === 'hour') + if (hours.length !== 1) return summaries + const common = JSON.stringify( + first.lines.filter((line) => line.variable !== 'hour') + ) + const windows: string[] = [] + for (const summary of summaries) { + const hourLines = summary.lines.filter((line) => line.variable === 'hour') + if ( + hourLines.length !== 1 || + hourLines[0].timezone !== hours[0].timezone || + JSON.stringify( + summary.lines.filter((line) => line.variable !== 'hour') + ) !== common + ) + return summaries + windows.push(hourLines[0].value) + } + return [ + { + impossible: false, + lines: first.lines.map((line) => + line.variable === 'hour' + ? { ...line, value: [...new Set(windows)].join(` ${orLabel} `) } + : line + ), + }, + ] +} diff --git a/web/default/src/features/system-settings/integrations/payment-settings-section.runtime.test.tsx b/web/default/src/features/system-settings/integrations/payment-settings-section.runtime.test.tsx new file mode 100644 index 00000000..417b0f4d --- /dev/null +++ b/web/default/src/features/system-settings/integrations/payment-settings-section.runtime.test.tsx @@ -0,0 +1,337 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, type ComponentProps } from 'react' +import type { Root } from 'react-dom/client' +import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createInstance } from 'i18next' +import { JSDOM } from 'jsdom' +import assert from 'node:assert/strict' +import { after, afterEach, beforeEach, test } from 'node:test' +import { I18nextProvider } from 'react-i18next' +import type { ConfirmPaymentComplianceResponse } from '../types' + +// Import DOM-dependent components after installing the browser globals. +let createRoot: typeof import('react-dom/client').createRoot +let fireEvent: typeof import('@testing-library/react/pure').fireEvent +let within: typeof import('@testing-library/react/pure').within +let PaymentSettingsSection: typeof import('./payment-settings-section').PaymentSettingsSection +let api: typeof import('@/lib/api').api +let originalAdapter: typeof api.defaults.adapter + +type PaymentProps = ComponentProps +const dom = new JSDOM('', { + url: 'http://localhost/', + pretendToBeVisual: true, +}) +const i18n = createInstance() +const previousGlobals = new Map() +const requests: InternalAxiosRequestConfig[] = [] +let respond: () => Promise +let container: HTMLDivElement +let root: Root +let queryClient: QueryClient + +function defaults(): PaymentProps { + return { + defaultValues: { + PayAddress: '', + EpayId: '', + EpayKey: '', + Price: 1, + MinTopUp: 1, + CustomCallbackAddress: '', + PayMethods: '[]', + AmountOptions: '[]', + AmountDiscount: '{}', + StripeApiSecret: '', + StripeWebhookSecret: '', + StripePriceId: '', + StripeUnitPrice: 1, + StripeMinTopUp: 1, + StripePromotionCodesEnabled: false, + CreemApiKey: '', + CreemWebhookSecret: '', + CreemTestMode: false, + CreemProducts: '[]', + }, + waffoDefaultValues: { + WaffoEnabled: false, + WaffoApiKey: '', + WaffoPrivateKey: '', + WaffoPublicCert: '', + WaffoSandboxPublicCert: '', + WaffoSandboxApiKey: '', + WaffoSandboxPrivateKey: '', + WaffoSandbox: false, + WaffoMerchantId: '', + WaffoCurrency: 'USD', + WaffoUnitPrice: 1, + WaffoMinTopUp: 1, + WaffoNotifyUrl: '', + WaffoReturnUrl: '', + WaffoPayMethods: '[]', + }, + waffoPancakeDefaultValues: { + WaffoPancakeMerchantID: '', + WaffoPancakePrivateKey: '', + WaffoPancakeReturnURL: '', + }, + complianceDefaults: { + confirmed: false, + termsVersion: '', + confirmedAt: 0, + confirmedBy: 0, + }, + } +} + +async function renderPayment(props: PaymentProps = defaults()): Promise { + await act(async (): Promise => { + root.render( + + + + + + ) + }) +} + +async function flushUpdates(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function click(element: HTMLElement): Promise { + await act(async (): Promise => { + fireEvent.click(element) + await flushUpdates() + }) +} + +async function setup(): Promise { + const window = dom.window + const globals: Record = { + window, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + Element: window.Element, + Node: window.Node, + Event: window.Event, + MouseEvent: window.MouseEvent, + MutationObserver: window.MutationObserver, + getComputedStyle: window.getComputedStyle.bind(window), + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + localStorage: window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + } + for (const [key, value] of Object.entries(globals)) { + previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)) + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }) + } + await i18n.init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: {} } }, + }) + ;({ createRoot } = await import('react-dom/client')) + ;({ fireEvent, within } = await import('@testing-library/react/pure')) + ;({ PaymentSettingsSection } = await import('./payment-settings-section')) + ;({ api } = await import('@/lib/api')) + originalAdapter = api.defaults.adapter +} + +// Keep module loading outside the timed interactions, as in a loaded browser page. +await setup() + +function complianceAlert(): ReturnType { + const alert = within(container) + .getByText(/^(Compliance confirmation required|Compliance confirmed)$/) + .closest('[role="alert"]') + assert.ok(alert) + return within(alert) +} + +beforeEach((): void => { + container = dom.window.document.createElement('div') + dom.window.document.body.append(container) + root = createRoot(container) + queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + queryClient.setQueryData(['system-options'], []) + requests.length = 0 + respond = async (): Promise => ({ + success: true, + message: '', + }) + api.defaults.adapter = async ( + config: InternalAxiosRequestConfig + ): Promise => { + requests.push(config) + assert.equal(config.url, '/api/option/payment_compliance') + assert.equal(config.method, 'post') + return { + config, + data: await respond(), + status: 200, + statusText: 'OK', + headers: {}, + } + } +}) + +afterEach(async (): Promise => { + await act(async (): Promise => root.unmount()) + container.remove() + queryClient.clear() + api.defaults.adapter = originalAdapter +}) + +after((): void => { + for (const [key, descriptor] of [...previousGlobals].reverse()) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } + dom.window.close() +}) + +test('shows the terms without typing and does not confirm on open or cancel', async (): Promise => { + await renderPayment() + const screen = within(dom.window.document.body) + await click( + complianceAlert().getByRole('button', { name: 'Confirm compliance' }) + ) + const dialog = screen.getByRole('alertdialog', { + name: 'Confirm compliance terms', + }) + const dialogScreen = within(dialog) + assert.equal(dialogScreen.getAllByRole('listitem').length, 6) + assert.ok( + dialogScreen.getByText( + 'You understand and independently bear legal responsibility arising from deployment, operation, and charging behavior.' + ) + ) + assert.equal(dialogScreen.queryByRole('textbox'), null) + assert.equal(dialogScreen.queryByRole('checkbox'), null) + const confirm = dialogScreen.getByRole('button', { + name: 'Acknowledge and enable features', + }) + assert.equal(confirm.disabled, false) + assert.equal(requests.length, 0) + await click(dialogScreen.getByRole('button', { name: 'Cancel' })) + assert.equal(screen.queryByRole('alertdialog'), null) + assert.equal(requests.length, 0) +}) + +test('a single click submits acknowledgment, disables pending actions and refreshes saved state', async (): Promise => { + let finish!: (value: ConfirmPaymentComplianceResponse) => void + const pending = new Promise((resolve) => { + finish = resolve + }) + respond = (): Promise => pending + await renderPayment() + const screen = within(dom.window.document.body) + await click( + complianceAlert().getByRole('button', { name: 'Confirm compliance' }) + ) + const dialogScreen = within(screen.getByRole('alertdialog')) + const confirm = dialogScreen.getByRole('button', { + name: 'Acknowledge and enable features', + }) + const cancel = dialogScreen.getByRole('button', { + name: 'Cancel', + }) + await click(confirm) + assert.equal(confirm.disabled, true) + assert.equal(cancel.disabled, true) + await click(confirm) + assert.equal(requests.length, 1) + assert.deepEqual(JSON.parse(requests[0].data as string), { confirmed: true }) + await act(async (): Promise => { + finish({ success: true, message: '' }) + await flushUpdates() + }) + assert.equal(screen.queryByRole('alertdialog'), null) + assert.equal( + queryClient.getQueryState(['system-options'])?.isInvalidated, + true + ) + + const props = defaults() + props.complianceDefaults = { + confirmed: true, + termsVersion: 'v1', + confirmedAt: 1700000000, + confirmedBy: 1, + } + await renderPayment(props) + assert.ok(screen.getByText('Compliance confirmed')) + assert.equal( + complianceAlert().queryByRole('button', { name: 'Confirm compliance' }), + null + ) +}) + +test('rejected acknowledgment keeps the dialog open without unlocking features', async (): Promise => { + respond = async (): Promise => ({ + success: false, + message: 'Confirmation rejected', + }) + await renderPayment() + const screen = within(dom.window.document.body) + await click( + complianceAlert().getByRole('button', { name: 'Confirm compliance' }) + ) + const confirm = within( + screen.getByRole('alertdialog') + ).getByRole('button', { + name: 'Acknowledge and enable features', + }) + await click(confirm) + assert.equal(requests.length, 1) + assert.ok(screen.getByRole('alertdialog')) + assert.equal(confirm.disabled, false) + assert.equal(screen.queryByText('Compliance confirmed'), null) + assert.equal( + queryClient.getQueryState(['system-options'])?.isInvalidated, + false + ) +}) + +test('an outdated terms version still requires explicit acknowledgment', async (): Promise => { + const props = defaults() + props.complianceDefaults = { + confirmed: true, + termsVersion: 'v0', + confirmedAt: 1700000000, + confirmedBy: 1, + } + await renderPayment(props) + assert.ok( + complianceAlert().getByRole('button', { name: 'Confirm compliance' }) + ) + assert.equal(requests.length, 0) +}) diff --git a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx index b16d1146..d83b2185 100644 --- a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx +++ b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx @@ -279,36 +279,6 @@ export function PaymentSettingsSection({ [t] ) - const complianceRequiredText = t( - 'I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.' - ) - const complianceRequiredTextParts = React.useMemo( - () => [ - { - type: 'input' as const, - text: t('I have read and understood the above compliance reminder'), - }, - { type: 'static' as const, text: t(',') }, - { - type: 'input' as const, - text: t('acknowledge the related legal risks'), - }, - { type: 'static' as const, text: t(',and ') }, - { - type: 'input' as const, - text: t( - 'confirm that I bear legal responsibility arising from deployment' - ), - }, - { type: 'static' as const, text: t('、') }, - { - type: 'input' as const, - text: t('operation and charging behavior'), - }, - ], - [t] - ) - const complianceConfirmed = complianceDefaults.confirmed && complianceDefaults.termsVersion === CURRENT_COMPLIANCE_TERMS_VERSION @@ -833,12 +803,7 @@ export function PaymentSettingsSection({ 'This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.' )} items={complianceStatements} - requiredText={complianceRequiredText} - requiredTextParts={complianceRequiredTextParts} - inputPrompt={t('Please type the following text to confirm:')} - inputPlaceholder={t('Type the confirmation text here')} - mismatchHint={t('The entered text does not match the required text.')} - confirmText={t('Confirm and enable')} + confirmText={t('Acknowledge and enable features')} isLoading={confirmComplianceMutation.isPending} onConfirm={() => confirmComplianceMutation.mutate()} /> diff --git a/web/default/src/features/system-settings/models/billing-time-help.tsx b/web/default/src/features/system-settings/models/billing-time-help.tsx new file mode 100644 index 00000000..32c4552c --- /dev/null +++ b/web/default/src/features/system-settings/models/billing-time-help.tsx @@ -0,0 +1,85 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import type { ReactElement } from 'react' +import type { TFunction } from 'i18next' +import { useTranslation } from 'react-i18next' +import { FieldDescription } from '@/components/ui/field' +import { TIME_FUNCS, type TimeFunc } from '@/features/pricing/lib/billing-expr' + +function describeTimeValue(timeFunc: TimeFunc, t: TFunction): string { + switch (timeFunc) { + case 'hour': + return t('Hour: 0–23 (24-hour clock); 0 is midnight.') + case 'minute': + return t('Minute: 0–59 within the current hour.') + case 'weekday': + return t( + 'Weekday: 0 = Sunday, 1 = Monday, 2 = Tuesday, 3 = Wednesday, 4 = Thursday, 5 = Friday, 6 = Saturday.' + ) + case 'month': + return t('Month: 1–12; January is 1.') + case 'day': + return t( + 'Day of month: 1–31, depending on the month and leap year; this is not a duration.' + ) + } +} + +type BillingTimeHelpProps = { + id: string + timeFunc?: TimeFunc + mode: 'visual' | 'expression' +} + +export function BillingTimeHelp(props: BillingTimeHelpProps): ReactElement { + const { t } = useTranslation() + const timeFunctions = props.timeFunc ? [props.timeFunc] : TIME_FUNCS + + return ( + + {timeFunctions.map((timeFunc) => ( + + {props.mode === 'expression' && {timeFunc}(tz): } + {describeTimeValue(timeFunc, t)} + + ))} + {timeFunctions.includes('weekday') && ( + + {t( + 'Weekday < 6 includes Sunday through Friday. For Monday–Friday, use >= 1 AND <= 5 in the same condition group.' + )} + + )} + + {t( + 'Time values use the configured timezone. Asia/Shanghai is China Standard Time (UTC+8).' + )} + + + {props.mode === 'visual' + ? t( + 'An empty timezone in this visual editor uses Asia/Shanghai when generating the expression. Unrecognized timezones are evaluated as UTC.' + ) + : t( + 'In expressions, an empty or unrecognized timezone is evaluated as UTC.' + )} + + + ) +} diff --git a/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx b/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx index ff9e6662..a840f383 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx @@ -23,7 +23,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createInstance } from 'i18next' import { JSDOM } from 'jsdom' import assert from 'node:assert/strict' -import { after, before, describe, test } from 'node:test' +import { readFileSync } from 'node:fs' +import { after, describe, test } from 'node:test' import { I18nextProvider } from 'react-i18next' import { BILLING_EXTRA_VARS, @@ -39,6 +40,7 @@ let within: typeof import('@testing-library/react').within let ModelPricingEditorPanel: typeof import('./model-pricing-sheet').ModelPricingEditorPanel let ModelRatioVisualEditor: typeof import('./model-ratio-visual-editor').ModelRatioVisualEditor let TieredPricingEditor: typeof import('./tiered-pricing-editor').TieredPricingEditor +let DynamicPricingBreakdown: typeof import('@/features/pricing/components/dynamic-pricing-breakdown').DynamicPricingBreakdown let TieredBillingSettings: typeof import('./tiered-billing-settings').TieredBillingSettings let TaskRateCardSettings: typeof import('./task-rate-card-settings').TaskRateCardSettings let SettingsPageProvider: typeof import('../components/settings-page-context').SettingsPageProvider @@ -819,7 +821,7 @@ describe('optional zero prices in the actual editor', () => { }) }) -before(async () => { +async function setup(): Promise { await i18n.init({ lng: 'en', fallbackLng: 'en', @@ -865,11 +867,17 @@ before(async () => { ;({ ModelPricingEditorPanel } = await import('./model-pricing-sheet')) ;({ ModelRatioVisualEditor } = await import('./model-ratio-visual-editor')) ;({ TieredPricingEditor } = await import('./tiered-pricing-editor')) + ;({ DynamicPricingBreakdown } = + await import('@/features/pricing/components/dynamic-pricing-breakdown')) ;({ TieredBillingSettings } = await import('./tiered-billing-settings')) ;({ TaskRateCardSettings } = await import('./task-rate-card-settings')) ;({ SettingsPageProvider } = await import('../components/settings-page-context')) -}) +} + +// Module loading precedes timed interactions, just as it does on a loaded page. +// Keep DOM installation before imports so Base UI uses its browser lifecycle. +await setup() after(() => { restoreGlobals() @@ -877,6 +885,196 @@ after(() => { }) describe('TieredPricingEditor runtime behavior', () => { + test('explains weekday values and visual timezone defaults without changing pricing', async (): Promise => { + const container = createContainer() + const root = createRoot(container) + const billingExpr = + 'weekday("Asia/Shanghai") < 6 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)' + const requestRuleExpr = '(weekday("Asia/Shanghai") == 0 ? 0.8 : 1)' + const changes: string[] = [] + try { + await act(async () => + root.render( + + changes.push(next)} + onRequestRuleExprChange={(next) => changes.push(next)} + /> + + ) + ) + const values = within(container).getAllByLabelText('Condition Value') + assert.deepEqual( + values.map((input) => (input as HTMLInputElement).value), + ['6', '0'] + ) + for (const input of values) { + const descriptionId = input.getAttribute('aria-describedby') + assert.ok(descriptionId) + const description = dom.window.document.getElementById(descriptionId) + assert.ok(description) + assert.match(description.textContent ?? '', /0 = Sunday, 1 = Monday/) + assert.match(description.textContent ?? '', /6 = Saturday/) + assert.match( + description.textContent ?? '', + /< 6 includes Sunday through Friday/ + ) + assert.match(description.textContent ?? '', />= 1 AND <= 5/) + assert.match( + description.textContent ?? '', + /empty timezone in this visual editor uses Asia\/Shanghai/ + ) + } + const timezone = within(container).getByLabelText('Timezone') + assert.equal( + timezone.getAttribute('aria-describedby'), + values[0].getAttribute('aria-describedby') + ) + assert.deepEqual(changes, []) + } finally { + await unmount(root, container) + } + }) + + test('provides all time ranges and expression timezone semantics in raw mode', async (): Promise => { + const container = createContainer() + const root = createRoot(container) + const source = + 'weekday("") == 0 ? tier("sunday", p * 2 + c * 4) : tier("other", p * 1 + c * 2)' + const changes: string[] = [] + try { + await act(async () => + root.render( + + changes.push(next)} + onRequestRuleExprChange={(next) => changes.push(next)} + /> + + ) + ) + const textarea = container.querySelector('textarea') + assert.ok(textarea) + assert.equal(textarea.value, source) + const descriptionId = textarea.getAttribute('aria-describedby') + assert.ok(descriptionId) + const description = dom.window.document.getElementById(descriptionId) + assert.ok(description) + for (const helper of ['hour', 'minute', 'weekday', 'month', 'day']) { + assert.ok(description.textContent?.includes(`${helper}(tz)`)) + } + for (const range of ['0–23', '0–59', '1–12', '1–31']) { + assert.ok(description.textContent?.includes(range)) + } + assert.match( + description.textContent ?? '', + /empty or unrecognized timezone is evaluated as UTC/ + ) + assert.doesNotMatch( + description.textContent ?? '', + /visual editor uses Asia\/Shanghai/ + ) + assert.deepEqual(changes, []) + } finally { + await unmount(root, container) + } + }) + + const conditionLocaleKeys = [ + 'Add condition group', + 'Condition group', + 'Remove condition group', + 'Each tier supports multiple conditions. The last tier without conditions is the fallback.', + 'Month number', + ] + const conditionLocales = Object.fromEntries( + ['en', 'zh', 'fr', 'ja', 'ru', 'vi'].map((locale) => [ + locale, + JSON.parse( + readFileSync( + new URL(`../../../i18n/locales/${locale}.json`, import.meta.url), + 'utf8' + ) + ) as { translation: Record }, + ]) + ) + + for (const [locale, resource] of Object.entries(conditionLocales)) { + test(`provides condition group translations in ${locale}`, () => { + for (const key of conditionLocaleKeys) { + assert.ok(resource.translation[key]?.trim(), `${locale}: ${key}`) + if (locale !== 'en') { + assert.notEqual(resource.translation[key], key, `${locale}: ${key}`) + } + } + }) + } + + test('localizes condition groups and calendar months without changing the expression', async (): Promise => { + const localizedI18n = createInstance() + await localizedI18n.init({ + lng: 'zh', + fallbackLng: 'en', + resources: conditionLocales, + interpolation: { escapeValue: false }, + }) + const container = createContainer() + const root = createRoot(container) + const source = + 'month("Asia/Shanghai") >= 9 ? tier("peak", p * 2 + c * 4) : tier("off", p * 1 + c * 2)' + const changes: string[] = [] + try { + await act(async () => + root.render( + + changes.push(next)} + onRequestRuleExprChange={() => undefined} + /> + + + ) + ) + const editor = within(container) + assert.ok(editor.getAllByRole('button', { name: '新增条件组' }).length) + assert.ok(editor.getByText('条件组 1')) + assert.ok(editor.getByRole('button', { name: '删除条件组' })) + assert.ok( + editor.getByText('每个档位支持多个条件,最后一个无条件档位作为兜底。') + ) + const monthSelector = editor + .getAllByRole('combobox') + .find((element) => element.textContent?.trim() === '月') + assert.ok(monthSelector) + assert.ok(editor.getByText('1–12')) + assert.ok(editor.getAllByText('月 ≥ 9 (Asia/Shanghai)').length) + assert.equal(editor.queryByText('本月'), null) + for (const key of conditionLocaleKeys.slice(0, 4)) { + assert.equal(container.textContent?.includes(key), false) + } + // The generic key can still describe a current-month dashboard filter. + assert.equal(localizedI18n.t('Month'), '本月') + assert.deepEqual(changes, []) + + await act(async () => { + await localizedI18n.changeLanguage('en') + }) + assert.ok( + editor.getAllByRole('button', { name: 'Add condition group' }).length + ) + assert.equal(monthSelector.textContent?.trim(), 'Month number') + assert.deepEqual(changes, []) + } finally { + await unmount(root, container) + } + }) + test('keeps an empty timezone editable and applies the default only to the expression', async (): Promise => { const container = createContainer() const root = createRoot(container) diff --git a/web/default/src/features/system-settings/models/tiered-pricing-request-rules.tsx b/web/default/src/features/system-settings/models/tiered-pricing-request-rules.tsx index 29408fc2..f5be180f 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-request-rules.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-request-rules.tsx @@ -57,6 +57,7 @@ import { type TimeCondition, type TimeFunc, } from '@/features/pricing/lib/billing-expr' +import { BillingTimeHelp } from './billing-time-help' import { DraftNumberInput } from './tiered-pricing-fields' // --------------------------------------------------------------------------- @@ -75,6 +76,7 @@ function RuleConditionRow({ onRemove, }: RuleConditionRowProps) { const { t } = useTranslation() + const timeHelpId = useId() const matchOptions = getRequestRuleMatchOptions(condition.source) const getMatchLabel = (mode: string) => { switch (mode) { @@ -175,7 +177,7 @@ function RuleConditionRow({ value !== null && onChange({ ...timeCond, timezone: value }) } > - + {COMMON_TIMEZONES.find((tz) => tz.value === timeCond.timezone) ?.label ?? timeCond.timezone} @@ -222,6 +224,7 @@ function RuleConditionRow({ onChange({ ...timeCond, rangeStart: String(value) }) } aria-label={t('Start')} + aria-describedby={timeHelpId} placeholder={t('Start')} className='w-20' /> @@ -232,6 +235,7 @@ function RuleConditionRow({ onChange({ ...timeCond, rangeEnd: String(value) }) } aria-label={t('End')} + aria-describedby={timeHelpId} placeholder={t('End')} className='w-20' /> @@ -243,6 +247,7 @@ function RuleConditionRow({ onChange({ ...timeCond, value: String(value) }) } aria-label={t('Condition Value')} + aria-describedby={timeHelpId} placeholder={t('Value')} className='w-24' /> @@ -333,6 +338,13 @@ function RuleConditionRow({ > + {condition.source === SOURCE_TIME && ( + + )} ) } diff --git a/web/default/src/features/system-settings/models/tiered-pricing-visual-editor.tsx b/web/default/src/features/system-settings/models/tiered-pricing-visual-editor.tsx index 43299e33..a996078a 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-visual-editor.tsx @@ -38,6 +38,7 @@ import { Textarea } from '@/components/ui/textarea' import { BILLING_EXTRA_VARS, type BillingVar, + type TimeFunc, } from '@/features/pricing/lib/billing-expr' import { CACHE_MODE_GENERIC, @@ -53,6 +54,7 @@ import { normalizeVisualConfig, normalizeVisualTier, } from '@/features/pricing/lib/tier-expr' +import { BillingTimeHelp } from './billing-time-help' import { DraftNumberInput } from './tiered-pricing-fields' import { formatTokenHint, @@ -78,8 +80,8 @@ const CONDITION_INPUT_OPTIONS: { { value: 'hour', labelKey: 'Hour' }, { value: 'minute', labelKey: 'Minute' }, { value: 'weekday', labelKey: 'Weekday' }, - { value: 'month', labelKey: 'Month' }, - { value: 'day', labelKey: 'Day' }, + { value: 'month', labelKey: 'Month number' }, + { value: 'day', labelKey: 'Day of month' }, ] const OPS: TierConditionInput['op'][] = ['<', '<=', '>', '>='] @@ -101,6 +103,7 @@ function ConditionRow({ removeDisabled, }: ConditionRowProps) { const { t } = useTranslation() + const timeHelpId = useId() const currentInputOption = CONDITION_INPUT_OPTIONS.find( (option) => option.value === condition.var ) @@ -169,6 +172,7 @@ function ConditionRow({ onChange({ ...condition, timezone: event.target.value }) } aria-label={t('Timezone')} + aria-describedby={timeHelpId} placeholder='Asia/Shanghai' className='w-40' /> @@ -207,6 +211,7 @@ function ConditionRow({ }) } aria-label={t('Condition Value')} + aria-describedby={isTimeCondition ? timeHelpId : undefined} placeholder={ timeBounds ? `${timeBounds.min}-${timeBounds.max}` : t('tokens') } @@ -228,6 +233,13 @@ function ConditionRow({ > + {isTimeCondition && ( + + )} ) } @@ -792,6 +804,7 @@ type RawExprEditorProps = { export function RawExprEditor({ exprString, onChange }: RawExprEditorProps) { const { t } = useTranslation() + const timeHelpId = useId() return (
@@ -808,9 +821,12 @@ export function RawExprEditor({ exprString, onChange }: RawExprEditorProps) { abs, header(name),{' '} param(path), has(source, text)
+
{t('Time condition reference')}
+