Skip to content

Commit 5f41b74

Browse files
authored
Merge pull request Wei-Shaw#1242 from Ethan0x0000/feat/anthropic-openai-endpoint-compat
支持 Anthropic Responses / Chat Completions 兼容端点并完善会话一致性与错误可观测性
2 parents 25d961d + f10e56b commit 5f41b74

33 files changed

Lines changed: 3364 additions & 26 deletions

backend/internal/handler/gateway_handler.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
178178
c.Request = c.Request.WithContext(service.WithThinkingEnabled(c.Request.Context(), parsedReq.ThinkingEnabled, h.metadataBridgeEnabled()))
179179

180180
setOpsRequestContext(c, reqModel, reqStream, body)
181+
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
181182

182183
// 验证 model 必填
183184
if reqModel == "" {
@@ -1396,6 +1397,7 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
13961397
}
13971398

13981399
setOpsRequestContext(c, parsedReq.Model, parsedReq.Stream, body)
1400+
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(parsedReq.Stream, false)))
13991401

14001402
// 获取订阅信息(可能为nil)
14011403
subscription, _ := middleware2.GetSubscriptionFromContext(c)
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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

Comments
 (0)