mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(antigravity): add project fallback for standard tier
This commit is contained in:
@@ -90,6 +90,10 @@ const (
|
||||
antigravityFallbackSecondsEnv = "GATEWAY_ANTIGRAVITY_FALLBACK_COOLDOWN_SECONDS"
|
||||
)
|
||||
|
||||
const antigravityProjectIDFallbackCredentialKey = "antigravity_project_id"
|
||||
|
||||
var errAntigravityProjectIDRequired = errors.New("该 standard-tier Antigravity 账号需配置 project_id")
|
||||
|
||||
// AntigravityAccountSwitchError 账号切换信号
|
||||
// 当账号限流时间超过阈值时,通知上层切换账号
|
||||
type AntigravityAccountSwitchError struct {
|
||||
@@ -1029,6 +1033,22 @@ func (s *AntigravityGatewayService) getMappedModel(account *Account, requestedMo
|
||||
return mapAntigravityModel(account, requestedModel)
|
||||
}
|
||||
|
||||
func resolveAntigravityProjectID(account *Account) (string, error) {
|
||||
if account == nil {
|
||||
return "", errAntigravityProjectIDRequired
|
||||
}
|
||||
if projectID := strings.TrimSpace(account.GetCredential("project_id")); projectID != "" {
|
||||
return projectID, nil
|
||||
}
|
||||
if projectID := strings.TrimSpace(account.GetCredential(antigravityProjectIDFallbackCredentialKey)); projectID != "" {
|
||||
return projectID, nil
|
||||
}
|
||||
if projectID := strings.TrimSpace(account.GetExtraString(antigravityProjectIDFallbackCredentialKey)); projectID != "" {
|
||||
return projectID, nil
|
||||
}
|
||||
return "", errAntigravityProjectIDRequired
|
||||
}
|
||||
|
||||
// applyThinkingModelSuffix 根据 thinking 配置调整模型名
|
||||
// 当映射结果是 claude-sonnet-4-5 且请求开启了 thinking 时,改为 claude-sonnet-4-5-thinking
|
||||
func applyThinkingModelSuffix(mappedModel string, thinkingEnabled bool) string {
|
||||
@@ -1068,8 +1088,10 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
|
||||
return nil, fmt.Errorf("获取 access_token 失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取 project_id(部分账户类型可能没有)
|
||||
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||
projectID, err := resolveAntigravityProjectID(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 模型映射
|
||||
mappedModel := s.getMappedModel(account, modelID)
|
||||
@@ -1326,6 +1348,10 @@ func (s *AntigravityGatewayService) wrapV1InternalRequest(projectID, model strin
|
||||
if err := json.Unmarshal(originalBody, &request); err != nil {
|
||||
return nil, fmt.Errorf("解析请求体失败: %w", err)
|
||||
}
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return nil, errAntigravityProjectIDRequired
|
||||
}
|
||||
|
||||
wrapped := map[string]any{
|
||||
"project": projectID,
|
||||
@@ -1403,8 +1429,11 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 project_id(部分账户类型可能没有)
|
||||
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||
projectID, err := resolveAntigravityProjectID(account)
|
||||
if err != nil {
|
||||
_ = s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 代理 URL
|
||||
proxyURL := ""
|
||||
@@ -2171,8 +2200,11 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 project_id(部分账户类型可能没有)
|
||||
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||
projectID, err := resolveAntigravityProjectID(account)
|
||||
if err != nil {
|
||||
_ = s.writeGoogleError(c, http.StatusBadRequest, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 代理 URL
|
||||
proxyURL := ""
|
||||
|
||||
@@ -185,6 +185,21 @@ func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, a
|
||||
return s.Do(req, proxyURL, accountID, concurrency)
|
||||
}
|
||||
|
||||
type recordingInternal500CounterCache struct {
|
||||
incrementCalls []int64
|
||||
resetCalls []int64
|
||||
}
|
||||
|
||||
func (c *recordingInternal500CounterCache) IncrementInternal500Count(_ context.Context, accountID int64) (int64, error) {
|
||||
c.incrementCalls = append(c.incrementCalls, accountID)
|
||||
return int64(len(c.incrementCalls)), nil
|
||||
}
|
||||
|
||||
func (c *recordingInternal500CounterCache) ResetInternal500Count(_ context.Context, accountID int64) error {
|
||||
c.resetCalls = append(c.resetCalls, accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type antigravitySettingRepoStub struct{}
|
||||
|
||||
func (s *antigravitySettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
||||
@@ -215,6 +230,157 @@ func (s *antigravitySettingRepoStub) Delete(ctx context.Context, key string) err
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func TestResolveAntigravityProjectID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "uses onboard project_id first",
|
||||
account: &Account{Credentials: map[string]any{
|
||||
"project_id": " onboard-project ",
|
||||
antigravityProjectIDFallbackCredentialKey: " configured-project ",
|
||||
}},
|
||||
want: "onboard-project",
|
||||
},
|
||||
{
|
||||
name: "uses configured credentials fallback",
|
||||
account: &Account{Credentials: map[string]any{
|
||||
antigravityProjectIDFallbackCredentialKey: " configured-project ",
|
||||
}},
|
||||
want: "configured-project",
|
||||
},
|
||||
{
|
||||
name: "uses configured extra fallback",
|
||||
account: &Account{Extra: map[string]any{
|
||||
antigravityProjectIDFallbackCredentialKey: " extra-project ",
|
||||
}},
|
||||
want: "extra-project",
|
||||
},
|
||||
{
|
||||
name: "missing project",
|
||||
account: &Account{Credentials: map[string]any{}},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := resolveAntigravityProjectID(tc.account)
|
||||
if tc.wantErr {
|
||||
require.ErrorIs(t, err, errAntigravityProjectIDRequired)
|
||||
require.Empty(t, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityGatewayService_ForwardGemini_UsesConfiguredProjectFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"role": "user", "parts": []map[string]any{{"text": "hello"}}},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(body))
|
||||
|
||||
upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1}}}\n\n")
|
||||
upstream := &queuedHTTPUpstreamStub{
|
||||
responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(bytes.NewReader(upstreamBody)),
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := &AntigravityGatewayService{
|
||||
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 101,
|
||||
Name: "acc-configured-project",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
antigravityProjectIDFallbackCredentialKey: "configured-project",
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-2.5-flash": "gemini-2.5-flash",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, body, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.requestBodies, 1)
|
||||
|
||||
var wrapped map[string]any
|
||||
require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped))
|
||||
require.Equal(t, "configured-project", wrapped["project"])
|
||||
}
|
||||
|
||||
func TestAntigravityGatewayService_ForwardGemini_MissingProjectReturnsLocalError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"role": "user", "parts": []map[string]any{{"text": "hello"}}},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(body))
|
||||
|
||||
upstream := &queuedHTTPUpstreamStub{}
|
||||
internal500Cache := &recordingInternal500CounterCache{}
|
||||
svc := &AntigravityGatewayService{
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: upstream,
|
||||
internal500Cache: internal500Cache,
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 102,
|
||||
Name: "acc-missing-project",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-2.5-flash": "gemini-2.5-flash",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, body, false)
|
||||
require.Nil(t, result)
|
||||
require.ErrorIs(t, err, errAntigravityProjectIDRequired)
|
||||
require.Equal(t, http.StatusBadRequest, writer.Code)
|
||||
require.Empty(t, upstream.requestBodies)
|
||||
require.Empty(t, internal500Cache.incrementCalls)
|
||||
require.Contains(t, writer.Body.String(), "project_id")
|
||||
require.NotContains(t, writer.Body.String(), `"project":""`)
|
||||
}
|
||||
|
||||
func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
@@ -255,6 +421,7 @@ func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"project_id": "proj",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -313,6 +480,7 @@ func TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover(t *tes
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"project_id": "proj",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
@@ -369,6 +537,7 @@ func TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover(
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"project_id": "proj",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
@@ -423,6 +592,7 @@ func TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling(t *tes
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"project_id": "proj",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
@@ -478,6 +648,7 @@ func TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling(
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"project_id": "proj",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
@@ -623,6 +794,7 @@ func TestAntigravityGatewayService_Forward_BillsWithMappedModel(t *testing.T) {
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"project_id": "proj",
|
||||
"model_mapping": map[string]any{
|
||||
"claude-sonnet-4-5": mappedModel,
|
||||
},
|
||||
@@ -676,6 +848,7 @@ func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"project_id": "proj",
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-2.5-flash": mappedModel,
|
||||
},
|
||||
@@ -747,6 +920,7 @@ func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignatur
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"project_id": "proj",
|
||||
"model_mapping": map[string]any{
|
||||
originalModel: mappedModel,
|
||||
},
|
||||
@@ -805,6 +979,7 @@ func TestAntigravityGatewayService_ForwardGemini_SignatureRetryPropagatesFailove
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"project_id": "proj",
|
||||
"model_mapping": map[string]any{
|
||||
originalModel: mappedModel,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user