Merge pull request #8093 from baryon/contrib/gemini-4-argon-support

feat(antigravity): support Gemini 4 Argon and accurate upstream model catalogs
This commit is contained in:
Wesley Liddick
2026-10-15 16:13:07 +08:00
committed by GitHub
40 changed files with 1636 additions and 216 deletions
+13
View File
@@ -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",
+22
View File
@@ -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()
@@ -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)
}
}
@@ -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 {
@@ -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)仍被过滤。
@@ -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
}
@@ -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())
})
}
@@ -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
@@ -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")
}
}
+47 -4
View File
@@ -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"
@@ -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()
+3 -3
View File
@@ -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。
+107 -1
View File
@@ -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 {
@@ -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
@@ -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)
}
+1
View File
@@ -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},
}
}
+1
View File
@@ -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
+47 -20
View File
@@ -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
@@ -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
@@ -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"))
}
@@ -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 {
@@ -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 智能重试的处理结果
@@ -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 格式请求注入身份提示词
@@ -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"])
}
@@ -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
@@ -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
@@ -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 {
@@ -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},
+1 -1
View File
@@ -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"]
}
@@ -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)
@@ -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.
@@ -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)
+25 -2
View File
@@ -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
@@ -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)
}
}
}
+95 -16
View File
@@ -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)
}
@@ -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")
}
@@ -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(() => {
@@ -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(() => {
@@ -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({
+48 -3
View File
@@ -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' },