fix(composite): support CN providers

Extend composite routing, pricing, migrations, and admin options for Kimi, GLM, and DeepSeek.
This commit is contained in:
wucm667
2026-08-19 13:01:25 +08:00
parent 82f7dd14f7
commit b171bb0e4a
21 changed files with 200 additions and 35 deletions
@@ -234,7 +234,7 @@ type UpdateGroupRequest struct {
type CompositeRouteRequest struct {
PublicModel string `json:"public_model" binding:"required"`
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
Priority int `json:"priority"`
@@ -71,13 +71,11 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
}
}
// 守住 composite 路由目标不放行 CN:CN 平台不可作为 composite 路由目标
// (DetectModelPlatform/isConcreteRequestPlatform 均无 CN 分支,放行即打开半实现路径)。
func TestCompositeRouteTargetPlatform_StillExcludesCNProviders(t *testing.T) {
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"composite target_platform %q 应保持被拒", platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
require.Equal(t, platform, req.TargetPlatform)
}
}
@@ -22,19 +22,41 @@ func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
require.Equal(t, service.PlatformOpenAI, platform)
}
func TestOpenAICompatibleTextTargetAllowsCompositeGrokModel(t *testing.T) {
func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, path := range []string{"/v1/messages", "/v1/chat/completions"} {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", path, nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, "grok-4.3"), "path=%s", path)
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok, "path=%s", path)
require.Equal(t, service.PlatformGrok, platform, "path=%s", path)
providers := []struct {
model string
platform string
}{
{model: "grok-4.3", platform: service.PlatformGrok},
{model: "kimi-k2-thinking", platform: service.PlatformKimi},
{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 _, provider := range providers {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", path, nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, provider.model), "path=%s model=%s", path, provider.model)
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok, "path=%s model=%s", path, provider.model)
require.Equal(t, provider.platform, platform, "path=%s model=%s", path, provider.model)
}
}
}
func TestResponsesWebSocketCompositePlatformGuardAllowsOpenAICompatibleProviders(t *testing.T) {
for _, platform := range []string{
service.PlatformOpenAI, service.PlatformGrok, service.PlatformKimi,
service.PlatformZhipu, service.PlatformDeepseek,
} {
require.True(t, isOpenAICompatibleTextPlatform(platform), "platform=%s", platform)
}
require.False(t, isOpenAICompatibleTextPlatform(service.PlatformAnthropic))
require.False(t, isOpenAICompatibleTextPlatform(service.PlatformGemini))
}
func TestCompositeTargetPlatformAllowedRejectsWrongOrUnknownModel(t *testing.T) {
+4 -2
View File
@@ -1147,7 +1147,7 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
seen := make(map[string]struct{})
models := make([]string, 0)
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok} {
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 {
@@ -1369,10 +1369,12 @@ 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{})
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok} {
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
continue
@@ -321,6 +321,27 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
},
},
},
{
ID: 4,
Platform: service.PlatformKimi,
Credentials: map[string]any{
"model_mapping": map[string]any{"kimi-custom": "kimi-upstream"},
},
},
{
ID: 5,
Platform: service.PlatformZhipu,
Credentials: map[string]any{
"model_mapping": map[string]any{"glm-custom": "glm-upstream"},
},
},
{
ID: 6,
Platform: service.PlatformDeepseek,
Credentials: map[string]any{
"model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"},
},
},
},
},
},
@@ -335,7 +356,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
Platform: service.PlatformComposite,
ModelsListConfig: service.GroupModelsListConfig{
Enabled: true,
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5"},
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"},
},
},
})
@@ -346,7 +367,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5"}, modelIDsForTest(got.Data))
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data))
}
func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) {
@@ -213,6 +213,16 @@ func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, m
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek)
}
func isOpenAICompatibleTextPlatform(platform string) bool {
switch platform {
case service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
return true
default:
return false
}
}
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
func NewOpenAIGatewayHandler(
gatewayService *service.OpenAIGatewayService,
@@ -333,7 +343,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
}
reqModel := 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
}
@@ -1750,7 +1760,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 || (platform != service.PlatformOpenAI && platform != service.PlatformGrok) {
if !ok || !isOpenAICompatibleTextPlatform(platform) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "Responses WebSocket API only supports OpenAI-compatible models for composite groups")
return
}
+3 -1
View File
@@ -247,6 +247,8 @@ func defaultModelsListCandidateIDs(platform string) []string {
return ids
case PlatformGrok:
return xai.DefaultModelIDs()
case PlatformKimi, PlatformZhipu, PlatformDeepseek:
return nil
case PlatformComposite:
return compositeDefaultModelsListCandidateIDs()
default:
@@ -267,7 +269,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool {
func compositeDefaultModelsListCandidateIDs() []string {
seen := make(map[string]struct{})
ids := make([]string, 0)
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok} {
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, id := range defaultModelsListCandidateIDs(platform) {
if _, ok := seen[id]; ok {
continue
@@ -174,6 +174,13 @@ func TestAdminService_CompositeModelsListCandidatesIncludeConcreteAccountMapping
"model_mapping": map[string]any{"gemini-custom": "gemini-2.5-flash"},
},
},
{
ID: 3,
Platform: PlatformKimi,
Credentials: map[string]any{
"model_mapping": map[string]any{"kimi-custom": "kimi-k2"},
},
},
},
}
groupRepo := &groupRepoStubForAdmin{
@@ -188,6 +195,7 @@ func TestAdminService_CompositeModelsListCandidatesIncludeConcreteAccountMapping
require.NoError(t, err)
require.Contains(t, candidates, "gpt-custom")
require.Contains(t, candidates, "gemini-custom")
require.Contains(t, candidates, "kimi-custom")
require.Contains(t, candidates, "gpt-5.5")
require.Contains(t, candidates, "gemini-2.5-flash")
}
+1 -1
View File
@@ -357,7 +357,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool {
// fallback used before a request target has been resolved.
func matchingPlatforms(groupPlatform string) []string {
if groupPlatform == PlatformComposite {
return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok}
return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}
}
return []string{groupPlatform}
}
@@ -2041,6 +2041,9 @@ func TestIsPlatformPricingMatch(t *testing.T) {
{"gemini does NOT match anthropic", PlatformGemini, PlatformAnthropic, false},
{"composite matches openai pricing", PlatformComposite, PlatformOpenAI, true},
{"composite matches gemini pricing", PlatformComposite, PlatformGemini, true},
{"composite matches kimi pricing", PlatformComposite, PlatformKimi, true},
{"composite matches zhipu pricing", PlatformComposite, PlatformZhipu, true},
{"composite matches deepseek pricing", PlatformComposite, PlatformDeepseek, true},
{"empty string matches nothing", "", PlatformAnthropic, false},
{"empty string matches empty", "", "", true},
}
@@ -2066,7 +2069,7 @@ func TestMatchingPlatforms(t *testing.T) {
{"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}},
{"gemini returns itself", PlatformGemini, []string{PlatformGemini}},
{"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}},
}
for _, tt := range tests {
+15 -1
View File
@@ -106,6 +106,12 @@ func DetectModelPlatform(model string) (string, bool) {
return PlatformGemini, true
case "xai", "x-ai", "grok":
return PlatformGrok, true
case "kimi", "moonshot":
return PlatformKimi, true
case "zhipu", "glm", "bigmodel":
return PlatformZhipu, true
case "deepseek":
return PlatformDeepseek, true
}
if rest != "" {
normalized = strings.TrimPrefix(rest, "models/")
@@ -133,6 +139,13 @@ func DetectModelPlatform(model string) (string, bool) {
return PlatformGemini, true
case normalized == "grok" || strings.HasPrefix(normalized, "grok-"):
return PlatformGrok, true
case strings.HasPrefix(normalized, "kimi-"),
strings.HasPrefix(normalized, "moonshot-"):
return PlatformKimi, true
case strings.HasPrefix(normalized, "glm-"):
return PlatformZhipu, true
case strings.HasPrefix(normalized, "deepseek-"):
return PlatformDeepseek, true
default:
return "", false
}
@@ -179,7 +192,8 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou
func isConcreteRequestPlatform(platform string) bool {
switch platform {
case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok:
case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek:
return true
default:
return false
@@ -25,6 +25,10 @@ func TestDetectModelPlatform(t *testing.T) {
{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: "kimi", model: "kimi-k2-thinking", platform: PlatformKimi, ok: true},
{name: "moonshot prefix", model: "moonshot/moonshot-v1-32k", platform: PlatformKimi, ok: true},
{name: "zhipu", model: "glm-5.2", platform: PlatformZhipu, ok: true},
{name: "deepseek", model: "deepseek-v4-pro", platform: PlatformDeepseek, ok: true},
{name: "unknown", model: "llama-4-maverick", ok: false},
}
@@ -59,7 +63,14 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) {
platforms = append(platforms, platform)
}
require.ElementsMatch(t,
[]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok},
[]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek},
platforms,
)
}
func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
require.True(t, isConcreteRequestPlatform(platform))
require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform))
}
}
@@ -114,6 +114,21 @@ func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing.
require.Empty(t, deleted)
}
func TestSchedulerBulkAccountEventScopesCNRebuildToFreshPlatform(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
t.Run(platform, func(t *testing.T) {
cache := newBulkEventSnapshotCache()
repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: platform, GroupIDs: []int64{12}})
svc := newBulkEventTestService(cache, repo)
err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{1}, []int64{11}), make(map[batchSeenKey]struct{}))
require.NoError(t, err)
require.ElementsMatch(t, schedulerBucketsForTest([]int64{11, 12}, platform), cache.capturedBuckets())
})
}
}
func TestSchedulerBulkAccountEventRebuildsOpenAIUngroupedBucket(t *testing.T) {
cache := newBulkEventSnapshotCache()
repo := newBulkEventAccountRepo(&Account{ID: 6, Platform: PlatformOpenAI})
@@ -325,8 +325,8 @@ func newGroupLifecycleTestService(cache SchedulerCache, accounts AccountReposito
}
func expectedGroupLifecycleBuckets(groupID int64) []SchedulerBucket {
platforms := []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok}
buckets := make([]SchedulerBucket, 0, 12)
platforms := schedulerSnapshotPlatforms()
buckets := make([]SchedulerBucket, 0, 18)
for _, platform := range platforms {
buckets = append(buckets,
SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle},
@@ -609,7 +609,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p
}
accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs)
switch account.Platform {
case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok:
case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek:
addPlatformGroups(account.Platform, accountGroupIDs)
case PlatformAntigravity:
// 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。
@@ -824,8 +824,8 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account
return s.rebuildBuckets(ctx, buckets, reason)
}
func schedulerSnapshotPlatforms() [5]string {
return [5]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok}
func schedulerSnapshotPlatforms() [8]string {
return [8]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}
}
// 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。
@@ -0,0 +1,8 @@
-- Allow Composite model routes to target the three concrete CN providers.
ALTER TABLE composite_model_routes
DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check;
ALTER TABLE composite_model_routes
ADD CONSTRAINT composite_model_routes_target_platform_check
CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek'));
@@ -0,0 +1,18 @@
package migrations
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestCompositeRoutesCNProvidersMigration(t *testing.T) {
content, err := FS.ReadFile("227_composite_routes_add_cn_providers.sql")
require.NoError(t, err)
sql := strings.Join(strings.Fields(string(content)), " ")
require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check")
require.Contains(t, sql,
"CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek'))")
}