Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 125 additions & 28 deletions cmd/capi/antigravity.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -44,8 +45,13 @@ const (
antigravityGeneratePath = "/v1internal:generateContent"
antigravityStreamPath = "/v1internal:streamGenerateContent"
antigravityLoadCodeAssistPath = "/v1internal:loadCodeAssist"
antigravityFetchModelsPath = "/v1internal:fetchAvailableModels"

antigravityUserAgent = "antigravity/hub/2.9.1 darwin/arm64"
// antigravityDefaultClientVersion is the hub client version baked into the
// User-Agent. It is only a default — admins can override it from settings
// (AntigravitySettings.ClientVersion) so the UA can track upstream client
// bumps without a redeploy.
antigravityDefaultClientVersion = "2.9.1"

// A valid Google access token is reused until it is within this window of
// expiry, then refreshed under the shared openAIRefreshMu lock.
Expand Down Expand Up @@ -73,9 +79,10 @@ var antigravityOAuthClientSecret = func() string {
// loadCodeAssist is only called once per account per process.
var antigravityProjectCache sync.Map

// antigravityModelIDs is the set of model IDs Antigravity exposes upstream. The
// exposed ID is forwarded verbatim as the top-level "model" field; there is no
// rename table. Admins add these to a channel's model list to route to them.
// antigravityModelIDs is the default set of model IDs Antigravity exposes
// upstream. It is only a fallback: once an admin saves a model list (typically
// pulled live via fetchAvailableModels), antigravityModelsLocked returns that
// instead, so the catalog updates without a redeploy.
func antigravityModelIDs() []string {
return []string{
"claude-opus-4-6-thinking",
Expand All @@ -93,6 +100,22 @@ func antigravityModelIDs() []string {
}
}

// antigravityModelsLocked returns the admin-configured model catalog, or the
// baked-in default when none is configured. Callers must hold s.mu.
func (s *Server) antigravityModelsLocked() []string {
if configured := mergeStrings(nil, s.state.Settings.Antigravity.Models); len(configured) > 0 {
return configured
}
return antigravityModelIDs()
}

// antigravityModels is the locking wrapper around antigravityModelsLocked.
func (s *Server) antigravityModels() []string {
s.mu.Lock()
defer s.mu.Unlock()
return s.antigravityModelsLocked()
}

func isAntigravitySource(source string) bool {
switch strings.ToLower(strings.TrimSpace(source)) {
case "antigravity", "agy", "google-antigravity":
Expand Down Expand Up @@ -126,7 +149,7 @@ func (s *Server) ensureAntigravityChannelLocked(channel *Channel) bool {
channel.Provider = "antigravity"
changed = true
}
for _, modelID := range antigravityModelIDs() {
for _, modelID := range s.antigravityModelsLocked() {
if s.ensureImportedModelLocked(modelID) {
changed = true
}
Expand Down Expand Up @@ -217,7 +240,7 @@ func (s *Server) refreshAntigravityAccount(refreshToken string) (OpenAIRefreshRe
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", antigravityUserAgent)
request.Header.Set("User-Agent", s.antigravityUserAgentCanonical())

response, err := s.httpClient.Do(request)
if err != nil {
Expand Down Expand Up @@ -289,7 +312,7 @@ func (s *Server) exchangeAntigravityOAuthCode(code, verifier string) (OpenAIRefr
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", antigravityUserAgent)
request.Header.Set("User-Agent", s.antigravityUserAgentCanonical())

response, err := s.httpClient.Do(request)
if err != nil {
Expand Down Expand Up @@ -375,26 +398,24 @@ func (s *Server) resolveAntigravityProject(account OpenAIAccount, accessToken st
return project, nil
}

// antigravityUserAgents is the pool of realistic Antigravity desktop-client
// User-Agent strings. Real clients report their host platform, so spreading
// pooled accounts across the platform axis (while keeping the known-good client
// version) avoids every account fingerprinting as one identical client from a
// single server IP. Only the os/arch suffix varies — inventing version numbers
// would risk looking less real, not more.
var antigravityUserAgents = []string{
"antigravity/hub/2.9.1 darwin/arm64",
"antigravity/hub/2.9.1 darwin/x64",
"antigravity/hub/2.9.1 win32/x64",
"antigravity/hub/2.9.1 linux/x64",
// antigravityPlatforms is the set of realistic host platforms an Antigravity
// desktop client reports. Spreading pooled accounts across this axis (while the
// client version stays whatever settings configure) avoids every account
// fingerprinting as one identical client from a single server IP.
var antigravityPlatforms = []string{
"darwin/arm64",
"darwin/x64",
"win32/x64",
"linux/x64",
}

// antigravityUserAgentFor returns a stable User-Agent for an account: the same
// account always reports the same client (mirroring a real single-device user),
// while different accounts spread deterministically across the pool. A stable
// per-account UA is deliberately chosen over per-request rotation — a single
// account flipping platforms every call looks more bot-like, not less. Falls
// back to the canonical UA when the account has no identifier yet.
func antigravityUserAgentFor(account OpenAIAccount) string {
// antigravityPlatformFor returns a stable platform for an account: the same
// account always reports the same platform (mirroring a real single-device
// user), while different accounts spread deterministically across the pool. A
// stable per-account platform is deliberately chosen over per-request rotation
// — a single account flipping platforms every call looks more bot-like, not
// less. Falls back to the first platform when the account has no identifier.
func antigravityPlatformFor(account OpenAIAccount) string {
id := strings.TrimSpace(account.ID)
if id == "" {
id = strings.TrimSpace(account.AccountID)
Expand All @@ -403,17 +424,93 @@ func antigravityUserAgentFor(account OpenAIAccount) string {
id = strings.TrimSpace(account.Email)
}
if id == "" {
return antigravityUserAgent
return antigravityPlatforms[0]
}
hasher := fnv.New32a()
_, _ = hasher.Write([]byte(id))
return antigravityUserAgents[hasher.Sum32()%uint32(len(antigravityUserAgents))]
return antigravityPlatforms[hasher.Sum32()%uint32(len(antigravityPlatforms))]
}

// antigravityUserAgentFor builds the client UA for an account from the configured
// version and the account's stable platform.
func antigravityUserAgentFor(version string, account OpenAIAccount) string {
if strings.TrimSpace(version) == "" {
version = antigravityDefaultClientVersion
}
return "antigravity/hub/" + version + " " + antigravityPlatformFor(account)
}

// antigravityClientVersion returns the admin-configured client version, or the
// baked-in default when unset.
func (s *Server) antigravityClientVersion() string {
s.mu.Lock()
version := strings.TrimSpace(s.state.Settings.Antigravity.ClientVersion)
s.mu.Unlock()
if version == "" {
return antigravityDefaultClientVersion
}
return version
}

// antigravityUserAgentCanonical is the account-agnostic UA used on the OAuth
// token endpoints (refresh/exchange), where per-account platform variety adds
// nothing. It still tracks the configured version.
func (s *Server) antigravityUserAgentCanonical() string {
return antigravityUserAgentFor(s.antigravityClientVersion(), OpenAIAccount{})
}

func (s *Server) setAntigravityHeaders(request *http.Request, account OpenAIAccount, accessToken string) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+accessToken)
request.Header.Set("User-Agent", antigravityUserAgentFor(account))
request.Header.Set("User-Agent", antigravityUserAgentFor(s.antigravityClientVersion(), account))
}

// fetchAntigravityModels asks the upstream which models the account can use, via
// the same fetchAvailableModels call the real client makes. The available model
// IDs are the keys of the top-level "models" map in the response.
func (s *Server) fetchAntigravityModels(account OpenAIAccount, accessToken string) ([]string, error) {
project, err := s.resolveAntigravityProject(account, accessToken)
if err != nil {
return nil, err
}
payload, err := json.Marshal(map[string]string{"project": project})
if err != nil {
return nil, err
}
request, err := http.NewRequest(http.MethodPost, antigravityBaseURLProd+antigravityFetchModelsPath, bytes.NewReader(payload))
if err != nil {
return nil, err
}
s.setAntigravityHeaders(request, account, accessToken)

response, err := s.httpClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()

content, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return nil, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("fetchAvailableModels failed: %s", truncateString(strings.TrimSpace(string(content)), 300))
}

var parsed struct {
Models map[string]json.RawMessage `json:"models"`
}
if err := json.Unmarshal(content, &parsed); err != nil {
return nil, fmt.Errorf("fetchAvailableModels 响应不是有效 JSON")
}
ids := make([]string, 0, len(parsed.Models))
for id := range parsed.Models {
if trimmed := strings.TrimSpace(id); trimmed != "" {
ids = append(ids, trimmed)
}
}
sort.Strings(ids)
return ids, nil
}

// buildAntigravityPayload converts an OpenAI chat request into the Antigravity
Expand Down
70 changes: 52 additions & 18 deletions cmd/capi/antigravity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,40 +215,74 @@ func TestAntigravityAuthorizeURL(t *testing.T) {
}

func TestAntigravityUserAgentForIsStableAndSpread(t *testing.T) {
// Every UA in the pool must be a well-formed antigravity client string.
// The same account always reports the same platform (a real single-device
// user), and the chosen platform is always drawn from the pool.
poolSet := map[string]bool{}
for _, ua := range antigravityUserAgents {
if !strings.HasPrefix(ua, "antigravity/hub/") {
t.Fatalf("unexpected UA format in pool: %q", ua)
}
poolSet[ua] = true
for _, platform := range antigravityPlatforms {
poolSet[platform] = true
}

// The same account always reports the same UA (a real single-device user),
// and the chosen UA is always drawn from the pool.
account := OpenAIAccount{ID: "oaiacc_abc"}
first := antigravityUserAgentFor(account)
first := antigravityPlatformFor(account)
if !poolSet[first] {
t.Fatalf("UA %q not from pool", first)
t.Fatalf("platform %q not from pool", first)
}
for i := 0; i < 5; i++ {
if got := antigravityUserAgentFor(account); got != first {
t.Fatalf("UA not stable for same account: %q vs %q", got, first)
if got := antigravityPlatformFor(account); got != first {
t.Fatalf("platform not stable for same account: %q vs %q", got, first)
}
}

// Across many accounts the selection spreads over more than one platform,
// so the pool does not fingerprint as a single identical client.
seen := map[string]bool{}
for i := 0; i < 200; i++ {
seen[antigravityUserAgentFor(OpenAIAccount{ID: "oaiacc_" + strconv.Itoa(i)})] = true
seen[antigravityPlatformFor(OpenAIAccount{ID: "oaiacc_" + strconv.Itoa(i)})] = true
}
if len(seen) < 2 {
t.Fatalf("UA selection did not spread across the pool: %v", seen)
t.Fatalf("platform selection did not spread across the pool: %v", seen)
}

// The UA carries the configured version and a pool platform; an empty version
// falls back to the default, and an identifierless account uses platform[0].
ua := antigravityUserAgentFor("3.0.0", account)
if !strings.HasPrefix(ua, "antigravity/hub/3.0.0 ") {
t.Fatalf("UA should carry the configured version, got %q", ua)
}
if got := antigravityUserAgentFor("", OpenAIAccount{}); got != "antigravity/hub/"+antigravityDefaultClientVersion+" "+antigravityPlatforms[0] {
t.Fatalf("empty version + empty account should yield the canonical default UA, got %q", got)
}
}

func TestAntigravityVersionValid(t *testing.T) {
for _, ok := range []string{"2.9.1", "2.10.0", "3.0.0-beta", "3.0.0+build.2", "12"} {
if !antigravityVersionValid(ok) {
t.Fatalf("version %q should be valid", ok)
}
}
for _, bad := range []string{"2.9.1 darwin", "", "2.9.1;rm -rf", "\n2.9", "版本"} {
if antigravityVersionValid(bad) {
t.Fatalf("version %q should be rejected", bad)
}
}
}

// No identifier at all falls back to the canonical UA rather than panicking.
if got := antigravityUserAgentFor(OpenAIAccount{}); got != antigravityUserAgent {
t.Fatalf("empty account should fall back to canonical UA, got %q", got)
func TestPickAntigravityProbeAccount(t *testing.T) {
channel := Channel{OpenAIAccounts: []OpenAIAccount{
{ID: "a", Status: "invalid", AccessToken: "x"},
{ID: "b", Status: "healthy", RefreshToken: "r"},
{ID: "c", Status: "unchecked"}, // no credential
}}
// Prefers a healthy account with a credential.
if account, ok := pickAntigravityProbeAccount(channel, ""); !ok || account.ID != "b" {
t.Fatalf("expected healthy account b, got %q ok=%v", account.ID, ok)
}
// Honors an explicit account selection even if not healthy.
if account, ok := pickAntigravityProbeAccount(channel, "a"); !ok || account.ID != "a" {
t.Fatalf("expected explicitly selected account a, got %q ok=%v", account.ID, ok)
}
// A pool with no usable credentials yields nothing.
empty := Channel{OpenAIAccounts: []OpenAIAccount{{ID: "c", Status: "unchecked"}}}
if _, ok := pickAntigravityProbeAccount(empty, ""); ok {
t.Fatalf("expected no probe account when none carry a credential")
}
}
Loading
Loading