diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go index d612414c..ad1c9200 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"}, @@ -32,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/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/billing.go b/controller/billing.go index 8870f5a4..9ae5c9bb 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,41 @@ 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 { + 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 { + 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{ + Message: message, + Type: errorType, + }, + }) +} + func GetSubscription(c *gin.Context) { var remainQuota int64 var usedQuota int64 @@ -17,45 +56,39 @@ func GetSubscription(c *gin.Context) { if common.DisplayTokenStatEnabled { tokenId := c.GetInt("token_id") token, err = model.GetTokenById(tokenId) + if err != nil { + 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 remainQuota = int64(token.RemainQuota) usedQuota = int64(token.UsedQuota) } else { userId := c.GetInt("id") remainQuota, err = model.GetUserQuota(userId, false) + if err != nil { + 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 { + common.SysError(fmt.Sprintf("billing subscription used-quota lookup failed: user_id=%d: %v", userId, err)) + writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "upstream_error") + return + } } if expiredTime <= 0 { expiredTime = 0 } + quota := remainQuota + usedQuota + amount, err := billingSubscriptionDisplayAmount(quota, token != nil && token.UnlimitedQuota) 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 - } subscription := OpenAISubscriptionResponse{ Object: "billing_subscription", HasPaymentMethod: true, @@ -64,7 +97,7 @@ func GetSubscription(c *gin.Context) { SystemHardLimitUSD: amount, AccessUntil: expiredTime, } - c.JSON(200, subscription) + c.JSON(http.StatusOK, subscription) return } @@ -75,34 +108,31 @@ func GetUsage(c *gin.Context) { if common.DisplayTokenStatEnabled { tokenId := c.GetInt("token_id") token, err = model.GetTokenById(tokenId) + if err != nil { + 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) } else { userId := c.GetInt("id") quota, err = model.GetUserUsedQuota(userId) + if err != nil { + common.SysError(fmt.Sprintf("billing usage quota lookup failed: user_id=%d: %v", userId, err)) + writeBillingOpenAIError(c, http.StatusOK, billingLookupErrorMessage, "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..b4e7f057 --- /dev/null +++ b/controller/billing_test.go @@ -0,0 +1,118 @@ +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) +} + +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/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/misc.go b/controller/misc.go index 95248c9e..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" @@ -40,6 +42,47 @@ 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 ( + healthReadyProbeDB = model.PingDBContext + healthReadyProbeTimeout = 2 * time.Second +) + +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) { + 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) + 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..54ef35f4 --- /dev/null +++ b/controller/misc_test.go @@ -0,0 +1,133 @@ +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" + "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) + + originalProbeDB := healthReadyProbeDB + healthReadyProbeDB = func(context.Context) error { + return errors.New("driver-specific database failure") + } + t.Cleanup(func() { + healthReadyProbeDB = originalProbeDB + }) + + 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") +} + +func TestHealthReadyUsesUncachedDatabaseProbe(t *testing.T) { + gin.SetMode(gin.TestMode) + + originalProbeDB := healthReadyProbeDB + probeCalls := 0 + healthReadyProbeDB = func(context.Context) 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) +} + +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/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/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..5db62d9e 100644 --- a/model/channel_selection_test.go +++ b/model/channel_selection_test.go @@ -159,6 +159,69 @@ 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" + 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() { + 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")) + 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() + channel, err = GetRandomSatisfiedChannel(groupName, modelName, 0, "/v1/alpha/search") + 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) { clearPreferredOwnerTables(t) t.Cleanup(func() { diff --git a/model/main.go b/model/main.go index 34967397..e336bd62 100644 --- a/model/main.go +++ b/model/main.go @@ -1,6 +1,7 @@ package model import ( + "context" "database/sql" "errors" "fmt" @@ -1130,19 +1131,35 @@ 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 { + 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 } - lastPingTime = time.Now() - common.SysLog("Database pinged successfully") return nil } 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/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..60431c20 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,109 @@ 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) + + 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) + 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, deleteStartedAt) + assert.LessOrEqual(t, storedSubscription.EndTime, deleteFinishedAt) +} + +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) + + 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) + 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, deleteStartedAt) + assert.LessOrEqual(t, storedSubscription.EndTime, deleteFinishedAt) +} diff --git a/relay/alpha_search_handler.go b/relay/alpha_search_handler.go new file mode 100644 index 00000000..869941df --- /dev/null +++ b/relay/alpha_search_handler.go @@ -0,0 +1,132 @@ +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, "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()) + } + 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 + } + + recordAlphaSearchUsage(info) + service.PostTextConsumeQuota(c, info, &dto.Usage{}, nil) + + 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()) + } + + return nil +} + +func recordAlphaSearchUsage(info *relaycommon.RelayInfo) { + 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, + } +} + +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..cf45bb5c --- /dev/null +++ b/relay/alpha_search_handler_test.go @@ -0,0 +1,160 @@ +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", + "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) +} + +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/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/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/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/midjourney.go b/service/midjourney.go index 79e5e902..cc74e26d 100644 --- a/service/midjourney.go +++ b/service/midjourney.go @@ -84,28 +84,58 @@ 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 + 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[position]) if err != nil { - return 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") + } + return index, true, nil + } + parseIndex := func() (int, *dto.MidjourneyResponse) { + index, numeric, resp := parseIndexAt(indexPosition) + if resp == nil || numeric || actionIndex != 1 { + return index, resp + } + index, _, resp = parseIndexAt(indexPosition + 1) + return index, resp + } + if 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 @@ -113,6 +143,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 @@ -136,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 } @@ -145,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 d7e3f706..2e1130e8 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,115 @@ 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::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", + } + + 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) +} + +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) { + tests := []string{ + "MJ::upsample::legacy-field::2::task-id", + "MJ::upsample::legacy-field::2", + } + + for _, customID := range tests { + t.Run(customID, func(t *testing.T) { + req := &dto.MidjourneyRequest{CustomId: customID} + + 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) { + 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) { diff --git a/service/text_quota.go b/service/text_quota.go index 4b33fe28..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,6 +58,16 @@ type textQuotaSummary struct { ToolCallSurchargeQuota decimal.Decimal } +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 { if summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0 { splitCacheWriteTokens := summary.CacheCreationTokens5m + summary.CacheCreationTokens1h @@ -163,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) @@ -292,7 +309,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 +316,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 +326,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(relayInfo) { summary.Quota = 0 } else if !ratio.IsZero() && summary.Quota == 0 { summary.Quota = 1 @@ -411,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.TotalTokens != 0 - if summary.TotalTokens == 0 { + 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 5890a4e9..c247e07b 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -6,12 +6,16 @@ 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" "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" @@ -763,6 +767,102 @@ 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 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()) + + 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() @@ -836,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/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/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/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/public/favicon.ico b/web/default/public/favicon.ico index ab5f17bc..a6bba891 100644 Binary files a/web/default/public/favicon.ico and b/web/default/public/favicon.ico differ 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-samples.ts b/web/default/src/features/pricing/components/model-details-api-samples.ts new file mode 100644 index 00000000..63caef6c --- /dev/null +++ b/web/default/src/features/pricing/components/model-details-api-samples.ts @@ -0,0 +1,128 @@ +export type ModelDetailsSampleLanguage = + | 'curl' + | 'python' + | 'typescript' + | 'javascript' + +type ModelDetailsSampleContext = { + baseUrl: string + apiKeyEnv: string + modelName: string + endpointPath: string +} + +export function buildResponsesCompactSample( + lang: ModelDetailsSampleLanguage, + ctx: ModelDetailsSampleContext +): 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') +} + +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}),`, + `})`, + '', + `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 new file mode 100644 index 00000000..19f0c6ed --- /dev/null +++ b/web/default/src/features/pricing/components/model-details-api.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { + buildAlphaSearchSample, + 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/) + }) + } + + 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/) + 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\}`\)/) + } + }) + } +}) 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..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,6 +54,10 @@ import { import { replaceModelInPath } from '../lib/model-helpers' import { inferApiInfo } from '../lib/model-metadata' import type { PricingModel } from '../types' +import { + buildAlphaSearchSample, + buildResponsesCompactSample, +} from './model-details-api-samples' // --------------------------------------------------------------------------- // Code-sample registry @@ -434,6 +438,10 @@ 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) 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 生态都保持可替换。",