Files
magpie/internal/provider/google.go
T

1013 lines
32 KiB
Go

package provider
// Google's subscriptions: Gemini CLI's and Antigravity's Google sign-ins.
// Both are served by Google's Code Assist backend (cloudcode-pa), which
// speaks Gemini's API wrapped in an envelope of its own, under a Google
// Cloud project Code Assist gives the account. magpie signs in with each
// app's own OAuth client, keeps the tokens in logins.json and refreshes
// them in memory; Gemini CLI's own sign-in (~/.gemini/oauth_creds.json) is
// read, never written. The gateway speaks the envelope (gateway/codeassist.go).
//
// Google no longer serves Gemini CLI's sign-in to individuals: only a Code
// Assist Standard or Enterprise account, with a Google Cloud project of
// its own, gets an answer. Antigravity's serves individuals, but Google
// may suspend an account it sees used outside Antigravity.
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// CodeAssist is Google's Code Assist API, which magpie only speaks
// upstream, to a Gemini CLI or Antigravity account. Not in Protocols: no
// other provider serves it.
const CodeAssist Protocol = "codeassist"
// googleApp is one of Google's apps whose sign-in magpie borrows.
type googleApp struct {
agent, name, icon, website string
clientID, clientSecret string
scopes []string
callback string // the path the browser comes back to
loopback string // the host it comes back to
base string // where requests go
loadBase string // where the account's project is found
}
// Google's endpoints; vars so tests can point them elsewhere.
var (
googleAuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth"
googleTokenURL = "https://oauth2.googleapis.com/token"
googleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo?alt=json"
codeAssistProd = "https://cloudcode-pa.googleapis.com"
codeAssistDaily = "https://daily-cloudcode-pa.googleapis.com"
antigravityVersionURL = "https://antigravity-hub-auto-updater-974169037036.us-central1.run.app/manifest/latest-arm64-mac.yml"
)
var geminiApp = googleApp{
agent: "gemini", name: "Gemini CLI", icon: "geminicli-color", website: "https://github.com/google-gemini/gemini-cli",
// Gemini CLI's own OAuth client, an installed app's (its "secret" is
// no secret: it ships in the CLI)
clientID: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
clientSecret: "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",
scopes: []string{"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"},
callback: "/oauth2callback", loopback: "127.0.0.1",
}
var antigravityApp = googleApp{
agent: "antigravity", name: "Antigravity", icon: "antigravity-color", website: "https://antigravity.google",
clientID: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
clientSecret: "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",
scopes: []string{"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog", "https://www.googleapis.com/auth/experimentsandconfigs"},
callback: "/oauth-callback", loopback: "localhost",
}
func googleAppOf(agent string) (googleApp, bool) {
switch agent {
case "gemini":
a := geminiApp
a.base, a.loadBase = codeAssistProd, codeAssistProd
return a, true
case "antigravity":
a := antigravityApp
a.base, a.loadBase = codeAssistDaily, codeAssistProd
return a, true
}
return googleApp{}, false
}
// AntigravityRisk is what magpie says before an Antigravity account is
// added.
const AntigravityRisk = "Google may suspend an Antigravity account it sees used outside Antigravity. Use one you can afford to lose."
// geminiCLIVersion is the Gemini CLI magpie says it is.
const geminiCLIVersion = "0.54.4"
// googleAuth is a Google sign-in as logins.json keeps it; Gemini CLI's
// oauth_creds.json has the same fields.
type googleAuth struct {
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token"`
Expiry int64 `json:"expiry_date,omitempty"` // Unix ms
// Project is the Google Cloud project requests are billed to: the one
// Code Assist gave the account, or one the user named (Code Assist
// Standard and Enterprise ask for one).
Project string `json:"project,omitempty"`
}
// googleAccount is one signed-in Google account of an app.
type googleAccount struct {
app googleApp
user string
auth googleAuth
own bool // Gemini CLI's own sign-in
}
// geminiDir is where Gemini CLI keeps its sign-in.
func geminiDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".gemini")
}
// geminiOwnLogin reads Gemini CLI's own Google sign-in.
func geminiOwnLogin() (googleAccount, bool) {
var a googleAuth
if !readJSON(filepath.Join(geminiDir(), "oauth_creds.json"), &a) || a.RefreshToken == "" {
return googleAccount{}, false
}
var accts struct {
Active string `json:"active"`
}
readJSON(filepath.Join(geminiDir(), "google_accounts.json"), &accts)
user := accts.Active
if user == "" {
user = "Google"
}
// the project Gemini CLI is told to use, in its own .env
a.Project = envFileValue(filepath.Join(geminiDir(), ".env"), "GOOGLE_CLOUD_PROJECT")
if a.Project == "" {
a.Project = savedGoogleProject("gemini", user)
}
app, _ := googleAppOf("gemini")
return googleAccount{app: app, user: user, auth: a, own: true}, true
}
// envFileValue is one KEY=value of a dotenv file.
func envFileValue(path, key string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
line = strings.TrimPrefix(line, "export ")
k, v, ok := strings.Cut(line, "=")
if !ok || strings.TrimSpace(k) != key {
continue
}
v = strings.TrimSpace(v)
if uq, err := strconv.Unquote(v); err == nil {
v = uq
} else {
v = strings.Trim(v, `'`)
}
return v
}
return ""
}
// savedGoogleProject is the project the user named for the agent's own
// account, which logins.json keeps beside it.
func savedGoogleProject(agent, user string) string {
loginsMu.Lock()
defer loginsMu.Unlock()
for _, l := range readLogins() {
if l.Agent == agent && strings.EqualFold(l.User, user) {
return l.Project
}
}
return ""
}
// googleSaved is the sign-in of an account magpie keeps.
func googleSaved(agent string, l savedLogin) (googleAccount, bool) {
var a googleAuth
if l.own() || json.Unmarshal(l.Auth, &a) != nil || a.RefreshToken == "" {
return googleAccount{}, false
}
if l.Project != "" {
a.Project = l.Project
}
app, _ := googleAppOf(agent)
return googleAccount{app: app, user: l.User, auth: a}, true
}
type googleLogin struct {
Login
acct googleAccount
}
// googleLogins lists an app's accounts, the first in use first.
func googleLogins(agent string) []googleLogin {
own, hasOwn := googleAccount{}, false
if agent == "gemini" {
own, hasOwn = geminiOwnLogin()
}
ownUser := ""
if hasOwn {
ownUser = own.user
}
var out []googleLogin
for _, l := range sideLogins(agent, ownUser, func(l savedLogin) bool {
_, ok := googleSaved(agent, l)
return ok
}) {
a := own
if !l.saved.own() {
a, _ = googleSaved(agent, l.saved)
}
a.user = l.User
out = append(out, googleLogin{l.Login, a})
}
return out
}
func googleSide(agent string) []sideLogin {
var out []sideLogin
for _, g := range googleLogins(agent) {
out = append(out, sideLogin{Login: g.Login})
}
return out
}
func googleLoginList(agent string) []Login { return loginsOf(googleSide(agent)) }
func switchGoogleLogin(agent, user string) error {
return switchSideLogin(agent, user, googleSide(agent))
}
func setGoogleLoginOn(agent, user string, on bool) error {
return setSideLoginOn(agent, user, on, googleSide(agent))
}
func forgetGoogleLogin(agent, user string) error {
return forgetSideLogin(agent, user, "Gemini CLI's own sign-in; sign out there (/auth)", googleSide(agent), nil)
}
// addGoogleLogin keeps an account magpie just signed in.
func addGoogleLogin(agent, user, plan string, a googleAuth) error {
auth, _ := json.Marshal(a)
return addSideLogin(savedLogin{Agent: agent, User: user, Plan: plan, Auth: auth, Project: a.Project}, func(savedLogin) {})
}
// SetGoogleProject names the Google Cloud project an account's requests
// go to, "" to let Code Assist pick; Code Assist Standard and Enterprise
// need one.
func SetGoogleProject(agent, user, project string) error {
if _, ok := googleAppOf(agent); !ok {
return fmt.Errorf("only Gemini CLI and Antigravity accounts have a Google Cloud project")
}
found := false
for _, l := range googleLogins(agent) {
if strings.EqualFold(l.User, user) {
found = true
}
}
if !found {
return fmt.Errorf("no %s account %q", agent, user)
}
googleState.Lock()
for k := range googleState.projects {
if strings.HasPrefix(k, agent+"\x00") {
delete(googleState.projects, k)
}
}
googleState.Unlock()
return editSideLogin(agent, user, func(ls []savedLogin, i int) ([]savedLogin, error) {
ls[i].Project = strings.TrimSpace(project)
return ls, nil
})
}
// googleState is what magpie learned of each sign-in while it runs: the
// access token, refreshed in memory (Google's refresh tokens don't turn
// over, so nothing needs writing back), and the project and plan Code
// Assist gave it.
var googleState = struct {
sync.Mutex
tokens map[string]googleAuth // by refresh token
projects map[string]googleProject
}{tokens: map[string]googleAuth{}, projects: map[string]googleProject{}}
type googleProject struct {
id, plan string
}
// token is a live access token for the account.
func (g googleAccount) token(ctx context.Context) (string, error) {
googleState.Lock()
t, ok := googleState.tokens[g.auth.RefreshToken]
googleState.Unlock()
if !ok {
t = g.auth
}
if t.AccessToken != "" && time.Until(time.UnixMilli(t.Expiry)) > 2*time.Minute {
return t.AccessToken, nil
}
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {g.auth.RefreshToken},
"client_id": {g.app.clientID}, "client_secret": {g.app.clientSecret}}
var fresh struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
}
if err := postToken(ctx, googleTokenURL, "application/x-www-form-urlencoded", []byte(form.Encode()), &fresh); err != nil || fresh.AccessToken == "" {
how := "sign in again from magpie"
if g.own {
how = "run gemini and sign in again"
}
msg := "no token"
if err != nil {
msg = err.Error()
}
return "", fmt.Errorf("%s is signed out (%s); %s", g.app.name, msg, how)
}
t = googleAuth{AccessToken: fresh.AccessToken, RefreshToken: g.auth.RefreshToken,
Expiry: time.Now().Add(time.Duration(fresh.ExpiresIn) * time.Second).UnixMilli()}
googleState.Lock()
googleState.tokens[g.auth.RefreshToken] = t
googleState.Unlock()
return t.AccessToken, nil
}
// userAgent is what the app says it is to Code Assist.
func (g googleApp) userAgent(model string) string {
if g.agent == "antigravity" {
return "antigravity/hub/" + antigravityVersion() + " " + runtime.GOOS + "/" + runtime.GOARCH
}
platform := map[string]string{"darwin": "darwin", "windows": "win32", "linux": "linux"}[runtime.GOOS]
arch := map[string]string{"amd64": "x64", "arm64": "arm64"}[runtime.GOARCH]
if model == "" {
model = "gemini-2.5-pro"
}
return fmt.Sprintf("GeminiCLI/%s/%s (%s; %s; terminal)", geminiCLIVersion, model, platform, arch)
}
var antigravityVer struct {
sync.Mutex
v string
at time.Time
}
// antigravityVersion is the newest Antigravity's version, which its
// requests carry; asked of its updater at most every few hours.
func antigravityVersion() string {
antigravityVer.Lock()
defer antigravityVer.Unlock()
if antigravityVer.v != "" && time.Since(antigravityVer.at) < 6*time.Hour {
return antigravityVer.v
}
antigravityVer.at = time.Now()
if antigravityVer.v == "" {
antigravityVer.v = "2.9.1"
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, antigravityVersionURL, nil)
if err != nil {
return antigravityVer.v
}
req.Header.Set("User-Agent", "electron-builder")
res, err := http.DefaultClient.Do(req)
if err != nil {
return antigravityVer.v
}
defer res.Body.Close()
sc := bufio.NewScanner(io.LimitReader(res.Body, 64<<10))
for sc.Scan() {
if v, ok := strings.CutPrefix(sc.Text(), "version:"); ok {
v = strings.Trim(strings.TrimSpace(v), `'"`)
if v != "" && strings.Trim(v, "0123456789.") == "" {
antigravityVer.v = v
}
break
}
}
return antigravityVer.v
}
// call posts one of Code Assist's methods.
func (g googleAccount) call(ctx context.Context, base, method string, body, out any, headers map[string]string) error {
tok, err := g.token(ctx)
if err != nil {
return err
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1internal:"+method, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("User-Agent", g.app.userAgent(""))
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20))
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", g.app.name, APIError(rb, res.Status))
}
if out == nil {
return nil
}
return json.Unmarshal(rb, out)
}
type codeAssistTier struct {
ID string `json:"id"`
Name string `json:"name"`
IsDefault bool `json:"isDefault"`
UserDefinedCloudaicompanionProject bool `json:"userDefinedCloudaicompanionProject"`
}
// companionProject is Code Assist's project, which comes as an id or as
// {id, name}.
type companionProject string
func (p *companionProject) UnmarshalJSON(b []byte) error {
var s string
if json.Unmarshal(b, &s) == nil {
*p = companionProject(s)
return nil
}
var o struct {
ID string `json:"id"`
}
if err := json.Unmarshal(b, &o); err != nil {
return nil
}
*p = companionProject(o.ID)
return nil
}
// project is the Google Cloud project the account's requests go to, found
// (and the account set up for Code Assist, the first time) the way the
// app does it, and the plan it is on.
func (g googleAccount) project(ctx context.Context) (googleProject, error) {
key := g.app.agent + "\x00" + g.auth.RefreshToken
googleState.Lock()
p, ok := googleState.projects[key]
googleState.Unlock()
if ok {
return p, nil
}
var err error
if g.app.agent == "antigravity" {
p, err = g.antigravityProject(ctx)
} else {
p, err = g.geminiProject(ctx)
}
if err != nil {
return googleProject{}, err
}
googleState.Lock()
googleState.projects[key] = p
googleState.Unlock()
return p, nil
}
type loadCodeAssistReply struct {
CurrentTier *codeAssistTier `json:"currentTier"`
PaidTier *codeAssistTier `json:"paidTier"`
AllowedTiers []codeAssistTier `json:"allowedTiers"`
CloudaicompanionProject companionProject `json:"cloudaicompanionProject"`
IneligibleTiers []struct {
ReasonCode string `json:"reasonCode"`
ReasonMessage string `json:"reasonMessage"`
TierID string `json:"tierId"`
} `json:"ineligibleTiers"`
}
func (r loadCodeAssistReply) plan() string {
t := r.PaidTier
if t == nil || t.Name == "" {
t = r.CurrentTier
}
if t == nil {
return ""
}
if t.Name != "" {
return t.Name
}
return t.ID
}
func (r loadCodeAssistReply) defaultTier() codeAssistTier {
for _, t := range r.AllowedTiers {
if t.IsDefault {
return t
}
}
if len(r.AllowedTiers) > 0 {
return r.AllowedTiers[0]
}
return codeAssistTier{ID: "legacy-tier", UserDefinedCloudaicompanionProject: true}
}
// geminiProject sets the account up as Gemini CLI does (its setupUser).
func (g googleAccount) geminiProject(ctx context.Context) (googleProject, error) {
meta := map[string]any{"ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI"}
want := g.auth.Project
body := map[string]any{"metadata": meta}
if want != "" {
meta["duetProject"] = want
body["cloudaicompanionProject"] = want
}
var load loadCodeAssistReply
if err := g.call(ctx, g.app.loadBase, "loadCodeAssist", body, &load, nil); err != nil {
return googleProject{}, err
}
if load.CurrentTier != nil {
if load.CloudaicompanionProject != "" {
return googleProject{string(load.CloudaicompanionProject), load.plan()}, nil
}
if want != "" {
return googleProject{want, load.plan()}, nil
}
return googleProject{}, g.needsProject("")
}
tier := load.defaultTier()
if tier.UserDefinedCloudaicompanionProject && want == "" {
why := ""
for _, t := range load.IneligibleTiers {
if t.ReasonMessage != "" {
why = t.ReasonMessage
break
}
}
return googleProject{}, g.needsProject(why)
}
req := map[string]any{"tierId": tier.ID, "metadata": meta}
if tier.ID != "free-tier" {
req["cloudaicompanionProject"] = want
}
id, err := g.onboard(ctx, g.app.loadBase, req, nil)
if err != nil {
return googleProject{}, err
}
if id == "" {
id = want
}
if id == "" {
return googleProject{}, g.needsProject("")
}
return googleProject{id, tier.Name}, nil
}
// needsProject says a Code Assist Standard or Enterprise account needs a
// Google Cloud project named, with Google's reason when it gave one.
func (g googleAccount) needsProject(why string) error {
msg := "Google serves Gemini CLI's sign-in only to Gemini Code Assist Standard and Enterprise, which bill a Google Cloud project: name it with `magpie accounts project gemini " + g.user + " <project-id>`"
if g.own {
msg += " or GOOGLE_CLOUD_PROJECT in ~/.gemini/.env"
}
if why != "" {
msg = why + " — " + msg
}
return errors.New(msg)
}
// antigravityProject sets the account up as Antigravity does.
func (g googleAccount) antigravityProject(ctx context.Context) (googleProject, error) {
if g.auth.Project != "" {
return googleProject{id: g.auth.Project}, nil
}
var load loadCodeAssistReply
body := map[string]any{"metadata": map[string]any{"ideType": "ANTIGRAVITY"}}
if err := g.call(ctx, g.app.loadBase, "loadCodeAssist", body, &load, nil); err != nil {
return googleProject{}, err
}
if load.CloudaicompanionProject != "" {
return googleProject{string(load.CloudaicompanionProject), load.plan()}, nil
}
tier := load.defaultTier()
if tier.ID == "" || tier.ID == "legacy-tier" {
tier.ID = "free-tier"
}
req := map[string]any{"tier_id": tier.ID, "metadata": map[string]any{
"ide_type": "ANTIGRAVITY", "ide_version": antigravityVersion(), "ide_name": "antigravity"}}
hdr := map[string]string{
"User-Agent": g.app.userAgent("") + " google-api-nodejs-client/10.3.0",
"X-Goog-Api-Client": "gl-node/22.21.1",
}
id, err := g.onboard(ctx, g.app.base, req, hdr)
if err != nil {
return googleProject{}, err
}
if id == "" {
// Google's reason, when it gave one: an account it won't serve
// Antigravity to (its region, its age) says so in ineligibleTiers
msg := "Antigravity hasn't set this Google account up (it gave no project); sign in to the Antigravity app with it once, then try again"
for _, t := range load.IneligibleTiers {
if t.ReasonMessage != "" {
msg = "Antigravity won't serve this Google account: " + t.ReasonMessage
break
}
}
return googleProject{}, errors.New(msg)
}
return googleProject{id, tier.Name}, nil
}
// onboardPoll is how long onboarding waits between asks; a var for tests.
var onboardPoll = 2 * time.Second
// onboard sets the account up for Code Assist, asking again until it is
// done, and returns the project it was given.
func (g googleAccount) onboard(ctx context.Context, base string, req map[string]any, hdr map[string]string) (string, error) {
for i := 0; i < 5; i++ {
var lro struct {
Done bool `json:"done"`
Response struct {
Project companionProject `json:"cloudaicompanionProject"`
} `json:"response"`
}
if err := g.call(ctx, base, "onboardUser", req, &lro, hdr); err != nil {
return "", err
}
if lro.Done {
return string(lro.Response.Project), nil
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(onboardPoll):
}
}
return "", errors.New(g.app.name + ": setting the account up for Code Assist is taking long; try again in a minute")
}
// ---- models -------------------------------------------------------------------
// geminiModels are the models Gemini CLI offers, when Code Assist doesn't
// say which this account has.
var geminiModels = []catalog.Model{
{ID: "gemini-3.1-pro-preview", Name: "Gemini 3.1 Pro Preview"},
{ID: "gemini-3-pro-preview", Name: "Gemini 3 Pro Preview"},
{ID: "gemini-3-flash-preview", Name: "Gemini 3 Flash Preview"},
{ID: "gemini-3.1-flash-lite-preview", Name: "Gemini 3.1 Flash Lite Preview"},
{ID: "gemini-2.5-pro", Name: "Gemini 2.5 Pro"},
{ID: "gemini-2.5-flash", Name: "Gemini 2.5 Flash"},
{ID: "gemini-2.5-flash-lite", Name: "Gemini 2.5 Flash Lite"},
}
// antigravityModels are the models Antigravity offers, when it doesn't say.
var antigravityModels = []catalog.Model{
{ID: "gemini-pro-agent", Name: "Gemini 3.1 Pro (High)"},
{ID: "gemini-3.1-pro-low", Name: "Gemini 3.1 Pro (Low)"},
{ID: "gemini-3-flash", Name: "Gemini 3 Flash"},
{ID: "gemini-3.1-flash-lite", Name: "Gemini 3.1 Flash Lite"},
{ID: "claude-opus-4-6-thinking", Name: "Claude Opus 4.6 (Thinking)"},
{ID: "claude-sonnet-4-6", Name: "Claude Sonnet 4.6"},
{ID: "gpt-oss-120b-medium", Name: "GPT-OSS 120B (Medium)"},
}
// antigravityHidden are the models Antigravity lists that aren't for chat.
var antigravityHidden = map[string]bool{
"chat_20706": true, "chat_23310": true, "tab_flash_lite_preview": true, "tab_jump_flash_lite_preview": true,
"gemini-2.5-flash-thinking": true, "gemini-2.5-pro": true,
}
// googleModelInfo is one model an account has, with how much of its
// allowance is left.
type googleModelInfo struct {
catalog.Model
remaining float64 // 0..1, -1 not known
resets time.Time
}
// modelInfo asks Code Assist which models the account has.
func (g googleAccount) modelInfo(ctx context.Context) ([]googleModelInfo, error) {
p, err := g.project(ctx)
if err != nil {
return nil, err
}
var out []googleModelInfo
if g.app.agent == "antigravity" {
var res struct {
Models map[string]struct {
DisplayName string `json:"displayName"`
QuotaInfo *struct {
RemainingFraction *float64 `json:"remainingFraction"`
ResetTime string `json:"resetTime"`
} `json:"quotaInfo"`
} `json:"models"`
}
if err := g.call(ctx, g.app.base, "fetchAvailableModels", map[string]any{"project": p.id}, &res, nil); err != nil {
return nil, err
}
for id, m := range res.Models {
if antigravityHidden[id] || strings.HasPrefix(id, "chat_") || strings.HasPrefix(id, "tab_") {
continue
}
mi := googleModelInfo{Model: catalog.Model{ID: id, Name: m.DisplayName}, remaining: -1}
if q := m.QuotaInfo; q != nil {
if q.RemainingFraction != nil {
mi.remaining = *q.RemainingFraction
} else {
mi.remaining = 0 // Antigravity leaves it out once none is left
}
mi.resets, _ = time.Parse(time.RFC3339, q.ResetTime)
}
out = append(out, mi)
}
} else {
var res struct {
Buckets []struct {
ModelID string `json:"modelId"`
RemainingFraction *float64 `json:"remainingFraction"`
ResetTime string `json:"resetTime"`
TokenType string `json:"tokenType"`
} `json:"buckets"`
}
if err := g.call(ctx, g.app.base, "retrieveUserQuota", map[string]any{"project": p.id}, &res, nil); err != nil {
return nil, err
}
seen := map[string]bool{}
for _, b := range res.Buckets {
if b.ModelID == "" || seen[b.ModelID] || strings.HasSuffix(b.ModelID, "_vertex") {
continue
}
seen[b.ModelID] = true
mi := googleModelInfo{Model: catalog.Model{ID: b.ModelID}, remaining: -1}
if b.RemainingFraction != nil {
mi.remaining = *b.RemainingFraction
}
mi.resets, _ = time.Parse(time.RFC3339, b.ResetTime)
out = append(out, mi)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
// models is the account's models for the catalog, named as magpie knows
// them where Code Assist doesn't name them.
func (g googleAccount) models(ctx context.Context) ([]catalog.Model, error) {
infos, err := g.modelInfo(ctx)
if err != nil {
return nil, err
}
fallback := g.fallbackModels()
names := map[string]string{}
for _, m := range fallback {
names[m.ID] = m.Name
}
var out []catalog.Model
for _, mi := range infos {
m := mi.Model
if m.Name == "" {
m.Name = names[m.ID]
}
m.Images = !strings.HasPrefix(m.ID, "gpt-oss")
out = append(out, m)
}
if len(out) == 0 {
return fallback, nil
}
return out, nil
}
func (g googleAccount) fallbackModels() []catalog.Model {
src := geminiModels
if g.app.agent == "antigravity" {
src = antigravityModels
}
out := make([]catalog.Model, len(src))
for i, m := range src {
m.Images = !strings.HasPrefix(m.ID, "gpt-oss")
out[i] = m
}
return out
}
// quota is how much of each model's allowance the account has used.
func (g googleAccount) quota(ctx context.Context, plan string) SubscriptionQuota {
q := SubscriptionQuota{Provider: g.app.agent, Name: g.app.name, Icon: g.app.icon, Plan: plan, User: g.user, Windows: []QuotaWindow{}}
infos, err := g.modelInfo(ctx)
if err != nil {
q.Error = err.Error()
return q
}
for _, mi := range infos {
if mi.remaining < 0 {
continue
}
w := QuotaWindow{Name: mi.ID, Used: (1 - mi.remaining) * 100, Model: mi.ID}
if mi.Name != "" {
w.Name = mi.Name
}
if !mi.resets.IsZero() {
t := mi.resets
w.ResetsAt = &t
w.ResetSecs = int64(max(time.Until(t), 0) / time.Second)
}
q.Windows = append(q.Windows, w)
}
if p, err := g.project(ctx); err == nil && q.Plan == "" {
q.Plan = p.plan
}
return q
}
// ---- the provider -------------------------------------------------------------
// googleProvider is an app's Google account as a provider.
func googleProvider(g googleAccount, plan string) Provider {
acct := &Account{Agent: g.app.agent, User: g.user, Plan: plan, Stream: true, codeAssist: g.app.base}
acct.sign = func(ctx context.Context, req *http.Request, body []byte) error {
tok, err := g.token(ctx)
if err != nil {
return err
}
p, err := g.project(ctx)
if err != nil {
return err
}
out, model, err := codeAssistEnvelope(g.app.agent, body, p.id)
if err != nil {
return err
}
req.Body = io.NopCloser(bytes.NewReader(out))
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(out)), nil }
req.ContentLength = int64(len(out))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("User-Agent", g.app.userAgent(model))
req.Header.Del("Accept")
return nil
}
acct.models = g.fallbackModels
acct.fetch = func(ctx context.Context) ([]catalog.Model, error) {
ms, err := g.models(ctx)
if err != nil {
return nil, err
}
return ms, catalog.SaveLive(g.app.agent, g.app.base, ms)
}
return Provider{ID: g.app.agent, Name: g.app.name, Icon: g.app.icon, Website: g.app.website, Account: acct}
}
// codeAssistEnvelope finishes a request the gateway built — {model,
// request} — with what only the account knows: its project, and the ids
// the app sends along.
func codeAssistEnvelope(agent string, body []byte, project string) ([]byte, string, error) {
var env map[string]any
if err := json.Unmarshal(body, &env); err != nil {
return nil, "", err
}
model, _ := env["model"].(string)
env["project"] = project
id := randomToken(12)
if agent == "antigravity" {
env["userAgent"] = "antigravity"
env["requestType"] = "agent"
env["requestId"] = "agent-" + id
if r, ok := env["request"].(map[string]any); ok {
r["sessionId"] = antigravitySession(r)
}
} else {
env["user_prompt_id"] = id
}
out, err := json.Marshal(env)
return out, model, err
}
// antigravitySession names the conversation as Antigravity does: from its
// first message, so every turn of one conversation has the same.
func antigravitySession(r map[string]any) string {
first := ""
if cs, ok := r["contents"].([]any); ok {
for _, c := range cs {
cm, _ := c.(map[string]any)
if cm["role"] != "user" {
continue
}
parts, _ := cm["parts"].([]any)
for _, p := range parts {
if t, ok := p.(map[string]any)["text"].(string); ok && t != "" {
first = t
break
}
}
break
}
}
if first == "" {
first = randomToken(16)
}
sum := sha256.Sum256([]byte(first))
n := int64(binary.BigEndian.Uint64(sum[:8]) & 0x7fffffffffffffff)
return "-" + strconv.FormatInt(n, 10)
}
// googleAccountOf is the app's account in use first.
func googleAccountOf(agent string) (Provider, bool) {
ls := googleLogins(agent)
if len(ls) == 0 {
return Provider{}, false
}
return googleProvider(ls[0].acct, ls[0].Plan), true
}
// googleAlsoOn is the app's accounts in use behind the first.
func googleAlsoOn(agent string) []Provider {
var out []Provider
for _, l := range googleLogins(agent) {
if !l.Active && l.On {
out = append(out, googleProvider(l.acct, l.Plan))
}
}
return out
}
// googleLoginQuota is one account's allowance.
func googleLoginQuota(ctx context.Context, l Login) SubscriptionQuota {
for _, g := range googleLogins(l.Agent) {
if strings.EqualFold(g.User, l.User) {
return g.acct.quota(ctx, l.Plan)
}
}
return SubscriptionQuota{Provider: l.Agent, Plan: l.Plan, Windows: []QuotaWindow{}, Error: "not signed in"}
}
// ---- signing in -----------------------------------------------------------------
// startGoogleSignIn builds the Google sign-in page's link for the app.
func startGoogleSignIn(s *signInFlow, app googleApp, port int, challenge string) {
s.redirect = fmt.Sprintf("http://%s:%d%s", app.loopback, port, app.callback)
q := url.Values{}
q.Set("client_id", app.clientID)
q.Set("redirect_uri", s.redirect)
q.Set("response_type", "code")
q.Set("scope", strings.Join(app.scopes, " "))
q.Set("access_type", "offline")
q.Set("prompt", "consent")
q.Set("state", s.state)
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
s.st.URL = googleAuthorizeURL + "?" + q.Encode()
}
// googleExchange trades the code for tokens, finds whose account it is and
// sets it up for Code Assist.
func googleExchange(ctx context.Context, app googleApp, code, verifier, redirect string) (googleAccount, string, error) {
form := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirect},
"client_id": {app.clientID}, "client_secret": {app.clientSecret}, "code_verifier": {verifier}}
var tok struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
}
if err := postToken(ctx, googleTokenURL, "application/x-www-form-urlencoded", []byte(form.Encode()), &tok); err != nil {
return googleAccount{}, "", err
}
if tok.AccessToken == "" || tok.RefreshToken == "" {
return googleAccount{}, "", errors.New("Google sent back no token")
}
g := googleAccount{app: app, auth: googleAuth{AccessToken: tok.AccessToken, RefreshToken: tok.RefreshToken,
Expiry: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).UnixMilli()}}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleUserInfoURL, nil)
if err != nil {
return googleAccount{}, "", err
}
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
return googleAccount{}, "", err
}
defer res.Body.Close()
var who struct {
Email string `json:"email"`
}
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusOK || json.Unmarshal(b, &who) != nil || who.Email == "" {
return googleAccount{}, "", errors.New("Google didn't say whose account this is")
}
g.user = who.Email
// kept even when Code Assist has no project for it yet: a Standard
// account gets one named afterwards
plan := ""
if p, err := g.project(ctx); err == nil {
g.auth.Project, plan = p.id, p.plan
}
return g, plan, nil
}