diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 39b1c982a4..7f03536b7f 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -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)) diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index bf92f8b3f8..8763da57b0 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -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) diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 0cede4c0e8..ce7fc69edf 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -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) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index b835a84105..086466d319 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -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) } diff --git a/backend/internal/service/antigravity_gemini_thinking_variant.go b/backend/internal/service/antigravity_gemini_thinking_variant.go index fbf02cc1c5..88c4906864 100644 --- a/backend/internal/service/antigravity_gemini_thinking_variant.go +++ b/backend/internal/service/antigravity_gemini_thinking_variant.go @@ -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 { diff --git a/backend/internal/service/antigravity_gemini_thinking_variant_test.go b/backend/internal/service/antigravity_gemini_thinking_variant_test.go index 3e9764249c..d0c17765a9 100644 --- a/backend/internal/service/antigravity_gemini_thinking_variant_test.go +++ b/backend/internal/service/antigravity_gemini_thinking_variant_test.go @@ -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")) + }) +}