fix(composite): keep CN rollout on fully supported paths

- Responses WebSocket composite whitelist stays openai+grok: CN accounts
  cannot pass the WSv2 ingress transport filter and the WS HTTP bridge
  has no Responses conversion for them, so admitting CN targets only
  turns a clear policy rejection into a misleading no-available-account
- widen the responses/input_tokens and messages/count_tokens composite
  gates to the shared openai-compatible text whitelist so CN targets get
  the same local-estimate token counting as generation
- restore the Claude default-model fallback for standalone CN groups
  (admin candidates and custom models list) while keeping composite
  listings limited to CN account mapping keys
- refresh the scheduler bulk-rebuild comment and bucket capacity hints
  for the 8-platform canonical set
This commit is contained in:
shaw
2026-08-19 14:58:32 +08:00
parent bd1ccd9736
commit aa673062e2
8 changed files with 90 additions and 21 deletions
@@ -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) {
+3 -3
View File
@@ -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{})
@@ -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)
@@ -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
}
@@ -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
}
-2
View File
@@ -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:
@@ -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)
}
}
@@ -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)...)
}