Antigravity:
- Client ID 保留双 ID 支持(二进制确认两个都存在)
- Daily URL 去掉 .sandbox 后缀(日志确认)
- Redirect URI /callback → /oauth-callback(extension.js 确认)
- User-Agent 动态 OS/arch: antigravity/{ver} {os}/{arch}
- 新增 x-goog-api-client: gl-go/{goVer} gax-go/v2 grpc-go/1.81.0-dev
- googleapis 不再走 Node.js proxy → Go 原生 TLS(匹配真实 BoringCrypto)
- 新增 Go 后端心跳服务(每5分钟 loadCodeAssist + fetchAvailableModels)
- Dockerfile 切换 BoringCrypto 编译(CGO_ENABLED=1 GOEXPERIMENT=boringcrypto)
GeminiCLI:
- User-Agent 动态化: GeminiCLI/0.1.5 ({OS}; {ARCH})
- AI Studio 请求补上 User-Agent
Claude:
- CLI 版本 2.1.84, 包版本 0.74.0, 运行时 v24.3.0
- Token 交换 axios/1.13.6, timeout 15s
- proxy.js 仅服务 api.anthropic.com(Claude 专属)
架构变更:
- Node.js proxy 仅用于 Claude (api.anthropic.com)
- Antigravity (googleapis) 走 Go 原生 HTTP + GOST proxy
- TLS 指纹: Go BoringCrypto ≈ 真实 Antigravity BoringCrypto
205 lines
4.9 KiB
Go
205 lines
4.9 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"math/rand"
|
||
"net/http"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||
)
|
||
|
||
// AntigravityHeartbeat 模拟真实 Antigravity IDE 的心跳行为
|
||
// 真实 IDE 每 5 分钟发送 loadCodeAssist + fetchAvailableModels
|
||
type AntigravityHeartbeat struct {
|
||
mu sync.Mutex
|
||
sessions map[int64]*heartbeatSession // accountID -> session
|
||
stopCh chan struct{}
|
||
}
|
||
|
||
type heartbeatSession struct {
|
||
accountID int64
|
||
accessToken string
|
||
projectID string
|
||
proxyURL string
|
||
lastBeat time.Time
|
||
}
|
||
|
||
// NewAntigravityHeartbeat 创建心跳管理器
|
||
func NewAntigravityHeartbeat() *AntigravityHeartbeat {
|
||
hb := &AntigravityHeartbeat{
|
||
sessions: make(map[int64]*heartbeatSession),
|
||
stopCh: make(chan struct{}),
|
||
}
|
||
go hb.loop()
|
||
return hb
|
||
}
|
||
|
||
// Register 注册账号心跳(首次 API 调用时调用)
|
||
func (h *AntigravityHeartbeat) Register(accountID int64, accessToken, projectID, proxyURL string) {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
|
||
if _, exists := h.sessions[accountID]; exists {
|
||
// 更新 token(可能已刷新)
|
||
h.sessions[accountID].accessToken = accessToken
|
||
return
|
||
}
|
||
|
||
h.sessions[accountID] = &heartbeatSession{
|
||
accountID: accountID,
|
||
accessToken: accessToken,
|
||
projectID: projectID,
|
||
proxyURL: proxyURL,
|
||
lastBeat: time.Now(),
|
||
}
|
||
log.Printf("[antigravity-heartbeat] registered account %d (project: %s)", accountID, projectID)
|
||
}
|
||
|
||
// UpdateToken 更新账号的 access token(token 刷新后调用)
|
||
func (h *AntigravityHeartbeat) UpdateToken(accountID int64, accessToken string) {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
if s, ok := h.sessions[accountID]; ok {
|
||
s.accessToken = accessToken
|
||
}
|
||
}
|
||
|
||
// Unregister 移除账号心跳
|
||
func (h *AntigravityHeartbeat) Unregister(accountID int64) {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
delete(h.sessions, accountID)
|
||
}
|
||
|
||
// Stop 停止心跳
|
||
func (h *AntigravityHeartbeat) Stop() {
|
||
select {
|
||
case <-h.stopCh:
|
||
default:
|
||
close(h.stopCh)
|
||
}
|
||
}
|
||
|
||
func (h *AntigravityHeartbeat) loop() {
|
||
ticker := time.NewTicker(1 * time.Minute)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-h.stopCh:
|
||
return
|
||
case <-ticker.C:
|
||
h.tick()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (h *AntigravityHeartbeat) tick() {
|
||
h.mu.Lock()
|
||
// 收集需要心跳的 session
|
||
var toSend []*heartbeatSession
|
||
now := time.Now()
|
||
for _, s := range h.sessions {
|
||
if now.Sub(s.lastBeat) >= 5*time.Minute {
|
||
s.lastBeat = now
|
||
// 复制一份避免持锁时发请求
|
||
cp := *s
|
||
toSend = append(toSend, &cp)
|
||
}
|
||
}
|
||
h.mu.Unlock()
|
||
|
||
for _, s := range toSend {
|
||
go h.sendHeartbeat(s)
|
||
}
|
||
}
|
||
|
||
func (h *AntigravityHeartbeat) sendHeartbeat(s *heartbeatSession) {
|
||
client, err := antigravity.NewClient(s.proxyURL)
|
||
if err != nil {
|
||
log.Printf("[antigravity-heartbeat] account %d: client error: %v", s.accountID, err)
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
// 1. loadCodeAssist
|
||
h.doLoadCodeAssist(ctx, client, s)
|
||
|
||
// 模拟真实 IDE 的延迟(~500ms)
|
||
time.Sleep(time.Duration(400+rand.Intn(200)) * time.Millisecond)
|
||
|
||
// 2. fetchAvailableModels
|
||
h.doFetchAvailableModels(ctx, client, s)
|
||
}
|
||
|
||
func (h *AntigravityHeartbeat) doLoadCodeAssist(ctx context.Context, client *antigravity.Client, s *heartbeatSession) {
|
||
reqBody := map[string]any{
|
||
"metadata": map[string]string{
|
||
"ideType": "ANTIGRAVITY",
|
||
},
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
|
||
for _, baseURL := range antigravity.BaseURLs {
|
||
apiURL := fmt.Sprintf("%s/v1internal:loadCodeAssist", baseURL)
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||
req.Header.Set("User-Agent", antigravity.GetUserAgent())
|
||
req.Header.Set("X-Goog-Api-Client", antigravity.GetGoogAPIClient())
|
||
|
||
resp, err := client.DoRaw(req)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
io.Copy(io.Discard, resp.Body)
|
||
resp.Body.Close()
|
||
|
||
if resp.StatusCode == http.StatusOK {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (h *AntigravityHeartbeat) doFetchAvailableModels(ctx context.Context, client *antigravity.Client, s *heartbeatSession) {
|
||
reqBody := map[string]string{
|
||
"project": s.projectID,
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
|
||
for _, baseURL := range antigravity.BaseURLs {
|
||
apiURL := fmt.Sprintf("%s/v1internal:fetchAvailableModels", baseURL)
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||
req.Header.Set("User-Agent", antigravity.GetUserAgent())
|
||
req.Header.Set("X-Goog-Api-Client", antigravity.GetGoogAPIClient())
|
||
|
||
resp, err := client.DoRaw(req)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
io.Copy(io.Discard, resp.Body)
|
||
resp.Body.Close()
|
||
|
||
if resp.StatusCode == http.StatusOK {
|
||
return
|
||
}
|
||
}
|
||
}
|