diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 1c3ecb07e1..99833e912a 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -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"` diff --git a/backend/internal/handler/admin/group_handler_platform_test.go b/backend/internal/handler/admin/group_handler_platform_test.go index ca1703180c..aeba6d1c5a 100644 --- a/backend/internal/handler/admin/group_handler_platform_test.go +++ b/backend/internal/handler/admin/group_handler_platform_test.go @@ -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) } } diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 2fa79c538b..b5911e218b 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -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) { diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 07315d5efc..139b254f17 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -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 diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 58f0190297..00569d1371 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -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) { diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index f632803b7d..7ef14b84de 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -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 } diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index f8f9acb4e7..c0fa3d7df7 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -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 diff --git a/backend/internal/service/admin_service_composite_group_test.go b/backend/internal/service/admin_service_composite_group_test.go index 5530720f5e..822fcf546c 100644 --- a/backend/internal/service/admin_service_composite_group_test.go +++ b/backend/internal/service/admin_service_composite_group_test.go @@ -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") } diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index a2c4bf6ebf..ee732dbeef 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -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} } diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index 9a81913b04..e443b80b59 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -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 { diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index b5def4ff54..e4d836febe 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -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 diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index 290fc4ac0e..e145634889 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -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)) + } +} diff --git a/backend/internal/service/scheduler_snapshot_bulk_event_test.go b/backend/internal/service/scheduler_snapshot_bulk_event_test.go index 00454a498d..0f6003a01a 100644 --- a/backend/internal/service/scheduler_snapshot_bulk_event_test.go +++ b/backend/internal/service/scheduler_snapshot_bulk_event_test.go @@ -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}) diff --git a/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go b/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go index ffc697eafc..d088f3df7a 100644 --- a/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go +++ b/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go @@ -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}, diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index 9655f54f25..da69cb83fc 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -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。 diff --git a/backend/migrations/227_composite_routes_add_cn_providers.sql b/backend/migrations/227_composite_routes_add_cn_providers.sql new file mode 100644 index 0000000000..ec4b228241 --- /dev/null +++ b/backend/migrations/227_composite_routes_add_cn_providers.sql @@ -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')); diff --git a/backend/migrations/composite_routes_cn_providers_migration_test.go b/backend/migrations/composite_routes_cn_providers_migration_test.go new file mode 100644 index 0000000000..7b6bba7471 --- /dev/null +++ b/backend/migrations/composite_routes_cn_providers_migration_test.go @@ -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'))") +} diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index e3a7ca8262..4da09f900c 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -763,9 +763,8 @@ let abortController: AbortController | null = null // ── Platform config ── const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek'] -// composite 分组仅覆盖主平台(与后端 isConcreteRequestPlatform / composite-routes target_platform 一致), -// 不含国产供应商平台。 -const compositePlatforms: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] +// Composite pricing/mapping may target every concrete schedulable provider. +const compositePlatforms: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek'] // ── Helpers ── function formatDate(value: string): string { diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 73d75cb4e0..265226021f 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -4768,6 +4768,9 @@ const compositeRoutePlatformOptions = computed(() => [ { value: "gemini", label: "Gemini" }, { value: "antigravity", label: "Antigravity" }, { value: "grok", label: "Grok" }, + { value: "kimi", label: "Kimi" }, + { value: "zhipu", label: "Zhipu GLM" }, + { value: "deepseek", label: "DeepSeek" }, ]); const compositeRouteEndpointOptions = computed(() => [ diff --git a/frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts new file mode 100644 index 0000000000..d6b5abcfd7 --- /dev/null +++ b/frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('GroupsView Composite route options', () => { + it('offers Kimi, Zhipu GLM, and DeepSeek as route targets', () => { + const source = readFileSync(resolve('src/views/admin/GroupsView.vue'), 'utf8') + const options = source.slice( + source.indexOf('const compositeRoutePlatformOptions'), + source.indexOf('const compositeRouteEndpointOptions') + ) + + expect(options).toContain('{ value: "kimi", label: "Kimi" }') + expect(options).toContain('{ value: "zhipu", label: "Zhipu GLM" }') + expect(options).toContain('{ value: "deepseek", label: "DeepSeek" }') + }) +}) diff --git a/frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts b/frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts new file mode 100644 index 0000000000..bd078c8175 --- /dev/null +++ b/frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('Composite channel platform options', () => { + it('includes the CN concrete providers for pricing and model mapping', () => { + const source = readFileSync(resolve('src/views/admin/ChannelsView.vue'), 'utf8') + const declaration = source.match(/const compositePlatforms:[^=]+=[^\n]+/)?.[0] + + expect(declaration).toContain("'kimi'") + expect(declaration).toContain("'zhipu'") + expect(declaration).toContain("'deepseek'") + }) +})