diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index b5911e218b..be1706fba3 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -34,7 +34,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) { {model: "glm-5.2", platform: service.PlatformZhipu}, {model: "deepseek-v3.2", platform: service.PlatformDeepseek}, } - for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses"} { + for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} { for _, provider := range providers { c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest("POST", path, nil) @@ -48,15 +48,17 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) { } } -func TestResponsesWebSocketCompositePlatformGuardAllowsOpenAICompatibleProviders(t *testing.T) { +// WS ingress 对 CN 账号既过不了 transport 过滤、HTTP 桥也没有 Responses 转换, +// 放行只会把明确的策略拒绝换成 "no available account",因此 WS 白名单保持 openai+grok。 +func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testing.T) { + require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI)) + require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok)) for _, platform := range []string{ - service.PlatformOpenAI, service.PlatformGrok, service.PlatformKimi, - service.PlatformZhipu, service.PlatformDeepseek, + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, + service.PlatformAnthropic, service.PlatformGemini, } { - require.True(t, isOpenAICompatibleTextPlatform(platform), "platform=%s", platform) + require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform) } - require.False(t, isOpenAICompatibleTextPlatform(service.PlatformAnthropic)) - require.False(t, isOpenAICompatibleTextPlatform(service.PlatformGemini)) } func TestCompositeTargetPlatformAllowedRejectsWrongOrUnknownModel(t *testing.T) { diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 139b254f17..1128329be8 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1150,7 +1150,9 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) if len(platformModels) == 0 { - if _, ok := schedulablePlatforms[platform]; ok { + // CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的 + // default 分支是 Claude 列表),composite 下只暴露账号映射键。 + if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) { platformModels = defaultModelIDsForPlatform(platform) } } @@ -1369,8 +1371,6 @@ func defaultModelIDsForPlatform(platform string) []string { return mergeModelIDs(ids, nil) case service.PlatformGrok: return xai.DefaultModelIDs() - case service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek: - return nil case service.PlatformComposite: ids := make([]string, 0) seen := make(map[string]struct{}) diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 00569d1371..313b3216fb 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -406,6 +407,56 @@ func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t require.NotContains(t, ids, "gemini-2.5-flash") } +// CN 供应商没有静态默认模型列表:composite 下无映射的可调度 CN 账号不得把 +// defaultModelIDsForPlatform default 分支的 Claude 列表挂到 CN 平台名下。 +func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(35) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + {ID: 1, Platform: service.PlatformOpenAI}, + {ID: 2, Platform: service.PlatformKimi}, + {ID: 3, Platform: service.PlatformZhipu}, + {ID: 4, Platform: service.PlatformDeepseek}, + }, + }, + }, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + + ids := modelIDsForTest(got.Data) + require.Contains(t, ids, "gpt-5.5") + require.NotContains(t, ids, "claude-sonnet-4-6") +} + +// 独立 CN 分组沿用 default 分支的 Claude 默认列表(Claude Code 客户端请求的 +// 就是这些模型名并经账号 model_mapping 转换),composite 支持不得改变该回退。 +func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T) { + want := make([]string, 0, len(claude.DefaultModels)) + for _, model := range claude.DefaultModels { + want = append(want, model.ID) + } + for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { + require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform) + } +} + func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index f300b31b95..3e9ac06e43 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -60,7 +60,7 @@ func (h *OpenAIGatewayHandler) ResponsesInputTokens(c *gin.Context) { } reqModel := strings.TrimSpace(modelResult.String()) ensureCompositeTargetPlatform(c, apiKey, reqModel) - if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI, service.PlatformGrok) { + if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } @@ -243,7 +243,9 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { reqModel := parsedReq.Model ensureCompositeTargetPlatform(c, apiKey, reqModel) - if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI) { + // composite+grok 在路由层已分流到 GrokCountTokens,这里可达的目标平台是 + // openai 与 CN 供应商;CN 账号由 ForwardCountTokensAsAnthropic 本地估算。 + if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) { h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 7ef14b84de..f386a40b8c 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -213,10 +213,13 @@ func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, m service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek) } -func isOpenAICompatibleTextPlatform(platform string) bool { +// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket +// 上可服务的目标平台。CN 供应商(kimi/zhipu/deepseek)刻意排除:其账号无法通过 +// WSv2 ingress 的 transport 过滤,且 WS HTTP 桥没有面向 CN 的 Responses 转换, +// 放行只会把明确的策略拒绝变成误导性的 "no available account"。 +func isResponsesWebSocketCompositePlatform(platform string) bool { switch platform { - case service.PlatformOpenAI, service.PlatformGrok, - service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek: + case service.PlatformOpenAI, service.PlatformGrok: return true default: return false @@ -1760,7 +1763,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { ctx = c.Request.Context() if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { platform, ok := service.ResolvedTargetPlatformFromContext(ctx) - if !ok || !isOpenAICompatibleTextPlatform(platform) { + if !ok || !isResponsesWebSocketCompositePlatform(platform) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "Responses WebSocket API only supports OpenAI-compatible models for composite groups") return } diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index aff7fb6d96..7b64e4b60f 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -247,8 +247,6 @@ func defaultModelsListCandidateIDs(platform string) []string { return ids case PlatformGrok: return xai.DefaultModelIDs() - case PlatformKimi, PlatformZhipu, PlatformDeepseek: - return nil case PlatformComposite: return compositeDefaultModelsListCandidateIDs() default: diff --git a/backend/internal/service/admin_service_composite_group_test.go b/backend/internal/service/admin_service_composite_group_test.go index 822fcf546c..f1c00482b1 100644 --- a/backend/internal/service/admin_service_composite_group_test.go +++ b/backend/internal/service/admin_service_composite_group_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/stretchr/testify/require" ) @@ -199,3 +200,15 @@ func TestAdminService_CompositeModelsListCandidatesIncludeConcreteAccountMapping require.Contains(t, candidates, "gpt-5.5") require.Contains(t, candidates, "gemini-2.5-flash") } + +// 独立 CN 分组的模型列表候选沿用 default 分支的 Claude 默认列表; +// composite 支持不得改变独立分组的候选语义。 +func TestAdminService_CNProviderModelsListCandidatesKeepClaudeDefaults(t *testing.T) { + want := make([]string, 0, len(claude.DefaultModels)) + for _, model := range claude.DefaultModels { + want = append(want, model.ID) + } + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + require.Equal(t, want, defaultModelsListCandidateIDs(platform), "platform=%s", platform) + } +} diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index da69cb83fc..e38d0291ba 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -587,7 +587,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p rebuildGroupIDs = append(rebuildGroupIDs, gid) } - // 缺失账户无法确定原平台,保留五平台重建以避免遗留旧快照。 + // 缺失账户无法确定原平台,保留全平台重建以避免遗留旧快照。 if !allAccountsFound { return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen) } @@ -837,7 +837,7 @@ func schedulerBucketsForGroup(groupID int64) []SchedulerBucket { } func schedulerCanonicalBuckets(groupID int64) []SchedulerBucket { - buckets := make([]SchedulerBucket, 0, 12) + buckets := make([]SchedulerBucket, 0, 18) for _, platform := range schedulerSnapshotPlatforms() { buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle}, @@ -855,7 +855,7 @@ func (s *SchedulerSnapshotService) rebuildByGroupIDs(ctx context.Context, groupI if len(groupIDs) == 0 { return nil } - buckets := make([]SchedulerBucket, 0, len(groupIDs)*12) + buckets := make([]SchedulerBucket, 0, len(groupIDs)*18) for _, platform := range schedulerSnapshotPlatforms() { buckets = append(buckets, s.bucketsForPlatform(platform, groupIDs, seen)...) }