fix(antigravity): keep legacy default passthroughs and bill Claude 5.5 tier IDs at base rates

- Restore runtime default passthroughs and Gemini 3.1 Pro aliases for
  explicit Antigravity model_mapping. Mappings saved before the Gemini
  3.x Flash / 3.1 Pro releases still depend on them; dropping them would
  silently remove those models from existing accounts on upgrade.
- Capability sync now skips injected-only passthrough targets, so models
  the upstream catalog omits no longer mark the sync as partial.
- Price Antigravity claude-{opus,sonnet}-5-5-{high,medium,low} at the base
  Opus/Sonnet 5.5 card instead of substring-matching claude-opus-5 /
  claude-sonnet-4-5 (1.25x-3x overbilling).
This commit is contained in:
shaw
2026-10-15 15:47:42 +08:00
parent 8afd2abd97
commit 9d3cc4507f
9 changed files with 211 additions and 30 deletions
@@ -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 分组。
+84 -5
View File
@@ -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 {
@@ -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"])
}
}
@@ -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},
+1 -1
View File
@@ -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"]
}
+25 -2
View File
@@ -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
@@ -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)
}
}
}
+5 -1
View File
@@ -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
@@ -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")
}