Files
magpie/internal/provider/google.go
T
yetone 5df609e525 usage: an Antigravity account shows each group of models' 5-hour and weekly allowance, one row a group (a user on Discord: 这里显示5h 和一周剩余比较好吧,现在是只显示5h,这3个模型都是一样的,没必要分开…多加个7day 条就好) — fetchAvailableModels gives each model only its 5-hour figure, the same for every model drawing on one quota, so the Usage page, the panel and the account rows showed a figure a family (Gemini, Claude, GPT-OSS) and no week. Antigravity's retrieveUserQuotaSummary reports the quota a group ("Gemini Models"; "Claude and GPT models", Claude and GPT-OSS sharing one), each with a 5h and a weekly bucket; magpie now reads it beside the models' (best-effort: without it the windows are as before) and adds each group's windows, named "5 hours" and "7 days" with their pool (Gemini, Claude & GPT), each model's window naming its pool. The Usage page shows Gemini · 5 hours, Gemini · 7 days, Claude & GPT · 5 hours, Claude & GPT · 7 days, the pool's models in each tooltip; the panel four rings (Gemini 5h/7d, Claude 5h/7d); the account rows each pool with its 5h and 7d meters; the TUI the pools' windows in place of the models'. "Every model" lists the models' own windows. The pools' windows are aside for routing, which reads the models' as before; the tray's text is unchanged. TestAntigravityQuotaPools and quota-pools.test.cjs (Chromium, WebKit; en, zh) fail without the change; quota-families, click-scroll, old-webkit, tray-usages, panel-usage pass. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
2026-10-02 07:03:17 +08:00

