mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
Merge pull request #8093 from baryon/contrib/gemini-4-argon-support
feat(antigravity): support Gemini 4 Argon and accurate upstream model catalogs
This commit is contained in:
@@ -113,6 +113,14 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
// Claude 白名单
|
||||
"claude-fable-5-1": "claude-fable-5-1", // 官方模型
|
||||
"claude-fable-5": "claude-fable-5", // 官方模型
|
||||
"claude-opus-5-5": "claude-opus-5-5-high", // 简称映射
|
||||
"claude-opus-5-5-high": "claude-opus-5-5-high", // 官方模型
|
||||
"claude-opus-5-5-medium": "claude-opus-5-5-medium", // 官方模型
|
||||
"claude-opus-5-5-low": "claude-opus-5-5-low", // 官方模型
|
||||
"claude-sonnet-5-5": "claude-sonnet-5-5-high", // 简称映射
|
||||
"claude-sonnet-5-5-high": "claude-sonnet-5-5-high", // 官方模型
|
||||
"claude-sonnet-5-5-medium": "claude-sonnet-5-5-medium", // 官方模型
|
||||
"claude-sonnet-5-5-low": "claude-sonnet-5-5-low", // 官方模型
|
||||
"claude-opus-4-8": "claude-opus-4-8", // 官方模型
|
||||
"claude-opus-4-7": "claude-opus-4-7", // 官方模型
|
||||
"claude-opus-4-6-thinking": "claude-opus-4-6-thinking", // 官方模型
|
||||
@@ -170,6 +178,11 @@ var DefaultAntigravityModelMapping = 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-tiered": "gemini-3.8-flash-tiered",
|
||||
// Gemini 4 Argon tiered models
|
||||
"gemini-4-argon": "gemini-4-argon-high",
|
||||
"gemini-4-argon-high": "gemini-4-argon-high",
|
||||
"gemini-4-argon-low": "gemini-4-argon-low",
|
||||
"gemini-4-argon-medium": "gemini-4-argon-medium",
|
||||
// Gemini 3 image 兼容映射(向 3.1 image 迁移)
|
||||
"gemini-3-pro-image": "gemini-3.1-flash-image",
|
||||
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
|
||||
|
||||
@@ -105,6 +105,28 @@ func TestDefaultAntigravityModelMapping_Gemini38FlashModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_Gemini4ArgonAndClaude55Models(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"gemini-4-argon": "gemini-4-argon-high",
|
||||
"gemini-4-argon-high": "gemini-4-argon-high",
|
||||
"gemini-4-argon-low": "gemini-4-argon-low",
|
||||
"gemini-4-argon-medium": "gemini-4-argon-medium",
|
||||
"claude-opus-5-5": "claude-opus-5-5-high",
|
||||
"claude-opus-5-5-high": "claude-opus-5-5-high",
|
||||
"claude-opus-5-5-low": "claude-opus-5-5-low",
|
||||
"claude-opus-5-5-medium": "claude-opus-5-5-medium",
|
||||
"claude-sonnet-5-5": "claude-sonnet-5-5-high",
|
||||
"claude-sonnet-5-5-high": "claude-sonnet-5-5-high",
|
||||
"claude-sonnet-5-5-low": "claude-sonnet-5-5-low",
|
||||
"claude-sonnet-5-5-medium": "claude-sonnet-5-5-medium",
|
||||
}
|
||||
for from, want := range cases {
|
||||
if got := DefaultAntigravityModelMapping[from]; got != want {
|
||||
t.Fatalf("expected %s to map to %q, got %q", from, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -3004,13 +3004,13 @@ func antigravityAccountTestModels(rawMapping any) []antigravity.ClaudeModel {
|
||||
switch mapping := rawMapping.(type) {
|
||||
case map[string]any:
|
||||
for id := range mapping {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
if strings.TrimSpace(id) != "" && !strings.Contains(id, "*") {
|
||||
mappedIDs = append(mappedIDs, id)
|
||||
}
|
||||
}
|
||||
case map[string]string:
|
||||
for id := range mapping {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
if strings.TrimSpace(id) != "" && !strings.Contains(id, "*") {
|
||||
mappedIDs = append(mappedIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,11 +345,12 @@ func TestAccountHandlerGetAvailableModels_Antigravity(t *testing.T) {
|
||||
{name: "empty JSON mapping", credentials: map[string]any{"model_mapping": map[string]any{}}, want: defaults},
|
||||
{name: "empty string mapping", credentials: map[string]any{"model_mapping": map[string]string{}}, want: defaults},
|
||||
{name: "blank key", credentials: map[string]any{"model_mapping": map[string]any{" ": "upstream-model"}}, want: defaults},
|
||||
{name: "JSON mapping keys and metadata", credentials: map[string]any{"model_mapping": map[string]any{
|
||||
known.ID: known.ID, custom.ID: "upstream-model",
|
||||
{name: "wildcard only falls back to defaults", credentials: map[string]any{"model_mapping": map[string]any{"claude-*": "claude-sonnet-4-6", "gemini-3*": "gemini-3-flash"}}, want: defaults},
|
||||
{name: "JSON mapping keys and metadata skips wildcards", credentials: map[string]any{"model_mapping": map[string]any{
|
||||
known.ID: known.ID, custom.ID: "upstream-model", "claude-*": "claude-sonnet-4-6",
|
||||
}}, want: []antigravity.ClaudeModel{custom, known}},
|
||||
{name: "string mapping keys and metadata", credentials: map[string]any{"model_mapping": map[string]string{
|
||||
known.ID: known.ID, custom.ID: "upstream-model",
|
||||
{name: "string mapping keys and metadata skips wildcards", credentials: map[string]any{"model_mapping": map[string]string{
|
||||
known.ID: known.ID, custom.ID: "upstream-model", "gemini-*": "gemini-3-flash",
|
||||
}}, want: []antigravity.ClaudeModel{custom, known}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -1408,6 +1408,60 @@ func modelIDsForTest(models []gatewayModelItemForTest) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
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-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) {
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{groupID: {{
|
||||
ID: 25, Platform: service.PlatformAntigravity,
|
||||
Extra: map[string]any{"mixed_scheduling": true},
|
||||
Credentials: map[string]any{"model_mapping": mapping},
|
||||
}}},
|
||||
})
|
||||
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]
|
||||
}
|
||||
for _, codex := range []bool{false, true} {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: platform},
|
||||
})
|
||||
var ids []string
|
||||
if codex {
|
||||
h.CodexModels(c)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
for _, model := range got.Models {
|
||||
ids = append(ids, model.Slug)
|
||||
}
|
||||
} else {
|
||||
h.Models(c)
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
ids = modelIDsForTest(got.Data)
|
||||
}
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
// 显式条目必须同时出现在两种目录;默认透传补齐的条目不锁定。
|
||||
require.Subset(t, ids, want, "codex=%t", codex)
|
||||
if platform == service.PlatformGemini {
|
||||
require.NotContains(t, ids, "claude-opus-4-6-thinking", "codex=%t", codex)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// gemini 分组混合调度:组内 antigravity 账号的 gemini-* 映射也应出现在 /v1/models,
|
||||
// 与路由层(listSchedulableAccountsOnce 的 useMixedScheduling)同源;claude-* 不带入,
|
||||
// 非混合调度的异平台账号(anthropic)仍被过滤。
|
||||
|
||||
@@ -39,9 +39,11 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
googleError(c, http.StatusUnauthorized, "Invalid API key")
|
||||
return
|
||||
}
|
||||
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组
|
||||
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组(composite 分组允许路由到 antigravity)
|
||||
forcePlatform, hasForcePlatform := middleware.GetForcePlatformFromContext(c)
|
||||
if !hasForcePlatform && effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
|
||||
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
||||
if !hasForcePlatform && effectivePlatform != service.PlatformGemini &&
|
||||
!servesCompositeAntigravityGemini(apiKey, effectivePlatform) {
|
||||
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
||||
return
|
||||
}
|
||||
@@ -223,9 +225,11 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
|
||||
googleError(c, http.StatusUnauthorized, "Invalid API key")
|
||||
return
|
||||
}
|
||||
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组
|
||||
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组(composite 分组允许路由到 antigravity)
|
||||
forcePlatform, hasForcePlatform := middleware.GetForcePlatformFromContext(c)
|
||||
if !hasForcePlatform && effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
|
||||
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
||||
if !hasForcePlatform && effectivePlatform != service.PlatformGemini &&
|
||||
!servesCompositeAntigravityGemini(apiKey, effectivePlatform) {
|
||||
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
||||
return
|
||||
}
|
||||
@@ -244,9 +248,13 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
|
||||
if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" {
|
||||
modelName = strings.TrimSpace(resolvedModel)
|
||||
}
|
||||
if !hasForcePlatform && servesCompositeAntigravityGemini(apiKey, effectivePlatform) && !isAntigravityGeminiModel(modelName) {
|
||||
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
||||
return
|
||||
}
|
||||
|
||||
// 强制 antigravity 模式:返回 antigravity 模型信息
|
||||
if forcePlatform == service.PlatformAntigravity {
|
||||
// 强制 antigravity 模式或 composite 路由到 antigravity:返回 antigravity 模型信息
|
||||
if forcePlatform == service.PlatformAntigravity || effectivePlatform == service.PlatformAntigravity {
|
||||
c.JSON(http.StatusOK, antigravity.FallbackGeminiModel(modelName))
|
||||
return
|
||||
}
|
||||
@@ -299,9 +307,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
)
|
||||
|
||||
// 检查平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则要求 gemini 分组
|
||||
// 检查平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则要求 gemini 分组(composite 分组允许路由到 antigravity)
|
||||
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
||||
if !middleware.HasForcePlatform(c) {
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
|
||||
if effectivePlatform != service.PlatformGemini &&
|
||||
!servesCompositeAntigravityGemini(apiKey, effectivePlatform) {
|
||||
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
||||
return
|
||||
}
|
||||
@@ -321,6 +331,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" {
|
||||
modelName = strings.TrimSpace(resolvedModel)
|
||||
}
|
||||
if !middleware.HasForcePlatform(c) && servesCompositeAntigravityGemini(apiKey, effectivePlatform) && !isAntigravityGeminiModel(modelName) {
|
||||
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
||||
return
|
||||
}
|
||||
|
||||
stream := action == "streamGenerateContent"
|
||||
reqLog = reqLog.With(zap.String("model", modelName), zap.String("action", action), zap.Bool("stream", stream))
|
||||
@@ -567,7 +581,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini)
|
||||
diagPlatform := service.PlatformGemini
|
||||
if p := effectiveAPIKeyPlatform(c, apiKey); p == service.PlatformAntigravity {
|
||||
diagPlatform = service.PlatformAntigravity
|
||||
}
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, diagPlatform)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
@@ -1053,3 +1071,16 @@ func derefGroupID(groupID *int64) int64 {
|
||||
}
|
||||
return *groupID
|
||||
}
|
||||
|
||||
func servesCompositeAntigravityGemini(apiKey *service.APIKey, effectivePlatform string) bool {
|
||||
return apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite && effectivePlatform == service.PlatformAntigravity
|
||||
}
|
||||
|
||||
func isAntigravityGeminiModel(model string) bool {
|
||||
trimmed := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(model, "models/")))
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "claude-") {
|
||||
return false
|
||||
}
|
||||
detected, ok := service.DetectModelPlatform(trimmed)
|
||||
return ok && detected == service.PlatformGemini
|
||||
}
|
||||
|
||||
@@ -216,3 +216,75 @@ func TestShouldFallbackGeminiModel_DelegatesScopeFallback(t *testing.T) {
|
||||
}
|
||||
require.True(t, shouldFallbackGeminiModel("gemini-future-model", res))
|
||||
}
|
||||
|
||||
func TestGeminiV1BetaGetModel_CompositeAntigravityRoute(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("composite route to antigravity gemini model succeeds", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-4-argon", nil)
|
||||
req = req.WithContext(service.WithCompositeRouteDecision(req.Context(), service.CompositeRouteDecision{
|
||||
Matched: true,
|
||||
TargetPlatform: service.PlatformAntigravity,
|
||||
UpstreamModel: "gemini-4-argon-high",
|
||||
}))
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "model", Value: "gemini-4-argon"}}
|
||||
groupID := int64(4)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
|
||||
ID: 1,
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
(&GatewayHandler{}).GeminiV1BetaGetModel(c)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
require.Contains(t, rec.Body.String(), "models/gemini-4-argon-high")
|
||||
})
|
||||
|
||||
t.Run("composite route to antigravity claude model is rejected on v1beta", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1beta/models/claude-sonnet-5-5-high", nil)
|
||||
req = req.WithContext(service.WithCompositeRouteDecision(req.Context(), service.CompositeRouteDecision{
|
||||
Matched: true,
|
||||
TargetPlatform: service.PlatformAntigravity,
|
||||
UpstreamModel: "claude-sonnet-5-5-high",
|
||||
}))
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "model", Value: "claude-sonnet-5-5-high"}}
|
||||
groupID := int64(4)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
|
||||
ID: 1,
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
(&GatewayHandler{}).GeminiV1BetaGetModel(c)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("composite route to antigravity claude model is rejected on generateContent", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/claude-sonnet-5-5-high:generateContent", nil)
|
||||
req = req.WithContext(service.WithCompositeRouteDecision(req.Context(), service.CompositeRouteDecision{
|
||||
Matched: true,
|
||||
TargetPlatform: service.PlatformAntigravity,
|
||||
UpstreamModel: "claude-sonnet-5-5-high",
|
||||
}))
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "modelAction", Value: "/claude-sonnet-5-5-high:generateContent"}}
|
||||
groupID := int64(4)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
|
||||
ID: 1,
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1, Concurrency: 1})
|
||||
|
||||
(&GatewayHandler{}).GeminiV1BetaModels(c)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +169,12 @@ type modelDef struct {
|
||||
var claudeModels = []modelDef{
|
||||
{ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1", CreatedAt: "2026-09-01T00:00:00Z"},
|
||||
{ID: "claude-fable-5", DisplayName: "Claude Fable 5", CreatedAt: "2026-06-09T00:00:00Z"},
|
||||
{ID: "claude-opus-5-5-high", DisplayName: "Claude Opus 5.5 (High)", CreatedAt: "2026-09-22T00:00:00Z"},
|
||||
{ID: "claude-opus-5-5-medium", DisplayName: "Claude Opus 5.5 (Medium)", CreatedAt: "2026-09-22T00:00:00Z"},
|
||||
{ID: "claude-opus-5-5-low", DisplayName: "Claude Opus 5.5 (Low)", CreatedAt: "2026-09-22T00:00:00Z"},
|
||||
{ID: "claude-sonnet-5-5-high", DisplayName: "Claude Sonnet 5.5 (High)", CreatedAt: "2026-09-28T00:00:00Z"},
|
||||
{ID: "claude-sonnet-5-5-medium", DisplayName: "Claude Sonnet 5.5 (Medium)", CreatedAt: "2026-09-28T00:00:00Z"},
|
||||
{ID: "claude-sonnet-5-5-low", DisplayName: "Claude Sonnet 5.5 (Low)", CreatedAt: "2026-09-28T00:00:00Z"},
|
||||
{ID: "claude-opus-4-5-thinking", DisplayName: "Claude Opus 4.5 Thinking", CreatedAt: "2025-11-01T00:00:00Z"},
|
||||
{ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", CreatedAt: "2025-09-29T00:00:00Z"},
|
||||
{ID: "claude-sonnet-4-5-thinking", DisplayName: "Claude Sonnet 4.5 Thinking", CreatedAt: "2025-09-29T00:00:00Z"},
|
||||
@@ -208,6 +214,9 @@ var geminiModels = []modelDef{
|
||||
{ID: "gemini-3.8-flash-low", DisplayName: "Gemini 3.8 Flash Low", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.8-flash-medium", DisplayName: "Gemini 3.8 Flash Medium", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.8-flash-tiered", DisplayName: "Gemini 3.8 Flash", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-4-argon-high", DisplayName: "Gemini 4 Argon (High)", CreatedAt: "2026-10-13T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-4-argon-low", DisplayName: "Gemini 4 Argon (Low)", CreatedAt: "2026-10-13T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-4-argon-medium", DisplayName: "Gemini 4 Argon (Medium)", CreatedAt: "2026-10-13T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"},
|
||||
}
|
||||
@@ -277,6 +286,9 @@ func FallbackGeminiModel(model string) GeminiModel {
|
||||
// IsGeminiReasoningModel 判断是否为不支持参数和强制 ToolConfig 的 Gemini 推理模型
|
||||
func IsGeminiReasoningModel(modelID string) bool {
|
||||
lowerID := strings.ToLower(modelID)
|
||||
if strings.Contains(lowerID, "gemini-pro-agent") || strings.Contains(lowerID, "gemini-4-argon") {
|
||||
return true
|
||||
}
|
||||
for _, m := range geminiModels {
|
||||
if strings.Contains(lowerID, m.ID) && m.IsReasoning {
|
||||
return true
|
||||
|
||||
@@ -39,6 +39,15 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
|
||||
"gemini-3.8-flash-low",
|
||||
"gemini-3.8-flash-medium",
|
||||
"gemini-3.8-flash-tiered",
|
||||
"gemini-4-argon-high",
|
||||
"gemini-4-argon-low",
|
||||
"gemini-4-argon-medium",
|
||||
"claude-opus-5-5-high",
|
||||
"claude-opus-5-5-low",
|
||||
"claude-opus-5-5-medium",
|
||||
"claude-sonnet-5-5-high",
|
||||
"claude-sonnet-5-5-low",
|
||||
"claude-sonnet-5-5-medium",
|
||||
}
|
||||
|
||||
for _, id := range requiredIDs {
|
||||
@@ -67,3 +76,23 @@ func TestClaudeContentItem_ThinkingAlwaysHasSignature(t *testing.T) {
|
||||
t.Fatalf("text block = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGeminiReasoningModel_IncludesProAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !IsGeminiReasoningModel("gemini-pro-agent") {
|
||||
t.Fatal("expected gemini-pro-agent to be recognized as a Gemini reasoning model")
|
||||
}
|
||||
if !IsGeminiReasoningModel("gemini-3.8-flash-high") {
|
||||
t.Fatal("expected gemini-3.8-flash-high to be recognized as a Gemini reasoning model")
|
||||
}
|
||||
if !IsGeminiReasoningModel("gemini-4-argon") {
|
||||
t.Fatal("expected gemini-4-argon to be recognized as a Gemini reasoning model")
|
||||
}
|
||||
if !IsGeminiReasoningModel("gemini-4-argon-high") {
|
||||
t.Fatal("expected gemini-4-argon-high to be recognized as a Gemini reasoning model")
|
||||
}
|
||||
if IsGeminiReasoningModel("gemini-2.5-flash") {
|
||||
t.Fatal("did not expect gemini-2.5-flash to be recognized as a Gemini reasoning model")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,10 +239,39 @@ func TierIDToPlanType(tierID string) string {
|
||||
if tierID == "" {
|
||||
return "Free"
|
||||
}
|
||||
// Google 新增的细分档位(如 g1-ultra-lite-tier、g1-pro-plus-tier)仍归入 Ultra / Pro。
|
||||
if plan := paidPlanFromTierID(tierID); plan != "" {
|
||||
return plan
|
||||
}
|
||||
return tierID
|
||||
}
|
||||
}
|
||||
|
||||
// paidPlanFromTierID 识别 g1-ultra-*-tier / g1-pro-*-tier 形式的付费档位 ID,未知时返回空串。
|
||||
func paidPlanFromTierID(tierID string) string {
|
||||
id := strings.ToLower(strings.TrimSpace(tierID))
|
||||
if !strings.HasSuffix(id, "-tier") {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(id, "g1-ultra-"):
|
||||
return "Ultra"
|
||||
case strings.HasPrefix(id, "g1-pro-"):
|
||||
return "Pro"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// IsPaidPlanType 判断账号记录的 plan_type 是否为付费套餐,兼容旧记录里直接保存的档位 ID。
|
||||
func IsPaidPlanType(planType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(planType)) {
|
||||
case "pro", "ultra":
|
||||
return true
|
||||
}
|
||||
return paidPlanFromTierID(planType) != ""
|
||||
}
|
||||
|
||||
// Client Antigravity API 客户端
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
@@ -653,8 +682,22 @@ type FetchAvailableModelsResponse struct {
|
||||
}
|
||||
|
||||
// FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON
|
||||
// 支持 URL fallback:sandbox → daily → prod
|
||||
// 保留旧调用的 URL fallback:prod → daily。
|
||||
func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) {
|
||||
return c.fetchAvailableModels(ctx, BaseURLs, accessToken, projectID, bodyLimit, false)
|
||||
}
|
||||
|
||||
// FetchAvailableModelsFromURL 获取指定端点的模型目录,不回退到其他端点。
|
||||
// 模型发现应使用与生成请求相同的端点,避免返回该端点无法访问的模型。
|
||||
func (c *Client) FetchAvailableModelsFromURL(ctx context.Context, baseURL, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
return nil, nil, errors.New("fetchAvailableModels base URL is required")
|
||||
}
|
||||
return c.fetchAvailableModels(ctx, []string{baseURL}, accessToken, projectID, bodyLimit, true)
|
||||
}
|
||||
|
||||
func (c *Client) fetchAvailableModels(ctx context.Context, availableURLs []string, accessToken, projectID string, bodyLimit int64, fixedEndpoint bool) (*FetchAvailableModelsResponse, map[string]any, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil, nil, errors.New("antigravity client is not configured")
|
||||
}
|
||||
@@ -668,10 +711,10 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
|
||||
return nil, nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 固定顺序:prod -> daily
|
||||
availableURLs := BaseURLs
|
||||
|
||||
fetchClient := c.fetchAvailableModelsHTTPClient()
|
||||
if fixedEndpoint {
|
||||
fetchClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
}
|
||||
var lastErr error
|
||||
for urlIdx, baseURL := range availableURLs {
|
||||
apiURL := baseURL + "/v1internal:fetchAvailableModels"
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -260,6 +261,8 @@ func TestTierIDToPlanType(t *testing.T) {
|
||||
{"free-tier", "Free"},
|
||||
{"g1-pro-tier", "Pro"},
|
||||
{"g1-ultra-tier", "Ultra"},
|
||||
{"g1-ultra-lite-tier", "Ultra"},
|
||||
{"G1-Pro-Plus-Tier", "Pro"},
|
||||
{"FREE-TIER", "Free"},
|
||||
{"", "Free"},
|
||||
{"unknown-tier", "unknown-tier"},
|
||||
@@ -1468,6 +1471,113 @@ func TestClient_LoadCodeAssist_ContextCanceled_RealCall(t *testing.T) {
|
||||
// Client.FetchAvailableModels - 真正调用方法的测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestClient_FetchAvailableModelsFromURL_UsesSelectedEndpoint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1internal:fetchAvailableModels" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected model list request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer selected-token" {
|
||||
t.Errorf("unexpected authorization header")
|
||||
}
|
||||
var body FetchAvailableModelsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Project != "selected-project" {
|
||||
t.Errorf("unexpected project request: %+v, %v", body, err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"models":{"gemini-3.8-flash-high":{"displayName":"Gemini 3.8 Flash High","supportsImages":true,"supportsThinking":true,"thinkingBudget":24576,"maxTokens":1048576,"maxOutputTokens":65536}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
response, raw, err := client.FetchAvailableModelsFromURL(context.Background(), server.URL, "selected-token", "selected-project", defaultFetchAvailableModelsBodyLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("selected endpoint should return its catalog: %v", err)
|
||||
}
|
||||
model, ok := response.Models["gemini-3.8-flash-high"]
|
||||
if !ok || model.SupportsThinking == nil || !*model.SupportsThinking || model.MaxTokens == nil || *model.MaxTokens != 1048576 {
|
||||
t.Fatalf("selected endpoint metadata must remain available: %+v", model)
|
||||
}
|
||||
if raw["models"] == nil {
|
||||
t.Fatal("selected endpoint raw catalog must remain available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_FetchAvailableModelsFromURL_DoesNotFollowAnotherEndpoint(t *testing.T) {
|
||||
otherEndpointCalls := 0
|
||||
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
otherEndpointCalls++
|
||||
_, _ = w.Write([]byte(`{"models":{"other-endpoint-model":{}}}`))
|
||||
}))
|
||||
defer other.Close()
|
||||
selected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, other.URL+"/v1internal:fetchAvailableModels", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer selected.Close()
|
||||
withMockBaseURLs(t, []string{selected.URL, other.URL})
|
||||
|
||||
response, _, err := mustNewClient(t, "").FetchAvailableModelsFromURL(context.Background(), selected.URL, "token", "project", defaultFetchAvailableModelsBodyLimit)
|
||||
if err == nil || response != nil {
|
||||
t.Fatalf("selected endpoint redirect must not produce another endpoint's catalog: %v, %v", response, err)
|
||||
}
|
||||
if otherEndpointCalls != 0 {
|
||||
t.Fatalf("model discovery must stay on selected endpoint; other endpoint received %d requests", otherEndpointCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_FetchAvailableModelsFromURL_DoesNotFallbackOnFailure(t *testing.T) {
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusForbidden} {
|
||||
t.Run(fmt.Sprint(status), func(t *testing.T) {
|
||||
otherEndpointCalls := 0
|
||||
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
otherEndpointCalls++
|
||||
_, _ = w.Write([]byte(`{"models":{"other-endpoint-model":{}}}`))
|
||||
}))
|
||||
defer other.Close()
|
||||
selected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(`{"error":"selected endpoint unavailable"}`))
|
||||
}))
|
||||
defer selected.Close()
|
||||
withMockBaseURLs(t, []string{selected.URL, other.URL})
|
||||
|
||||
response, raw, err := mustNewClient(t, "").FetchAvailableModelsFromURL(context.Background(), selected.URL, "token", "project", defaultFetchAvailableModelsBodyLimit)
|
||||
if err == nil || response != nil || raw != nil {
|
||||
t.Fatalf("selected endpoint HTTP %d must fail without another catalog: %v, %v", status, response, err)
|
||||
}
|
||||
if otherEndpointCalls != 0 {
|
||||
t.Fatalf("another endpoint must not receive fallback requests: %d", otherEndpointCalls)
|
||||
}
|
||||
if status == http.StatusForbidden {
|
||||
if _, ok := err.(*ForbiddenError); !ok {
|
||||
t.Fatalf("403 must preserve typed forbidden error: %T", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_FetchAvailableModelsFromURL_ValidatesResponseAndLimit(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
limit int64
|
||||
}{
|
||||
{name: "invalid JSON", body: `not JSON`, limit: defaultFetchAvailableModelsBodyLimit},
|
||||
{name: "oversized response", body: `{"models":{"model":{}}}`, limit: 8},
|
||||
{name: "nonpositive limit", body: `{"models":{"model":{}}}`, limit: 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, _, err := mustNewClient(t, "").FetchAvailableModelsFromURL(context.Background(), server.URL, "token", "project", tc.limit)
|
||||
if err == nil {
|
||||
t.Fatal("invalid model catalog response or limit must fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -1787,6 +1897,51 @@ func TestClient_FetchAvailableModels_404Fallback_RealCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_HubUserAgentAndIDEVersionUseContextOverride(t *testing.T) {
|
||||
withTestUserAgentVersionResolver(t)
|
||||
ctx := context.WithValue(context.Background(), userAgentVersionTestContextKey{}, "3.4.5")
|
||||
const expectedUserAgent = "antigravity/hub/3.4.5 windows/amd64"
|
||||
for _, action := range []string{"generateContent", "streamGenerateContent"} {
|
||||
req, err := NewAPIRequestWithURL(ctx, "https://upstream.example", action, "test-token", []byte(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := req.Header.Get("User-Agent"); got != expectedUserAgent {
|
||||
t.Fatalf("generation request must use the context Hub user agent: %q", got)
|
||||
}
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("User-Agent"); got != expectedUserAgent {
|
||||
t.Errorf("client API must use the context Hub user agent: %q", got)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v1internal:loadCodeAssist":
|
||||
var body LoadCodeAssistRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if body.Metadata.IDEVersion != "3.4.5" {
|
||||
t.Errorf("ideVersion must match the user agent version: %q", body.Metadata.IDEVersion)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"cloudaicompanionProject":"test-project"}`)
|
||||
case "/v1internal:fetchAvailableModels":
|
||||
_, _ = io.WriteString(w, `{"models":{"gemini-4-argon-high":{}}}`)
|
||||
default:
|
||||
t.Errorf("unexpected client endpoint: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
client := mustNewClient(t, "")
|
||||
if _, _, err := client.LoadCodeAssist(ctx, "test-token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := client.FetchAvailableModelsFromURL(ctx, server.URL, "test-token", "test-project", defaultFetchAvailableModelsBodyLimit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractProjectIDFromOnboardResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const (
|
||||
AntigravityUserAgentVersionEnv = "ANTIGRAVITY_USER_AGENT_VERSION"
|
||||
|
||||
// DefaultUserAgentVersion 是未通过环境变量或后台设置覆盖时使用的默认版本号。
|
||||
DefaultUserAgentVersion = "2.9.1"
|
||||
DefaultUserAgentVersion = "2.23.0"
|
||||
|
||||
// 固定的 redirect_uri(用户需手动复制 code)
|
||||
RedirectURI = "http://localhost:8085/callback"
|
||||
@@ -123,9 +123,9 @@ func GetUserAgentVersionForContext(ctx context.Context) string {
|
||||
// BuildUserAgent 使用指定版本号构造 User-Agent;版本为空或非法时回退默认值。
|
||||
func BuildUserAgent(version string) string {
|
||||
if normalized := NormalizeUserAgentVersion(version); normalized != "" {
|
||||
return fmt.Sprintf("antigravity/%s windows/amd64", normalized)
|
||||
return fmt.Sprintf("antigravity/hub/%s windows/amd64", normalized)
|
||||
}
|
||||
return fmt.Sprintf("antigravity/%s windows/amd64", defaultUserAgentVersion)
|
||||
return fmt.Sprintf("antigravity/hub/%s windows/amd64", defaultUserAgentVersion)
|
||||
}
|
||||
|
||||
// GetUserAgentForContext 返回当前请求应使用的 User-Agent。
|
||||
|
||||
@@ -3,16 +3,119 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildUserAgentUsesHubClientFamily(t *testing.T) {
|
||||
if got := BuildUserAgent(" 2.23.0 "); got != "antigravity/hub/2.23.0 windows/amd64" {
|
||||
t.Fatalf("Hub client family must be sent with the configured version: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAgentValidatesCustomVersion(t *testing.T) {
|
||||
previous := defaultUserAgentVersion
|
||||
defaultUserAgentVersion = "2.23.0"
|
||||
t.Cleanup(func() { defaultUserAgentVersion = previous })
|
||||
for _, tc := range []struct {
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{version: " 3.4.5 ", want: "antigravity/hub/3.4.5 windows/amd64"},
|
||||
{version: "", want: "antigravity/hub/2.23.0 windows/amd64"},
|
||||
{version: "hub/3.4.5", want: "antigravity/hub/2.23.0 windows/amd64"},
|
||||
{version: "3.4", want: "antigravity/hub/2.23.0 windows/amd64"},
|
||||
{version: "3.4.5\r\nInjected: header", want: "antigravity/hub/2.23.0 windows/amd64"},
|
||||
} {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
if got := BuildUserAgent(tc.version); got != tc.want {
|
||||
t.Fatalf("custom version must be validated before building Hub user agent: got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type userAgentVersionTestContextKey struct{}
|
||||
|
||||
func withTestUserAgentVersionResolver(t *testing.T) {
|
||||
t.Helper()
|
||||
userAgentVersionMu.RLock()
|
||||
previous := userAgentVersionResolver
|
||||
userAgentVersionMu.RUnlock()
|
||||
SetUserAgentVersionResolver(func(ctx context.Context) string {
|
||||
version, _ := ctx.Value(userAgentVersionTestContextKey{}).(string)
|
||||
return version
|
||||
})
|
||||
t.Cleanup(func() { SetUserAgentVersionResolver(previous) })
|
||||
}
|
||||
|
||||
func TestGetUserAgentForContextPreservesVersionOverride(t *testing.T) {
|
||||
withTestUserAgentVersionResolver(t)
|
||||
previous := defaultUserAgentVersion
|
||||
defaultUserAgentVersion = "2.23.0"
|
||||
t.Cleanup(func() { defaultUserAgentVersion = previous })
|
||||
for _, tc := range []struct {
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{version: " 3.4.5 ", want: "antigravity/hub/3.4.5 windows/amd64"},
|
||||
{version: "invalid", want: "antigravity/hub/2.23.0 windows/amd64"},
|
||||
} {
|
||||
ctx := context.WithValue(context.Background(), userAgentVersionTestContextKey{}, tc.version)
|
||||
if got := GetUserAgentForContext(ctx); got != tc.want {
|
||||
t.Fatalf("context version override must use Hub family: got %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := GetUserAgentForContext(nil); got != "antigravity/hub/2.23.0 windows/amd64" {
|
||||
t.Fatalf("nil context must keep the default Hub user agent: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserAgentEnvironmentOverride(t *testing.T) {
|
||||
const expectedVersionEnv = "SUB2API_TEST_EXPECTED_ANTIGRAVITY_VERSION"
|
||||
if expected := os.Getenv(expectedVersionEnv); expected != "" {
|
||||
if got := GetDefaultUserAgentVersion(); got != expected {
|
||||
t.Fatalf("environment version must be loaded during initialization: got %q, want %q", got, expected)
|
||||
}
|
||||
if got := GetUserAgent(); got != "antigravity/hub/"+expected+" windows/amd64" {
|
||||
t.Fatalf("environment override must keep Hub family: %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{version: " 3.4.5 ", want: "3.4.5"},
|
||||
{version: "invalid", want: "2.23.0"},
|
||||
} {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := exec.Command(binary, "-test.run=^TestGetUserAgentEnvironmentOverride$")
|
||||
for _, variable := range os.Environ() {
|
||||
if !strings.HasPrefix(variable, AntigravityUserAgentVersionEnv+"=") && !strings.HasPrefix(variable, expectedVersionEnv+"=") {
|
||||
command.Env = append(command.Env, variable)
|
||||
}
|
||||
}
|
||||
command.Env = append(command.Env, AntigravityUserAgentVersionEnv+"="+tc.version, expectedVersionEnv+"="+tc.want)
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("environment override failed: %v\n%s", err, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getClientSecret
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -693,7 +796,10 @@ func TestConstants_值正确(t *testing.T) {
|
||||
if RedirectURI != "http://localhost:8085/callback" {
|
||||
t.Errorf("RedirectURI 不匹配: got %s", RedirectURI)
|
||||
}
|
||||
if GetUserAgent() != "antigravity/2.9.1 windows/amd64" {
|
||||
if DefaultUserAgentVersion != "2.23.0" {
|
||||
t.Errorf("default Hub version must match the verified upstream release: got %s", DefaultUserAgentVersion)
|
||||
}
|
||||
if GetUserAgent() != "antigravity/hub/2.23.0 windows/amd64" {
|
||||
t.Errorf("UserAgent 不匹配: got %s", GetUserAgent())
|
||||
}
|
||||
if SessionTTL != 30*time.Minute {
|
||||
|
||||
@@ -588,6 +588,7 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
|
||||
ID: block.ToolUseID,
|
||||
},
|
||||
})
|
||||
parts = append(parts, extractToolResultInlineParts(block.Content)...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +639,31 @@ func parseToolResultContent(content json.RawMessage, isError bool) string {
|
||||
return string(content)
|
||||
}
|
||||
|
||||
func extractToolResultInlineParts(content json.RawMessage) []GeminiPart {
|
||||
if len(content) == 0 {
|
||||
return nil
|
||||
}
|
||||
var innerBlocks []ContentBlock
|
||||
if err := json.Unmarshal(content, &innerBlocks); err != nil {
|
||||
return nil
|
||||
}
|
||||
var inlineParts []GeminiPart
|
||||
for _, item := range innerBlocks {
|
||||
if (item.Type == "image" || item.Type == "document") &&
|
||||
item.Source != nil &&
|
||||
item.Source.Type == "base64" &&
|
||||
strings.TrimSpace(item.Source.Data) != "" {
|
||||
inlineParts = append(inlineParts, GeminiPart{
|
||||
InlineData: &GeminiInlineData{
|
||||
MimeType: item.Source.MediaType,
|
||||
Data: item.Source.Data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return inlineParts
|
||||
}
|
||||
|
||||
// buildGenerationConfig 构建 generationConfig
|
||||
const (
|
||||
defaultMaxOutputTokens = 64000
|
||||
|
||||
@@ -694,3 +694,64 @@ func TestToolConfigAlwaysPresent(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformClaudeToGemini_ToolResultImagesReachUpstream(t *testing.T) {
|
||||
claudeReq := &ClaudeRequest{
|
||||
Model: "claude-sonnet-4-5",
|
||||
Messages: []ClaudeMessage{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: json.RawMessage(`[
|
||||
{"type":"tool_use","id":"toolu_img_1","name":"read_image","input":{"path":"colors.png"}}
|
||||
]`),
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: json.RawMessage(`[
|
||||
{
|
||||
"type":"tool_result",
|
||||
"tool_use_id":"toolu_img_1",
|
||||
"content":[
|
||||
{"type":"text","text":"loaded colors.png"},
|
||||
{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type":"tool_result",
|
||||
"tool_use_id":"toolu_img_1",
|
||||
"content":[
|
||||
{"type":"image","source":{"type":"base64","media_type":"image/jpeg","data":"d29ybGQ="}}
|
||||
]
|
||||
}
|
||||
]`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions())
|
||||
require.NoError(t, err)
|
||||
|
||||
var req V1InternalRequest
|
||||
require.NoError(t, json.Unmarshal(body, &req))
|
||||
require.Len(t, req.Request.Contents, 2)
|
||||
|
||||
userParts := req.Request.Contents[1].Parts
|
||||
require.Len(t, userParts, 4)
|
||||
|
||||
require.NotNil(t, userParts[0].FunctionResponse)
|
||||
require.Equal(t, "read_image", userParts[0].FunctionResponse.Name)
|
||||
require.Equal(t, "toolu_img_1", userParts[0].FunctionResponse.ID)
|
||||
require.Equal(t, "loaded colors.png", userParts[0].FunctionResponse.Response["result"])
|
||||
|
||||
require.NotNil(t, userParts[1].InlineData)
|
||||
require.Equal(t, "image/png", userParts[1].InlineData.MimeType)
|
||||
require.Equal(t, "aGVsbG8=", userParts[1].InlineData.Data)
|
||||
|
||||
require.NotNil(t, userParts[2].FunctionResponse)
|
||||
require.Equal(t, "read_image", userParts[2].FunctionResponse.Name)
|
||||
require.Equal(t, "Command executed successfully.", userParts[2].FunctionResponse.Response["result"])
|
||||
|
||||
require.NotNil(t, userParts[3].InlineData)
|
||||
require.Equal(t, "image/jpeg", userParts[3].InlineData.MimeType)
|
||||
require.Equal(t, "d29ybGQ=", userParts[3].InlineData.Data)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func DefaultModels() []Model {
|
||||
{Name: "models/gemini-3.1-pro-preview", SupportedGenerationMethods: methods},
|
||||
{Name: "models/gemini-3.1-pro-preview-customtools", SupportedGenerationMethods: methods},
|
||||
{Name: "models/gemini-3.1-flash-image", SupportedGenerationMethods: methods},
|
||||
{Name: "models/gemini-4-argon", SupportedGenerationMethods: methods},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ var DefaultModels = []Model{
|
||||
{ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview", CreatedAt: ""},
|
||||
{ID: "gemini-3.1-pro-preview", Type: "model", DisplayName: "Gemini 3.1 Pro Preview", CreatedAt: ""},
|
||||
{ID: "gemini-3.1-flash-image", Type: "model", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: ""},
|
||||
{ID: "gemini-4-argon", Type: "model", DisplayName: "Gemini 4 Argon", CreatedAt: ""},
|
||||
}
|
||||
|
||||
// GoogleOneModels is the conservative model set exposed for legacy Google One
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -690,26 +691,7 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri
|
||||
}
|
||||
if len(result) > 0 {
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
ensureAntigravityDefaultPassthroughs(result, []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",
|
||||
})
|
||||
ensureAntigravityDefaultPassthroughs(result, antigravityDefaultPassthroughModels)
|
||||
applyAntigravityGemini31ProAliases(result)
|
||||
}
|
||||
return result
|
||||
@@ -759,6 +741,51 @@ 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
|
||||
|
||||
@@ -2464,12 +2464,12 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou
|
||||
}
|
||||
return s.testClaudeAccountConnection(c, account, modelID)
|
||||
}
|
||||
return s.testAntigravityAccountConnection(c, account, modelID)
|
||||
return s.testAntigravityAccountConnection(c, account, modelID, prompt)
|
||||
}
|
||||
|
||||
// testAntigravityAccountConnection tests an Antigravity account's connection
|
||||
// 支持 Claude 和 Gemini 两种协议,使用非流式请求
|
||||
func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error {
|
||||
func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
testModelID := antigravityConnectionTestModel(modelID)
|
||||
@@ -2488,8 +2488,8 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac
|
||||
// Send test_start event
|
||||
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
||||
|
||||
// 调用 AntigravityGatewayService.TestConnection(复用协议转换逻辑)
|
||||
result, err := s.antigravityGatewayService.TestConnection(ctx, account, testModelID)
|
||||
// 调用 AntigravityGatewayService.TestConnectionWithPrompt(复用协议转换逻辑)
|
||||
result, err := s.antigravityGatewayService.TestConnectionWithPrompt(ctx, account, testModelID, prompt)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, err.Error())
|
||||
}
|
||||
@@ -2498,6 +2498,13 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac
|
||||
if result.Text != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: result.Text})
|
||||
}
|
||||
for _, img := range result.Images {
|
||||
s.sendEvent(c, TestEvent{
|
||||
Type: "image",
|
||||
ImageURL: img.ImageURL,
|
||||
MimeType: img.MimeType,
|
||||
})
|
||||
}
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -14,3 +15,68 @@ func TestAntigravityConnectionTestModel(t *testing.T) {
|
||||
require.Equal(t, "claude-sonnet-4-6", antigravityConnectionTestModel(""))
|
||||
require.Equal(t, "gemini-3.1-pro-preview", antigravityConnectionTestModel("gemini-3.1-pro-preview"))
|
||||
}
|
||||
|
||||
func TestAntigravityBuildGeminiTestRequest_IncludesValidatedToolConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
raw, err := svc.buildGeminiTestRequestWithPrompt("proj-123", "gemini-3.8-flash-high", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
var v1Req map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &v1Req))
|
||||
require.Equal(t, "proj-123", v1Req["project"])
|
||||
require.Equal(t, "gemini-3.8-flash-high", v1Req["model"])
|
||||
|
||||
inner, ok := v1Req["request"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
toolCfg, ok := inner["toolConfig"].(map[string]any)
|
||||
require.True(t, ok, "expected toolConfig to be present in Gemini test request")
|
||||
fcCfg, ok := toolCfg["functionCallingConfig"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "VALIDATED", fcCfg["mode"])
|
||||
}
|
||||
|
||||
func TestAntigravityBuildGeminiTestRequestWithPrompt_ImageModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
raw, err := svc.buildGeminiTestRequestWithPrompt("proj-123", "gemini-3.1-flash-image", "a cute cat")
|
||||
require.NoError(t, err)
|
||||
|
||||
var v1Req map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &v1Req))
|
||||
inner, ok := v1Req["request"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
genCfg, ok := inner["generationConfig"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, []any{"TEXT", "IMAGE"}, genCfg["responseModalities"])
|
||||
|
||||
contents, ok := inner["contents"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, contents, 1)
|
||||
firstContent := contents[0].(map[string]any)
|
||||
parts := firstContent["parts"].([]any)
|
||||
require.Equal(t, "a cute cat", parts[0].(map[string]any)["text"])
|
||||
}
|
||||
|
||||
func TestExtractContentFromSSEResponse_ExtractsTextAndInlineImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sse := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Here is your image\"},{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"iVBORw0KGgo=\"}}]}}]}}\n\n"
|
||||
text, images := extractContentFromSSEResponse([]byte(sse))
|
||||
require.Equal(t, "Here is your image", text)
|
||||
require.Len(t, images, 1)
|
||||
require.Equal(t, "image/png", images[0].MimeType)
|
||||
require.Equal(t, "data:image/png;base64,iVBORw0KGgo=", images[0].ImageURL)
|
||||
}
|
||||
|
||||
func TestAntigravityShouldUseGeminiTestBuilder_UsesMappedModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, shouldUseAntigravityGeminiTestRequest("custom-alias", "gemini-2.5-flash"))
|
||||
require.True(t, shouldUseAntigravityGeminiTestRequest("gemini-3.1-pro", "gemini-pro-agent"))
|
||||
require.False(t, shouldUseAntigravityGeminiTestRequest("gemini-alias", "claude-sonnet-4-6"))
|
||||
require.False(t, shouldUseAntigravityGeminiTestRequest("claude-sonnet-4-6", "claude-sonnet-4-6"))
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
injectedBody = reconciled
|
||||
}
|
||||
|
||||
injectedBody = ensureGeminiFunctionCallThoughtSignatures(injectedBody)
|
||||
|
||||
// 包装请求
|
||||
wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody)
|
||||
if err != nil {
|
||||
|
||||
@@ -78,12 +78,7 @@ func accountHasAntigravityPaidTier(account *Account) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(planType)) {
|
||||
case "pro", "ultra":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return antigravity.IsPaidPlanType(planType)
|
||||
}
|
||||
|
||||
// smartRetryAction 智能重试的处理结果
|
||||
|
||||
@@ -344,16 +344,32 @@ func (s *AntigravityGatewayService) IsModelSupported(requestedModel string) bool
|
||||
strings.HasPrefix(requestedModel, "gemini-")
|
||||
}
|
||||
|
||||
// TestConnectionImage 测试连接返回的图片结果
|
||||
type TestConnectionImage struct {
|
||||
ImageURL string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
// TestConnectionResult 测试连接结果
|
||||
type TestConnectionResult struct {
|
||||
Text string // 响应文本
|
||||
MappedModel string // 实际使用的模型
|
||||
Text string // 响应文本
|
||||
Images []TestConnectionImage // 响应图片(图片生成模型)
|
||||
MappedModel string // 实际使用的模型
|
||||
}
|
||||
|
||||
func shouldUseAntigravityGeminiTestRequest(_, mappedModel string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(mappedModel)), "gemini-")
|
||||
}
|
||||
|
||||
// TestConnection 测试 Antigravity 账号连接。
|
||||
// 复用 antigravityRetryLoop 的完整重试 / credits overages / 智能重试逻辑,
|
||||
// 与真实调度行为一致。差异:不做账号切换(测试指定账号)、不记录 ops 错误。
|
||||
func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account *Account, modelID string) (*TestConnectionResult, error) {
|
||||
return s.TestConnectionWithPrompt(ctx, account, modelID, "")
|
||||
}
|
||||
|
||||
// TestConnectionWithPrompt 测试 Antigravity 账号连接,支持为图片生成模型指定测试提示词。
|
||||
func (s *AntigravityGatewayService) TestConnectionWithPrompt(ctx context.Context, account *Account, modelID string, prompt string) (*TestConnectionResult, error) {
|
||||
|
||||
// 获取 token
|
||||
if s.tokenProvider == nil {
|
||||
@@ -377,8 +393,8 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
|
||||
|
||||
// 构建请求体
|
||||
var requestBody []byte
|
||||
if strings.HasPrefix(modelID, "gemini-") {
|
||||
requestBody, err = s.buildGeminiTestRequest(projectID, mappedModel)
|
||||
if shouldUseAntigravityGeminiTestRequest(modelID, mappedModel) {
|
||||
requestBody, err = s.buildGeminiTestRequestWithPrompt(projectID, mappedModel, prompt)
|
||||
} else {
|
||||
requestBody, err = s.buildClaudeTestRequest(projectID, mappedModel)
|
||||
}
|
||||
@@ -434,8 +450,8 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
|
||||
return nil, fmt.Errorf("API 返回 %d: %s", result.resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
text := extractTextFromSSEResponse(respBody)
|
||||
return &TestConnectionResult{Text: text, MappedModel: mappedModel}, nil
|
||||
text, images := extractContentFromSSEResponse(respBody)
|
||||
return &TestConnectionResult{Text: text, Images: images, MappedModel: mappedModel}, nil
|
||||
}
|
||||
|
||||
// testConnectionHandleError 是 TestConnection 使用的轻量 handleError 回调。
|
||||
@@ -451,9 +467,39 @@ func testConnectionHandleError(
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildGeminiTestRequest 构建 Gemini 格式测试请求
|
||||
// 使用最小 token 消耗:输入 "." + maxOutputTokens: 1
|
||||
func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model string) ([]byte, error) {
|
||||
// buildGeminiTestRequestWithPrompt 构建 Gemini 格式测试请求
|
||||
// 文本模型使用最小 token 消耗:输入 "." + maxOutputTokens: 1;图片模型使用图片生成配置
|
||||
func (s *AntigravityGatewayService) buildGeminiTestRequestWithPrompt(projectID, model, prompt string) ([]byte, error) {
|
||||
if isImageGenerationModel(model) {
|
||||
imagePrompt := strings.TrimSpace(prompt)
|
||||
if imagePrompt == "" {
|
||||
imagePrompt = defaultGeminiImageTestPrompt
|
||||
}
|
||||
payload := map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"parts": []map[string]any{
|
||||
{"text": imagePrompt},
|
||||
},
|
||||
},
|
||||
},
|
||||
"systemInstruction": map[string]any{
|
||||
"parts": []map[string]any{
|
||||
{"text": antigravity.GetDefaultIdentityPatch()},
|
||||
},
|
||||
},
|
||||
"generationConfig": map[string]any{
|
||||
"responseModalities": []string{"TEXT", "IMAGE"},
|
||||
"imageConfig": map[string]any{
|
||||
"aspectRatio": "1:1",
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
return s.wrapV1InternalRequest(projectID, model, payloadBytes)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{
|
||||
@@ -472,6 +518,11 @@ func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model stri
|
||||
"generationConfig": map[string]any{
|
||||
"maxOutputTokens": 1,
|
||||
},
|
||||
"toolConfig": map[string]any{
|
||||
"functionCallingConfig": map[string]any{
|
||||
"mode": "VALIDATED",
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
return s.wrapV1InternalRequest(projectID, model, payloadBytes)
|
||||
@@ -504,9 +555,10 @@ func (s *AntigravityGatewayService) getClaudeTransformOptions(ctx context.Contex
|
||||
return opts
|
||||
}
|
||||
|
||||
// extractTextFromSSEResponse 从 SSE 流式响应中提取文本
|
||||
func extractTextFromSSEResponse(respBody []byte) string {
|
||||
// extractContentFromSSEResponse 从 SSE 流式响应中提取文本和内联图片
|
||||
func extractContentFromSSEResponse(respBody []byte) (string, []TestConnectionImage) {
|
||||
var texts []string
|
||||
var images []TestConnectionImage
|
||||
lines := bytes.Split(respBody, []byte("\n"))
|
||||
|
||||
for _, line := range lines {
|
||||
@@ -532,7 +584,7 @@ func extractTextFromSSEResponse(respBody []byte) string {
|
||||
continue
|
||||
}
|
||||
|
||||
// 尝试从 response.candidates[0].content.parts[].text 提取
|
||||
// 尝试从 response.candidates[0].content.parts[] 提取
|
||||
response, ok := data["response"].(map[string]any)
|
||||
if !ok {
|
||||
// 尝试直接从 candidates 提取(某些响应格式)
|
||||
@@ -564,11 +616,21 @@ func extractTextFromSSEResponse(respBody []byte) string {
|
||||
if text, ok := partMap["text"].(string); ok && text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
if inlineData, ok := partMap["inlineData"].(map[string]any); ok {
|
||||
mimeType, _ := inlineData["mimeType"].(string)
|
||||
b64Data, _ := inlineData["data"].(string)
|
||||
if strings.HasPrefix(strings.ToLower(mimeType), "image/") && b64Data != "" {
|
||||
images = append(images, TestConnectionImage{
|
||||
ImageURL: fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data),
|
||||
MimeType: mimeType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(texts, "")
|
||||
return strings.Join(texts, ""), images
|
||||
}
|
||||
|
||||
// injectIdentityPatchToGeminiRequest 为 Gemini 格式请求注入身份提示词
|
||||
|
||||
@@ -2012,3 +2012,175 @@ func generateLargeUnwrapJSON(minSize int) []byte {
|
||||
b, _ := json.Marshal(outer)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestAntigravityGatewayService_ForwardGemini_NonStreamPreservesFunctionCallAndThoughtSignature(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"contents":[{"role":"user","parts":[{"text":"Call get_weather for Tokyo."}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}]}`)
|
||||
|
||||
writer := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-4-argon:generateContent", bytes.NewReader(body))
|
||||
|
||||
sseStream := strings.Join([]string{
|
||||
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig_fc_1","functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_1"}}]}}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":16,"totalTokenCount":328,"thoughtsTokenCount":12},"modelVersion":"gemini-4-argon","responseId":"resp_1"}}`,
|
||||
"",
|
||||
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":22,"totalTokenCount":334,"thoughtsTokenCount":12},"modelVersion":"gemini-4-argon","responseId":"resp_1"}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
upstream := &queuedHTTPUpstreamStub{
|
||||
responses: []*http.Response{{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(sseStream)),
|
||||
}},
|
||||
}
|
||||
svc := &AntigravityGatewayService{
|
||||
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 109,
|
||||
Name: "antigravity-fc-nonstream",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "test-access-token",
|
||||
"project_id": "test-project",
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-4-argon": "gemini-4-argon",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-4-argon", "generateContent", false, body, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, http.StatusOK, writer.Code)
|
||||
|
||||
var respPayload map[string]any
|
||||
require.NoError(t, json.Unmarshal(writer.Body.Bytes(), &respPayload))
|
||||
candidates, ok := respPayload["candidates"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, candidates, 1)
|
||||
candidate, ok := candidates[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "STOP", candidate["finishReason"])
|
||||
|
||||
content, ok := candidate["content"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
parts, ok := content["parts"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, parts, 1, "trailing empty text part should not overwrite or pollute functionCall part")
|
||||
|
||||
part0, ok := parts[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "sig_fc_1", part0["thoughtSignature"])
|
||||
fc, ok := part0["functionCall"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "get_weather", fc["name"])
|
||||
require.Equal(t, "call_1", fc["id"])
|
||||
require.Equal(t, map[string]any{"city": "Tokyo"}, fc["args"])
|
||||
}
|
||||
|
||||
func TestAntigravityGatewayService_ForwardGemini_StreamAndFunctionResponseFollowUp(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// 1) Stream request with functionDeclarations returns unwrapped functionCall and thoughtSignature
|
||||
streamReqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"Call get_weather for Tokyo."}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}]}]}`)
|
||||
streamWriter := httptest.NewRecorder()
|
||||
streamCtx, _ := gin.CreateTestContext(streamWriter)
|
||||
streamCtx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-4-argon:streamGenerateContent?alt=sse", bytes.NewReader(streamReqBody))
|
||||
|
||||
streamSSE := strings.Join([]string{
|
||||
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig_stream_1","functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_s1"}}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}}`,
|
||||
"",
|
||||
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":6}}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
// 2) Non-stream follow-up with functionCall (missing thoughtSignature) + functionResponse (with inlineData image)
|
||||
followUpReqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"Call get_weather for Tokyo."}]},{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_s1"}}]},{"role":"user","parts":[{"functionResponse":{"name":"get_weather","id":"call_s1","response":{"weather":"Sunny"},"parts":[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="}}]}}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}]}]}`)
|
||||
followUpWriter := httptest.NewRecorder()
|
||||
followUpCtx, _ := gin.CreateTestContext(followUpWriter)
|
||||
followUpCtx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-4-argon:generateContent", bytes.NewReader(followUpReqBody))
|
||||
|
||||
followUpSSE := strings.Join([]string{
|
||||
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"It is sunny in "}]}}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":4}}}`,
|
||||
"",
|
||||
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"Tokyo."}]}}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":6}}}`,
|
||||
"",
|
||||
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig_final","text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":8}}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
upstream := &queuedHTTPUpstreamStub{
|
||||
responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(streamSSE)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(followUpSSE)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := &AntigravityGatewayService{
|
||||
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 110,
|
||||
Name: "antigravity-fc-stream",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "test-access-token",
|
||||
"project_id": "test-project",
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-4-argon": "gemini-4-argon",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
res1, err := svc.ForwardGemini(context.Background(), streamCtx, account, "gemini-4-argon", "streamGenerateContent", true, streamReqBody, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res1)
|
||||
require.Contains(t, streamWriter.Body.String(), `"functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_s1"}`)
|
||||
require.Contains(t, streamWriter.Body.String(), `"thoughtSignature":"sig_stream_1"`)
|
||||
|
||||
res2, err := svc.ForwardGemini(context.Background(), followUpCtx, account, "gemini-4-argon", "generateContent", false, followUpReqBody, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res2)
|
||||
require.Len(t, upstream.requestBodies, 2)
|
||||
|
||||
// Verify follow-up request had dummy thoughtSignature injected on functionCall and preserved functionResponse image part
|
||||
secondSent := string(upstream.requestBodies[1])
|
||||
require.Contains(t, secondSent, `"thoughtSignature":"skip_thought_signature_validator"`)
|
||||
require.Contains(t, secondSent, `"mimeType":"image/png"`)
|
||||
|
||||
var followUpResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(followUpWriter.Body.Bytes(), &followUpResp))
|
||||
cands, ok := followUpResp["candidates"].([]any)
|
||||
require.True(t, ok)
|
||||
cand0, ok := cands[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
content, ok := cand0["content"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
parts, ok := content["parts"].([]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, parts)
|
||||
firstPart, ok := parts[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "It is sunny in Tokyo.", firstPart["text"])
|
||||
}
|
||||
|
||||
@@ -350,8 +350,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
||||
var firstTokenMs *int
|
||||
var last map[string]any
|
||||
var lastWithParts map[string]any
|
||||
var collectedImageParts []map[string]any // 收集所有包含图片的 parts
|
||||
var collectedTextParts []string // 收集所有文本片段
|
||||
var collectedParts []map[string]any // 收集所有 parts(text、thinking、functionCall、inlineData 等)
|
||||
|
||||
type scanEvent struct {
|
||||
line string
|
||||
@@ -467,19 +466,10 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
||||
}
|
||||
}
|
||||
|
||||
// 保留最后一个有 parts 的响应
|
||||
// 保留最后一个有 parts 的响应,并收集所有 parts
|
||||
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
||||
lastWithParts = parsed
|
||||
// 收集包含图片和文本的 parts
|
||||
for _, part := range parts {
|
||||
if inlineData, ok := part["inlineData"].(map[string]any); ok {
|
||||
collectedImageParts = append(collectedImageParts, part)
|
||||
_ = inlineData // 避免 unused 警告
|
||||
}
|
||||
if text, ok := part["text"].(string); ok && text != "" {
|
||||
collectedTextParts = append(collectedTextParts, text)
|
||||
}
|
||||
}
|
||||
collectedParts = append(collectedParts, parts...)
|
||||
}
|
||||
|
||||
case <-intervalCh:
|
||||
@@ -506,14 +496,9 @@ returnResponse:
|
||||
}
|
||||
}
|
||||
|
||||
// 如果收集到了图片 parts,需要合并到最终响应中
|
||||
if len(collectedImageParts) > 0 {
|
||||
finalResponse = mergeImagePartsToResponse(finalResponse, collectedImageParts)
|
||||
}
|
||||
|
||||
// 如果收集到了文本,需要合并到最终响应中
|
||||
if len(collectedTextParts) > 0 {
|
||||
finalResponse = mergeTextPartsToResponse(finalResponse, collectedTextParts)
|
||||
// 将收集的所有 parts 合并到最终响应中
|
||||
if len(collectedParts) > 0 {
|
||||
finalResponse = mergeCollectedPartsToResponse(finalResponse, collectedParts)
|
||||
}
|
||||
|
||||
respBody, err := json.Marshal(finalResponse)
|
||||
@@ -576,21 +561,27 @@ func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map
|
||||
return response
|
||||
}
|
||||
|
||||
result, _, setParts := getOrCreateGeminiParts(response)
|
||||
result, existingParts, setParts := getOrCreateGeminiParts(response)
|
||||
|
||||
// 合并策略:
|
||||
// 1. 保持原始顺序
|
||||
// 2. 连续的普通 text parts 合并为一个
|
||||
// 2. 连续的普通 text parts 合并为一个,并保留关联的 thoughtSignature
|
||||
// 3. thinking、functionCall、inlineData 等保持原样
|
||||
var mergedParts []any
|
||||
var textBuffer strings.Builder
|
||||
var textSignature string
|
||||
|
||||
flushTextBuffer := func() {
|
||||
if textBuffer.Len() > 0 {
|
||||
mergedParts = append(mergedParts, map[string]any{
|
||||
if textBuffer.Len() > 0 || textSignature != "" {
|
||||
part := map[string]any{
|
||||
"text": textBuffer.String(),
|
||||
})
|
||||
}
|
||||
if textSignature != "" {
|
||||
part["thoughtSignature"] = textSignature
|
||||
}
|
||||
mergedParts = append(mergedParts, part)
|
||||
textBuffer.Reset()
|
||||
textSignature = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +596,9 @@ func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map
|
||||
} else {
|
||||
// 普通 text,累积到 buffer
|
||||
_, _ = textBuffer.WriteString(text)
|
||||
if sig, ok := part["thoughtSignature"].(string); ok && sig != "" {
|
||||
textSignature = sig
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 非 text part(functionCall、inlineData 等),先刷新 text buffer,然后保留原样
|
||||
@@ -616,76 +610,14 @@ func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map
|
||||
// 刷新剩余的 text
|
||||
flushTextBuffer()
|
||||
|
||||
if len(mergedParts) == 0 {
|
||||
mergedParts = existingParts
|
||||
}
|
||||
|
||||
setParts(mergedParts)
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeImagePartsToResponse 将收集到的图片 parts 合并到 Gemini 响应中
|
||||
func mergeImagePartsToResponse(response map[string]any, imageParts []map[string]any) map[string]any {
|
||||
if len(imageParts) == 0 {
|
||||
return response
|
||||
}
|
||||
|
||||
result, existingParts, setParts := getOrCreateGeminiParts(response)
|
||||
|
||||
// 检查现有 parts 中是否已经有图片
|
||||
for _, p := range existingParts {
|
||||
if pm, ok := p.(map[string]any); ok {
|
||||
if _, hasInline := pm["inlineData"]; hasInline {
|
||||
return result // 已有图片,不重复添加
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加收集到的图片 parts
|
||||
for _, imgPart := range imageParts {
|
||||
existingParts = append(existingParts, imgPart)
|
||||
}
|
||||
setParts(existingParts)
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeTextPartsToResponse 将收集到的文本合并到 Gemini 响应中
|
||||
func mergeTextPartsToResponse(response map[string]any, textParts []string) map[string]any {
|
||||
if len(textParts) == 0 {
|
||||
return response
|
||||
}
|
||||
|
||||
mergedText := strings.Join(textParts, "")
|
||||
result, existingParts, setParts := getOrCreateGeminiParts(response)
|
||||
|
||||
// 查找并更新第一个 text part,或创建新的
|
||||
newParts := make([]any, 0, len(existingParts)+1)
|
||||
textUpdated := false
|
||||
|
||||
for _, p := range existingParts {
|
||||
pm, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
newParts = append(newParts, p)
|
||||
continue
|
||||
}
|
||||
if _, hasText := pm["text"]; hasText && !textUpdated {
|
||||
// 用累积的文本替换
|
||||
newPart := make(map[string]any)
|
||||
for k, v := range pm {
|
||||
newPart[k] = v
|
||||
}
|
||||
newPart["text"] = mergedText
|
||||
newParts = append(newParts, newPart)
|
||||
textUpdated = true
|
||||
} else {
|
||||
newParts = append(newParts, pm)
|
||||
}
|
||||
}
|
||||
|
||||
if !textUpdated {
|
||||
newParts = append([]any{map[string]any{"text": mergedText}}, newParts...)
|
||||
}
|
||||
|
||||
setParts(newParts)
|
||||
return result
|
||||
}
|
||||
|
||||
// antigravityStatusClientClosed 是客户端在上游响应前断开时回写的状态码(499 client closed request)。
|
||||
const antigravityStatusClientClosed = 499
|
||||
|
||||
|
||||
@@ -108,17 +108,6 @@ func geminiThinkingLevelFromClaudeThinking(thinking *antigravity.ThinkingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// accountRawModelMappingHasKey 判断 key 是否由用户写在账号 credentials.model_mapping 里
|
||||
// (而不是 resolveModelMapping 运行时补进来的默认透传条目)。
|
||||
func accountRawModelMappingHasKey(account *Account, key string) bool {
|
||||
if account == nil || account.Credentials == nil {
|
||||
return false
|
||||
}
|
||||
raw, _ := account.Credentials["model_mapping"].(map[string]any)
|
||||
_, ok := raw[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// resolveGeminiThinkingVariant 为裸 Gemini 模型名挑选账号映射表里存在的思考深度变体。
|
||||
// 返回 (映射后的上游模型名, 是否命中)。未命中时调用方应回退到常规 getMappedModel 流程。
|
||||
func resolveGeminiThinkingVariant(account *Account, requestedModel string, body []byte) (string, bool) {
|
||||
@@ -140,16 +129,16 @@ func resolveGeminiThinkingVariantForLevel(account *Account, requestedModel strin
|
||||
if len(mapping) == 0 {
|
||||
return "", false
|
||||
}
|
||||
// 裸名本身已有"真正的"映射 → 尊重现有配置,不做推导。判定分两层:
|
||||
// 1. 映射目标不是自己(如 gemini-3.8-flash → gemini-3.8-flash-tiered):用户明确指定了目标;
|
||||
// 2. 映射目标是自己(原样透传):仅当这条是用户亲手写进 credentials.model_mapping 的才尊重。
|
||||
// resolveModelMapping 会给每个 Antigravity 账号自动补 gemini-3.x-flash 裸名自映射
|
||||
// (ensureAntigravityDefaultPassthroughs),而上游目录里并没有裸名模型,那条自映射
|
||||
// 正是导致 404 的来源,不能算作用户意图。
|
||||
if mapped, matched := resolveRequestedModelInMapping(mapping, model); matched {
|
||||
if strings.TrimSpace(mapped) != model || accountRawModelMappingHasKey(account, model) {
|
||||
return "", false
|
||||
}
|
||||
// 裸名本身若已映射到其他具体模型(如 gemini-3.8-flash → gemini-3.8-flash-tiered、
|
||||
// gemini-3.1-pro → gemini-pro-agent),尊重显式别名配置。
|
||||
// 若裸名映射目标是自己(如前端从 DefaultAntigravityModelMapping 拉取并持久化的
|
||||
// gemini-3.8-flash → gemini-3.8-flash,或 ensureAntigravityDefaultPassthroughs 注入的条目),
|
||||
// 当映射表中存在该模型的精确思考变体(-high/-medium/-low/-tiered)时继续解析变体,
|
||||
// 避免把上游不存在的裸名直接发出导致 404;而对已在映射表中的真实裸模型(如 gemini-2.5-flash),
|
||||
// 仅查找精确变体键以避免被通配符误匹配。
|
||||
mapped, modelMatched := resolveRequestedModelInMapping(mapping, model)
|
||||
if modelMatched && strings.TrimSpace(mapped) != model {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if preferred == "" {
|
||||
@@ -163,8 +152,17 @@ func resolveGeminiThinkingVariantForLevel(account *Account, requestedModel strin
|
||||
}
|
||||
for _, level := range order {
|
||||
candidate := model + "-" + level
|
||||
if mapped, matched := resolveRequestedModelInMapping(mapping, candidate); matched && strings.TrimSpace(mapped) != "" {
|
||||
return mapped, true
|
||||
var (
|
||||
variantMapped string
|
||||
variantMatched bool
|
||||
)
|
||||
if modelMatched {
|
||||
variantMapped, variantMatched = mapping[candidate]
|
||||
} else {
|
||||
variantMapped, variantMatched = resolveRequestedModelInMapping(mapping, candidate)
|
||||
}
|
||||
if variantMatched && strings.TrimSpace(variantMapped) != "" {
|
||||
return variantMapped, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
|
||||
@@ -74,10 +74,10 @@ func TestResolveGeminiThinkingVariant(t *testing.T) {
|
||||
{"agy low (1000) -> -low", catalog, "gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
|
||||
{"models/ prefix stripped", catalog, "models/gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
|
||||
{"no thinkingConfig -> -high", catalog, "gemini-3.8-flash", []byte(`{"contents":[]}`), "gemini-3.8-flash-high", true},
|
||||
// gemini-3.6/3.7/3.8-flash 的四个变体会被 resolveModelMapping 自动补齐,所以降级用一个不在默认表里的型号
|
||||
{"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},
|
||||
// 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},
|
||||
@@ -90,16 +90,22 @@ 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},
|
||||
{"user-written bare identity passthrough: respected", map[string]string{
|
||||
// 前端持久化的默认目录含裸名自映射;-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"), "", false},
|
||||
{"runtime-injected bare passthrough (not in credentials): still resolved", map[string]string{
|
||||
// resolveModelMapping 会补 gemini-3.7-flash → gemini-3.7-flash 的默认透传,
|
||||
// 但 credentials 里没有这条,应当继续推导变体。
|
||||
}, "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},
|
||||
{"bare model absent from whitelist: configured variant still resolved", map[string]string{
|
||||
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
|
||||
}, "gemini-3.7-flash", budget("4000"), "gemini-3.7-flash-medium", true},
|
||||
// 空 credentials 映射 → 走 DefaultAntigravityModelMapping,其中同样只有带后缀的 3.8 flash
|
||||
// 空 credentials 映射保留默认目录兼容行为。
|
||||
{"empty mapping falls back to default catalog", map[string]string{}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -1093,6 +1093,15 @@ func TestResolveAntigravityForwardBaseURL(t *testing.T) {
|
||||
name: "ultra defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "ULTRA"}},
|
||||
want: dailyURL,
|
||||
},
|
||||
{
|
||||
name: "stored ultra tier id defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "g1-ultra-lite-tier"}},
|
||||
want: dailyURL,
|
||||
},
|
||||
{
|
||||
name: "stored pro tier id defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "g1-pro-tier"}},
|
||||
want: dailyURL,
|
||||
},
|
||||
{name: "free tier id defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "free-tier"}}, want: prodURL},
|
||||
{name: "free defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "free"}}, want: prodURL},
|
||||
{name: "abnormal defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "Abnormal"}}, want: prodURL},
|
||||
{name: "unknown defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "enterprise"}}, want: prodURL},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -123,6 +123,12 @@ func codexModelRoutingAccountIDs(group *Group, modelID string) []int64 {
|
||||
return group.GetRoutingAccountIDs(strings.TrimSpace(modelID))
|
||||
}
|
||||
|
||||
func codexAccountServesPlatformModel(account *Account, platform, modelID string) bool {
|
||||
return account.Platform == platform ||
|
||||
(account.Platform == PlatformAntigravity && mixedListingAccountAllowed(platform, account) &&
|
||||
mixedListingModelAllowed(platform, modelID))
|
||||
}
|
||||
|
||||
func groupCodexModelMetadata(
|
||||
platform string,
|
||||
modelID string,
|
||||
@@ -168,7 +174,7 @@ func groupCodexModelMetadata(
|
||||
if routed && !containsInt64(routedAccountIDs, account.ID) {
|
||||
continue
|
||||
}
|
||||
if account.Platform == platform && codexExplicitModelMappingClaims(*account, modelID) {
|
||||
if codexAccountServesPlatformModel(account, platform, modelID) && codexExplicitModelMappingClaims(*account, modelID) {
|
||||
explicitClaims = true
|
||||
break
|
||||
}
|
||||
@@ -183,7 +189,7 @@ func groupCodexModelMetadata(
|
||||
missingMetadata := false
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.Platform != platform {
|
||||
if !codexAccountServesPlatformModel(account, platform, modelID) {
|
||||
continue
|
||||
}
|
||||
if routed && !containsInt64(routedAccountIDs, account.ID) {
|
||||
@@ -251,7 +257,7 @@ func codexExplicitModelTargetsConflictForPlatform(accounts []Account, platform,
|
||||
targets := make(map[string]struct{})
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.Platform != platform {
|
||||
if !codexAccountServesPlatformModel(account, platform, modelID) {
|
||||
continue
|
||||
}
|
||||
mappedModel, matched := account.ResolveMappedModel(modelID)
|
||||
|
||||
@@ -9,6 +9,71 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCodexCatalogUsesAntigravityCapabilitiesInGeminiMixedGroup(t *testing.T) {
|
||||
const groupID int64 = 93
|
||||
account := Account{
|
||||
ID: 25, Platform: PlatformAntigravity, Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"mixed_scheduling": true},
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{
|
||||
"gemini-custom": "gemini-3.8-flash-high",
|
||||
}},
|
||||
}
|
||||
reasoning := true
|
||||
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{
|
||||
Models: map[string]UpstreamModelMetadata{"gemini-3.8-flash-high": {
|
||||
ID: "gemini-3.8-flash-high", Reasoning: &reasoning,
|
||||
DefaultReasoningLevel: "high", SupportedReasoningLevels: []string{"high"},
|
||||
InputModalities: []string{"text", "image"}, ContextWindow: 1_048_576,
|
||||
MaxOutputTokens: 65_536,
|
||||
}},
|
||||
})
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
||||
byGroup: map[int64][]Account{groupID: {account}},
|
||||
}}
|
||||
for _, platform := range []string{PlatformAntigravity, PlatformComposite, PlatformGemini} {
|
||||
t.Run(platform, func(t *testing.T) {
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(context.Background(),
|
||||
&Group{ID: groupID, Platform: platform}, "", []string{"gemini-custom"})
|
||||
require.NoError(t, err)
|
||||
model := decodeCodexManifestModels(t, body)[0]
|
||||
require.Equal(t, "gemini-custom", model["slug"])
|
||||
require.Equal(t, float64(1_048_576), model["context_window"])
|
||||
require.Equal(t, []any{"text", "image"}, model["input_modalities"])
|
||||
require.Equal(t, []string{"high"}, effortsFromManifestModel(t, model))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexCatalogGeminiCapabilitiesExcludeNonMixedAntigravityAccounts(t *testing.T) {
|
||||
const groupID int64 = 94
|
||||
reasoning := true
|
||||
native := Account{ID: 1, Platform: PlatformGemini, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-custom": "gemini-3.8-flash-high"}},
|
||||
}
|
||||
native.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{
|
||||
Models: map[string]UpstreamModelMetadata{"gemini-3.8-flash-high": {
|
||||
ID: "gemini-3.8-flash-high", Reasoning: &reasoning,
|
||||
SupportedReasoningLevels: []string{"high"}, InputModalities: []string{"text", "image"},
|
||||
ContextWindow: 1_048_576,
|
||||
}},
|
||||
})
|
||||
for _, extra := range []map[string]any{nil, {"mixed_scheduling": false}} {
|
||||
// A peer lacking capabilities would narrow the catalog if included.
|
||||
peer := Account{ID: 2, Platform: PlatformAntigravity, Type: AccountTypeOAuth,
|
||||
Extra: extra, Credentials: native.Credentials}
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
||||
byGroup: map[int64][]Account{groupID: {native, peer}},
|
||||
}}
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformGemini}, "", []string{"gemini-custom"})
|
||||
require.NoError(t, err)
|
||||
model := decodeCodexManifestModels(t, body)[0]
|
||||
require.Equal(t, float64(1_048_576), model["context_window"])
|
||||
require.Equal(t, []any{"text", "image"}, model["input_modalities"])
|
||||
require.Equal(t, []string{"high"}, effortsFromManifestModel(t, model))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAstraUltraCatalogPreservesWorkflowMetadata(t *testing.T) {
|
||||
// Ultra is a Codex workflow. Its inference effort must survive catalog sync,
|
||||
// aliases and group generation instead of silently falling back to max.
|
||||
|
||||
@@ -1094,7 +1094,7 @@ func uniqueCodexMappedModel(accounts []Account, platform string, modelID string)
|
||||
targets := make(map[string]struct{})
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.Platform != platform {
|
||||
if !codexAccountServesPlatformModel(account, platform, modelID) {
|
||||
continue
|
||||
}
|
||||
mappedModel, matched := account.ResolveMappedModel(modelID)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -738,8 +742,7 @@ func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account
|
||||
}
|
||||
|
||||
if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey {
|
||||
models, err := s.fetchAntigravityOAuthUpstreamModels(ctx, account)
|
||||
return models, nil, err
|
||||
return s.fetchAntigravityOAuthUpstreamModels(ctx, account)
|
||||
}
|
||||
|
||||
if s.httpUpstream == nil {
|
||||
@@ -1263,42 +1266,47 @@ func (s *AccountTestService) buildGeminiUpstreamModelsRequest(ctx context.Contex
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (s *AccountTestService) fetchAntigravityOAuthUpstreamModels(ctx context.Context, account *Account) ([]string, error) {
|
||||
func (s *AccountTestService) fetchAntigravityOAuthUpstreamModels(ctx context.Context, account *Account) ([]string, []byte, error) {
|
||||
if s.antigravityGatewayService == nil || s.antigravityGatewayService.GetTokenProvider() == nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Antigravity token provider is not configured", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Antigravity token provider is not configured", nil)
|
||||
}
|
||||
|
||||
accessToken, err := s.antigravityGatewayService.GetTokenProvider().GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Failed to get Antigravity access token", err)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Failed to get Antigravity access token", err)
|
||||
}
|
||||
accessToken = strings.TrimSpace(accessToken)
|
||||
if accessToken == "" {
|
||||
return nil, newUpstreamModelSyncConfigError("No Antigravity access token is available", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("No Antigravity access token is available", nil)
|
||||
}
|
||||
|
||||
client, err := antigravity.NewClient(upstreamModelsProxyURL(account))
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Failed to configure Antigravity client", err)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Failed to configure Antigravity client", err)
|
||||
}
|
||||
modelsResp, _, err := client.FetchAvailableModels(
|
||||
modelsResp, raw, err := client.FetchAvailableModelsFromURL(
|
||||
ctx,
|
||||
resolveAntigravityForwardBaseURL(account),
|
||||
accessToken,
|
||||
strings.TrimSpace(account.GetCredential("project_id")),
|
||||
resolveModelsListReadLimit(s.cfg),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Failed to fetch Antigravity available models", err)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Failed to fetch Antigravity available models", err)
|
||||
}
|
||||
if modelsResp == nil || len(modelsResp.Models) == 0 {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil)
|
||||
}
|
||||
|
||||
models := make([]string, 0, len(modelsResp.Models))
|
||||
for modelID := range modelsResp.Models {
|
||||
models = append(models, strings.TrimSpace(modelID))
|
||||
}
|
||||
return dedupeAndSortModelIDs(models), nil
|
||||
body, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read Antigravity model metadata", err)
|
||||
}
|
||||
return dedupeAndSortModelIDs(models), body, nil
|
||||
}
|
||||
|
||||
func (s *AccountTestService) doUpstreamModelsRequest(req *http.Request, proxyURL string, account *Account) (*http.Response, error) {
|
||||
@@ -1394,6 +1402,12 @@ type upstreamModelCapabilityEntry struct {
|
||||
MaxContextWindow int64 `json:"max_context_window"`
|
||||
MaxOutputTokens int64 `json:"max_output_tokens"`
|
||||
Limit modelsDevLimit `json:"limit"`
|
||||
DisplayNameCamel string `json:"displayName"`
|
||||
SupportsImages *bool `json:"supportsImages"`
|
||||
SupportsThinking *bool `json:"supportsThinking"`
|
||||
ThinkingBudget *int64 `json:"thinkingBudget"`
|
||||
MaxTokens int64 `json:"maxTokens"`
|
||||
MaxOutputTokensCamel int64 `json:"maxOutputTokens"`
|
||||
}
|
||||
|
||||
func extractUpstreamModelIDs(body []byte) ([]string, error) {
|
||||
@@ -1442,12 +1456,33 @@ func extractUpstreamModelCatalog(body []byte, grok bool) ([]string, map[string]U
|
||||
func extractUpstreamModelRawEntries(body []byte) ([]json.RawMessage, error) {
|
||||
var response struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
Models []json.RawMessage `json:"models"`
|
||||
Models json.RawMessage `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &response); err == nil && (response.Data != nil || response.Models != nil) {
|
||||
entries := make([]json.RawMessage, 0, len(response.Data)+len(response.Models))
|
||||
entries = append(entries, response.Data...)
|
||||
entries = append(entries, response.Models...)
|
||||
entries := append([]json.RawMessage{}, response.Data...)
|
||||
if len(response.Models) > 0 && string(response.Models) != "null" {
|
||||
var list []json.RawMessage
|
||||
if err := json.Unmarshal(response.Models, &list); err == nil {
|
||||
entries = append(entries, list...)
|
||||
} else {
|
||||
// Antigravity keys its model catalog by the exact generation model ID.
|
||||
var models map[string]map[string]json.RawMessage
|
||||
if err := json.Unmarshal(response.Models, &models); err != nil {
|
||||
return nil, fmt.Errorf("parse upstream model catalog: %w", err)
|
||||
}
|
||||
for modelID, fields := range models {
|
||||
if fields == nil {
|
||||
fields = make(map[string]json.RawMessage)
|
||||
}
|
||||
fields["id"], _ = json.Marshal(modelID)
|
||||
raw, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse upstream model metadata: %w", err)
|
||||
}
|
||||
entries = append(entries, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
var entries []json.RawMessage
|
||||
@@ -1463,6 +1498,34 @@ func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapa
|
||||
levels = reasoningLevelsFromModelsDevOptions(entry.ReasoningOptions)
|
||||
}
|
||||
reasoning := entry.Reasoning
|
||||
if reasoning == nil {
|
||||
reasoning = entry.SupportsThinking
|
||||
}
|
||||
if len(levels) == 0 && entry.SupportsThinking != nil && *entry.SupportsThinking {
|
||||
level := "high"
|
||||
fixedLevel := normalizeReasoningLevel(modelID[strings.LastIndex(modelID, "-")+1:])
|
||||
if fixedLevel != "" && fixedLevel != "none" {
|
||||
level = fixedLevel
|
||||
} else if entry.ThinkingBudget != nil {
|
||||
switch budget := *entry.ThinkingBudget; {
|
||||
case budget < 0:
|
||||
case budget <= geminiThinkingBudgetLowMax:
|
||||
level = "low"
|
||||
case budget <= geminiThinkingBudgetMediumMax:
|
||||
level = "medium"
|
||||
}
|
||||
}
|
||||
if fixedLevel == "" || fixedLevel == "none" {
|
||||
// Bare and tiered AGY models accept configurable budgets. The fetched
|
||||
// thinkingBudget is their default, not the only supported budget.
|
||||
levels = []string{"low", "medium", "high"}
|
||||
if entry.DefaultReasoningLevel == "" {
|
||||
entry.DefaultReasoningLevel = level
|
||||
}
|
||||
} else {
|
||||
levels = []string{level}
|
||||
}
|
||||
}
|
||||
if reasoning == nil && len(levels) > 0 {
|
||||
inferred := len(levels) != 1 || levels[0] != "none"
|
||||
reasoning = &inferred
|
||||
@@ -1471,6 +1534,13 @@ func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapa
|
||||
if len(modalities) == 0 {
|
||||
modalities = entry.Modalities.Input
|
||||
}
|
||||
if len(modalities) == 0 && (entry.SupportsImages != nil || entry.SupportsThinking != nil || entry.MaxTokens > 0) {
|
||||
// AGY models accept text; image input requires an explicit capability.
|
||||
modalities = []string{"text"}
|
||||
if entry.SupportsImages != nil && *entry.SupportsImages {
|
||||
modalities = append(modalities, "image")
|
||||
}
|
||||
}
|
||||
contextWindow := entry.ContextWindow
|
||||
if contextWindow <= 0 {
|
||||
contextWindow = entry.MaxContextWindow
|
||||
@@ -1478,15 +1548,24 @@ func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapa
|
||||
if contextWindow <= 0 {
|
||||
contextWindow = entry.Limit.Context
|
||||
}
|
||||
if contextWindow <= 0 {
|
||||
contextWindow = entry.MaxTokens
|
||||
}
|
||||
maxOutputTokens := entry.MaxOutputTokens
|
||||
if maxOutputTokens <= 0 {
|
||||
maxOutputTokens = entry.Limit.Output
|
||||
}
|
||||
if maxOutputTokens <= 0 {
|
||||
maxOutputTokens = entry.MaxOutputTokensCamel
|
||||
}
|
||||
defaultReasoningLevel := normalizeReasoningLevel(entry.DefaultReasoningLevel)
|
||||
if defaultReasoningLevel == "" && len(levels) > 0 {
|
||||
defaultReasoningLevel = levels[0]
|
||||
}
|
||||
displayName := strings.TrimSpace(entry.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = strings.TrimSpace(entry.DisplayNameCamel)
|
||||
}
|
||||
if displayName == "" && strings.TrimSpace(entry.Name) != "" && strings.TrimSpace(entry.Name) != modelID {
|
||||
displayName = strings.TrimSpace(entry.Name)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func antigravityModelSyncAccount(plan string) *Account {
|
||||
return &Account{
|
||||
ID: 121, Platform: PlatformAntigravity, Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "model-sync-test-token",
|
||||
"expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
|
||||
"project_id": "model-sync-test-project",
|
||||
"plan_type": plan,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func antigravityModelSyncService() *AccountTestService {
|
||||
return &AccountTestService{
|
||||
antigravityGatewayService: &AntigravityGatewayService{tokenProvider: &AntigravityTokenProvider{}},
|
||||
cfg: upstreamModelSyncTestConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func withAntigravityModelSyncEndpoints(t *testing.T, production, daily string) {
|
||||
t.Helper()
|
||||
previous := antigravity.BaseURLs
|
||||
antigravity.BaseURLs = []string{production, daily}
|
||||
t.Cleanup(func() { antigravity.BaseURLs = previous })
|
||||
}
|
||||
|
||||
func TestFetchUpstreamSupportedModelsAntigravityUsesGenerationEndpoint(t *testing.T) {
|
||||
t.Setenv(antigravityForwardBaseURLEnv, "")
|
||||
production := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"models":{"gemini-3.7-flash-high":{}}}`)
|
||||
}))
|
||||
defer production.Close()
|
||||
daily := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"models":{"gemini-3.8-flash-high":{}}}`)
|
||||
}))
|
||||
defer daily.Close()
|
||||
withAntigravityModelSyncEndpoints(t, production.URL, daily.URL)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
plan string
|
||||
mode string
|
||||
model string
|
||||
}{
|
||||
{name: "Ultra daily", plan: "Ultra", model: "gemini-3.8-flash-high"},
|
||||
{name: "Pro daily", plan: "Pro", model: "gemini-3.8-flash-high"},
|
||||
{name: "Free production", plan: "Free", model: "gemini-3.7-flash-high"},
|
||||
{name: "explicit production", plan: "Ultra", mode: "prod", model: "gemini-3.7-flash-high"},
|
||||
{name: "explicit daily", plan: "Free", mode: "daily", model: "gemini-3.8-flash-high"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(antigravityForwardBaseURLEnv, tc.mode)
|
||||
models, err := antigravityModelSyncService().FetchUpstreamSupportedModels(context.Background(), antigravityModelSyncAccount(tc.plan))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{tc.model}, models, "catalog must come from the endpoint used for generation")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogAntigravityPreservesDirectCapabilities(t *testing.T) {
|
||||
t.Setenv(antigravityForwardBaseURLEnv, "")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"models":{"gemini-3.8-flash-high":{
|
||||
"displayName":"Gemini 3.8 Flash High",
|
||||
"supportsImages":true,"supportsThinking":true,"thinkingBudget":24576,
|
||||
"maxTokens":1048576,"maxOutputTokens":65536
|
||||
}}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
withAntigravityModelSyncEndpoints(t, server.URL, server.URL)
|
||||
account := antigravityModelSyncAccount("Ultra")
|
||||
account.Credentials["model_mapping"] = map[string]any{"gemini-3.8-flash-high": "gemini-3.8-flash-high"}
|
||||
registry := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`))}}
|
||||
svc := antigravityModelSyncService()
|
||||
svc.accountRepo = &upstreamModelMetadataRepoStub{}
|
||||
svc.httpUpstream = registry
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"gemini-3.8-flash-high"}, catalog.Models)
|
||||
require.Contains(t, catalog.Metadata, "gemini-3.8-flash-high")
|
||||
metadata := catalog.Metadata["gemini-3.8-flash-high"]
|
||||
require.Equal(t, "Gemini 3.8 Flash High", metadata.DisplayName)
|
||||
require.NotNil(t, metadata.Reasoning)
|
||||
require.True(t, *metadata.Reasoning)
|
||||
require.Equal(t, []string{"high"}, metadata.SupportedReasoningLevels, "fixed variant must advertise only its own reasoning level")
|
||||
require.Equal(t, "high", metadata.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
|
||||
require.Equal(t, int64(1048576), metadata.ContextWindow)
|
||||
require.Equal(t, int64(65536), metadata.MaxOutputTokens)
|
||||
require.Empty(t, registry.requests, "complete upstream capabilities must not need guessed registry capabilities")
|
||||
require.Empty(t, catalog.Warnings)
|
||||
snapshot := account.GetUpstreamModelMetadataSnapshot()
|
||||
require.NotNil(t, snapshot)
|
||||
require.Equal(t, "upstream", snapshot.Source)
|
||||
serializedMetadata, err := json.Marshal(metadata)
|
||||
require.NoError(t, err)
|
||||
serializedSnapshotEntry, err := json.Marshal(snapshot.Models["gemini-3.8-flash-high"])
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(serializedMetadata), string(serializedSnapshotEntry))
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogAntigravityTieredReasoning(t *testing.T) {
|
||||
t.Setenv(antigravityForwardBaseURLEnv, "")
|
||||
for _, tc := range []struct {
|
||||
budget string
|
||||
defaultLevel string
|
||||
}{
|
||||
{budget: "-1", defaultLevel: "high"},
|
||||
{budget: "0", defaultLevel: "low"},
|
||||
{budget: "1024", defaultLevel: "low"},
|
||||
{budget: "1025", defaultLevel: "medium"},
|
||||
{budget: "4000", defaultLevel: "medium"},
|
||||
{budget: "8192", defaultLevel: "medium"},
|
||||
{budget: "8193", defaultLevel: "high"},
|
||||
{defaultLevel: "high"},
|
||||
} {
|
||||
t.Run("budget="+tc.budget, func(t *testing.T) {
|
||||
budgetJSON := ""
|
||||
if tc.budget != "" {
|
||||
budgetJSON = `"thinkingBudget":` + tc.budget + ","
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, fmt.Sprintf(`{"models":{"gemini-3.8-flash-tiered":{
|
||||
"supportsImages":false,"supportsThinking":true,%s
|
||||
"maxTokens":1048576,"maxOutputTokens":65536
|
||||
}}}`, budgetJSON))
|
||||
}))
|
||||
defer server.Close()
|
||||
withAntigravityModelSyncEndpoints(t, server.URL, server.URL)
|
||||
account := antigravityModelSyncAccount("Ultra")
|
||||
account.Credentials["model_mapping"] = map[string]any{"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered"}
|
||||
svc := antigravityModelSyncService()
|
||||
svc.accountRepo = &upstreamModelMetadataRepoStub{}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
metadata := catalog.Metadata["gemini-3.8-flash-tiered"]
|
||||
require.Equal(t, []string{"low", "medium", "high"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, tc.defaultLevel, metadata.DefaultReasoningLevel, "tiered default must follow its upstream thinking budget")
|
||||
require.Equal(t, []string{"text"}, metadata.InputModalities)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogAntigravityMissingImageSupportIsTextOnly(t *testing.T) {
|
||||
t.Setenv(antigravityForwardBaseURLEnv, "")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"models":{"claude-plain":{
|
||||
"supportsThinking":false,"maxTokens":200000,"maxOutputTokens":64000
|
||||
}}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
withAntigravityModelSyncEndpoints(t, server.URL, server.URL)
|
||||
account := antigravityModelSyncAccount("Ultra")
|
||||
account.Credentials["model_mapping"] = map[string]any{"claude-plain": "claude-plain"}
|
||||
registry := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`))}}
|
||||
svc := antigravityModelSyncService()
|
||||
svc.accountRepo = &upstreamModelMetadataRepoStub{}
|
||||
svc.httpUpstream = registry
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
metadata := catalog.Metadata["claude-plain"]
|
||||
require.Equal(t, []string{"text"}, metadata.InputModalities, "missing image declaration must not advertise image input")
|
||||
require.NotNil(t, metadata.Reasoning)
|
||||
require.False(t, *metadata.Reasoning)
|
||||
require.Empty(t, metadata.SupportedReasoningLevels)
|
||||
require.Empty(t, registry.requests)
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogAntigravityBudgetReasoningLevels(t *testing.T) {
|
||||
t.Setenv(antigravityForwardBaseURLEnv, "")
|
||||
for _, tc := range []struct {
|
||||
model string
|
||||
budget int
|
||||
defaultLevel string
|
||||
levels []string
|
||||
}{
|
||||
{model: "gemini-2.5-flash", budget: -1, defaultLevel: "high", levels: []string{"low", "medium", "high"}},
|
||||
{model: "gemini-2.5-flash-lite", budget: 4000, defaultLevel: "medium", levels: []string{"low", "medium", "high"}},
|
||||
{model: "gemini-3.8-flash-low", budget: -1, defaultLevel: "low", levels: []string{"low"}},
|
||||
{model: "gemini-3.8-flash-medium", budget: 1000, defaultLevel: "medium", levels: []string{"medium"}},
|
||||
{model: "gemini-3.8-flash-high", budget: 4000, defaultLevel: "high", levels: []string{"high"}},
|
||||
} {
|
||||
t.Run(tc.model, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, fmt.Sprintf(`{"models":{%q:{
|
||||
"supportsImages":true,"supportsThinking":true,"thinkingBudget":%d,
|
||||
"maxTokens":1048576,"maxOutputTokens":65536
|
||||
}}}`, tc.model, tc.budget))
|
||||
}))
|
||||
defer server.Close()
|
||||
withAntigravityModelSyncEndpoints(t, server.URL, server.URL)
|
||||
account := antigravityModelSyncAccount("Ultra")
|
||||
account.Credentials["model_mapping"] = map[string]any{tc.model: tc.model}
|
||||
svc := antigravityModelSyncService()
|
||||
svc.accountRepo = &upstreamModelMetadataRepoStub{}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
metadata := catalog.Metadata[tc.model]
|
||||
require.Equal(t, tc.levels, metadata.SupportedReasoningLevels, "default budget must not restrict configurable models to one reasoning level")
|
||||
require.Equal(t, tc.defaultLevel, metadata.DefaultReasoningLevel)
|
||||
require.Empty(t, catalog.Warnings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -298,7 +298,7 @@ const supportsGeminiImageTest = computed(() => {
|
||||
const modelID = selectedModelId.value.toLowerCase()
|
||||
if (!modelID.startsWith('gemini-') || !modelID.includes('-image')) return false
|
||||
|
||||
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
|
||||
return props.account?.platform === 'gemini' || props.account?.platform === 'antigravity'
|
||||
})
|
||||
|
||||
const supportsOpenAIImageTest = computed(() => {
|
||||
|
||||
@@ -442,7 +442,7 @@ const supportsGeminiImageTest = computed(() => {
|
||||
const modelID = selectedModelId.value.toLowerCase()
|
||||
if (!modelID.startsWith('gemini-') || !modelID.includes('-image')) return false
|
||||
|
||||
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
|
||||
return props.account?.platform === 'gemini' || props.account?.platform === 'antigravity'
|
||||
})
|
||||
|
||||
const supportsOpenAIImageTest = computed(() => {
|
||||
|
||||
@@ -171,6 +171,21 @@ describe('useModelWhitelist', () => {
|
||||
expect(models).toContain('gemini-3.1-pro')
|
||||
})
|
||||
|
||||
it('Gemini 4 Argon 在 gemini 与 antigravity 平台使用对应真实模型 ID 与预设映射', () => {
|
||||
expect(getModelsByPlatform('gemini')).toContain('gemini-4-argon')
|
||||
const agModels = getModelsByPlatform('antigravity')
|
||||
expect(agModels).not.toContain('gemini-4-argon')
|
||||
expect(agModels).toContain('gemini-4-argon-high')
|
||||
expect(agModels).toContain('gemini-4-argon-medium')
|
||||
expect(agModels).toContain('gemini-4-argon-low')
|
||||
expect(getPresetMappingsByPlatform('antigravity')).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ from: 'gemini-4-argon', to: 'gemini-4-argon-high' }),
|
||||
expect.objectContaining({ from: 'gemini-4-argon-high', to: 'gemini-4-argon-high' }),
|
||||
expect.objectContaining({ from: 'gemini-4-argon-medium', to: 'gemini-4-argon-medium' }),
|
||||
expect.objectContaining({ from: 'gemini-4-argon-low', to: 'gemini-4-argon-low' })
|
||||
]))
|
||||
})
|
||||
|
||||
it('whitelist 模式会忽略通配符条目', () => {
|
||||
const mapping = buildModelMappingObject('whitelist', ['claude-*', 'gemini-3.1-flash-image'], [])
|
||||
expect(mapping).toEqual({
|
||||
|
||||
@@ -57,15 +57,22 @@ const geminiModels = [
|
||||
'gemini-2.5-pro',
|
||||
'gemini-3.5-flash',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-3-pro-preview'
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-4-argon'
|
||||
]
|
||||
|
||||
// Antigravity 官方支持的模型(精确匹配)
|
||||
// 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+
|
||||
const antigravityModels = [
|
||||
// Claude 4.5+ 系列
|
||||
// Claude 4.5+ / 5.5 系列
|
||||
'claude-fable-5-1',
|
||||
'claude-fable-5',
|
||||
'claude-opus-5-5-high',
|
||||
'claude-opus-5-5-medium',
|
||||
'claude-opus-5-5-low',
|
||||
'claude-sonnet-5-5-high',
|
||||
'claude-sonnet-5-5-medium',
|
||||
'claude-sonnet-5-5-low',
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-7',
|
||||
@@ -89,7 +96,28 @@ const antigravityModels = [
|
||||
'gemini-3.1-pro',
|
||||
'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro-low',
|
||||
'gemini-pro-agent',
|
||||
'gemini-3-pro-image',
|
||||
// Gemini 3.6 / 3.7 / 3.8 Flash 系列
|
||||
'gemini-3.6-flash',
|
||||
'gemini-3.6-flash-low',
|
||||
'gemini-3.6-flash-medium',
|
||||
'gemini-3.6-flash-high',
|
||||
'gemini-3.6-flash-tiered',
|
||||
'gemini-3.7-flash',
|
||||
'gemini-3.7-flash-low',
|
||||
'gemini-3.7-flash-medium',
|
||||
'gemini-3.7-flash-high',
|
||||
'gemini-3.7-flash-tiered',
|
||||
'gemini-3.8-flash',
|
||||
'gemini-3.8-flash-low',
|
||||
'gemini-3.8-flash-medium',
|
||||
'gemini-3.8-flash-high',
|
||||
'gemini-3.8-flash-tiered',
|
||||
// Gemini 4 Argon 系列
|
||||
'gemini-4-argon-low',
|
||||
'gemini-4-argon-medium',
|
||||
'gemini-4-argon-high',
|
||||
// 其他
|
||||
'gpt-oss-120b-medium',
|
||||
'tab_flash_lite_preview'
|
||||
@@ -357,7 +385,22 @@ const grokPresetMappings = [
|
||||
|
||||
// Antigravity 预设映射(支持通配符)
|
||||
const antigravityPresetMappings = [
|
||||
// Claude 5.5 / Gemini 4 Argon / 3.8 Flash 推荐映射
|
||||
{ label: '4 Argon→High', from: 'gemini-4-argon', to: 'gemini-4-argon-high', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: '4 Argon High', from: 'gemini-4-argon-high', to: 'gemini-4-argon-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '4 Argon Medium', from: 'gemini-4-argon-medium', to: 'gemini-4-argon-medium', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
{ label: '4 Argon Low', from: 'gemini-4-argon-low', to: 'gemini-4-argon-low', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
|
||||
{ label: '3.8 Flash', from: 'gemini-3.8-flash', to: 'gemini-3.8-flash', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: '3.8 Flash High', from: 'gemini-3.8-flash-high', to: 'gemini-3.8-flash-high', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
|
||||
{ label: 'Opus 5.5→High', from: 'claude-opus-5-5', to: 'claude-opus-5-5-high', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Opus 5.5 High', from: 'claude-opus-5-5-high', to: 'claude-opus-5-5-high', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Opus 5.5 Medium', from: 'claude-opus-5-5-medium', to: 'claude-opus-5-5-medium', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Sonnet 5.5→High', from: 'claude-sonnet-5-5', to: 'claude-sonnet-5-5-high', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Sonnet 5.5 High', from: 'claude-sonnet-5-5-high', to: 'claude-sonnet-5-5-high', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Sonnet 5.5 Medium', from: 'claude-sonnet-5-5-medium', to: 'claude-sonnet-5-5-medium', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
// Claude 通配符映射
|
||||
{ label: 'Claude→Sonnet 5.5', from: 'claude-*', to: 'claude-sonnet-5-5-high', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'Opus→Opus 5.5', from: 'claude-opus-*', to: 'claude-opus-5-5-high', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Claude→Sonnet', from: 'claude-*', to: 'claude-sonnet-4-5', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'Fable 5.1', from: 'claude-fable-5-1', to: 'claude-fable-5-1', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
|
||||
{ label: 'Fable 5', from: 'claude-fable-5', to: 'claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
|
||||
@@ -368,7 +411,8 @@ const antigravityPresetMappings = [
|
||||
{ label: 'Sonnet4.5→4.6', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Sonnet3.5→4.6', from: 'claude-3-5-sonnet-20241022', to: 'claude-sonnet-4-6', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
|
||||
{ label: 'Opus4.5→4.6', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-6-thinking', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' },
|
||||
// Gemini 3→3.1 映射
|
||||
// Gemini 3→3.1 / 4 Argon 映射
|
||||
{ label: '3.1-Pro→4-Argon-High', from: 'gemini-3.1-pro*', to: 'gemini-4-argon-high', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: '3-Pro-Preview→Pro-Agent', from: 'gemini-3-pro-preview', to: 'gemini-pro-agent', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: '3-Pro-High→Pro-Agent', from: 'gemini-3-pro-high', to: 'gemini-pro-agent', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '3-Pro-Low→3.1-Pro-Low', from: 'gemini-3-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
@@ -378,6 +422,7 @@ const antigravityPresetMappings = [
|
||||
{ label: '3.1-Pro-Low透传', from: 'gemini-3.1-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
{ label: '3.1-Pro-Preview→Pro-Agent', from: 'gemini-3.1-pro-preview', to: 'gemini-pro-agent', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
// Gemini 通配符映射
|
||||
{ label: 'Gemini 3→3.8 Flash', from: 'gemini-3*', to: 'gemini-3.8-flash-high', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
{ label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
{ label: 'Gemini 2.5→Flash', from: 'gemini-2.5*', to: 'gemini-2.5-flash', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '2.5-Flash-Image透传', from: 'gemini-2.5-flash-image', to: 'gemini-2.5-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
|
||||
Reference in New Issue
Block a user