Skip to content

Commit 0f4d8ac

Browse files
committed
fix(antigravity): Gemini 原生请求的裸模型名按 thinkingConfig 解析到 -low/-medium/-high 变体
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 决定实际思考量。
1 parent efe9aab commit 0f4d8ac

3 files changed

Lines changed: 263 additions & 1 deletion

File tree

‎backend/internal/service/antigravity_gateway_gemini.go‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,14 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
8686
return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action)
8787
}
8888

89-
mappedModel := s.getMappedModel(account, originalModel)
89+
// 裸模型名(gemini-3.8-flash)+ thinkingConfig → 账号映射表里对应的 -low/-medium/-high 变体;
90+
// 裸名有显式映射时保持原行为。
91+
mappedModel, variantResolved := resolveGeminiThinkingVariant(account, originalModel, body)
92+
if !variantResolved {
93+
mappedModel = s.getMappedModel(account, originalModel)
94+
} else {
95+
logger.LegacyPrintf("service.antigravity_gateway", "%s resolved bare Gemini model %s to thinking variant %s", prefix, originalModel, mappedModel)
96+
}
9097
if mappedModel == "" {
9198
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
9299
return nil, s.writeGoogleError(c, http.StatusForbidden, fmt.Sprintf("model %s not in whitelist", originalModel))
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package service
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
)
7+
8+
// Gemini 原生请求(/v1beta/models/{model}:generateContent 等)经 Antigravity 账号转发时,
9+
// 客户端(如 Antigravity CLI / go-genai)习惯发"裸"模型名(gemini-3.8-flash)并用
10+
// generationConfig.thinkingConfig 表达思考深度;而 Antigravity 上游的模型目录只有
11+
// gemini-3.8-flash-low / -medium / -high / -tiered 这类带后缀的变体,裸名直接转发会被上游
12+
// 以 404 "Requested entity was not found." 拒绝。
13+
//
14+
// 这里在账号 model_mapping 没有为裸名配置显式条目、但配置了它的后缀变体时,
15+
// 按 thinkingConfig 自动挑一个变体:
16+
// - thinkingLevel: "low" / "medium" / "high" → 同名后缀
17+
// - thinkingBudget: -1(动态)→ high;0 → low;1..1024 → low;1025..8192 → medium;>8192 → high
18+
// - 未携带 thinkingConfig → high(与 Gemini 3 系列默认开启动态思考一致)
19+
// 选中的后缀在映射表里不存在时按 high → medium → low → tiered 的顺序降级到存在的变体。
20+
// 显式映射(裸名本身在 model_mapping 里)始终优先,行为与现在完全一致。
21+
22+
var geminiThinkingVariantSuffixes = []string{"-low", "-medium", "-high", "-tiered"}
23+
24+
const (
25+
geminiThinkingBudgetLowMax = 1024
26+
geminiThinkingBudgetMediumMax = 8192
27+
)
28+
29+
type geminiThinkingConfigProbe struct {
30+
GenerationConfig struct {
31+
ThinkingConfig *struct {
32+
ThinkingBudget *json.Number `json:"thinkingBudget"`
33+
ThinkingLevel string `json:"thinkingLevel"`
34+
} `json:"thinkingConfig"`
35+
} `json:"generationConfig"`
36+
}
37+
38+
// hasGeminiThinkingVariantSuffix 判断模型名是否已经带了思考深度后缀。
39+
func hasGeminiThinkingVariantSuffix(model string) bool {
40+
for _, suffix := range geminiThinkingVariantSuffixes {
41+
if strings.HasSuffix(model, suffix) {
42+
return true
43+
}
44+
}
45+
return false
46+
}
47+
48+
// geminiThinkingLevelFromBody 从 Gemini 原生请求体推导期望的思考档位(low/medium/high)。
49+
// 解析失败或未携带 thinkingConfig 时返回 "high"。
50+
func geminiThinkingLevelFromBody(body []byte) string {
51+
if len(body) == 0 {
52+
return "high"
53+
}
54+
var probe geminiThinkingConfigProbe
55+
if err := json.Unmarshal(body, &probe); err != nil || probe.GenerationConfig.ThinkingConfig == nil {
56+
return "high"
57+
}
58+
tc := probe.GenerationConfig.ThinkingConfig
59+
switch strings.ToLower(strings.TrimSpace(tc.ThinkingLevel)) {
60+
case "low":
61+
return "low"
62+
case "medium":
63+
return "medium"
64+
case "high":
65+
return "high"
66+
}
67+
if tc.ThinkingBudget == nil {
68+
return "high"
69+
}
70+
budget, err := tc.ThinkingBudget.Float64()
71+
if err != nil {
72+
return "high"
73+
}
74+
switch {
75+
case budget < 0:
76+
return "high" // -1 = 动态思考
77+
case budget <= geminiThinkingBudgetLowMax:
78+
return "low" // 含 0(关闭思考):上游没有"无思考"变体,取最浅档,budget 本身仍原样透传
79+
case budget <= geminiThinkingBudgetMediumMax:
80+
return "medium"
81+
default:
82+
return "high"
83+
}
84+
}
85+
86+
// accountRawModelMappingHasKey 判断 key 是否由用户写在账号 credentials.model_mapping 里
87+
// (而不是 resolveModelMapping 运行时补进来的默认透传条目)。
88+
func accountRawModelMappingHasKey(account *Account, key string) bool {
89+
if account == nil || account.Credentials == nil {
90+
return false
91+
}
92+
raw, _ := account.Credentials["model_mapping"].(map[string]any)
93+
_, ok := raw[key]
94+
return ok
95+
}
96+
97+
// resolveGeminiThinkingVariant 为裸 Gemini 模型名挑选账号映射表里存在的思考深度变体。
98+
// 返回 (映射后的上游模型名, 是否命中)。未命中时调用方应回退到常规 getMappedModel 流程。
99+
func resolveGeminiThinkingVariant(account *Account, requestedModel string, body []byte) (string, bool) {
100+
if account == nil {
101+
return "", false
102+
}
103+
model := strings.TrimSpace(strings.TrimPrefix(requestedModel, "models/"))
104+
if !strings.HasPrefix(model, "gemini-") || hasGeminiThinkingVariantSuffix(model) {
105+
return "", false
106+
}
107+
mapping := account.GetModelMapping()
108+
if len(mapping) == 0 {
109+
return "", false
110+
}
111+
// 裸名本身已有"真正的"映射 → 尊重现有配置,不做推导。判定分两层:
112+
// 1. 映射目标不是自己(如 gemini-3.8-flash → gemini-3.8-flash-tiered):用户明确指定了目标;
113+
// 2. 映射目标是自己(原样透传):仅当这条是用户亲手写进 credentials.model_mapping 的才尊重。
114+
// resolveModelMapping 会给每个 Antigravity 账号自动补 gemini-3.x-flash 裸名自映射
115+
// (ensureAntigravityDefaultPassthroughs),而上游目录里并没有裸名模型,那条自映射
116+
// 正是导致 404 的来源,不能算作用户意图。
117+
if mapped, matched := resolveRequestedModelInMapping(mapping, model); matched {
118+
if strings.TrimSpace(mapped) != model || accountRawModelMappingHasKey(account, model) {
119+
return "", false
120+
}
121+
}
122+
123+
preferred := geminiThinkingLevelFromBody(body)
124+
order := []string{preferred}
125+
for _, level := range []string{"high", "medium", "low", "tiered"} {
126+
if level != preferred {
127+
order = append(order, level)
128+
}
129+
}
130+
for _, level := range order {
131+
candidate := model + "-" + level
132+
if mapped, matched := resolveRequestedModelInMapping(mapping, candidate); matched && strings.TrimSpace(mapped) != "" {
133+
return mapped, true
134+
}
135+
}
136+
return "", false
137+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package service
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func newAntigravityAccountWithMapping(mapping map[string]string) *Account {
10+
mappingAny := make(map[string]any, len(mapping))
11+
for k, v := range mapping {
12+
mappingAny[k] = v
13+
}
14+
return &Account{
15+
ID: 1,
16+
Name: "ag",
17+
Platform: PlatformAntigravity,
18+
Credentials: map[string]any{
19+
"model_mapping": mappingAny,
20+
},
21+
}
22+
}
23+
24+
func TestGeminiThinkingLevelFromBody(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
body string
28+
want string
29+
}{
30+
{"empty body", ``, "high"},
31+
{"no thinkingConfig", `{"contents":[]}`, "high"},
32+
{"budget -1 dynamic", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, "high"},
33+
{"budget 0 off", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, "low"},
34+
{"budget 1000 (agy low)", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":1000}}}`, "low"},
35+
{"budget 4000 (agy medium)", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":4000}}}`, "medium"},
36+
{"budget 24576", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":24576}}}`, "high"},
37+
{"thinkingLevel wins over budget", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":24576,"thinkingLevel":"low"}}}`, "low"},
38+
{"thinkingLevel medium", `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"MEDIUM"}}}`, "medium"},
39+
{"invalid json", `{"generationConfig":`, "high"},
40+
}
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
require.Equal(t, tt.want, geminiThinkingLevelFromBody([]byte(tt.body)))
44+
})
45+
}
46+
}
47+
48+
func TestResolveGeminiThinkingVariant(t *testing.T) {
49+
// 与生产 Antigravity 账号从上游同步来的目录一致:只有带后缀的变体,没有裸名。
50+
catalog := map[string]string{
51+
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
52+
"gemini-3.8-flash-medium": "gemini-3.8-flash-medium",
53+
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
54+
"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered",
55+
"gemini-3.6-flash-high": "gemini-3.6-flash-high",
56+
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
57+
"gemini-2.5-flash": "gemini-2.5-flash",
58+
}
59+
budget := func(v string) []byte {
60+
return []byte(`{"contents":[],"generationConfig":{"thinkingConfig":{"thinkingBudget":` + v + `}}}`)
61+
}
62+
63+
tests := []struct {
64+
name string
65+
mapping map[string]string
66+
model string
67+
body []byte
68+
want string
69+
wantMatch bool
70+
}{
71+
{"agy high (-1) -> -high", catalog, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true},
72+
{"agy medium (4000) -> -medium", catalog, "gemini-3.8-flash", budget("4000"), "gemini-3.8-flash-medium", true},
73+
{"agy low (1000) -> -low", catalog, "gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
74+
{"models/ prefix stripped", catalog, "models/gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
75+
{"no thinkingConfig -> -high", catalog, "gemini-3.8-flash", []byte(`{"contents":[]}`), "gemini-3.8-flash-high", true},
76+
// gemini-3.6/3.7/3.8-flash 的四个变体会被 resolveModelMapping 自动补齐,所以降级用一个不在默认表里的型号
77+
{"only -high exists: low request degrades to high", map[string]string{
78+
"gemini-3.5-flash-high": "gemini-3.5-flash-high",
79+
}, "gemini-3.5-flash", budget("1000"), "gemini-3.5-flash-high", true},
80+
{"injected default variants are usable", catalog, "gemini-3.6-flash", budget("1000"), "gemini-3.6-flash-low", true},
81+
{"already suffixed: untouched", catalog, "gemini-3.8-flash-low", budget("-1"), "", false},
82+
{"bare name explicitly mapped: untouched", catalog, "gemini-2.5-flash", budget("-1"), "", false},
83+
{"no variants in mapping: untouched", catalog, "gemini-9.9-flash", budget("-1"), "", false},
84+
{"non-gemini model: untouched", catalog, "claude-sonnet-4-6", budget("-1"), "", false},
85+
{"explicit bare alias wins over variants", map[string]string{
86+
"gemini-3.8-flash": "gemini-3.8-flash-tiered",
87+
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
88+
}, "gemini-3.8-flash", budget("-1"), "", false},
89+
{"variant value is itself a mapping target", map[string]string{
90+
"gemini-3.8-flash-high": "gemini-3.8-flash-tiered",
91+
}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-tiered", true},
92+
{"user-written bare identity passthrough: respected", map[string]string{
93+
"gemini-3.8-flash": "gemini-3.8-flash",
94+
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
95+
}, "gemini-3.8-flash", budget("1000"), "", false},
96+
{"runtime-injected bare passthrough (not in credentials): still resolved", map[string]string{
97+
// resolveModelMapping 会补 gemini-3.7-flash → gemini-3.7-flash 的默认透传,
98+
// 但 credentials 里没有这条,应当继续推导变体。
99+
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
100+
}, "gemini-3.7-flash", budget("4000"), "gemini-3.7-flash-medium", true},
101+
// 空 credentials 映射 → 走 DefaultAntigravityModelMapping,其中同样只有带后缀的 3.8 flash
102+
{"empty mapping falls back to default catalog", map[string]string{}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true},
103+
}
104+
for _, tt := range tests {
105+
t.Run(tt.name, func(t *testing.T) {
106+
account := newAntigravityAccountWithMapping(tt.mapping)
107+
got, matched := resolveGeminiThinkingVariant(account, tt.model, tt.body)
108+
require.Equal(t, tt.wantMatch, matched)
109+
require.Equal(t, tt.want, got)
110+
})
111+
}
112+
113+
t.Run("nil account", func(t *testing.T) {
114+
got, matched := resolveGeminiThinkingVariant(nil, "gemini-3.8-flash", budget("-1"))
115+
require.False(t, matched)
116+
require.Empty(t, got)
117+
})
118+
}

0 commit comments

Comments
 (0)