From c0ee2ad9a663ef27c1c3a7f7a1231ed4c7e8cb14 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sun, 9 Aug 2026 23:35:02 +0800 Subject: [PATCH 01/10] alpha search --- common/endpoint_defaults.go | 1 + common/endpoint_type.go | 6 + common/endpoint_type_test.go | 16 +++ constant/endpoint_type.go | 1 + controller/channel-test.go | 64 +++++++++ controller/channel_test_internal_test.go | 16 +++ controller/relay.go | 16 +++ dto/alpha_search_request.go | 38 ++++++ relay/alpha_search_handler.go | 128 ++++++++++++++++++ relay/alpha_search_handler_test.go | 48 +++++++ relay/channel/codex/adaptor.go | 35 +++-- relay/channel/codex/adaptor_test.go | 26 ++++ relay/common/relay_info.go | 22 +++ relay/constant/relay_mode.go | 3 + relay/constant/relay_mode_test.go | 20 +++ relay/helper/alpha_search_request_test.go | 38 ++++++ relay/helper/valid_request.go | 22 +++ router/relay-router.go | 3 + router/relay_router_test.go | 25 ++++ service/alpha_search_billing.go | 49 +++++++ service/alpha_search_billing_test.go | 96 +++++++++++++ service/text_quota.go | 14 +- service/text_quota_test.go | 59 ++++++++ service/tiered_settle.go | 21 ++- types/relay_format.go | 1 + .../dialogs/channel-test-dialog.tsx | 5 + .../channels/lib/advanced-custom.test.ts | 22 +++ .../features/channels/lib/advanced-custom.ts | 4 + .../pricing/components/model-details-api.tsx | 61 +++++++++ web/default/src/features/pricing/constants.ts | 2 + .../features/pricing/lib/model-metadata.ts | 1 + web/default/src/i18n/locales/en.json | 2 + web/default/src/i18n/locales/fr.json | 2 + web/default/src/i18n/locales/ja.json | 2 + web/default/src/i18n/locales/ru.json | 2 + web/default/src/i18n/locales/vi.json | 2 + web/default/src/i18n/locales/zh.json | 2 + 37 files changed, 853 insertions(+), 22 deletions(-) create mode 100644 common/endpoint_type_test.go create mode 100644 dto/alpha_search_request.go create mode 100644 relay/alpha_search_handler.go create mode 100644 relay/alpha_search_handler_test.go create mode 100644 relay/channel/codex/adaptor_test.go create mode 100644 relay/constant/relay_mode_test.go create mode 100644 relay/helper/alpha_search_request_test.go create mode 100644 router/relay_router_test.go create mode 100644 service/alpha_search_billing.go create mode 100644 service/alpha_search_billing_test.go create mode 100644 web/default/src/features/channels/lib/advanced-custom.test.ts diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go index d612414c..36d639aa 100644 --- a/common/endpoint_defaults.go +++ b/common/endpoint_defaults.go @@ -20,6 +20,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"}, constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"}, constant.EndpointTypeOpenAIResponseCompact: {Path: "/v1/responses/compact", Method: "POST"}, + constant.EndpointTypeOpenAIAlphaSearch: {Path: "/v1/alpha/search", Method: "POST"}, constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"}, constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"}, diff --git a/common/endpoint_type.go b/common/endpoint_type.go index 4a7fc55d..42edcd03 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -30,6 +30,12 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse} case constant.ChannelTypeSora: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo} + case constant.ChannelTypeCodex: + endpointTypes = []constant.EndpointType{ + constant.EndpointTypeOpenAIResponse, + constant.EndpointTypeOpenAIResponseCompact, + constant.EndpointTypeOpenAIAlphaSearch, + } default: if IsOpenAIResponseOnlyModel(modelName) { endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse} diff --git a/common/endpoint_type_test.go b/common/endpoint_type_test.go new file mode 100644 index 00000000..b8963394 --- /dev/null +++ b/common/endpoint_type_test.go @@ -0,0 +1,16 @@ +package common + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/stretchr/testify/require" +) + +func TestCodexEndpointTypesIncludeAlphaSearch(t *testing.T) { + endpointTypes := GetEndpointTypesByChannelType(constant.ChannelTypeCodex, "gpt-5.1") + + require.Contains(t, endpointTypes, constant.EndpointTypeOpenAIResponse) + require.Contains(t, endpointTypes, constant.EndpointTypeOpenAIResponseCompact) + require.Contains(t, endpointTypes, constant.EndpointTypeOpenAIAlphaSearch) +} diff --git a/constant/endpoint_type.go b/constant/endpoint_type.go index 8681bf06..5d27956d 100644 --- a/constant/endpoint_type.go +++ b/constant/endpoint_type.go @@ -6,6 +6,7 @@ const ( EndpointTypeOpenAI EndpointType = "openai" EndpointTypeOpenAIResponse EndpointType = "openai-response" EndpointTypeOpenAIResponseCompact EndpointType = "openai-response-compact" + EndpointTypeOpenAIAlphaSearch EndpointType = "openai-alpha-search" EndpointTypeAnthropic EndpointType = "anthropic" EndpointTypeGemini EndpointType = "gemini" EndpointTypeJinaRerank EndpointType = "jina-rerank" diff --git a/controller/channel-test.go b/controller/channel-test.go index 4c7d3b73..5a11c7ea 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -197,6 +197,8 @@ func testChannel(channel *model.Channel, testUserID int, testModel string, endpo relayFormat = types.RelayFormatOpenAIResponses case constant.EndpointTypeOpenAIResponseCompact: relayFormat = types.RelayFormatOpenAIResponsesCompaction + case constant.EndpointTypeOpenAIAlphaSearch: + relayFormat = types.RelayFormatOpenAIAlphaSearch case constant.EndpointTypeAnthropic: relayFormat = types.RelayFormatClaude case constant.EndpointTypeGemini: @@ -234,6 +236,9 @@ func testChannel(channel *model.Channel, testUserID int, testModel string, endpo if strings.HasPrefix(c.Request.URL.Path, "/v1/responses/compact") { relayFormat = types.RelayFormatOpenAIResponsesCompaction } + if c.Request.URL.Path == "/v1/alpha/search" { + relayFormat = types.RelayFormatOpenAIAlphaSearch + } } request := buildTestRequest(testModel, endpointType, channel, isStream) @@ -374,6 +379,21 @@ func testChannel(channel *model.Channel, testUserID int, testModel string, endpo maxAPIError: types.NewError(errors.New("invalid response compaction request type"), types.ErrorCodeConvertRequestFailed), } } + case relayconstant.RelayModeAlphaSearch: + if alphaReq, ok := request.(*dto.AlphaSearchRequest); ok { + var body map[string]any + err = common.Unmarshal(alphaReq.RawBody, &body) + if err == nil { + body["model"] = alphaReq.Model + convertedRequest = body + } + } else { + return testResult{ + context: c, + localErr: errors.New("invalid alpha search request type"), + maxAPIError: types.NewError(errors.New("invalid alpha search request type"), types.ErrorCodeConvertRequestFailed), + } + } default: // Chat/Completion 等其他请求类型 if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok { @@ -462,6 +482,32 @@ func testChannel(channel *model.Channel, testUserID int, testModel string, endpo } } } + if info.RelayMode == relayconstant.RelayModeAlphaSearch { + if httpResp == nil { + return testResult{ + context: c, + localErr: errors.New("alpha search response is nil"), + maxAPIError: types.NewOpenAIError(errors.New("alpha search response is nil"), types.ErrorCodeBadResponse, http.StatusInternalServerError), + } + } + respBody, err := readTestResponseBody(httpResp.Body, false) + if err != nil { + return testResult{ + context: c, + localErr: err, + maxAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), + } + } + if bodyErr := validateTestResponseBody(respBody, false); bodyErr != nil { + return testResult{ + context: c, + localErr: bodyErr, + maxAPIError: types.NewOpenAIError(bodyErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), + } + } + common.SysLog(fmt.Sprintf("testing channel #%d alpha search response: \n%s", channel.Id, string(respBody))) + return testResult{context: c} + } usageA, respErr := adaptor.DoResponse(c, httpResp, info) if respErr != nil { return testResult{ @@ -737,6 +783,24 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel, Model: model, Input: testResponsesInput, } + case constant.EndpointTypeOpenAIAlphaSearch: + rawBody, err := common.Marshal(map[string]any{ + "model": model, + "input": []map[string]any{{ + "role": "user", + "content": "Search for the latest artificial intelligence news.", + }}, + "commands": map[string]any{ + "search_query": []map[string]any{{"q": "latest artificial intelligence news"}}, + }, + }) + if err != nil { + rawBody = []byte(`{"model":""}`) + } + return &dto.AlphaSearchRequest{ + Model: model, + RawBody: rawBody, + } case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI: // 返回 GeneralOpenAIRequest maxTokens := uint(16) diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go index 04f3fdae..dc59cdcc 100644 --- a/controller/channel_test_internal_test.go +++ b/controller/channel_test_internal_test.go @@ -5,7 +5,9 @@ import ( "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/pkg/billingexpr" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/types" @@ -80,3 +82,17 @@ func TestResolveChannelTestUserIDUsesRequestUser(t *testing.T) { require.NoError(t, err) require.Equal(t, 2, userID) } + +func TestBuildTestRequestAlphaSearch(t *testing.T) { + request := buildTestRequest( + "gpt-5.1", + string(constant.EndpointTypeOpenAIAlphaSearch), + &model.Channel{Type: constant.ChannelTypeCodex}, + false, + ) + + alphaRequest, ok := request.(*dto.AlphaSearchRequest) + require.True(t, ok) + require.Equal(t, "gpt-5.1", alphaRequest.Model) + require.Contains(t, string(alphaRequest.RawBody), `"search_query"`) +} diff --git a/controller/relay.go b/controller/relay.go index aa71153d..5bc2486f 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -49,6 +49,8 @@ func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.MaxAPIErro err = relay.EmbeddingHelper(c, info) case relayconstant.RelayModeResponses, relayconstant.RelayModeResponsesCompact: err = relay.ResponsesHelper(c, info) + case relayconstant.RelayModeAlphaSearch: + err = relay.AlphaSearchHelper(c, info) default: err = relay.TextHelper(c, info) } @@ -155,6 +157,20 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { maxAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)) return } + basePreConsumedQuota := priceData.QuotaToPreConsume + priceData.QuotaToPreConsume, err = service.AlphaSearchPreConsumeQuota( + basePreConsumedQuota, + relayInfo, + priceData.GroupRatioInfo.GroupRatio, + ) + if err != nil { + maxAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)) + return + } + if priceData.QuotaToPreConsume > basePreConsumedQuota { + priceData.FreeModel = false + } + relayInfo.PriceData = priceData // common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta) diff --git a/dto/alpha_search_request.go b/dto/alpha_search_request.go new file mode 100644 index 00000000..9bd8d23d --- /dev/null +++ b/dto/alpha_search_request.go @@ -0,0 +1,38 @@ +package dto + +import ( + "encoding/json" + + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" +) + +// AlphaSearchRequest is the Codex standalone web search request. +// RawBody preserves unknown fields for forward compatibility. +type AlphaSearchRequest struct { + Model string `json:"model"` + ID string `json:"id,omitempty"` + Stream *bool `json:"stream,omitempty"` + RawBody json.RawMessage `json:"-"` +} + +func (r *AlphaSearchRequest) GetTokenCountMeta() *types.TokenCountMeta { + combineText := "" + if len(r.RawBody) > 0 { + combineText = string(r.RawBody) + } + return &types.TokenCountMeta{ + CombineText: combineText, + TokenType: types.TokenTypeTokenizer, + } +} + +func (r *AlphaSearchRequest) IsStream(_ *gin.Context) bool { + return false +} + +func (r *AlphaSearchRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} diff --git a/relay/alpha_search_handler.go b/relay/alpha_search_handler.go new file mode 100644 index 00000000..b9012ee1 --- /dev/null +++ b/relay/alpha_search_handler.go @@ -0,0 +1,128 @@ +package relay + +import ( + "errors" + "fmt" + "io" + "net/http" + + "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/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/service" + "github.com/MAX-API-Next/MAX-API/types" + + "github.com/gin-gonic/gin" +) + +func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError *types.MaxAPIError) { + info.InitChannelMeta(c) + + switch info.ChannelType { + case constant.ChannelTypeCodex, constant.ChannelTypeAdvancedCustom: + default: + return types.NewError( + errors.New("channel does not support /v1/alpha/search"), + types.ErrorCodeInvalidRequest, + ) + } + + request, ok := info.Request.(*dto.AlphaSearchRequest) + if !ok { + return types.NewErrorWithStatusCode( + fmt.Errorf("invalid request type, expected *dto.AlphaSearchRequest, got %T", info.Request), + types.ErrorCodeInvalidRequest, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + } + + if err := helper.ModelMappedHelper(c, info, request); err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + jsonData, err := buildAlphaSearchRequestBody(request.RawBody, info.OriginModelName, info.UpstreamModelName) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) + if err != nil { + return maxAPIErrorFromParamOverride(err) + } + } + + logger.LogDebug(c, "requestBody: %s", jsonData) + body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + defer closer.Close() + info.UpstreamRequestBodySize = size + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + resp, err := adaptor.DoRequest(c, info, body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + httpResp, ok := resp.(*http.Response) + if !ok || httpResp == nil { + return types.NewOpenAIError(errors.New("invalid http response"), types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + maxAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + service.ResetStatusCode(maxAPIError, c.GetString("status_code_mapping")) + return maxAPIError + } + + if contentType := httpResp.Header.Get("Content-Type"); contentType != "" { + c.Writer.Header().Set("Content-Type", contentType) + } + c.Writer.WriteHeader(httpResp.StatusCode) + if _, err := io.Copy(c.Writer, httpResp.Body); err != nil { + return types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithSkipRetry()) + } + + if info.ResponsesUsageInfo == nil { + info.ResponsesUsageInfo = &relaycommon.ResponsesUsageInfo{ + BuiltInTools: make(map[string]*relaycommon.BuildInToolInfo), + } + } + if info.ResponsesUsageInfo.BuiltInTools == nil { + info.ResponsesUsageInfo.BuiltInTools = make(map[string]*relaycommon.BuildInToolInfo) + } + info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview] = &relaycommon.BuildInToolInfo{ + ToolName: dto.BuildInToolWebSearchPreview, + CallCount: 1, + } + + service.PostTextConsumeQuota(c, info, &dto.Usage{}, nil) + return nil +} + +func buildAlphaSearchRequestBody(rawBody []byte, originModel, upstreamModel string) ([]byte, error) { + if len(rawBody) == 0 { + return nil, errors.New("empty alpha search request body") + } + if upstreamModel == "" || upstreamModel == originModel { + return rawBody, nil + } + + var body map[string]any + if err := common.Unmarshal(rawBody, &body); err != nil { + return nil, err + } + body["model"] = upstreamModel + return common.Marshal(body) +} diff --git a/relay/alpha_search_handler_test.go b/relay/alpha_search_handler_test.go new file mode 100644 index 00000000..1bcda732 --- /dev/null +++ b/relay/alpha_search_handler_test.go @@ -0,0 +1,48 @@ +package relay + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/stretchr/testify/require" +) + +func TestBuildAlphaSearchRequestBodyPreservesUnknownFields(t *testing.T) { + raw := []byte(`{ + "id":"req_1", + "model":"gpt-5.1", + "input":[{"role":"user","content":"hi"}], + "commands":{"search_query":[{"q":"weather","recency":1}]}, + "settings":{"locale":"en"}, + "future_field":{"nested":true} + }`) + + out, err := buildAlphaSearchRequestBody(raw, "gpt-5.1", "gpt-5.1-mapped") + require.NoError(t, err) + + var body map[string]any + require.NoError(t, common.Unmarshal(out, &body)) + require.Equal(t, "gpt-5.1-mapped", body["model"]) + require.Equal(t, "req_1", body["id"]) + require.Contains(t, body, "commands") + require.Contains(t, body, "settings") + require.Contains(t, body, "future_field") + require.Contains(t, body, "input") + + commands, ok := body["commands"].(map[string]any) + require.True(t, ok) + require.Contains(t, commands, "search_query") + + future, ok := body["future_field"].(map[string]any) + require.True(t, ok) + require.Equal(t, true, future["nested"]) +} + +func TestBuildAlphaSearchRequestBodyNoMappingKeepsRawBytes(t *testing.T) { + raw := []byte(`{"model":"gpt-5.1","commands":{"search_query":[{"q":"x"}]},"future_field":1}`) + + out, err := buildAlphaSearchRequestBody(raw, "gpt-5.1", "gpt-5.1") + + require.NoError(t, err) + require.Equal(t, raw, out) +} diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index 0d5f0bfb..c7d3cbda 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -112,18 +112,19 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.MaxAPIError) { - if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact { - return nil, types.NewError(errors.New("codex channel: endpoint not supported"), types.ErrorCodeInvalidRequest) - } - - if info.RelayMode == relayconstant.RelayModeResponsesCompact { + switch info.RelayMode { + case relayconstant.RelayModeAlphaSearch: + return nil, types.NewError(errors.New("codex channel: alpha search response should be handled by AlphaSearchHelper"), types.ErrorCodeInvalidRequest) + case relayconstant.RelayModeResponsesCompact: return openai.OaiResponsesCompactionHandler(c, info, resp) + case relayconstant.RelayModeResponses: + if info.IsStream { + return openai.OaiResponsesStreamHandler(c, info, resp) + } + return openai.OaiResponsesHandler(c, info, resp) + default: + return nil, types.NewError(errors.New("codex channel: endpoint not supported"), types.ErrorCodeInvalidRequest) } - - if info.IsStream { - return openai.OaiResponsesStreamHandler(c, info, resp) - } - return openai.OaiResponsesHandler(c, info, resp) } func (a *Adaptor) GetModelList() []string { @@ -135,12 +136,16 @@ func (a *Adaptor) GetChannelName() string { } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact { - return "", errors.New("codex channel: only /v1/responses and /v1/responses/compact are supported") - } - path := "/backend-api/codex/responses" - if info.RelayMode == relayconstant.RelayModeResponsesCompact { + var path string + switch info.RelayMode { + case relayconstant.RelayModeResponses: + path = "/backend-api/codex/responses" + case relayconstant.RelayModeResponsesCompact: path = "/backend-api/codex/responses/compact" + case relayconstant.RelayModeAlphaSearch: + path = "/backend-api/codex/alpha/search" + default: + return "", errors.New("codex channel: only /v1/responses, /v1/responses/compact and /v1/alpha/search are supported") } return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, path, info.ChannelType), nil } diff --git a/relay/channel/codex/adaptor_test.go b/relay/channel/codex/adaptor_test.go new file mode 100644 index 00000000..9955b25d --- /dev/null +++ b/relay/channel/codex/adaptor_test.go @@ -0,0 +1,26 @@ +package codex + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/constant" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + relayconstant "github.com/MAX-API-Next/MAX-API/relay/constant" + "github.com/stretchr/testify/require" +) + +func TestGetRequestURLAlphaSearch(t *testing.T) { + adaptor := &Adaptor{} + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeCodex, + ChannelBaseUrl: "https://chatgpt.com", + }, + RelayMode: relayconstant.RelayModeAlphaSearch, + } + + requestURL, err := adaptor.GetRequestURL(info) + + require.NoError(t, err) + require.Equal(t, "https://chatgpt.com/backend-api/codex/alpha/search", requestURL) +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index c4589af2..286f41af 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -577,6 +577,11 @@ func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Req return GenRelayInfoResponsesCompaction(c, request), nil } return nil, errors.New("request is not a OpenAIResponsesCompactionRequest") + case types.RelayFormatOpenAIAlphaSearch: + if request, ok := request.(*dto.AlphaSearchRequest); ok { + return GenRelayInfoAlphaSearch(c, request), nil + } + return nil, errors.New("request is not an AlphaSearchRequest") case types.RelayFormatTask: info = genBaseRelayInfo(c, nil) info.TaskRelayInfo = &TaskRelayInfo{} @@ -651,6 +656,23 @@ func GenRelayInfoResponsesCompaction(c *gin.Context, request *dto.OpenAIResponse return info } +func GenRelayInfoAlphaSearch(c *gin.Context, request *dto.AlphaSearchRequest) *RelayInfo { + info := genBaseRelayInfo(c, request) + if info.RelayMode == relayconstant.RelayModeUnknown { + info.RelayMode = relayconstant.RelayModeAlphaSearch + } + info.RelayFormat = types.RelayFormatOpenAIAlphaSearch + info.ResponsesUsageInfo = &ResponsesUsageInfo{ + BuiltInTools: map[string]*BuildInToolInfo{ + dto.BuildInToolWebSearchPreview: { + ToolName: dto.BuildInToolWebSearchPreview, + CallCount: 0, + }, + }, + } + return info +} + //func (info *RelayInfo) SetPromptTokens(promptTokens int) { // info.promptTokens = promptTokens //} diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 25671567..7fd20793 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -52,6 +52,7 @@ const ( RelayModeGemini RelayModeResponsesCompact + RelayModeAlphaSearch ) func Path2RelayMode(path string) int { @@ -76,6 +77,8 @@ func Path2RelayMode(path string) int { relayMode = RelayModeResponsesCompact } else if strings.HasPrefix(path, "/v1/responses") { relayMode = RelayModeResponses + } else if strings.HasPrefix(path, "/v1/alpha/search") { + relayMode = RelayModeAlphaSearch } else if strings.HasPrefix(path, "/v1/audio/speech") { relayMode = RelayModeAudioSpeech } else if strings.HasPrefix(path, "/v1/audio/transcriptions") { diff --git a/relay/constant/relay_mode_test.go b/relay/constant/relay_mode_test.go new file mode 100644 index 00000000..376c4546 --- /dev/null +++ b/relay/constant/relay_mode_test.go @@ -0,0 +1,20 @@ +package constant + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPath2RelayModeAlphaSearch(t *testing.T) { + tests := []string{ + "/v1/alpha/search", + "/v1/alpha/search?foo=1", + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + require.Equal(t, RelayModeAlphaSearch, Path2RelayMode(path)) + }) + } +} diff --git a/relay/helper/alpha_search_request_test.go b/relay/helper/alpha_search_request_test.go new file mode 100644 index 00000000..7a2e5e31 --- /dev/null +++ b/relay/helper/alpha_search_request_test.go @@ -0,0 +1,38 @@ +package helper + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestGetAndValidateAlphaSearchRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + newContext := func(body string) *gin.Context { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c + } + + t.Run("requires model", func(t *testing.T) { + _, err := GetAndValidateAlphaSearchRequest(newContext(`{"commands":{"search_query":[{"q":"weather"}]}}`)) + + require.EqualError(t, err, "model is required") + }) + + t.Run("preserves original json bytes", func(t *testing.T) { + raw := "{\n \"model\": \"gpt-5.1\",\n \"future_field\": {\"enabled\": true}\n}" + + request, err := GetAndValidateAlphaSearchRequest(newContext(raw)) + + require.NoError(t, err) + require.Equal(t, "gpt-5.1", request.Model) + require.Equal(t, []byte(raw), []byte(request.RawBody)) + }) +} diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index f0da5d44..567f8bec 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -37,6 +37,8 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt request, err = GetAndValidateResponsesRequest(c) case types.RelayFormatOpenAIResponsesCompaction: request, err = GetAndValidateResponsesCompactionRequest(c) + case types.RelayFormatOpenAIAlphaSearch: + request, err = GetAndValidateAlphaSearchRequest(c) case types.RelayFormatOpenAIImage: request, err = GetAndValidOpenAIImageRequest(c, relayMode) @@ -54,6 +56,26 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt return request, err } +func GetAndValidateAlphaSearchRequest(c *gin.Context) (*dto.AlphaSearchRequest, error) { + request := &dto.AlphaSearchRequest{} + if err := common.UnmarshalBodyReusable(c, request); err != nil { + return nil, err + } + if request.Model == "" { + return nil, errors.New("model is required") + } + storage, err := common.GetBodyStorage(c) + if err != nil { + return nil, err + } + rawBody, err := storage.Bytes() + if err != nil { + return nil, err + } + request.RawBody = rawBody + return request, nil +} + func GetAndValidAudioRequest(c *gin.Context, relayMode int) (*dto.AudioRequest, error) { audioRequest := &dto.AudioRequest{} err := common.UnmarshalBodyReusable(c, audioRequest) diff --git a/router/relay-router.go b/router/relay-router.go index f7afdd78..091c8d2f 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -104,6 +104,9 @@ func SetRelayRouter(router *gin.Engine) { httpRouter.POST("/responses/compact", func(c *gin.Context) { controller.Relay(c, types.RelayFormatOpenAIResponsesCompaction) }) + httpRouter.POST("/alpha/search", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIAlphaSearch) + }) // image related routes httpRouter.POST("/edits", func(c *gin.Context) { diff --git a/router/relay_router_test.go b/router/relay_router_test.go new file mode 100644 index 00000000..86087201 --- /dev/null +++ b/router/relay_router_test.go @@ -0,0 +1,25 @@ +package router + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestRelayRouterRegistersAlphaSearch(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + SetRelayRouter(engine) + + found := false + for _, route := range engine.Routes() { + if route.Method == http.MethodPost && route.Path == "/v1/alpha/search" { + found = true + break + } + } + + require.True(t, found, "expected POST /v1/alpha/search route to be registered") +} diff --git a/service/alpha_search_billing.go b/service/alpha_search_billing.go new file mode 100644 index 00000000..e5bcc0b4 --- /dev/null +++ b/service/alpha_search_billing.go @@ -0,0 +1,49 @@ +package service + +import ( + "fmt" + "math" + + "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" + relayconstant "github.com/MAX-API-Next/MAX-API/relay/constant" + "github.com/MAX-API-Next/MAX-API/setting/operation_setting" + + "github.com/shopspring/decimal" +) + +// AlphaSearchPreConsumeQuota adds the endpoint's deterministic single-search +// surcharge to the normal model estimate. The same surcharge is recorded only +// after a successful upstream response, so failed requests still refund the +// complete reservation through the existing billing failure path. +func AlphaSearchPreConsumeQuota(baseQuota int, info *relaycommon.RelayInfo, groupRatio float64) (int, error) { + if info == nil || info.RelayMode != relayconstant.RelayModeAlphaSearch { + return baseQuota, nil + } + if baseQuota < 0 { + return 0, fmt.Errorf("alpha search pre-consume quota cannot be negative: %d", baseQuota) + } + if math.IsNaN(groupRatio) || math.IsInf(groupRatio, 0) || groupRatio < 0 { + return 0, fmt.Errorf("alpha search group ratio is invalid: %v", groupRatio) + } + + pricePerThousand := operation_setting.GetToolPriceForModel(dto.BuildInToolWebSearchPreview, info.OriginModelName) + if math.IsNaN(pricePerThousand) || math.IsInf(pricePerThousand, 0) || pricePerThousand < 0 { + return 0, fmt.Errorf("alpha search tool price is invalid: %v", pricePerThousand) + } + if pricePerThousand == 0 || groupRatio == 0 { + return baseQuota, nil + } + + surcharge := decimal.NewFromFloat(pricePerThousand). + Div(decimal.NewFromInt(1000)). + Mul(decimal.NewFromFloat(groupRatio)). + Mul(decimal.NewFromFloat(common.QuotaPerUnit)) + total := decimal.NewFromInt(int64(baseQuota)).Add(surcharge) + quota, clamp := common.QuotaFromDecimalChecked(total) + if clamp != nil { + return 0, fmt.Errorf("alpha search pre-consume quota is out of range: %w", clamp) + } + return quota, nil +} diff --git a/service/alpha_search_billing_test.go b/service/alpha_search_billing_test.go new file mode 100644 index 00000000..d199a813 --- /dev/null +++ b/service/alpha_search_billing_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "net/http/httptest" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/pkg/billingexpr" + 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" +) + +func TestAlphaSearchPreConsumeQuotaMatchesSettlement(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeAlphaSearch, + OriginModelName: "o1", + PriceData: types.PriceData{ + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ + BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ + dto.BuildInToolWebSearchPreview: { + ToolName: dto.BuildInToolWebSearchPreview, + CallCount: 1, + }, + }, + }, + } + info.PriceData.AddOtherRatio("n", 3) + + preConsumedQuota, err := AlphaSearchPreConsumeQuota(0, info, 1) + require.NoError(t, err) + + settlement := calculateTextQuotaSummary(ctx, info, &dto.Usage{}) + require.Equal(t, settlement.Quota, preConsumedQuota) + require.Equal(t, common.QuotaFromDecimal(settlement.ToolCallSurchargeQuota), preConsumedQuota) +} + +func TestAlphaSearchPreConsumeQuotaLeavesOtherModesUnchanged(t *testing.T) { + quota, err := AlphaSearchPreConsumeQuota(123, &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeResponses, + OriginModelName: "o1", + }, 1) + + require.NoError(t, err) + require.Equal(t, 123, quota) +} + +func TestAlphaSearchPreConsumeQuotaRejectsOverflow(t *testing.T) { + _, err := AlphaSearchPreConsumeQuota(common.MaxQuota, &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeAlphaSearch, + OriginModelName: "o1", + }, 1) + + require.Error(t, err) +} + +func TestPrepareTieredAlphaSearchBillingKeepsSurchargeAfterGroupChange(t *testing.T) { + const expr = `tier("base", p)` + billing := &recordingBillingSettler{preConsumed: 6_000} + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeAlphaSearch, + OriginModelName: "o1", + Billing: billing, + FinalPreConsumedQuota: 6_000, + PriceData: types.PriceData{ + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 2}, + }, + TieredBillingSnapshot: &billingexpr.BillingSnapshot{ + BillingMode: "tiered_expr", + ExprString: expr, + ExprHash: billingexpr.ExprHashString(expr), + GroupRatio: 1, + EstimatedQuotaBeforeGroup: 1_000, + EstimatedQuotaAfterGroup: 1_000, + QuotaPerUnit: common.QuotaPerUnit, + }, + } + + expected, err := AlphaSearchPreConsumeQuota(2_000, info, 2) + require.NoError(t, err) + require.Nil(t, PrepareTieredBillingForSelectedGroup(nil, info)) + + require.Equal(t, []int{expected}, billing.reserves) + require.Equal(t, expected, info.FinalPreConsumedQuota) + require.Equal(t, expected, info.PriceData.QuotaToPreConsume) + require.Equal(t, 2_000, info.TieredBillingSnapshot.EstimatedQuotaAfterGroup) +} diff --git a/service/text_quota.go b/service/text_quota.go index 4b33fe28..43775628 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -57,6 +57,10 @@ type textQuotaSummary struct { ToolCallSurchargeQuota decimal.Decimal } +func (s *textQuotaSummary) hasBillableUsage() bool { + return s.TotalTokens > 0 || !s.ToolCallSurchargeQuota.IsZero() +} + func cacheWriteTokensTotal(summary textQuotaSummary) int { if summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0 { splitCacheWriteTokens := summary.CacheCreationTokens5m + summary.CacheCreationTokens1h @@ -292,7 +296,6 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio) completionQuota := dCompletionTokens.Mul(dCompletionRatio) quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio) - quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) if len(relayInfo.PriceData.OtherRatios) > 0 { @@ -300,6 +303,7 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio)) } } + quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota) if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) { quotaCalculateDecimal = decimal.NewFromInt(1) @@ -309,19 +313,19 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf noteQuotaClamp(relayInfo, clamp) } else { quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) - quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) if len(relayInfo.PriceData.OtherRatios) > 0 { for _, otherRatio := range relayInfo.PriceData.OtherRatios { quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio)) } } + quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota) quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal) summary.Quota = quota noteQuotaClamp(relayInfo, clamp) } - if summary.TotalTokens == 0 { + if !summary.hasBillableUsage() { summary.Quota = 0 } else if !ratio.IsZero() && summary.Quota == 0 { summary.Quota = 1 @@ -411,8 +415,8 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us extraContent = append(extraContent, fmt.Sprintf("Image Generation Call 花费 %s", decimal.NewFromFloat(summary.ImageGenerationCallPrice).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String())) } - shouldUpdateUsageStats := summary.TotalTokens != 0 - if summary.TotalTokens == 0 { + shouldUpdateUsageStats := summary.hasBillableUsage() + if !summary.hasBillableUsage() { extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)") logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, summary.ModelName, relayInfo.FinalPreConsumedQuota)) } diff --git a/service/text_quota_test.go b/service/text_quota_test.go index 5890a4e9..ae1c3490 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "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" @@ -763,6 +764,64 @@ func TestComposeTieredTextQuotaKeepsToolCallSurcharges(t *testing.T) { require.Equal(t, 14000, quota) } +func TestCalculateTextQuotaSummaryZeroTokensStillBillsToolSurcharge(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "o1", + PriceData: types.PriceData{ + ModelRatio: 1, + CompletionRatio: 1, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ + BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ + dto.BuildInToolWebSearchPreview: { + CallCount: 1, + }, + }, + }, + StartTime: time.Now(), + } + + summary := calculateTextQuotaSummary(ctx, relayInfo, &dto.Usage{}) + + require.Equal(t, 0, summary.TotalTokens) + require.False(t, summary.ToolCallSurchargeQuota.IsZero()) + require.Greater(t, summary.Quota, 0) + require.Equal(t, common.QuotaFromDecimal(summary.ToolCallSurchargeQuota), summary.Quota) +} + +func TestCalculateTextQuotaSummaryDoesNotApplyRequestMultipliersToToolSurcharge(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "o1", + PriceData: types.PriceData{ + ModelRatio: 1, + CompletionRatio: 1, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ + BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ + dto.BuildInToolWebSearchPreview: { + CallCount: 1, + }, + }, + }, + StartTime: time.Now(), + } + relayInfo.PriceData.AddOtherRatio("n", 3) + + summary := calculateTextQuotaSummary(ctx, relayInfo, &dto.Usage{}) + expected := decimal.NewFromFloat(10.0 / 1000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)) + + require.True(t, expected.Equal(summary.ToolCallSurchargeQuota)) + require.Equal(t, common.QuotaFromDecimal(expected), summary.Quota) +} + func TestComposeTieredTextQuotaFallbackKeepsToolCallSurcharges(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/service/tiered_settle.go b/service/tiered_settle.go index 260c5673..cf36286e 100644 --- a/service/tiered_settle.go +++ b/service/tiered_settle.go @@ -8,6 +8,7 @@ import ( "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/pkg/billingexpr" 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" ) @@ -147,6 +148,22 @@ func PrepareTieredBillingForSelectedGroup(c *gin.Context, relayInfo *relaycommon if snap == nil { return nil } + targetQuota, quotaErr := AlphaSearchPreConsumeQuota( + snap.EstimatedQuotaAfterGroup, + relayInfo, + relayInfo.PriceData.GroupRatioInfo.GroupRatio, + ) + if quotaErr != nil { + return types.NewErrorWithStatusCode( + quotaErr, + types.ErrorCodeModelPriceError, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + } + if relayInfo.RelayMode == relayconstant.RelayModeAlphaSearch { + relayInfo.PriceData.QuotaToPreConsume = targetQuota + } if snap.GroupRatio == 0 { return nil } @@ -162,9 +179,9 @@ func PrepareTieredBillingForSelectedGroup(c *gin.Context, relayInfo *relaycommon types.ErrOptionWithSkipRetry(), ) } - return PreConsumeBilling(c, snap.EstimatedQuotaAfterGroup, relayInfo) + return PreConsumeBilling(c, targetQuota, relayInfo) } - if err := relayInfo.Billing.Reserve(snap.EstimatedQuotaAfterGroup); err != nil { + if err := relayInfo.Billing.Reserve(targetQuota); err != nil { return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry()) } relayInfo.FinalPreConsumedQuota = relayInfo.Billing.GetPreConsumedQuota() diff --git a/types/relay_format.go b/types/relay_format.go index 9b4c86f2..86c2de17 100644 --- a/types/relay_format.go +++ b/types/relay_format.go @@ -8,6 +8,7 @@ const ( RelayFormatGemini = "gemini" RelayFormatOpenAIResponses = "openai_responses" RelayFormatOpenAIResponsesCompaction = "openai_responses_compaction" + RelayFormatOpenAIAlphaSearch = "openai_alpha_search" RelayFormatOpenAIAudio = "openai_audio" RelayFormatOpenAIImage = "openai_image" RelayFormatOpenAIRealtime = "openai_realtime" diff --git a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx index ed32f0ac..74d0a100 100644 --- a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -134,6 +134,10 @@ const endpointTypeOptions: Array<{ value: string; label: string }> = [ value: 'openai-response-compact', label: 'OpenAI Response Compaction (/v1/responses/compact)', }, + { + value: 'openai-alpha-search', + label: 'OpenAI Alpha Search (/v1/alpha/search)', + }, { value: 'anthropic', label: 'Anthropic (/v1/messages)' }, { value: 'gemini', @@ -152,6 +156,7 @@ const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([ 'image-generation', 'jina-rerank', 'openai-response-compact', + 'openai-alpha-search', ]) const MODEL_PRICE_ERROR_CODE = 'model_price_error' diff --git a/web/default/src/features/channels/lib/advanced-custom.test.ts b/web/default/src/features/channels/lib/advanced-custom.test.ts new file mode 100644 index 00000000..117318b1 --- /dev/null +++ b/web/default/src/features/channels/lib/advanced-custom.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { + ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS, + getAdvancedCustomIncomingPathLabel, +} from './advanced-custom' + +describe('advanced custom alpha search route', () => { + test('offers the standalone alpha search endpoint', () => { + assert.ok( + ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.some( + (option) => + option.value === '/v1/alpha/search' && + option.label === 'OpenAI Alpha Search' + ) + ) + assert.equal( + getAdvancedCustomIncomingPathLabel('/v1/alpha/search'), + 'OpenAI Alpha Search' + ) + }) +}) diff --git a/web/default/src/features/channels/lib/advanced-custom.ts b/web/default/src/features/channels/lib/advanced-custom.ts index fff5dba4..11c5c4b5 100644 --- a/web/default/src/features/channels/lib/advanced-custom.ts +++ b/web/default/src/features/channels/lib/advanced-custom.ts @@ -92,6 +92,10 @@ export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOp value: '/v1/responses/compact', label: 'OpenAI Responses Compact', }, + { + value: '/v1/alpha/search', + label: 'OpenAI Alpha Search', + }, { value: '/v1/embeddings', label: 'OpenAI Embeddings', diff --git a/web/default/src/features/pricing/components/model-details-api.tsx b/web/default/src/features/pricing/components/model-details-api.tsx index 345effb5..8ed72ece 100644 --- a/web/default/src/features/pricing/components/model-details-api.tsx +++ b/web/default/src/features/pricing/components/model-details-api.tsx @@ -164,6 +164,65 @@ function buildChatSample(lang: Lang, ctx: SampleContext): string { ].join('\n') } +function buildAlphaSearchSample(lang: Lang, ctx: SampleContext): string { + const url = `${ctx.baseUrl}${ctx.endpointPath}` + const query = 'latest artificial intelligence news' + const bodyJson = JSON.stringify( + { + model: ctx.modelName, + input: [{ role: 'user', content: `Search for ${query}.` }], + commands: { search_query: [{ q: query }] }, + }, + null, + 2 + ) + + if (lang === 'curl') { + return [ + `curl ${url} \\`, + ` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`, + ` -H "Content-Type: application/json" \\`, + ` -d '${bodyJson.replace(/\n/g, '\n ')}'`, + ].join('\n') + } + + if (lang === 'python') { + return [ + 'import json', + 'import os', + 'import urllib.request', + '', + `payload = ${bodyJson}`, + `request = urllib.request.Request(`, + ` "${url}",`, + ` data=json.dumps(payload).encode("utf-8"),`, + ` headers={`, + ` "Authorization": f"Bearer {os.environ['${ctx.apiKeyEnv}']}",`, + ` "Content-Type": "application/json",`, + ` },`, + ` method="POST",`, + `)`, + '', + 'with urllib.request.urlopen(request) as response:', + ' print(json.load(response))', + ].join('\n') + } + + return [ + `const response = await fetch('${url}', {`, + ` method: 'POST',`, + ` headers: {`, + ` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`, + ` 'Content-Type': 'application/json',`, + ` },`, + ` body: JSON.stringify(${bodyJson}),`, + `})`, + '', + `const data = await response.json()`, + `console.log(data)`, + ].join('\n') +} + function buildAnthropicSample(lang: Lang, ctx: SampleContext): string { const url = `${ctx.baseUrl}${ctx.endpointPath}` const userMessage = 'Explain quantum entanglement in one paragraph.' @@ -434,6 +493,8 @@ function buildSample( endpointType: string, ctx: SampleContext ): string { + if (endpointType === 'openai-alpha-search') + return buildAlphaSearchSample(lang, ctx) if (endpointType === 'anthropic') return buildAnthropicSample(lang, ctx) if (endpointType === 'gemini') return buildGeminiSample(lang, ctx) if (endpointType === 'embeddings' || endpointType === 'jina-rerank') diff --git a/web/default/src/features/pricing/constants.ts b/web/default/src/features/pricing/constants.ts index 365c03e8..d62aa49b 100644 --- a/web/default/src/features/pricing/constants.ts +++ b/web/default/src/features/pricing/constants.ts @@ -68,6 +68,7 @@ export const ENDPOINT_TYPES = { ALL: 'all', OPENAI: 'openai', OPENAI_RESPONSE: 'openai-response', + OPENAI_ALPHA_SEARCH: 'openai-alpha-search', ANTHROPIC: 'anthropic', GEMINI: 'gemini', JINA_RERANK: 'jina-rerank', @@ -87,6 +88,7 @@ export function getEndpointTypeLabels( [ENDPOINT_TYPES.ALL]: t('All Types'), [ENDPOINT_TYPES.OPENAI]: 'Chat', [ENDPOINT_TYPES.OPENAI_RESPONSE]: 'Response', + [ENDPOINT_TYPES.OPENAI_ALPHA_SEARCH]: 'Alpha Search', [ENDPOINT_TYPES.ANTHROPIC]: 'Anthropic', [ENDPOINT_TYPES.GEMINI]: 'Gemini', [ENDPOINT_TYPES.JINA_RERANK]: 'Rerank', diff --git a/web/default/src/features/pricing/lib/model-metadata.ts b/web/default/src/features/pricing/lib/model-metadata.ts index b70a7891..49b33db8 100644 --- a/web/default/src/features/pricing/lib/model-metadata.ts +++ b/web/default/src/features/pricing/lib/model-metadata.ts @@ -36,6 +36,7 @@ import { hashStringToSeed, seededRandom } from './seed' const TEXT_INPUT_ENDPOINTS = new Set([ 'openai', 'openai-response', + 'openai-alpha-search', 'anthropic', 'gemini', 'embeddings', diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 0269a8f0..4e110c67 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -3237,6 +3237,8 @@ "OpenAI Rerank": "OpenAI Rerank", "OpenAI Responses": "OpenAI Responses", "OpenAI Responses Compact": "OpenAI Responses Compact", + "OpenAI Alpha Search": "OpenAI Alpha Search", + "OpenAI Alpha Search (/v1/alpha/search)": "OpenAI Alpha Search (/v1/alpha/search)", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.", "OpenAI, Claude, Gemini, Azure, AWS, Vertex, Ollama, domestic models, image, audio, video, embedding, rerank, and compatible API ecosystems stay replaceable.": "OpenAI, Claude, Gemini, Azure, AWS, Vertex, Ollama, domestic models, image, audio, video, embedding, rerank, and compatible API ecosystems stay replaceable.", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 663f5cc8..24c7fc4d 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -3237,6 +3237,8 @@ "OpenAI Rerank": "OpenAI Rerank", "OpenAI Responses": "OpenAI Responses", "OpenAI Responses Compact": "OpenAI Responses Compact", + "OpenAI Alpha Search": "Recherche Alpha OpenAI", + "OpenAI Alpha Search (/v1/alpha/search)": "Recherche Alpha OpenAI (/v1/alpha/search)", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.", "OpenAI, Claude, Gemini, Azure, AWS, Vertex, Ollama, domestic models, image, audio, video, embedding, rerank, and compatible API ecosystems stay replaceable.": "OpenAI, Claude, Gemini, Azure, AWS, Vertex, Ollama, modèles nationaux, image, audio, vidéo, embeddings, rerank et écosystèmes d'API compatibles restent remplaçables.", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 8e6a4124..c212ba80 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -3237,6 +3237,8 @@ "OpenAI Rerank": "OpenAI 再ランク付け", "OpenAI Responses": "OpenAI レスポンス", "OpenAI Responses Compact": "OpenAI レスポンス圧縮", + "OpenAI Alpha Search": "OpenAI Alpha 検索", + "OpenAI Alpha Search (/v1/alpha/search)": "OpenAI Alpha 検索 (/v1/alpha/search)", "OpenAI, Anthropic, etc.": "OpenAI、Anthropicなど", "OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Googleなど", "OpenAI, Claude, Gemini, Azure, AWS, Vertex, Ollama, domestic models, image, audio, video, embedding, rerank, and compatible API ecosystems stay replaceable.": "OpenAI、Claude、Gemini、Azure、AWS、Vertex、Ollama、国内モデル、画像、音声、動画、Embedding、Rerank、互換 API エコシステムを差し替え可能なままにします。", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index ebe5cb7b..aafa73d8 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -3237,6 +3237,8 @@ "OpenAI Rerank": "Реранжирование OpenAI", "OpenAI Responses": "Ответы OpenAI", "OpenAI Responses Compact": "Компактные ответы OpenAI", + "OpenAI Alpha Search": "Alpha-поиск OpenAI", + "OpenAI Alpha Search (/v1/alpha/search)": "Alpha-поиск OpenAI (/v1/alpha/search)", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic и т.д.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google и т.д.", "OpenAI, Claude, Gemini, Azure, AWS, Vertex, Ollama, domestic models, image, audio, video, embedding, rerank, and compatible API ecosystems stay replaceable.": "OpenAI, Claude, Gemini, Azure, AWS, Vertex, Ollama, локальные модели, изображения, аудио, видео, embeddings, rerank и совместимые API-экосистемы остаются заменяемыми.", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 638893d6..7c4f0cb6 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -3237,6 +3237,8 @@ "OpenAI Rerank": "OpenAI Rerank", "OpenAI Responses": "OpenAI Responses", "OpenAI Responses Compact": "OpenAI Responses Compact", + "OpenAI Alpha Search": "Tìm kiếm Alpha OpenAI", + "OpenAI Alpha Search (/v1/alpha/search)": "Tìm kiếm Alpha OpenAI (/v1/alpha/search)", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic, v.v.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, v.v.", "OpenAI, Claude, Gemini, Azure, AWS, Vertex, Ollama, domestic models, image, audio, video, embedding, rerank, and compatible API ecosystems stay replaceable.": "OpenAI, Claude, Gemini, Azure, AWS, Vertex, Ollama, mô hình nội địa, hình ảnh, âm thanh, video, embedding, rerank và hệ sinh thái API tương thích vẫn có thể thay thế.", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index b427c484..8e623083 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -3237,6 +3237,8 @@ "OpenAI Rerank": "OpenAI 重排序", "OpenAI Responses": "OpenAI 响应", "OpenAI Responses Compact": "OpenAI 响应压缩", + "OpenAI Alpha Search": "OpenAI Alpha 搜索", + "OpenAI Alpha Search (/v1/alpha/search)": "OpenAI Alpha 搜索(/v1/alpha/search)", "OpenAI, Anthropic, etc.": "OpenAI、Anthropic 等", "OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Google 等", "OpenAI, Claude, Gemini, Azure, AWS, Vertex, Ollama, domestic models, image, audio, video, embedding, rerank, and compatible API ecosystems stay replaceable.": "OpenAI、Claude、Gemini、Azure、AWS、Vertex、Ollama、国内模型、图像、音频、视频、Embedding、Rerank 和兼容 API 生态都保持可替换。", From 41aadc7ae416ffe71370bf283a7c0536f618f66f Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 10 Aug 2026 00:46:18 +0800 Subject: [PATCH 02/10] alpha search --- common/endpoint_defaults.go | 10 ++ model/ability.go | 30 +++-- model/channel_cache.go | 17 ++- model/channel_selection_test.go | 40 +++++++ model/pricing.go | 77 +++++++++++- model/pricing_test.go | 42 +++++++ relay/alpha_search_handler.go | 12 +- relay/alpha_search_handler_test.go | 112 ++++++++++++++++++ service/text_quota.go | 23 +++- service/text_quota_test.go | 69 +++++++++++ tools/jsonwrapcheck/allowlist.txt | 2 - .../components/model-details-api-samples.ts | 65 ++++++++++ .../components/model-details-api.test.ts | 26 ++++ .../pricing/components/model-details-api.tsx | 3 + 14 files changed, 496 insertions(+), 32 deletions(-) create mode 100644 model/pricing_test.go create mode 100644 web/default/src/features/pricing/components/model-details-api-samples.ts create mode 100644 web/default/src/features/pricing/components/model-details-api.test.ts diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go index 36d639aa..ad1c9200 100644 --- a/common/endpoint_defaults.go +++ b/common/endpoint_defaults.go @@ -33,3 +33,13 @@ func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) { info, ok := defaultEndpointInfoMap[et] return info, ok } + +// GetDefaultEndpointTypeByPath returns the built-in endpoint type for an exact path. +func GetDefaultEndpointTypeByPath(path string) (constant.EndpointType, bool) { + for endpointType, info := range defaultEndpointInfoMap { + if info.Path == path { + return endpointType, true + } + } + return "", false +} diff --git a/model/ability.go b/model/ability.go index 1157f7a6..0c8690b6 100644 --- a/model/ability.go +++ b/model/ability.go @@ -253,21 +253,17 @@ func filterAbilityRowsByRequestPath(abilityRows []AbilityWithChannel, requestPat seen[row.ChannelId] = struct{}{} channelIds = append(channelIds, row.ChannelId) } - if len(channelIds) == 0 { - return abilities, nil - } - var channels []*Channel - if err := DB.Select("id", "type", "settings"). - Where("id IN ? AND type = ?", channelIds, constant.ChannelTypeAdvancedCustom). - Find(&channels).Error; err != nil { - return nil, err + if len(channelIds) > 0 { + if err := DB.Select("id", "type", "settings"). + Where("id IN ? AND type = ?", channelIds, constant.ChannelTypeAdvancedCustom). + Find(&channels).Error; err != nil { + return nil, err + } } - advancedChannelIds := make(map[int]struct{}, len(channels)) advancedConfigs := make(map[int]*dto.AdvancedCustomConfig) for _, channel := range channels { - advancedChannelIds[channel.Id] = struct{}{} config, err := validatedAdvancedCustomConfigFromChannel(channel) if err == nil && config != nil { advancedConfigs[channel.Id] = config @@ -279,14 +275,16 @@ func filterAbilityRowsByRequestPath(abilityRows []AbilityWithChannel, requestPat } filtered := make([]Ability, 0, len(abilities)) - for _, ability := range abilities { - _, isAdvancedCustom := advancedChannelIds[ability.ChannelId] - if !isAdvancedCustom { - filtered = append(filtered, ability) + for _, row := range abilityRows { + if !channelTypeSupportsRequestPath(row.ChannelType, requestPath) { + continue + } + if row.ChannelType != constant.ChannelTypeAdvancedCustom { + filtered = append(filtered, row.Ability) continue } - if config := advancedConfigs[ability.ChannelId]; config != nil && config.SupportsPath(requestPath) { - filtered = append(filtered, ability) + if config := advancedConfigs[row.ChannelId]; config != nil && config.SupportsPath(requestPath) { + filtered = append(filtered, row.Ability) } } return filtered, nil diff --git a/model/channel_cache.go b/model/channel_cache.go index 832bced4..751cbdf2 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -246,7 +246,12 @@ func filterChannelsByRequestPath(channels []int, requestPath string) ([]int, err for _, channelId := range channels { channel, ok := channelsIDM[channelId] if !ok { - filtered = append(filtered, channelId) + if requestPath != "/v1/alpha/search" { + filtered = append(filtered, channelId) + } + continue + } + if !channelTypeSupportsRequestPath(channel.Type, requestPath) { continue } if channel.Type != constant.ChannelTypeAdvancedCustom { @@ -263,10 +268,20 @@ func filterChannelsByRequestPath(channels []int, requestPath string) ([]int, err return filtered, nil } +func channelTypeSupportsRequestPath(channelType int, requestPath string) bool { + if requestPath != "/v1/alpha/search" { + return true + } + return channelType == constant.ChannelTypeCodex || channelType == constant.ChannelTypeAdvancedCustom +} + func ChannelSupportsRequestPath(channel *Channel, requestPath string) bool { if channel == nil { return false } + if !channelTypeSupportsRequestPath(channel.Type, requestPath) { + return false + } if channel.Type != constant.ChannelTypeAdvancedCustom { return true } diff --git a/model/channel_selection_test.go b/model/channel_selection_test.go index 389865b2..b4de95a9 100644 --- a/model/channel_selection_test.go +++ b/model/channel_selection_test.go @@ -159,6 +159,46 @@ func TestMemoryCacheSelectionSkipsInvalidAdvancedCustomConfig(t *testing.T) { assert.Equal(t, 2, channel.Id) } +func TestAlphaSearchSelectionUsesOnlySupportedChannelTypes(t *testing.T) { + clearPreferredOwnerTables(t) + t.Cleanup(func() { + clearPreferredOwnerTables(t) + channelSyncLock.Lock() + group2model2channels = nil + channelsIDM = nil + channel2advancedCustomConfig = nil + channel2advancedCustomConfigError = nil + channelSyncLock.Unlock() + }) + + const ( + groupName = "default" + modelName = "gpt-alpha-selection" + ) + insertChannelSelectionCandidate(t, 1, constant.ChannelTypeOpenAI, "", modelName, groupName, 10) + insertChannelSelectionCandidate(t, 2, constant.ChannelTypeCodex, "", modelName, groupName, 1) + + previousMemoryCacheEnabled := common.MemoryCacheEnabled + t.Cleanup(func() { + common.MemoryCacheEnabled = previousMemoryCacheEnabled + }) + + common.MemoryCacheEnabled = false + channel, err := GetChannel(groupName, modelName, 0, "/v1/alpha/search") + require.NoError(t, err) + require.NotNil(t, channel) + assert.Equal(t, 2, channel.Id) + assert.False(t, ChannelSupportsRequestPath(&Channel{Type: constant.ChannelTypeOpenAI}, "/v1/alpha/search")) + assert.True(t, ChannelSupportsRequestPath(&Channel{Type: constant.ChannelTypeCodex}, "/v1/alpha/search")) + + common.MemoryCacheEnabled = true + InitChannelCache() + channel, err = GetRandomSatisfiedChannel(groupName, modelName, 0, "/v1/alpha/search") + require.NoError(t, err) + require.NotNil(t, channel) + assert.Equal(t, 2, channel.Id) +} + func TestChannelSelectionExcludesPreviouslyTriedChannel(t *testing.T) { clearPreferredOwnerTables(t) t.Cleanup(func() { diff --git a/model/pricing.go b/model/pricing.go index f4d0848b..98265396 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -1,7 +1,6 @@ package model import ( - "encoding/json" "fmt" "strings" @@ -10,6 +9,7 @@ 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/setting/billing_setting" "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" @@ -128,6 +128,74 @@ func GetModelSupportEndpointTypes(model string) []constant.EndpointType { return make([]constant.EndpointType, 0) } +func loadPricingAdvancedCustomConfigs(abilities []AbilityWithChannel) map[int]*dto.AdvancedCustomConfig { + channelIDs := make([]int, 0) + seen := make(map[int]struct{}) + for _, ability := range abilities { + if ability.ChannelType != constant.ChannelTypeAdvancedCustom { + continue + } + if _, ok := seen[ability.ChannelId]; ok { + continue + } + seen[ability.ChannelId] = struct{}{} + channelIDs = append(channelIDs, ability.ChannelId) + } + if len(channelIDs) == 0 { + return nil + } + + var channels []*Channel + if err := DB.Select("id", "type", "settings"). + Where("id IN ? AND type = ?", channelIDs, constant.ChannelTypeAdvancedCustom). + Find(&channels).Error; err != nil { + common.SysError(fmt.Sprintf("failed to load advanced custom pricing configs: %v", err)) + return nil + } + + configs := make(map[int]*dto.AdvancedCustomConfig, len(channels)) + for _, channel := range channels { + config, err := validatedAdvancedCustomConfigFromChannel(channel) + if err != nil { + common.SysError(fmt.Sprintf("failed to parse advanced custom pricing config: channel_id=%d, error=%v", channel.Id, err)) + continue + } + if config != nil { + configs[channel.Id] = config + } + } + return configs +} + +func getPricingEndpointTypesForAbility(ability AbilityWithChannel, advancedConfigs map[int]*dto.AdvancedCustomConfig) []constant.EndpointType { + if ability.ChannelType != constant.ChannelTypeAdvancedCustom { + return common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) + } + + config := advancedConfigs[ability.ChannelId] + if config == nil { + return nil + } + endpointTypes := make([]constant.EndpointType, 0, len(config.Routes)) + for _, route := range config.Routes { + endpointType, ok := common.GetDefaultEndpointTypeByPath(strings.TrimSpace(route.IncomingPath)) + if !ok { + continue + } + duplicate := false + for _, existing := range endpointTypes { + if existing == endpointType { + duplicate = true + break + } + } + if !duplicate { + endpointTypes = append(endpointTypes, endpointType) + } + } + return endpointTypes +} + func updatePricing() { //modelRatios := common.GetModelRatios() enableAbilities, err := GetAllEnableAbilityWithChannels() @@ -222,11 +290,12 @@ func updatePricing() { //这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点 modelSupportEndpointsStr := make(map[string][]string) + advancedCustomConfigs := loadPricingAdvancedCustomConfigs(enableAbilities) // 先根据已有能力填充原生端点 for _, ability := range enableAbilities { endpoints := modelSupportEndpointsStr[ability.Model] - channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) + channelTypes := getPricingEndpointTypesForAbility(ability, advancedCustomConfigs) for _, channelType := range channelTypes { if !common.StringsContains(endpoints, string(channelType)) { endpoints = append(endpoints, string(channelType)) @@ -241,7 +310,7 @@ func updatePricing() { continue } var raw map[string]interface{} - if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { + if err := common.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { endpoints := make([]string, 0, len(raw)) for k, v := range raw { switch v.(type) { @@ -285,7 +354,7 @@ func updatePricing() { continue } var raw map[string]interface{} - if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { + if err := common.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { for k, v := range raw { switch val := v.(type) { case string: diff --git a/model/pricing_test.go b/model/pricing_test.go new file mode 100644 index 00000000..05dae0b8 --- /dev/null +++ b/model/pricing_test.go @@ -0,0 +1,42 @@ +package model + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/stretchr/testify/require" +) + +func TestGetPricingEndpointTypesForAdvancedCustomAbilityUsesConfiguredRoutes(t *testing.T) { + config := &dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/alpha/search", + UpstreamPath: "/v1/alpha/search", + Converter: dto.AdvancedCustomConverterNone, + }, + { + IncomingPath: "/v1/responses", + UpstreamPath: "/v1/responses", + Converter: dto.AdvancedCustomConverterNone, + }, + }, + } + require.NoError(t, config.Validate()) + + ability := AbilityWithChannel{ + Ability: Ability{ChannelId: 17, Model: "gpt-advanced-alpha"}, + ChannelType: constant.ChannelTypeAdvancedCustom, + } + configs := map[int]*dto.AdvancedCustomConfig{ + 17: config, + } + + endpointTypes := getPricingEndpointTypesForAbility(ability, configs) + + require.Equal(t, []constant.EndpointType{ + constant.EndpointTypeOpenAIAlphaSearch, + constant.EndpointTypeOpenAIResponse, + }, endpointTypes) +} diff --git a/relay/alpha_search_handler.go b/relay/alpha_search_handler.go index b9012ee1..869941df 100644 --- a/relay/alpha_search_handler.go +++ b/relay/alpha_search_handler.go @@ -55,7 +55,7 @@ func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError } } - logger.LogDebug(c, "requestBody: %s", jsonData) + logger.LogDebug(c, "alpha search request prepared: model=%s, bytes=%d", info.UpstreamModelName, len(jsonData)) body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) @@ -86,6 +86,9 @@ func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError return maxAPIError } + recordAlphaSearchUsage(info) + service.PostTextConsumeQuota(c, info, &dto.Usage{}, nil) + if contentType := httpResp.Header.Get("Content-Type"); contentType != "" { c.Writer.Header().Set("Content-Type", contentType) } @@ -94,6 +97,10 @@ func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError return types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithSkipRetry()) } + return nil +} + +func recordAlphaSearchUsage(info *relaycommon.RelayInfo) { if info.ResponsesUsageInfo == nil { info.ResponsesUsageInfo = &relaycommon.ResponsesUsageInfo{ BuiltInTools: make(map[string]*relaycommon.BuildInToolInfo), @@ -106,9 +113,6 @@ func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError ToolName: dto.BuildInToolWebSearchPreview, CallCount: 1, } - - service.PostTextConsumeQuota(c, info, &dto.Usage{}, nil) - return nil } func buildAlphaSearchRequestBody(rawBody []byte, originModel, upstreamModel string) ([]byte, error) { diff --git a/relay/alpha_search_handler_test.go b/relay/alpha_search_handler_test.go index 1bcda732..cf45bb5c 100644 --- a/relay/alpha_search_handler_test.go +++ b/relay/alpha_search_handler_test.go @@ -1,12 +1,59 @@ package relay import ( + "errors" + "net/http" + "net/http/httptest" "testing" + "time" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + 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/service" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +type failingAlphaSearchResponseWriter struct { + gin.ResponseWriter + err error +} + +func (w *failingAlphaSearchResponseWriter) Write(_ []byte) (int, error) { + return 0, w.err +} + +func (w *failingAlphaSearchResponseWriter) WriteString(_ string) (int, error) { + return 0, w.err +} + +type recordingAlphaSearchBilling struct { + settledQuotas []int +} + +func (b *recordingAlphaSearchBilling) Settle(actualQuota int) error { + b.settledQuotas = append(b.settledQuotas, actualQuota) + return nil +} + +func (b *recordingAlphaSearchBilling) Refund(_ *gin.Context) {} + +func (b *recordingAlphaSearchBilling) NeedsRefund() bool { + return len(b.settledQuotas) == 0 +} + +func (b *recordingAlphaSearchBilling) GetPreConsumedQuota() int { + return 0 +} + +func (b *recordingAlphaSearchBilling) Reserve(_ int) error { + return nil +} + func TestBuildAlphaSearchRequestBodyPreservesUnknownFields(t *testing.T) { raw := []byte(`{ "id":"req_1", @@ -46,3 +93,68 @@ func TestBuildAlphaSearchRequestBodyNoMappingKeepsRawBytes(t *testing.T) { require.NoError(t, err) require.Equal(t, raw, out) } + +func TestAlphaSearchSettlesSuccessfulUpstreamBeforeClientWriteFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + service.InitHttpClient() + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/search" { + t.Errorf("upstream path = %q, want /search", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"type":"computer_initialize_state"}`)) + })) + t.Cleanup(upstream.Close) + + previousLogConsumeEnabled := common.LogConsumeEnabled + common.LogConsumeEnabled = false + t.Cleanup(func() { + common.LogConsumeEnabled = previousLogConsumeEnabled + }) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", nil) + writeErr := errors.New("client response write failed") + ctx.Writer = &failingAlphaSearchResponseWriter{ + ResponseWriter: ctx.Writer, + err: writeErr, + } + ctx.Set(string(constant.ContextKeyChannelType), constant.ChannelTypeAdvancedCustom) + ctx.Set(string(constant.ContextKeyChannelBaseUrl), upstream.URL) + ctx.Set(string(constant.ContextKeyChannelKey), "test-key") + ctx.Set(string(constant.ContextKeyOriginalModel), "gpt-5.1") + ctx.Set(string(constant.ContextKeyChannelOtherSetting), dto.ChannelOtherSettings{ + AdvancedCustom: &dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/alpha/search", + UpstreamPath: "/search", + Converter: dto.AdvancedCustomConverterNone, + }, + }}, + }) + + billing := &recordingAlphaSearchBilling{} + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeAlphaSearch, + OriginModelName: "gpt-5.1", + StartTime: time.Now(), + Billing: billing, + PriceData: types.PriceData{ + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0}, + }, + Request: &dto.AlphaSearchRequest{ + Model: "gpt-5.1", + RawBody: []byte(`{"model":"gpt-5.1","input":"latest news"}`), + }, + } + + apiErr := AlphaSearchHelper(ctx, info) + + require.ErrorIs(t, apiErr, writeErr) + require.Equal(t, []int{0}, billing.settledQuotas) + require.NotNil(t, info.ResponsesUsageInfo) + tool := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview] + require.NotNil(t, tool) + require.Equal(t, 1, tool.CallCount) +} diff --git a/service/text_quota.go b/service/text_quota.go index 43775628..a7825e0c 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -13,6 +13,7 @@ import ( "github.com/MAX-API-Next/MAX-API/pkg/billingexpr" perfmetrics "github.com/MAX-API-Next/MAX-API/pkg/perf_metrics" 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/setting/operation_setting" "github.com/MAX-API-Next/MAX-API/types" @@ -57,8 +58,14 @@ type textQuotaSummary struct { ToolCallSurchargeQuota decimal.Decimal } -func (s *textQuotaSummary) hasBillableUsage() bool { - return s.TotalTokens > 0 || !s.ToolCallSurchargeQuota.IsZero() +func (s *textQuotaSummary) hasBillableUsage(relayInfo *relaycommon.RelayInfo) bool { + if s.TotalTokens > 0 || !s.ToolCallSurchargeQuota.IsZero() { + return true + } + return relayInfo != nil && + relayInfo.RelayMode == relayconstant.RelayModeAlphaSearch && + relayInfo.PriceData.UsePrice && + relayInfo.PriceData.ModelPrice > 0 } func cacheWriteTokensTotal(summary textQuotaSummary) int { @@ -167,6 +174,12 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS return quota } } + if relayInfo != nil && + relayInfo.RelayMode == relayconstant.RelayModeAlphaSearch && + relayInfo.FinalPreConsumedQuota > 0 && + tieredQuota == relayInfo.FinalPreConsumedQuota { + return tieredQuota + } quota, clamp := common.QuotaFromDecimalChecked(decimal.NewFromInt(int64(tieredQuota)).Add(summary.ToolCallSurchargeQuota)) noteQuotaClamp(relayInfo, clamp) @@ -325,7 +338,7 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf noteQuotaClamp(relayInfo, clamp) } - if !summary.hasBillableUsage() { + if !summary.hasBillableUsage(relayInfo) { summary.Quota = 0 } else if !ratio.IsZero() && summary.Quota == 0 { summary.Quota = 1 @@ -415,8 +428,8 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us extraContent = append(extraContent, fmt.Sprintf("Image Generation Call 花费 %s", decimal.NewFromFloat(summary.ImageGenerationCallPrice).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String())) } - shouldUpdateUsageStats := summary.hasBillableUsage() - if !summary.hasBillableUsage() { + shouldUpdateUsageStats := summary.hasBillableUsage(relayInfo) + if !summary.hasBillableUsage(relayInfo) { extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)") logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, summary.ModelName, relayInfo.FinalPreConsumedQuota)) } diff --git a/service/text_quota_test.go b/service/text_quota_test.go index ae1c3490..c247e07b 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -12,7 +12,10 @@ import ( "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/pkg/billingexpr" 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/service/openaicompat" + "github.com/MAX-API-Next/MAX-API/setting/config" + "github.com/MAX-API-Next/MAX-API/setting/operation_setting" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" @@ -793,6 +796,44 @@ func TestCalculateTextQuotaSummaryZeroTokensStillBillsToolSurcharge(t *testing.T require.Equal(t, common.QuotaFromDecimal(summary.ToolCallSurchargeQuota), summary.Quota) } +func TestCalculateTextQuotaSummaryAlphaSearchKeepsFixedPriceWhenToolPriceIsZero(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + + toolPrices := config.GlobalConfig.Get("tool_price_setting").(*operation_setting.ToolPriceSetting) + originalPrices := make(map[string]float64, len(toolPrices.Prices)) + for key, value := range toolPrices.Prices { + originalPrices[key] = value + } + toolPrices.Prices = map[string]float64{"web_search_preview": 0} + operation_setting.RebuildToolPriceIndex() + t.Cleanup(func() { + toolPrices.Prices = originalPrices + operation_setting.RebuildToolPriceIndex() + }) + + relayInfo := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeAlphaSearch, + OriginModelName: "fixed-price-alpha-search", + PriceData: types.PriceData{ + UsePrice: true, + ModelPrice: 0.02, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{ + BuiltInTools: map[string]*relaycommon.BuildInToolInfo{ + dto.BuildInToolWebSearchPreview: {CallCount: 1}, + }, + }, + StartTime: time.Now(), + } + + summary := calculateTextQuotaSummary(ctx, relayInfo, &dto.Usage{}) + + require.True(t, summary.ToolCallSurchargeQuota.IsZero()) + require.Equal(t, common.QuotaFromFloat(0.02*common.QuotaPerUnit), summary.Quota) +} + func TestCalculateTextQuotaSummaryDoesNotApplyRequestMultipliersToToolSurcharge(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) @@ -895,6 +936,34 @@ func TestComposeTieredTextQuotaErrorFallbackUsesPreConsumedQuota(t *testing.T) { require.Equal(t, 14500, quota) } +func TestComposeTieredTextQuotaAlphaSearchErrorFallbackDoesNotDuplicateReservedSurcharge(t *testing.T) { + summary := textQuotaSummary{ + ToolCallSurchargeQuota: decimal.NewFromInt(12_500), + } + relayInfo := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeAlphaSearch, + FinalPreConsumedQuota: 14_500, + } + + quota := composeTieredTextQuota(relayInfo, summary, 14_500, nil) + + require.Equal(t, 14_500, quota) +} + +func TestComposeTieredTextQuotaTrustedAlphaSearchFallbackAddsUnreservedSurcharge(t *testing.T) { + summary := textQuotaSummary{ + ToolCallSurchargeQuota: decimal.NewFromInt(12_500), + } + relayInfo := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeAlphaSearch, + FinalPreConsumedQuota: 0, + } + + quota := composeTieredTextQuota(relayInfo, summary, 2_000, nil) + + require.Equal(t, 14_500, quota) +} + func TestComposeTieredTextQuotaFallbackSaturatesFinalQuota(t *testing.T) { summary := textQuotaSummary{ ToolCallSurchargeQuota: decimal.NewFromInt(100), diff --git a/tools/jsonwrapcheck/allowlist.txt b/tools/jsonwrapcheck/allowlist.txt index e6285063..7b58707e 100644 --- a/tools/jsonwrapcheck/allowlist.txt +++ b/tools/jsonwrapcheck/allowlist.txt @@ -47,8 +47,6 @@ middleware/jimeng_adapter.go|JimengRequestConvert|Marshal|418a4a72b6b9c61eb7c62b model/passkey.go|*PasskeyCredential.TransportList|Unmarshal|8174884feacc1fdfef084adb2900b1a97565b6c6c5f072b2d390e1c1dbadaa7f model/passkey.go|*PasskeyCredential.SetTransports|Marshal|891aca816b90a7b0706fdd24581e477bee4335c8f916733130b54141eb6448c7 model/prefill_group.go|*JSONValue.Scan|Marshal|be383ea6cfaaf292483613ff481847bcf18cbddf173a0870a25e345b29d9f5eb -model/pricing.go|updatePricing|Unmarshal|fc4d7eb2d1aedddae8988bc2c29dadcd7441835badae2b3274c3b3c0da3b4ab6 -model/pricing.go|updatePricing|Unmarshal|fc4d7eb2d1aedddae8988bc2c29dadcd7441835badae2b3274c3b3c0da3b4ab6 oauth/discord.go|*DiscordProvider.ExchangeToken|NewDecoder|64ee82d1a886a8c84d5771d6a7733571f892d8b735ff5b5ffa0167dc5f1ecb39 oauth/discord.go|*DiscordProvider.GetUserInfo|NewDecoder|64ee82d1a886a8c84d5771d6a7733571f892d8b735ff5b5ffa0167dc5f1ecb39 oauth/github.go|*GitHubProvider.ExchangeToken|Marshal|143f0e7565c0ae9ec63514edc3df01ffd64656e84ebb437a776db18b41482840 diff --git a/web/default/src/features/pricing/components/model-details-api-samples.ts b/web/default/src/features/pricing/components/model-details-api-samples.ts new file mode 100644 index 00000000..c2274a35 --- /dev/null +++ b/web/default/src/features/pricing/components/model-details-api-samples.ts @@ -0,0 +1,65 @@ +export type ModelDetailsSampleLanguage = + | 'curl' + | 'python' + | 'typescript' + | 'javascript' + +type ResponsesCompactSampleContext = { + baseUrl: string + apiKeyEnv: string + modelName: string + endpointPath: string +} + +export function buildResponsesCompactSample( + lang: ModelDetailsSampleLanguage, + ctx: ResponsesCompactSampleContext +): string { + const url = `${ctx.baseUrl}${ctx.endpointPath}` + const input = 'Summarize the conversation context for the next request.' + const bodyJson = JSON.stringify({ model: ctx.modelName, input }, null, 2) + + if (lang === 'curl') { + return [ + `curl ${url} \\`, + ` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`, + ` -H "Content-Type: application/json" \\`, + ` -d '${bodyJson.replace(/\n/g, '\n ')}'`, + ].join('\n') + } + + if (lang === 'python') { + return [ + 'import os', + 'import requests', + '', + `response = requests.post(`, + ` "${url}",`, + ` headers={`, + ` "Authorization": f"Bearer {os.environ['${ctx.apiKeyEnv}']}",`, + ` "Content-Type": "application/json",`, + ` },`, + ` json={`, + ` "model": "${ctx.modelName}",`, + ` "input": "${input}",`, + ` },`, + `)`, + 'response.raise_for_status()', + 'print(response.json())', + ].join('\n') + } + + return [ + `const response = await fetch('${url}', {`, + ` method: 'POST',`, + ` headers: {`, + ` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`, + ` 'Content-Type': 'application/json',`, + ` },`, + ` body: JSON.stringify(${bodyJson}),`, + `})`, + '', + `if (!response.ok) throw new Error(\`HTTP \${response.status}\`)`, + `console.log(await response.json())`, + ].join('\n') +} diff --git a/web/default/src/features/pricing/components/model-details-api.test.ts b/web/default/src/features/pricing/components/model-details-api.test.ts new file mode 100644 index 00000000..2337fcff --- /dev/null +++ b/web/default/src/features/pricing/components/model-details-api.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { buildResponsesCompactSample } from './model-details-api-samples' + +describe('model details API samples', () => { + for (const language of [ + 'curl', + 'python', + 'typescript', + 'javascript', + ] as const) { + test(`builds a Responses Compact request for ${language}`, () => { + const sample = buildResponsesCompactSample(language, { + baseUrl: 'https://api.example.com', + apiKeyEnv: 'MAX_API_KEY', + modelName: 'gpt-5.1-codex-mini', + endpointPath: '/v1/responses/compact', + }) + + assert.match(sample, /\/v1\/responses\/compact/) + assert.match(sample, /input/) + assert.doesNotMatch(sample, /messages/) + assert.doesNotMatch(sample, /temperature/) + }) + } +}) diff --git a/web/default/src/features/pricing/components/model-details-api.tsx b/web/default/src/features/pricing/components/model-details-api.tsx index 8ed72ece..caa356bc 100644 --- a/web/default/src/features/pricing/components/model-details-api.tsx +++ b/web/default/src/features/pricing/components/model-details-api.tsx @@ -54,6 +54,7 @@ import { import { replaceModelInPath } from '../lib/model-helpers' import { inferApiInfo } from '../lib/model-metadata' import type { PricingModel } from '../types' +import { buildResponsesCompactSample } from './model-details-api-samples' // --------------------------------------------------------------------------- // Code-sample registry @@ -493,6 +494,8 @@ function buildSample( endpointType: string, ctx: SampleContext ): string { + if (endpointType === 'openai-response-compact') + return buildResponsesCompactSample(lang, ctx) if (endpointType === 'openai-alpha-search') return buildAlphaSearchSample(lang, ctx) if (endpointType === 'anthropic') return buildAnthropicSample(lang, ctx) From f1b5fb0ad1592f53b1979d2f2b94fd25806c356c Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 10 Aug 2026 01:34:49 +0800 Subject: [PATCH 03/10] alpha search --- model/channel_selection_test.go | 27 +++++++- .../components/model-details-api-samples.ts | 66 ++++++++++++++++++- .../components/model-details-api.test.ts | 34 +++++++++- .../pricing/components/model-details-api.tsx | 64 ++---------------- 4 files changed, 126 insertions(+), 65 deletions(-) diff --git a/model/channel_selection_test.go b/model/channel_selection_test.go index b4de95a9..5db62d9e 100644 --- a/model/channel_selection_test.go +++ b/model/channel_selection_test.go @@ -172,11 +172,21 @@ func TestAlphaSearchSelectionUsesOnlySupportedChannelTypes(t *testing.T) { }) const ( - groupName = "default" - modelName = "gpt-alpha-selection" + groupName = "default" + modelName = "gpt-alpha-selection" + advancedModelName = "gpt-alpha-advanced-selection" ) insertChannelSelectionCandidate(t, 1, constant.ChannelTypeOpenAI, "", modelName, groupName, 10) insertChannelSelectionCandidate(t, 2, constant.ChannelTypeCodex, "", modelName, groupName, 1) + advancedSettings := advancedCustomSettingsForSelectionTest(t, []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/alpha/search", + UpstreamPath: "/v1/alpha/search", + Converter: dto.AdvancedCustomConverterNone, + }, + }) + insertChannelSelectionCandidate(t, 3, constant.ChannelTypeOpenAI, "", advancedModelName, groupName, 10) + insertChannelSelectionCandidate(t, 4, constant.ChannelTypeAdvancedCustom, advancedSettings, advancedModelName, groupName, 1) previousMemoryCacheEnabled := common.MemoryCacheEnabled t.Cleanup(func() { @@ -190,6 +200,15 @@ func TestAlphaSearchSelectionUsesOnlySupportedChannelTypes(t *testing.T) { assert.Equal(t, 2, channel.Id) assert.False(t, ChannelSupportsRequestPath(&Channel{Type: constant.ChannelTypeOpenAI}, "/v1/alpha/search")) assert.True(t, ChannelSupportsRequestPath(&Channel{Type: constant.ChannelTypeCodex}, "/v1/alpha/search")) + channel, err = GetChannel(groupName, advancedModelName, 0, "/v1/alpha/search") + require.NoError(t, err) + require.NotNil(t, channel) + assert.Equal(t, 4, channel.Id) + assert.True(t, ChannelSupportsRequestPath(&Channel{ + Id: 4, + Type: constant.ChannelTypeAdvancedCustom, + OtherSettings: advancedSettings, + }, "/v1/alpha/search")) common.MemoryCacheEnabled = true InitChannelCache() @@ -197,6 +216,10 @@ func TestAlphaSearchSelectionUsesOnlySupportedChannelTypes(t *testing.T) { require.NoError(t, err) require.NotNil(t, channel) assert.Equal(t, 2, channel.Id) + channel, err = GetRandomSatisfiedChannel(groupName, advancedModelName, 0, "/v1/alpha/search") + require.NoError(t, err) + require.NotNil(t, channel) + assert.Equal(t, 4, channel.Id) } func TestChannelSelectionExcludesPreviouslyTriedChannel(t *testing.T) { diff --git a/web/default/src/features/pricing/components/model-details-api-samples.ts b/web/default/src/features/pricing/components/model-details-api-samples.ts index c2274a35..6810f1c9 100644 --- a/web/default/src/features/pricing/components/model-details-api-samples.ts +++ b/web/default/src/features/pricing/components/model-details-api-samples.ts @@ -4,7 +4,7 @@ export type ModelDetailsSampleLanguage = | 'typescript' | 'javascript' -type ResponsesCompactSampleContext = { +type ModelDetailsSampleContext = { baseUrl: string apiKeyEnv: string modelName: string @@ -13,7 +13,7 @@ type ResponsesCompactSampleContext = { export function buildResponsesCompactSample( lang: ModelDetailsSampleLanguage, - ctx: ResponsesCompactSampleContext + ctx: ModelDetailsSampleContext ): string { const url = `${ctx.baseUrl}${ctx.endpointPath}` const input = 'Summarize the conversation context for the next request.' @@ -63,3 +63,65 @@ export function buildResponsesCompactSample( `console.log(await response.json())`, ].join('\n') } + +export function buildAlphaSearchSample( + lang: ModelDetailsSampleLanguage, + ctx: ModelDetailsSampleContext +): string { + const url = `${ctx.baseUrl}${ctx.endpointPath}` + const query = 'latest artificial intelligence news' + const bodyJson = JSON.stringify( + { + model: ctx.modelName, + input: [{ role: 'user', content: `Search for ${query}.` }], + commands: { search_query: [{ q: query }] }, + }, + null, + 2 + ) + + if (lang === 'curl') { + return [ + `curl ${url} \\`, + ` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`, + ` -H "Content-Type: application/json" \\`, + ` -d '${bodyJson.replace(/\n/g, '\n ')}'`, + ].join('\n') + } + + if (lang === 'python') { + return [ + 'import json', + 'import os', + 'import urllib.request', + '', + `payload = ${bodyJson}`, + `request = urllib.request.Request(`, + ` "${url}",`, + ` data=json.dumps(payload).encode("utf-8"),`, + ` headers={`, + ` "Authorization": f"Bearer {os.environ['${ctx.apiKeyEnv}']}",`, + ` "Content-Type": "application/json",`, + ` },`, + ` method="POST",`, + `)`, + '', + 'with urllib.request.urlopen(request) as response:', + ' print(json.load(response))', + ].join('\n') + } + + return [ + `const response = await fetch('${url}', {`, + ` method: 'POST',`, + ` headers: {`, + ` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`, + ` 'Content-Type': 'application/json',`, + ` },`, + ` body: JSON.stringify(${bodyJson}),`, + `})`, + '', + `const data = await response.json()`, + `console.log(data)`, + ].join('\n') +} diff --git a/web/default/src/features/pricing/components/model-details-api.test.ts b/web/default/src/features/pricing/components/model-details-api.test.ts index 2337fcff..316d61db 100644 --- a/web/default/src/features/pricing/components/model-details-api.test.ts +++ b/web/default/src/features/pricing/components/model-details-api.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' -import { buildResponsesCompactSample } from './model-details-api-samples' +import { + buildAlphaSearchSample, + buildResponsesCompactSample, +} from './model-details-api-samples' describe('model details API samples', () => { for (const language of [ @@ -23,4 +26,33 @@ describe('model details API samples', () => { assert.doesNotMatch(sample, /temperature/) }) } + + for (const language of [ + 'curl', + 'python', + 'typescript', + 'javascript', + ] as const) { + test(`builds an Alpha Search request for ${language}`, () => { + const sample = buildAlphaSearchSample(language, { + baseUrl: 'https://api.example.com', + apiKeyEnv: 'MAX_API_KEY', + modelName: 'gpt-5.1-codex-mini', + endpointPath: '/v1/alpha/search', + }) + + assert.match(sample, /\/v1\/alpha\/search/) + assert.match(sample, /"search_query"\s*:\s*\[/) + assert.match(sample, /"q"\s*:\s*"latest artificial intelligence news"/) + assert.match(sample, /gpt-5\.1-codex-mini/) + + if (language === 'curl') { + assert.match(sample, /Authorization: Bearer \$MAX_API_KEY/) + } else if (language === 'python') { + assert.match(sample, /os\.environ\['MAX_API_KEY'\]/) + } else { + assert.match(sample, /process\.env\.MAX_API_KEY/) + } + }) + } }) diff --git a/web/default/src/features/pricing/components/model-details-api.tsx b/web/default/src/features/pricing/components/model-details-api.tsx index caa356bc..e2806664 100644 --- a/web/default/src/features/pricing/components/model-details-api.tsx +++ b/web/default/src/features/pricing/components/model-details-api.tsx @@ -54,7 +54,10 @@ import { import { replaceModelInPath } from '../lib/model-helpers' import { inferApiInfo } from '../lib/model-metadata' import type { PricingModel } from '../types' -import { buildResponsesCompactSample } from './model-details-api-samples' +import { + buildAlphaSearchSample, + buildResponsesCompactSample, +} from './model-details-api-samples' // --------------------------------------------------------------------------- // Code-sample registry @@ -165,65 +168,6 @@ function buildChatSample(lang: Lang, ctx: SampleContext): string { ].join('\n') } -function buildAlphaSearchSample(lang: Lang, ctx: SampleContext): string { - const url = `${ctx.baseUrl}${ctx.endpointPath}` - const query = 'latest artificial intelligence news' - const bodyJson = JSON.stringify( - { - model: ctx.modelName, - input: [{ role: 'user', content: `Search for ${query}.` }], - commands: { search_query: [{ q: query }] }, - }, - null, - 2 - ) - - if (lang === 'curl') { - return [ - `curl ${url} \\`, - ` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`, - ` -H "Content-Type: application/json" \\`, - ` -d '${bodyJson.replace(/\n/g, '\n ')}'`, - ].join('\n') - } - - if (lang === 'python') { - return [ - 'import json', - 'import os', - 'import urllib.request', - '', - `payload = ${bodyJson}`, - `request = urllib.request.Request(`, - ` "${url}",`, - ` data=json.dumps(payload).encode("utf-8"),`, - ` headers={`, - ` "Authorization": f"Bearer {os.environ['${ctx.apiKeyEnv}']}",`, - ` "Content-Type": "application/json",`, - ` },`, - ` method="POST",`, - `)`, - '', - 'with urllib.request.urlopen(request) as response:', - ' print(json.load(response))', - ].join('\n') - } - - return [ - `const response = await fetch('${url}', {`, - ` method: 'POST',`, - ` headers: {`, - ` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`, - ` 'Content-Type': 'application/json',`, - ` },`, - ` body: JSON.stringify(${bodyJson}),`, - `})`, - '', - `const data = await response.json()`, - `console.log(data)`, - ].join('\n') -} - function buildAnthropicSample(lang: Lang, ctx: SampleContext): string { const url = `${ctx.baseUrl}${ctx.endpointPath}` const userMessage = 'Explain quantum entanglement in one paragraph.' From 9b8e086aa8ccfa70d3d2ab0287ee365ee0c22b64 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 10 Aug 2026 02:12:31 +0800 Subject: [PATCH 04/10] alpha search --- .../features/pricing/components/model-details-api-samples.ts | 1 + .../features/pricing/components/model-details-api.test.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/web/default/src/features/pricing/components/model-details-api-samples.ts b/web/default/src/features/pricing/components/model-details-api-samples.ts index 6810f1c9..63caef6c 100644 --- a/web/default/src/features/pricing/components/model-details-api-samples.ts +++ b/web/default/src/features/pricing/components/model-details-api-samples.ts @@ -121,6 +121,7 @@ export function buildAlphaSearchSample( ` body: JSON.stringify(${bodyJson}),`, `})`, '', + `if (!response.ok) throw new Error(\`HTTP \${response.status}\`)`, `const data = await response.json()`, `console.log(data)`, ].join('\n') diff --git a/web/default/src/features/pricing/components/model-details-api.test.ts b/web/default/src/features/pricing/components/model-details-api.test.ts index 316d61db..19f0c6ed 100644 --- a/web/default/src/features/pricing/components/model-details-api.test.ts +++ b/web/default/src/features/pricing/components/model-details-api.test.ts @@ -52,6 +52,11 @@ describe('model details API samples', () => { assert.match(sample, /os\.environ\['MAX_API_KEY'\]/) } else { assert.match(sample, /process\.env\.MAX_API_KEY/) + const statusGuardIndex = sample.indexOf('if (!response.ok)') + const responseParseIndex = sample.indexOf('response.json()') + assert.notEqual(statusGuardIndex, -1) + assert.ok(statusGuardIndex < responseParseIndex) + assert.match(sample, /throw new Error\(`HTTP \$\{response\.status\}`\)/) } }) } From 4ed635551793bc5b152f476273f01df992a238d5 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 10 Aug 2026 13:37:51 +0800 Subject: [PATCH 05/10] alpha search --- controller/billing.go | 96 ++++++++++++++++++++++---------------- controller/billing_test.go | 47 +++++++++++++++++++ service/midjourney.go | 37 +++++++++++---- service/midjourney_test.go | 36 ++++++++++++++ 4 files changed, 167 insertions(+), 49 deletions(-) create mode 100644 controller/billing_test.go diff --git a/controller/billing.go b/controller/billing.go index 8870f5a4..b2959faa 100644 --- a/controller/billing.go +++ b/controller/billing.go @@ -1,6 +1,10 @@ package controller import ( + "fmt" + "math" + "net/http" + "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/setting/operation_setting" @@ -8,6 +12,29 @@ import ( "github.com/gin-gonic/gin" ) +func billingQuotaDisplayAmount(quota int64) (float64, error) { + amount := float64(quota) + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + return amount, nil + } + if common.QuotaPerUnit <= 0 || math.IsNaN(common.QuotaPerUnit) || math.IsInf(common.QuotaPerUnit, 0) { + return 0, fmt.Errorf("invalid QuotaPerUnit: %g", common.QuotaPerUnit) + } + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeCNY { + return amount / common.QuotaPerUnit * operation_setting.USDExchangeRate, nil + } + return amount / common.QuotaPerUnit, nil +} + +func writeBillingOpenAIError(c *gin.Context, status int, message string, errorType string) { + c.JSON(status, gin.H{ + "error": types.OpenAIError{ + Message: message, + Type: errorType, + }, + }) +} + func GetSubscription(c *gin.Context) { var remainQuota int64 var usedQuota int64 @@ -17,42 +44,36 @@ func GetSubscription(c *gin.Context) { if common.DisplayTokenStatEnabled { tokenId := c.GetInt("token_id") token, err = model.GetTokenById(tokenId) + if err != nil { + writeBillingOpenAIError(c, http.StatusOK, err.Error(), "upstream_error") + return + } expiredTime = token.ExpiredTime remainQuota = int64(token.RemainQuota) usedQuota = int64(token.UsedQuota) } else { userId := c.GetInt("id") remainQuota, err = model.GetUserQuota(userId, false) + if err != nil { + writeBillingOpenAIError(c, http.StatusOK, err.Error(), "upstream_error") + return + } usedQuota, err = model.GetUserUsedQuota(userId) + if err != nil { + writeBillingOpenAIError(c, http.StatusOK, err.Error(), "upstream_error") + return + } } if expiredTime <= 0 { expiredTime = 0 } + quota := remainQuota + usedQuota + amount, err := billingQuotaDisplayAmount(quota) if err != nil { - openAIError := types.OpenAIError{ - Message: err.Error(), - Type: "upstream_error", - } - c.JSON(200, gin.H{ - "error": openAIError, - }) + common.SysError("billing subscription quota display failed: " + err.Error()) + writeBillingOpenAIError(c, http.StatusInternalServerError, err.Error(), "max_api_error") return } - quota := remainQuota + usedQuota - amount := float64(quota) - // OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值: - // 我们将其解释为以“站点展示类型”为准: - // - USD: 直接除以 QuotaPerUnit - // - CNY: 先转 USD 再乘汇率 - // - TOKENS: 直接使用 tokens 数量 - switch operation_setting.GetQuotaDisplayType() { - case operation_setting.QuotaDisplayTypeCNY: - amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate - case operation_setting.QuotaDisplayTypeTokens: - // amount 保持 tokens 数值 - default: - amount = amount / common.QuotaPerUnit - } if token != nil && token.UnlimitedQuota { amount = 100000000 } @@ -64,7 +85,7 @@ func GetSubscription(c *gin.Context) { SystemHardLimitUSD: amount, AccessUntil: expiredTime, } - c.JSON(200, subscription) + c.JSON(http.StatusOK, subscription) return } @@ -75,34 +96,29 @@ func GetUsage(c *gin.Context) { if common.DisplayTokenStatEnabled { tokenId := c.GetInt("token_id") token, err = model.GetTokenById(tokenId) + if err != nil { + writeBillingOpenAIError(c, http.StatusOK, err.Error(), "max_api_error") + return + } quota = int64(token.UsedQuota) } else { userId := c.GetInt("id") quota, err = model.GetUserUsedQuota(userId) + if err != nil { + writeBillingOpenAIError(c, http.StatusOK, err.Error(), "max_api_error") + return + } } + amount, err := billingQuotaDisplayAmount(quota) if err != nil { - openAIError := types.OpenAIError{ - Message: err.Error(), - Type: "max_api_error", - } - c.JSON(200, gin.H{ - "error": openAIError, - }) + common.SysError("billing usage quota display failed: " + err.Error()) + writeBillingOpenAIError(c, http.StatusInternalServerError, err.Error(), "max_api_error") return } - amount := float64(quota) - switch operation_setting.GetQuotaDisplayType() { - case operation_setting.QuotaDisplayTypeCNY: - amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate - case operation_setting.QuotaDisplayTypeTokens: - // tokens 保持原值 - default: - amount = amount / common.QuotaPerUnit - } usage := OpenAIUsageResponse{ Object: "list", TotalUsage: amount * 100, } - c.JSON(200, usage) + c.JSON(http.StatusOK, usage) return } diff --git a/controller/billing_test.go b/controller/billing_test.go new file mode 100644 index 00000000..254fb432 --- /dev/null +++ b/controller/billing_test.go @@ -0,0 +1,47 @@ +package controller + +import ( + "math" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/setting/operation_setting" + "github.com/stretchr/testify/require" +) + +func TestBillingQuotaDisplayAmountRejectsInvalidQuotaPerUnit(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType + t.Cleanup(func() { + common.QuotaPerUnit = oldQuotaPerUnit + operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType + }) + + for _, quotaPerUnit := range []float64{0, -1, math.NaN(), math.Inf(1)} { + t.Run("invalid_quota_per_unit", func(t *testing.T) { + common.QuotaPerUnit = quotaPerUnit + operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeUSD + + _, err := billingQuotaDisplayAmount(1000) + + require.Error(t, err) + }) + } +} + +func TestBillingQuotaDisplayAmountAllowsTokenDisplayWithInvalidQuotaPerUnit(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType + t.Cleanup(func() { + common.QuotaPerUnit = oldQuotaPerUnit + operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType + }) + + common.QuotaPerUnit = 0 + operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeTokens + + amount, err := billingQuotaDisplayAmount(1000) + + require.NoError(t, err) + require.Equal(t, 1000.0, amount) +} diff --git a/service/midjourney.go b/service/midjourney.go index 79e5e902..50319592 100644 --- a/service/midjourney.go +++ b/service/midjourney.go @@ -84,28 +84,47 @@ func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.Midj } splits := strings.Split(customId, "::") var action string - if splits[1] == "JOB" { - action = splits[2] + actionIndex := 1 + if len(splits) <= actionIndex { + return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action") + } + if splits[actionIndex] == "JOB" { + actionIndex++ + if len(splits) <= actionIndex { + return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action") + } + action = splits[actionIndex] } else { - action = splits[1] + action = splits[actionIndex] } if action == "" { return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action") } - if strings.Contains(action, "upsample") { - index, err := strconv.Atoi(splits[3]) + indexPosition := actionIndex + 1 + parseIndex := func() (int, *dto.MidjourneyResponse) { + if len(splits) <= indexPosition { + return 0, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + } + index, err := strconv.Atoi(splits[indexPosition]) if err != nil { - return MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + return 0, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + } + return index, nil + } + if strings.Contains(action, "upsample") { + index, resp := parseIndex() + if resp != nil { + return resp } midjRequest.Index = index midjRequest.Action = constant.MjActionUpscale } else if strings.Contains(action, "variation") { midjRequest.Index = 1 if action == "variation" { - index, err := strconv.Atoi(splits[3]) - if err != nil { - return MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + index, resp := parseIndex() + if resp != nil { + return resp } midjRequest.Index = index midjRequest.Action = constant.MjActionVariation diff --git a/service/midjourney_test.go b/service/midjourney_test.go index d7e3f706..7f0c5adf 100644 --- a/service/midjourney_test.go +++ b/service/midjourney_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -82,6 +84,40 @@ func TestDoMidjourneyHttpRequestTreatsReceivedResponseAsSent(t *testing.T) { require.True(t, requestSent) } +func TestCoverPlusActionToNormalActionRejectsMalformedCustomID(t *testing.T) { + tests := []string{ + "plain", + "MJ", + "MJ::JOB", + "MJ::JOB::upsample", + "MJ::JOB::variation", + } + + for _, customID := range tests { + t.Run(customID, func(t *testing.T) { + req := &dto.MidjourneyRequest{CustomId: customID} + + var resp *dto.MidjourneyResponse + require.NotPanics(t, func() { + resp = CoverPlusActionToNormalAction(req) + }) + + require.NotNil(t, resp) + require.Equal(t, constant.MjRequestError, resp.Code) + }) + } +} + +func TestCoverPlusActionToNormalActionAcceptsJobUpsampleCustomID(t *testing.T) { + req := &dto.MidjourneyRequest{CustomId: "MJ::JOB::upsample::2::task-id"} + + resp := CoverPlusActionToNormalAction(req) + + require.Nil(t, resp) + require.Equal(t, constant.MjActionUpscale, req.Action) + require.Equal(t, 2, req.Index) +} + type midjourneyFailingReader struct{} func (midjourneyFailingReader) Read([]byte) (int, error) { From 69e489c2ca0c0b96e16117334fff94d56a1d4dd0 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 10 Aug 2026 13:45:45 +0800 Subject: [PATCH 06/10] alpha search --- web/default/public/favicon.ico | Bin 15406 -> 222907 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/web/default/public/favicon.ico b/web/default/public/favicon.ico index ab5f17bcdb35e96cf7673ca8fa7ba8d6a33bd7ce..a6bba891b76d5b695186a11e0cc43f67aaa262bc 100644 GIT binary patch literal 222907 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%0D$^3kWV14FEK3%;V6vYCbkJ~;xzvX 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 From b6b491e26baa9ff870002e5a36fc540a780b58a2 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 10 Aug 2026 14:38:22 +0800 Subject: [PATCH 07/10] alpha search --- controller/billing.go | 15 +++++--- controller/billing_test.go | 71 ++++++++++++++++++++++++++++++++++++++ service/midjourney.go | 4 ++- service/midjourney_test.go | 11 ++++++ 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/controller/billing.go b/controller/billing.go index b2959faa..fd9ba622 100644 --- a/controller/billing.go +++ b/controller/billing.go @@ -21,11 +21,21 @@ func billingQuotaDisplayAmount(quota int64) (float64, error) { return 0, fmt.Errorf("invalid QuotaPerUnit: %g", common.QuotaPerUnit) } if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeCNY { + if operation_setting.USDExchangeRate <= 0 || math.IsNaN(operation_setting.USDExchangeRate) || math.IsInf(operation_setting.USDExchangeRate, 0) { + return 0, fmt.Errorf("invalid USDExchangeRate: %g", operation_setting.USDExchangeRate) + } return amount / common.QuotaPerUnit * operation_setting.USDExchangeRate, nil } return amount / common.QuotaPerUnit, nil } +func billingSubscriptionDisplayAmount(quota int64, unlimitedToken bool) (float64, error) { + if unlimitedToken { + return 100000000, nil + } + return billingQuotaDisplayAmount(quota) +} + func writeBillingOpenAIError(c *gin.Context, status int, message string, errorType string) { c.JSON(status, gin.H{ "error": types.OpenAIError{ @@ -68,15 +78,12 @@ func GetSubscription(c *gin.Context) { expiredTime = 0 } quota := remainQuota + usedQuota - amount, err := billingQuotaDisplayAmount(quota) + amount, err := billingSubscriptionDisplayAmount(quota, token != nil && token.UnlimitedQuota) if err != nil { common.SysError("billing subscription quota display failed: " + err.Error()) writeBillingOpenAIError(c, http.StatusInternalServerError, err.Error(), "max_api_error") return } - if token != nil && token.UnlimitedQuota { - amount = 100000000 - } subscription := OpenAISubscriptionResponse{ Object: "billing_subscription", HasPaymentMethod: true, diff --git a/controller/billing_test.go b/controller/billing_test.go index 254fb432..b4e7f057 100644 --- a/controller/billing_test.go +++ b/controller/billing_test.go @@ -45,3 +45,74 @@ func TestBillingQuotaDisplayAmountAllowsTokenDisplayWithInvalidQuotaPerUnit(t *t require.NoError(t, err) require.Equal(t, 1000.0, amount) } + +func TestBillingQuotaDisplayAmountRejectsInvalidUSDExchangeRate(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType + oldUSDExchangeRate := operation_setting.USDExchangeRate + t.Cleanup(func() { + common.QuotaPerUnit = oldQuotaPerUnit + operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType + operation_setting.USDExchangeRate = oldUSDExchangeRate + }) + + common.QuotaPerUnit = 500 + operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeCNY + + tests := []struct { + name string + usdExchangeRate float64 + }{ + {name: "zero", usdExchangeRate: 0}, + {name: "negative", usdExchangeRate: -1}, + {name: "nan", usdExchangeRate: math.NaN()}, + {name: "infinite", usdExchangeRate: math.Inf(1)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + operation_setting.USDExchangeRate = tt.usdExchangeRate + + _, err := billingQuotaDisplayAmount(1000) + + require.Error(t, err) + }) + } +} + +func TestBillingQuotaDisplayAmountAllowsValidUSDExchangeRate(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType + oldUSDExchangeRate := operation_setting.USDExchangeRate + t.Cleanup(func() { + common.QuotaPerUnit = oldQuotaPerUnit + operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType + operation_setting.USDExchangeRate = oldUSDExchangeRate + }) + + common.QuotaPerUnit = 500 + operation_setting.USDExchangeRate = 7.2 + operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeCNY + + amount, err := billingQuotaDisplayAmount(1000) + + require.NoError(t, err) + require.Equal(t, 14.4, amount) +} + +func TestBillingSubscriptionDisplayAmountAllowsUnlimitedTokenWithInvalidQuotaPerUnit(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType + t.Cleanup(func() { + common.QuotaPerUnit = oldQuotaPerUnit + operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType + }) + + common.QuotaPerUnit = 0 + operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeUSD + + amount, err := billingSubscriptionDisplayAmount(0, true) + + require.NoError(t, err) + require.Equal(t, 100000000.0, amount) +} diff --git a/service/midjourney.go b/service/midjourney.go index 50319592..023b8eed 100644 --- a/service/midjourney.go +++ b/service/midjourney.go @@ -101,7 +101,7 @@ func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.Midj if action == "" { return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action") } - indexPosition := actionIndex + 1 + indexPosition := 3 parseIndex := func() (int, *dto.MidjourneyResponse) { if len(splits) <= indexPosition { return 0, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") @@ -132,6 +132,8 @@ func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.Midj midjRequest.Action = constant.MjActionLowVariation } else if action == "high_variation" { midjRequest.Action = constant.MjActionHighVariation + } else { + return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action") } } else if strings.Contains(action, "pan") { midjRequest.Action = constant.MjActionPan diff --git a/service/midjourney_test.go b/service/midjourney_test.go index 7f0c5adf..faa23ad3 100644 --- a/service/midjourney_test.go +++ b/service/midjourney_test.go @@ -91,6 +91,7 @@ func TestCoverPlusActionToNormalActionRejectsMalformedCustomID(t *testing.T) { "MJ::JOB", "MJ::JOB::upsample", "MJ::JOB::variation", + "MJ::JOB::unsupported_variation::1", } for _, customID := range tests { @@ -118,6 +119,16 @@ func TestCoverPlusActionToNormalActionAcceptsJobUpsampleCustomID(t *testing.T) { require.Equal(t, 2, req.Index) } +func TestCoverPlusActionToNormalActionAcceptsLegacyNonJobUpsampleCustomID(t *testing.T) { + req := &dto.MidjourneyRequest{CustomId: "MJ::upsample::legacy-field::2::task-id"} + + resp := CoverPlusActionToNormalAction(req) + + require.Nil(t, resp) + require.Equal(t, constant.MjActionUpscale, req.Action) + require.Equal(t, 2, req.Index) +} + type midjourneyFailingReader struct{} func (midjourneyFailingReader) Read([]byte) (int, error) { From 09654cc5215f1ac9cd32851e93089c7a92e960c7 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 10 Aug 2026 16:11:51 +0800 Subject: [PATCH 08/10] alpha search --- controller/billing.go | 17 +++++-- controller/misc.go | 35 +++++++++++++ controller/misc_test.go | 78 ++++++++++++++++++++++++++++ model/task_cas_test.go | 2 + model/user.go | 67 +++++++++++++++++------- model/user_delete_test.go | 101 +++++++++++++++++++++++++++++++++++++ router/api-router.go | 4 ++ router/api_router_test.go | 24 +++++++++ service/midjourney.go | 3 ++ service/midjourney_test.go | 2 + 10 files changed, 309 insertions(+), 24 deletions(-) create mode 100644 controller/misc_test.go diff --git a/controller/billing.go b/controller/billing.go index fd9ba622..9ae5c9bb 100644 --- a/controller/billing.go +++ b/controller/billing.go @@ -12,6 +12,8 @@ import ( "github.com/gin-gonic/gin" ) +const billingLookupErrorMessage = "billing information unavailable" + func billingQuotaDisplayAmount(quota int64) (float64, error) { amount := float64(quota) if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { @@ -55,7 +57,8 @@ func GetSubscription(c *gin.Context) { tokenId := c.GetInt("token_id") token, err = model.GetTokenById(tokenId) if err != nil { - writeBillingOpenAIError(c, http.StatusOK, err.Error(), "upstream_error") + common.SysError(fmt.Sprintf("billing subscription token lookup failed: token_id=%d: %v", tokenId, err)) + writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "upstream_error") return } expiredTime = token.ExpiredTime @@ -65,12 +68,14 @@ func GetSubscription(c *gin.Context) { userId := c.GetInt("id") remainQuota, err = model.GetUserQuota(userId, false) if err != nil { - writeBillingOpenAIError(c, http.StatusOK, err.Error(), "upstream_error") + common.SysError(fmt.Sprintf("billing subscription quota lookup failed: user_id=%d: %v", userId, err)) + writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "upstream_error") return } usedQuota, err = model.GetUserUsedQuota(userId) if err != nil { - writeBillingOpenAIError(c, http.StatusOK, err.Error(), "upstream_error") + common.SysError(fmt.Sprintf("billing subscription used-quota lookup failed: user_id=%d: %v", userId, err)) + writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "upstream_error") return } } @@ -104,7 +109,8 @@ func GetUsage(c *gin.Context) { tokenId := c.GetInt("token_id") token, err = model.GetTokenById(tokenId) if err != nil { - writeBillingOpenAIError(c, http.StatusOK, err.Error(), "max_api_error") + common.SysError(fmt.Sprintf("billing usage token lookup failed: token_id=%d: %v", tokenId, err)) + writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "max_api_error") return } quota = int64(token.UsedQuota) @@ -112,7 +118,8 @@ func GetUsage(c *gin.Context) { userId := c.GetInt("id") quota, err = model.GetUserUsedQuota(userId) if err != nil { - writeBillingOpenAIError(c, http.StatusOK, err.Error(), "max_api_error") + common.SysError(fmt.Sprintf("billing usage quota lookup failed: user_id=%d: %v", userId, err)) + writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "max_api_error") return } } diff --git a/controller/misc.go b/controller/misc.go index 95248c9e..bbb78649 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -40,6 +40,41 @@ func TestStatus(c *gin.Context) { return } +func writeHealthResponse(c *gin.Context, status int, healthStatus string, err error) { + response := gin.H{ + "success": true, + "status": healthStatus, + } + if err != nil { + response["success"] = false + response["status"] = "unhealthy" + response["message"] = "database connection failed" + c.JSON(status, response) + return + } + c.JSON(status, response) +} + +var healthReadyPingDB = model.PingDB + +func GetHealth(c *gin.Context) { + writeHealthResponse(c, http.StatusOK, "ok", nil) +} + +func GetHealthLive(c *gin.Context) { + writeHealthResponse(c, http.StatusOK, "ok", nil) +} + +func GetHealthReady(c *gin.Context) { + err := healthReadyPingDB() + if err != nil { + common.SysError("health ready check failed: " + err.Error()) + writeHealthResponse(c, http.StatusServiceUnavailable, "unhealthy", err) + return + } + writeHealthResponse(c, http.StatusOK, "ready", nil) +} + func GetStatus(c *gin.Context) { cs := console_setting.GetConsoleSetting() diff --git a/controller/misc_test.go b/controller/misc_test.go new file mode 100644 index 00000000..7fc27c7a --- /dev/null +++ b/controller/misc_test.go @@ -0,0 +1,78 @@ +package controller + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type healthResponse struct { + Success bool `json:"success"` + Status string `json:"status"` + Message string `json:"message"` +} + +func performHealthRequest(t *testing.T, handler gin.HandlerFunc, path string) (*httptest.ResponseRecorder, healthResponse) { + t.Helper() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, path, nil) + + handler(ctx) + + var response healthResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + return recorder, response +} + +func TestHealthEndpointsReturnLivenessStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + path string + handler gin.HandlerFunc + status string + }{ + {name: "health", path: "/health", handler: GetHealth, status: "ok"}, + {name: "live", path: "/health/live", handler: GetHealthLive, status: "ok"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder, response := performHealthRequest(t, tt.handler, tt.path) + + require.Equal(t, http.StatusOK, recorder.Code) + assert.True(t, response.Success) + assert.Equal(t, tt.status, response.Status) + assert.Empty(t, response.Message) + }) + } +} + +func TestHealthReadyReturnsServiceUnavailableWhenDatabasePingFails(t *testing.T) { + gin.SetMode(gin.TestMode) + + originalPingDB := healthReadyPingDB + healthReadyPingDB = func() error { + return errors.New("driver-specific database failure") + } + t.Cleanup(func() { + healthReadyPingDB = originalPingDB + }) + + recorder, response := performHealthRequest(t, GetHealthReady, "/health/ready") + + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + assert.False(t, response.Success) + assert.Equal(t, "unhealthy", response.Status) + assert.Equal(t, "database connection failed", response.Message) + assert.NotContains(t, recorder.Body.String(), "driver-specific database failure") +} diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 47057a5e..9c4e2bcd 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -55,6 +55,7 @@ func TestMain(m *testing.M) { &Midjourney{}, &MidjourneyBillingClaim{}, &CacheInvalidationTask{}, + &UserOAuthBinding{}, &PerfMetric{}, &SystemTask{}, &SystemInstance{}, @@ -86,6 +87,7 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM midjourneys") DB.Exec("DELETE FROM midjourney_billing_claims") DB.Exec("DELETE FROM cache_invalidation_tasks") + DB.Exec("DELETE FROM user_oauth_bindings") DB.Exec("DELETE FROM perf_metrics") DB.Exec("DELETE FROM system_tasks") DB.Exec("DELETE FROM system_instances") diff --git a/model/user.go b/model/user.go index dfe167cc..fe9cbe56 100644 --- a/model/user.go +++ b/model/user.go @@ -1303,27 +1303,56 @@ func (user *User) deleteWithCacheInvalidation(hardDelete bool) error { return errors.New("id 为空!") } var tasks []CacheInvalidationTask - err := DB.Transaction(func(tx *gorm.DB) error { - if err := user.ensureCanDeleteTx(tx); err != nil { - return err - } - userTask, err := stageUserCacheInvalidationTx(tx, user.Id, true) - if err != nil { - return err - } - tokenTasks, err := stageUserTokenCacheInvalidationsTx(tx, user.Id, hardDelete) - if err != nil { - return err - } - tasks = append(tasks, userTask) - tasks = append(tasks, tokenTasks...) - if hardDelete { - if err := tx.Unscoped().Where("user_id = ?", user.Id).Delete(&Token{}).Error; err != nil { + err := withUserOAuthIdentityMutationLock(DB, func(conn *gorm.DB) error { + return conn.Transaction(func(tx *gorm.DB) error { + if err := user.ensureCanDeleteTx(tx); err != nil { return err } - return tx.Unscoped().Where("id = ?", user.Id).Delete(&User{}).Error - } - return tx.Delete(user).Error + if err := tx.Where("user_id = ?", user.Id).Delete(&UserOAuthBinding{}).Error; err != nil { + return err + } + now := common.GetTimestamp() + if err := tx.Model(&UserSubscription{}). + Where("user_id = ? AND status = ?", user.Id, "active"). + Updates(map[string]interface{}{ + "status": "cancelled", + "end_time": now, + "updated_at": now, + }).Error; err != nil { + return err + } + if !hardDelete { + if err := tx.Model(&User{}).Where("id = ?", user.Id).Updates(map[string]interface{}{ + "access_token": nil, + }).Error; err != nil { + return err + } + if err := tx.Model(&Token{}). + Where("user_id = ? AND status = ?", user.Id, common.TokenStatusEnabled). + Updates(map[string]interface{}{ + "status": common.TokenStatusDisabled, + }).Error; err != nil { + return err + } + } + userTask, err := stageUserCacheInvalidationTx(tx, user.Id, true) + if err != nil { + return err + } + tokenTasks, err := stageUserTokenCacheInvalidationsTx(tx, user.Id, hardDelete) + if err != nil { + return err + } + tasks = append(tasks, userTask) + tasks = append(tasks, tokenTasks...) + if hardDelete { + if err := tx.Unscoped().Where("user_id = ?", user.Id).Delete(&Token{}).Error; err != nil { + return err + } + return tx.Unscoped().Where("id = ?", user.Id).Delete(&User{}).Error + } + return tx.Delete(user).Error + }) }) if err != nil { return err diff --git a/model/user_delete_test.go b/model/user_delete_test.go index 51ed292a..f00338a7 100644 --- a/model/user_delete_test.go +++ b/model/user_delete_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/MAX-API-Next/MAX-API/common" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -32,3 +33,103 @@ func TestUserHardDeleteRejectsRootUserInModelLayer(t *testing.T) { require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", root.Id).Count(&count).Error) require.EqualValues(t, 1, count) } + +func TestUserSoftDeleteInvalidatesCredentialsAndCancelsActiveSubscriptions(t *testing.T) { + truncateTables(t) + accessToken := "soft-delete-access-token" + now := common.GetTimestamp() + user := User{ + Id: 9103, + Username: "soft-delete-cleanup", + Password: "password123", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + AccessToken: &accessToken, + AffCode: "soft-delete-cleanup", + } + require.NoError(t, DB.Create(&user).Error) + token := Token{Id: 19103, UserId: user.Id, Key: "soft-delete-token", Status: common.TokenStatusEnabled} + require.NoError(t, DB.Create(&token).Error) + binding := UserOAuthBinding{UserId: user.Id, ProviderId: 9103, ProviderUserId: "soft-delete-provider-user"} + require.NoError(t, DB.Create(&binding).Error) + subscription := UserSubscription{ + Id: 29103, + UserId: user.Id, + PlanId: 39103, + AmountTotal: 100, + StartTime: now - 60, + EndTime: now + 3600, + Status: "active", + } + require.NoError(t, DB.Create(&subscription).Error) + + require.NoError(t, (&User{Id: user.Id}).Delete()) + + var storedUser User + require.NoError(t, DB.Unscoped().First(&storedUser, user.Id).Error) + assert.True(t, storedUser.DeletedAt.Valid) + assert.Nil(t, storedUser.AccessToken) + + var storedToken Token + require.NoError(t, DB.Unscoped().First(&storedToken, token.Id).Error) + assert.False(t, storedToken.DeletedAt.Valid) + assert.Equal(t, common.TokenStatusDisabled, storedToken.Status) + + var bindingCount int64 + require.NoError(t, DB.Model(&UserOAuthBinding{}).Where("user_id = ?", user.Id).Count(&bindingCount).Error) + assert.Zero(t, bindingCount) + + var storedSubscription UserSubscription + require.NoError(t, DB.First(&storedSubscription, subscription.Id).Error) + assert.Equal(t, "cancelled", storedSubscription.Status) + assert.GreaterOrEqual(t, storedSubscription.EndTime, now) +} + +func TestUserHardDeleteRemovesCredentialsAndCancelsActiveSubscriptions(t *testing.T) { + truncateTables(t) + accessToken := "hard-delete-access-token" + now := common.GetTimestamp() + user := User{ + Id: 9104, + Username: "hard-delete-cleanup", + Password: "password123", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + AccessToken: &accessToken, + AffCode: "hard-delete-cleanup", + } + require.NoError(t, DB.Create(&user).Error) + token := Token{Id: 19104, UserId: user.Id, Key: "hard-delete-token", Status: common.TokenStatusEnabled} + require.NoError(t, DB.Create(&token).Error) + binding := UserOAuthBinding{UserId: user.Id, ProviderId: 9104, ProviderUserId: "hard-delete-provider-user"} + require.NoError(t, DB.Create(&binding).Error) + subscription := UserSubscription{ + Id: 29104, + UserId: user.Id, + PlanId: 39104, + AmountTotal: 100, + StartTime: now - 60, + EndTime: now + 3600, + Status: "active", + } + require.NoError(t, DB.Create(&subscription).Error) + + require.NoError(t, HardDeleteUserById(user.Id)) + + var userCount int64 + require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&userCount).Error) + assert.Zero(t, userCount) + + var tokenCount int64 + require.NoError(t, DB.Unscoped().Model(&Token{}).Where("id = ?", token.Id).Count(&tokenCount).Error) + assert.Zero(t, tokenCount) + + var bindingCount int64 + require.NoError(t, DB.Model(&UserOAuthBinding{}).Where("user_id = ?", user.Id).Count(&bindingCount).Error) + assert.Zero(t, bindingCount) + + var storedSubscription UserSubscription + require.NoError(t, DB.First(&storedSubscription, subscription.Id).Error) + assert.Equal(t, "cancelled", storedSubscription.Status) + assert.GreaterOrEqual(t, storedSubscription.EndTime, now) +} diff --git a/router/api-router.go b/router/api-router.go index 90c465cc..1b1a4f0f 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -12,6 +12,10 @@ import ( ) func SetApiRouter(router *gin.Engine) { + router.GET("/health", middleware.PublicStatusRateLimit(), controller.GetHealth) + router.GET("/health/live", middleware.PublicStatusRateLimit(), controller.GetHealthLive) + router.GET("/health/ready", middleware.PublicStatusRateLimit(), controller.GetHealthReady) + apiRouter := router.Group("/api") apiRouter.Use(middleware.RouteTag("api")) apiRouter.Use(gzip.Gzip(gzip.DefaultCompression)) diff --git a/router/api_router_test.go b/router/api_router_test.go index 6a5b692c..be2d0a64 100644 --- a/router/api_router_test.go +++ b/router/api_router_test.go @@ -115,6 +115,30 @@ func TestVerificationMethodsRouteIsRegistered(t *testing.T) { t.Fatal("expected GET /api/verify/methods route to be registered") } +func TestHealthRoutesAreRegistered(t *testing.T) { + gin.SetMode(gin.TestMode) + + engine := gin.New() + SetApiRouter(engine) + + expected := map[string]bool{ + "/health": false, + "/health/live": false, + "/health/ready": false, + } + for _, route := range engine.Routes() { + if route.Method != http.MethodGet { + continue + } + if _, ok := expected[route.Path]; ok { + expected[route.Path] = true + } + } + for path, registered := range expected { + require.Truef(t, registered, "expected GET %s route to be registered", path) + } +} + func TestUniversalVerifyRateLimitFollowsUserAcrossIPs(t *testing.T) { gin.SetMode(gin.TestMode) testRun := atomic.AddUint64(&universalVerifyRateLimitTestRun, 1) diff --git a/service/midjourney.go b/service/midjourney.go index 023b8eed..40ad4ea1 100644 --- a/service/midjourney.go +++ b/service/midjourney.go @@ -110,6 +110,9 @@ func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.Midj if err != nil { return 0, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") } + if index < 1 { + return 0, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + } return index, nil } if strings.Contains(action, "upsample") { diff --git a/service/midjourney_test.go b/service/midjourney_test.go index faa23ad3..21af2d6e 100644 --- a/service/midjourney_test.go +++ b/service/midjourney_test.go @@ -90,6 +90,8 @@ func TestCoverPlusActionToNormalActionRejectsMalformedCustomID(t *testing.T) { "MJ", "MJ::JOB", "MJ::JOB::upsample", + "MJ::JOB::upsample::0::task-id", + "MJ::JOB::upsample::-1::task-id", "MJ::JOB::variation", "MJ::JOB::unsupported_variation::1", } From 2d89c8236b4024fce89522002d65974798527513 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 10 Aug 2026 18:29:52 +0800 Subject: [PATCH 09/10] fix bugs --- controller/misc.go | 4 +-- controller/misc_test.go | 36 +++++++++++++++++++++++--- model/main.go | 12 +++++++-- model/user_delete_test.go | 10 +++++-- service/midjourney.go | 40 ++++++++++++++++++---------- service/midjourney_test.go | 53 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 23 deletions(-) diff --git a/controller/misc.go b/controller/misc.go index bbb78649..185e72c8 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -55,7 +55,7 @@ func writeHealthResponse(c *gin.Context, status int, healthStatus string, err er c.JSON(status, response) } -var healthReadyPingDB = model.PingDB +var healthReadyProbeDB = model.PingDBUncached func GetHealth(c *gin.Context) { writeHealthResponse(c, http.StatusOK, "ok", nil) @@ -66,7 +66,7 @@ func GetHealthLive(c *gin.Context) { } func GetHealthReady(c *gin.Context) { - err := healthReadyPingDB() + err := healthReadyProbeDB() if err != nil { common.SysError("health ready check failed: " + err.Error()) writeHealthResponse(c, http.StatusServiceUnavailable, "unhealthy", err) diff --git a/controller/misc_test.go b/controller/misc_test.go index 7fc27c7a..a46a44b9 100644 --- a/controller/misc_test.go +++ b/controller/misc_test.go @@ -60,12 +60,12 @@ func TestHealthEndpointsReturnLivenessStatus(t *testing.T) { func TestHealthReadyReturnsServiceUnavailableWhenDatabasePingFails(t *testing.T) { gin.SetMode(gin.TestMode) - originalPingDB := healthReadyPingDB - healthReadyPingDB = func() error { + originalProbeDB := healthReadyProbeDB + healthReadyProbeDB = func() error { return errors.New("driver-specific database failure") } t.Cleanup(func() { - healthReadyPingDB = originalPingDB + healthReadyProbeDB = originalProbeDB }) recorder, response := performHealthRequest(t, GetHealthReady, "/health/ready") @@ -76,3 +76,33 @@ func TestHealthReadyReturnsServiceUnavailableWhenDatabasePingFails(t *testing.T) assert.Equal(t, "database connection failed", response.Message) assert.NotContains(t, recorder.Body.String(), "driver-specific database failure") } + +func TestHealthReadyUsesUncachedDatabaseProbe(t *testing.T) { + gin.SetMode(gin.TestMode) + + originalProbeDB := healthReadyProbeDB + probeCalls := 0 + healthReadyProbeDB = func() error { + defer func() { + probeCalls++ + }() + if probeCalls == 0 { + return nil + } + return errors.New("database failed after cached success") + } + t.Cleanup(func() { + healthReadyProbeDB = originalProbeDB + }) + + firstRecorder, firstResponse := performHealthRequest(t, GetHealthReady, "/health/ready") + secondRecorder, secondResponse := performHealthRequest(t, GetHealthReady, "/health/ready") + + require.Equal(t, http.StatusOK, firstRecorder.Code) + assert.True(t, firstResponse.Success) + assert.Equal(t, "ready", firstResponse.Status) + require.Equal(t, http.StatusServiceUnavailable, secondRecorder.Code) + assert.Equal(t, 2, probeCalls) + assert.False(t, secondResponse.Success) + assert.Equal(t, "unhealthy", secondResponse.Status) +} diff --git a/model/main.go b/model/main.go index 34967397..ad408bd5 100644 --- a/model/main.go +++ b/model/main.go @@ -1130,6 +1130,16 @@ func PingDB() error { return nil } + if err := PingDBUncached(); err != nil { + return err + } + + lastPingTime = time.Now() + common.SysLog("Database pinged successfully") + return nil +} + +func PingDBUncached() error { sqlDB, err := DB.DB() if err != nil { log.Printf("Error getting sql.DB from GORM: %v", err) @@ -1142,7 +1152,5 @@ func PingDB() error { return err } - lastPingTime = time.Now() - common.SysLog("Database pinged successfully") return nil } diff --git a/model/user_delete_test.go b/model/user_delete_test.go index f00338a7..60431c20 100644 --- a/model/user_delete_test.go +++ b/model/user_delete_test.go @@ -63,7 +63,9 @@ func TestUserSoftDeleteInvalidatesCredentialsAndCancelsActiveSubscriptions(t *te } require.NoError(t, DB.Create(&subscription).Error) + deleteStartedAt := common.GetTimestamp() require.NoError(t, (&User{Id: user.Id}).Delete()) + deleteFinishedAt := common.GetTimestamp() var storedUser User require.NoError(t, DB.Unscoped().First(&storedUser, user.Id).Error) @@ -82,7 +84,8 @@ func TestUserSoftDeleteInvalidatesCredentialsAndCancelsActiveSubscriptions(t *te var storedSubscription UserSubscription require.NoError(t, DB.First(&storedSubscription, subscription.Id).Error) assert.Equal(t, "cancelled", storedSubscription.Status) - assert.GreaterOrEqual(t, storedSubscription.EndTime, now) + assert.GreaterOrEqual(t, storedSubscription.EndTime, deleteStartedAt) + assert.LessOrEqual(t, storedSubscription.EndTime, deleteFinishedAt) } func TestUserHardDeleteRemovesCredentialsAndCancelsActiveSubscriptions(t *testing.T) { @@ -114,7 +117,9 @@ func TestUserHardDeleteRemovesCredentialsAndCancelsActiveSubscriptions(t *testin } require.NoError(t, DB.Create(&subscription).Error) + deleteStartedAt := common.GetTimestamp() require.NoError(t, HardDeleteUserById(user.Id)) + deleteFinishedAt := common.GetTimestamp() var userCount int64 require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&userCount).Error) @@ -131,5 +136,6 @@ func TestUserHardDeleteRemovesCredentialsAndCancelsActiveSubscriptions(t *testin var storedSubscription UserSubscription require.NoError(t, DB.First(&storedSubscription, subscription.Id).Error) assert.Equal(t, "cancelled", storedSubscription.Status) - assert.GreaterOrEqual(t, storedSubscription.EndTime, now) + assert.GreaterOrEqual(t, storedSubscription.EndTime, deleteStartedAt) + assert.LessOrEqual(t, storedSubscription.EndTime, deleteFinishedAt) } diff --git a/service/midjourney.go b/service/midjourney.go index 40ad4ea1..ef9c1303 100644 --- a/service/midjourney.go +++ b/service/midjourney.go @@ -101,21 +101,29 @@ func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.Midj if action == "" { return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action") } - indexPosition := 3 - parseIndex := func() (int, *dto.MidjourneyResponse) { - if len(splits) <= indexPosition { - return 0, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + indexPosition := actionIndex + 1 + parseIndexAt := func(position int) (int, bool, *dto.MidjourneyResponse) { + if len(splits) <= position { + return 0, false, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") } - index, err := strconv.Atoi(splits[indexPosition]) + index, err := strconv.Atoi(splits[position]) if err != nil { - return 0, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + return 0, false, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + } + if index < 1 || index > 4 { + return 0, true, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") } - if index < 1 { - return 0, MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + return index, true, nil + } + parseIndex := func() (int, *dto.MidjourneyResponse) { + index, numeric, resp := parseIndexAt(indexPosition) + if resp == nil || numeric || actionIndex != 1 || len(splits) <= indexPosition+2 { + return index, resp } - return index, nil + index, _, resp = parseIndexAt(indexPosition + 1) + return index, resp } - if strings.Contains(action, "upsample") { + if action == "upsample" { index, resp := parseIndex() if resp != nil { return resp @@ -160,7 +168,7 @@ func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.Midj } func ConvertSimpleChangeParams(content string) *dto.MidjourneyRequest { - split := strings.Split(content, " ") + split := strings.Fields(content) if len(split) != 2 { return nil } @@ -169,13 +177,17 @@ func ConvertSimpleChangeParams(content string) *dto.MidjourneyRequest { changeParams := &dto.MidjourneyRequest{} changeParams.TaskId = split[0] + if action == "r" { + changeParams.Action = "REROLL" + return changeParams + } + if len(action) != 2 { + return nil + } if action[0] == 'u' { changeParams.Action = "UPSCALE" } else if action[0] == 'v' { changeParams.Action = "VARIATION" - } else if action == "r" { - changeParams.Action = "REROLL" - return changeParams } else { return nil } diff --git a/service/midjourney_test.go b/service/midjourney_test.go index 21af2d6e..5eaa0893 100644 --- a/service/midjourney_test.go +++ b/service/midjourney_test.go @@ -92,7 +92,10 @@ func TestCoverPlusActionToNormalActionRejectsMalformedCustomID(t *testing.T) { "MJ::JOB::upsample", "MJ::JOB::upsample::0::task-id", "MJ::JOB::upsample::-1::task-id", + "MJ::JOB::upsample::5::task-id", + "MJ::JOB::unsupported_upsample::1", "MJ::JOB::variation", + "MJ::JOB::variation::5::task-id", "MJ::JOB::unsupported_variation::1", } @@ -121,6 +124,46 @@ func TestCoverPlusActionToNormalActionAcceptsJobUpsampleCustomID(t *testing.T) { require.Equal(t, 2, req.Index) } +func TestConvertSimpleChangeParamsRejectsMalformedActions(t *testing.T) { + tests := []string{ + "task-id ", + "task-id u", + "task-id v", + "task-id u10", + "task-id x1", + } + + for _, content := range tests { + t.Run(content, func(t *testing.T) { + require.NotPanics(t, func() { + require.Nil(t, ConvertSimpleChangeParams(content)) + }) + }) + } +} + +func TestConvertSimpleChangeParamsAcceptsValidActions(t *testing.T) { + tests := []struct { + content string + action string + index int + }{ + {content: "task-id u2", action: "UPSCALE", index: 2}, + {content: "task-id v3", action: "VARIATION", index: 3}, + {content: "task-id r", action: "REROLL", index: 0}, + } + + for _, tt := range tests { + t.Run(tt.content, func(t *testing.T) { + got := ConvertSimpleChangeParams(tt.content) + require.NotNil(t, got) + require.Equal(t, "task-id", got.TaskId) + require.Equal(t, tt.action, got.Action) + require.Equal(t, tt.index, got.Index) + }) + } +} + func TestCoverPlusActionToNormalActionAcceptsLegacyNonJobUpsampleCustomID(t *testing.T) { req := &dto.MidjourneyRequest{CustomId: "MJ::upsample::legacy-field::2::task-id"} @@ -131,6 +174,16 @@ func TestCoverPlusActionToNormalActionAcceptsLegacyNonJobUpsampleCustomID(t *tes require.Equal(t, 2, req.Index) } +func TestCoverPlusActionToNormalActionAcceptsCompactLegacyNonJobUpsampleCustomID(t *testing.T) { + req := &dto.MidjourneyRequest{CustomId: "MJ::upsample::2::task-id"} + + resp := CoverPlusActionToNormalAction(req) + + require.Nil(t, resp) + require.Equal(t, constant.MjActionUpscale, req.Action) + require.Equal(t, 2, req.Index) +} + type midjourneyFailingReader struct{} func (midjourneyFailingReader) Read([]byte) (int, error) { From bb40ae8cff6dcc203da5734cbf5bc9f59a9c591f Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 10 Aug 2026 18:57:48 +0800 Subject: [PATCH 10/10] fix bugs --- controller/misc.go | 12 ++++++++++-- controller/misc_test.go | 29 +++++++++++++++++++++++++++-- model/main.go | 11 ++++++++++- service/midjourney.go | 2 +- service/midjourney_test.go | 19 ++++++++++++++----- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/controller/misc.go b/controller/misc.go index 185e72c8..d7b81929 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -1,10 +1,12 @@ package controller import ( + "context" "errors" "fmt" "net/http" "strings" + "time" "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" @@ -55,7 +57,10 @@ func writeHealthResponse(c *gin.Context, status int, healthStatus string, err er c.JSON(status, response) } -var healthReadyProbeDB = model.PingDBUncached +var ( + healthReadyProbeDB = model.PingDBContext + healthReadyProbeTimeout = 2 * time.Second +) func GetHealth(c *gin.Context) { writeHealthResponse(c, http.StatusOK, "ok", nil) @@ -66,7 +71,10 @@ func GetHealthLive(c *gin.Context) { } func GetHealthReady(c *gin.Context) { - err := healthReadyProbeDB() + ctx, cancel := context.WithTimeout(c.Request.Context(), healthReadyProbeTimeout) + defer cancel() + + err := healthReadyProbeDB(ctx) if err != nil { common.SysError("health ready check failed: " + err.Error()) writeHealthResponse(c, http.StatusServiceUnavailable, "unhealthy", err) diff --git a/controller/misc_test.go b/controller/misc_test.go index a46a44b9..54ef35f4 100644 --- a/controller/misc_test.go +++ b/controller/misc_test.go @@ -1,10 +1,12 @@ package controller import ( + "context" "errors" "net/http" "net/http/httptest" "testing" + "time" "github.com/MAX-API-Next/MAX-API/common" "github.com/gin-gonic/gin" @@ -61,7 +63,7 @@ func TestHealthReadyReturnsServiceUnavailableWhenDatabasePingFails(t *testing.T) gin.SetMode(gin.TestMode) originalProbeDB := healthReadyProbeDB - healthReadyProbeDB = func() error { + healthReadyProbeDB = func(context.Context) error { return errors.New("driver-specific database failure") } t.Cleanup(func() { @@ -82,7 +84,7 @@ func TestHealthReadyUsesUncachedDatabaseProbe(t *testing.T) { originalProbeDB := healthReadyProbeDB probeCalls := 0 - healthReadyProbeDB = func() error { + healthReadyProbeDB = func(context.Context) error { defer func() { probeCalls++ }() @@ -106,3 +108,26 @@ func TestHealthReadyUsesUncachedDatabaseProbe(t *testing.T) { assert.False(t, secondResponse.Success) assert.Equal(t, "unhealthy", secondResponse.Status) } + +func TestHealthReadyTimesOutStalledDatabaseProbe(t *testing.T) { + gin.SetMode(gin.TestMode) + + originalProbeDB := healthReadyProbeDB + originalTimeout := healthReadyProbeTimeout + healthReadyProbeTimeout = time.Millisecond + healthReadyProbeDB = func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + } + t.Cleanup(func() { + healthReadyProbeDB = originalProbeDB + healthReadyProbeTimeout = originalTimeout + }) + + recorder, response := performHealthRequest(t, GetHealthReady, "/health/ready") + + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + assert.False(t, response.Success) + assert.Equal(t, "unhealthy", response.Status) + assert.Equal(t, "database connection failed", response.Message) +} diff --git a/model/main.go b/model/main.go index ad408bd5..e336bd62 100644 --- a/model/main.go +++ b/model/main.go @@ -1,6 +1,7 @@ package model import ( + "context" "database/sql" "errors" "fmt" @@ -1140,13 +1141,21 @@ func PingDB() error { } func PingDBUncached() error { + return PingDBContext(context.Background()) +} + +func PingDBContext(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + sqlDB, err := DB.DB() if err != nil { log.Printf("Error getting sql.DB from GORM: %v", err) return err } - err = sqlDB.Ping() + err = sqlDB.PingContext(ctx) if err != nil { log.Printf("Error pinging DB: %v", err) return err diff --git a/service/midjourney.go b/service/midjourney.go index ef9c1303..cc74e26d 100644 --- a/service/midjourney.go +++ b/service/midjourney.go @@ -117,7 +117,7 @@ func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.Midj } parseIndex := func() (int, *dto.MidjourneyResponse) { index, numeric, resp := parseIndexAt(indexPosition) - if resp == nil || numeric || actionIndex != 1 || len(splits) <= indexPosition+2 { + if resp == nil || numeric || actionIndex != 1 { return index, resp } index, _, resp = parseIndexAt(indexPosition + 1) diff --git a/service/midjourney_test.go b/service/midjourney_test.go index 5eaa0893..2e1130e8 100644 --- a/service/midjourney_test.go +++ b/service/midjourney_test.go @@ -165,13 +165,22 @@ func TestConvertSimpleChangeParamsAcceptsValidActions(t *testing.T) { } func TestCoverPlusActionToNormalActionAcceptsLegacyNonJobUpsampleCustomID(t *testing.T) { - req := &dto.MidjourneyRequest{CustomId: "MJ::upsample::legacy-field::2::task-id"} + tests := []string{ + "MJ::upsample::legacy-field::2::task-id", + "MJ::upsample::legacy-field::2", + } - resp := CoverPlusActionToNormalAction(req) + for _, customID := range tests { + t.Run(customID, func(t *testing.T) { + req := &dto.MidjourneyRequest{CustomId: customID} - require.Nil(t, resp) - require.Equal(t, constant.MjActionUpscale, req.Action) - require.Equal(t, 2, req.Index) + resp := CoverPlusActionToNormalAction(req) + + require.Nil(t, resp) + require.Equal(t, constant.MjActionUpscale, req.Action) + require.Equal(t, 2, req.Index) + }) + } } func TestCoverPlusActionToNormalActionAcceptsCompactLegacyNonJobUpsampleCustomID(t *testing.T) {