fix(antigravity): 裸 Gemini 模型名在所有转发入口解析到思考变体

#7258 引入的 resolveGeminiThinkingVariant 只接到了 ForwardGemini,
因此仅覆盖 /v1beta 原生 Gemini 路径。经 /v1/chat/completions 与
/v1/messages 进来的请求直接调用 getMappedModel,裸名被原样透传;
Antigravity 目录只登记 -low/-medium/-high/-tiered 变体,这些请求
仍然以 404 "Requested entity was not found." 失败。

把解析下沉到 getMappedModel(新增 getMappedModelForThinkingLevel),
一次覆盖四个调用点:/v1beta 原生、/v1/chat/completions、/v1/messages
以及后台账号连接测试。各转发入口按自身协议推导档位——原生读
generationConfig.thinkingConfig,Claude 侧读 thinking.budget_tokens,
两者阈值一致;默认入口按 high 兜底。

mapAntigravityModel 保持不变,调度侧的模型支持判定语义不受影响。
This commit is contained in:
lcy
2026-09-21 20:13:44 +08:00
parent 7c700729c2
commit 27f4398b50
6 changed files with 155 additions and 9 deletions
@@ -49,7 +49,11 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
}
originalModel := claudeReq.Model
mappedModel := s.getMappedModel(account, claudeReq.Model)
mappedModel := s.getMappedModelForThinkingLevel(
account,
claudeReq.Model,
geminiThinkingLevelFromClaudeThinking(claudeReq.Thinking),
)
if mappedModel == "" {
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
return nil, s.writeClaudeError(c, http.StatusForbidden, "permission_error", fmt.Sprintf("model %s not in whitelist", claudeReq.Model))
@@ -212,7 +212,11 @@ func (s *AntigravityGatewayService) prepareAntigravityCompatCall(
return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body")
}
mappedModel := s.getMappedModel(account, request.originalModel)
mappedModel := s.getMappedModelForThinkingLevel(
account,
request.originalModel,
geminiThinkingLevelFromClaudeThinking(claudeRequest.Thinking),
)
if mappedModel == "" {
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
message := fmt.Sprintf("model %s not in whitelist", request.originalModel)
@@ -86,13 +86,11 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action)
}
// 裸模型名(gemini-3.8-flash)+ thinkingConfig → 账号映射表里对应的 -low/-medium/-high 变体;
// 裸模型名(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)
mappedModel := s.getMappedModelForThinkingLevel(account, originalModel, geminiThinkingLevelFromBody(body))
if mappedModel != "" && mappedModel != originalModel {
logger.LegacyPrintf("service.antigravity_gateway", "%s mapped Gemini model %s to %s", prefix, originalModel, mappedModel)
}
if mappedModel == "" {
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
@@ -294,6 +294,18 @@ func mapAntigravityModel(account *Account, requestedModel string) string {
// getMappedModel 获取映射后的模型名
// 完全依赖映射配置:账户映射(通配符)→ 默认映射兜底
func (s *AntigravityGatewayService) getMappedModel(account *Account, requestedModel string) string {
return s.getMappedModelForThinkingLevel(account, requestedModel, "")
}
// getMappedModelForThinkingLevel 在常规映射之前先把裸 Gemini 模型名
// (gemini-3.x-flash)解析到上游目录里真实存在的 -low/-medium/-high/-tiered 变体。
// Antigravity 目录只登记带后缀的变体,裸名直接转发会被上游以
// 404 "Requested entity was not found." 拒绝,因此所有转发入口都必须经过这一步。
// thinkingLevel 为空时按 high 兜底;调用方可按自身协议传入推导出的档位。
func (s *AntigravityGatewayService) getMappedModelForThinkingLevel(account *Account, requestedModel string, thinkingLevel string) string {
if mapped, ok := resolveGeminiThinkingVariantForLevel(account, requestedModel, thinkingLevel); ok {
return mapped
}
return mapAntigravityModel(account, requestedModel)
}
@@ -3,6 +3,8 @@ package service
import (
"encoding/json"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
)
// Gemini 原生请求(/v1beta/models/{model}:generateContent 等)经 Antigravity 账号转发时,
@@ -83,6 +85,29 @@ func geminiThinkingLevelFromBody(body []byte) string {
}
}
// geminiThinkingLevelFromClaudeThinking 用 Claude Messages 协议的 thinking 配置推导档位,
// 阈值与 geminiThinkingLevelFromBody 保持一致,使同一请求无论走 Gemini 原生还是
// Chat Completions / Messages 兼容层都落到同一个上游变体。
func geminiThinkingLevelFromClaudeThinking(thinking *antigravity.ThinkingConfig) string {
if thinking == nil {
return "high"
}
if strings.EqualFold(strings.TrimSpace(thinking.Type), "disabled") {
return "low"
}
budget := thinking.BudgetTokens
switch {
case budget <= 0:
return "high" // 动态思考 / 未指定预算
case budget <= geminiThinkingBudgetLowMax:
return "low"
case budget <= geminiThinkingBudgetMediumMax:
return "medium"
default:
return "high"
}
}
// accountRawModelMappingHasKey 判断 key 是否由用户写在账号 credentials.model_mapping 里
// (而不是 resolveModelMapping 运行时补进来的默认透传条目)。
func accountRawModelMappingHasKey(account *Account, key string) bool {
@@ -97,6 +122,13 @@ func accountRawModelMappingHasKey(account *Account, key string) bool {
// resolveGeminiThinkingVariant 为裸 Gemini 模型名挑选账号映射表里存在的思考深度变体。
// 返回 (映射后的上游模型名, 是否命中)。未命中时调用方应回退到常规 getMappedModel 流程。
func resolveGeminiThinkingVariant(account *Account, requestedModel string, body []byte) (string, bool) {
return resolveGeminiThinkingVariantForLevel(account, requestedModel, geminiThinkingLevelFromBody(body))
}
// resolveGeminiThinkingVariantForLevel 是 resolveGeminiThinkingVariant 的协议无关内核:
// 调用方负责按自身协议推导 preferred 档位(Gemini 原生读 generationConfig.thinkingConfig,
// Claude/OpenAI 兼容层读 thinking.budget_tokens),此处只做映射查找与降级。
func resolveGeminiThinkingVariantForLevel(account *Account, requestedModel string, preferred string) (string, bool) {
if account == nil {
return "", false
}
@@ -120,7 +152,9 @@ func resolveGeminiThinkingVariant(account *Account, requestedModel string, body
}
}
preferred := geminiThinkingLevelFromBody(body)
if preferred == "" {
preferred = "high"
}
order := []string{preferred}
for _, level := range []string{"high", "medium", "low", "tiered"} {
if level != preferred {
@@ -3,6 +3,7 @@ package service
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/stretchr/testify/require"
)
@@ -116,3 +117,96 @@ func TestResolveGeminiThinkingVariant(t *testing.T) {
require.Empty(t, got)
})
}
func TestGeminiThinkingLevelFromClaudeThinking(t *testing.T) {
tests := []struct {
name string
thinking *antigravity.ThinkingConfig
want string
}{
{"nil thinking", nil, "high"},
{"disabled", &antigravity.ThinkingConfig{Type: "disabled"}, "low"},
{"enabled without budget", &antigravity.ThinkingConfig{Type: "enabled"}, "high"},
{"budget 1024 -> low", &antigravity.ThinkingConfig{Type: "enabled", BudgetTokens: 1024}, "low"},
{"budget 4000 -> medium", &antigravity.ThinkingConfig{Type: "enabled", BudgetTokens: 4000}, "medium"},
{"budget 20000 -> high", &antigravity.ThinkingConfig{Type: "enabled", BudgetTokens: 20000}, "high"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, geminiThinkingLevelFromClaudeThinking(tt.thinking))
})
}
}
// 锁定线上 404 回归:Chat Completions / Messages 兼容层收到裸 Gemini 模型名时,
// 必须和 Gemini 原生路径一样解析到带后缀的上游变体,而不是把裸名透传给上游。
func TestResolveGeminiThinkingVariantForLevel_CompatPathResolvesBareModel(t *testing.T) {
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",
}
account := newAntigravityAccountWithMapping(catalog)
for _, tc := range []struct{ level, want string }{
{"low", "gemini-3.8-flash-low"},
{"medium", "gemini-3.8-flash-medium"},
{"high", "gemini-3.8-flash-high"},
{"", "gemini-3.8-flash-high"},
} {
t.Run("level="+tc.level, func(t *testing.T) {
got, matched := resolveGeminiThinkingVariantForLevel(account, "gemini-3.8-flash", tc.level)
require.True(t, matched)
require.Equal(t, tc.want, got)
})
}
t.Run("already suffixed model is untouched", func(t *testing.T) {
got, matched := resolveGeminiThinkingVariantForLevel(account, "gemini-3.8-flash-high", "low")
require.False(t, matched)
require.Empty(t, got)
})
}
// 同一请求无论走 Gemini 原生还是兼容层,都必须落到同一个上游变体。
func TestGeminiThinkingLevel_NativeAndCompatAgree(t *testing.T) {
native := geminiThinkingLevelFromBody([]byte(`{"generationConfig":{"thinkingConfig":{"thinkingBudget":4000}}}`))
compat := geminiThinkingLevelFromClaudeThinking(&antigravity.ThinkingConfig{Type: "enabled", BudgetTokens: 4000})
require.Equal(t, native, compat)
}
// 回归锁:裸名解析必须发生在 getMappedModel 这一层,从而覆盖全部 Antigravity
// 转发入口(/v1beta 原生、/v1/chat/completions、/v1/messages)以及账号测试。
// 早前只在单个入口打补丁,导致 /v1/messages 仍把裸名透传给上游并触发
// 404 "Requested entity was not found."
func TestGetMappedModelResolvesBareGeminiModelForAllEntrypoints(t *testing.T) {
svc := &AntigravityGatewayService{}
account := newAntigravityAccountWithMapping(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",
})
t.Run("default entrypoint falls back to high", func(t *testing.T) {
require.Equal(t, "gemini-3.8-flash-high", svc.getMappedModel(account, "gemini-3.8-flash"))
})
t.Run("caller supplied level wins", func(t *testing.T) {
require.Equal(t, "gemini-3.8-flash-low",
svc.getMappedModelForThinkingLevel(account, "gemini-3.8-flash", "low"))
require.Equal(t, "gemini-3.8-flash-medium",
svc.getMappedModelForThinkingLevel(account, "gemini-3.8-flash", "medium"))
})
t.Run("models/ prefix is handled", func(t *testing.T) {
require.Equal(t, "gemini-3.8-flash-high", svc.getMappedModel(account, "models/gemini-3.8-flash"))
})
t.Run("suffixed model keeps normal mapping", func(t *testing.T) {
require.Equal(t, "gemini-3.8-flash-low", svc.getMappedModel(account, "gemini-3.8-flash-low"))
})
t.Run("unsupported model still reports unsupported", func(t *testing.T) {
require.Empty(t, svc.getMappedModel(account, "gemini-9.9-flash"))
})
}