diff --git a/Dockerfile b/Dockerfile index a16eb958..74ad8508 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.1-alpine +ARG GOLANG_IMAGE=golang:1.26.1 ARG ALPINE_IMAGE=alpine:3.21 ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct @@ -46,8 +46,8 @@ ARG GOSUMDB ENV GOPROXY=${GOPROXY} ENV GOSUMDB=${GOSUMDB} -# Install build dependencies -RUN apk add --no-cache git ca-certificates tzdata +# Install build dependencies (non-alpine image uses apt) +RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app/backend @@ -61,14 +61,14 @@ COPY backend/ ./ # Copy frontend dist from previous stage (must be after backend copy to avoid being overwritten) COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist -# Build the binary (BuildType=release for CI builds, embed frontend) -# Version precedence: build arg VERSION > cmd/server/VERSION +# Build the binary with BoringCrypto (matches real Antigravity TLS fingerprint) +# CGO_ENABLED=1 required for BoringCrypto; static linking via -extldflags for scratch-like deployment RUN VERSION_VALUE="${VERSION}" && \ if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(tr -d '\r\n' < ./cmd/server/VERSION)"; fi && \ DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \ - CGO_ENABLED=0 GOOS=linux go build \ + CGO_ENABLED=1 GOEXPERIMENT=boringcrypto GOOS=linux go build \ -tags embed \ - -ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \ + -ldflags="-s -w -linkmode external -extldflags '-static' -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \ -trimpath \ -o /app/sub2api \ ./cmd/server diff --git a/antigravity/node-tls-proxy/proxy.js b/antigravity/node-tls-proxy/proxy.js index 1233ea88..f827f737 100644 --- a/antigravity/node-tls-proxy/proxy.js +++ b/antigravity/node-tls-proxy/proxy.js @@ -16,10 +16,10 @@ const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10); const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '600000', 10); const TELEMETRY_ENABLED = process.env.TELEMETRY_ENABLED !== 'false'; // 默认开启 const DD_API_KEY = process.env.DD_API_KEY || 'pubbbf48e6d78dae54bceaa4acf463299bf'; -const CLI_VERSION = process.env.CLI_VERSION || '2.1.81'; -const BUILD_TIME = process.env.BUILD_TIME || '2026-03-20T21:26:18Z'; -// 伪装的 Node 版本(CLI 2.1.81 打包的 Node 版本) -const FAKE_NODE_VERSION = process.env.FAKE_NODE_VERSION || 'v22.14.0'; +const CLI_VERSION = process.env.CLI_VERSION || '2.1.84'; +const BUILD_TIME = process.env.BUILD_TIME || '2026-03-25T23:49:18Z'; +// 伪装的 Node 版本(CLI 2.1.84 打包的 Bun 报告的 Node 兼容版本) +const FAKE_NODE_VERSION = process.env.FAKE_NODE_VERSION || 'v24.3.0'; const log = (level, msg, extra = {}) => { const entry = { time: new Date().toISOString(), level, msg, ...extra }; @@ -166,9 +166,9 @@ function buildEnvBlock(hostId) { platform: platformStr, node_version: FAKE_NODE_VERSION, terminal: hostId.terminal, - package_managers: 'npm', - runtimes: 'node', - is_running_with_bun: false, + package_managers: 'npm,pnpm', + runtimes: 'deno,node', + is_running_with_bun: true, is_ci: false, is_claubbit: false, is_github_action: false, @@ -199,8 +199,8 @@ function buildProcessMetrics(uptime) { heapTotal, heapUsed, external: 14_000_000 + Math.floor(Math.random() * 2_000_000), - arrayBuffers: Math.floor(Math.random() * 10_000), - constrainedMemory: 0, + arrayBuffers: Math.floor(Math.random() * 200_000), + constrainedMemory: 51539607552, cpuUsage: { user: Math.floor(uptime * 10_000 + Math.random() * 300_000), system: Math.floor(uptime * 2_000 + Math.random() * 80_000), @@ -251,12 +251,8 @@ function sendTelemetryEvents(events, session) { 'x-service-name': 'claude-code', 'Content-Length': Buffer.byteLength(body), }; - // 如果有 session,注入 OTEL trace headers(匹配 CLI 的 W3C Trace Context) - if (session) { - const traceId = crypto.randomBytes(16).toString('hex'); - const spanId = crypto.randomBytes(8).toString('hex'); - headers['traceparent'] = `00-${traceId}-${spanId}-01`; - } + // 注意:真实 CLI 2.1.84 的 event_logging/batch 不发 traceparent + // traceparent 仅在 OTLP exporter(单独通道)中使用,不在这个端点 const opts = { hostname: 'api.anthropic.com', @@ -682,6 +678,8 @@ async function proxyRequest(req, res) { 'api.anthropic.com', 'cloudaicompanion.googleapis.com', 'generativelanguage.googleapis.com', + 'cloudcode-pa.googleapis.com', + 'daily-cloudcode-pa.googleapis.com', ]); if (H2_PREFER_HOSTS.has(targetHost) || h2Hosts.has(targetHost)) { await sendViaH2(targetHost, req.method, req.url, req.headers, body, res, savedHeaders); diff --git a/backend/Makefile b/backend/Makefile index 7084ccb9..a3ecd015 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -4,7 +4,7 @@ VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION) LDFLAGS ?= -s -w -X main.Version=$(VERSION) build: - CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -trimpath -o bin/server ./cmd/server + CGO_ENABLED=1 GOEXPERIMENT=boringcrypto go build -ldflags="$(LDFLAGS)" -trimpath -o bin/server ./cmd/server generate: go generate ./ent diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index e2802535..a278acd1 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -12,6 +12,7 @@ import ( "net" "net/http" "net/url" + "runtime" "strings" "time" @@ -29,6 +30,21 @@ func (e *ForbiddenError) Error() string { return fmt.Sprintf("fetchAvailableModels 失败 (HTTP %d): %s", e.StatusCode, e.Body) } +// GetGoogAPIClient 返回 x-goog-api-client 头的值(导出供心跳等外部使用) +// 格式与真实 Antigravity 的 Go SDK 一致: gl-go/{goVersion} gax-go/v2 grpc-go/1.81.0-dev +func GetGoogAPIClient() string { + goVer := runtime.Version() // e.g. "go1.22.0" + return fmt.Sprintf("gl-go/%s gax-go/v2 grpc-go/1.81.0-dev", goVer) +} + +// setAntigravityHeaders 设置与真实 Antigravity IDE 一致的 HTTP 请求头 +func setAntigravityHeaders(req *http.Request, accessToken string) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("User-Agent", GetUserAgent()) + req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient()) +} + // NewAPIRequestWithURL 使用指定的 base URL 创建 Antigravity API 请求(v1internal 端点) func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken string, body []byte) (*http.Request, error) { // 构建 URL,流式请求添加 ?alt=sse 参数 @@ -43,10 +59,8 @@ func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken stri return nil, err } - // 基础 Headers(与 Antigravity-Manager 保持一致,只设置这 3 个) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("User-Agent", GetUserAgent()) + // 设置与真实 Antigravity IDE 一致的请求头 + setAntigravityHeaders(req, accessToken) return req, nil } @@ -274,6 +288,11 @@ func NewClient(proxyURL string) (*Client, error) { }, nil } +// DoRaw 执行原始 HTTP 请求(供心跳等内部使用) +func (c *Client) DoRaw(req *http.Request) (*http.Response, error) { + return c.httpClient.Do(req) +} + // IsConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝) func IsConnectionError(err error) bool { if err == nil { @@ -451,6 +470,7 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", GetUserAgent()) + req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient()) resp, err := c.httpClient.Do(req) if err != nil { @@ -530,6 +550,7 @@ func (c *Client) OnboardUser(ctx context.Context, accessToken, tierID string) (s req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", GetUserAgent()) + req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient()) resp, err := c.httpClient.Do(req) if err != nil { @@ -664,6 +685,7 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", GetUserAgent()) + req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient()) resp, err := c.httpClient.Do(req) if err != nil { diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go index 8a8bed92..fdecbe92 100644 --- a/backend/internal/pkg/antigravity/oauth.go +++ b/backend/internal/pkg/antigravity/oauth.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "os" + "runtime" "strings" "sync" "time" @@ -23,13 +24,16 @@ const ( UserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo" // Antigravity OAuth 客户端凭证 + // 注意:真实 Antigravity 主 Client ID 是 884354919052-...,但需要对应的 client_secret + // 当前使用的 1071006060591-... 同样存在于真实二进制中(可能是备用登录模式) + // 如需切换,必须同时更新 client_secret(通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET) ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" // AntigravityOAuthClientSecretEnv 是 Antigravity OAuth client_secret 的环境变量名。 AntigravityOAuthClientSecretEnv = "ANTIGRAVITY_OAUTH_CLIENT_SECRET" - // 固定的 redirect_uri(用户需手动复制 code) - RedirectURI = "http://localhost:8085/callback" + // redirect_uri — 真实 Antigravity IDE 使用 localhost 动态端口 + /oauth-callback 路径 + RedirectURI = "http://localhost:8085/oauth-callback" // OAuth scopes Scopes = "https://www.googleapis.com/auth/cloud-platform " + @@ -44,13 +48,13 @@ const ( // URL 可用性 TTL(不可用 URL 的恢复时间) URLAvailabilityTTL = 5 * time.Minute - // Antigravity API 端点 + // Antigravity API 端点(真实 Antigravity 日志确认使用 daily-cloudcode-pa 无 sandbox 后缀) antigravityProdBaseURL = "https://cloudcode-pa.googleapis.com" - antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com" + antigravityDailyBaseURL = "https://daily-cloudcode-pa.googleapis.com" ) -// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.5 -var defaultUserAgentVersion = "1.20.5" +// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 0.2.0(匹配真实 extension 版本) +var defaultUserAgentVersion = "0.2.0" // defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置 var defaultClientSecret = "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" @@ -66,9 +70,11 @@ func init() { } } -// GetUserAgent 返回当前配置的 User-Agent +// GetUserAgent 返回当前配置的 User-Agent(匹配真实 Antigravity 格式: antigravity/{version} {os}/{arch}) func GetUserAgent() string { - return fmt.Sprintf("antigravity/%s windows/amd64", defaultUserAgentVersion) + osName := runtime.GOOS // darwin, linux, windows + arch := runtime.GOARCH // arm64, amd64 + return fmt.Sprintf("antigravity/%s %s/%s", defaultUserAgentVersion, osName, arch) } func getClientSecret() (string, error) { diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 629b6c93..c9c015bb 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -52,13 +52,13 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking var DefaultHeaders = map[string]string{ // Keep these in sync with recent Claude CLI traffic to reduce the chance // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. - "User-Agent": "claude-cli/2.1.81 (external, cli)", + "User-Agent": "claude-cli/2.1.84 (external, cli)", "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.80.0", - "X-Stainless-OS": "Linux", + "X-Stainless-Package-Version": "0.74.0", + "X-Stainless-OS": "MacOS", "X-Stainless-Arch": "arm64", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.13.0", + "X-Stainless-Runtime-Version": "v24.3.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Timeout": "600", "X-App": "cli", diff --git a/backend/internal/pkg/geminicli/constants.go b/backend/internal/pkg/geminicli/constants.go index 97234ffd..9b204640 100644 --- a/backend/internal/pkg/geminicli/constants.go +++ b/backend/internal/pkg/geminicli/constants.go @@ -1,7 +1,12 @@ // Package geminicli provides helpers for interacting with Gemini CLI tools. package geminicli -import "time" +import ( + "fmt" + "runtime" + "strings" + "time" +) const ( AIStudioBaseURL = "https://generativelanguage.googleapis.com" @@ -47,5 +52,14 @@ const ( SessionTTL = 30 * time.Minute // GeminiCLIUserAgent mimics Gemini CLI to maximize compatibility with internal endpoints. - GeminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)" + // Note: The real Gemini CLI uses OS-appropriate platform strings. + // Use GetGeminiCLIUserAgent() for runtime-aware User-Agent. + GeminiCLIUserAgent = "GeminiCLI/0.1.5" ) + +// GetGeminiCLIUserAgent 返回带有正确平台信息的 Gemini CLI User-Agent +func GetGeminiCLIUserAgent() string { + osName := strings.Title(runtime.GOOS) // Darwin, Linux, Windows + arch := strings.ToUpper(runtime.GOARCH) + return fmt.Sprintf("GeminiCLI/0.1.5 (%s; %s)", osName, arch) +} diff --git a/backend/internal/repository/claude_oauth_service.go b/backend/internal/repository/claude_oauth_service.go index b754bd55..7c7274e7 100644 --- a/backend/internal/repository/claude_oauth_service.go +++ b/backend/internal/repository/claude_oauth_service.go @@ -212,7 +212,7 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod SetContext(ctx). SetHeader("Accept", "application/json, text/plain, */*"). SetHeader("Content-Type", "application/json"). - SetHeader("User-Agent", "axios/1.8.4"). + SetHeader("User-Agent", "axios/1.13.6"). SetBody(reqBody). SetSuccessResult(&tokenResp). Post(s.tokenURL) @@ -242,6 +242,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro "grant_type": "refresh_token", "refresh_token": refreshToken, "client_id": oauth.ClientID, + "scope": oauth.ScopeAPI, } var tokenResp oauth.TokenResponse @@ -250,7 +251,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro SetContext(ctx). SetHeader("Accept", "application/json, text/plain, */*"). SetHeader("Content-Type", "application/json"). - SetHeader("User-Agent", "axios/1.8.4"). + SetHeader("User-Agent", "axios/1.13.6"). SetBody(reqBody). SetSuccessResult(&tokenResp). Post(s.tokenURL) @@ -268,9 +269,9 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro func createReqClient(proxyURL string) (*req.Client, error) { // 禁用 CookieJar,确保每次授权都是干净的会话 + // 不使用 ImpersonateChrome() — 真实 Claude CLI 用 axios (Bun fetch),TLS 指纹应为 Node.js/Bun client := req.C(). - SetTimeout(60 * time.Second). - ImpersonateChrome(). + SetTimeout(15 * time.Second). SetCookieJar(nil) // 禁用 CookieJar trimmed, _, err := proxyurl.Parse(proxyURL) diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 7c79f128..3602b83b 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -124,13 +124,12 @@ func NewHTTPUpstream(cfg *config.Config) service.HTTPUpstream { // - 调用方必须关闭 resp.Body,否则会导致 inFlight 计数泄漏 // - inFlight > 0 的客户端不会被淘汰,确保活跃请求不被中断 func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { - // Node.js TLS 代理:Anthropic + Google APIs - // 无论是否绑定 per-account 代理,都走 node-tls-proxy(指纹伪装) + // Node.js TLS 代理:仅 Anthropic API + // Antigravity (googleapis) 使用 Go 原生 TLS(更接近真实 BoringCrypto 指纹) // proxyURL 通过 X-Upstream-Proxy header 传递给 node-tls-proxy 动态选择出口 if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil && req.URL.Scheme == "https" { host := req.URL.Hostname() - if host == "api.anthropic.com" || - strings.HasSuffix(host, ".googleapis.com") { + if host == "api.anthropic.com" { return s.doViaNodeTLSProxy(req, proxyURL, accountID, accountConcurrency) } } @@ -186,11 +185,11 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco return s.Do(req, proxyURL, accountID, accountConcurrency) } - // 优先使用 Node.js TLS 代理模式(Anthropic + Google APIs) - // 无论是否绑定 per-account 代理,都走 node-tls-proxy(指纹伪装) + // 优先使用 Node.js TLS 代理模式(仅 Anthropic API) + // Antigravity (googleapis) 使用 Go 原生 TLS(更接近真实 BoringCrypto 指纹) if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil { host := req.URL.Hostname() - if host == "api.anthropic.com" || strings.HasSuffix(host, ".googleapis.com") { + if host == "api.anthropic.com" { return s.doViaNodeTLSProxy(req, proxyURL, accountID, accountConcurrency) } } diff --git a/backend/internal/service/antigravity_heartbeat.go b/backend/internal/service/antigravity_heartbeat.go new file mode 100644 index 00000000..396922b9 --- /dev/null +++ b/backend/internal/service/antigravity_heartbeat.go @@ -0,0 +1,204 @@ +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 + } + } +} diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 5b1abc11..f1eac565 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -670,7 +670,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex } upstreamReq.Header.Set("Content-Type", "application/json") upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) - upstreamReq.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent) + upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent()) return upstreamReq, "x-request-id", nil } else { // Mode 2: AI Studio API with OAuth (like API key mode, but using Bearer token) @@ -691,6 +691,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex } upstreamReq.Header.Set("Content-Type", "application/json") upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) + upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent()) return upstreamReq, "x-request-id", nil } } @@ -722,7 +723,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex c.Set(OpsUpstreamRequestBodyKey, string(body)) } - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ @@ -1171,7 +1172,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. } upstreamReq.Header.Set("Content-Type", "application/json") upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) - upstreamReq.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent) + upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent()) return upstreamReq, "x-request-id", nil } else { // Mode 2: AI Studio API with OAuth (like API key mode, but using Bearer token) @@ -1192,6 +1193,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. } upstreamReq.Header.Set("Content-Type", "application/json") upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) + upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent()) return upstreamReq, "x-request-id", nil } } @@ -1222,7 +1224,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. c.Set(OpsUpstreamRequestBodyKey, string(body)) } - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ @@ -2589,7 +2591,7 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac return nil, fmt.Errorf("unsupported account type: %s", account.Type) } - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if err != nil { return nil, err } diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 33c60c4b..ba22e1ca 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -26,13 +26,13 @@ var ( // 默认指纹值(当客户端未提供时使用) var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/2.1.81 (external, cli)", + UserAgent: "claude-cli/2.1.84 (external, cli)", StainlessLang: "js", - StainlessPackageVersion: "0.80.0", - StainlessOS: "Linux", + StainlessPackageVersion: "0.74.0", + StainlessOS: "MacOS", StainlessArch: "arm64", StainlessRuntime: "node", - StainlessRuntimeVersion: "v24.13.0", + StainlessRuntimeVersion: "v24.3.0", } diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 63df74a1..7a080ab0 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -409,10 +409,10 @@ gateway: # other sub2api deployments. Empty values use built-in defaults. # 每个实例可设置不同的版本号,与其他 sub2api 部署区分。空值使用内置默认值。 fingerprint_defaults: - # claude_cli_version: "2.1.81" - # stainless_package_version: "0.80.0" - # stainless_runtime_version: "v24.13.0" - # stainless_os: "Linux" # Linux / Darwin + # claude_cli_version: "2.1.84" + # stainless_package_version: "0.74.0" + # stainless_runtime_version: "v24.3.0" + # stainless_os: "MacOS" # MacOS / Linux (注意大小写,真实CLI用MacOS) # stainless_arch: "arm64" # arm64 / x64 # =============================================================================