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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions constant/codex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package constant

const CodexSearchPath = "/v1/alpha/search"

const ContextKeyCodexSearchBillingFallback ContextKey = "codex_search_billing_fallback"
64 changes: 63 additions & 1 deletion controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down
23 changes: 23 additions & 0 deletions dto/codex_search.go
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 1 addition & 15 deletions middleware/distributor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
29 changes: 14 additions & 15 deletions model/ability.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
Expand Down
17 changes: 17 additions & 0 deletions model/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions model/channel_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
14 changes: 9 additions & 5 deletions relay/channel/codex/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
59 changes: 59 additions & 0 deletions relay/codex_search_handler.go
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions relay/common/relay_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}
Expand Down
Loading