diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 0b1b3e8d1c..25b05a8c7a 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -1408,14 +1408,14 @@ func modelIDsForTest(models []gatewayModelItemForTest) []string { return ids } -func TestGatewayModels_AntigravityExplicitMappingDefinesBothCatalogs(t *testing.T) { +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-5-5-high": "claude-opus-5-5-high", - "gemini-pro-agent": "gemini-pro-agent", + "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) { @@ -1426,7 +1426,7 @@ func TestGatewayModels_AntigravityExplicitMappingDefinesBothCatalogs(t *testing. Credentials: map[string]any{"model_mapping": mapping}, }}}, }) - want := []string{"gemini-3.8-flash", "gemini-3.8-flash-high", "gemini-pro-agent", "claude-opus-5-5-high"} + 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] } @@ -1452,7 +1452,11 @@ func TestGatewayModels_AntigravityExplicitMappingDefinesBothCatalogs(t *testing. ids = modelIDsForTest(got.Data) } require.Equal(t, http.StatusOK, rec.Code) - require.ElementsMatch(t, want, ids, "codex=%t", codex) + // 显式条目必须同时出现在两种目录;默认透传补齐的条目不锁定。 + require.Subset(t, ids, want, "codex=%t", codex) + if platform == service.PlatformGemini { + require.NotContains(t, ids, "claude-opus-4-6-thinking", "codex=%t", codex) + } } }) } @@ -1517,7 +1521,12 @@ func TestGatewayModels_GeminiGroupIncludesAntigravityGeminiMappings(t *testing.T var got gatewayModelsResponseForTest require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) ids := modelIDsForTest(got.Data) - require.ElementsMatch(t, []string{"gemini-3.8-flash-high", "gemini-synced-custom", "gemini-3.8-flash-low", "gemini-native-only"}, ids) + // 账号显式配置的 gemini-* 映射必须出现(resolveModelMapping 还会补一组 antigravity + // 默认透传别名,这里只断言显式条目与排除项,不锁定补齐后的完整集合)。 + require.Contains(t, ids, "gemini-3.8-flash-high") + require.Contains(t, ids, "gemini-synced-custom") + require.Contains(t, ids, "gemini-3.8-flash-low") + require.Contains(t, ids, "gemini-native-only") require.NotContains(t, ids, "gemini-disabled-only") require.NotContains(t, ids, "gemini-unset-only") // antigravity 账号的 claude-* 映射与 anthropic 账号的映射都不应出现在 gemini 分组。 diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 0b6dd45a8f..7d1428ff8e 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/url" "reflect" + "slices" "sort" "strconv" "strings" @@ -690,8 +691,7 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri } if len(result) > 0 { if a.Platform == domain.PlatformAntigravity { - // Explicit mappings define this account's whitelist. Injecting static - // defaults re-enables retired models after an upstream model sync. + ensureAntigravityDefaultPassthroughs(result, antigravityDefaultPassthroughModels) applyAntigravityGemini31ProAliases(result) } return result @@ -741,6 +741,72 @@ 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 + } + if _, exists := mapping[model]; exists { + return + } + for pattern := range mapping { + if matchWildcard(pattern, model) { + return + } + } + mapping[model] = model +} + +func ensureAntigravityDefaultPassthroughs(mapping map[string]string, models []string) { + for _, model := range models { + ensureAntigravityDefaultPassthrough(mapping, model) + } +} + func applyAntigravityGemini31ProAliases(mapping map[string]string) { target := strings.TrimSpace(mapping[domain.AntigravityGemini31ProAgentModel]) if target == "" { @@ -774,13 +840,26 @@ func applyAntigravityGemini31ProAliases(mapping map[string]string) { for _, alias := range aliases { current, exists := mapping[alias.model] - if !exists { + if exists { + if _, legacy := alias.legacyTargets[current]; legacy { + mapping[alias.model] = target + } continue } - if _, legacy := alias.legacyTargets[current]; legacy { - mapping[alias.model] = target + if mappingHasWildcardForModel(mapping, alias.model) { + continue + } + mapping[alias.model] = target + } +} + +func mappingHasWildcardForModel(mapping map[string]string, model string) bool { + for pattern := range mapping { + if matchWildcard(pattern, model) { + return true } } + return false } func normalizeRequestedModelForLookup(platform, requestedModel string) string { diff --git a/backend/internal/service/account_wildcard_test.go b/backend/internal/service/account_wildcard_test.go index 8efa483235..c2f08d94a4 100644 --- a/backend/internal/service/account_wildcard_test.go +++ b/backend/internal/service/account_wildcard_test.go @@ -437,8 +437,8 @@ func TestAccountGetModelMapping_AntigravityNormalizesGemini31ProAliases(t *testi mapping := account.GetModelMapping() - if account.IsModelSupported("gemini-3.1-pro") { - t.Fatal("legacy target normalization must not add an unconfigured alias") + if got := mapping["gemini-3.1-pro"]; got != domain.AntigravityGemini31ProAgentModel { + t.Fatalf("expected gemini-3.1-pro to map to %q, got %q", domain.AntigravityGemini31ProAgentModel, got) } if got := mapping["gemini-3.1-pro-high"]; got != domain.AntigravityGemini31ProAgentModel { t.Fatalf("expected gemini-3.1-pro-high to map to %q, got %q", domain.AntigravityGemini31ProAgentModel, got) @@ -470,8 +470,8 @@ func TestAccountGetModelMapping_AntigravityPreservesGemini31ProOverrides(t *test if got := mapping["gemini-3.1-pro-preview"]; got != "custom-preview" { t.Fatalf("expected gemini-3.1-pro-preview override to be preserved, got %q", got) } - if account.IsModelSupported("gemini-3.1-pro") { - t.Fatal("explicit overrides must not add an unconfigured alias") + if got := mapping["gemini-3.1-pro"]; got != domain.AntigravityGemini31ProAgentModel { + t.Fatalf("expected gemini-3.1-pro alias to default to %q, got %q", domain.AntigravityGemini31ProAgentModel, got) } } @@ -591,7 +591,7 @@ func TestAccountResolveMappedModel(t *testing.T) { } } -func TestAccountGetModelMapping_AntigravityExplicitWhitelistDoesNotReenableDefaults(t *testing.T) { +func TestAccountGetModelMapping_AntigravityEnsuresGeminiDefaultPassthroughs(t *testing.T) { account := &Account{ Platform: PlatformAntigravity, Credentials: map[string]any{ @@ -602,13 +602,14 @@ func TestAccountGetModelMapping_AntigravityExplicitWhitelistDoesNotReenableDefau } mapping := account.GetModelMapping() - if len(mapping) != 1 || account.GetMappedModel("gemini-3-pro-high") != "gemini-3.1-pro-high" { - t.Fatalf("expected the explicit alias to be retained, got %v", mapping) + if mapping["gemini-3-flash"] != "gemini-3-flash" { + t.Fatalf("expected gemini-3-flash passthrough to be auto-filled, got: %q", mapping["gemini-3-flash"]) } - for _, model := range []string{"gemini-3-flash", "gemini-3.1-pro-high", "gemini-3.1-pro-low", "gemini-3.7-flash-high", "claude-opus-5-5-high"} { - if account.IsModelSupported(model) { - t.Fatalf("explicit whitelist must not re-enable %q", model) - } + if mapping["gemini-3.1-pro-high"] != "gemini-3.1-pro-high" { + t.Fatalf("expected gemini-3.1-pro-high passthrough to be auto-filled, got: %q", mapping["gemini-3.1-pro-high"]) + } + if mapping["gemini-3.1-pro-low"] != "gemini-3.1-pro-low" { + t.Fatalf("expected gemini-3.1-pro-low passthrough to be auto-filled, got: %q", mapping["gemini-3.1-pro-low"]) } } diff --git a/backend/internal/service/antigravity_gemini_thinking_variant_test.go b/backend/internal/service/antigravity_gemini_thinking_variant_test.go index b58eb73c3f..d700182bc1 100644 --- a/backend/internal/service/antigravity_gemini_thinking_variant_test.go +++ b/backend/internal/service/antigravity_gemini_thinking_variant_test.go @@ -77,7 +77,8 @@ func TestResolveGeminiThinkingVariant(t *testing.T) { {"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}, - {"unconfigured default variants stay unavailable", catalog, "gemini-3.6-flash", budget("1000"), "gemini-3.6-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}, {"no variants in mapping: untouched", catalog, "gemini-9.9-flash", budget("-1"), "", false}, @@ -89,10 +90,15 @@ 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}, + // 前端持久化的默认目录含裸名自映射;-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"), "gemini-3.8-flash-high", true}, + }, "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}, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 45014870ee..7341fb123d 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -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"] } diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 5df65b5be8..6b8c6e0665 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -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 diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 4466be1f3f..08f5171a7c 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -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) + } + } +} diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 7dd00009db..bfd82887f5 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -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 diff --git a/backend/internal/service/upstream_models_antigravity_test.go b/backend/internal/service/upstream_models_antigravity_test.go index da80866105..53ff31478d 100644 --- a/backend/internal/service/upstream_models_antigravity_test.go +++ b/backend/internal/service/upstream_models_antigravity_test.go @@ -223,3 +223,23 @@ func TestSyncUpstreamModelCatalogAntigravityBudgetReasoningLevels(t *testing.T) }) } } + +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") +}