1335 lines
43 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"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/edit"
)
// 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."
// ClaudeRisk is what magpie says before a Claude account is added, as the
// window does.
const ClaudeRisk = "Anthropic may suspend or ban a Claude account it sees used outside its own apps. magpie sends requests through Claude Code, but Anthropic may still act on them; you use it at your own risk. Use an account you can afford to lose."
// geminiCLIVersion is the Gemini CLI magpie says it is.
const geminiCLIVersion = "0.61.0"
// 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. One another app's
// OAuth client minted there isn't Gemini CLI's: Google refreshes a token
// only for the client that minted it, so it is left alone. (Antigravity's
// CLI, agy, keeps its sign-in in the system keyring, not here.)
func geminiOwnLogin() (googleAccount, bool) {
path := filepath.Join(geminiDir(), "oauth_creds.json")
var a googleAuth
if !readJSON(path, &a) || a.RefreshToken == "" {
return googleAccount{}, false
}
var id struct {
IDToken string `json:"id_token"`
}
readJSON(path, &id)
if c := googleClientOf(id.IDToken); c != "" && c != geminiApp.clientID {
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
}
// googleClientOf is the OAuth client a Google ID token was minted for, ""
// when there is none to tell.
func googleClientOf(idToken string) string {
c := jwtClaims(idToken)
if azp := claimString(c, "azp"); azp != "" {
return azp
}
return claimString(c, "aud")
}
// envFileValue is one KEY=value of a dotenv file.
func envFileValue(path, key string) string {
v, _ := edit.GetEnvFile(path, key)
return v
}
// 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, googleSide(agent), nil)
}
// addGoogleLogin keeps an account magpie just signed in.
func addGoogleLogin(agent, user, plan string, a googleAuth) error {
auth, _ := json.Marshal(a)
ownUser := ""
if own, ok := geminiOwnLogin(); ok && agent == "gemini" {
ownUser = own.user
}
return addSideLogin(savedLogin{Agent: agent, User: user, Plan: plan, Auth: auth, Project: a.Project}, ownUser, 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
flags map[string]geminiFlags // by refresh token and project
}{tokens: map[string]googleAuth{}, projects: map[string]googleProject{}, flags: map[string]geminiFlags{}}
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"
}
msg += ". A personal Google account is served to Antigravity instead: add it under Antigravity in magpie"
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 (0.61) offers, when Code Assist
// doesn't say which this account has; which it has is
// geminiFlags.offered.
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.8-flash", Name: "Gemini 3.8 Flash"},
{ID: "gemini-3.5-flash", Name: "Gemini 3.5 Flash"},
{ID: "gemini-3.5-flash-lite", Name: "Gemini 3.5 Flash Lite"},
{ID: "gemini-3.1-flash-lite", Name: "Gemini 3.1 Flash Lite"},
{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"},
}
// geminiFlags are the experiments Code Assist has an account in that
// decide which models Gemini CLI offers it, and the id each goes out as.
type geminiFlags struct {
gemini31 bool // Gemini 3.1 Pro launched to it
noPro bool // it has no Pro model
latestFlash bool // Gemini 3.8 Flash in place of 3.5 Flash
latestFlashLite bool // Gemini 3.5 Flash Lite in place of 3.1 Flash Lite
at time.Time
}
// Gemini CLI's ids of those experiments (its ExperimentFlags).
const (
flagGemini31Pro = 45760185
flagProNoAccess = 45768879
flagLatestFlash = 45842815
flagLatestFlashLite = 45827489
)
// flags asks Code Assist which experiments the account is in, as Gemini
// CLI does when it starts, at most every hour; none is on when it can't
// say, as in the CLI.
func (g googleAccount) flags(ctx context.Context, project string) geminiFlags {
key := g.auth.RefreshToken + "\x00" + project
googleState.Lock()
f, ok := googleState.flags[key]
googleState.Unlock()
if ok && time.Since(f.at) < time.Hour {
return f
}
platform := map[string]string{"darwin/amd64": "DARWIN_AMD64", "darwin/arm64": "DARWIN_ARM64", "linux/amd64": "LINUX_AMD64",
"linux/arm64": "LINUX_ARM64", "windows/amd64": "WINDOWS_AMD64"}[runtime.GOOS+"/"+runtime.GOARCH]
if platform == "" {
platform = "PLATFORM_UNSPECIFIED"
}
meta := map[string]any{"ideName": "IDE_UNSPECIFIED", "pluginType": "GEMINI", "ideVersion": geminiCLIVersion,
"platform": platform, "updateChannel": "stable", "duetProject": project}
var res struct {
Flags []struct {
FlagID int64 `json:"flagId"`
BoolValue bool `json:"boolValue"`
} `json:"flags"`
}
f = geminiFlags{at: time.Now()}
if err := g.call(ctx, g.app.base, "listExperiments", map[string]any{"project": project, "metadata": meta}, &res, nil); err == nil {
for _, fl := range res.Flags {
switch fl.FlagID {
case flagGemini31Pro:
f.gemini31 = fl.BoolValue
case flagProNoAccess:
f.noPro = fl.BoolValue
case flagLatestFlash:
f.latestFlash = fl.BoolValue
case flagLatestFlashLite:
f.latestFlashLite = fl.BoolValue
}
}
}
googleState.Lock()
googleState.flags[key] = f
googleState.Unlock()
return f
}
// offered is what Gemini CLI's /model offers the account (its
// getAvailableModelOptions): previews only to one with quota for one,
// 3.1 Pro in place of 3 Pro once launched to it, and one Flash and one
// Flash Lite, the latest where Google has rolled it out to it.
func (f geminiFlags) offered(preview bool) []catalog.Model {
skip := map[string]bool{
"gemini-3.1-pro-preview": !f.gemini31 || !preview || f.noPro,
"gemini-3-pro-preview": f.gemini31 || !preview || f.noPro,
"gemini-3-flash-preview": !preview,
"gemini-3.8-flash": !f.latestFlash,
"gemini-3.5-flash": f.latestFlash,
"gemini-3.5-flash-lite": !f.latestFlashLite,
"gemini-3.1-flash-lite": f.latestFlashLite,
"gemini-2.5-pro": f.noPro,
"gemini-2.5-flash": f.latestFlash, // the CLI's 2.5 Flash is 3.8 Flash then
}
var out []catalog.Model
for _, m := range geminiModels {
if !skip[m.ID] {
m.Images = true
out = append(out, m)
}
}
return out
}
// wire is the id Gemini CLI sends Code Assist a model as (its
// getBackendModelMappings): 3.5 Flash goes as 3.8 Flash where that is
// rolled out, else as gemini-3-flash, and 3.1 Flash Lite as 3.5 Flash
// Lite where that is; 3.8 Flash and 3.5 Flash Lite where they aren't go
// as the account's Flash and Flash Lite.
func (f geminiFlags) wire(model string) string {
switch {
case f.latestFlash && (model == "gemini-3.5-flash" || model == "gemini-3-flash"):
return "gemini-3.8-flash"
case !f.latestFlash && model == "gemini-3.5-flash":
return "gemini-3-flash"
case f.latestFlashLite && model == "gemini-3.1-flash-lite":
return "gemini-3.5-flash-lite"
case !f.latestFlash && model == "gemini-3.8-flash":
// asked for by an account it isn't rolled out to (picked from the
// list magpie falls back on): Code Assist may not serve it that
// account, so it goes as the Flash the account has (蒙面人: 502)
return "gemini-3-flash"
case !f.latestFlashLite && model == "gemini-3.5-flash-lite":
return "gemini-3.1-flash-lite"
}
return model
}
// shown is the model Gemini CLI takes a quota bucket to be for (its
// refreshUserQuota): Code Assist's gemini-3-flash is 3.5 or 3.8 Flash.
func (f geminiFlags) shown(model string) string {
switch model {
case "gemini-3-flash", "gemini-3.5-flash", "gemini-3.8-flash":
if f.latestFlash {
return "gemini-3.8-flash"
}
return "gemini-3.5-flash"
case "gemini-3.1-flash-lite", "gemini-3.5-flash-lite":
if f.latestFlashLite {
return "gemini-3.5-flash-lite"
}
return "gemini-3.1-flash-lite"
}
return model
}
// 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
}
f := g.flags(ctx, p.id)
seen := map[string]bool{}
for _, b := range res.Buckets {
id := f.shown(b.ModelID)
if id == "" || seen[id] || strings.HasSuffix(id, "_vertex") {
continue
}
seen[id] = true
mi := googleModelInfo{Model: catalog.Model{ID: id}, 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. Gemini CLI's are the ones the
// CLI offers the account, not its quota's buckets: those name the models
// it has an allowance of, by Code Assist's ids (gemini-3-flash is the
// CLI's 3.5 Flash), and the CLI doesn't list by them.
func (g googleAccount) models(ctx context.Context) ([]catalog.Model, error) {
infos, err := g.modelInfo(ctx)
if err != nil {
return nil, err
}
if g.app.agent != "antigravity" {
p, err := g.project(ctx)
if err != nil {
return nil, err
}
// previews only once a bucket is for one, as in the CLI
preview := false
for _, mi := range infos {
preview = preview || strings.Contains(mi.ID, "-preview")
}
return g.flags(ctx, p.id).offered(preview), nil
}
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 g.app.agent == "antigravity" {
w.Family = antigravityVendor(mi.Model)
}
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 g.app.agent == "antigravity" {
q.Windows = append(q.Windows, g.pools(ctx, q.Windows)...)
}
if p, err := g.project(ctx); err == nil && q.Plan == "" {
q.Plan = p.plan
}
return q
}
// pools are an Antigravity account's shared allowances, a 5-hour and a
// weekly one for each group of models drawing on one quota ("Gemini
// Models", "Claude and GPT models"), as retrieveUserQuotaSummary reports
// them; fetchAvailableModels gives each model only the 5-hour figure, the
// same for every model of a group. Each per-model window in ws is given
// its group's name as its Pool, so the GUI shows one row a group. None
// when the summary can't be read: the per-model windows stand alone. They
// are Aside: routing goes on reading the per-model windows, as before.
func (g googleAccount) pools(ctx context.Context, ws []QuotaWindow) []QuotaWindow {
p, err := g.project(ctx)
if err != nil {
return nil
}
var res struct {
Groups []struct {
DisplayName string `json:"displayName"`
Description string `json:"description"`
Buckets []struct {
BucketID string `json:"bucketId"`
Window string `json:"window"`
RemainingFraction *float64 `json:"remainingFraction"`
ResetTime string `json:"resetTime"`
DisplayName string `json:"displayName"`
Disabled bool `json:"disabled"`
} `json:"buckets"`
} `json:"groups"`
}
if err := g.call(ctx, g.app.base, "retrieveUserQuotaSummary", map[string]any{"project": p.id}, &res, nil); err != nil {
return nil
}
var out []QuotaWindow
for _, gr := range res.Groups {
pool := poolName(gr.DisplayName)
if pool == "" {
continue
}
n := 0
for _, b := range gr.Buckets {
if b.Disabled {
continue
}
w := QuotaWindow{Pool: pool, Used: 100, Aside: true}
switch strings.ToLower(b.Window) {
case "5h":
w.Name, w.Span = "5 hours", 5*time.Hour
case "weekly":
w.Name, w.Span = "7 days", 7*24*time.Hour
default:
for _, s := range []string{b.DisplayName, b.Window, b.BucketID} {
if w.Name == "" {
w.Name = s
}
}
}
if w.Name == "" {
continue
}
if b.RemainingFraction != nil {
w.Used = (1 - *b.RemainingFraction) * 100
} // left out once none is left, as in fetchAvailableModels
if t, err := time.Parse(time.RFC3339, b.ResetTime); err == nil {
w.ResetsAt = &t
w.ResetSecs = int64(max(time.Until(t), 0) / time.Second)
}
out = append(out, w)
n++
}
if n == 0 {
continue
}
// a model is in the group its name or description names its
// family in ("Models within this group: Claude Opus, Claude
// Sonnet, GPT-OSS")
about := strings.ToLower(gr.DisplayName + " " + gr.Description)
for i := range ws {
if ws[i].Pool == "" && ws[i].Family != "" && strings.Contains(about, strings.ToLower(ws[i].Family)) {
ws[i].Pool = pool
}
}
}
return out
}
// PooledWindows are ws as a page of text shows them: a pool's own windows,
// named with their pool ("Gemini · 7 days"), in place of the per-model
// windows drawing on it; ws as it is when it has no pool.
func PooledWindows(ws []QuotaWindow) []QuotaWindow {
if !slices.ContainsFunc(ws, func(w QuotaWindow) bool { return w.Pool != "" && w.Model == "" }) {
return ws
}
var out []QuotaWindow
for _, w := range ws {
switch {
case w.Pool == "":
out = append(out, w)
case w.Model == "":
w.Name = w.Pool + " · " + w.Name
out = append(out, w)
}
}
return out
}
// poolName is a quota group's name without "models" at its end, its "and"
// an ampersand: "Gemini Models" is Gemini, "Claude and GPT models" Claude
// & GPT.
func poolName(s string) string {
s = strings.TrimSpace(s)
if l := strings.ToLower(s); strings.HasSuffix(l, " models") {
s = strings.TrimSpace(s[:len(s)-len(" models")])
}
return strings.ReplaceAll(s, " and ", " & ")
}
// antigravityVendor is the family a model of Antigravity's is in — Gemini,
// Claude, GPT-OSS — as its id begins; one it doesn't know by its name's
// first word. (antigravityFamily is one model's levels, a narrower thing.)
func antigravityVendor(m catalog.Model) string {
id := strings.ToLower(m.ID)
switch {
case strings.HasPrefix(id, "gemini"):
return "Gemini"
case strings.HasPrefix(id, "claude"):
return "Claude"
case strings.HasPrefix(id, "gpt-oss"):
return "GPT-OSS"
}
if f, _, _ := strings.Cut(strings.TrimSpace(m.Name), " "); f != "" {
return f
}
f, _, _ := strings.Cut(m.ID, "-")
return f
}
// ---- 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
}
var f geminiFlags
if g.app.agent != "antigravity" {
f = g.flags(ctx, p.id)
}
out, model, err := codeAssistEnvelope(g.app.agent, body, p.id, f)
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, the ids the
// app sends along, and (Gemini CLI's) the id the model goes out as.
func codeAssistEnvelope(agent string, body []byte, project string, flags geminiFlags) ([]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
// the User-Agent keeps the model asked for, as the CLI's does
if model != "" {
env["model"] = flags.wire(model)
}
}
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()}}
who, err := googleWho(ctx, tok.AccessToken)
if err != nil {
return googleAccount{}, "", err
}
g.user = who
// 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
}
// googleWho is whose Google account an access token is.
func googleWho(ctx context.Context, access string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleUserInfoURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+access)
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", 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 "", errors.New("Google didn't say whose account this is")
}
return who.Email, nil
}