mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user