Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
72310276
Unverified
Commit
72310276
authored
Feb 11, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 11, 2026
Browse files
Merge pull request #553 from Edric-Li/feat/antigravity-onboard-projectid
feat(antigravity): 添加 onboardUser 支持,修复 project_id 缺失问题
parents
ae6fed15
a4a46a86
Changes
5
Hide whitespace changes
Inline
Side-by-side
backend/internal/pkg/antigravity/client.go
View file @
72310276
...
...
@@ -115,6 +115,23 @@ type LoadCodeAssistResponse struct {
IneligibleTiers
[]
*
IneligibleTier
`json:"ineligibleTiers,omitempty"`
}
// OnboardUserRequest onboardUser 请求
type
OnboardUserRequest
struct
{
TierID
string
`json:"tierId"`
Metadata
struct
{
IDEType
string
`json:"ideType"`
Platform
string
`json:"platform,omitempty"`
PluginType
string
`json:"pluginType,omitempty"`
}
`json:"metadata"`
}
// OnboardUserResponse onboardUser 响应
type
OnboardUserResponse
struct
{
Name
string
`json:"name,omitempty"`
Done
bool
`json:"done"`
Response
map
[
string
]
any
`json:"response,omitempty"`
}
// GetTier 获取账户类型
// 优先返回 paidTier(付费订阅级别),否则返回 currentTier
func
(
r
*
LoadCodeAssistResponse
)
GetTier
()
string
{
...
...
@@ -361,6 +378,117 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
return
nil
,
nil
,
lastErr
}
// OnboardUser 触发账号 onboarding,并返回 project_id
// 说明:
// 1) 部分账号 loadCodeAssist 不会立即返回 cloudaicompanionProject;
// 2) 这时需要调用 onboardUser 完成初始化,之后才能拿到 project_id。
func
(
c
*
Client
)
OnboardUser
(
ctx
context
.
Context
,
accessToken
,
tierID
string
)
(
string
,
error
)
{
tierID
=
strings
.
TrimSpace
(
tierID
)
if
tierID
==
""
{
return
""
,
fmt
.
Errorf
(
"tier_id 为空"
)
}
reqBody
:=
OnboardUserRequest
{
TierID
:
tierID
}
reqBody
.
Metadata
.
IDEType
=
"ANTIGRAVITY"
reqBody
.
Metadata
.
Platform
=
"PLATFORM_UNSPECIFIED"
reqBody
.
Metadata
.
PluginType
=
"GEMINI"
bodyBytes
,
err
:=
json
.
Marshal
(
reqBody
)
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"序列化请求失败: %w"
,
err
)
}
availableURLs
:=
BaseURLs
var
lastErr
error
for
urlIdx
,
baseURL
:=
range
availableURLs
{
apiURL
:=
baseURL
+
"/v1internal:onboardUser"
for
attempt
:=
1
;
attempt
<=
5
;
attempt
++
{
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
http
.
MethodPost
,
apiURL
,
bytes
.
NewReader
(
bodyBytes
))
if
err
!=
nil
{
lastErr
=
fmt
.
Errorf
(
"创建请求失败: %w"
,
err
)
break
}
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
accessToken
)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"User-Agent"
,
UserAgent
)
resp
,
err
:=
c
.
httpClient
.
Do
(
req
)
if
err
!=
nil
{
lastErr
=
fmt
.
Errorf
(
"onboardUser 请求失败: %w"
,
err
)
if
shouldFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
log
.
Printf
(
"[antigravity] onboardUser URL fallback: %s -> %s"
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
break
}
return
""
,
lastErr
}
respBodyBytes
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
_
=
resp
.
Body
.
Close
()
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"读取响应失败: %w"
,
err
)
}
if
shouldFallbackToNextURL
(
nil
,
resp
.
StatusCode
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
log
.
Printf
(
"[antigravity] onboardUser URL fallback (HTTP %d): %s -> %s"
,
resp
.
StatusCode
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
break
}
if
resp
.
StatusCode
!=
http
.
StatusOK
{
lastErr
=
fmt
.
Errorf
(
"onboardUser 失败 (HTTP %d): %s"
,
resp
.
StatusCode
,
string
(
respBodyBytes
))
return
""
,
lastErr
}
var
onboardResp
OnboardUserResponse
if
err
:=
json
.
Unmarshal
(
respBodyBytes
,
&
onboardResp
);
err
!=
nil
{
lastErr
=
fmt
.
Errorf
(
"onboardUser 响应解析失败: %w"
,
err
)
return
""
,
lastErr
}
if
onboardResp
.
Done
{
if
projectID
:=
extractProjectIDFromOnboardResponse
(
onboardResp
.
Response
);
projectID
!=
""
{
DefaultURLAvailability
.
MarkSuccess
(
baseURL
)
return
projectID
,
nil
}
lastErr
=
fmt
.
Errorf
(
"onboardUser 完成但未返回 project_id"
)
return
""
,
lastErr
}
// done=false 时等待后重试(与 CLIProxyAPI 行为一致)
select
{
case
<-
time
.
After
(
2
*
time
.
Second
)
:
case
<-
ctx
.
Done
()
:
return
""
,
ctx
.
Err
()
}
}
}
if
lastErr
!=
nil
{
return
""
,
lastErr
}
return
""
,
fmt
.
Errorf
(
"onboardUser 未返回 project_id"
)
}
func
extractProjectIDFromOnboardResponse
(
resp
map
[
string
]
any
)
string
{
if
len
(
resp
)
==
0
{
return
""
}
if
v
,
ok
:=
resp
[
"cloudaicompanionProject"
];
ok
{
switch
project
:=
v
.
(
type
)
{
case
string
:
return
strings
.
TrimSpace
(
project
)
case
map
[
string
]
any
:
if
id
,
ok
:=
project
[
"id"
]
.
(
string
);
ok
{
return
strings
.
TrimSpace
(
id
)
}
}
}
return
""
}
// ModelQuotaInfo 模型配额信息
type
ModelQuotaInfo
struct
{
RemainingFraction
float64
`json:"remainingFraction"`
...
...
backend/internal/pkg/antigravity/client_test.go
0 → 100644
View file @
72310276
package
antigravity
import
(
"testing"
)
func
TestExtractProjectIDFromOnboardResponse
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
resp
map
[
string
]
any
want
string
}{
{
name
:
"nil response"
,
resp
:
nil
,
want
:
""
,
},
{
name
:
"empty response"
,
resp
:
map
[
string
]
any
{},
want
:
""
,
},
{
name
:
"project as string"
,
resp
:
map
[
string
]
any
{
"cloudaicompanionProject"
:
"my-project-123"
,
},
want
:
"my-project-123"
,
},
{
name
:
"project as string with spaces"
,
resp
:
map
[
string
]
any
{
"cloudaicompanionProject"
:
" my-project-123 "
,
},
want
:
"my-project-123"
,
},
{
name
:
"project as map with id"
,
resp
:
map
[
string
]
any
{
"cloudaicompanionProject"
:
map
[
string
]
any
{
"id"
:
"proj-from-map"
,
},
},
want
:
"proj-from-map"
,
},
{
name
:
"project as map without id"
,
resp
:
map
[
string
]
any
{
"cloudaicompanionProject"
:
map
[
string
]
any
{
"name"
:
"some-name"
,
},
},
want
:
""
,
},
{
name
:
"missing cloudaicompanionProject key"
,
resp
:
map
[
string
]
any
{
"otherField"
:
"value"
,
},
want
:
""
,
},
}
for
_
,
tc
:=
range
tests
{
t
.
Run
(
tc
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
:=
extractProjectIDFromOnboardResponse
(
tc
.
resp
)
if
got
!=
tc
.
want
{
t
.
Fatalf
(
"extractProjectIDFromOnboardResponse() = %q, want %q"
,
got
,
tc
.
want
)
}
})
}
}
backend/internal/service/antigravity_oauth_service.go
View file @
72310276
...
...
@@ -273,12 +273,21 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
}
client
:=
antigravity
.
NewClient
(
proxyURL
)
loadResp
,
_
,
err
:=
client
.
LoadCodeAssist
(
ctx
,
accessToken
)
loadResp
,
loadRaw
,
err
:=
client
.
LoadCodeAssist
(
ctx
,
accessToken
)
if
err
==
nil
&&
loadResp
!=
nil
&&
loadResp
.
CloudAICompanionProject
!=
""
{
return
loadResp
.
CloudAICompanionProject
,
nil
}
if
err
==
nil
{
if
projectID
,
onboardErr
:=
tryOnboardProjectID
(
ctx
,
client
,
accessToken
,
loadRaw
);
onboardErr
==
nil
&&
projectID
!=
""
{
return
projectID
,
nil
}
else
if
onboardErr
!=
nil
{
lastErr
=
onboardErr
continue
}
}
// 记录错误
if
err
!=
nil
{
lastErr
=
err
...
...
@@ -292,6 +301,65 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
return
""
,
fmt
.
Errorf
(
"获取 project_id 失败 (重试 %d 次后): %w"
,
maxRetries
,
lastErr
)
}
func
tryOnboardProjectID
(
ctx
context
.
Context
,
client
*
antigravity
.
Client
,
accessToken
string
,
loadRaw
map
[
string
]
any
)
(
string
,
error
)
{
tierID
:=
resolveDefaultTierID
(
loadRaw
)
if
tierID
==
""
{
return
""
,
fmt
.
Errorf
(
"loadCodeAssist 未返回可用的默认 tier"
)
}
projectID
,
err
:=
client
.
OnboardUser
(
ctx
,
accessToken
,
tierID
)
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"onboardUser 失败 (tier=%s): %w"
,
tierID
,
err
)
}
return
projectID
,
nil
}
func
resolveDefaultTierID
(
loadRaw
map
[
string
]
any
)
string
{
if
len
(
loadRaw
)
==
0
{
return
""
}
rawTiers
,
ok
:=
loadRaw
[
"allowedTiers"
]
if
!
ok
{
return
""
}
tiers
,
ok
:=
rawTiers
.
([]
any
)
if
!
ok
{
return
""
}
for
_
,
rawTier
:=
range
tiers
{
tier
,
ok
:=
rawTier
.
(
map
[
string
]
any
)
if
!
ok
{
continue
}
if
isDefault
,
_
:=
tier
[
"isDefault"
]
.
(
bool
);
!
isDefault
{
continue
}
if
id
,
ok
:=
tier
[
"id"
]
.
(
string
);
ok
{
id
=
strings
.
TrimSpace
(
id
)
if
id
!=
""
{
return
id
}
}
}
return
""
}
// FillProjectID 仅获取 project_id,不刷新 OAuth token
func
(
s
*
AntigravityOAuthService
)
FillProjectID
(
ctx
context
.
Context
,
account
*
Account
,
accessToken
string
)
(
string
,
error
)
{
var
proxyURL
string
if
account
.
ProxyID
!=
nil
{
proxy
,
err
:=
s
.
proxyRepo
.
GetByID
(
ctx
,
*
account
.
ProxyID
)
if
err
==
nil
&&
proxy
!=
nil
{
proxyURL
=
proxy
.
URL
()
}
}
return
s
.
loadProjectIDWithRetry
(
ctx
,
accessToken
,
proxyURL
,
3
)
}
// BuildAccountCredentials 构建账户凭证
func
(
s
*
AntigravityOAuthService
)
BuildAccountCredentials
(
tokenInfo
*
AntigravityTokenInfo
)
map
[
string
]
any
{
creds
:=
map
[
string
]
any
{
...
...
backend/internal/service/antigravity_oauth_service_test.go
0 → 100644
View file @
72310276
package
service
import
(
"testing"
)
func
TestResolveDefaultTierID
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
loadRaw
map
[
string
]
any
want
string
}{
{
name
:
"nil loadRaw"
,
loadRaw
:
nil
,
want
:
""
,
},
{
name
:
"missing allowedTiers"
,
loadRaw
:
map
[
string
]
any
{
"paidTier"
:
map
[
string
]
any
{
"id"
:
"g1-pro-tier"
},
},
want
:
""
,
},
{
name
:
"empty allowedTiers"
,
loadRaw
:
map
[
string
]
any
{
"allowedTiers"
:
[]
any
{}},
want
:
""
,
},
{
name
:
"tier missing id field"
,
loadRaw
:
map
[
string
]
any
{
"allowedTiers"
:
[]
any
{
map
[
string
]
any
{
"isDefault"
:
true
},
},
},
want
:
""
,
},
{
name
:
"allowedTiers but no default"
,
loadRaw
:
map
[
string
]
any
{
"allowedTiers"
:
[]
any
{
map
[
string
]
any
{
"id"
:
"free-tier"
,
"isDefault"
:
false
},
map
[
string
]
any
{
"id"
:
"standard-tier"
,
"isDefault"
:
false
},
},
},
want
:
""
,
},
{
name
:
"default tier found"
,
loadRaw
:
map
[
string
]
any
{
"allowedTiers"
:
[]
any
{
map
[
string
]
any
{
"id"
:
"free-tier"
,
"isDefault"
:
true
},
map
[
string
]
any
{
"id"
:
"standard-tier"
,
"isDefault"
:
false
},
},
},
want
:
"free-tier"
,
},
{
name
:
"default tier id with spaces"
,
loadRaw
:
map
[
string
]
any
{
"allowedTiers"
:
[]
any
{
map
[
string
]
any
{
"id"
:
" standard-tier "
,
"isDefault"
:
true
},
},
},
want
:
"standard-tier"
,
},
}
for
_
,
tc
:=
range
tests
{
t
.
Run
(
tc
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
:=
resolveDefaultTierID
(
tc
.
loadRaw
)
if
got
!=
tc
.
want
{
t
.
Fatalf
(
"resolveDefaultTierID() = %q, want %q"
,
got
,
tc
.
want
)
}
})
}
}
backend/internal/service/antigravity_token_provider.go
View file @
72310276
...
...
@@ -7,12 +7,14 @@ import (
"log/slog"
"strconv"
"strings"
"sync"
"time"
)
const
(
antigravityTokenRefreshSkew
=
3
*
time
.
Minute
antigravityTokenCacheSkew
=
5
*
time
.
Minute
antigravityBackfillCooldown
=
5
*
time
.
Minute
)
// AntigravityTokenCache Token 缓存接口(复用 GeminiTokenCache 接口定义)
...
...
@@ -23,6 +25,7 @@ type AntigravityTokenProvider struct {
accountRepo
AccountRepository
tokenCache
AntigravityTokenCache
antigravityOAuthService
*
AntigravityOAuthService
backfillCooldown
sync
.
Map
// key: int64 (account.ID) → value: time.Time
}
func
NewAntigravityTokenProvider
(
...
...
@@ -93,13 +96,7 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
if
err
!=
nil
{
return
""
,
err
}
newCredentials
:=
p
.
antigravityOAuthService
.
BuildAccountCredentials
(
tokenInfo
)
for
k
,
v
:=
range
account
.
Credentials
{
if
_
,
exists
:=
newCredentials
[
k
];
!
exists
{
newCredentials
[
k
]
=
v
}
}
account
.
Credentials
=
newCredentials
p
.
mergeCredentials
(
account
,
tokenInfo
)
if
updateErr
:=
p
.
accountRepo
.
Update
(
ctx
,
account
);
updateErr
!=
nil
{
log
.
Printf
(
"[AntigravityTokenProvider] Failed to update account credentials: %v"
,
updateErr
)
}
...
...
@@ -113,6 +110,21 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
return
""
,
errors
.
New
(
"access_token not found in credentials"
)
}
// 如果账号还没有 project_id,尝试在线补齐,避免请求 daily/sandbox 时出现
// "Invalid project resource name projects/"。
// 仅调用 loadProjectIDWithRetry,不刷新 OAuth token;带冷却机制防止频繁重试。
if
strings
.
TrimSpace
(
account
.
GetCredential
(
"project_id"
))
==
""
&&
p
.
antigravityOAuthService
!=
nil
{
if
p
.
shouldAttemptBackfill
(
account
.
ID
)
{
p
.
markBackfillAttempted
(
account
.
ID
)
if
projectID
,
err
:=
p
.
antigravityOAuthService
.
FillProjectID
(
ctx
,
account
,
accessToken
);
err
==
nil
&&
projectID
!=
""
{
account
.
Credentials
[
"project_id"
]
=
projectID
if
updateErr
:=
p
.
accountRepo
.
Update
(
ctx
,
account
);
updateErr
!=
nil
{
log
.
Printf
(
"[AntigravityTokenProvider] project_id 补齐持久化失败: %v"
,
updateErr
)
}
}
}
}
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
if
p
.
tokenCache
!=
nil
{
latestAccount
,
isStale
:=
CheckTokenVersion
(
ctx
,
account
,
p
.
accountRepo
)
...
...
@@ -144,6 +156,31 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
return
accessToken
,
nil
}
// mergeCredentials 将 tokenInfo 构建的凭证合并到 account 中,保留原有未覆盖的字段
func
(
p
*
AntigravityTokenProvider
)
mergeCredentials
(
account
*
Account
,
tokenInfo
*
AntigravityTokenInfo
)
{
newCredentials
:=
p
.
antigravityOAuthService
.
BuildAccountCredentials
(
tokenInfo
)
for
k
,
v
:=
range
account
.
Credentials
{
if
_
,
exists
:=
newCredentials
[
k
];
!
exists
{
newCredentials
[
k
]
=
v
}
}
account
.
Credentials
=
newCredentials
}
// shouldAttemptBackfill 检查是否应该尝试补齐 project_id(冷却期内不重复尝试)
func
(
p
*
AntigravityTokenProvider
)
shouldAttemptBackfill
(
accountID
int64
)
bool
{
if
v
,
ok
:=
p
.
backfillCooldown
.
Load
(
accountID
);
ok
{
if
lastAttempt
,
ok
:=
v
.
(
time
.
Time
);
ok
{
return
time
.
Since
(
lastAttempt
)
>
antigravityBackfillCooldown
}
}
return
true
}
func
(
p
*
AntigravityTokenProvider
)
markBackfillAttempted
(
accountID
int64
)
{
p
.
backfillCooldown
.
Store
(
accountID
,
time
.
Now
())
}
func
AntigravityTokenCacheKey
(
account
*
Account
)
string
{
projectID
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"project_id"
))
if
projectID
!=
""
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment