diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go
index 044e07d7a2..ddc8c1e49a 100644
--- a/backend/internal/domain/constants.go
+++ b/backend/internal/domain/constants.go
@@ -23,6 +23,7 @@ const (
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
+ PlatformComposite = "composite"
)
// Account type constants
diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go
index 7545c14f5a..63b7d83616 100644
--- a/backend/internal/handler/admin/group_handler.go
+++ b/backend/internal/handler/admin/group_handler.go
@@ -87,7 +87,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
- Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok"`
+ Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -144,7 +144,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
- Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok"`
+ Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go
new file mode 100644
index 0000000000..b2f72cabab
--- /dev/null
+++ b/backend/internal/handler/composite_platform.go
@@ -0,0 +1,48 @@
+package handler
+
+import (
+ "github.com/Wei-Shaw/sub2api/internal/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+func ensureCompositeTargetPlatform(c *gin.Context, apiKey *service.APIKey, model string) {
+ if c == nil || c.Request == nil || apiKey == nil || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformComposite {
+ return
+ }
+ if _, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
+ return
+ }
+ if platform, ok := service.DetectModelPlatform(model); ok {
+ c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), platform))
+ }
+}
+
+func compositeTargetPlatformAllowed(c *gin.Context, apiKey *service.APIKey, model string, allowed ...string) bool {
+ if c == nil || c.Request == nil || apiKey == nil || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformComposite {
+ return true
+ }
+ ensureCompositeTargetPlatform(c, apiKey, model)
+ platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
+ if !ok {
+ return false
+ }
+ for _, allowedPlatform := range allowed {
+ if platform == allowedPlatform {
+ return true
+ }
+ }
+ return false
+}
+
+func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
+ if c != nil && c.Request != nil {
+ if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
+ return platform
+ }
+ }
+ if apiKey == nil || apiKey.Group == nil {
+ return ""
+ }
+ return apiKey.Group.Platform
+}
diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go
new file mode 100644
index 0000000000..46a2595ebb
--- /dev/null
+++ b/backend/internal/handler/composite_platform_test.go
@@ -0,0 +1,42 @@
+package handler
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest("POST", "/v1/embeddings", nil)
+ apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
+
+ require.True(t, compositeTargetPlatformAllowed(c, apiKey, "text-embedding-3-large", service.PlatformOpenAI))
+ platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
+ require.True(t, ok)
+ require.Equal(t, service.PlatformOpenAI, platform)
+}
+
+func TestCompositeTargetPlatformAllowedRejectsWrongOrUnknownModel(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ for _, tc := range []struct {
+ name string
+ model string
+ }{
+ {name: "wrong provider", model: "claude-sonnet-4-5"},
+ {name: "unknown provider", model: "llama-4-maverick"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest("POST", "/v1/embeddings", nil)
+ apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
+
+ require.False(t, compositeTargetPlatformAllowed(c, apiKey, tc.model, service.PlatformOpenAI))
+ })
+ }
+}
diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go
index 891878799a..5ed98e6ec8 100644
--- a/backend/internal/handler/gateway_handler.go
+++ b/backend/internal/handler/gateway_handler.go
@@ -169,6 +169,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
body = parsedReq.Body.Bytes()
reqModel := parsedReq.Model
reqStream := parsedReq.Stream
+ ensureCompositeTargetPlatform(c, apiKey, reqModel)
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
// 解析渠道级模型映射
@@ -259,10 +260,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
zap.String("metadata_user_id_raw", parsedReq.MetadataUserID),
)
- // 获取平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则使用分组平台
+ // 获取平台:优先使用强制平台(/antigravity 路由),其次使用 composite 解析出的目标平台,否则使用分组平台
platform := ""
if forcePlatform, ok := middleware2.GetForcePlatformFromContext(c); ok {
platform = forcePlatform
+ } else if resolvedPlatform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
+ platform = resolvedPlatform
} else if apiKey.Group != nil {
platform = apiKey.Group.Platform
}
@@ -1016,6 +1019,16 @@ func (h *GatewayHandler) Models(c *gin.Context) {
platform = forcedPlatform
}
+ if platform == service.PlatformComposite {
+ availableModels := h.compositeAvailableModels(c.Request.Context(), groupID)
+ if len(availableModels) > 0 {
+ writeModelsList(c, service.PlatformComposite, availableModels)
+ return
+ }
+ writeModelsList(c, service.PlatformComposite, defaultModelIDsForPlatform(service.PlatformComposite))
+ return
+ }
+
// Get available models from account configurations for the selected group platform.
availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
@@ -1057,6 +1070,28 @@ func (h *GatewayHandler) Models(c *gin.Context) {
})
}
+func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string {
+ if h == nil || h.gatewayService == nil {
+ return nil
+ }
+ seen := make(map[string]struct{})
+ models := make([]string, 0)
+ for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformGrok} {
+ for _, model := range h.gatewayService.GetAvailableModels(ctx, groupID, platform) {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ continue
+ }
+ if _, ok := seen[model]; ok {
+ continue
+ }
+ seen[model] = struct{}{}
+ models = append(models, model)
+ }
+ }
+ return models
+}
+
func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
if platform == service.PlatformGrok {
writeGrokModelsList(c, modelIDs)
@@ -1257,6 +1292,19 @@ func defaultModelIDsForPlatform(platform string) []string {
return mergeModelIDs(ids, nil)
case service.PlatformGrok:
return xai.DefaultModelIDs()
+ case service.PlatformComposite:
+ ids := make([]string, 0)
+ seen := make(map[string]struct{})
+ for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformGrok} {
+ for _, id := range defaultModelIDsForPlatform(concretePlatform) {
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ ids = append(ids, id)
+ }
+ }
+ return ids
default:
ids := make([]string, 0, len(claude.DefaultModels))
for _, model := range claude.DefaultModels {
@@ -1889,6 +1937,7 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
body = parsedReq.Body.Bytes()
// count_tokens 走 messages 严格校验时,复用已解析请求,避免二次反序列化。
SetClaudeCodeClientContext(c, body, parsedReq)
+ ensureCompositeTargetPlatform(c, apiKey, parsedReq.Model)
reqLog = reqLog.With(zap.String("model", parsedReq.Model), zap.Bool("stream", parsedReq.Stream))
// 在请求上下文中记录 thinking 状态,供 Antigravity 最终模型 key 推导/模型维度限流使用
c.Request = c.Request.WithContext(service.WithThinkingEnabled(c.Request.Context(), parsedReq.ThinkingEnabled, h.metadataBridgeEnabled()))
diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go
index 3263834799..51ddf8bcac 100644
--- a/backend/internal/handler/gateway_handler_chat_completions.go
+++ b/backend/internal/handler/gateway_handler_chat_completions.go
@@ -75,6 +75,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
return
}
reqModel := modelResult.String()
+ ensureCompositeTargetPlatform(c, apiKey, reqModel)
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
@@ -147,10 +148,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
APIKeyID: apiKey.ID,
}
sessionHash := h.gatewayService.GenerateSessionHash(parsedReq)
- groupPlatform := ""
- if apiKey.Group != nil {
- groupPlatform = apiKey.Group.Platform
- }
+ groupPlatform := effectiveAPIKeyPlatform(c, apiKey)
selectionSessionHash := sessionHash
if groupPlatform == service.PlatformGemini && selectionSessionHash != "" {
selectionSessionHash = "gemini:" + selectionSessionHash
diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go
index b708ab5ced..7a96a611c7 100644
--- a/backend/internal/handler/gateway_handler_responses.go
+++ b/backend/internal/handler/gateway_handler_responses.go
@@ -75,6 +75,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
return
}
reqModel := modelResult.String()
+ ensureCompositeTargetPlatform(c, apiKey, reqModel)
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
@@ -85,7 +86,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
requestCtx := c.Request.Context()
- if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(apiKey)) {
+ if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(c.Request.Context(), apiKey)) {
requestCtx = service.WithOpenAIImageGenerationIntent(requestCtx)
}
@@ -163,7 +164,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformAnthropic)
+ cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey))
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go
index f570d8a6c7..9a042f0e91 100644
--- a/backend/internal/handler/gemini_v1beta_handler.go
+++ b/backend/internal/handler/gemini_v1beta_handler.go
@@ -41,7 +41,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
}
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组
forcePlatform, hasForcePlatform := middleware.GetForcePlatformFromContext(c)
- if !hasForcePlatform && (apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGemini) {
+ if !hasForcePlatform && effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
return
}
@@ -88,7 +88,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
}
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组
forcePlatform, hasForcePlatform := middleware.GetForcePlatformFromContext(c)
- if !hasForcePlatform && (apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGemini) {
+ if !hasForcePlatform && effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
return
}
@@ -155,7 +155,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// 检查平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则要求 gemini 分组
if !middleware.HasForcePlatform(c) {
- if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGemini {
+ if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
return
}
diff --git a/backend/internal/handler/no_account_error.go b/backend/internal/handler/no_account_error.go
index 842935aa2d..8982c25b40 100644
--- a/backend/internal/handler/no_account_error.go
+++ b/backend/internal/handler/no_account_error.go
@@ -116,13 +116,17 @@ func classifyOpenAICompatibleNoAccountErrorFromGin(
routingModel string,
displayModel string,
) noAccountErrorClassification {
+ ctx := context.Background()
+ if c != nil && c.Request != nil {
+ ctx = c.Request.Context()
+ }
return classifyNoAccountErrorFromGin(
c,
diag,
apiKey,
routingModel,
displayModel,
- openAICompatibleRequestPlatform(apiKey),
+ openAICompatibleRequestPlatform(ctx, apiKey),
)
}
diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go
index 101eb57867..e90d7fcffb 100644
--- a/backend/internal/handler/openai_chat_completions.go
+++ b/backend/internal/handler/openai_chat_completions.go
@@ -75,6 +75,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
return
}
reqModel := modelResult.String()
+ ensureCompositeTargetPlatform(c, apiKey, reqModel)
+ if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI) {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
+ return
+ }
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed {
body = cappedBody
@@ -111,7 +116,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
- requestPlatform := openAICompatibleRequestPlatform(apiKey)
+ requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
routingStart := time.Now()
diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go
index f2e8835134..0b3fd82ccc 100644
--- a/backend/internal/handler/openai_embeddings.go
+++ b/backend/internal/handler/openai_embeddings.go
@@ -71,6 +71,11 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
return
}
reqModel := modelResult.String()
+ ensureCompositeTargetPlatform(c, apiKey, reqModel)
+ if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI) {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
+ return
+ }
reqLog = reqLog.With(zap.String("model", reqModel))
setOpsRequestContext(c, reqModel, false)
setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go
index bfbd2d72a5..2d642e6c79 100644
--- a/backend/internal/handler/openai_gateway_count_tokens.go
+++ b/backend/internal/handler/openai_gateway_count_tokens.go
@@ -116,6 +116,11 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
}
reqModel := parsedReq.Model
+ ensureCompositeTargetPlatform(c, apiKey, reqModel)
+ if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI) {
+ h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
+ return
+ }
routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel)
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", parsedReq.Stream))
@@ -155,11 +160,11 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
false,
false,
false,
- openAICompatibleRequestPlatform(apiKey),
+ openAICompatibleRequestPlatform(c.Request.Context(), apiKey),
)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
if err != nil {
- requestPlatform := openAICompatibleRequestPlatform(apiKey)
+ requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey)
reqLog.Warn("openai_count_tokens.account_select_failed", zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)))
cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel)
if !cls.ModelNotFound {
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index 9a3151e985..6aa57a3f45 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -117,7 +117,13 @@ func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecord
}
}
-func openAICompatibleRequestPlatform(apiKey *service.APIKey) string {
+func openAICompatibleRequestPlatform(ctx context.Context, apiKey *service.APIKey) string {
+ if platform, ok := service.ResolvedTargetPlatformFromContext(ctx); ok {
+ if platform == service.PlatformGrok {
+ return service.PlatformGrok
+ }
+ return service.PlatformOpenAI
+ }
if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == service.PlatformGrok {
return service.PlatformGrok
}
@@ -253,6 +259,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
}
reqModel := modelResult.String()
+ ensureCompositeTargetPlatform(c, apiKey, reqModel)
+ if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI, service.PlatformGrok) {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
+ return
+ }
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed {
body = cappedBody
@@ -332,7 +343,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
// Get subscription info (may be nil)
subscription, _ := middleware2.GetSubscriptionFromContext(c)
- requestPlatform := openAICompatibleRequestPlatform(apiKey)
+ requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
routingStart := time.Now()
@@ -417,7 +428,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "compact_not_supported", "No available accounts support /responses/compact", streamStarted)
return
}
- cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
+ cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, requestPlatform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -432,7 +443,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
}
if selection == nil || selection.Account == nil {
- cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
+ cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, requestPlatform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
@@ -861,6 +872,11 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
return
}
reqModel := modelResult.String()
+ ensureCompositeTargetPlatform(c, apiKey, reqModel)
+ if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI) {
+ h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
+ return
+ }
routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel)
reqStream := gjson.GetBytes(body, "stream").Bool()
@@ -885,7 +901,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
- requestPlatform := openAICompatibleRequestPlatform(apiKey)
+ requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
routingStart := time.Now()
@@ -1476,6 +1492,15 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "model is required in first response.create payload")
return
}
+ ensureCompositeTargetPlatform(c, apiKey, reqModel)
+ ctx = c.Request.Context()
+ if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
+ platform, ok := service.ResolvedTargetPlatformFromContext(ctx)
+ if !ok || (platform != service.PlatformOpenAI && platform != service.PlatformGrok) {
+ closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "Responses WebSocket API only supports OpenAI-compatible models for composite groups")
+ return
+ }
+ }
previousResponseID := strings.TrimSpace(gjson.GetBytes(firstMessage, "previous_response_id").String())
previousResponseIDKind := service.ClassifyOpenAIPreviousResponseIDKind(previousResponseID)
if previousResponseID != "" && previousResponseIDKind == service.OpenAIPreviousResponseIDKindMessageID {
@@ -1567,7 +1592,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
- requestPlatform := openAICompatibleRequestPlatform(apiKey)
+ requestPlatform := openAICompatibleRequestPlatform(ctx, apiKey)
requiredTransport := service.OpenAIUpstreamTransportResponsesWebsocketV2Ingress
if requestPlatform == service.PlatformGrok {
requiredTransport = service.OpenAIUpstreamTransportHTTPSSE
diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go
index c70dc55a46..6509672eac 100644
--- a/backend/internal/handler/openai_images.go
+++ b/backend/internal/handler/openai_images.go
@@ -74,6 +74,11 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
return
}
requestModel := parsed.Model
+ ensureCompositeTargetPlatform(c, apiKey, requestModel)
+ if !compositeTargetPlatformAllowed(c, apiKey, requestModel, service.PlatformOpenAI) {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
+ return
+ }
reqLog = reqLog.With(
zap.String("model", requestModel),
diff --git a/backend/internal/pkg/ctxkey/ctxkey.go b/backend/internal/pkg/ctxkey/ctxkey.go
index 9ba397a12c..064b70bdc8 100644
--- a/backend/internal/pkg/ctxkey/ctxkey.go
+++ b/backend/internal/pkg/ctxkey/ctxkey.go
@@ -8,6 +8,9 @@ const (
// ForcePlatform 强制平台(用于 /antigravity 路由),由 middleware.ForcePlatform 设置
ForcePlatform Key = "ctx_force_platform"
+ // ResolvedTargetPlatform 是 composite 分组按请求模型解析出的真实目标平台。
+ ResolvedTargetPlatform Key = "ctx_resolved_target_platform"
+
// RequestID 为服务端生成/透传的请求 ID。
RequestID Key = "ctx_request_id"
diff --git a/backend/internal/server/routes/composite_platform_test.go b/backend/internal/server/routes/composite_platform_test.go
new file mode 100644
index 0000000000..4f6ce5f430
--- /dev/null
+++ b/backend/internal/server/routes/composite_platform_test.go
@@ -0,0 +1,46 @@
+package routes
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCompositeTargetPlatformMiddlewareResolvesModelAndRestoresBody(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
+ groupID := int64(1)
+ c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
+ GroupID: &groupID,
+ Group: &service.Group{Platform: service.PlatformComposite},
+ })
+ c.Next()
+ })))
+ router.Use(compositeTargetPlatformMiddleware())
+ router.POST("/", func(c *gin.Context) {
+ platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
+ require.True(t, ok)
+ require.Equal(t, service.PlatformOpenAI, platform)
+
+ body, err := io.ReadAll(c.Request.Body)
+ require.NoError(t, err)
+ require.JSONEq(t, `{"model":"gpt-5"}`, string(body))
+ c.Status(http.StatusNoContent)
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"model":"gpt-5"}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusNoContent, w.Code)
+}
diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go
index 8e14f1d5aa..070e32c077 100644
--- a/backend/internal/server/routes/gateway.go
+++ b/backend/internal/server/routes/gateway.go
@@ -1,14 +1,21 @@
package routes
import (
+ "bytes"
+ "errors"
+ "io"
"net/http"
+ "strconv"
+ "strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
+ pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
+ "github.com/tidwall/gjson"
)
// RegisterGatewayRoutes 注册 API 网关路由(Claude/OpenAI/Gemini 兼容)
@@ -27,6 +34,8 @@ func RegisterGatewayRoutes(
clientRequestID := middleware.ClientRequestID()
opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService)
endpointNorm := handler.InboundEndpointMiddleware()
+ compositeTarget := compositeTargetPlatformMiddleware()
+ compositeGeminiTarget := compositeImplicitTargetPlatformMiddleware(service.PlatformGemini)
// 未分组 Key 拦截中间件(按协议格式区分错误响应)
requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter)
@@ -60,6 +69,9 @@ func RegisterGatewayRoutes(
}
h.Gateway.Models(c)
}
+ isOpenAIOnlyEndpointGatewayPlatform := func(c *gin.Context) bool {
+ return getGroupPlatform(c) == service.PlatformOpenAI
+ }
imagesHandler := func(c *gin.Context) {
switch getGroupPlatform(c) {
case service.PlatformOpenAI:
@@ -139,6 +151,7 @@ func RegisterGatewayRoutes(
gateway.Use(endpointNorm)
gateway.Use(gin.HandlerFunc(apiKeyAuth))
gateway.GET("/sub2api/billing", h.Gateway.KeyBillingInfo)
+ gateway.Use(compositeTarget)
gateway.Use(requireGroupAnthropic)
{
// /v1/messages: auto-route based on group platform
@@ -185,7 +198,7 @@ func RegisterGatewayRoutes(
h.Gateway.ChatCompletions(c)
})
gateway.POST("/embeddings", textBodyLimit, func(c *gin.Context) {
- if getGroupPlatform(c) != service.PlatformOpenAI {
+ if !isOpenAIOnlyEndpointGatewayPlatform(c) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
@@ -226,6 +239,7 @@ func RegisterGatewayRoutes(
gemini.Use(opsErrorLogger)
gemini.Use(endpointNorm)
gemini.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
+ gemini.Use(compositeGeminiTarget)
gemini.Use(requireGroupGoogle)
{
gemini.GET("/models", h.Gateway.GeminiV1BetaListModels)
@@ -242,16 +256,16 @@ func RegisterGatewayRoutes(
}
h.Gateway.Responses(c)
}
- r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
- r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
- r.POST("/alpha/search", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.AlphaSearch)
- r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
+ r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler)
+ r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler)
+ r.POST("/alpha/search", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.OpenAIGateway.AlphaSearch)
+ r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
h.OpenAIGateway.ResponsesWebSocket(c)
})
r.GET("/models", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, modelsHandler)
- r.POST("/messages/count_tokens", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, countTokensHandler)
+ r.POST("/messages/count_tokens", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, countTokensHandler)
codexDirect := r.Group("/backend-api/codex")
- codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic)
+ codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic)
{
codexDirect.POST("/responses", responsesHandler)
codexDirect.POST("/responses/*subpath", responsesHandler)
@@ -262,15 +276,15 @@ func RegisterGatewayRoutes(
codexDirect.GET("/models", h.OpenAIGateway.CodexModels)
}
// OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform
- r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
+ r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
h.OpenAIGateway.ChatCompletions(c)
return
}
h.Gateway.ChatCompletions(c)
})
- r.POST("/embeddings", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
- if getGroupPlatform(c) != service.PlatformOpenAI {
+ r.POST("/embeddings", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
+ if !isOpenAIOnlyEndpointGatewayPlatform(c) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
@@ -282,16 +296,16 @@ func RegisterGatewayRoutes(
}
h.OpenAIGateway.Embeddings(c)
})
- r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler)
- r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler)
- r.POST("/images/generations/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.AsyncImage.Submit)
- r.POST("/images/edits/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.AsyncImage.Submit)
- r.GET("/images/tasks/:task_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.AsyncImage.Get)
- r.POST("/videos/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoGenerationHandler)
- r.POST("/videos/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoEditHandler)
- r.POST("/videos/extensions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoExtensionHandler)
- r.GET("/videos/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoStatusHandler)
- r.GET("/videos/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoContentHandler)
+ r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, imagesHandler)
+ r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, imagesHandler)
+ r.POST("/images/generations/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit)
+ r.POST("/images/edits/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit)
+ r.GET("/images/tasks/:task_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Get)
+ r.POST("/videos/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler)
+ r.POST("/videos/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoEditHandler)
+ r.POST("/videos/extensions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoExtensionHandler)
+ r.GET("/videos/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler)
+ r.GET("/videos/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler)
// Antigravity 模型列表
r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels)
@@ -334,5 +348,63 @@ func getGroupPlatform(c *gin.Context) string {
if !ok || apiKey.Group == nil {
return ""
}
+ if apiKey.Group.Platform == service.PlatformComposite {
+ if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
+ return platform
+ }
+ }
return apiKey.Group.Platform
}
+
+func compositeTargetPlatformMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ apiKey, ok := middleware.GetAPIKeyFromContext(c)
+ if !ok || apiKey == nil || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformComposite {
+ c.Next()
+ return
+ }
+ if c.Request == nil || c.Request.Method == http.MethodGet {
+ c.Next()
+ return
+ }
+
+ body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
+ if err != nil {
+ status := http.StatusBadRequest
+ message := "Failed to read request body"
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) {
+ status = http.StatusRequestEntityTooLarge
+ message = "Request body is too large"
+ }
+ c.JSON(status, gin.H{"error": gin.H{"type": "invalid_request_error", "message": message}})
+ c.Abort()
+ return
+ }
+ resetRequestBody(c, body)
+
+ model := strings.TrimSpace(gjson.GetBytes(body, "model").String())
+ if model != "" {
+ if platform, ok := service.DetectModelPlatform(model); ok {
+ c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), platform))
+ }
+ }
+ c.Next()
+ }
+}
+
+func compositeImplicitTargetPlatformMiddleware(platform string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ apiKey, ok := middleware.GetAPIKeyFromContext(c)
+ if ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
+ c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), platform))
+ }
+ c.Next()
+ }
+}
+
+func resetRequestBody(c *gin.Context, body []byte) {
+ c.Request.Body = io.NopCloser(bytes.NewReader(body))
+ c.Request.ContentLength = int64(len(body))
+ c.Request.Header.Set("Content-Length", strconv.Itoa(len(body)))
+}
diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go
index f4e7f39a42..80eee93617 100644
--- a/backend/internal/service/admin_group.go
+++ b/backend/internal/service/admin_group.go
@@ -115,6 +115,8 @@ func defaultModelsListCandidateIDs(platform string) []string {
return ids
case PlatformGrok:
return xai.DefaultModelIDs()
+ case PlatformComposite:
+ return nil
default:
ids := make([]string, 0, len(claude.DefaultModels))
for _, model := range claude.DefaultModels {
@@ -130,6 +132,22 @@ func defaultAllowImageGenerationForPlatform(platform string) bool {
return platform == PlatformGrok
}
+func canCopyAccountsFromGroupPlatform(targetPlatform, sourcePlatform string) bool {
+ if targetPlatform == PlatformComposite {
+ return sourcePlatform == PlatformComposite || isConcreteRequestPlatform(sourcePlatform)
+ }
+ return sourcePlatform == targetPlatform
+}
+
+func groupSupportsOAuthOnlyFilter(platform string) bool {
+ return platform == PlatformOpenAI ||
+ platform == PlatformAntigravity ||
+ platform == PlatformAnthropic ||
+ platform == PlatformGemini ||
+ platform == PlatformGrok ||
+ platform == PlatformComposite
+}
+
func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
if input.RateMultiplier <= 0 {
return nil, errors.New("rate_multiplier must be > 0")
@@ -255,7 +273,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
if err != nil {
return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err)
}
- if srcGroup.Platform != platform {
+ if !canCopyAccountsFromGroupPlatform(platform, srcGroup.Platform) {
return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, platform, srcGroup.Platform)
}
}
@@ -321,7 +339,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
}
// require_oauth_only: 过滤掉 apikey 类型账号
- if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && len(accountIDsToCopy) > 0 {
+ if group.RequireOAuthOnly && groupSupportsOAuthOnlyFilter(group.Platform) && len(accountIDsToCopy) > 0 {
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy)
if err != nil {
return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err)
@@ -678,7 +696,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
if err != nil {
return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err)
}
- if srcGroup.Platform != group.Platform {
+ if !canCopyAccountsFromGroupPlatform(group.Platform, srcGroup.Platform) {
return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, group.Platform, srcGroup.Platform)
}
}
@@ -695,7 +713,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
}
// require_oauth_only: 过滤掉 apikey 类型账号
- if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && len(accountIDsToCopy) > 0 {
+ if group.RequireOAuthOnly && groupSupportsOAuthOnlyFilter(group.Platform) && len(accountIDsToCopy) > 0 {
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy)
if err != nil {
return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err)
diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go
new file mode 100644
index 0000000000..137c2f9286
--- /dev/null
+++ b/backend/internal/service/composite_platform.go
@@ -0,0 +1,114 @@
+package service
+
+import (
+ "context"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+)
+
+// WithResolvedTargetPlatform stores the concrete provider chosen for a request
+// made through a composite group.
+func WithResolvedTargetPlatform(ctx context.Context, platform string) context.Context {
+ platform = strings.TrimSpace(platform)
+ if ctx == nil || platform == "" {
+ return ctx
+ }
+ return context.WithValue(ctx, ctxkey.ResolvedTargetPlatform, platform)
+}
+
+// ResolvedTargetPlatformFromContext returns the concrete provider chosen for
+// the current request, if one was resolved.
+func ResolvedTargetPlatformFromContext(ctx context.Context) (string, bool) {
+ if ctx == nil {
+ return "", false
+ }
+ platform, ok := ctx.Value(ctxkey.ResolvedTargetPlatform).(string)
+ platform = strings.TrimSpace(platform)
+ if !ok || platform == "" {
+ return "", false
+ }
+ return platform, true
+}
+
+// DetectModelPlatform maps common public model IDs to the concrete provider
+// platform used by sub2api. It intentionally returns false for ambiguous model
+// names so composite groups fail closed instead of guessing.
+func DetectModelPlatform(model string) (string, bool) {
+ normalized := strings.ToLower(strings.TrimSpace(model))
+ if normalized == "" {
+ return "", false
+ }
+
+ normalized = strings.TrimPrefix(normalized, "models/")
+ if slash := strings.IndexByte(normalized, '/'); slash > 0 {
+ provider := strings.TrimSpace(normalized[:slash])
+ rest := strings.TrimSpace(normalized[slash+1:])
+ switch provider {
+ case "anthropic", "claude":
+ return PlatformAnthropic, true
+ case "openai", "chatgpt":
+ return PlatformOpenAI, true
+ case "google", "google-ai-studio", "gemini":
+ return PlatformGemini, true
+ case "xai", "x-ai", "grok":
+ return PlatformGrok, true
+ }
+ if rest != "" {
+ normalized = strings.TrimPrefix(rest, "models/")
+ }
+ }
+
+ switch {
+ case strings.HasPrefix(normalized, "anthropic.claude-"),
+ strings.HasPrefix(normalized, "claude-"):
+ return PlatformAnthropic, true
+ case strings.HasPrefix(normalized, "gpt-"),
+ strings.HasPrefix(normalized, "chatgpt-"),
+ strings.HasPrefix(normalized, "codex-"),
+ strings.HasPrefix(normalized, "text-embedding-"),
+ strings.HasPrefix(normalized, "text-moderation-"),
+ strings.HasPrefix(normalized, "omni-moderation-"),
+ strings.HasPrefix(normalized, "dall-e-"),
+ strings.HasPrefix(normalized, "gpt-image-"),
+ strings.HasPrefix(normalized, "tts-"),
+ strings.HasPrefix(normalized, "whisper-"),
+ hasOpenAISeriesPrefix(normalized):
+ return PlatformOpenAI, true
+ case strings.HasPrefix(normalized, "gemini-"),
+ strings.HasPrefix(normalized, "learnlm-"):
+ return PlatformGemini, true
+ case normalized == "grok" || strings.HasPrefix(normalized, "grok-"):
+ return PlatformGrok, true
+ default:
+ return "", false
+ }
+}
+
+func hasOpenAISeriesPrefix(model string) bool {
+ for _, prefix := range []string{"o1", "o3", "o4", "o5"} {
+ if model == prefix || strings.HasPrefix(model, prefix+"-") {
+ return true
+ }
+ }
+ return false
+}
+
+func resolveCompositeTargetPlatform(ctx context.Context, group *Group, requestedModel string) (string, bool) {
+ if platform, ok := ResolvedTargetPlatformFromContext(ctx); ok {
+ return platform, true
+ }
+ if group == nil || group.Platform != PlatformComposite {
+ return "", false
+ }
+ return DetectModelPlatform(requestedModel)
+}
+
+func isConcreteRequestPlatform(platform string) bool {
+ switch platform {
+ case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go
new file mode 100644
index 0000000000..e127f95d44
--- /dev/null
+++ b/backend/internal/service/composite_platform_test.go
@@ -0,0 +1,58 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDetectModelPlatform(t *testing.T) {
+ tests := []struct {
+ name string
+ model string
+ platform string
+ ok bool
+ }{
+ {name: "claude", model: "claude-sonnet-4-5", platform: PlatformAnthropic, ok: true},
+ {name: "anthropic prefix", model: "anthropic/claude-opus-4-5", platform: PlatformAnthropic, ok: true},
+ {name: "gpt", model: "gpt-5.1", platform: PlatformOpenAI, ok: true},
+ {name: "o series", model: "o3-mini", platform: PlatformOpenAI, ok: true},
+ {name: "embedding", model: "text-embedding-3-large", platform: PlatformOpenAI, ok: true},
+ {name: "gemini", model: "gemini-3-pro", platform: PlatformGemini, ok: true},
+ {name: "gemini models prefix", model: "models/gemini-2.5-flash", platform: PlatformGemini, ok: true},
+ {name: "learnlm", model: "learnlm-2.0-flash-experimental", platform: PlatformGemini, ok: true},
+ {name: "grok", model: "grok-4", platform: PlatformGrok, ok: true},
+ {name: "xai prefix", model: "xai/grok-4", platform: PlatformGrok, ok: true},
+ {name: "unknown", model: "llama-4-maverick", ok: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ platform, ok := DetectModelPlatform(tt.model)
+ require.Equal(t, tt.ok, ok)
+ require.Equal(t, tt.platform, platform)
+ })
+ }
+}
+
+func TestQuotaPlatformCompositeUsesResolvedOrForceOnly(t *testing.T) {
+ apiKey := &APIKey{Group: &Group{Platform: PlatformComposite}}
+
+ require.Equal(t, "", QuotaPlatform(context.Background(), apiKey))
+ require.Equal(t, PlatformGemini, QuotaPlatform(WithResolvedTargetPlatform(context.Background(), PlatformGemini), apiKey))
+ require.Equal(t, PlatformAntigravity, QuotaPlatform(context.WithValue(context.Background(), ctxkey.ForcePlatform, PlatformAntigravity), apiKey))
+
+ ctx := WithResolvedTargetPlatform(context.Background(), PlatformAnthropic)
+ ctx = context.WithValue(ctx, ctxkey.ForcePlatform, PlatformAntigravity)
+ require.Equal(t, PlatformAntigravity, QuotaPlatform(ctx, apiKey))
+}
+
+func TestSchedulerPlatformsForCompositeGroup(t *testing.T) {
+ require.ElementsMatch(t,
+ []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok},
+ schedulerPlatformsForGroup(PlatformComposite),
+ )
+ require.Equal(t, []string{PlatformAnthropic}, schedulerPlatformsForGroup(PlatformAnthropic))
+}
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index e503cfacab..279ebdba04 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -43,6 +43,7 @@ const (
PlatformGemini = domain.PlatformGemini
PlatformAntigravity = domain.PlatformAntigravity
PlatformGrok = domain.PlatformGrok
+ PlatformComposite = domain.PlatformComposite
)
// AllowedQuotaPlatforms 是允许设置 user × platform quota 的平台列表(单一权威来源)。
diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go
index d72303cd92..c876acf845 100644
--- a/backend/internal/service/gateway_scheduling.go
+++ b/backend/internal/service/gateway_scheduling.go
@@ -45,6 +45,14 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
groupID = resolvedGroupID
ctx = s.withGroupContext(ctx, group)
platform = group.Platform
+ if group != nil && group.Platform == PlatformComposite {
+ targetPlatform, ok := resolveCompositeTargetPlatform(ctx, group, requestedModel)
+ if !ok {
+ return nil, fmt.Errorf("%w supporting model: %s (composite target platform unknown)", ErrNoAvailableAccounts, requestedModel)
+ }
+ platform = targetPlatform
+ ctx = WithResolvedTargetPlatform(ctx, targetPlatform)
+ }
} else {
// 无分组时只使用原生 anthropic 平台
platform = PlatformAnthropic
@@ -194,7 +202,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
}
- platform, hasForcePlatform, err := s.resolvePlatform(ctx, groupID, group)
+ platform, hasForcePlatform, err := s.resolvePlatform(ctx, groupID, group, requestedModel)
if err != nil {
return nil, err
}
@@ -226,9 +234,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
return excluded
}
- // 获取模型路由配置(仅 anthropic 平台)
+ // 获取模型路由配置(anthropic 目标平台;composite 分组按目标平台判断)
var routingAccountIDs []int64
- if group != nil && requestedModel != "" && group.Platform == PlatformAnthropic {
+ if group != nil && requestedModel != "" && platform == PlatformAnthropic &&
+ (group.Platform == PlatformAnthropic || group.Platform == PlatformComposite) {
routingAccountIDs = group.GetRoutingAccountIDs(requestedModel)
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] context group routing: group_id=%d model=%s enabled=%v rules=%d matched_ids=%v session=%s sticky_account=%d",
@@ -822,8 +831,9 @@ func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupI
}
return nil
}
- // Preserve existing behavior: model routing only applies to anthropic groups.
- if group.Platform != PlatformAnthropic {
+ // Model routing applies only to requests resolved to Anthropic. Composite
+ // groups may still use those rules once their model resolved to Anthropic.
+ if group.Platform != PlatformAnthropic && group.Platform != PlatformComposite {
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] skip: non-anthropic group platform: group_id=%d group_platform=%s model=%s", group.ID, group.Platform, requestedModel)
}
@@ -888,12 +898,22 @@ func (s *GatewayService) checkClaudeCodeRestriction(ctx context.Context, groupID
return group, resolvedID, nil
}
-func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, group *Group) (string, bool, error) {
+func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, group *Group, requestedModel string) (string, bool, error) {
forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string)
if hasForcePlatform && forcePlatform != "" {
return forcePlatform, true, nil
}
+ if platform, ok := ResolvedTargetPlatformFromContext(ctx); ok {
+ return platform, false, nil
+ }
if group != nil {
+ if group.Platform == PlatformComposite {
+ targetPlatform, ok := resolveCompositeTargetPlatform(ctx, group, requestedModel)
+ if !ok {
+ return "", false, fmt.Errorf("%w supporting model: %s (composite target platform unknown)", ErrNoAvailableAccounts, requestedModel)
+ }
+ return targetPlatform, false, nil
+ }
return group.Platform, false, nil
}
if groupID != nil {
@@ -901,6 +921,13 @@ func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, gr
if err != nil {
return "", false, err
}
+ if group.Platform == PlatformComposite {
+ targetPlatform, ok := resolveCompositeTargetPlatform(ctx, group, requestedModel)
+ if !ok {
+ return "", false, fmt.Errorf("%w supporting model: %s (composite target platform unknown)", ErrNoAvailableAccounts, requestedModel)
+ }
+ return targetPlatform, false, nil
+ }
return group.Platform, false, nil
}
return PlatformAnthropic, false, nil
diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go
index 71236b69bd..1aa156f2e6 100644
--- a/backend/internal/service/gateway_usage_billing.go
+++ b/backend/internal/service/gateway_usage_billing.go
@@ -100,10 +100,19 @@ func PlatformFromAPIKey(apiKey *APIKey) string {
// 后扣运行在 worker 池的 background ctx 上没有 ForcePlatform,因此后扣平台由 handler
// 预先算定、经 RecordUsageInput.QuotaPlatform 传入,不要在后扣链路用 worker ctx 调用本函数。
func QuotaPlatform(ctx context.Context, apiKey *APIKey) string {
- if fp, ok := ctx.Value(ctxkey.ForcePlatform).(string); ok && fp != "" {
- return fp
+ if ctx != nil {
+ if fp, ok := ctx.Value(ctxkey.ForcePlatform).(string); ok && fp != "" {
+ return fp
+ }
}
- return PlatformFromAPIKey(apiKey)
+ if platform, ok := ResolvedTargetPlatformFromContext(ctx); ok {
+ return platform
+ }
+ platform := PlatformFromAPIKey(apiKey)
+ if platform == PlatformComposite {
+ return ""
+ }
+ return platform
}
func (p *postUsageBillingParams) shouldDeductAPIKeyQuota() bool {
@@ -733,6 +742,9 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
quotaPlatform := input.QuotaPlatform
if quotaPlatform == "" {
quotaPlatform = PlatformFromAPIKey(apiKey)
+ if quotaPlatform == PlatformComposite && account != nil {
+ quotaPlatform = account.Platform
+ }
}
requestID := usageLog.RequestID
_, billingErr := applyUsageBilling(ctx, requestID, usageLog, &postUsageBillingParams{
diff --git a/frontend/src/components/common/GroupSelector.vue b/frontend/src/components/common/GroupSelector.vue
index e5980ef5a8..3a067ac676 100644
--- a/frontend/src/components/common/GroupSelector.vue
+++ b/frontend/src/components/common/GroupSelector.vue
@@ -94,11 +94,11 @@ const filteredGroups = computed(() => {
// antigravity 账户启用混合调度后,可选择 anthropic/gemini 分组
if (props.platform === 'antigravity' && props.mixedScheduling) {
result = result.filter(
- (g) => g.platform === 'antigravity' || g.platform === 'anthropic' || g.platform === 'gemini'
+ (g) => g.platform === 'antigravity' || g.platform === 'anthropic' || g.platform === 'gemini' || g.platform === 'composite'
)
} else {
- // 默认:只能选择同 platform 的分组
- result = result.filter((g) => g.platform === props.platform)
+ // 默认:只能选择同 platform 的分组;composite 分组可接收任意具体平台账号
+ result = result.filter((g) => g.platform === props.platform || g.platform === 'composite')
}
}
if (isSearchable.value && searchText.value) {
diff --git a/frontend/src/components/common/PlatformIcon.vue b/frontend/src/components/common/PlatformIcon.vue
index e950b007ef..937990ad75 100644
--- a/frontend/src/components/common/PlatformIcon.vue
+++ b/frontend/src/components/common/PlatformIcon.vue
@@ -25,6 +25,13 @@
d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"
/>
+
+