From 0f4d8acaa8400ddfe9c88efbd791105d8677e071 Mon Sep 17 00:00:00 2001 From: HuntercodeT <61233559+HuntercodeT@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:58:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(antigravity):=20Gemini=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E7=9A=84=E8=A3=B8=E6=A8=A1=E5=9E=8B=E5=90=8D?= =?UTF-8?q?=E6=8C=89=20thinkingConfig=20=E8=A7=A3=E6=9E=90=E5=88=B0=20-low?= =?UTF-8?q?/-medium/-high=20=E5=8F=98=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antigravity CLI / go-genai 发裸 Gemini 模型名(gemini-3.8-flash)并用 generationConfig.thinkingConfig 表达思考深度,而 Antigravity 上游目录只有 -low/-medium/-high/-tiered 变体。v0.2.5 起 resolveModelMapping 会给 antigravity 账号运行时补 gemini-3.x-flash 裸名自映射,裸名因此被当成显式透传直发上游, 上游回 404 "Requested entity was not found."。 在 ForwardGemini 取映射之前加 resolveGeminiThinkingVariant: - 仅对 gemini- 前缀且不带后缀的裸名生效; - 用户在 credentials.model_mapping 亲写的裸名条目一律不介入,运行时注入的 默认自映射不算用户意图; - thinkingLevel 优先;否则 thinkingBudget <0→high,≤1024→low,≤8192→medium, 其余 high;未携带 thinkingConfig→high;所选变体不存在时按 high→medium→low→tiered 降级;一个变体都没有则回退原逻辑; - thinkingConfig 仍原样透传,上游按 budget 决定实际思考量。 --- .../service/antigravity_gateway_gemini.go | 9 +- .../antigravity_gemini_thinking_variant.go | 137 ++++++++++++++++++ ...ntigravity_gemini_thinking_variant_test.go | 118 +++++++++++++++ 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/antigravity_gemini_thinking_variant.go create mode 100644 backend/internal/service/antigravity_gemini_thinking_variant_test.go diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 5c84b746e1..777c1de9f1 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -86,7 +86,14 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action) } - mappedModel := s.getMappedModel(account, originalModel) + // 裸模型名(gemini-3.8-flash)+ thinkingConfig → 账号映射表里对应的 -low/-medium/-high 变体; + // 裸名有显式映射时保持原行为。 + mappedModel, variantResolved := resolveGeminiThinkingVariant(account, originalModel, body) + if !variantResolved { + mappedModel = s.getMappedModel(account, originalModel) + } else { + logger.LegacyPrintf("service.antigravity_gateway", "%s resolved bare Gemini model %s to thinking variant %s", prefix, originalModel, mappedModel) + } if mappedModel == "" { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) return nil, s.writeGoogleError(c, http.StatusForbidden, fmt.Sprintf("model %s not in whitelist", originalModel)) diff --git a/backend/internal/service/antigravity_gemini_thinking_variant.go b/backend/internal/service/antigravity_gemini_thinking_variant.go new file mode 100644 index 0000000000..fbf02cc1c5 --- /dev/null +++ b/backend/internal/service/antigravity_gemini_thinking_variant.go @@ -0,0 +1,137 @@ +package service + +import ( + "encoding/json" + "strings" +) + +// Gemini 原生请求(/v1beta/models/{model}:generateContent 等)经 Antigravity 账号转发时, +// 客户端(如 Antigravity CLI / go-genai)习惯发"裸"模型名(gemini-3.8-flash)并用 +// generationConfig.thinkingConfig 表达思考深度;而 Antigravity 上游的模型目录只有 +// gemini-3.8-flash-low / -medium / -high / -tiered 这类带后缀的变体,裸名直接转发会被上游 +// 以 404 "Requested entity was not found." 拒绝。 +// +// 这里在账号 model_mapping 没有为裸名配置显式条目、但配置了它的后缀变体时, +// 按 thinkingConfig 自动挑一个变体: +// - thinkingLevel: "low" / "medium" / "high" → 同名后缀 +// - thinkingBudget: -1(动态)→ high;0 → low;1..1024 → low;1025..8192 → medium;>8192 → high +// - 未携带 thinkingConfig → high(与 Gemini 3 系列默认开启动态思考一致) +// 选中的后缀在映射表里不存在时按 high → medium → low → tiered 的顺序降级到存在的变体。 +// 显式映射(裸名本身在 model_mapping 里)始终优先,行为与现在完全一致。 + +var geminiThinkingVariantSuffixes = []string{"-low", "-medium", "-high", "-tiered"} + +const ( + geminiThinkingBudgetLowMax = 1024 + geminiThinkingBudgetMediumMax = 8192 +) + +type geminiThinkingConfigProbe struct { + GenerationConfig struct { + ThinkingConfig *struct { + ThinkingBudget *json.Number `json:"thinkingBudget"` + ThinkingLevel string `json:"thinkingLevel"` + } `json:"thinkingConfig"` + } `json:"generationConfig"` +} + +// hasGeminiThinkingVariantSuffix 判断模型名是否已经带了思考深度后缀。 +func hasGeminiThinkingVariantSuffix(model string) bool { + for _, suffix := range geminiThinkingVariantSuffixes { + if strings.HasSuffix(model, suffix) { + return true + } + } + return false +} + +// geminiThinkingLevelFromBody 从 Gemini 原生请求体推导期望的思考档位(low/medium/high)。 +// 解析失败或未携带 thinkingConfig 时返回 "high"。 +func geminiThinkingLevelFromBody(body []byte) string { + if len(body) == 0 { + return "high" + } + var probe geminiThinkingConfigProbe + if err := json.Unmarshal(body, &probe); err != nil || probe.GenerationConfig.ThinkingConfig == nil { + return "high" + } + tc := probe.GenerationConfig.ThinkingConfig + switch strings.ToLower(strings.TrimSpace(tc.ThinkingLevel)) { + case "low": + return "low" + case "medium": + return "medium" + case "high": + return "high" + } + if tc.ThinkingBudget == nil { + return "high" + } + budget, err := tc.ThinkingBudget.Float64() + if err != nil { + return "high" + } + switch { + case budget < 0: + return "high" // -1 = 动态思考 + case budget <= geminiThinkingBudgetLowMax: + return "low" // 含 0(关闭思考):上游没有"无思考"变体,取最浅档,budget 本身仍原样透传 + case budget <= geminiThinkingBudgetMediumMax: + return "medium" + default: + return "high" + } +} + +// 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) { + if account == nil { + return "", false + } + model := strings.TrimSpace(strings.TrimPrefix(requestedModel, "models/")) + if !strings.HasPrefix(model, "gemini-") || hasGeminiThinkingVariantSuffix(model) { + return "", false + } + mapping := account.GetModelMapping() + 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 + } + } + + preferred := geminiThinkingLevelFromBody(body) + order := []string{preferred} + for _, level := range []string{"high", "medium", "low", "tiered"} { + if level != preferred { + order = append(order, level) + } + } + for _, level := range order { + candidate := model + "-" + level + if mapped, matched := resolveRequestedModelInMapping(mapping, candidate); matched && strings.TrimSpace(mapped) != "" { + return mapped, true + } + } + return "", false +} diff --git a/backend/internal/service/antigravity_gemini_thinking_variant_test.go b/backend/internal/service/antigravity_gemini_thinking_variant_test.go new file mode 100644 index 0000000000..3e9764249c --- /dev/null +++ b/backend/internal/service/antigravity_gemini_thinking_variant_test.go @@ -0,0 +1,118 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func newAntigravityAccountWithMapping(mapping map[string]string) *Account { + mappingAny := make(map[string]any, len(mapping)) + for k, v := range mapping { + mappingAny[k] = v + } + return &Account{ + ID: 1, + Name: "ag", + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "model_mapping": mappingAny, + }, + } +} + +func TestGeminiThinkingLevelFromBody(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {"empty body", ``, "high"}, + {"no thinkingConfig", `{"contents":[]}`, "high"}, + {"budget -1 dynamic", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, "high"}, + {"budget 0 off", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, "low"}, + {"budget 1000 (agy low)", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":1000}}}`, "low"}, + {"budget 4000 (agy medium)", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":4000}}}`, "medium"}, + {"budget 24576", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":24576}}}`, "high"}, + {"thinkingLevel wins over budget", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":24576,"thinkingLevel":"low"}}}`, "low"}, + {"thinkingLevel medium", `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"MEDIUM"}}}`, "medium"}, + {"invalid json", `{"generationConfig":`, "high"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, geminiThinkingLevelFromBody([]byte(tt.body))) + }) + } +} + +func TestResolveGeminiThinkingVariant(t *testing.T) { + // 与生产 Antigravity 账号从上游同步来的目录一致:只有带后缀的变体,没有裸名。 + catalog := 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-high": "gemini-3.8-flash-high", + "gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered", + "gemini-3.6-flash-high": "gemini-3.6-flash-high", + "gemini-3.1-pro-high": "gemini-3.1-pro-high", + "gemini-2.5-flash": "gemini-2.5-flash", + } + budget := func(v string) []byte { + return []byte(`{"contents":[],"generationConfig":{"thinkingConfig":{"thinkingBudget":` + v + `}}}`) + } + + tests := []struct { + name string + mapping map[string]string + model string + body []byte + want string + wantMatch bool + }{ + {"agy high (-1) -> -high", catalog, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true}, + {"agy medium (4000) -> -medium", catalog, "gemini-3.8-flash", budget("4000"), "gemini-3.8-flash-medium", true}, + {"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}, + {"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}, + {"no variants in mapping: untouched", catalog, "gemini-9.9-flash", budget("-1"), "", false}, + {"non-gemini model: untouched", catalog, "claude-sonnet-4-6", budget("-1"), "", false}, + {"explicit bare alias wins over variants", map[string]string{ + "gemini-3.8-flash": "gemini-3.8-flash-tiered", + "gemini-3.8-flash-high": "gemini-3.8-flash-high", + }, "gemini-3.8-flash", budget("-1"), "", false}, + {"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{ + "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.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 + {"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 { + t.Run(tt.name, func(t *testing.T) { + account := newAntigravityAccountWithMapping(tt.mapping) + got, matched := resolveGeminiThinkingVariant(account, tt.model, tt.body) + require.Equal(t, tt.wantMatch, matched) + require.Equal(t, tt.want, got) + }) + } + + t.Run("nil account", func(t *testing.T) { + got, matched := resolveGeminiThinkingVariant(nil, "gemini-3.8-flash", budget("-1")) + require.False(t, matched) + require.Empty(t, got) + }) +}