diff --git a/constant/codex.go b/constant/codex.go new file mode 100644 index 000000000000..11653a0308f4 --- /dev/null +++ b/constant/codex.go @@ -0,0 +1,5 @@ +package constant + +const CodexSearchPath = "/v1/alpha/search" + +const ContextKeyCodexSearchBillingFallback ContextKey = "codex_search_billing_fallback" diff --git a/controller/relay.go b/controller/relay.go index 6e91ccb60506..d11695d83763 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -49,12 +49,49 @@ func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIErro err = relay.EmbeddingHelper(c, info) case relayconstant.RelayModeResponses, relayconstant.RelayModeResponsesCompact: err = relay.ResponsesHelper(c, info) + case relayconstant.RelayModeCodexSearch: + err = relay.CodexSearchHelper(c, info) default: err = relay.TextHelper(c, info) } return err } +func prepareCodexSearchAttemptBilling(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { + priceData, err := helper.CodexSearchPriceHelper(c, info) + if err != nil { + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest), types.ErrOptionWithSkipRetry()) + } + if priceData.FreeModel { + return nil + } + if info.Billing == nil { + return service.PreConsumeBilling(c, priceData.QuotaToPreConsume, info) + } + if err := info.Billing.Reserve(priceData.QuotaToPreConsume); err != nil { + return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry()) + } + return nil +} + +func settleCodexSearchBilling(c *gin.Context, info *relaycommon.RelayInfo) { + if err := service.SettleBilling(c, info, info.PriceData.Quota); err == nil { + return + } else { + common.SysError("settle Codex search billing error: " + err.Error()) + } + + if info.Billing == nil || !info.Billing.NeedsRefund() { + return + } + chargedQuota := info.Billing.GetPreConsumedQuota() + info.PriceData.Quota = chargedQuota + common.SetContextKey(c, constant.ContextKeyCodexSearchBillingFallback, true) + if err := service.SettleBilling(c, info, chargedQuota); err != nil { + common.SysError("finalize Codex search reserved billing error: " + err.Error()) + } +} + func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { var err *types.NewAPIError if strings.Contains(c.Request.URL.Path, "embed") { @@ -122,6 +159,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed) return } + if relayFormat == types.RelayFormatCodexSearch && c.GetInt("channel_type") != constant.ChannelTypeCodex { + newAPIError = types.NewErrorWithStatusCode( + errors.New("standalone search is only supported by Codex channels"), + types.ErrorCodeInvalidRequest, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + return + } needSensitiveCheck := setting.ShouldCheckPromptSensitive() needCountToken := constant.CountToken @@ -150,7 +196,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { relayInfo.SetEstimatePromptTokens(tokens) - priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta) + var priceData types.PriceData + if relayFormat == types.RelayFormatCodexSearch { + priceData, err = helper.CodexSearchPriceHelper(c, relayInfo) + } else { + priceData, err = helper.ModelPriceHelper(c, relayInfo, tokens, meta) + } if err != nil { newAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)) return @@ -210,6 +261,13 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { } c.Request.Body = io.NopCloser(bodyStorage) + if relayFormat == types.RelayFormatCodexSearch { + newAPIError = prepareCodexSearchAttemptBilling(c, relayInfo) + if newAPIError != nil { + break + } + } + switch relayFormat { case types.RelayFormatOpenAIRealtime: newAPIError = relay.WssHelper(c, relayInfo) @@ -223,6 +281,10 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { if newAPIError == nil { relayInfo.LastError = nil + if relayFormat == types.RelayFormatCodexSearch { + settleCodexSearchBilling(c, relayInfo) + service.LogCodexSearchConsumption(c, relayInfo) + } return } diff --git a/dto/codex_search.go b/dto/codex_search.go new file mode 100644 index 000000000000..97863b5304a7 --- /dev/null +++ b/dto/codex_search.go @@ -0,0 +1,23 @@ +package dto + +import "github.com/QuantumNous/new-api/types" + +// CodexSearchRequest contains only fields owned by the gateway. The complete +// alpha search payload is forwarded from the stored request body unchanged. +type CodexSearchRequest struct { + BaseRequest + Model string `json:"model"` + MaxOutputTokens *uint `json:"max_output_tokens,omitempty"` +} + +func (r *CodexSearchRequest) GetTokenCountMeta() *types.TokenCountMeta { + meta := &types.TokenCountMeta{TokenType: types.TokenTypeTokenizer} + if r.MaxOutputTokens != nil { + meta.MaxTokens = int(*r.MaxOutputTokens) + } + return meta +} + +func (r *CodexSearchRequest) SetModelName(modelName string) { + r.Model = modelName +} diff --git a/middleware/distributor.go b/middleware/distributor.go index 4234011c9f7c..d07e302937f9 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -105,7 +105,7 @@ func Distribute() func(c *gin.Context) { affinityUsable := false preferred, err := model.CacheGetChannel(preferredChannelID) if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled && - channelSupportsRequestPath(preferred, c.Request.URL.Path, modelRequest.Model) { + model.ChannelSupportsRequestPath(preferred, c.Request.URL.Path, modelRequest.Model) { if usingGroup == "auto" { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) autoGroups := service.GetUserAutoGroup(userGroup) @@ -169,20 +169,6 @@ func Distribute() func(c *gin.Context) { } } -// channelSupportsRequestPath reports whether a channel can serve the request path. -// Only Advanced Custom (type 58) channels are path-checked; all other channel types -// always pass. A type-58 channel is usable only when one of its routes matches. -func channelSupportsRequestPath(channel *model.Channel, requestPath string, requestModel string) bool { - if channel == nil { - return false - } - if channel.Type != constant.ChannelTypeAdvancedCustom { - return true - } - config := channel.GetOtherSettings().AdvancedCustom - return config != nil && config.SupportsPathForModel(requestPath, requestModel) -} - // getModelFromRequest 从请求中读取模型信息 // 根据 Content-Type 自动处理: // - application/json diff --git a/model/ability.go b/model/ability.go index e67b28301e02..e46ceed79e51 100644 --- a/model/ability.go +++ b/model/ability.go @@ -8,7 +8,6 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" - "github.com/QuantumNous/new-api/dto" "github.com/samber/lo" "gorm.io/gorm" @@ -146,11 +145,8 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha return &channel, err } -// filterAbilitiesByRequestPathAndModel restricts candidates by request path and -// model for the DB (non-memory-cache) selection path. Only Advanced Custom -// (type 58) channels are path-checked: kept only when one of their routes matches -// requestPath and model; all other channel types always pass. When requestPath is -// empty, filtering is skipped. +// filterAbilitiesByRequestPathAndModel applies the same path contract as the +// in-memory channel selector to the DB selection path. func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath string, model string) []Ability { if requestPath == "" || len(abilities) == 0 { return abilities @@ -168,25 +164,28 @@ func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath strin var channels []*Channel if err := DB.Where("id IN ?", channelIds).Find(&channels).Error; err != nil { - // On error, fall back to unfiltered candidates to avoid blocking selection + if requestPath == constant.CodexSearchPath { + return nil + } + // On error, preserve the legacy fail-open behavior for unrelated paths. return abilities } - advancedConfigs := make(map[int]*dto.AdvancedCustomConfig) + channelsByID := make(map[int]*Channel, len(channels)) for _, channel := range channels { - if channel.Type == constant.ChannelTypeAdvancedCustom { - advancedConfigs[channel.Id] = channel.GetOtherSettings().AdvancedCustom - } + channelsByID[channel.Id] = channel } filtered := make([]Ability, 0, len(abilities)) for _, ability := range abilities { - config, isAdvancedCustom := advancedConfigs[ability.ChannelId] - if !isAdvancedCustom { - filtered = append(filtered, ability) + channel, ok := channelsByID[ability.ChannelId] + if !ok { + if requestPath != constant.CodexSearchPath { + filtered = append(filtered, ability) + } continue } - if config != nil && config.SupportsPathForModel(requestPath, model) { + if ChannelSupportsRequestPath(channel, requestPath, model) { filtered = append(filtered, ability) } } diff --git a/model/channel.go b/model/channel.go index dbd1deaef4d4..e9d048a6fe6a 100644 --- a/model/channel.go +++ b/model/channel.go @@ -69,6 +69,23 @@ type ChannelInfo struct { MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` } +// ChannelSupportsRequestPath reports whether a channel can serve a relay path. +// Standalone Codex Search is channel-type-specific; Advanced Custom channels +// remain constrained by their configured route table. +func ChannelSupportsRequestPath(channel *Channel, requestPath string, requestModel string) bool { + if channel == nil { + return false + } + if requestPath == constant.CodexSearchPath { + return channel.Type == constant.ChannelTypeCodex + } + if channel.Type != constant.ChannelTypeAdvancedCustom { + return true + } + config := channel.GetOtherSettings().AdvancedCustom + return config != nil && config.SupportsPathForModel(requestPath, requestModel) +} + type ChannelSortOptions struct { SortBy string SortOrder string diff --git a/model/channel_cache.go b/model/channel_cache.go index 81923017d79c..b49ebe23d18a 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -209,9 +209,8 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat } // filterChannelsByRequestPathAndModel restricts candidates by request path and -// model. Only Advanced Custom (type 58) channels are path-checked: they are kept -// only when one of their configured routes matches requestPath and model. All -// other channel types always pass. When requestPath is empty, filtering is skipped. +// model. Standalone Codex Search only accepts Codex channels. Advanced Custom +// channels are kept only when one of their configured routes matches. // Caller must hold channelSyncLock (read lock). The cached slice is never mutated. func filterChannelsByRequestPathAndModel(channels []int, requestPath string, model string) []int { if requestPath == "" || len(channels) == 0 { @@ -225,8 +224,10 @@ func filterChannelsByRequestPathAndModel(channels []int, requestPath string, mod filtered = append(filtered, channelId) continue } - if channel.Type != constant.ChannelTypeAdvancedCustom { - filtered = append(filtered, channelId) + if requestPath == constant.CodexSearchPath || channel.Type != constant.ChannelTypeAdvancedCustom { + if ChannelSupportsRequestPath(channel, requestPath, model) { + filtered = append(filtered, channelId) + } continue } if config := channel2advancedCustomConfig[channelId]; config != nil && config.SupportsPathForModel(requestPath, model) { diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index ef4d4fa04125..c8203763c96a 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -135,12 +135,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.RelayModeCodexSearch: + path = "/backend-api/codex/alpha/search" + default: + return "", errors.New("codex channel: endpoint not supported") } return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, path, info.ChannelType), nil } diff --git a/relay/codex_search_handler.go b/relay/codex_search_handler.go new file mode 100644 index 000000000000..dd55717f2a77 --- /dev/null +++ b/relay/codex_search_handler.go @@ -0,0 +1,59 @@ +package relay + +import ( + "errors" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + appconstant "github.com/QuantumNous/new-api/constant" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +func CodexSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { + info.InitChannelMeta(c) + if info.ChannelType != appconstant.ChannelTypeCodex { + return types.NewErrorWithStatusCode( + errors.New("standalone search is only supported by Codex channels"), + types.ErrorCodeInvalidRequest, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(errors.New("invalid Codex channel adaptor"), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + + storage, err := common.GetBodyStorage(c) + if err != nil { + return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry()) + } + info.UpstreamRequestBodySize = storage.Size() + + resp, err := adaptor.DoRequest(c, info, common.ReaderOnly(storage)) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + httpResp, ok := resp.(*http.Response) + if !ok || httpResp == nil { + return types.NewError(errors.New("invalid Codex search response"), types.ErrorCodeBadResponse) + } + if httpResp.StatusCode != http.StatusOK { + newAPIError := service.RelayErrorHandler(c.Request.Context(), httpResp, false) + service.ResetStatusCode(newAPIError, c.GetString("status_code_mapping")) + return newAPIError + } + defer service.CloseResponseBodyGracefully(httpResp) + + responseBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.IOCopyBytesGracefully(c, httpResp, responseBody) + return nil +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 9f460ce5c6a7..e89e5ae4a35c 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -416,6 +416,13 @@ func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest) return info } +func GenRelayInfoCodexSearch(c *gin.Context, request *dto.CodexSearchRequest) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayMode = relayconstant.RelayModeCodexSearch + info.RelayFormat = types.RelayFormatCodexSearch + return info +} + func GenRelayInfoGemini(c *gin.Context, request dto.Request) *RelayInfo { info := genBaseRelayInfo(c, request) info.RelayFormat = types.RelayFormatGemini @@ -575,6 +582,12 @@ 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.RelayFormatCodexSearch: + if request, ok := request.(*dto.CodexSearchRequest); ok { + info = GenRelayInfoCodexSearch(c, request) + break + } + err = errors.New("request is not a CodexSearchRequest") case types.RelayFormatTask: info = genBaseRelayInfo(c, nil) info.TaskRelayInfo = &TaskRelayInfo{} diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 256715679213..51591edddd0b 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -3,6 +3,8 @@ package constant import ( "net/http" "strings" + + appconstant "github.com/QuantumNous/new-api/constant" ) const ( @@ -52,6 +54,7 @@ const ( RelayModeGemini RelayModeResponsesCompact + RelayModeCodexSearch ) func Path2RelayMode(path string) int { @@ -76,6 +79,8 @@ func Path2RelayMode(path string) int { relayMode = RelayModeResponsesCompact } else if strings.HasPrefix(path, "/v1/responses") { relayMode = RelayModeResponses + } else if path == appconstant.CodexSearchPath { + relayMode = RelayModeCodexSearch } else if strings.HasPrefix(path, "/v1/audio/speech") { relayMode = RelayModeAudioSpeech } else if strings.HasPrefix(path, "/v1/audio/transcriptions") { diff --git a/relay/helper/max_tokens_bounds_test.go b/relay/helper/max_tokens_bounds_test.go index 7cdcba874d57..c4901532f182 100644 --- a/relay/helper/max_tokens_bounds_test.go +++ b/relay/helper/max_tokens_bounds_test.go @@ -69,4 +69,25 @@ func TestMaxTokensBounds(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "max_output_tokens is invalid") }) + + t.Run("codex search model required", func(t *testing.T) { + c := newJSONContext(t, `{"id":"search-1","commands":{"search_query":[{"q":"test"}]}}`) + _, err := GetAndValidateCodexSearchRequest(c) + require.EqualError(t, err, "model is required") + }) + + t.Run("codex search max_output_tokens overflow rejected", func(t *testing.T) { + c := newJSONContext(t, `{"id":"search-1","model":"gpt-5.4","max_output_tokens":`+hugeN+`}`) + _, err := GetAndValidateCodexSearchRequest(c) + require.Error(t, err) + require.Contains(t, err.Error(), "max_output_tokens is invalid") + }) + + t.Run("codex search normal request accepted", func(t *testing.T) { + c := newJSONContext(t, `{"id":"search-1","model":"gpt-5.4","unknown_alpha_field":true,"max_output_tokens":4096}`) + req, err := GetAndValidateCodexSearchRequest(c) + require.NoError(t, err) + require.Equal(t, "gpt-5.4", req.Model) + require.EqualValues(t, 4096, *req.MaxOutputTokens) + }) } diff --git a/relay/helper/price.go b/relay/helper/price.go index 2e8ebb2d2fc1..a9e8336664c5 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -1,6 +1,7 @@ package helper import ( + "errors" "fmt" "strings" @@ -251,6 +252,39 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types return priceData, nil } +// CodexSearchPriceHelper prices one standalone search request using the +// model-aware web_search tool price, which is configured in dollars per 1K calls. +func CodexSearchPriceHelper(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) { + groupRatioInfo := HandleGroupRatio(c, info) + pricePerCall := operation_setting.GetToolPriceForModel("web_search", info.OriginModelName) / 1000 + if pricePerCall < 0 { + return types.PriceData{}, errors.New("web_search price cannot be negative") + } + + quota, clamp := common.QuotaRoundChecked(pricePerCall * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + if clamp != nil { + info.QuotaClamp = clamp + return types.PriceData{}, clamp + } + + freeModel := false + if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume && + (pricePerCall == 0 || groupRatioInfo.GroupRatio == 0) { + freeModel = true + } + + priceData := types.PriceData{ + FreeModel: freeModel, + ModelPrice: pricePerCall, + UsePrice: true, + Quota: quota, + QuotaToPreConsume: quota, + GroupRatioInfo: groupRatioInfo, + } + info.PriceData = priceData + return priceData, nil +} + func HasModelBillingConfig(modelName string) bool { if _, ok := ratio_setting.GetModelPrice(modelName, false); ok { return true diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go index ef396146a31c..20d8ac061ab1 100644 --- a/relay/helper/price_test.go +++ b/relay/helper/price_test.go @@ -10,6 +10,7 @@ import ( relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/setting/billing_setting" "github.com/QuantumNous/new-api/setting/config" + "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -64,6 +65,59 @@ func TestModelPriceHelperTieredUsesPreloadedRequestInput(t *testing.T) { require.Equal(t, common.QuotaPerUnit, info.TieredBillingSnapshot.QuotaPerUnit) } +func TestCodexSearchPriceHelperUsesModelAwareWebSearchPricePerCall(t *testing.T) { + gin.SetMode(gin.TestMode) + + saved := map[string]string{} + require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error { + saved[key] = value + return nil + })) + t.Cleanup(func() { + require.NoError(t, config.GlobalConfig.LoadFromDB(saved)) + operation_setting.RebuildToolPriceIndex() + }) + + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "tool_price_setting.prices": `{"web_search":20,"web_search:gpt-5*":30}`, + "group_ratio_setting.group_ratio": `{"default":1.5}`, + })) + operation_setting.RebuildToolPriceIndex() + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + OriginModelName: "gpt-5.4", + UserGroup: "default", + UsingGroup: "default", + } + + priceData, err := CodexSearchPriceHelper(c, info) + + require.NoError(t, err) + require.Equal(t, 0.03, priceData.ModelPrice) + require.Equal(t, 22500, priceData.QuotaToPreConsume) + require.Equal(t, 22500, priceData.Quota) + require.True(t, priceData.UsePrice) + require.Equal(t, priceData, info.PriceData) + + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "tool_price_setting.prices": `{"web_search":-1}`, + })) + operation_setting.RebuildToolPriceIndex() + _, err = CodexSearchPriceHelper(c, info) + require.EqualError(t, err, "web_search price cannot be negative") + + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "tool_price_setting.prices": `{"web_search":1e308}`, + })) + operation_setting.RebuildToolPriceIndex() + info.QuotaClamp = nil + _, err = CodexSearchPriceHelper(c, info) + require.Error(t, err) + require.NotNil(t, info.QuotaClamp) + require.Equal(t, common.QuotaClampOverflow, info.QuotaClamp.Kind) +} + func TestModelPriceHelperTieredPreConsumeMaxTokensFallback(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index afbd59ff9680..736ddd218a35 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -38,6 +38,8 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt request, err = GetAndValidateResponsesRequest(c) case types.RelayFormatOpenAIResponsesCompaction: request, err = GetAndValidateResponsesCompactionRequest(c) + case types.RelayFormatCodexSearch: + request, err = GetAndValidateCodexSearchRequest(c) case types.RelayFormatOpenAIImage: request, err = GetAndValidOpenAIImageRequest(c, relayMode) @@ -146,6 +148,20 @@ func GetAndValidateResponsesRequest(c *gin.Context) (*dto.OpenAIResponsesRequest return request, nil } +func GetAndValidateCodexSearchRequest(c *gin.Context) (*dto.CodexSearchRequest, error) { + request := &dto.CodexSearchRequest{} + if err := common.UnmarshalBodyReusable(c, request); err != nil { + return nil, err + } + if request.Model == "" { + return nil, errors.New("model is required") + } + if exceedsMaxTokensLimit(request.MaxOutputTokens) { + return nil, errors.New("max_output_tokens is invalid") + } + return request, nil +} + func GetAndValidateResponsesCompactionRequest(c *gin.Context) (*dto.OpenAIResponsesCompactionRequest, error) { request := &dto.OpenAIResponsesCompactionRequest{} if err := common.UnmarshalBodyReusable(c, request); err != nil { diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad7fd6..5222961dfa88 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.RelayFormatCodexSearch) + }) // image related routes httpRouter.POST("/edits", func(c *gin.Context) { diff --git a/service/codex_search_billing.go b/service/codex_search_billing.go new file mode 100644 index 000000000000..26db89a19741 --- /dev/null +++ b/service/codex_search_billing.go @@ -0,0 +1,64 @@ +package service + +import ( + "fmt" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" +) + +func LogCodexSearchConsumption(c *gin.Context, info *relaycommon.RelayInfo) { + pricePer1K := info.PriceData.ModelPrice * 1000 + other := map[string]interface{}{ + "request_path": c.Request.URL.Path, + "web_search": true, + "web_search_call_count": 1, + } + billingFallback := common.GetContextKeyBool(c, constant.ContextKeyCodexSearchBillingFallback) + if billingFallback { + other["billing_settlement_fallback"] = true + } else { + other["web_search_price"] = pricePer1K + other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio + if info.PriceData.GroupRatioInfo.HasSpecialRatio { + other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio + } + } + if info.IsModelMapped { + other["is_model_mapped"] = true + other["upstream_model_name"] = info.UpstreamModelName + } + attachQuotaSaturation(c, info, other) + + useTimeSeconds := 0 + if !info.StartTime.IsZero() { + useTimeSeconds = int(time.Since(info.StartTime).Seconds()) + } + logContent := fmt.Sprintf("Standalone Web Search 调用 1 次,按次计费 %s", logger.FormatQuota(info.PriceData.Quota)) + if billingFallback { + logContent = fmt.Sprintf("Standalone Web Search 调用 1 次,结算调整失败,按实际预留额度计费 %s", logger.FormatQuota(info.PriceData.Quota)) + } + model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{ + ChannelId: info.ChannelId, + ModelName: info.OriginModelName, + TokenName: c.GetString("token_name"), + Quota: info.PriceData.Quota, + Content: logContent, + TokenId: info.TokenId, + UseTimeSeconds: useTimeSeconds, + Group: info.UsingGroup, + Other: other, + }) + model.UpdateUserUsedQuotaAndRequestCount(info.UserId, info.PriceData.Quota) + model.UpdateChannelUsedQuota(info.ChannelId, info.PriceData.Quota) + gopool.Go(func() { + perfmetrics.RecordRelaySample(info, true, 0) + }) +} diff --git a/types/relay_format.go b/types/relay_format.go index 9b4c86f24938..02de99b67248 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" + RelayFormatCodexSearch = "codex_search" RelayFormatOpenAIAudio = "openai_audio" RelayFormatOpenAIImage = "openai_image" RelayFormatOpenAIRealtime = "openai_realtime"