diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index f889281e87..463a1839a9 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -113,6 +113,14 @@ var DefaultAntigravityModelMapping = map[string]string{ // Claude 白名单 "claude-fable-5-1": "claude-fable-5-1", // 官方模型 "claude-fable-5": "claude-fable-5", // 官方模型 + "claude-opus-5-5": "claude-opus-5-5-high", // 简称映射 + "claude-opus-5-5-high": "claude-opus-5-5-high", // 官方模型 + "claude-opus-5-5-medium": "claude-opus-5-5-medium", // 官方模型 + "claude-opus-5-5-low": "claude-opus-5-5-low", // 官方模型 + "claude-sonnet-5-5": "claude-sonnet-5-5-high", // 简称映射 + "claude-sonnet-5-5-high": "claude-sonnet-5-5-high", // 官方模型 + "claude-sonnet-5-5-medium": "claude-sonnet-5-5-medium", // 官方模型 + "claude-sonnet-5-5-low": "claude-sonnet-5-5-low", // 官方模型 "claude-opus-4-8": "claude-opus-4-8", // 官方模型 "claude-opus-4-7": "claude-opus-4-7", // 官方模型 "claude-opus-4-6-thinking": "claude-opus-4-6-thinking", // 官方模型 @@ -170,6 +178,11 @@ var DefaultAntigravityModelMapping = map[string]string{ "gemini-3.8-flash-low": "gemini-3.8-flash-low", "gemini-3.8-flash-medium": "gemini-3.8-flash-medium", "gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered", + // Gemini 4 Argon tiered models + "gemini-4-argon": "gemini-4-argon-high", + "gemini-4-argon-high": "gemini-4-argon-high", + "gemini-4-argon-low": "gemini-4-argon-low", + "gemini-4-argon-medium": "gemini-4-argon-medium", // Gemini 3 image 兼容映射(向 3.1 image 迁移) "gemini-3-pro-image": "gemini-3.1-flash-image", "gemini-3-pro-image-preview": "gemini-3.1-flash-image", diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index 076688d6c0..430d4802ee 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -105,6 +105,28 @@ func TestDefaultAntigravityModelMapping_Gemini38FlashModels(t *testing.T) { } } +func TestDefaultAntigravityModelMapping_Gemini4ArgonAndClaude55Models(t *testing.T) { + cases := map[string]string{ + "gemini-4-argon": "gemini-4-argon-high", + "gemini-4-argon-high": "gemini-4-argon-high", + "gemini-4-argon-low": "gemini-4-argon-low", + "gemini-4-argon-medium": "gemini-4-argon-medium", + "claude-opus-5-5": "claude-opus-5-5-high", + "claude-opus-5-5-high": "claude-opus-5-5-high", + "claude-opus-5-5-low": "claude-opus-5-5-low", + "claude-opus-5-5-medium": "claude-opus-5-5-medium", + "claude-sonnet-5-5": "claude-sonnet-5-5-high", + "claude-sonnet-5-5-high": "claude-sonnet-5-5-high", + "claude-sonnet-5-5-low": "claude-sonnet-5-5-low", + "claude-sonnet-5-5-medium": "claude-sonnet-5-5-medium", + } + for from, want := range cases { + if got := DefaultAntigravityModelMapping[from]; got != want { + t.Fatalf("expected %s to map to %q, got %q", from, want, got) + } + } +} + func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) { t.Parallel() diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index f954cce572..4c2fbd37ca 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -3004,13 +3004,13 @@ func antigravityAccountTestModels(rawMapping any) []antigravity.ClaudeModel { switch mapping := rawMapping.(type) { case map[string]any: for id := range mapping { - if strings.TrimSpace(id) != "" { + if strings.TrimSpace(id) != "" && !strings.Contains(id, "*") { mappedIDs = append(mappedIDs, id) } } case map[string]string: for id := range mapping { - if strings.TrimSpace(id) != "" { + if strings.TrimSpace(id) != "" && !strings.Contains(id, "*") { mappedIDs = append(mappedIDs, id) } } diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go index b8f091bccc..79fbbc79e0 100644 --- a/backend/internal/handler/admin/account_handler_available_models_test.go +++ b/backend/internal/handler/admin/account_handler_available_models_test.go @@ -345,11 +345,12 @@ func TestAccountHandlerGetAvailableModels_Antigravity(t *testing.T) { {name: "empty JSON mapping", credentials: map[string]any{"model_mapping": map[string]any{}}, want: defaults}, {name: "empty string mapping", credentials: map[string]any{"model_mapping": map[string]string{}}, want: defaults}, {name: "blank key", credentials: map[string]any{"model_mapping": map[string]any{" ": "upstream-model"}}, want: defaults}, - {name: "JSON mapping keys and metadata", credentials: map[string]any{"model_mapping": map[string]any{ - known.ID: known.ID, custom.ID: "upstream-model", + {name: "wildcard only falls back to defaults", credentials: map[string]any{"model_mapping": map[string]any{"claude-*": "claude-sonnet-4-6", "gemini-3*": "gemini-3-flash"}}, want: defaults}, + {name: "JSON mapping keys and metadata skips wildcards", credentials: map[string]any{"model_mapping": map[string]any{ + known.ID: known.ID, custom.ID: "upstream-model", "claude-*": "claude-sonnet-4-6", }}, want: []antigravity.ClaudeModel{custom, known}}, - {name: "string mapping keys and metadata", credentials: map[string]any{"model_mapping": map[string]string{ - known.ID: known.ID, custom.ID: "upstream-model", + {name: "string mapping keys and metadata skips wildcards", credentials: map[string]any{"model_mapping": map[string]string{ + known.ID: known.ID, custom.ID: "upstream-model", "gemini-*": "gemini-3-flash", }}, want: []antigravity.ClaudeModel{custom, known}}, } for _, tc := range cases { diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index e3bb7be616..25b05a8c7a 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -1408,6 +1408,60 @@ func modelIDsForTest(models []gatewayModelItemForTest) []string { return ids } +func TestGatewayModels_AntigravityExplicitMappingAppearsInBothCatalogs(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 93 + mapping := map[string]any{ + "gemini-3.8-flash": "gemini-3.8-flash-high", + "gemini-3.8-flash-high": "gemini-3.8-flash-high", + "claude-opus-4-6-thinking": "claude-opus-4-6-thinking", + "gemini-pro-agent": "gemini-pro-agent", + } + for _, platform := range []string{service.PlatformAntigravity, service.PlatformComposite, service.PlatformGemini} { + t.Run(platform, func(t *testing.T) { + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{groupID: {{ + ID: 25, Platform: service.PlatformAntigravity, + Extra: map[string]any{"mixed_scheduling": true}, + Credentials: map[string]any{"model_mapping": mapping}, + }}}, + }) + want := []string{"gemini-3.8-flash", "gemini-3.8-flash-high", "gemini-pro-agent", "claude-opus-4-6-thinking"} + if platform == service.PlatformGemini { + want = want[:3] + } + for _, codex := range []bool{false, true} { + 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: platform}, + }) + var ids []string + if codex { + h.CodexModels(c) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + for _, model := range got.Models { + ids = append(ids, model.Slug) + } + } else { + h.Models(c) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + ids = modelIDsForTest(got.Data) + } + require.Equal(t, http.StatusOK, rec.Code) + // 显式条目必须同时出现在两种目录;默认透传补齐的条目不锁定。 + require.Subset(t, ids, want, "codex=%t", codex) + if platform == service.PlatformGemini { + require.NotContains(t, ids, "claude-opus-4-6-thinking", "codex=%t", codex) + } + } + }) + } +} + // gemini 分组混合调度:组内 antigravity 账号的 gemini-* 映射也应出现在 /v1/models, // 与路由层(listSchedulableAccountsOnce 的 useMixedScheduling)同源;claude-* 不带入, // 非混合调度的异平台账号(anthropic)仍被过滤。 diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 8627e0d139..95c550302e 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -39,9 +39,11 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { googleError(c, http.StatusUnauthorized, "Invalid API key") return } - // 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组 + // 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组(composite 分组允许路由到 antigravity) forcePlatform, hasForcePlatform := middleware.GetForcePlatformFromContext(c) - if !hasForcePlatform && effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini { + effectivePlatform := effectiveAPIKeyPlatform(c, apiKey) + if !hasForcePlatform && effectivePlatform != service.PlatformGemini && + !servesCompositeAntigravityGemini(apiKey, effectivePlatform) { googleError(c, http.StatusBadRequest, "API key group platform is not gemini") return } @@ -223,9 +225,11 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { googleError(c, http.StatusUnauthorized, "Invalid API key") return } - // 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组 + // 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组(composite 分组允许路由到 antigravity) forcePlatform, hasForcePlatform := middleware.GetForcePlatformFromContext(c) - if !hasForcePlatform && effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini { + effectivePlatform := effectiveAPIKeyPlatform(c, apiKey) + if !hasForcePlatform && effectivePlatform != service.PlatformGemini && + !servesCompositeAntigravityGemini(apiKey, effectivePlatform) { googleError(c, http.StatusBadRequest, "API key group platform is not gemini") return } @@ -244,9 +248,13 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" { modelName = strings.TrimSpace(resolvedModel) } + if !hasForcePlatform && servesCompositeAntigravityGemini(apiKey, effectivePlatform) && !isAntigravityGeminiModel(modelName) { + googleError(c, http.StatusBadRequest, "API key group platform is not gemini") + return + } - // 强制 antigravity 模式:返回 antigravity 模型信息 - if forcePlatform == service.PlatformAntigravity { + // 强制 antigravity 模式或 composite 路由到 antigravity:返回 antigravity 模型信息 + if forcePlatform == service.PlatformAntigravity || effectivePlatform == service.PlatformAntigravity { c.JSON(http.StatusOK, antigravity.FallbackGeminiModel(modelName)) return } @@ -299,9 +307,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { zap.Any("group_id", apiKey.GroupID), ) - // 检查平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则要求 gemini 分组 + // 检查平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则要求 gemini 分组(composite 分组允许路由到 antigravity) + effectivePlatform := effectiveAPIKeyPlatform(c, apiKey) if !middleware.HasForcePlatform(c) { - if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini { + if effectivePlatform != service.PlatformGemini && + !servesCompositeAntigravityGemini(apiKey, effectivePlatform) { googleError(c, http.StatusBadRequest, "API key group platform is not gemini") return } @@ -321,6 +331,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" { modelName = strings.TrimSpace(resolvedModel) } + if !middleware.HasForcePlatform(c) && servesCompositeAntigravityGemini(apiKey, effectivePlatform) && !isAntigravityGeminiModel(modelName) { + googleError(c, http.StatusBadRequest, "API key group platform is not gemini") + return + } stream := action == "streamGenerateContent" reqLog = reqLog.With(zap.String("model", modelName), zap.String("action", action), zap.Bool("stream", stream)) @@ -567,7 +581,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { return } if len(fs.FailedAccountIDs) == 0 { - cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini) + diagPlatform := service.PlatformGemini + if p := effectiveAPIKeyPlatform(c, apiKey); p == service.PlatformAntigravity { + diagPlatform = service.PlatformAntigravity + } + cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, diagPlatform) if !cls.ModelNotFound { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) } @@ -1053,3 +1071,16 @@ func derefGroupID(groupID *int64) int64 { } return *groupID } + +func servesCompositeAntigravityGemini(apiKey *service.APIKey, effectivePlatform string) bool { + return apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite && effectivePlatform == service.PlatformAntigravity +} + +func isAntigravityGeminiModel(model string) bool { + trimmed := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(model, "models/"))) + if trimmed == "" || strings.HasPrefix(trimmed, "claude-") { + return false + } + detected, ok := service.DetectModelPlatform(trimmed) + return ok && detected == service.PlatformGemini +} diff --git a/backend/internal/handler/gemini_v1beta_handler_test.go b/backend/internal/handler/gemini_v1beta_handler_test.go index 44d66d5c97..4f84879599 100644 --- a/backend/internal/handler/gemini_v1beta_handler_test.go +++ b/backend/internal/handler/gemini_v1beta_handler_test.go @@ -216,3 +216,75 @@ func TestShouldFallbackGeminiModel_DelegatesScopeFallback(t *testing.T) { } require.True(t, shouldFallbackGeminiModel("gemini-future-model", res)) } + +func TestGeminiV1BetaGetModel_CompositeAntigravityRoute(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("composite route to antigravity gemini model succeeds", func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-4-argon", nil) + req = req.WithContext(service.WithCompositeRouteDecision(req.Context(), service.CompositeRouteDecision{ + Matched: true, + TargetPlatform: service.PlatformAntigravity, + UpstreamModel: "gemini-4-argon-high", + })) + c.Request = req + c.Params = gin.Params{{Key: "model", Value: "gemini-4-argon"}} + groupID := int64(4) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ + ID: 1, + GroupID: &groupID, + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + (&GatewayHandler{}).GeminiV1BetaGetModel(c) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), "models/gemini-4-argon-high") + }) + + t.Run("composite route to antigravity claude model is rejected on v1beta", func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodGet, "/v1beta/models/claude-sonnet-5-5-high", nil) + req = req.WithContext(service.WithCompositeRouteDecision(req.Context(), service.CompositeRouteDecision{ + Matched: true, + TargetPlatform: service.PlatformAntigravity, + UpstreamModel: "claude-sonnet-5-5-high", + })) + c.Request = req + c.Params = gin.Params{{Key: "model", Value: "claude-sonnet-5-5-high"}} + groupID := int64(4) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ + ID: 1, + GroupID: &groupID, + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + (&GatewayHandler{}).GeminiV1BetaGetModel(c) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + + t.Run("composite route to antigravity claude model is rejected on generateContent", func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPost, "/v1beta/models/claude-sonnet-5-5-high:generateContent", nil) + req = req.WithContext(service.WithCompositeRouteDecision(req.Context(), service.CompositeRouteDecision{ + Matched: true, + TargetPlatform: service.PlatformAntigravity, + UpstreamModel: "claude-sonnet-5-5-high", + })) + c.Request = req + c.Params = gin.Params{{Key: "modelAction", Value: "/claude-sonnet-5-5-high:generateContent"}} + groupID := int64(4) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ + ID: 1, + GroupID: &groupID, + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1, Concurrency: 1}) + + (&GatewayHandler{}).GeminiV1BetaModels(c) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) +} diff --git a/backend/internal/pkg/antigravity/claude_types.go b/backend/internal/pkg/antigravity/claude_types.go index 61e32b615d..3af852c8f1 100644 --- a/backend/internal/pkg/antigravity/claude_types.go +++ b/backend/internal/pkg/antigravity/claude_types.go @@ -169,6 +169,12 @@ type modelDef struct { var claudeModels = []modelDef{ {ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1", CreatedAt: "2026-09-01T00:00:00Z"}, {ID: "claude-fable-5", DisplayName: "Claude Fable 5", CreatedAt: "2026-06-09T00:00:00Z"}, + {ID: "claude-opus-5-5-high", DisplayName: "Claude Opus 5.5 (High)", CreatedAt: "2026-09-22T00:00:00Z"}, + {ID: "claude-opus-5-5-medium", DisplayName: "Claude Opus 5.5 (Medium)", CreatedAt: "2026-09-22T00:00:00Z"}, + {ID: "claude-opus-5-5-low", DisplayName: "Claude Opus 5.5 (Low)", CreatedAt: "2026-09-22T00:00:00Z"}, + {ID: "claude-sonnet-5-5-high", DisplayName: "Claude Sonnet 5.5 (High)", CreatedAt: "2026-09-28T00:00:00Z"}, + {ID: "claude-sonnet-5-5-medium", DisplayName: "Claude Sonnet 5.5 (Medium)", CreatedAt: "2026-09-28T00:00:00Z"}, + {ID: "claude-sonnet-5-5-low", DisplayName: "Claude Sonnet 5.5 (Low)", CreatedAt: "2026-09-28T00:00:00Z"}, {ID: "claude-opus-4-5-thinking", DisplayName: "Claude Opus 4.5 Thinking", CreatedAt: "2025-11-01T00:00:00Z"}, {ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", CreatedAt: "2025-09-29T00:00:00Z"}, {ID: "claude-sonnet-4-5-thinking", DisplayName: "Claude Sonnet 4.5 Thinking", CreatedAt: "2025-09-29T00:00:00Z"}, @@ -208,6 +214,9 @@ var geminiModels = []modelDef{ {ID: "gemini-3.8-flash-low", DisplayName: "Gemini 3.8 Flash Low", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true}, {ID: "gemini-3.8-flash-medium", DisplayName: "Gemini 3.8 Flash Medium", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true}, {ID: "gemini-3.8-flash-tiered", DisplayName: "Gemini 3.8 Flash", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true}, + {ID: "gemini-4-argon-high", DisplayName: "Gemini 4 Argon (High)", CreatedAt: "2026-10-13T00:00:00Z", IsReasoning: true}, + {ID: "gemini-4-argon-low", DisplayName: "Gemini 4 Argon (Low)", CreatedAt: "2026-10-13T00:00:00Z", IsReasoning: true}, + {ID: "gemini-4-argon-medium", DisplayName: "Gemini 4 Argon (Medium)", CreatedAt: "2026-10-13T00:00:00Z", IsReasoning: true}, {ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true}, {ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"}, } @@ -277,6 +286,9 @@ func FallbackGeminiModel(model string) GeminiModel { // IsGeminiReasoningModel 判断是否为不支持参数和强制 ToolConfig 的 Gemini 推理模型 func IsGeminiReasoningModel(modelID string) bool { lowerID := strings.ToLower(modelID) + if strings.Contains(lowerID, "gemini-pro-agent") || strings.Contains(lowerID, "gemini-4-argon") { + return true + } for _, m := range geminiModels { if strings.Contains(lowerID, m.ID) && m.IsReasoning { return true diff --git a/backend/internal/pkg/antigravity/claude_types_test.go b/backend/internal/pkg/antigravity/claude_types_test.go index 1aa5bca312..e65dcc2862 100644 --- a/backend/internal/pkg/antigravity/claude_types_test.go +++ b/backend/internal/pkg/antigravity/claude_types_test.go @@ -39,6 +39,15 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) { "gemini-3.8-flash-low", "gemini-3.8-flash-medium", "gemini-3.8-flash-tiered", + "gemini-4-argon-high", + "gemini-4-argon-low", + "gemini-4-argon-medium", + "claude-opus-5-5-high", + "claude-opus-5-5-low", + "claude-opus-5-5-medium", + "claude-sonnet-5-5-high", + "claude-sonnet-5-5-low", + "claude-sonnet-5-5-medium", } for _, id := range requiredIDs { @@ -67,3 +76,23 @@ func TestClaudeContentItem_ThinkingAlwaysHasSignature(t *testing.T) { t.Fatalf("text block = %s, want %s", got, want) } } + +func TestIsGeminiReasoningModel_IncludesProAgent(t *testing.T) { + t.Parallel() + + if !IsGeminiReasoningModel("gemini-pro-agent") { + t.Fatal("expected gemini-pro-agent to be recognized as a Gemini reasoning model") + } + if !IsGeminiReasoningModel("gemini-3.8-flash-high") { + t.Fatal("expected gemini-3.8-flash-high to be recognized as a Gemini reasoning model") + } + if !IsGeminiReasoningModel("gemini-4-argon") { + t.Fatal("expected gemini-4-argon to be recognized as a Gemini reasoning model") + } + if !IsGeminiReasoningModel("gemini-4-argon-high") { + t.Fatal("expected gemini-4-argon-high to be recognized as a Gemini reasoning model") + } + if IsGeminiReasoningModel("gemini-2.5-flash") { + t.Fatal("did not expect gemini-2.5-flash to be recognized as a Gemini reasoning model") + } +} diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index a3ea6709f7..b147e5b24b 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -239,10 +239,39 @@ func TierIDToPlanType(tierID string) string { if tierID == "" { return "Free" } + // Google 新增的细分档位(如 g1-ultra-lite-tier、g1-pro-plus-tier)仍归入 Ultra / Pro。 + if plan := paidPlanFromTierID(tierID); plan != "" { + return plan + } return tierID } } +// paidPlanFromTierID 识别 g1-ultra-*-tier / g1-pro-*-tier 形式的付费档位 ID,未知时返回空串。 +func paidPlanFromTierID(tierID string) string { + id := strings.ToLower(strings.TrimSpace(tierID)) + if !strings.HasSuffix(id, "-tier") { + return "" + } + switch { + case strings.HasPrefix(id, "g1-ultra-"): + return "Ultra" + case strings.HasPrefix(id, "g1-pro-"): + return "Pro" + default: + return "" + } +} + +// IsPaidPlanType 判断账号记录的 plan_type 是否为付费套餐,兼容旧记录里直接保存的档位 ID。 +func IsPaidPlanType(planType string) bool { + switch strings.ToLower(strings.TrimSpace(planType)) { + case "pro", "ultra": + return true + } + return paidPlanFromTierID(planType) != "" +} + // Client Antigravity API 客户端 type Client struct { httpClient *http.Client @@ -653,8 +682,22 @@ type FetchAvailableModelsResponse struct { } // FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON -// 支持 URL fallback:sandbox → daily → prod +// 保留旧调用的 URL fallback:prod → daily。 func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) { + return c.fetchAvailableModels(ctx, BaseURLs, accessToken, projectID, bodyLimit, false) +} + +// FetchAvailableModelsFromURL 获取指定端点的模型目录,不回退到其他端点。 +// 模型发现应使用与生成请求相同的端点,避免返回该端点无法访问的模型。 +func (c *Client) FetchAvailableModelsFromURL(ctx context.Context, baseURL, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return nil, nil, errors.New("fetchAvailableModels base URL is required") + } + return c.fetchAvailableModels(ctx, []string{baseURL}, accessToken, projectID, bodyLimit, true) +} + +func (c *Client) fetchAvailableModels(ctx context.Context, availableURLs []string, accessToken, projectID string, bodyLimit int64, fixedEndpoint bool) (*FetchAvailableModelsResponse, map[string]any, error) { if c == nil || c.httpClient == nil { return nil, nil, errors.New("antigravity client is not configured") } @@ -668,10 +711,10 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI return nil, nil, fmt.Errorf("序列化请求失败: %w", err) } - // 固定顺序:prod -> daily - availableURLs := BaseURLs - fetchClient := c.fetchAvailableModelsHTTPClient() + if fixedEndpoint { + fetchClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + } var lastErr error for urlIdx, baseURL := range availableURLs { apiURL := baseURL + "/v1internal:fetchAvailableModels" diff --git a/backend/internal/pkg/antigravity/client_test.go b/backend/internal/pkg/antigravity/client_test.go index 7a525879fa..b6a3427885 100644 --- a/backend/internal/pkg/antigravity/client_test.go +++ b/backend/internal/pkg/antigravity/client_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -260,6 +261,8 @@ func TestTierIDToPlanType(t *testing.T) { {"free-tier", "Free"}, {"g1-pro-tier", "Pro"}, {"g1-ultra-tier", "Ultra"}, + {"g1-ultra-lite-tier", "Ultra"}, + {"G1-Pro-Plus-Tier", "Pro"}, {"FREE-TIER", "Free"}, {"", "Free"}, {"unknown-tier", "unknown-tier"}, @@ -1468,6 +1471,113 @@ func TestClient_LoadCodeAssist_ContextCanceled_RealCall(t *testing.T) { // Client.FetchAvailableModels - 真正调用方法的测试 // --------------------------------------------------------------------------- +func TestClient_FetchAvailableModelsFromURL_UsesSelectedEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1internal:fetchAvailableModels" || r.Method != http.MethodPost { + t.Errorf("unexpected model list request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer selected-token" { + t.Errorf("unexpected authorization header") + } + var body FetchAvailableModelsRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Project != "selected-project" { + t.Errorf("unexpected project request: %+v, %v", body, err) + } + _, _ = w.Write([]byte(`{"models":{"gemini-3.8-flash-high":{"displayName":"Gemini 3.8 Flash High","supportsImages":true,"supportsThinking":true,"thinkingBudget":24576,"maxTokens":1048576,"maxOutputTokens":65536}}}`)) + })) + defer server.Close() + + client := mustNewClient(t, "") + response, raw, err := client.FetchAvailableModelsFromURL(context.Background(), server.URL, "selected-token", "selected-project", defaultFetchAvailableModelsBodyLimit) + if err != nil { + t.Fatalf("selected endpoint should return its catalog: %v", err) + } + model, ok := response.Models["gemini-3.8-flash-high"] + if !ok || model.SupportsThinking == nil || !*model.SupportsThinking || model.MaxTokens == nil || *model.MaxTokens != 1048576 { + t.Fatalf("selected endpoint metadata must remain available: %+v", model) + } + if raw["models"] == nil { + t.Fatal("selected endpoint raw catalog must remain available") + } +} + +func TestClient_FetchAvailableModelsFromURL_DoesNotFollowAnotherEndpoint(t *testing.T) { + otherEndpointCalls := 0 + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + otherEndpointCalls++ + _, _ = w.Write([]byte(`{"models":{"other-endpoint-model":{}}}`)) + })) + defer other.Close() + selected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+"/v1internal:fetchAvailableModels", http.StatusTemporaryRedirect) + })) + defer selected.Close() + withMockBaseURLs(t, []string{selected.URL, other.URL}) + + response, _, err := mustNewClient(t, "").FetchAvailableModelsFromURL(context.Background(), selected.URL, "token", "project", defaultFetchAvailableModelsBodyLimit) + if err == nil || response != nil { + t.Fatalf("selected endpoint redirect must not produce another endpoint's catalog: %v, %v", response, err) + } + if otherEndpointCalls != 0 { + t.Fatalf("model discovery must stay on selected endpoint; other endpoint received %d requests", otherEndpointCalls) + } +} + +func TestClient_FetchAvailableModelsFromURL_DoesNotFallbackOnFailure(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusForbidden} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + otherEndpointCalls := 0 + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + otherEndpointCalls++ + _, _ = w.Write([]byte(`{"models":{"other-endpoint-model":{}}}`)) + })) + defer other.Close() + selected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"error":"selected endpoint unavailable"}`)) + })) + defer selected.Close() + withMockBaseURLs(t, []string{selected.URL, other.URL}) + + response, raw, err := mustNewClient(t, "").FetchAvailableModelsFromURL(context.Background(), selected.URL, "token", "project", defaultFetchAvailableModelsBodyLimit) + if err == nil || response != nil || raw != nil { + t.Fatalf("selected endpoint HTTP %d must fail without another catalog: %v, %v", status, response, err) + } + if otherEndpointCalls != 0 { + t.Fatalf("another endpoint must not receive fallback requests: %d", otherEndpointCalls) + } + if status == http.StatusForbidden { + if _, ok := err.(*ForbiddenError); !ok { + t.Fatalf("403 must preserve typed forbidden error: %T", err) + } + } + }) + } +} + +func TestClient_FetchAvailableModelsFromURL_ValidatesResponseAndLimit(t *testing.T) { + for _, tc := range []struct { + name string + body string + limit int64 + }{ + {name: "invalid JSON", body: `not JSON`, limit: defaultFetchAvailableModelsBodyLimit}, + {name: "oversized response", body: `{"models":{"model":{}}}`, limit: 8}, + {name: "nonpositive limit", body: `{"models":{"model":{}}}`, limit: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, tc.body) + })) + defer server.Close() + _, _, err := mustNewClient(t, "").FetchAvailableModelsFromURL(context.Background(), server.URL, "token", "project", tc.limit) + if err == nil { + t.Fatal("invalid model catalog response or limit must fail") + } + }) + } +} + func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -1787,6 +1897,51 @@ func TestClient_FetchAvailableModels_404Fallback_RealCall(t *testing.T) { } } +func TestClient_HubUserAgentAndIDEVersionUseContextOverride(t *testing.T) { + withTestUserAgentVersionResolver(t) + ctx := context.WithValue(context.Background(), userAgentVersionTestContextKey{}, "3.4.5") + const expectedUserAgent = "antigravity/hub/3.4.5 windows/amd64" + for _, action := range []string{"generateContent", "streamGenerateContent"} { + req, err := NewAPIRequestWithURL(ctx, "https://upstream.example", action, "test-token", []byte(`{}`)) + if err != nil { + t.Fatal(err) + } + if got := req.Header.Get("User-Agent"); got != expectedUserAgent { + t.Fatalf("generation request must use the context Hub user agent: %q", got) + } + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("User-Agent"); got != expectedUserAgent { + t.Errorf("client API must use the context Hub user agent: %q", got) + } + switch r.URL.Path { + case "/v1internal:loadCodeAssist": + var body LoadCodeAssistRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body.Metadata.IDEVersion != "3.4.5" { + t.Errorf("ideVersion must match the user agent version: %q", body.Metadata.IDEVersion) + } + _, _ = io.WriteString(w, `{"cloudaicompanionProject":"test-project"}`) + case "/v1internal:fetchAvailableModels": + _, _ = io.WriteString(w, `{"models":{"gemini-4-argon-high":{}}}`) + default: + t.Errorf("unexpected client endpoint: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + withMockBaseURLs(t, []string{server.URL}) + client := mustNewClient(t, "") + if _, _, err := client.LoadCodeAssist(ctx, "test-token"); err != nil { + t.Fatal(err) + } + if _, _, err := client.FetchAvailableModelsFromURL(ctx, server.URL, "test-token", "test-project", defaultFetchAvailableModelsBodyLimit); err != nil { + t.Fatal(err) + } +} + func TestExtractProjectIDFromOnboardResponse(t *testing.T) { t.Parallel() diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go index 625dcaea69..2ad14afd91 100644 --- a/backend/internal/pkg/antigravity/oauth.go +++ b/backend/internal/pkg/antigravity/oauth.go @@ -34,7 +34,7 @@ const ( AntigravityUserAgentVersionEnv = "ANTIGRAVITY_USER_AGENT_VERSION" // DefaultUserAgentVersion 是未通过环境变量或后台设置覆盖时使用的默认版本号。 - DefaultUserAgentVersion = "2.9.1" + DefaultUserAgentVersion = "2.23.0" // 固定的 redirect_uri(用户需手动复制 code) RedirectURI = "http://localhost:8085/callback" @@ -123,9 +123,9 @@ func GetUserAgentVersionForContext(ctx context.Context) string { // BuildUserAgent 使用指定版本号构造 User-Agent;版本为空或非法时回退默认值。 func BuildUserAgent(version string) string { if normalized := NormalizeUserAgentVersion(version); normalized != "" { - return fmt.Sprintf("antigravity/%s windows/amd64", normalized) + return fmt.Sprintf("antigravity/hub/%s windows/amd64", normalized) } - return fmt.Sprintf("antigravity/%s windows/amd64", defaultUserAgentVersion) + return fmt.Sprintf("antigravity/hub/%s windows/amd64", defaultUserAgentVersion) } // GetUserAgentForContext 返回当前请求应使用的 User-Agent。 diff --git a/backend/internal/pkg/antigravity/oauth_test.go b/backend/internal/pkg/antigravity/oauth_test.go index 4ebc7e5434..f75f2b4ff8 100644 --- a/backend/internal/pkg/antigravity/oauth_test.go +++ b/backend/internal/pkg/antigravity/oauth_test.go @@ -3,16 +3,119 @@ package antigravity import ( + "context" "crypto/sha256" "encoding/base64" "encoding/hex" "net/url" "os" + "os/exec" "strings" "testing" "time" ) +func TestBuildUserAgentUsesHubClientFamily(t *testing.T) { + if got := BuildUserAgent(" 2.23.0 "); got != "antigravity/hub/2.23.0 windows/amd64" { + t.Fatalf("Hub client family must be sent with the configured version: %q", got) + } +} + +func TestBuildUserAgentValidatesCustomVersion(t *testing.T) { + previous := defaultUserAgentVersion + defaultUserAgentVersion = "2.23.0" + t.Cleanup(func() { defaultUserAgentVersion = previous }) + for _, tc := range []struct { + version string + want string + }{ + {version: " 3.4.5 ", want: "antigravity/hub/3.4.5 windows/amd64"}, + {version: "", want: "antigravity/hub/2.23.0 windows/amd64"}, + {version: "hub/3.4.5", want: "antigravity/hub/2.23.0 windows/amd64"}, + {version: "3.4", want: "antigravity/hub/2.23.0 windows/amd64"}, + {version: "3.4.5\r\nInjected: header", want: "antigravity/hub/2.23.0 windows/amd64"}, + } { + t.Run(tc.version, func(t *testing.T) { + if got := BuildUserAgent(tc.version); got != tc.want { + t.Fatalf("custom version must be validated before building Hub user agent: got %q, want %q", got, tc.want) + } + }) + } +} + +type userAgentVersionTestContextKey struct{} + +func withTestUserAgentVersionResolver(t *testing.T) { + t.Helper() + userAgentVersionMu.RLock() + previous := userAgentVersionResolver + userAgentVersionMu.RUnlock() + SetUserAgentVersionResolver(func(ctx context.Context) string { + version, _ := ctx.Value(userAgentVersionTestContextKey{}).(string) + return version + }) + t.Cleanup(func() { SetUserAgentVersionResolver(previous) }) +} + +func TestGetUserAgentForContextPreservesVersionOverride(t *testing.T) { + withTestUserAgentVersionResolver(t) + previous := defaultUserAgentVersion + defaultUserAgentVersion = "2.23.0" + t.Cleanup(func() { defaultUserAgentVersion = previous }) + for _, tc := range []struct { + version string + want string + }{ + {version: " 3.4.5 ", want: "antigravity/hub/3.4.5 windows/amd64"}, + {version: "invalid", want: "antigravity/hub/2.23.0 windows/amd64"}, + } { + ctx := context.WithValue(context.Background(), userAgentVersionTestContextKey{}, tc.version) + if got := GetUserAgentForContext(ctx); got != tc.want { + t.Fatalf("context version override must use Hub family: got %q, want %q", got, tc.want) + } + } + if got := GetUserAgentForContext(nil); got != "antigravity/hub/2.23.0 windows/amd64" { + t.Fatalf("nil context must keep the default Hub user agent: %q", got) + } +} + +func TestGetUserAgentEnvironmentOverride(t *testing.T) { + const expectedVersionEnv = "SUB2API_TEST_EXPECTED_ANTIGRAVITY_VERSION" + if expected := os.Getenv(expectedVersionEnv); expected != "" { + if got := GetDefaultUserAgentVersion(); got != expected { + t.Fatalf("environment version must be loaded during initialization: got %q, want %q", got, expected) + } + if got := GetUserAgent(); got != "antigravity/hub/"+expected+" windows/amd64" { + t.Fatalf("environment override must keep Hub family: %q", got) + } + return + } + for _, tc := range []struct { + version string + want string + }{ + {version: " 3.4.5 ", want: "3.4.5"}, + {version: "invalid", want: "2.23.0"}, + } { + t.Run(tc.version, func(t *testing.T) { + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + command := exec.Command(binary, "-test.run=^TestGetUserAgentEnvironmentOverride$") + for _, variable := range os.Environ() { + if !strings.HasPrefix(variable, AntigravityUserAgentVersionEnv+"=") && !strings.HasPrefix(variable, expectedVersionEnv+"=") { + command.Env = append(command.Env, variable) + } + } + command.Env = append(command.Env, AntigravityUserAgentVersionEnv+"="+tc.version, expectedVersionEnv+"="+tc.want) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("environment override failed: %v\n%s", err, output) + } + }) + } +} + // --------------------------------------------------------------------------- // getClientSecret // --------------------------------------------------------------------------- @@ -693,7 +796,10 @@ func TestConstants_值正确(t *testing.T) { if RedirectURI != "http://localhost:8085/callback" { t.Errorf("RedirectURI 不匹配: got %s", RedirectURI) } - if GetUserAgent() != "antigravity/2.9.1 windows/amd64" { + if DefaultUserAgentVersion != "2.23.0" { + t.Errorf("default Hub version must match the verified upstream release: got %s", DefaultUserAgentVersion) + } + if GetUserAgent() != "antigravity/hub/2.23.0 windows/amd64" { t.Errorf("UserAgent 不匹配: got %s", GetUserAgent()) } if SessionTTL != 30*time.Minute { diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 9349e54ee8..a13748926e 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -588,6 +588,7 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu ID: block.ToolUseID, }, }) + parts = append(parts, extractToolResultInlineParts(block.Content)...) } } @@ -638,6 +639,31 @@ func parseToolResultContent(content json.RawMessage, isError bool) string { return string(content) } +func extractToolResultInlineParts(content json.RawMessage) []GeminiPart { + if len(content) == 0 { + return nil + } + var innerBlocks []ContentBlock + if err := json.Unmarshal(content, &innerBlocks); err != nil { + return nil + } + var inlineParts []GeminiPart + for _, item := range innerBlocks { + if (item.Type == "image" || item.Type == "document") && + item.Source != nil && + item.Source.Type == "base64" && + strings.TrimSpace(item.Source.Data) != "" { + inlineParts = append(inlineParts, GeminiPart{ + InlineData: &GeminiInlineData{ + MimeType: item.Source.MediaType, + Data: item.Source.Data, + }, + }) + } + } + return inlineParts +} + // buildGenerationConfig 构建 generationConfig const ( defaultMaxOutputTokens = 64000 diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index 1aff9f5775..7bdb40914b 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -694,3 +694,64 @@ func TestToolConfigAlwaysPresent(t *testing.T) { }) } } + +func TestTransformClaudeToGemini_ToolResultImagesReachUpstream(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: "claude-sonnet-4-5", + Messages: []ClaudeMessage{ + { + Role: "assistant", + Content: json.RawMessage(`[ + {"type":"tool_use","id":"toolu_img_1","name":"read_image","input":{"path":"colors.png"}} + ]`), + }, + { + Role: "user", + Content: json.RawMessage(`[ + { + "type":"tool_result", + "tool_use_id":"toolu_img_1", + "content":[ + {"type":"text","text":"loaded colors.png"}, + {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}} + ] + }, + { + "type":"tool_result", + "tool_use_id":"toolu_img_1", + "content":[ + {"type":"image","source":{"type":"base64","media_type":"image/jpeg","data":"d29ybGQ="}} + ] + } + ]`), + }, + }, + } + + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.Len(t, req.Request.Contents, 2) + + userParts := req.Request.Contents[1].Parts + require.Len(t, userParts, 4) + + require.NotNil(t, userParts[0].FunctionResponse) + require.Equal(t, "read_image", userParts[0].FunctionResponse.Name) + require.Equal(t, "toolu_img_1", userParts[0].FunctionResponse.ID) + require.Equal(t, "loaded colors.png", userParts[0].FunctionResponse.Response["result"]) + + require.NotNil(t, userParts[1].InlineData) + require.Equal(t, "image/png", userParts[1].InlineData.MimeType) + require.Equal(t, "aGVsbG8=", userParts[1].InlineData.Data) + + require.NotNil(t, userParts[2].FunctionResponse) + require.Equal(t, "read_image", userParts[2].FunctionResponse.Name) + require.Equal(t, "Command executed successfully.", userParts[2].FunctionResponse.Response["result"]) + + require.NotNil(t, userParts[3].InlineData) + require.Equal(t, "image/jpeg", userParts[3].InlineData.MimeType) + require.Equal(t, "d29ybGQ=", userParts[3].InlineData.Data) +} diff --git a/backend/internal/pkg/gemini/models.go b/backend/internal/pkg/gemini/models.go index d5d4ed64ce..1d38fc9909 100644 --- a/backend/internal/pkg/gemini/models.go +++ b/backend/internal/pkg/gemini/models.go @@ -28,6 +28,7 @@ func DefaultModels() []Model { {Name: "models/gemini-3.1-pro-preview", SupportedGenerationMethods: methods}, {Name: "models/gemini-3.1-pro-preview-customtools", SupportedGenerationMethods: methods}, {Name: "models/gemini-3.1-flash-image", SupportedGenerationMethods: methods}, + {Name: "models/gemini-4-argon", SupportedGenerationMethods: methods}, } } diff --git a/backend/internal/pkg/geminicli/models.go b/backend/internal/pkg/geminicli/models.go index cc9cbc80b2..4e2fa55bb1 100644 --- a/backend/internal/pkg/geminicli/models.go +++ b/backend/internal/pkg/geminicli/models.go @@ -20,6 +20,7 @@ var DefaultModels = []Model{ {ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview", CreatedAt: ""}, {ID: "gemini-3.1-pro-preview", Type: "model", DisplayName: "Gemini 3.1 Pro Preview", CreatedAt: ""}, {ID: "gemini-3.1-flash-image", Type: "model", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: ""}, + {ID: "gemini-4-argon", Type: "model", DisplayName: "Gemini 4 Argon", CreatedAt: ""}, } // GoogleOneModels is the conservative model set exposed for legacy Google One diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 1cb756b79e..7d1428ff8e 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/url" "reflect" + "slices" "sort" "strconv" "strings" @@ -690,26 +691,7 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri } if len(result) > 0 { if a.Platform == domain.PlatformAntigravity { - ensureAntigravityDefaultPassthroughs(result, []string{ - "gemini-3-flash", - "gemini-3.1-pro-high", - "gemini-3.1-pro-low", - "gemini-3.6-flash", - "gemini-3.6-flash-high", - "gemini-3.6-flash-low", - "gemini-3.6-flash-medium", - "gemini-3.6-flash-tiered", - "gemini-3.7-flash", - "gemini-3.7-flash-high", - "gemini-3.7-flash-low", - "gemini-3.7-flash-medium", - "gemini-3.7-flash-tiered", - "gemini-3.8-flash", - "gemini-3.8-flash-high", - "gemini-3.8-flash-low", - "gemini-3.8-flash-medium", - "gemini-3.8-flash-tiered", - }) + ensureAntigravityDefaultPassthroughs(result, antigravityDefaultPassthroughModels) applyAntigravityGemini31ProAliases(result) } return result @@ -759,6 +741,51 @@ func modelMappingSignature(rawMapping map[string]any) uint64 { return h.Sum64() } +// antigravityDefaultPassthroughModels 是 Antigravity 显式 model_mapping 运行时补齐的默认透传条目: +// 让新模型发布前保存的映射也能调用这些模型。 +var antigravityDefaultPassthroughModels = []string{ + "gemini-3-flash", + "gemini-3.1-pro-high", + "gemini-3.1-pro-low", + "gemini-3.6-flash", + "gemini-3.6-flash-high", + "gemini-3.6-flash-low", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered", + "gemini-3.7-flash", + "gemini-3.7-flash-high", + "gemini-3.7-flash-low", + "gemini-3.7-flash-medium", + "gemini-3.7-flash-tiered", + "gemini-3.8-flash", + "gemini-3.8-flash-high", + "gemini-3.8-flash-low", + "gemini-3.8-flash-medium", + "gemini-3.8-flash-tiered", +} + +// isAntigravityInjectedDefaultPassthrough 判断 GetModelMapping 中的 key→target 是否仅由 +// resolveModelMapping 运行时补齐(管理员未写入 credentials.model_mapping)。 +func (a *Account) isAntigravityInjectedDefaultPassthrough(key, target string) bool { + if a == nil || a.Platform != domain.PlatformAntigravity || key != target || !slices.Contains(antigravityDefaultPassthroughModels, key) { + return false + } + rawMapping, _ := a.Credentials["model_mapping"].(map[string]any) + hasExplicitEntry := false + for _, v := range rawMapping { + if _, ok := v.(string); ok { + hasExplicitEntry = true + break + } + } + if !hasExplicitEntry { + // 空映射走默认目录,不做运行时补齐。 + return false + } + _, configured := rawMapping[key].(string) + return !configured +} + func ensureAntigravityDefaultPassthrough(mapping map[string]string, model string) { if mapping == nil || model == "" { return diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 26060efc78..210033179a 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -2464,12 +2464,12 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou } return s.testClaudeAccountConnection(c, account, modelID) } - return s.testAntigravityAccountConnection(c, account, modelID) + return s.testAntigravityAccountConnection(c, account, modelID, prompt) } // testAntigravityAccountConnection tests an Antigravity account's connection // 支持 Claude 和 Gemini 两种协议,使用非流式请求 -func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error { +func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error { ctx := c.Request.Context() testModelID := antigravityConnectionTestModel(modelID) @@ -2488,8 +2488,8 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac // Send test_start event s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) - // 调用 AntigravityGatewayService.TestConnection(复用协议转换逻辑) - result, err := s.antigravityGatewayService.TestConnection(ctx, account, testModelID) + // 调用 AntigravityGatewayService.TestConnectionWithPrompt(复用协议转换逻辑) + result, err := s.antigravityGatewayService.TestConnectionWithPrompt(ctx, account, testModelID, prompt) if err != nil { return s.sendErrorAndEnd(c, err.Error()) } @@ -2498,6 +2498,13 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac if result.Text != "" { s.sendEvent(c, TestEvent{Type: "content", Text: result.Text}) } + for _, img := range result.Images { + s.sendEvent(c, TestEvent{ + Type: "image", + ImageURL: img.ImageURL, + MimeType: img.MimeType, + }) + } s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil diff --git a/backend/internal/service/account_test_service_antigravity_test.go b/backend/internal/service/account_test_service_antigravity_test.go index b3362f3f69..6a14ea85c1 100644 --- a/backend/internal/service/account_test_service_antigravity_test.go +++ b/backend/internal/service/account_test_service_antigravity_test.go @@ -3,6 +3,7 @@ package service import ( + "encoding/json" "testing" "github.com/stretchr/testify/require" @@ -14,3 +15,68 @@ func TestAntigravityConnectionTestModel(t *testing.T) { require.Equal(t, "claude-sonnet-4-6", antigravityConnectionTestModel("")) require.Equal(t, "gemini-3.1-pro-preview", antigravityConnectionTestModel("gemini-3.1-pro-preview")) } + +func TestAntigravityBuildGeminiTestRequest_IncludesValidatedToolConfig(t *testing.T) { + t.Parallel() + + svc := &AntigravityGatewayService{} + raw, err := svc.buildGeminiTestRequestWithPrompt("proj-123", "gemini-3.8-flash-high", "") + require.NoError(t, err) + + var v1Req map[string]any + require.NoError(t, json.Unmarshal(raw, &v1Req)) + require.Equal(t, "proj-123", v1Req["project"]) + require.Equal(t, "gemini-3.8-flash-high", v1Req["model"]) + + inner, ok := v1Req["request"].(map[string]any) + require.True(t, ok) + toolCfg, ok := inner["toolConfig"].(map[string]any) + require.True(t, ok, "expected toolConfig to be present in Gemini test request") + fcCfg, ok := toolCfg["functionCallingConfig"].(map[string]any) + require.True(t, ok) + require.Equal(t, "VALIDATED", fcCfg["mode"]) +} + +func TestAntigravityBuildGeminiTestRequestWithPrompt_ImageModel(t *testing.T) { + t.Parallel() + + svc := &AntigravityGatewayService{} + raw, err := svc.buildGeminiTestRequestWithPrompt("proj-123", "gemini-3.1-flash-image", "a cute cat") + require.NoError(t, err) + + var v1Req map[string]any + require.NoError(t, json.Unmarshal(raw, &v1Req)) + inner, ok := v1Req["request"].(map[string]any) + require.True(t, ok) + + genCfg, ok := inner["generationConfig"].(map[string]any) + require.True(t, ok) + require.Equal(t, []any{"TEXT", "IMAGE"}, genCfg["responseModalities"]) + + contents, ok := inner["contents"].([]any) + require.True(t, ok) + require.Len(t, contents, 1) + firstContent := contents[0].(map[string]any) + parts := firstContent["parts"].([]any) + require.Equal(t, "a cute cat", parts[0].(map[string]any)["text"]) +} + +func TestExtractContentFromSSEResponse_ExtractsTextAndInlineImages(t *testing.T) { + t.Parallel() + + sse := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Here is your image\"},{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"iVBORw0KGgo=\"}}]}}]}}\n\n" + text, images := extractContentFromSSEResponse([]byte(sse)) + require.Equal(t, "Here is your image", text) + require.Len(t, images, 1) + require.Equal(t, "image/png", images[0].MimeType) + require.Equal(t, "data:image/png;base64,iVBORw0KGgo=", images[0].ImageURL) +} + +func TestAntigravityShouldUseGeminiTestBuilder_UsesMappedModel(t *testing.T) { + t.Parallel() + + require.True(t, shouldUseAntigravityGeminiTestRequest("custom-alias", "gemini-2.5-flash")) + require.True(t, shouldUseAntigravityGeminiTestRequest("gemini-3.1-pro", "gemini-pro-agent")) + require.False(t, shouldUseAntigravityGeminiTestRequest("gemini-alias", "claude-sonnet-4-6")) + require.False(t, shouldUseAntigravityGeminiTestRequest("claude-sonnet-4-6", "claude-sonnet-4-6")) +} diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 4a8c70dcfb..b6396cb08a 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -141,6 +141,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co injectedBody = reconciled } + injectedBody = ensureGeminiFunctionCallThoughtSignatures(injectedBody) + // 包装请求 wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody) if err != nil { diff --git a/backend/internal/service/antigravity_gateway_retry.go b/backend/internal/service/antigravity_gateway_retry.go index 78d46a4b23..ee7a983481 100644 --- a/backend/internal/service/antigravity_gateway_retry.go +++ b/backend/internal/service/antigravity_gateway_retry.go @@ -78,12 +78,7 @@ func accountHasAntigravityPaidTier(account *Account) bool { if !ok { return false } - switch strings.ToLower(strings.TrimSpace(planType)) { - case "pro", "ultra": - return true - default: - return false - } + return antigravity.IsPaidPlanType(planType) } // smartRetryAction 智能重试的处理结果 diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 086466d319..66a0f02cdb 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -344,16 +344,32 @@ func (s *AntigravityGatewayService) IsModelSupported(requestedModel string) bool strings.HasPrefix(requestedModel, "gemini-") } +// TestConnectionImage 测试连接返回的图片结果 +type TestConnectionImage struct { + ImageURL string + MimeType string +} + // TestConnectionResult 测试连接结果 type TestConnectionResult struct { - Text string // 响应文本 - MappedModel string // 实际使用的模型 + Text string // 响应文本 + Images []TestConnectionImage // 响应图片(图片生成模型) + MappedModel string // 实际使用的模型 +} + +func shouldUseAntigravityGeminiTestRequest(_, mappedModel string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(mappedModel)), "gemini-") } // TestConnection 测试 Antigravity 账号连接。 // 复用 antigravityRetryLoop 的完整重试 / credits overages / 智能重试逻辑, // 与真实调度行为一致。差异:不做账号切换(测试指定账号)、不记录 ops 错误。 func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account *Account, modelID string) (*TestConnectionResult, error) { + return s.TestConnectionWithPrompt(ctx, account, modelID, "") +} + +// TestConnectionWithPrompt 测试 Antigravity 账号连接,支持为图片生成模型指定测试提示词。 +func (s *AntigravityGatewayService) TestConnectionWithPrompt(ctx context.Context, account *Account, modelID string, prompt string) (*TestConnectionResult, error) { // 获取 token if s.tokenProvider == nil { @@ -377,8 +393,8 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account // 构建请求体 var requestBody []byte - if strings.HasPrefix(modelID, "gemini-") { - requestBody, err = s.buildGeminiTestRequest(projectID, mappedModel) + if shouldUseAntigravityGeminiTestRequest(modelID, mappedModel) { + requestBody, err = s.buildGeminiTestRequestWithPrompt(projectID, mappedModel, prompt) } else { requestBody, err = s.buildClaudeTestRequest(projectID, mappedModel) } @@ -434,8 +450,8 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account return nil, fmt.Errorf("API 返回 %d: %s", result.resp.StatusCode, string(respBody)) } - text := extractTextFromSSEResponse(respBody) - return &TestConnectionResult{Text: text, MappedModel: mappedModel}, nil + text, images := extractContentFromSSEResponse(respBody) + return &TestConnectionResult{Text: text, Images: images, MappedModel: mappedModel}, nil } // testConnectionHandleError 是 TestConnection 使用的轻量 handleError 回调。 @@ -451,9 +467,39 @@ func testConnectionHandleError( return nil } -// buildGeminiTestRequest 构建 Gemini 格式测试请求 -// 使用最小 token 消耗:输入 "." + maxOutputTokens: 1 -func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model string) ([]byte, error) { +// buildGeminiTestRequestWithPrompt 构建 Gemini 格式测试请求 +// 文本模型使用最小 token 消耗:输入 "." + maxOutputTokens: 1;图片模型使用图片生成配置 +func (s *AntigravityGatewayService) buildGeminiTestRequestWithPrompt(projectID, model, prompt string) ([]byte, error) { + if isImageGenerationModel(model) { + imagePrompt := strings.TrimSpace(prompt) + if imagePrompt == "" { + imagePrompt = defaultGeminiImageTestPrompt + } + payload := map[string]any{ + "contents": []map[string]any{ + { + "role": "user", + "parts": []map[string]any{ + {"text": imagePrompt}, + }, + }, + }, + "systemInstruction": map[string]any{ + "parts": []map[string]any{ + {"text": antigravity.GetDefaultIdentityPatch()}, + }, + }, + "generationConfig": map[string]any{ + "responseModalities": []string{"TEXT", "IMAGE"}, + "imageConfig": map[string]any{ + "aspectRatio": "1:1", + }, + }, + } + payloadBytes, _ := json.Marshal(payload) + return s.wrapV1InternalRequest(projectID, model, payloadBytes) + } + payload := map[string]any{ "contents": []map[string]any{ { @@ -472,6 +518,11 @@ func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model stri "generationConfig": map[string]any{ "maxOutputTokens": 1, }, + "toolConfig": map[string]any{ + "functionCallingConfig": map[string]any{ + "mode": "VALIDATED", + }, + }, } payloadBytes, _ := json.Marshal(payload) return s.wrapV1InternalRequest(projectID, model, payloadBytes) @@ -504,9 +555,10 @@ func (s *AntigravityGatewayService) getClaudeTransformOptions(ctx context.Contex return opts } -// extractTextFromSSEResponse 从 SSE 流式响应中提取文本 -func extractTextFromSSEResponse(respBody []byte) string { +// extractContentFromSSEResponse 从 SSE 流式响应中提取文本和内联图片 +func extractContentFromSSEResponse(respBody []byte) (string, []TestConnectionImage) { var texts []string + var images []TestConnectionImage lines := bytes.Split(respBody, []byte("\n")) for _, line := range lines { @@ -532,7 +584,7 @@ func extractTextFromSSEResponse(respBody []byte) string { continue } - // 尝试从 response.candidates[0].content.parts[].text 提取 + // 尝试从 response.candidates[0].content.parts[] 提取 response, ok := data["response"].(map[string]any) if !ok { // 尝试直接从 candidates 提取(某些响应格式) @@ -564,11 +616,21 @@ func extractTextFromSSEResponse(respBody []byte) string { if text, ok := partMap["text"].(string); ok && text != "" { texts = append(texts, text) } + if inlineData, ok := partMap["inlineData"].(map[string]any); ok { + mimeType, _ := inlineData["mimeType"].(string) + b64Data, _ := inlineData["data"].(string) + if strings.HasPrefix(strings.ToLower(mimeType), "image/") && b64Data != "" { + images = append(images, TestConnectionImage{ + ImageURL: fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data), + MimeType: mimeType, + }) + } + } } } } - return strings.Join(texts, "") + return strings.Join(texts, ""), images } // injectIdentityPatchToGeminiRequest 为 Gemini 格式请求注入身份提示词 diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go index 79ce35f596..92735948aa 100644 --- a/backend/internal/service/antigravity_gateway_service_test.go +++ b/backend/internal/service/antigravity_gateway_service_test.go @@ -2012,3 +2012,175 @@ func generateLargeUnwrapJSON(minSize int) []byte { b, _ := json.Marshal(outer) return b } + +func TestAntigravityGatewayService_ForwardGemini_NonStreamPreservesFunctionCallAndThoughtSignature(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"contents":[{"role":"user","parts":[{"text":"Call get_weather for Tokyo."}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}]}`) + + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-4-argon:generateContent", bytes.NewReader(body)) + + sseStream := strings.Join([]string{ + `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig_fc_1","functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_1"}}]}}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":16,"totalTokenCount":328,"thoughtsTokenCount":12},"modelVersion":"gemini-4-argon","responseId":"resp_1"}}`, + "", + `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":22,"totalTokenCount":334,"thoughtsTokenCount":12},"modelVersion":"gemini-4-argon","responseId":"resp_1"}}`, + "", + }, "\n") + + upstream := &queuedHTTPUpstreamStub{ + responses: []*http.Response{{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sseStream)), + }}, + } + svc := &AntigravityGatewayService{ + settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}), + tokenProvider: &AntigravityTokenProvider{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 109, + Name: "antigravity-fc-nonstream", + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "test-access-token", + "project_id": "test-project", + "model_mapping": map[string]any{ + "gemini-4-argon": "gemini-4-argon", + }, + }, + } + + result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-4-argon", "generateContent", false, body, false) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, http.StatusOK, writer.Code) + + var respPayload map[string]any + require.NoError(t, json.Unmarshal(writer.Body.Bytes(), &respPayload)) + candidates, ok := respPayload["candidates"].([]any) + require.True(t, ok) + require.Len(t, candidates, 1) + candidate, ok := candidates[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "STOP", candidate["finishReason"]) + + content, ok := candidate["content"].(map[string]any) + require.True(t, ok) + parts, ok := content["parts"].([]any) + require.True(t, ok) + require.Len(t, parts, 1, "trailing empty text part should not overwrite or pollute functionCall part") + + part0, ok := parts[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "sig_fc_1", part0["thoughtSignature"]) + fc, ok := part0["functionCall"].(map[string]any) + require.True(t, ok) + require.Equal(t, "get_weather", fc["name"]) + require.Equal(t, "call_1", fc["id"]) + require.Equal(t, map[string]any{"city": "Tokyo"}, fc["args"]) +} + +func TestAntigravityGatewayService_ForwardGemini_StreamAndFunctionResponseFollowUp(t *testing.T) { + gin.SetMode(gin.TestMode) + + // 1) Stream request with functionDeclarations returns unwrapped functionCall and thoughtSignature + streamReqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"Call get_weather for Tokyo."}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}]}]}`) + streamWriter := httptest.NewRecorder() + streamCtx, _ := gin.CreateTestContext(streamWriter) + streamCtx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-4-argon:streamGenerateContent?alt=sse", bytes.NewReader(streamReqBody)) + + streamSSE := strings.Join([]string{ + `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig_stream_1","functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_s1"}}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}}`, + "", + `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":6}}}`, + "", + }, "\n") + + // 2) Non-stream follow-up with functionCall (missing thoughtSignature) + functionResponse (with inlineData image) + followUpReqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"Call get_weather for Tokyo."}]},{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_s1"}}]},{"role":"user","parts":[{"functionResponse":{"name":"get_weather","id":"call_s1","response":{"weather":"Sunny"},"parts":[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="}}]}}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}]}]}`) + followUpWriter := httptest.NewRecorder() + followUpCtx, _ := gin.CreateTestContext(followUpWriter) + followUpCtx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-4-argon:generateContent", bytes.NewReader(followUpReqBody)) + + followUpSSE := strings.Join([]string{ + `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"It is sunny in "}]}}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":4}}}`, + "", + `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"Tokyo."}]}}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":6}}}`, + "", + `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig_final","text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":8}}}`, + "", + }, "\n") + + upstream := &queuedHTTPUpstreamStub{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(streamSSE)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(followUpSSE)), + }, + }, + } + + svc := &AntigravityGatewayService{ + settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}), + tokenProvider: &AntigravityTokenProvider{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 110, + Name: "antigravity-fc-stream", + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "test-access-token", + "project_id": "test-project", + "model_mapping": map[string]any{ + "gemini-4-argon": "gemini-4-argon", + }, + }, + } + + res1, err := svc.ForwardGemini(context.Background(), streamCtx, account, "gemini-4-argon", "streamGenerateContent", true, streamReqBody, false) + require.NoError(t, err) + require.NotNil(t, res1) + require.Contains(t, streamWriter.Body.String(), `"functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_s1"}`) + require.Contains(t, streamWriter.Body.String(), `"thoughtSignature":"sig_stream_1"`) + + res2, err := svc.ForwardGemini(context.Background(), followUpCtx, account, "gemini-4-argon", "generateContent", false, followUpReqBody, false) + require.NoError(t, err) + require.NotNil(t, res2) + require.Len(t, upstream.requestBodies, 2) + + // Verify follow-up request had dummy thoughtSignature injected on functionCall and preserved functionResponse image part + secondSent := string(upstream.requestBodies[1]) + require.Contains(t, secondSent, `"thoughtSignature":"skip_thought_signature_validator"`) + require.Contains(t, secondSent, `"mimeType":"image/png"`) + + var followUpResp map[string]any + require.NoError(t, json.Unmarshal(followUpWriter.Body.Bytes(), &followUpResp)) + cands, ok := followUpResp["candidates"].([]any) + require.True(t, ok) + cand0, ok := cands[0].(map[string]any) + require.True(t, ok) + content, ok := cand0["content"].(map[string]any) + require.True(t, ok) + parts, ok := content["parts"].([]any) + require.True(t, ok) + require.NotEmpty(t, parts) + firstPart, ok := parts[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "It is sunny in Tokyo.", firstPart["text"]) +} diff --git a/backend/internal/service/antigravity_gateway_streaming.go b/backend/internal/service/antigravity_gateway_streaming.go index 675f0d7f87..58d5b9ce4f 100644 --- a/backend/internal/service/antigravity_gateway_streaming.go +++ b/backend/internal/service/antigravity_gateway_streaming.go @@ -350,8 +350,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont var firstTokenMs *int var last map[string]any var lastWithParts map[string]any - var collectedImageParts []map[string]any // 收集所有包含图片的 parts - var collectedTextParts []string // 收集所有文本片段 + var collectedParts []map[string]any // 收集所有 parts(text、thinking、functionCall、inlineData 等) type scanEvent struct { line string @@ -467,19 +466,10 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont } } - // 保留最后一个有 parts 的响应 + // 保留最后一个有 parts 的响应,并收集所有 parts if parts := extractGeminiParts(parsed); len(parts) > 0 { lastWithParts = parsed - // 收集包含图片和文本的 parts - for _, part := range parts { - if inlineData, ok := part["inlineData"].(map[string]any); ok { - collectedImageParts = append(collectedImageParts, part) - _ = inlineData // 避免 unused 警告 - } - if text, ok := part["text"].(string); ok && text != "" { - collectedTextParts = append(collectedTextParts, text) - } - } + collectedParts = append(collectedParts, parts...) } case <-intervalCh: @@ -506,14 +496,9 @@ returnResponse: } } - // 如果收集到了图片 parts,需要合并到最终响应中 - if len(collectedImageParts) > 0 { - finalResponse = mergeImagePartsToResponse(finalResponse, collectedImageParts) - } - - // 如果收集到了文本,需要合并到最终响应中 - if len(collectedTextParts) > 0 { - finalResponse = mergeTextPartsToResponse(finalResponse, collectedTextParts) + // 将收集的所有 parts 合并到最终响应中 + if len(collectedParts) > 0 { + finalResponse = mergeCollectedPartsToResponse(finalResponse, collectedParts) } respBody, err := json.Marshal(finalResponse) @@ -576,21 +561,27 @@ func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map return response } - result, _, setParts := getOrCreateGeminiParts(response) + result, existingParts, setParts := getOrCreateGeminiParts(response) // 合并策略: // 1. 保持原始顺序 - // 2. 连续的普通 text parts 合并为一个 + // 2. 连续的普通 text parts 合并为一个,并保留关联的 thoughtSignature // 3. thinking、functionCall、inlineData 等保持原样 var mergedParts []any var textBuffer strings.Builder + var textSignature string flushTextBuffer := func() { - if textBuffer.Len() > 0 { - mergedParts = append(mergedParts, map[string]any{ + if textBuffer.Len() > 0 || textSignature != "" { + part := map[string]any{ "text": textBuffer.String(), - }) + } + if textSignature != "" { + part["thoughtSignature"] = textSignature + } + mergedParts = append(mergedParts, part) textBuffer.Reset() + textSignature = "" } } @@ -605,6 +596,9 @@ func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map } else { // 普通 text,累积到 buffer _, _ = textBuffer.WriteString(text) + if sig, ok := part["thoughtSignature"].(string); ok && sig != "" { + textSignature = sig + } } } else { // 非 text part(functionCall、inlineData 等),先刷新 text buffer,然后保留原样 @@ -616,76 +610,14 @@ func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map // 刷新剩余的 text flushTextBuffer() + if len(mergedParts) == 0 { + mergedParts = existingParts + } + setParts(mergedParts) return result } -// mergeImagePartsToResponse 将收集到的图片 parts 合并到 Gemini 响应中 -func mergeImagePartsToResponse(response map[string]any, imageParts []map[string]any) map[string]any { - if len(imageParts) == 0 { - return response - } - - result, existingParts, setParts := getOrCreateGeminiParts(response) - - // 检查现有 parts 中是否已经有图片 - for _, p := range existingParts { - if pm, ok := p.(map[string]any); ok { - if _, hasInline := pm["inlineData"]; hasInline { - return result // 已有图片,不重复添加 - } - } - } - - // 添加收集到的图片 parts - for _, imgPart := range imageParts { - existingParts = append(existingParts, imgPart) - } - setParts(existingParts) - return result -} - -// mergeTextPartsToResponse 将收集到的文本合并到 Gemini 响应中 -func mergeTextPartsToResponse(response map[string]any, textParts []string) map[string]any { - if len(textParts) == 0 { - return response - } - - mergedText := strings.Join(textParts, "") - result, existingParts, setParts := getOrCreateGeminiParts(response) - - // 查找并更新第一个 text part,或创建新的 - newParts := make([]any, 0, len(existingParts)+1) - textUpdated := false - - for _, p := range existingParts { - pm, ok := p.(map[string]any) - if !ok { - newParts = append(newParts, p) - continue - } - if _, hasText := pm["text"]; hasText && !textUpdated { - // 用累积的文本替换 - newPart := make(map[string]any) - for k, v := range pm { - newPart[k] = v - } - newPart["text"] = mergedText - newParts = append(newParts, newPart) - textUpdated = true - } else { - newParts = append(newParts, pm) - } - } - - if !textUpdated { - newParts = append([]any{map[string]any{"text": mergedText}}, newParts...) - } - - setParts(newParts) - return result -} - // antigravityStatusClientClosed 是客户端在上游响应前断开时回写的状态码(499 client closed request)。 const antigravityStatusClientClosed = 499 diff --git a/backend/internal/service/antigravity_gemini_thinking_variant.go b/backend/internal/service/antigravity_gemini_thinking_variant.go index 88c4906864..e0ebc5c470 100644 --- a/backend/internal/service/antigravity_gemini_thinking_variant.go +++ b/backend/internal/service/antigravity_gemini_thinking_variant.go @@ -108,17 +108,6 @@ func geminiThinkingLevelFromClaudeThinking(thinking *antigravity.ThinkingConfig) } } -// accountRawModelMappingHasKey 判断 key 是否由用户写在账号 credentials.model_mapping 里 -// (而不是 resolveModelMapping 运行时补进来的默认透传条目)。 -func accountRawModelMappingHasKey(account *Account, key string) bool { - if account == nil || account.Credentials == nil { - return false - } - raw, _ := account.Credentials["model_mapping"].(map[string]any) - _, ok := raw[key] - return ok -} - // resolveGeminiThinkingVariant 为裸 Gemini 模型名挑选账号映射表里存在的思考深度变体。 // 返回 (映射后的上游模型名, 是否命中)。未命中时调用方应回退到常规 getMappedModel 流程。 func resolveGeminiThinkingVariant(account *Account, requestedModel string, body []byte) (string, bool) { @@ -140,16 +129,16 @@ func resolveGeminiThinkingVariantForLevel(account *Account, requestedModel strin if len(mapping) == 0 { return "", false } - // 裸名本身已有"真正的"映射 → 尊重现有配置,不做推导。判定分两层: - // 1. 映射目标不是自己(如 gemini-3.8-flash → gemini-3.8-flash-tiered):用户明确指定了目标; - // 2. 映射目标是自己(原样透传):仅当这条是用户亲手写进 credentials.model_mapping 的才尊重。 - // resolveModelMapping 会给每个 Antigravity 账号自动补 gemini-3.x-flash 裸名自映射 - // (ensureAntigravityDefaultPassthroughs),而上游目录里并没有裸名模型,那条自映射 - // 正是导致 404 的来源,不能算作用户意图。 - if mapped, matched := resolveRequestedModelInMapping(mapping, model); matched { - if strings.TrimSpace(mapped) != model || accountRawModelMappingHasKey(account, model) { - return "", false - } + // 裸名本身若已映射到其他具体模型(如 gemini-3.8-flash → gemini-3.8-flash-tiered、 + // gemini-3.1-pro → gemini-pro-agent),尊重显式别名配置。 + // 若裸名映射目标是自己(如前端从 DefaultAntigravityModelMapping 拉取并持久化的 + // gemini-3.8-flash → gemini-3.8-flash,或 ensureAntigravityDefaultPassthroughs 注入的条目), + // 当映射表中存在该模型的精确思考变体(-high/-medium/-low/-tiered)时继续解析变体, + // 避免把上游不存在的裸名直接发出导致 404;而对已在映射表中的真实裸模型(如 gemini-2.5-flash), + // 仅查找精确变体键以避免被通配符误匹配。 + mapped, modelMatched := resolveRequestedModelInMapping(mapping, model) + if modelMatched && strings.TrimSpace(mapped) != model { + return "", false } if preferred == "" { @@ -163,8 +152,17 @@ func resolveGeminiThinkingVariantForLevel(account *Account, requestedModel strin } for _, level := range order { candidate := model + "-" + level - if mapped, matched := resolveRequestedModelInMapping(mapping, candidate); matched && strings.TrimSpace(mapped) != "" { - return mapped, true + var ( + variantMapped string + variantMatched bool + ) + if modelMatched { + variantMapped, variantMatched = mapping[candidate] + } else { + variantMapped, variantMatched = resolveRequestedModelInMapping(mapping, candidate) + } + if variantMatched && strings.TrimSpace(variantMapped) != "" { + return variantMapped, true } } return "", false diff --git a/backend/internal/service/antigravity_gemini_thinking_variant_test.go b/backend/internal/service/antigravity_gemini_thinking_variant_test.go index d0c17765a9..d700182bc1 100644 --- a/backend/internal/service/antigravity_gemini_thinking_variant_test.go +++ b/backend/internal/service/antigravity_gemini_thinking_variant_test.go @@ -74,10 +74,10 @@ func TestResolveGeminiThinkingVariant(t *testing.T) { {"agy low (1000) -> -low", catalog, "gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true}, {"models/ prefix stripped", catalog, "models/gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true}, {"no thinkingConfig -> -high", catalog, "gemini-3.8-flash", []byte(`{"contents":[]}`), "gemini-3.8-flash-high", true}, - // gemini-3.6/3.7/3.8-flash 的四个变体会被 resolveModelMapping 自动补齐,所以降级用一个不在默认表里的型号 {"only -high exists: low request degrades to high", map[string]string{ "gemini-3.5-flash-high": "gemini-3.5-flash-high", }, "gemini-3.5-flash", budget("1000"), "gemini-3.5-flash-high", true}, + // gemini-3.6/3.7/3.8-flash 的四个变体会被 resolveModelMapping 自动补齐(存量显式映射账号的兼容透传)。 {"injected default variants are usable", catalog, "gemini-3.6-flash", budget("1000"), "gemini-3.6-flash-low", true}, {"already suffixed: untouched", catalog, "gemini-3.8-flash-low", budget("-1"), "", false}, {"bare name explicitly mapped: untouched", catalog, "gemini-2.5-flash", budget("-1"), "", false}, @@ -90,16 +90,22 @@ func TestResolveGeminiThinkingVariant(t *testing.T) { {"variant value is itself a mapping target", map[string]string{ "gemini-3.8-flash-high": "gemini-3.8-flash-tiered", }, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-tiered", true}, - {"user-written bare identity passthrough: respected", map[string]string{ + // 前端持久化的默认目录含裸名自映射;-low 由默认透传补齐,按预算命中。 + {"persisted default bare identity passthrough still resolves when variants exist", map[string]string{ "gemini-3.8-flash": "gemini-3.8-flash", "gemini-3.8-flash-high": "gemini-3.8-flash-high", - }, "gemini-3.8-flash", budget("1000"), "", false}, - {"runtime-injected bare passthrough (not in credentials): still resolved", map[string]string{ - // resolveModelMapping 会补 gemini-3.7-flash → gemini-3.7-flash 的默认透传, - // 但 credentials 里没有这条,应当继续推导变体。 + }, "gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true}, + {"bare identity passthrough resolves a configured variant outside the default passthroughs", map[string]string{ + "gemini-3.5-flash": "gemini-3.5-flash", + "gemini-3.5-flash-high": "gemini-3.5-flash-high", + }, "gemini-3.5-flash", budget("1000"), "gemini-3.5-flash-high", true}, + {"wildcard mapping does not fabricate variants for non-tiered bare model", map[string]string{ + "gemini-2.5*": "gemini-2.5-flash", + }, "gemini-2.5-flash", budget("-1"), "", false}, + {"bare model absent from whitelist: configured variant still resolved", map[string]string{ "gemini-3.7-flash-medium": "gemini-3.7-flash-medium", }, "gemini-3.7-flash", budget("4000"), "gemini-3.7-flash-medium", true}, - // 空 credentials 映射 → 走 DefaultAntigravityModelMapping,其中同样只有带后缀的 3.8 flash + // 空 credentials 映射保留默认目录兼容行为。 {"empty mapping falls back to default catalog", map[string]string{}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true}, } for _, tt := range tests { diff --git a/backend/internal/service/antigravity_rate_limit_test.go b/backend/internal/service/antigravity_rate_limit_test.go index 2721e54a7f..d81219a1d9 100644 --- a/backend/internal/service/antigravity_rate_limit_test.go +++ b/backend/internal/service/antigravity_rate_limit_test.go @@ -1093,6 +1093,15 @@ func TestResolveAntigravityForwardBaseURL(t *testing.T) { name: "ultra defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "ULTRA"}}, want: dailyURL, }, + { + name: "stored ultra tier id defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "g1-ultra-lite-tier"}}, + want: dailyURL, + }, + { + name: "stored pro tier id defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "g1-pro-tier"}}, + want: dailyURL, + }, + {name: "free tier id defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "free-tier"}}, want: prodURL}, {name: "free defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "free"}}, want: prodURL}, {name: "abnormal defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "Abnormal"}}, want: prodURL}, {name: "unknown defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "enterprise"}}, want: prodURL}, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 45014870ee..7341fb123d 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1035,7 +1035,7 @@ func (s *BillingService) initFallbackPricing() { // getFallbackPricing 根据模型系列获取回退价格 func (s *BillingService) getFallbackPricing(model string) *ModelPricing { - modelLower := strings.ToLower(model) + modelLower := normalizeAntigravityClaudeTierAlias(strings.ToLower(model)) if modelLower == "jev-latest" { return s.fallbackPrices["jev-latest"] } diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index 69e452b751..7f60212bf4 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -123,6 +123,12 @@ func codexModelRoutingAccountIDs(group *Group, modelID string) []int64 { return group.GetRoutingAccountIDs(strings.TrimSpace(modelID)) } +func codexAccountServesPlatformModel(account *Account, platform, modelID string) bool { + return account.Platform == platform || + (account.Platform == PlatformAntigravity && mixedListingAccountAllowed(platform, account) && + mixedListingModelAllowed(platform, modelID)) +} + func groupCodexModelMetadata( platform string, modelID string, @@ -168,7 +174,7 @@ func groupCodexModelMetadata( if routed && !containsInt64(routedAccountIDs, account.ID) { continue } - if account.Platform == platform && codexExplicitModelMappingClaims(*account, modelID) { + if codexAccountServesPlatformModel(account, platform, modelID) && codexExplicitModelMappingClaims(*account, modelID) { explicitClaims = true break } @@ -183,7 +189,7 @@ func groupCodexModelMetadata( missingMetadata := false for i := range accounts { account := &accounts[i] - if account.Platform != platform { + if !codexAccountServesPlatformModel(account, platform, modelID) { continue } if routed && !containsInt64(routedAccountIDs, account.ID) { @@ -251,7 +257,7 @@ func codexExplicitModelTargetsConflictForPlatform(accounts []Account, platform, targets := make(map[string]struct{}) for i := range accounts { account := &accounts[i] - if account.Platform != platform { + if !codexAccountServesPlatformModel(account, platform, modelID) { continue } mappedModel, matched := account.ResolveMappedModel(modelID) diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index 16ed878e90..aafe8aae1f 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -9,6 +9,71 @@ import ( "github.com/stretchr/testify/require" ) +func TestCodexCatalogUsesAntigravityCapabilitiesInGeminiMixedGroup(t *testing.T) { + const groupID int64 = 93 + account := Account{ + ID: 25, Platform: PlatformAntigravity, Type: AccountTypeOAuth, + Extra: map[string]any{"mixed_scheduling": true}, + Credentials: map[string]any{"model_mapping": map[string]any{ + "gemini-custom": "gemini-3.8-flash-high", + }}, + } + reasoning := true + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ + Models: map[string]UpstreamModelMetadata{"gemini-3.8-flash-high": { + ID: "gemini-3.8-flash-high", Reasoning: &reasoning, + DefaultReasoningLevel: "high", SupportedReasoningLevels: []string{"high"}, + InputModalities: []string{"text", "image"}, ContextWindow: 1_048_576, + MaxOutputTokens: 65_536, + }}, + }) + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{groupID: {account}}, + }} + for _, platform := range []string{PlatformAntigravity, PlatformComposite, PlatformGemini} { + t.Run(platform, func(t *testing.T) { + body, err := svc.BuildCodexModelsManifestForGroup(context.Background(), + &Group{ID: groupID, Platform: platform}, "", []string{"gemini-custom"}) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, "gemini-custom", model["slug"]) + require.Equal(t, float64(1_048_576), model["context_window"]) + require.Equal(t, []any{"text", "image"}, model["input_modalities"]) + require.Equal(t, []string{"high"}, effortsFromManifestModel(t, model)) + }) + } +} + +func TestCodexCatalogGeminiCapabilitiesExcludeNonMixedAntigravityAccounts(t *testing.T) { + const groupID int64 = 94 + reasoning := true + native := Account{ID: 1, Platform: PlatformGemini, Type: AccountTypeAPIKey, + Credentials: map[string]any{"model_mapping": map[string]any{"gemini-custom": "gemini-3.8-flash-high"}}, + } + native.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ + Models: map[string]UpstreamModelMetadata{"gemini-3.8-flash-high": { + ID: "gemini-3.8-flash-high", Reasoning: &reasoning, + SupportedReasoningLevels: []string{"high"}, InputModalities: []string{"text", "image"}, + ContextWindow: 1_048_576, + }}, + }) + for _, extra := range []map[string]any{nil, {"mixed_scheduling": false}} { + // A peer lacking capabilities would narrow the catalog if included. + peer := Account{ID: 2, Platform: PlatformAntigravity, Type: AccountTypeOAuth, + Extra: extra, Credentials: native.Credentials} + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{groupID: {native, peer}}, + }} + body, err := svc.BuildCodexModelsManifestForGroup(context.Background(), + &Group{ID: groupID, Platform: PlatformGemini}, "", []string{"gemini-custom"}) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, float64(1_048_576), model["context_window"]) + require.Equal(t, []any{"text", "image"}, model["input_modalities"]) + require.Equal(t, []string{"high"}, effortsFromManifestModel(t, model)) + } +} + func TestAstraUltraCatalogPreservesWorkflowMetadata(t *testing.T) { // Ultra is a Codex workflow. Its inference effort must survive catalog sync, // aliases and group generation instead of silently falling back to max. diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index ae58ddf200..40dbd00297 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -1094,7 +1094,7 @@ func uniqueCodexMappedModel(accounts []Account, platform string, modelID string) targets := make(map[string]struct{}) for i := range accounts { account := &accounts[i] - if account.Platform != platform { + if !codexAccountServesPlatformModel(account, platform, modelID) { continue } mappedModel, matched := account.ResolveMappedModel(modelID) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 5df65b5be8..6b8c6e0665 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -1309,7 +1309,7 @@ func (s *PricingService) buildModelLookupCandidates(modelLower string) []string // one later. Antigravity's Gemini thinking tiers share the base rate, // so the normalized base remains the fallback after the exact aliases. candidates := rawCandidates - if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) { + if isAntigravityThinkingTierAlias(lastSegment(modelLower)) { candidates = append(candidates, normalized) } else { // Prefer canonical model names for all other aliases (including models/xxx). @@ -1365,7 +1365,29 @@ func normalizeModelNameForPricing(model string) string { } return canonical } - return normalizeGeminiThinkingTierAlias(model) + return normalizeAntigravityClaudeTierAlias(normalizeGeminiThinkingTierAlias(model)) +} + +// normalizeAntigravityClaudeTierAlias maps Antigravity's Claude 5.5 thinking-tier +// model IDs (claude-opus-5-5-high, claude-sonnet-5-5-low, ...) to the public base +// model. Like the Gemini tiers, the suffix selects reasoning depth rather than a +// separate token rate; without this the IDs fall into substring family matching +// and are billed as claude-opus-5 / claude-sonnet-4-5. +func normalizeAntigravityClaudeTierAlias(model string) string { + for _, baseModel := range []string{"claude-opus-5-5", "claude-sonnet-5-5"} { + for _, tier := range []string{"-high", "-low", "-medium"} { + if model == baseModel+tier { + return baseModel + } + } + } + return model +} + +// isAntigravityThinkingTierAlias reports whether model is an Antigravity +// Gemini or Claude thinking-tier alias of a priced base model. +func isAntigravityThinkingTierAlias(model string) bool { + return normalizeGeminiThinkingTierAlias(model) != model || normalizeAntigravityClaudeTierAlias(model) != model } // normalizeGeminiThinkingTierAlias maps Antigravity's Gemini thinking-tier @@ -1411,6 +1433,7 @@ func (s *PricingService) extractBaseName(model string) string { // matchByModelFamily 基于模型系列匹配 func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { + model = normalizeAntigravityClaudeTierAlias(model) if claude.IsOpus55(model) { if pricing, ok := s.pricingData["claude-opus-5-5"]; ok { return pricing diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 4466be1f3f..08f5171a7c 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -1278,3 +1278,42 @@ func TestCalculateCost_ClaudeSonnetCatalogLadderIsDataDriven(t *testing.T) { require.NoError(t, err) require.False(t, cost.LongContextBillingApplied, "恰好 200000 不进高档(严格大于)") } + +// Antigravity 的 Claude 5.5 思考档位 ID 只决定推理深度,必须按基础型号计价, +// 不能落入子串家族匹配(opus-5-5-high → claude-opus-5、sonnet-5-5-high → sonnet-4-5)。 +func TestPricingService_AntigravityClaude55TiersUseBaseModelPricing(t *testing.T) { + opus := &LiteLLMModelPricing{InputCostPerToken: 4e-6, OutputCostPerToken: 20e-6} + sonnet := &LiteLLMModelPricing{InputCostPerToken: 2e-6, OutputCostPerToken: 10e-6} + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "claude-opus-5-5": opus, + "claude-sonnet-5-5": sonnet, + "claude-opus-5": {InputCostPerToken: 5e-6, OutputCostPerToken: 25e-6}, + "claude-sonnet-4-5": {InputCostPerToken: 3e-6, OutputCostPerToken: 15e-6}, + }} + for _, tier := range []string{"high", "medium", "low"} { + require.Same(t, opus, svc.GetModelPricing("claude-opus-5-5-"+tier)) + require.Same(t, sonnet, svc.GetModelPricing("claude-sonnet-5-5-"+tier)) + } + + // 价格目录缺基础型号时走家族兜底,同样不能落到 claude-opus-5 / sonnet-4-5。 + sparse := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "claude-opus-5": {InputCostPerToken: 5e-6, OutputCostPerToken: 25e-6}, + "claude-sonnet-4-5": {InputCostPerToken: 3e-6, OutputCostPerToken: 15e-6}, + }} + require.Same(t, claudeOpus55FallbackPricing, sparse.GetModelPricing("claude-opus-5-5-high")) + require.Same(t, claudeSonnet55FallbackPricing, sparse.GetModelPricing("claude-sonnet-5-5-low")) +} + +func TestBillingService_AntigravityClaude55TierFallbacksUseBaseRates(t *testing.T) { + svc := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000} + for _, tier := range []string{"high", "medium", "low"} { + for base, want := range map[string]string{"claude-opus-5-5": "claude-opus-5-5", "claude-sonnet-5-5": "claude-sonnet-5-5"} { + got, err := svc.CalculateCost(base+"-"+tier, tokens, 1) + require.NoError(t, err) + ref, err := svc.CalculateCost(want, tokens, 1) + require.NoError(t, err) + require.InDelta(t, ref.TotalCost, got.TotalCost, 1e-12, base+"-"+tier) + } + } +} diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 447f2fdb98..bfd82887f5 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -340,7 +340,11 @@ func configuredUpstreamModelsForCapabilitySync(account *Account) []string { return nil } models := make([]string, 0) - for _, mappedModel := range account.GetModelMapping() { + for requestedModel, mappedModel := range account.GetModelMapping() { + // Antigravity 运行时补齐的默认透传不是管理员配置的目标,上游目录未列出时不应拉低能力同步完整度。 + if account.isAntigravityInjectedDefaultPassthrough(requestedModel, mappedModel) { + continue + } mappedModel = strings.TrimSpace(mappedModel) if mappedModel == "" || strings.Contains(mappedModel, "*") { continue @@ -738,8 +742,7 @@ func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account } if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey { - models, err := s.fetchAntigravityOAuthUpstreamModels(ctx, account) - return models, nil, err + return s.fetchAntigravityOAuthUpstreamModels(ctx, account) } if s.httpUpstream == nil { @@ -1263,42 +1266,47 @@ func (s *AccountTestService) buildGeminiUpstreamModelsRequest(ctx context.Contex return req, nil } -func (s *AccountTestService) fetchAntigravityOAuthUpstreamModels(ctx context.Context, account *Account) ([]string, error) { +func (s *AccountTestService) fetchAntigravityOAuthUpstreamModels(ctx context.Context, account *Account) ([]string, []byte, error) { if s.antigravityGatewayService == nil || s.antigravityGatewayService.GetTokenProvider() == nil { - return nil, newUpstreamModelSyncConfigError("Antigravity token provider is not configured", nil) + return nil, nil, newUpstreamModelSyncConfigError("Antigravity token provider is not configured", nil) } accessToken, err := s.antigravityGatewayService.GetTokenProvider().GetAccessToken(ctx, account) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Failed to get Antigravity access token", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Failed to get Antigravity access token", err) } accessToken = strings.TrimSpace(accessToken) if accessToken == "" { - return nil, newUpstreamModelSyncConfigError("No Antigravity access token is available", nil) + return nil, nil, newUpstreamModelSyncConfigError("No Antigravity access token is available", nil) } client, err := antigravity.NewClient(upstreamModelsProxyURL(account)) if err != nil { - return nil, newUpstreamModelSyncConfigError("Failed to configure Antigravity client", err) + return nil, nil, newUpstreamModelSyncConfigError("Failed to configure Antigravity client", err) } - modelsResp, _, err := client.FetchAvailableModels( + modelsResp, raw, err := client.FetchAvailableModelsFromURL( ctx, + resolveAntigravityForwardBaseURL(account), accessToken, strings.TrimSpace(account.GetCredential("project_id")), resolveModelsListReadLimit(s.cfg), ) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Failed to fetch Antigravity available models", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Failed to fetch Antigravity available models", err) } if modelsResp == nil || len(modelsResp.Models) == 0 { - return nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) } models := make([]string, 0, len(modelsResp.Models)) for modelID := range modelsResp.Models { models = append(models, strings.TrimSpace(modelID)) } - return dedupeAndSortModelIDs(models), nil + body, err := json.Marshal(raw) + if err != nil { + return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read Antigravity model metadata", err) + } + return dedupeAndSortModelIDs(models), body, nil } func (s *AccountTestService) doUpstreamModelsRequest(req *http.Request, proxyURL string, account *Account) (*http.Response, error) { @@ -1394,6 +1402,12 @@ type upstreamModelCapabilityEntry struct { MaxContextWindow int64 `json:"max_context_window"` MaxOutputTokens int64 `json:"max_output_tokens"` Limit modelsDevLimit `json:"limit"` + DisplayNameCamel string `json:"displayName"` + SupportsImages *bool `json:"supportsImages"` + SupportsThinking *bool `json:"supportsThinking"` + ThinkingBudget *int64 `json:"thinkingBudget"` + MaxTokens int64 `json:"maxTokens"` + MaxOutputTokensCamel int64 `json:"maxOutputTokens"` } func extractUpstreamModelIDs(body []byte) ([]string, error) { @@ -1442,12 +1456,33 @@ func extractUpstreamModelCatalog(body []byte, grok bool) ([]string, map[string]U func extractUpstreamModelRawEntries(body []byte) ([]json.RawMessage, error) { var response struct { Data []json.RawMessage `json:"data"` - Models []json.RawMessage `json:"models"` + Models json.RawMessage `json:"models"` } if err := json.Unmarshal(body, &response); err == nil && (response.Data != nil || response.Models != nil) { - entries := make([]json.RawMessage, 0, len(response.Data)+len(response.Models)) - entries = append(entries, response.Data...) - entries = append(entries, response.Models...) + entries := append([]json.RawMessage{}, response.Data...) + if len(response.Models) > 0 && string(response.Models) != "null" { + var list []json.RawMessage + if err := json.Unmarshal(response.Models, &list); err == nil { + entries = append(entries, list...) + } else { + // Antigravity keys its model catalog by the exact generation model ID. + var models map[string]map[string]json.RawMessage + if err := json.Unmarshal(response.Models, &models); err != nil { + return nil, fmt.Errorf("parse upstream model catalog: %w", err) + } + for modelID, fields := range models { + if fields == nil { + fields = make(map[string]json.RawMessage) + } + fields["id"], _ = json.Marshal(modelID) + raw, err := json.Marshal(fields) + if err != nil { + return nil, fmt.Errorf("parse upstream model metadata: %w", err) + } + entries = append(entries, raw) + } + } + } return entries, nil } var entries []json.RawMessage @@ -1463,6 +1498,34 @@ func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapa levels = reasoningLevelsFromModelsDevOptions(entry.ReasoningOptions) } reasoning := entry.Reasoning + if reasoning == nil { + reasoning = entry.SupportsThinking + } + if len(levels) == 0 && entry.SupportsThinking != nil && *entry.SupportsThinking { + level := "high" + fixedLevel := normalizeReasoningLevel(modelID[strings.LastIndex(modelID, "-")+1:]) + if fixedLevel != "" && fixedLevel != "none" { + level = fixedLevel + } else if entry.ThinkingBudget != nil { + switch budget := *entry.ThinkingBudget; { + case budget < 0: + case budget <= geminiThinkingBudgetLowMax: + level = "low" + case budget <= geminiThinkingBudgetMediumMax: + level = "medium" + } + } + if fixedLevel == "" || fixedLevel == "none" { + // Bare and tiered AGY models accept configurable budgets. The fetched + // thinkingBudget is their default, not the only supported budget. + levels = []string{"low", "medium", "high"} + if entry.DefaultReasoningLevel == "" { + entry.DefaultReasoningLevel = level + } + } else { + levels = []string{level} + } + } if reasoning == nil && len(levels) > 0 { inferred := len(levels) != 1 || levels[0] != "none" reasoning = &inferred @@ -1471,6 +1534,13 @@ func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapa if len(modalities) == 0 { modalities = entry.Modalities.Input } + if len(modalities) == 0 && (entry.SupportsImages != nil || entry.SupportsThinking != nil || entry.MaxTokens > 0) { + // AGY models accept text; image input requires an explicit capability. + modalities = []string{"text"} + if entry.SupportsImages != nil && *entry.SupportsImages { + modalities = append(modalities, "image") + } + } contextWindow := entry.ContextWindow if contextWindow <= 0 { contextWindow = entry.MaxContextWindow @@ -1478,15 +1548,24 @@ func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapa if contextWindow <= 0 { contextWindow = entry.Limit.Context } + if contextWindow <= 0 { + contextWindow = entry.MaxTokens + } maxOutputTokens := entry.MaxOutputTokens if maxOutputTokens <= 0 { maxOutputTokens = entry.Limit.Output } + if maxOutputTokens <= 0 { + maxOutputTokens = entry.MaxOutputTokensCamel + } defaultReasoningLevel := normalizeReasoningLevel(entry.DefaultReasoningLevel) if defaultReasoningLevel == "" && len(levels) > 0 { defaultReasoningLevel = levels[0] } displayName := strings.TrimSpace(entry.DisplayName) + if displayName == "" { + displayName = strings.TrimSpace(entry.DisplayNameCamel) + } if displayName == "" && strings.TrimSpace(entry.Name) != "" && strings.TrimSpace(entry.Name) != modelID { displayName = strings.TrimSpace(entry.Name) } diff --git a/backend/internal/service/upstream_models_antigravity_test.go b/backend/internal/service/upstream_models_antigravity_test.go new file mode 100644 index 0000000000..53ff31478d --- /dev/null +++ b/backend/internal/service/upstream_models_antigravity_test.go @@ -0,0 +1,245 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/stretchr/testify/require" +) + +func antigravityModelSyncAccount(plan string) *Account { + return &Account{ + ID: 121, Platform: PlatformAntigravity, Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "model-sync-test-token", + "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + "project_id": "model-sync-test-project", + "plan_type": plan, + }, + } +} + +func antigravityModelSyncService() *AccountTestService { + return &AccountTestService{ + antigravityGatewayService: &AntigravityGatewayService{tokenProvider: &AntigravityTokenProvider{}}, + cfg: upstreamModelSyncTestConfig(), + } +} + +func withAntigravityModelSyncEndpoints(t *testing.T, production, daily string) { + t.Helper() + previous := antigravity.BaseURLs + antigravity.BaseURLs = []string{production, daily} + t.Cleanup(func() { antigravity.BaseURLs = previous }) +} + +func TestFetchUpstreamSupportedModelsAntigravityUsesGenerationEndpoint(t *testing.T) { + t.Setenv(antigravityForwardBaseURLEnv, "") + production := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"models":{"gemini-3.7-flash-high":{}}}`) + })) + defer production.Close() + daily := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"models":{"gemini-3.8-flash-high":{}}}`) + })) + defer daily.Close() + withAntigravityModelSyncEndpoints(t, production.URL, daily.URL) + + for _, tc := range []struct { + name string + plan string + mode string + model string + }{ + {name: "Ultra daily", plan: "Ultra", model: "gemini-3.8-flash-high"}, + {name: "Pro daily", plan: "Pro", model: "gemini-3.8-flash-high"}, + {name: "Free production", plan: "Free", model: "gemini-3.7-flash-high"}, + {name: "explicit production", plan: "Ultra", mode: "prod", model: "gemini-3.7-flash-high"}, + {name: "explicit daily", plan: "Free", mode: "daily", model: "gemini-3.8-flash-high"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(antigravityForwardBaseURLEnv, tc.mode) + models, err := antigravityModelSyncService().FetchUpstreamSupportedModels(context.Background(), antigravityModelSyncAccount(tc.plan)) + require.NoError(t, err) + require.Equal(t, []string{tc.model}, models, "catalog must come from the endpoint used for generation") + }) + } +} + +func TestSyncUpstreamModelCatalogAntigravityPreservesDirectCapabilities(t *testing.T) { + t.Setenv(antigravityForwardBaseURLEnv, "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"models":{"gemini-3.8-flash-high":{ + "displayName":"Gemini 3.8 Flash High", + "supportsImages":true,"supportsThinking":true,"thinkingBudget":24576, + "maxTokens":1048576,"maxOutputTokens":65536 + }}}`) + })) + defer server.Close() + withAntigravityModelSyncEndpoints(t, server.URL, server.URL) + account := antigravityModelSyncAccount("Ultra") + account.Credentials["model_mapping"] = map[string]any{"gemini-3.8-flash-high": "gemini-3.8-flash-high"} + registry := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`))}} + svc := antigravityModelSyncService() + svc.accountRepo = &upstreamModelMetadataRepoStub{} + svc.httpUpstream = registry + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"gemini-3.8-flash-high"}, catalog.Models) + require.Contains(t, catalog.Metadata, "gemini-3.8-flash-high") + metadata := catalog.Metadata["gemini-3.8-flash-high"] + require.Equal(t, "Gemini 3.8 Flash High", metadata.DisplayName) + require.NotNil(t, metadata.Reasoning) + require.True(t, *metadata.Reasoning) + require.Equal(t, []string{"high"}, metadata.SupportedReasoningLevels, "fixed variant must advertise only its own reasoning level") + require.Equal(t, "high", metadata.DefaultReasoningLevel) + require.Equal(t, []string{"text", "image"}, metadata.InputModalities) + require.Equal(t, int64(1048576), metadata.ContextWindow) + require.Equal(t, int64(65536), metadata.MaxOutputTokens) + require.Empty(t, registry.requests, "complete upstream capabilities must not need guessed registry capabilities") + require.Empty(t, catalog.Warnings) + snapshot := account.GetUpstreamModelMetadataSnapshot() + require.NotNil(t, snapshot) + require.Equal(t, "upstream", snapshot.Source) + serializedMetadata, err := json.Marshal(metadata) + require.NoError(t, err) + serializedSnapshotEntry, err := json.Marshal(snapshot.Models["gemini-3.8-flash-high"]) + require.NoError(t, err) + require.JSONEq(t, string(serializedMetadata), string(serializedSnapshotEntry)) +} + +func TestSyncUpstreamModelCatalogAntigravityTieredReasoning(t *testing.T) { + t.Setenv(antigravityForwardBaseURLEnv, "") + for _, tc := range []struct { + budget string + defaultLevel string + }{ + {budget: "-1", defaultLevel: "high"}, + {budget: "0", defaultLevel: "low"}, + {budget: "1024", defaultLevel: "low"}, + {budget: "1025", defaultLevel: "medium"}, + {budget: "4000", defaultLevel: "medium"}, + {budget: "8192", defaultLevel: "medium"}, + {budget: "8193", defaultLevel: "high"}, + {defaultLevel: "high"}, + } { + t.Run("budget="+tc.budget, func(t *testing.T) { + budgetJSON := "" + if tc.budget != "" { + budgetJSON = `"thinkingBudget":` + tc.budget + "," + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, fmt.Sprintf(`{"models":{"gemini-3.8-flash-tiered":{ + "supportsImages":false,"supportsThinking":true,%s + "maxTokens":1048576,"maxOutputTokens":65536 + }}}`, budgetJSON)) + })) + defer server.Close() + withAntigravityModelSyncEndpoints(t, server.URL, server.URL) + account := antigravityModelSyncAccount("Ultra") + account.Credentials["model_mapping"] = map[string]any{"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered"} + svc := antigravityModelSyncService() + svc.accountRepo = &upstreamModelMetadataRepoStub{} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + metadata := catalog.Metadata["gemini-3.8-flash-tiered"] + require.Equal(t, []string{"low", "medium", "high"}, metadata.SupportedReasoningLevels) + require.Equal(t, tc.defaultLevel, metadata.DefaultReasoningLevel, "tiered default must follow its upstream thinking budget") + require.Equal(t, []string{"text"}, metadata.InputModalities) + }) + } +} + +func TestSyncUpstreamModelCatalogAntigravityMissingImageSupportIsTextOnly(t *testing.T) { + t.Setenv(antigravityForwardBaseURLEnv, "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"models":{"claude-plain":{ + "supportsThinking":false,"maxTokens":200000,"maxOutputTokens":64000 + }}}`) + })) + defer server.Close() + withAntigravityModelSyncEndpoints(t, server.URL, server.URL) + account := antigravityModelSyncAccount("Ultra") + account.Credentials["model_mapping"] = map[string]any{"claude-plain": "claude-plain"} + registry := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`))}} + svc := antigravityModelSyncService() + svc.accountRepo = &upstreamModelMetadataRepoStub{} + svc.httpUpstream = registry + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + metadata := catalog.Metadata["claude-plain"] + require.Equal(t, []string{"text"}, metadata.InputModalities, "missing image declaration must not advertise image input") + require.NotNil(t, metadata.Reasoning) + require.False(t, *metadata.Reasoning) + require.Empty(t, metadata.SupportedReasoningLevels) + require.Empty(t, registry.requests) +} + +func TestSyncUpstreamModelCatalogAntigravityBudgetReasoningLevels(t *testing.T) { + t.Setenv(antigravityForwardBaseURLEnv, "") + for _, tc := range []struct { + model string + budget int + defaultLevel string + levels []string + }{ + {model: "gemini-2.5-flash", budget: -1, defaultLevel: "high", levels: []string{"low", "medium", "high"}}, + {model: "gemini-2.5-flash-lite", budget: 4000, defaultLevel: "medium", levels: []string{"low", "medium", "high"}}, + {model: "gemini-3.8-flash-low", budget: -1, defaultLevel: "low", levels: []string{"low"}}, + {model: "gemini-3.8-flash-medium", budget: 1000, defaultLevel: "medium", levels: []string{"medium"}}, + {model: "gemini-3.8-flash-high", budget: 4000, defaultLevel: "high", levels: []string{"high"}}, + } { + t.Run(tc.model, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, fmt.Sprintf(`{"models":{%q:{ + "supportsImages":true,"supportsThinking":true,"thinkingBudget":%d, + "maxTokens":1048576,"maxOutputTokens":65536 + }}}`, tc.model, tc.budget)) + })) + defer server.Close() + withAntigravityModelSyncEndpoints(t, server.URL, server.URL) + account := antigravityModelSyncAccount("Ultra") + account.Credentials["model_mapping"] = map[string]any{tc.model: tc.model} + svc := antigravityModelSyncService() + svc.accountRepo = &upstreamModelMetadataRepoStub{} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + metadata := catalog.Metadata[tc.model] + require.Equal(t, tc.levels, metadata.SupportedReasoningLevels, "default budget must not restrict configurable models to one reasoning level") + require.Equal(t, tc.defaultLevel, metadata.DefaultReasoningLevel) + require.Empty(t, catalog.Warnings) + }) + } +} + +func TestConfiguredUpstreamModelsForCapabilitySyncSkipsInjectedAntigravityPassthroughs(t *testing.T) { + account := &Account{ + Platform: PlatformAntigravity, + Credentials: map[string]any{"model_mapping": map[string]any{ + "gemini-3.8-flash-high": "gemini-3.8-flash-high", + "gemini-3.8-flash": "gemini-3.8-flash-tiered", + "custom-alias": "gemini-3.6-flash-low", + }}, + } + // 运行时补齐的默认透传仍可调用(存量账号兼容)。 + require.True(t, account.IsModelSupported("gemini-3.7-flash-medium")) + + got := configuredUpstreamModelsForCapabilitySync(account) + require.ElementsMatch(t, []string{"gemini-3.8-flash-high", "gemini-3.8-flash-tiered", "gemini-3.6-flash-low"}, got) + + // 空映射走默认目录,其中的同名条目不是运行时补齐。 + empty := &Account{Platform: PlatformAntigravity, Credentials: map[string]any{}} + require.Contains(t, configuredUpstreamModelsForCapabilitySync(empty), "gemini-3.8-flash-low") +} diff --git a/frontend/src/components/account/AccountTestModal.vue b/frontend/src/components/account/AccountTestModal.vue index 5192d0b667..4ef3c50a94 100644 --- a/frontend/src/components/account/AccountTestModal.vue +++ b/frontend/src/components/account/AccountTestModal.vue @@ -298,7 +298,7 @@ const supportsGeminiImageTest = computed(() => { const modelID = selectedModelId.value.toLowerCase() if (!modelID.startsWith('gemini-') || !modelID.includes('-image')) return false - return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey') + return props.account?.platform === 'gemini' || props.account?.platform === 'antigravity' }) const supportsOpenAIImageTest = computed(() => { diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue index 766686e3de..226fed8786 100644 --- a/frontend/src/components/admin/account/AccountTestModal.vue +++ b/frontend/src/components/admin/account/AccountTestModal.vue @@ -442,7 +442,7 @@ const supportsGeminiImageTest = computed(() => { const modelID = selectedModelId.value.toLowerCase() if (!modelID.startsWith('gemini-') || !modelID.includes('-image')) return false - return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey') + return props.account?.platform === 'gemini' || props.account?.platform === 'antigravity' }) const supportsOpenAIImageTest = computed(() => { diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index fab6c2fc38..97d132b1ad 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -171,6 +171,21 @@ describe('useModelWhitelist', () => { expect(models).toContain('gemini-3.1-pro') }) + it('Gemini 4 Argon 在 gemini 与 antigravity 平台使用对应真实模型 ID 与预设映射', () => { + expect(getModelsByPlatform('gemini')).toContain('gemini-4-argon') + const agModels = getModelsByPlatform('antigravity') + expect(agModels).not.toContain('gemini-4-argon') + expect(agModels).toContain('gemini-4-argon-high') + expect(agModels).toContain('gemini-4-argon-medium') + expect(agModels).toContain('gemini-4-argon-low') + expect(getPresetMappingsByPlatform('antigravity')).toEqual(expect.arrayContaining([ + expect.objectContaining({ from: 'gemini-4-argon', to: 'gemini-4-argon-high' }), + expect.objectContaining({ from: 'gemini-4-argon-high', to: 'gemini-4-argon-high' }), + expect.objectContaining({ from: 'gemini-4-argon-medium', to: 'gemini-4-argon-medium' }), + expect.objectContaining({ from: 'gemini-4-argon-low', to: 'gemini-4-argon-low' }) + ])) + }) + it('whitelist 模式会忽略通配符条目', () => { const mapping = buildModelMappingObject('whitelist', ['claude-*', 'gemini-3.1-flash-image'], []) expect(mapping).toEqual({ diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 12c5ef2d3e..cf1db73156 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -57,15 +57,22 @@ const geminiModels = [ 'gemini-2.5-pro', 'gemini-3.5-flash', 'gemini-3-flash-preview', - 'gemini-3-pro-preview' + 'gemini-3-pro-preview', + 'gemini-4-argon' ] // Antigravity 官方支持的模型(精确匹配) // 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+ const antigravityModels = [ - // Claude 4.5+ 系列 + // Claude 4.5+ / 5.5 系列 'claude-fable-5-1', 'claude-fable-5', + 'claude-opus-5-5-high', + 'claude-opus-5-5-medium', + 'claude-opus-5-5-low', + 'claude-sonnet-5-5-high', + 'claude-sonnet-5-5-medium', + 'claude-sonnet-5-5-low', 'claude-opus-4-6', 'claude-opus-4-6-thinking', 'claude-opus-4-7', @@ -89,7 +96,28 @@ const antigravityModels = [ 'gemini-3.1-pro', 'gemini-3.1-pro-high', 'gemini-3.1-pro-low', + 'gemini-pro-agent', 'gemini-3-pro-image', + // Gemini 3.6 / 3.7 / 3.8 Flash 系列 + 'gemini-3.6-flash', + 'gemini-3.6-flash-low', + 'gemini-3.6-flash-medium', + 'gemini-3.6-flash-high', + 'gemini-3.6-flash-tiered', + 'gemini-3.7-flash', + 'gemini-3.7-flash-low', + 'gemini-3.7-flash-medium', + 'gemini-3.7-flash-high', + 'gemini-3.7-flash-tiered', + 'gemini-3.8-flash', + 'gemini-3.8-flash-low', + 'gemini-3.8-flash-medium', + 'gemini-3.8-flash-high', + 'gemini-3.8-flash-tiered', + // Gemini 4 Argon 系列 + 'gemini-4-argon-low', + 'gemini-4-argon-medium', + 'gemini-4-argon-high', // 其他 'gpt-oss-120b-medium', 'tab_flash_lite_preview' @@ -357,7 +385,22 @@ const grokPresetMappings = [ // Antigravity 预设映射(支持通配符) const antigravityPresetMappings = [ + // Claude 5.5 / Gemini 4 Argon / 3.8 Flash 推荐映射 + { label: '4 Argon→High', from: 'gemini-4-argon', to: 'gemini-4-argon-high', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, + { label: '4 Argon High', from: 'gemini-4-argon-high', to: 'gemini-4-argon-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, + { label: '4 Argon Medium', from: 'gemini-4-argon-medium', to: 'gemini-4-argon-medium', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' }, + { label: '4 Argon Low', from: 'gemini-4-argon-low', to: 'gemini-4-argon-low', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' }, + { label: '3.8 Flash', from: 'gemini-3.8-flash', to: 'gemini-3.8-flash', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, + { label: '3.8 Flash High', from: 'gemini-3.8-flash-high', to: 'gemini-3.8-flash-high', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' }, + { label: 'Opus 5.5→High', from: 'claude-opus-5-5', to: 'claude-opus-5-5-high', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }, + { label: 'Opus 5.5 High', from: 'claude-opus-5-5-high', to: 'claude-opus-5-5-high', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }, + { label: 'Opus 5.5 Medium', from: 'claude-opus-5-5-medium', to: 'claude-opus-5-5-medium', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, + { label: 'Sonnet 5.5→High', from: 'claude-sonnet-5-5', to: 'claude-sonnet-5-5-high', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, + { label: 'Sonnet 5.5 High', from: 'claude-sonnet-5-5-high', to: 'claude-sonnet-5-5-high', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, + { label: 'Sonnet 5.5 Medium', from: 'claude-sonnet-5-5-medium', to: 'claude-sonnet-5-5-medium', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, // Claude 通配符映射 + { label: 'Claude→Sonnet 5.5', from: 'claude-*', to: 'claude-sonnet-5-5-high', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }, + { label: 'Opus→Opus 5.5', from: 'claude-opus-*', to: 'claude-opus-5-5-high', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Claude→Sonnet', from: 'claude-*', to: 'claude-sonnet-4-5', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }, { label: 'Fable 5.1', from: 'claude-fable-5-1', to: 'claude-fable-5-1', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' }, { label: 'Fable 5', from: 'claude-fable-5', to: 'claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' }, @@ -368,7 +411,8 @@ const antigravityPresetMappings = [ { label: 'Sonnet4.5→4.6', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, { label: 'Sonnet3.5→4.6', from: 'claude-3-5-sonnet-20241022', to: 'claude-sonnet-4-6', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' }, { label: 'Opus4.5→4.6', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-6-thinking', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' }, - // Gemini 3→3.1 映射 + // Gemini 3→3.1 / 4 Argon 映射 + { label: '3.1-Pro→4-Argon-High', from: 'gemini-3.1-pro*', to: 'gemini-4-argon-high', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, { label: '3-Pro-Preview→Pro-Agent', from: 'gemini-3-pro-preview', to: 'gemini-pro-agent', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, { label: '3-Pro-High→Pro-Agent', from: 'gemini-3-pro-high', to: 'gemini-pro-agent', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, { label: '3-Pro-Low→3.1-Pro-Low', from: 'gemini-3-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' }, @@ -378,6 +422,7 @@ const antigravityPresetMappings = [ { label: '3.1-Pro-Low透传', from: 'gemini-3.1-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' }, { label: '3.1-Pro-Preview→Pro-Agent', from: 'gemini-3.1-pro-preview', to: 'gemini-pro-agent', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, // Gemini 通配符映射 + { label: 'Gemini 3→3.8 Flash', from: 'gemini-3*', to: 'gemini-3.8-flash-high', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' }, { label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' }, { label: 'Gemini 2.5→Flash', from: 'gemini-2.5*', to: 'gemini-2.5-flash', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, { label: '2.5-Flash-Image透传', from: 'gemini-2.5-flash-image', to: 'gemini-2.5-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },