openai_alpha_search.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. package handler
  2. import (
  3. "context"
  4. "errors"
  5. "net/http"
  6. "strconv"
  7. "strings"
  8. "time"
  9. pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  12. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. "github.com/gin-gonic/gin"
  15. "github.com/tidwall/gjson"
  16. "go.uber.org/zap"
  17. )
  18. // AlphaSearch proxies the standalone search endpoint used by Codex Responses Lite.
  19. func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
  20. streamStarted := false
  21. defer h.recoverResponsesPanic(c, &streamStarted)
  22. setOpenAIClientTransportHTTP(c)
  23. requestStart := time.Now()
  24. apiKey, ok := middleware2.GetAPIKeyFromContext(c)
  25. if !ok || apiKey.Group == nil {
  26. h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
  27. return
  28. }
  29. if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
  30. h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI and Composite groups")
  31. return
  32. }
  33. subject, ok := middleware2.GetAuthSubjectFromContext(c)
  34. if !ok {
  35. h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
  36. return
  37. }
  38. reqLog := requestLogger(
  39. c,
  40. "handler.openai_gateway.alpha_search",
  41. zap.Int64("user_id", subject.UserID),
  42. zap.Int64("api_key_id", apiKey.ID),
  43. zap.Any("group_id", apiKey.GroupID),
  44. )
  45. if !h.ensureResponsesDependencies(c, reqLog) {
  46. return
  47. }
  48. body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
  49. if err != nil {
  50. if maxErr, ok := extractMaxBytesError(err); ok {
  51. h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
  52. return
  53. }
  54. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
  55. return
  56. }
  57. if len(body) == 0 {
  58. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
  59. return
  60. }
  61. if !gjson.ValidBytes(body) {
  62. logRequestBodyParseFailure(reqLog, body, nil)
  63. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
  64. return
  65. }
  66. modelResult := gjson.GetBytes(body, "model")
  67. if !modelResult.Exists() || modelResult.Type != gjson.String || strings.TrimSpace(modelResult.String()) == "" {
  68. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
  69. return
  70. }
  71. requestedModel := strings.TrimSpace(modelResult.String())
  72. if !compositeTargetPlatformAllowed(c, apiKey, requestedModel, service.PlatformOpenAI) {
  73. h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search only supports OpenAI models for Composite groups")
  74. return
  75. }
  76. reqLog = reqLog.With(zap.String("model", requestedModel))
  77. setOpsRequestContext(c, requestedModel, false)
  78. setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
  79. if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, "openai_alpha_search", requestedModel, body); decision != nil && !decision.AllowNextStage {
  80. h.openAISecurityAuditError(c, decision)
  81. return
  82. }
  83. channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, requestedModel)
  84. forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody)
  85. subscription, _ := middleware2.GetSubscriptionFromContext(c)
  86. service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
  87. userRelease, acquired := h.acquireResponsesUserSlot(c, subject.UserID, subject.Concurrency, false, &streamStarted, reqLog)
  88. if !acquired {
  89. return
  90. }
  91. if userRelease != nil {
  92. defer userRelease()
  93. }
  94. if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
  95. status, code, message, retryAfter := billingErrorDetails(err)
  96. if retryAfter > 0 {
  97. c.Header("Retry-After", strconv.Itoa(retryAfter))
  98. }
  99. h.errorResponse(c, status, code, message)
  100. return
  101. }
  102. searchID := strings.TrimSpace(gjson.GetBytes(body, "id").String())
  103. sessionHash := h.gatewayService.GenerateSessionHashWithFallback(c, nil, searchID)
  104. profitVetoCount := 0
  105. failedAccountIDs := make(map[int64]struct{})
  106. sameAccountRetryCount := make(map[int64]int)
  107. var lastFailoverErr *service.UpstreamFailoverError
  108. switchCount := 0
  109. var oauth429FailoverState service.OpenAIOAuth429FailoverState
  110. routingStart := time.Now()
  111. // 分组利润控制:alpha search 文本入口请求级装门并固定 pricingAt
  112. //(记录路径经 service.OpenAIPricingAtFromContext 从请求 ctx 回读)。
  113. asPricingCtx, _ := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
  114. c.Request = c.Request.WithContext(asPricingCtx)
  115. for {
  116. selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
  117. c.Request.Context(),
  118. apiKey.GroupID,
  119. "",
  120. sessionHash,
  121. requestedModel,
  122. failedAccountIDs,
  123. service.OpenAIUpstreamTransportHTTPSSE,
  124. service.OpenAIEndpointCapabilityAlphaSearch,
  125. false,
  126. false,
  127. false,
  128. service.PlatformOpenAI,
  129. )
  130. if err != nil || selection == nil || selection.Account == nil {
  131. if failoverClientGone(c) {
  132. reqLog.Info("openai_alpha_search.account_select_aborted_client_disconnected", zap.Error(err))
  133. return
  134. }
  135. if len(failedAccountIDs) == 0 {
  136. cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestedModel, requestedModel, service.PlatformOpenAI)
  137. if !cls.ModelNotFound {
  138. markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
  139. }
  140. h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
  141. return
  142. }
  143. if lastFailoverErr != nil {
  144. h.handleFailoverExhausted(c, lastFailoverErr, false)
  145. } else {
  146. h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
  147. }
  148. return
  149. }
  150. account := selection.Account
  151. setOpsSelectedAccount(c, account.ID, account.Platform)
  152. accountRelease, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
  153. if slotResult == openAISlotAcquireProfitVetoed {
  154. // 利润终检否决:排除该账号重新选号;否决次数达上限则按无可用账号终止。
  155. if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
  156. h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount)
  157. return
  158. }
  159. continue
  160. }
  161. if slotResult != openAISlotAcquireOK {
  162. return
  163. }
  164. service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
  165. writerSizeBeforeForward := c.Writer.Size()
  166. forwardStart := time.Now()
  167. var result *service.OpenAIForwardResult
  168. result, err = func() (*service.OpenAIForwardResult, error) {
  169. if accountRelease != nil {
  170. defer accountRelease()
  171. }
  172. return h.gatewayService.ForwardAlphaSearch(c.Request.Context(), c, account, forwardBody)
  173. }()
  174. service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds())
  175. if err == nil {
  176. h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), true, nil)
  177. if result != nil {
  178. h.recordAlphaSearchUsage(c, apiKey, account, subscription, channelMapping, requestedModel, body, result, subject.UserID)
  179. }
  180. return
  181. }
  182. var failoverErr *service.UpstreamFailoverError
  183. if !errors.As(err, &failoverErr) {
  184. h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err)
  185. if c.Writer.Size() == writerSizeBeforeForward {
  186. h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
  187. }
  188. reqLog.Warn("openai_alpha_search.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
  189. return
  190. }
  191. h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err)
  192. if c.Writer.Size() != writerSizeBeforeForward {
  193. h.handleFailoverExhausted(c, failoverErr, true)
  194. return
  195. }
  196. if failoverClientGone(c) {
  197. reqLog.Info("openai_alpha_search.failover_aborted_client_disconnected",
  198. zap.Int64("account_id", account.ID),
  199. zap.Int("upstream_status", failoverErr.StatusCode),
  200. )
  201. return
  202. }
  203. if failoverErr.RetryableOnSameAccount {
  204. retryLimit := account.GetPoolModeRetryCount()
  205. if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) {
  206. sameAccountRetryCount[account.ID]++
  207. retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
  208. reqLog.Warn("openai_alpha_search.same_account_retry",
  209. zap.Int64("account_id", account.ID),
  210. zap.Int("upstream_status", failoverErr.StatusCode),
  211. zap.Int("retry_limit", retryLimit),
  212. zap.Int("retry_count", sameAccountRetryCount[account.ID]),
  213. zap.Duration("retry_delay", retryDelay),
  214. )
  215. select {
  216. case <-c.Request.Context().Done():
  217. return
  218. case <-time.After(retryDelay):
  219. }
  220. continue
  221. }
  222. }
  223. h.gatewayService.RecordOpenAIAccountSwitch()
  224. failedAccountIDs[account.ID] = struct{}{}
  225. lastFailoverErr = failoverErr
  226. if switchCount >= h.maxAccountSwitches {
  227. h.handleFailoverExhausted(c, failoverErr, false)
  228. return
  229. }
  230. switchCount++
  231. if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) {
  232. h.handleFailoverExhausted(c, failoverErr, false)
  233. return
  234. }
  235. reqLog.Warn("openai_alpha_search.upstream_failover_switching",
  236. zap.Int64("account_id", account.ID),
  237. zap.Int("upstream_status", failoverErr.StatusCode),
  238. zap.Int("switch_count", switchCount),
  239. )
  240. }
  241. }
  242. // recordAlphaSearchUsage 为一次成功的 alpha/search 网页搜索落按次计费用量行
  243. // (上游不返回 usage 字段,按 WebSearchCalls 走分组单价 × 倍率的按次口径)。
  244. // 与 images 一致使用 mandatory 池提交,池满时同步兜底执行,保证扣费不丢。
  245. func (h *OpenAIGatewayHandler) recordAlphaSearchUsage(
  246. c *gin.Context,
  247. apiKey *service.APIKey,
  248. account *service.Account,
  249. subscription *service.UserSubscription,
  250. channelMapping service.ChannelMappingResult,
  251. requestedModel string,
  252. body []byte,
  253. result *service.OpenAIForwardResult,
  254. userID int64,
  255. ) {
  256. userAgent := c.GetHeader("User-Agent")
  257. clientIP := ip.GetClientIP(c)
  258. sessionID := service.ExtractClientSessionID(c)
  259. requestPayloadHash := service.HashUsageRequestPayload(body)
  260. inboundEndpoint := GetInboundEndpoint(c)
  261. upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
  262. quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
  263. h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
  264. if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
  265. Result: result,
  266. APIKey: apiKey,
  267. User: apiKey.User,
  268. Account: account,
  269. Subscription: subscription,
  270. InboundEndpoint: inboundEndpoint,
  271. UpstreamEndpoint: upstreamEndpoint,
  272. UserAgent: userAgent,
  273. IPAddress: clientIP,
  274. RequestPayloadHash: requestPayloadHash,
  275. APIKeyService: h.apiKeyService,
  276. QuotaPlatform: quotaPlatform,
  277. SessionID: sessionID,
  278. ChannelUsageFields: channelMapping.ToUsageFields(requestedModel, result.UpstreamModel),
  279. PricingAt: service.OpenAIPricingAtFromContext(c.Request.Context()),
  280. }); err != nil {
  281. logger.L().With(
  282. zap.String("component", "handler.openai_gateway.alpha_search"),
  283. zap.Int64("user_id", userID),
  284. zap.Int64("api_key_id", apiKey.ID),
  285. zap.Any("group_id", apiKey.GroupID),
  286. zap.String("model", requestedModel),
  287. zap.Int64("account_id", account.ID),
  288. ).Error("openai_alpha_search.record_usage_failed", zap.Error(err))
  289. }
  290. })
  291. }