From c12d0131cb875030e96537480d6dc639d0bfb15e Mon Sep 17 00:00:00 2001 From: HuntercodeT <61233559+HuntercodeT@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:31:48 +0800 Subject: [PATCH] fix(gateway): list mixed antigravity models for gemini groups --- .../internal/handler/gateway_models_test.go | 164 ++++++++++++++++++ backend/internal/service/gateway_service.go | 31 +++- 2 files changed, 189 insertions(+), 6 deletions(-) diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 26c0c72ec4..81df742e9b 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -1315,3 +1316,166 @@ func modelIDsForTest(models []gatewayModelItemForTest) []string { } return ids } + +// gemini 分组混合调度:组内 antigravity 账号的 gemini-* 映射也应出现在 /v1/models, +// 与路由层(listSchedulableAccountsOnce 的 useMixedScheduling)同源;claude-* 不带入, +// 非混合调度的异平台账号(anthropic)仍被过滤。 +func TestGatewayModels_GeminiGroupIncludesAntigravityGeminiMappings(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(22) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformAntigravity, + Extra: map[string]any{"mixed_scheduling": true}, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gemini-3.8-flash-high": "gemini-3.8-flash-high", + "gemini-synced-custom": "gemini-3.8-flash-high", + "gemini-3.8-flash-low": "gemini-3.8-flash-low", + "claude-sonnet-4-6": "claude-sonnet-4-6", + }, + }, + }, + {ID: 3, Platform: service.PlatformAntigravity, + Extra: map[string]any{"mixed_scheduling": false}, + Credentials: map[string]any{"model_mapping": map[string]any{"gemini-disabled-only": "gemini-3.8-flash-high"}}}, + {ID: 4, Platform: service.PlatformAntigravity, + Credentials: map[string]any{"model_mapping": map[string]any{"gemini-unset-only": "gemini-3.8-flash-high"}}}, + {ID: 5, Platform: service.PlatformGemini, + Credentials: map[string]any{"model_mapping": map[string]any{"gemini-native-only": "gemini-2.5-pro"}}}, + { + ID: 2, + Platform: service.PlatformAnthropic, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "claude-opus-4-6": "claude-opus-4-6", + }, + }, + }, + }, + }, + }, + ) + + 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.PlatformGemini}, + }) + + 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) + // 账号显式配置的 gemini-* 映射必须出现(resolveModelMapping 还会补一组 antigravity + // 默认透传别名,这里只断言显式条目与排除项,不锁定补齐后的完整集合)。 + require.Contains(t, ids, "gemini-3.8-flash-high") + require.Contains(t, ids, "gemini-synced-custom") + require.Contains(t, ids, "gemini-3.8-flash-low") + require.Contains(t, ids, "gemini-native-only") + require.NotContains(t, ids, "gemini-disabled-only") + require.NotContains(t, ids, "gemini-unset-only") + // antigravity 账号的 claude-* 映射与 anthropic 账号的映射都不应出现在 gemini 分组。 + require.NotContains(t, ids, "claude-sonnet-4-6") + require.NotContains(t, ids, "claude-opus-4-6") + for _, id := range ids { + require.True(t, strings.HasPrefix(id, "gemini-"), "unexpected non-gemini model on gemini group: %s", id) + } + // 没有回落到 geminicli 静态表。 + require.NotContains(t, ids, "gemini-2.0-flash") +} + +// antigravity 账号未配置 model_mapping 时使用 DefaultAntigravityModelMapping: +// gemini 分组应列出其中的 gemini-* 条目,而不是回落到 geminicli 静态表;claude-* 仍不出现。 +func TestGatewayModels_GeminiGroupUsesAntigravityDefaultMappingWhenUnset(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(23) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + {ID: 1, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Credentials: map[string]any{}}, + }, + }, + }, + ) + + 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.PlatformGemini}, + }) + + 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, "gemini-3.8-flash-high") + for _, id := range ids { + require.True(t, strings.HasPrefix(id, "gemini-"), "unexpected non-gemini model on gemini group: %s", id) + } + // geminicli 静态表独有条目不应出现(说明没有回落到默认列表)。 + require.NotContains(t, ids, "gemini-2.0-flash") +} + +// Codex 通过 /models?client_version= 走 CodexModels,同样应看到混合调度账号的 gemini-* 映射。 +func TestGatewayModels_CodexGeminiGroupListsAntigravityGeminiMappings(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(24) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformAntigravity, + Extra: map[string]any{"mixed_scheduling": true}, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gemini-3.8-flash-high": "gemini-3.8-flash-high", + "gemini-synced-custom": "gemini-3.8-flash-high", + "claude-sonnet-4-6": "claude-sonnet-4-6", + }, + }, + }, + }, + }, + }, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformGemini}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + slugs := codexModelSlugsForTest(got.Models) + require.Contains(t, slugs, "gemini-3.8-flash-high") + require.Contains(t, slugs, "gemini-synced-custom") + require.NotContains(t, slugs, "claude-sonnet-4-6") + require.NotContains(t, slugs, "gemini-2.0-flash") + for _, slug := range slugs { + require.True(t, strings.HasPrefix(slug, "gemini-"), "unexpected non-gemini model on gemini group: %s", slug) + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 97e35fdb1b..897979dec5 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1375,6 +1375,19 @@ func (s *GatewayService) DoGrokNativeResponsesJSON(ctx context.Context, account return respBytes, nil } +// mixedListingAccountAllowed mirrors the mixed-scheduling rule in +// GeminiMessagesCompatService.listSchedulableAccountsOnce: a gemini group may be +// served by antigravity accounts, so model listing must consider them too. +func mixedListingAccountAllowed(groupPlatform string, account *Account) bool { + return groupPlatform == PlatformGemini && account.IsMixedSchedulingEnabled() +} + +// mixedListingModelAllowed limits what a mixed-scheduling account may advertise +// on the group's platform: only gemini-* wire IDs are meaningful on a gemini group. +func mixedListingModelAllowed(groupPlatform, model string) bool { + return groupPlatform == PlatformGemini && isAntigravityGeminiModel(model) +} + func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, platform string) []string { cacheKey := modelsListCacheKey(groupID, platform) if s.modelsListCache != nil { @@ -1400,11 +1413,13 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, return nil } - // Filter by platform if specified + // Filter by platform if specified. Mixed scheduling (a gemini group routing + // to antigravity accounts) is honoured here as well, so the advertised list + // stays in sync with what the request path can actually serve. if platform != "" { filtered := make([]Account, 0) for _, acc := range accounts { - if acc.Platform == platform { + if acc.Platform == platform || mixedListingAccountAllowed(platform, &acc) { filtered = append(filtered, acc) } } @@ -1428,11 +1443,15 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, } mapping := acc.GetModelMapping() - if len(mapping) > 0 { - hasAnyMapping = true - for model := range mapping { - modelSet[model] = struct{}{} + for model := range mapping { + // Accounts pulled in through mixed scheduling only contribute the + // models that belong to the listing platform (e.g. an antigravity + // account's claude-* mappings must not surface on a gemini group). + if platform != "" && acc.Platform != platform && !mixedListingModelAllowed(platform, model) { + continue } + modelSet[model] = struct{}{} + hasAnyMapping = true } }