| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309 |
- package handler
- import (
- "context"
- "errors"
- "net/http"
- "strconv"
- "strings"
- "time"
- pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
- "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/tidwall/gjson"
- "go.uber.org/zap"
- )
- // AlphaSearch proxies the standalone search endpoint used by Codex Responses Lite.
- func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
- streamStarted := false
- defer h.recoverResponsesPanic(c, &streamStarted)
- setOpenAIClientTransportHTTP(c)
- requestStart := time.Now()
- apiKey, ok := middleware2.GetAPIKeyFromContext(c)
- if !ok || apiKey.Group == nil {
- h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
- return
- }
- if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
- h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI and Composite groups")
- return
- }
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
- return
- }
- reqLog := requestLogger(
- c,
- "handler.openai_gateway.alpha_search",
- zap.Int64("user_id", subject.UserID),
- zap.Int64("api_key_id", apiKey.ID),
- zap.Any("group_id", apiKey.GroupID),
- )
- if !h.ensureResponsesDependencies(c, reqLog) {
- return
- }
- body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
- if err != nil {
- if maxErr, ok := extractMaxBytesError(err); ok {
- h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
- return
- }
- h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
- return
- }
- if len(body) == 0 {
- h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
- return
- }
- if !gjson.ValidBytes(body) {
- logRequestBodyParseFailure(reqLog, body, nil)
- h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
- return
- }
- modelResult := gjson.GetBytes(body, "model")
- if !modelResult.Exists() || modelResult.Type != gjson.String || strings.TrimSpace(modelResult.String()) == "" {
- h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
- return
- }
- requestedModel := strings.TrimSpace(modelResult.String())
- if !compositeTargetPlatformAllowed(c, apiKey, requestedModel, service.PlatformOpenAI) {
- h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search only supports OpenAI models for Composite groups")
- return
- }
- reqLog = reqLog.With(zap.String("model", requestedModel))
- setOpsRequestContext(c, requestedModel, false)
- setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
- if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, "openai_alpha_search", requestedModel, body); decision != nil && !decision.AllowNextStage {
- h.openAISecurityAuditError(c, decision)
- return
- }
- channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, requestedModel)
- forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody)
- subscription, _ := middleware2.GetSubscriptionFromContext(c)
- service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
- userRelease, acquired := h.acquireResponsesUserSlot(c, subject.UserID, subject.Concurrency, false, &streamStarted, reqLog)
- if !acquired {
- return
- }
- if userRelease != nil {
- defer userRelease()
- }
- if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
- status, code, message, retryAfter := billingErrorDetails(err)
- if retryAfter > 0 {
- c.Header("Retry-After", strconv.Itoa(retryAfter))
- }
- h.errorResponse(c, status, code, message)
- return
- }
- searchID := strings.TrimSpace(gjson.GetBytes(body, "id").String())
- sessionHash := h.gatewayService.GenerateSessionHashWithFallback(c, nil, searchID)
- profitVetoCount := 0
- failedAccountIDs := make(map[int64]struct{})
- sameAccountRetryCount := make(map[int64]int)
- var lastFailoverErr *service.UpstreamFailoverError
- switchCount := 0
- var oauth429FailoverState service.OpenAIOAuth429FailoverState
- routingStart := time.Now()
- // 分组利润控制:alpha search 文本入口请求级装门并固定 pricingAt
- //(记录路径经 service.OpenAIPricingAtFromContext 从请求 ctx 回读)。
- asPricingCtx, _ := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
- c.Request = c.Request.WithContext(asPricingCtx)
- for {
- selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
- c.Request.Context(),
- apiKey.GroupID,
- "",
- sessionHash,
- requestedModel,
- failedAccountIDs,
- service.OpenAIUpstreamTransportHTTPSSE,
- service.OpenAIEndpointCapabilityAlphaSearch,
- false,
- false,
- false,
- service.PlatformOpenAI,
- )
- if err != nil || selection == nil || selection.Account == nil {
- if failoverClientGone(c) {
- reqLog.Info("openai_alpha_search.account_select_aborted_client_disconnected", zap.Error(err))
- return
- }
- if len(failedAccountIDs) == 0 {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestedModel, requestedModel, service.PlatformOpenAI)
- if !cls.ModelNotFound {
- markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
- }
- h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
- return
- }
- if lastFailoverErr != nil {
- h.handleFailoverExhausted(c, lastFailoverErr, false)
- } else {
- h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
- }
- return
- }
- account := selection.Account
- setOpsSelectedAccount(c, account.ID, account.Platform)
- accountRelease, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
- if slotResult == openAISlotAcquireProfitVetoed {
- // 利润终检否决:排除该账号重新选号;否决次数达上限则按无可用账号终止。
- if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
- h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount)
- return
- }
- continue
- }
- if slotResult != openAISlotAcquireOK {
- return
- }
- service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
- writerSizeBeforeForward := c.Writer.Size()
- forwardStart := time.Now()
- var result *service.OpenAIForwardResult
- result, err = func() (*service.OpenAIForwardResult, error) {
- if accountRelease != nil {
- defer accountRelease()
- }
- return h.gatewayService.ForwardAlphaSearch(c.Request.Context(), c, account, forwardBody)
- }()
- service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds())
- if err == nil {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), true, nil)
- if result != nil {
- h.recordAlphaSearchUsage(c, apiKey, account, subscription, channelMapping, requestedModel, body, result, subject.UserID)
- }
- return
- }
- var failoverErr *service.UpstreamFailoverError
- if !errors.As(err, &failoverErr) {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err)
- if c.Writer.Size() == writerSizeBeforeForward {
- h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
- }
- reqLog.Warn("openai_alpha_search.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
- return
- }
- h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err)
- if c.Writer.Size() != writerSizeBeforeForward {
- h.handleFailoverExhausted(c, failoverErr, true)
- return
- }
- if failoverClientGone(c) {
- reqLog.Info("openai_alpha_search.failover_aborted_client_disconnected",
- zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- )
- return
- }
- if failoverErr.RetryableOnSameAccount {
- retryLimit := account.GetPoolModeRetryCount()
- if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) {
- sameAccountRetryCount[account.ID]++
- retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
- reqLog.Warn("openai_alpha_search.same_account_retry",
- zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- zap.Int("retry_limit", retryLimit),
- zap.Int("retry_count", sameAccountRetryCount[account.ID]),
- zap.Duration("retry_delay", retryDelay),
- )
- select {
- case <-c.Request.Context().Done():
- return
- case <-time.After(retryDelay):
- }
- continue
- }
- }
- h.gatewayService.RecordOpenAIAccountSwitch()
- failedAccountIDs[account.ID] = struct{}{}
- lastFailoverErr = failoverErr
- if switchCount >= h.maxAccountSwitches {
- h.handleFailoverExhausted(c, failoverErr, false)
- return
- }
- switchCount++
- if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) {
- h.handleFailoverExhausted(c, failoverErr, false)
- return
- }
- reqLog.Warn("openai_alpha_search.upstream_failover_switching",
- zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- zap.Int("switch_count", switchCount),
- )
- }
- }
- // recordAlphaSearchUsage 为一次成功的 alpha/search 网页搜索落按次计费用量行
- // (上游不返回 usage 字段,按 WebSearchCalls 走分组单价 × 倍率的按次口径)。
- // 与 images 一致使用 mandatory 池提交,池满时同步兜底执行,保证扣费不丢。
- func (h *OpenAIGatewayHandler) recordAlphaSearchUsage(
- c *gin.Context,
- apiKey *service.APIKey,
- account *service.Account,
- subscription *service.UserSubscription,
- channelMapping service.ChannelMappingResult,
- requestedModel string,
- body []byte,
- result *service.OpenAIForwardResult,
- userID int64,
- ) {
- userAgent := c.GetHeader("User-Agent")
- clientIP := ip.GetClientIP(c)
- sessionID := service.ExtractClientSessionID(c)
- requestPayloadHash := service.HashUsageRequestPayload(body)
- inboundEndpoint := GetInboundEndpoint(c)
- upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
- quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
- h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
- if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
- Result: result,
- APIKey: apiKey,
- User: apiKey.User,
- Account: account,
- Subscription: subscription,
- InboundEndpoint: inboundEndpoint,
- UpstreamEndpoint: upstreamEndpoint,
- UserAgent: userAgent,
- IPAddress: clientIP,
- RequestPayloadHash: requestPayloadHash,
- APIKeyService: h.apiKeyService,
- QuotaPlatform: quotaPlatform,
- SessionID: sessionID,
- ChannelUsageFields: channelMapping.ToUsageFields(requestedModel, result.UpstreamModel),
- PricingAt: service.OpenAIPricingAtFromContext(c.Request.Context()),
- }); err != nil {
- logger.L().With(
- zap.String("component", "handler.openai_gateway.alpha_search"),
- zap.Int64("user_id", userID),
- zap.Int64("api_key_id", apiKey.ID),
- zap.Any("group_id", apiKey.GroupID),
- zap.String("model", requestedModel),
- zap.Int64("account_id", account.ID),
- ).Error("openai_alpha_search.record_usage_failed", zap.Error(err))
- }
- })
- }
|