openai_chat_completions.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. package handler
  2. import (
  3. "context"
  4. "errors"
  5. "net/http"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  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. // ChatCompletions handles OpenAI Chat Completions API requests.
  19. // POST /v1/chat/completions
  20. func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
  21. streamStarted := false
  22. defer h.recoverResponsesPanic(c, &streamStarted)
  23. requestStart := time.Now()
  24. apiKey, ok := middleware2.GetAPIKeyFromContext(c)
  25. if !ok {
  26. h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
  27. return
  28. }
  29. subject, ok := middleware2.GetAuthSubjectFromContext(c)
  30. if !ok {
  31. h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
  32. return
  33. }
  34. reqLog := requestLogger(
  35. c,
  36. "handler.openai_gateway.chat_completions",
  37. zap.Int64("user_id", subject.UserID),
  38. zap.Int64("api_key_id", apiKey.ID),
  39. zap.Any("group_id", apiKey.GroupID),
  40. )
  41. if !h.ensureResponsesDependencies(c, reqLog) {
  42. return
  43. }
  44. body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
  45. if err != nil {
  46. if maxErr, ok := extractMaxBytesError(err); ok {
  47. h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
  48. return
  49. }
  50. logRequestBodyReadFailure(reqLog, c.Request, err)
  51. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
  52. return
  53. }
  54. if len(body) == 0 {
  55. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
  56. return
  57. }
  58. if !gjson.ValidBytes(body) {
  59. logRequestBodyParseFailure(reqLog, body, nil)
  60. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
  61. return
  62. }
  63. modelResult := gjson.GetBytes(body, "model")
  64. if !modelResult.Exists() || modelResult.Type != gjson.String || modelResult.String() == "" {
  65. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
  66. return
  67. }
  68. reqModel := modelResult.String()
  69. ensureCompositeTargetPlatform(c, apiKey, reqModel)
  70. if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) {
  71. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
  72. return
  73. }
  74. if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
  75. respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
  76. return
  77. } else if changed {
  78. body = cappedBody
  79. }
  80. reqStream, ok := parseOpenAICompatibleStream(body)
  81. if !ok {
  82. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
  83. return
  84. }
  85. if _, err := service.ValidateOpenAIServiceTierField(body); err != nil {
  86. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
  87. return
  88. }
  89. if service.IsGPTImageGenerationModel(reqModel) {
  90. h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint")
  91. return
  92. }
  93. reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
  94. setOpsRequestContext(c, reqModel, reqStream)
  95. setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
  96. if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && !decision.AllowNextStage {
  97. h.openAISecurityAuditError(c, decision)
  98. return
  99. }
  100. if h.rejectIfCyberSessionBlocked(c, apiKey, body, reqModel, cyberBlockFormatChat) {
  101. return
  102. }
  103. // 解析渠道级模型映射
  104. channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
  105. forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
  106. if h.errorPassthroughService != nil {
  107. service.BindErrorPassthroughService(c, h.errorPassthroughService)
  108. }
  109. subscription, _ := middleware2.GetSubscriptionFromContext(c)
  110. requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey)
  111. service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
  112. routingStart := time.Now()
  113. userReleaseFunc, acquired := h.acquireResponsesUserSlot(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted, reqLog)
  114. if !acquired {
  115. return
  116. }
  117. if userReleaseFunc != nil {
  118. defer userReleaseFunc()
  119. }
  120. if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
  121. reqLog.Info("openai_chat_completions.billing_eligibility_check_failed", zap.Error(err))
  122. status, code, message, retryAfter := billingErrorDetails(err)
  123. if retryAfter > 0 {
  124. c.Header("Retry-After", strconv.Itoa(retryAfter))
  125. }
  126. h.handleStreamingAwareError(c, status, code, message, streamStarted)
  127. return
  128. }
  129. sessionHash := h.gatewayService.GenerateSessionHash(c, body)
  130. promptCacheKey := h.gatewayService.ExtractSessionID(c, body)
  131. maxAccountSwitches := h.maxAccountSwitches
  132. switchCount := 0
  133. profitVetoCount := 0
  134. failedAccountIDs := make(map[int64]struct{})
  135. sameAccountRetryCount := make(map[int64]int)
  136. var lastFailoverErr *service.UpstreamFailoverError
  137. var oauth429FailoverState service.OpenAIOAuth429FailoverState
  138. // 分组利润控制:chat completions 文本入口请求级装门并固定 pricingAt。
  139. ccPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
  140. c.Request = c.Request.WithContext(ccPricingCtx)
  141. for {
  142. if failoverClientGone(c) {
  143. return
  144. }
  145. reqLog.Debug("openai_chat_completions.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
  146. selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
  147. c.Request.Context(),
  148. apiKey.GroupID,
  149. "",
  150. sessionHash,
  151. forwardModel,
  152. failedAccountIDs,
  153. service.OpenAIUpstreamTransportAny,
  154. service.OpenAIEndpointCapabilityChatCompletions,
  155. false,
  156. false,
  157. true,
  158. requestPlatform,
  159. )
  160. if err != nil {
  161. if failoverClientGone(c) {
  162. reqLog.Info("openai_chat_completions.account_select_aborted_client_disconnected", zap.Error(err))
  163. return
  164. }
  165. reqLog.Warn("openai_chat_completions.account_select_failed",
  166. zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)),
  167. zap.Int("excluded_account_count", len(failedAccountIDs)),
  168. )
  169. if len(failedAccountIDs) == 0 {
  170. cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
  171. cls = classifySelectionFailureError(err, cls)
  172. if !cls.ModelNotFound {
  173. markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
  174. }
  175. h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
  176. return
  177. } else {
  178. if lastFailoverErr != nil {
  179. h.handleFailoverExhausted(c, lastFailoverErr, streamStarted)
  180. } else {
  181. h.handleStreamingAwareError(c, http.StatusBadGateway, "api_error", "Upstream request failed", streamStarted)
  182. }
  183. return
  184. }
  185. }
  186. if selection == nil || selection.Account == nil {
  187. cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
  188. if !cls.ModelNotFound {
  189. markOpsRoutingCapacityLimited(c)
  190. }
  191. h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
  192. return
  193. }
  194. account := selection.Account
  195. sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
  196. reqLog.Debug("openai_chat_completions.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name))
  197. _ = scheduleDecision
  198. setOpsSelectedAccount(c, account.ID, account.Platform)
  199. accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog)
  200. if slotResult == openAISlotAcquireProfitVetoed {
  201. // 利润终检否决:排除该账号重新选号;否决次数达上限则按无可用账号终止。
  202. if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
  203. h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount)
  204. return
  205. }
  206. continue
  207. }
  208. if slotResult != openAISlotAcquireOK {
  209. return
  210. }
  211. service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
  212. forwardStart := time.Now()
  213. forwardBody := body
  214. if channelMapping.Mapped {
  215. forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
  216. }
  217. writerSizeBeforeForward := c.Writer.Size()
  218. result, err := func() (*service.OpenAIForwardResult, error) {
  219. defer func() {
  220. if accountReleaseFunc != nil {
  221. accountReleaseFunc()
  222. }
  223. }()
  224. return h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, promptCacheKey, "")
  225. }()
  226. var cyberBlockBodyChat []byte
  227. if service.GetOpsCyberPolicy(c) != nil {
  228. cyberBlockBodyChat = body
  229. }
  230. h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockBodyChat, clientRequestedUsageFields(c, channelMapping, reqModel, ""), service.HashUsageRequestPayload(body))
  231. forwardDurationMs := time.Since(forwardStart).Milliseconds()
  232. upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey)
  233. responseLatencyMs := forwardDurationMs
  234. if upstreamLatencyMs > 0 && forwardDurationMs > upstreamLatencyMs {
  235. responseLatencyMs = forwardDurationMs - upstreamLatencyMs
  236. }
  237. service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs)
  238. if err == nil && result != nil && result.FirstTokenMs != nil {
  239. service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
  240. }
  241. // #5148 对齐:错误返回携带的部分 result(流中断前上游已计量的 usage)照常
  242. // 入账;failover 错误恒定 result=nil,不会重复计费。
  243. submitChatUsage := func(res *service.OpenAIForwardResult) {
  244. if res == nil {
  245. return
  246. }
  247. stampOpenAIRequestedReasoningEffort(res, c)
  248. userAgent := c.GetHeader("User-Agent")
  249. clientIP := ip.GetClientIP(c)
  250. inboundEndpoint := GetInboundEndpoint(c)
  251. upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res)
  252. quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
  253. sessionID := service.ExtractClientSessionID(c)
  254. cyberBlocked := service.GetOpsCyberPolicy(c) != nil
  255. h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) {
  256. if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
  257. Result: res,
  258. APIKey: apiKey,
  259. User: apiKey.User,
  260. Account: account,
  261. Subscription: subscription,
  262. InboundEndpoint: inboundEndpoint,
  263. UpstreamEndpoint: upstreamEndpoint,
  264. UserAgent: userAgent,
  265. IPAddress: clientIP,
  266. APIKeyService: h.apiKeyService,
  267. QuotaPlatform: quotaPlatform,
  268. SessionID: sessionID,
  269. ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, res.UpstreamModel),
  270. PricingAt: pricingAt,
  271. CyberBlocked: cyberBlocked,
  272. }); err != nil {
  273. logger.L().With(
  274. zap.String("component", "handler.openai_gateway.chat_completions"),
  275. zap.Int64("user_id", subject.UserID),
  276. zap.Int64("api_key_id", apiKey.ID),
  277. zap.Any("group_id", apiKey.GroupID),
  278. zap.String("model", reqModel),
  279. zap.Int64("account_id", account.ID),
  280. ).Error("openai_chat_completions.record_usage_failed", zap.Error(err))
  281. }
  282. })
  283. }
  284. if err != nil {
  285. if result != nil && result.ImageCount > 0 {
  286. reqLog.Warn("openai_chat_completions.forward_partial_error_with_image_result",
  287. zap.Int64("account_id", account.ID),
  288. zap.Int("image_count", result.ImageCount),
  289. zap.Error(err),
  290. )
  291. } else {
  292. var failoverErr *service.UpstreamFailoverError
  293. if errors.As(err, &failoverErr) {
  294. if failoverClientGone(c) {
  295. reqLog.Info("openai_chat_completions.failover_aborted_client_disconnected",
  296. zap.Int64("account_id", account.ID),
  297. zap.Int("upstream_status", failoverErr.StatusCode),
  298. )
  299. return
  300. }
  301. if c.Writer.Size() != writerSizeBeforeForward {
  302. h.gatewayService.ObserveOpenAIAccountHealthFailure(c.Request.Context(), account, err)
  303. h.handleFailoverExhausted(c, failoverErr, true)
  304. return
  305. }
  306. if failoverErr.ShouldReportAccountScheduleFailure() {
  307. h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, nil), false, nil, err)
  308. }
  309. if !failoverErr.ShouldRetryNextAccount() {
  310. h.handleFailoverExhausted(c, failoverErr, streamStarted)
  311. return
  312. }
  313. // Pool mode: retry on the same account
  314. if failoverErr.RetryableOnSameAccount {
  315. retryLimit := effectiveSameAccountRetryLimit(failoverErr, account)
  316. if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) {
  317. sameAccountRetryCount[account.ID]++
  318. retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
  319. reqLog.Warn("openai_chat_completions.pool_mode_same_account_retry",
  320. zap.Int64("account_id", account.ID),
  321. zap.Int("upstream_status", failoverErr.StatusCode),
  322. zap.Int("retry_limit", retryLimit),
  323. zap.Int("retry_count", sameAccountRetryCount[account.ID]),
  324. zap.Duration("retry_delay", retryDelay),
  325. )
  326. select {
  327. case <-c.Request.Context().Done():
  328. return
  329. case <-time.After(retryDelay):
  330. }
  331. continue
  332. }
  333. }
  334. h.gatewayService.RecordOpenAIAccountSwitch()
  335. failedAccountIDs[account.ID] = struct{}{}
  336. lastFailoverErr = failoverErr
  337. if switchCount >= maxAccountSwitches {
  338. h.handleFailoverExhausted(c, failoverErr, streamStarted)
  339. return
  340. }
  341. switchCount++
  342. if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) {
  343. h.handleFailoverExhausted(c, failoverErr, streamStarted)
  344. return
  345. }
  346. reqLog.Warn("openai_chat_completions.upstream_failover_switching",
  347. zap.Int64("account_id", account.ID),
  348. zap.Int("upstream_status", failoverErr.StatusCode),
  349. zap.Int("switch_count", switchCount),
  350. zap.Int("max_switches", maxAccountSwitches),
  351. )
  352. continue
  353. }
  354. h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, nil), false, nil, err)
  355. upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err)
  356. wroteFallback := false
  357. if !upstreamErrorAlreadyCommunicated {
  358. wroteFallback = h.ensureOpenAIStreamReadErrorResponse(c, err, streamStarted)
  359. if !wroteFallback {
  360. wroteFallback = h.ensureForwardErrorResponse(c, streamStarted)
  361. }
  362. }
  363. reqLog.Warn("openai_chat_completions.forward_failed",
  364. zap.Int64("account_id", account.ID),
  365. zap.Bool("fallback_error_response_written", wroteFallback),
  366. zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated),
  367. zap.Error(err),
  368. )
  369. submitChatUsage(result)
  370. return
  371. }
  372. }
  373. if result != nil {
  374. h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), true, result.FirstTokenMs)
  375. } else {
  376. h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), true, nil)
  377. }
  378. submitChatUsage(result)
  379. reqLog.Debug("openai_chat_completions.request_completed",
  380. zap.Int64("account_id", account.ID),
  381. zap.Int("switch_count", switchCount),
  382. )
  383. return
  384. }
  385. }
  386. // resolveOpenAIUpstreamEndpoint returns the actual upstream endpoint for an
  387. // OpenAI-compatible account. A forwarding result is authoritative because a
  388. // single inbound route may choose raw Chat or a Responses bridge at runtime.
  389. // The account-based derivation remains as a fallback for existing callers and
  390. // forwarding paths that do not report their endpoint yet.
  391. func resolveOpenAIUpstreamEndpoint(c *gin.Context, account *service.Account, result *service.OpenAIForwardResult) string {
  392. if result != nil {
  393. if endpoint := strings.TrimSpace(result.UpstreamEndpoint); endpoint != "" {
  394. return endpoint
  395. }
  396. }
  397. if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
  398. return endpoint
  399. }
  400. if account != nil && account.Type == service.AccountTypeAPIKey &&
  401. !openai_compat.ShouldUseResponsesAPI(account.Extra) {
  402. return EndpointChatCompletions
  403. }
  404. return GetUpstreamEndpoint(c, account.Platform)
  405. }