gateway_handler_responses.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  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. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. "github.com/tidwall/gjson"
  14. "go.uber.org/zap"
  15. )
  16. // Responses handles OpenAI Responses API endpoint for Anthropic platform groups.
  17. // POST /v1/responses
  18. // This converts Responses API requests to Anthropic format, forwards to Anthropic
  19. // upstream, and converts responses back to Responses format.
  20. func (h *GatewayHandler) Responses(c *gin.Context) {
  21. streamStarted := false
  22. requestStart := time.Now()
  23. apiKey, ok := middleware2.GetAPIKeyFromContext(c)
  24. if !ok {
  25. h.responsesErrorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
  26. return
  27. }
  28. subject, ok := middleware2.GetAuthSubjectFromContext(c)
  29. if !ok {
  30. h.responsesErrorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
  31. return
  32. }
  33. reqLog := requestLogger(
  34. c,
  35. "handler.gateway.responses",
  36. zap.Int64("user_id", subject.UserID),
  37. zap.Int64("api_key_id", apiKey.ID),
  38. zap.Any("group_id", apiKey.GroupID),
  39. )
  40. // Read request body
  41. body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
  42. if err != nil {
  43. if maxErr, ok := extractMaxBytesError(err); ok {
  44. h.responsesErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
  45. return
  46. }
  47. h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
  48. return
  49. }
  50. if len(body) == 0 {
  51. h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
  52. return
  53. }
  54. setOpsRequestContext(c, "", false)
  55. // Validate JSON
  56. if !gjson.ValidBytes(body) {
  57. logRequestBodyParseFailure(reqLog, body, nil)
  58. h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
  59. return
  60. }
  61. // Extract model and stream using gjson (like OpenAI handler)
  62. modelResult := gjson.GetBytes(body, "model")
  63. if !modelResult.Exists() || modelResult.Type != gjson.String || modelResult.String() == "" {
  64. h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
  65. return
  66. }
  67. reqModel := modelResult.String()
  68. bindRequestedReasoningEffort(c, body, reqModel)
  69. ensureCompositeTargetPlatform(c, apiKey, reqModel)
  70. if !compositeTargetPlatformResolved(c, apiKey, reqModel) {
  71. h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
  72. return
  73. }
  74. reqStream, ok := parseOpenAICompatibleStream(body)
  75. if !ok {
  76. h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
  77. return
  78. }
  79. reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
  80. setOpsRequestContext(c, reqModel, reqStream)
  81. setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
  82. requestCtx := c.Request.Context()
  83. // 定价上下文无条件装配:/v1/responses 是 token 计费端点,声明生图工具的
  84. // 混合请求同样按 token 计费(外加图片部分),其 token 利润保护不因请求体
  85. // 里的任何工具声明(含 Codex 被动 image_gen namespace)而关闭。生图意图
  86. // 仅用于能力路由与图片计费;独立图片/视频端点才在利润门范围之外。
  87. requestCtx, pricingAt := service.WithGatewayTokenRequestPricing(requestCtx)
  88. if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(c.Request.Context(), apiKey)) {
  89. requestCtx = service.WithOpenAIImageGenerationIntent(requestCtx)
  90. }
  91. c.Request = c.Request.WithContext(requestCtx)
  92. // 解析渠道级模型映射
  93. channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(requestCtx, apiKey.GroupID, reqModel)
  94. // Claude Code only restriction:
  95. // /v1/responses is never a Claude Code endpoint.
  96. // When claude_code_only is enabled, this endpoint is rejected.
  97. // The existing service-layer checkClaudeCodeRestriction handles degradation
  98. // to fallback groups when the Forward path calls SelectAccountForModelWithExclusions.
  99. // Here we just reject at handler level since /v1/responses clients can't be Claude Code.
  100. if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly {
  101. h.responsesErrorResponse(c, http.StatusForbidden, "permission_error",
  102. "This group is restricted to Claude Code clients (/v1/messages only)")
  103. return
  104. }
  105. if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && !decision.AllowNextStage {
  106. h.responsesSecurityAuditError(c, decision)
  107. return
  108. }
  109. // Error passthrough binding
  110. if h.errorPassthroughService != nil {
  111. service.BindErrorPassthroughService(c, h.errorPassthroughService)
  112. }
  113. subscription, _ := middleware2.GetSubscriptionFromContext(c)
  114. service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
  115. userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted)
  116. if err != nil {
  117. reqLog.Warn("gateway.responses.user_slot_acquire_failed", zap.Error(err))
  118. h.handleConcurrencyError(c, err, "user", streamStarted)
  119. return
  120. }
  121. userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
  122. if userReleaseFunc != nil {
  123. defer userReleaseFunc()
  124. }
  125. // 2. Re-check billing
  126. if err := h.billingCacheService.CheckBillingEligibility(requestCtx, apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(requestCtx, apiKey)); err != nil {
  127. reqLog.Info("gateway.responses.billing_check_failed", zap.Error(err))
  128. status, code, message, retryAfter := billingErrorDetails(err)
  129. if retryAfter > 0 {
  130. c.Header("Retry-After", strconv.Itoa(retryAfter))
  131. }
  132. h.responsesErrorResponse(c, status, code, message)
  133. return
  134. }
  135. // Parse request for session hash
  136. bodyRef := service.NewRequestBodyRef(body)
  137. parsedReq, _ := service.ParseGatewayRequest(bodyRef, "responses")
  138. if parsedReq == nil {
  139. parsedReq = &service.ParsedRequest{Model: reqModel, Stream: reqStream, Body: bodyRef}
  140. }
  141. parsedReq.SessionContext = &service.SessionContext{
  142. ClientIP: ip.GetClientIP(c),
  143. UserAgent: c.GetHeader("User-Agent"),
  144. APIKeyID: apiKey.ID,
  145. }
  146. sessionHash := h.gatewayService.GenerateSessionHash(parsedReq)
  147. // 3. Account selection + failover loop
  148. fs := NewFailoverState(h.maxAccountSwitches, false)
  149. for {
  150. if requestCtx.Err() != nil {
  151. return
  152. }
  153. selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
  154. if err != nil {
  155. if len(fs.FailedAccountIDs) == 0 {
  156. cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey))
  157. cls = classifySelectionFailureError(err, cls)
  158. if !cls.ModelNotFound {
  159. markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
  160. }
  161. message := cls.Message
  162. if !cls.ModelNotFound {
  163. message = "No available accounts: " + err.Error()
  164. }
  165. h.responsesErrorResponse(c, cls.Status, cls.ErrType, message)
  166. return
  167. }
  168. action := fs.HandleSelectionExhausted(requestCtx)
  169. switch action {
  170. case FailoverContinue:
  171. continue
  172. case FailoverCanceled:
  173. failoverClientGone(c)
  174. return
  175. default:
  176. if fs.LastFailoverErr != nil {
  177. h.handleResponsesFailoverExhausted(c, fs.LastFailoverErr, streamStarted)
  178. } else {
  179. h.responsesErrorResponse(c, http.StatusBadGateway, "server_error", "All available accounts exhausted")
  180. }
  181. return
  182. }
  183. }
  184. account := selection.Account
  185. setOpsSelectedAccount(c, account.ID, account.Platform)
  186. // 4. Acquire account concurrency slot
  187. accountReleaseFunc := selection.ReleaseFunc
  188. if !selection.Acquired {
  189. if selection.WaitPlan == nil {
  190. markOpsRoutingCapacityLimited(c)
  191. h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts")
  192. return
  193. }
  194. accountReleaseFunc, err = h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(
  195. c,
  196. account.ID,
  197. selection.WaitPlan.MaxConcurrency,
  198. selection.WaitPlan.Timeout,
  199. reqStream,
  200. &streamStarted,
  201. )
  202. if err != nil {
  203. reqLog.Warn("gateway.responses.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
  204. h.handleConcurrencyError(c, err, "account", streamStarted)
  205. return
  206. }
  207. }
  208. // 终检与准入后绑定必须使用选号结果携带的门:门安装在调度栈的局部
  209. // ctx 上(composite/fallback 还可能解析出与入口分组不同的门),直接用
  210. // requestCtx 会退化为空操作。
  211. admissionCtx := service.ContextWithSelectionProfitGate(requestCtx, selection)
  212. latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
  213. if vetoed {
  214. if accountReleaseFunc != nil {
  215. accountReleaseFunc()
  216. }
  217. reqLog.Debug("gateway.responses.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
  218. if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
  219. reqLog.Warn("gateway.responses.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
  220. h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage)
  221. return
  222. }
  223. continue
  224. }
  225. account = latest
  226. selection.Account = latest
  227. if selection.ProfitGateActive() {
  228. if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, sessionHash, account.ID); err != nil {
  229. reqLog.Warn("gateway.responses.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
  230. }
  231. }
  232. accountReleaseFunc = wrapReleaseOnDone(c.Request.Context(), accountReleaseFunc)
  233. // 5. Forward request
  234. writerSizeBeforeForward := c.Writer.Size()
  235. forwardBody := body
  236. if channelMapping.Mapped {
  237. forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
  238. }
  239. var result *service.ForwardResult
  240. setActualUpstreamEndpoint(c, "")
  241. if shouldUseAntigravityCompat(account) {
  242. if h.antigravityGatewayService == nil {
  243. h.responsesErrorResponse(c, http.StatusBadGateway, "upstream_error", "Antigravity compatibility service is not configured")
  244. if accountReleaseFunc != nil {
  245. accountReleaseFunc()
  246. }
  247. return
  248. }
  249. setActualUpstreamEndpoint(c, EndpointAntigravityGenerateContent)
  250. result, err = h.antigravityGatewayService.ForwardAsResponses(requestCtx, c, account, forwardBody, parsedReq)
  251. } else {
  252. result, err = h.gatewayService.ForwardAsResponses(requestCtx, c, account, forwardBody, parsedReq)
  253. }
  254. if accountReleaseFunc != nil {
  255. accountReleaseFunc()
  256. }
  257. if err != nil {
  258. var failoverErr *service.UpstreamFailoverError
  259. if errors.As(err, &failoverErr) {
  260. // Can't failover if streaming content already sent
  261. if c.Writer.Size() != writerSizeBeforeForward {
  262. h.handleResponsesFailoverExhausted(c, failoverErr, true)
  263. return
  264. }
  265. action := fs.HandleFailoverError(requestCtx, h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr)
  266. switch action {
  267. case FailoverContinue:
  268. continue
  269. case FailoverExhausted:
  270. h.handleResponsesFailoverExhausted(c, fs.LastFailoverErr, streamStarted)
  271. return
  272. case FailoverCanceled:
  273. failoverClientGone(c)
  274. return
  275. }
  276. }
  277. upstreamErrorAlreadyCommunicated := gatewayForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err)
  278. wroteFallback := false
  279. if !upstreamErrorAlreadyCommunicated {
  280. wroteFallback = h.ensureForwardErrorResponse(c, streamStarted)
  281. }
  282. reqLog.Error("gateway.responses.forward_failed",
  283. zap.Int64("account_id", account.ID),
  284. zap.Bool("fallback_error_response_written", wroteFallback),
  285. zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated),
  286. zap.Error(err),
  287. )
  288. return
  289. }
  290. // 6. Record usage
  291. userAgent := c.GetHeader("User-Agent")
  292. clientIP := ip.GetClientIP(c)
  293. requestPayloadHash := service.HashUsageRequestPayload(body)
  294. inboundEndpoint := GetInboundEndpoint(c)
  295. upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
  296. quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
  297. sessionID := service.ExtractClientSessionID(c)
  298. stampForwardRequestedReasoningEffort(result, service.RequestedReasoningEffortFromContext(c.Request.Context()))
  299. h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
  300. if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
  301. Result: result,
  302. QuotaPlatform: quotaPlatform,
  303. APIKey: apiKey,
  304. User: apiKey.User,
  305. Account: account,
  306. Subscription: subscription,
  307. PricingAt: pricingAt,
  308. InboundEndpoint: inboundEndpoint,
  309. UpstreamEndpoint: upstreamEndpoint,
  310. UserAgent: userAgent,
  311. IPAddress: clientIP,
  312. RequestPayloadHash: requestPayloadHash,
  313. APIKeyService: h.apiKeyService,
  314. SessionID: sessionID,
  315. ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
  316. }); err != nil {
  317. reqLog.Error("gateway.responses.record_usage_failed",
  318. zap.Int64("account_id", account.ID),
  319. zap.Error(err),
  320. )
  321. }
  322. })
  323. return
  324. }
  325. }
  326. // responsesErrorResponse writes an error in OpenAI Responses API format.
  327. func (h *GatewayHandler) responsesErrorResponse(c *gin.Context, status int, code, message string) {
  328. c.JSON(status, gin.H{
  329. "error": gin.H{
  330. "code": code,
  331. "message": message,
  332. },
  333. })
  334. }
  335. // handleResponsesFailoverExhausted writes a failover-exhausted error in Responses format.
  336. func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastErr *service.UpstreamFailoverError, streamStarted bool) {
  337. if lastErr != nil {
  338. copyFailoverRetryAfter(c, lastErr.ResponseHeaders)
  339. }
  340. statusCode := http.StatusBadGateway
  341. if lastErr != nil && lastErr.StatusCode > 0 {
  342. statusCode = lastErr.StatusCode
  343. }
  344. status, code, message := statusCode, "server_error", "All available accounts exhausted"
  345. if lastErr != nil && lastErr.IsCredentialFailure() {
  346. status, message = credentialFailoverClientResponse(lastErr)
  347. } else if lastErr != nil && lastErr.IsOpenAICapacityShed() && strings.TrimSpace(lastErr.ClientMessage) != "" {
  348. status = lastErr.ClientStatusCode
  349. if status <= 0 {
  350. status = http.StatusServiceUnavailable
  351. }
  352. message = lastErr.ClientMessage
  353. } else if lastErr != nil && service.IsOpenAISilentRefusalErrorBody(lastErr.ResponseBody) {
  354. service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "")
  355. status, code, message = http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage()
  356. } else if lastErr != nil && statusCode == http.StatusTooManyRequests {
  357. status, code, message = http.StatusTooManyRequests, "rate_limit_error", "All available accounts are currently rate-limited. Please retry later."
  358. }
  359. if streamStarted {
  360. // A slot-wait heartbeat commits HTTP 200 before any upstream response.
  361. // In that case a terminal frame is still required; once any semantic or
  362. // official terminal bytes exist, preserve them without appending a second
  363. // generic response.failed.
  364. service.MarkOpsStreamError(c, code, message, status)
  365. if c != nil && c.Writer != nil && (c.Writer.Size() <= 0 || gatewayStreamHasOnlyHeartbeats(c)) {
  366. writeResponsesFailedSSE(c, code, "", message)
  367. }
  368. return
  369. }
  370. h.responsesErrorResponse(c, status, code, message)
  371. }