|
| 1 | +package handler |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "errors" |
| 6 | + "net/http" |
| 7 | + "time" |
| 8 | + |
| 9 | + pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" |
| 10 | + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" |
| 11 | + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" |
| 12 | + "github.com/Wei-Shaw/sub2api/internal/service" |
| 13 | + "github.com/gin-gonic/gin" |
| 14 | + "github.com/tidwall/gjson" |
| 15 | + "go.uber.org/zap" |
| 16 | +) |
| 17 | + |
| 18 | +// ChatCompletions handles OpenAI Chat Completions API endpoint for Anthropic platform groups. |
| 19 | +// POST /v1/chat/completions |
| 20 | +// This converts Chat Completions requests to Anthropic format (via Responses format chain), |
| 21 | +// forwards to Anthropic upstream, and converts responses back to Chat Completions format. |
| 22 | +func (h *GatewayHandler) ChatCompletions(c *gin.Context) { |
| 23 | + streamStarted := false |
| 24 | + |
| 25 | + requestStart := time.Now() |
| 26 | + |
| 27 | + apiKey, ok := middleware2.GetAPIKeyFromContext(c) |
| 28 | + if !ok { |
| 29 | + h.chatCompletionsErrorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") |
| 30 | + return |
| 31 | + } |
| 32 | + |
| 33 | + subject, ok := middleware2.GetAuthSubjectFromContext(c) |
| 34 | + if !ok { |
| 35 | + h.chatCompletionsErrorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") |
| 36 | + return |
| 37 | + } |
| 38 | + reqLog := requestLogger( |
| 39 | + c, |
| 40 | + "handler.gateway.chat_completions", |
| 41 | + zap.Int64("user_id", subject.UserID), |
| 42 | + zap.Int64("api_key_id", apiKey.ID), |
| 43 | + zap.Any("group_id", apiKey.GroupID), |
| 44 | + ) |
| 45 | + |
| 46 | + // Read request body |
| 47 | + body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) |
| 48 | + if err != nil { |
| 49 | + if maxErr, ok := extractMaxBytesError(err); ok { |
| 50 | + h.chatCompletionsErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) |
| 51 | + return |
| 52 | + } |
| 53 | + h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") |
| 54 | + return |
| 55 | + } |
| 56 | + |
| 57 | + if len(body) == 0 { |
| 58 | + h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") |
| 59 | + return |
| 60 | + } |
| 61 | + |
| 62 | + setOpsRequestContext(c, "", false, body) |
| 63 | + |
| 64 | + // Validate JSON |
| 65 | + if !gjson.ValidBytes(body) { |
| 66 | + h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") |
| 67 | + return |
| 68 | + } |
| 69 | + |
| 70 | + // Extract model and stream |
| 71 | + modelResult := gjson.GetBytes(body, "model") |
| 72 | + if !modelResult.Exists() || modelResult.Type != gjson.String || modelResult.String() == "" { |
| 73 | + h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required") |
| 74 | + return |
| 75 | + } |
| 76 | + reqModel := modelResult.String() |
| 77 | + reqStream := gjson.GetBytes(body, "stream").Bool() |
| 78 | + reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) |
| 79 | + |
| 80 | + setOpsRequestContext(c, reqModel, reqStream, body) |
| 81 | + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) |
| 82 | + |
| 83 | + // Claude Code only restriction |
| 84 | + if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly { |
| 85 | + h.chatCompletionsErrorResponse(c, http.StatusForbidden, "permission_error", |
| 86 | + "This group is restricted to Claude Code clients (/v1/messages only)") |
| 87 | + return |
| 88 | + } |
| 89 | + |
| 90 | + // Error passthrough binding |
| 91 | + if h.errorPassthroughService != nil { |
| 92 | + service.BindErrorPassthroughService(c, h.errorPassthroughService) |
| 93 | + } |
| 94 | + |
| 95 | + subscription, _ := middleware2.GetSubscriptionFromContext(c) |
| 96 | + |
| 97 | + service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) |
| 98 | + |
| 99 | + // 1. Acquire user concurrency slot |
| 100 | + maxWait := service.CalculateMaxWait(subject.Concurrency) |
| 101 | + canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait) |
| 102 | + waitCounted := false |
| 103 | + if err != nil { |
| 104 | + reqLog.Warn("gateway.cc.user_wait_counter_increment_failed", zap.Error(err)) |
| 105 | + } else if !canWait { |
| 106 | + h.chatCompletionsErrorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later") |
| 107 | + return |
| 108 | + } |
| 109 | + if err == nil && canWait { |
| 110 | + waitCounted = true |
| 111 | + } |
| 112 | + defer func() { |
| 113 | + if waitCounted { |
| 114 | + h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID) |
| 115 | + } |
| 116 | + }() |
| 117 | + |
| 118 | + userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted) |
| 119 | + if err != nil { |
| 120 | + reqLog.Warn("gateway.cc.user_slot_acquire_failed", zap.Error(err)) |
| 121 | + h.handleConcurrencyError(c, err, "user", streamStarted) |
| 122 | + return |
| 123 | + } |
| 124 | + if waitCounted { |
| 125 | + h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID) |
| 126 | + waitCounted = false |
| 127 | + } |
| 128 | + userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc) |
| 129 | + if userReleaseFunc != nil { |
| 130 | + defer userReleaseFunc() |
| 131 | + } |
| 132 | + |
| 133 | + // 2. Re-check billing |
| 134 | + if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription); err != nil { |
| 135 | + reqLog.Info("gateway.cc.billing_check_failed", zap.Error(err)) |
| 136 | + status, code, message := billingErrorDetails(err) |
| 137 | + h.chatCompletionsErrorResponse(c, status, code, message) |
| 138 | + return |
| 139 | + } |
| 140 | + |
| 141 | + // Parse request for session hash |
| 142 | + parsedReq, _ := service.ParseGatewayRequest(body, "chat_completions") |
| 143 | + if parsedReq == nil { |
| 144 | + parsedReq = &service.ParsedRequest{Model: reqModel, Stream: reqStream, Body: body} |
| 145 | + } |
| 146 | + parsedReq.SessionContext = &service.SessionContext{ |
| 147 | + ClientIP: ip.GetClientIP(c), |
| 148 | + UserAgent: c.GetHeader("User-Agent"), |
| 149 | + APIKeyID: apiKey.ID, |
| 150 | + } |
| 151 | + sessionHash := h.gatewayService.GenerateSessionHash(parsedReq) |
| 152 | + |
| 153 | + // 3. Account selection + failover loop |
| 154 | + fs := NewFailoverState(h.maxAccountSwitches, false) |
| 155 | + |
| 156 | + for { |
| 157 | + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "") |
| 158 | + if err != nil { |
| 159 | + if len(fs.FailedAccountIDs) == 0 { |
| 160 | + h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error()) |
| 161 | + return |
| 162 | + } |
| 163 | + action := fs.HandleSelectionExhausted(c.Request.Context()) |
| 164 | + switch action { |
| 165 | + case FailoverContinue: |
| 166 | + continue |
| 167 | + case FailoverCanceled: |
| 168 | + return |
| 169 | + default: |
| 170 | + if fs.LastFailoverErr != nil { |
| 171 | + h.handleCCFailoverExhausted(c, fs.LastFailoverErr, streamStarted) |
| 172 | + } else { |
| 173 | + h.chatCompletionsErrorResponse(c, http.StatusBadGateway, "server_error", "All available accounts exhausted") |
| 174 | + } |
| 175 | + return |
| 176 | + } |
| 177 | + } |
| 178 | + account := selection.Account |
| 179 | + setOpsSelectedAccount(c, account.ID, account.Platform) |
| 180 | + |
| 181 | + // 4. Acquire account concurrency slot |
| 182 | + accountReleaseFunc := selection.ReleaseFunc |
| 183 | + if !selection.Acquired { |
| 184 | + if selection.WaitPlan == nil { |
| 185 | + h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts") |
| 186 | + return |
| 187 | + } |
| 188 | + accountReleaseFunc, err = h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout( |
| 189 | + c, |
| 190 | + account.ID, |
| 191 | + selection.WaitPlan.MaxConcurrency, |
| 192 | + selection.WaitPlan.Timeout, |
| 193 | + reqStream, |
| 194 | + &streamStarted, |
| 195 | + ) |
| 196 | + if err != nil { |
| 197 | + reqLog.Warn("gateway.cc.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) |
| 198 | + h.handleConcurrencyError(c, err, "account", streamStarted) |
| 199 | + return |
| 200 | + } |
| 201 | + } |
| 202 | + accountReleaseFunc = wrapReleaseOnDone(c.Request.Context(), accountReleaseFunc) |
| 203 | + |
| 204 | + // 5. Forward request |
| 205 | + writerSizeBeforeForward := c.Writer.Size() |
| 206 | + result, err := h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, body, parsedReq) |
| 207 | + |
| 208 | + if accountReleaseFunc != nil { |
| 209 | + accountReleaseFunc() |
| 210 | + } |
| 211 | + |
| 212 | + if err != nil { |
| 213 | + var failoverErr *service.UpstreamFailoverError |
| 214 | + if errors.As(err, &failoverErr) { |
| 215 | + if c.Writer.Size() != writerSizeBeforeForward { |
| 216 | + h.handleCCFailoverExhausted(c, failoverErr, true) |
| 217 | + return |
| 218 | + } |
| 219 | + action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr) |
| 220 | + switch action { |
| 221 | + case FailoverContinue: |
| 222 | + continue |
| 223 | + case FailoverExhausted: |
| 224 | + h.handleCCFailoverExhausted(c, fs.LastFailoverErr, streamStarted) |
| 225 | + return |
| 226 | + case FailoverCanceled: |
| 227 | + return |
| 228 | + } |
| 229 | + } |
| 230 | + h.ensureForwardErrorResponse(c, streamStarted) |
| 231 | + reqLog.Error("gateway.cc.forward_failed", |
| 232 | + zap.Int64("account_id", account.ID), |
| 233 | + zap.Error(err), |
| 234 | + ) |
| 235 | + return |
| 236 | + } |
| 237 | + |
| 238 | + // 6. Record usage |
| 239 | + userAgent := c.GetHeader("User-Agent") |
| 240 | + clientIP := ip.GetClientIP(c) |
| 241 | + requestPayloadHash := service.HashUsageRequestPayload(body) |
| 242 | + inboundEndpoint := GetInboundEndpoint(c) |
| 243 | + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) |
| 244 | + |
| 245 | + h.submitUsageRecordTask(func(ctx context.Context) { |
| 246 | + if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ |
| 247 | + Result: result, |
| 248 | + APIKey: apiKey, |
| 249 | + User: apiKey.User, |
| 250 | + Account: account, |
| 251 | + Subscription: subscription, |
| 252 | + InboundEndpoint: inboundEndpoint, |
| 253 | + UpstreamEndpoint: upstreamEndpoint, |
| 254 | + UserAgent: userAgent, |
| 255 | + IPAddress: clientIP, |
| 256 | + RequestPayloadHash: requestPayloadHash, |
| 257 | + APIKeyService: h.apiKeyService, |
| 258 | + }); err != nil { |
| 259 | + reqLog.Error("gateway.cc.record_usage_failed", |
| 260 | + zap.Int64("account_id", account.ID), |
| 261 | + zap.Error(err), |
| 262 | + ) |
| 263 | + } |
| 264 | + }) |
| 265 | + return |
| 266 | + } |
| 267 | +} |
| 268 | + |
| 269 | +// chatCompletionsErrorResponse writes an error in OpenAI Chat Completions format. |
| 270 | +func (h *GatewayHandler) chatCompletionsErrorResponse(c *gin.Context, status int, errType, message string) { |
| 271 | + c.JSON(status, gin.H{ |
| 272 | + "error": gin.H{ |
| 273 | + "type": errType, |
| 274 | + "message": message, |
| 275 | + }, |
| 276 | + }) |
| 277 | +} |
| 278 | + |
| 279 | +// handleCCFailoverExhausted writes a failover-exhausted error in CC format. |
| 280 | +func (h *GatewayHandler) handleCCFailoverExhausted(c *gin.Context, lastErr *service.UpstreamFailoverError, streamStarted bool) { |
| 281 | + if streamStarted { |
| 282 | + return |
| 283 | + } |
| 284 | + statusCode := http.StatusBadGateway |
| 285 | + if lastErr != nil && lastErr.StatusCode > 0 { |
| 286 | + statusCode = lastErr.StatusCode |
| 287 | + } |
| 288 | + h.chatCompletionsErrorResponse(c, statusCode, "server_error", "All available accounts exhausted") |
| 289 | +} |
0 commit comments