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
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,23 +79,24 @@ or quota response is unavailable.

## Gemini CLI

Register each Google AI Studio account, then run the official Gemini CLI through Prism:
Register the Google account that owns a fixed-price Gemini or Code Assist
subscription, then run the official Gemini CLI through Prism:

```sh
prism gemini-ai auth add --name personal
prism gemini-ai auth list
prism gemini auth login
prism gemini auth list
prism gemini -p 'Reply with exactly GEMINI_OK.'
```

Prism rotates across registered AI Studio accounts unless `--account` selects one:
Prism rotates across registered subscription accounts unless `--account` selects one:

```sh
prism gemini --account work-admin -p 'Reply with exactly GEMINI_OK.'
```

Code Assist OAuth accounts remain available through `prism gemini auth login`,
but require a current Code Assist license and are selected only when no AI
Studio account is registered or when `--account` names one explicitly.
AI Studio API keys are intentionally unsupported because they can incur
usage-based charges. The Google account must expose a currently supported
Gemini CLI or Code Assist subscription tier.

The default model is `gemini-3.7-flash`. For harder software-engineering or
multi-step tool-use tasks, select `gemini-3.1-pro-preview` explicitly:
Expand Down
2 changes: 1 addition & 1 deletion internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ type ProviderUsage struct {
}

var providers = map[string]struct{}{
"chatgpt": {}, "anthropic": {}, "copilot": {}, "gemini": {}, "gemini-ai": {},
"chatgpt": {}, "anthropic": {}, "copilot": {}, "gemini": {},
"groq": {}, "mistral": {}, "deepseek": {}, "opencode-go": {},
"cloudflare": {}, "vercel": {}, "gemini-app": {},
}
Expand Down
9 changes: 9 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ import (
"testing"
)

func TestSupportedProvidersExcludeUsageBilledGeminiAIStudio(t *testing.T) {
if SupportedProvider("gemini-ai") {
t.Fatal("gemini-ai must remain disabled")
}
if !SupportedProvider("gemini") {
t.Fatal("Gemini subscription OAuth must remain supported")
}
}

func TestCredentialLifecycleUsesPrismAPIWithoutLeakingSecretsInURL(t *testing.T) {
var savedRequest map[string]any
removed := false
Expand Down
53 changes: 17 additions & 36 deletions internal/cli/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,58 +64,43 @@ func runGeminiCommand(ctx context.Context, args []string, stdout io.Writer, stde
if err != nil {
return err
}
aiStudioAccounts, err := client.List(ctx, "gemini-ai")
accounts, err := client.List(ctx, "gemini")
if err != nil {
return err
}
Comment on lines +67 to 70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Commands that do not need an account, such as prism gemini --version, now perform a network-backed credential listing before the Gemini CLI is invoked. This makes local CLI metadata commands fail when Prism is unavailable or credentials are invalid; handle local-only CLI commands before listing accounts or only list accounts when an authenticated execution is required. [api mismatch]

Severity Level: Major ⚠️
- ⚠️ `prism gemini --version` requires `GET /credentials/gemini`.
- ❌ Gemini CLI version output fails when Prism is unavailable.
- ⚠️ Local metadata commands cannot run with invalid credentials.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** internal/cli/gemini.go
**Line:** 67:70
**Comment:**
	*Api Mismatch: Commands that do not need an account, such as `prism gemini --version`, now perform a network-backed credential listing before the Gemini CLI is invoked. This makes local CLI metadata commands fail when Prism is unavailable or credentials are invalid; handle local-only CLI commands before listing accounts or only list accounts when an authenticated execution is required.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

codeAssistAccounts, err := client.List(ctx, "gemini")
selectedAccount, err := selectGeminiAccount(account, accounts)
if err != nil {
return err
}
provider, selectedAccount, err := selectGeminiAccount(account, aiStudioAccounts, codeAssistAccounts)
if err != nil {
return err
}
return runGemini(ctx, client.BaseURL, client.Token, provider, selectedAccount, withDefaultGeminiModel(passthrough), os.Stdin, stdout, stderr)
return runGemini(ctx, client.BaseURL, client.Token, selectedAccount, withDefaultGeminiModel(passthrough), os.Stdin, stdout, stderr)
}

func selectGeminiAccount(selector string, aiStudioAccounts []api.Credential, codeAssistAccounts []api.Credential) (string, string, error) {
func selectGeminiAccount(selector string, accounts []api.Credential) (string, error) {
if selector != "" {
matches := make([]struct{ provider, id string }, 0, 2)
for _, group := range []struct {
provider string
accounts []api.Credential
}{{"gemini-ai", aiStudioAccounts}, {"gemini", codeAssistAccounts}} {
for _, account := range group.accounts {
if account.ID == selector || account.Name == selector {
matches = append(matches, struct{ provider, id string }{group.provider, account.ID})
}
matches := make([]string, 0, 1)
for _, account := range accounts {
if account.ID == selector || account.Name == selector {
matches = append(matches, account.ID)
}
}
if len(matches) == 0 {
return "", "", fmt.Errorf("Gemini account %q is not registered", selector)
return "", fmt.Errorf("Gemini subscription account %q is not registered", selector)
}
if len(matches) > 1 {
return "", "", fmt.Errorf("Gemini account %q is ambiguous; use its credential ID", selector)
return "", fmt.Errorf("Gemini subscription account %q is ambiguous; use its credential ID", selector)
}
return matches[0].provider, matches[0].id, nil
return matches[0], nil
}
provider, accounts := "gemini-ai", aiStudioAccounts
if len(accounts) == 0 {
provider, accounts = "gemini", codeAssistAccounts
return "", errors.New("no Gemini subscription accounts are registered; run 'prism gemini auth login'")
}
if len(accounts) == 0 {
return "", "", errors.New("no Gemini accounts are registered; run 'prism gemini-ai auth add'")
}
account, err := rotateProviderAccount(provider, accounts)
return provider, account, err
return rotateProviderAccount("gemini", accounts)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Default execution now invokes the shared file-backed rotation routine for every Gemini launch. That routine performs an unlocked read, increment, and rename, so concurrent prism gemini processes can select the same account and overwrite each other's rotation state. Protect the rotation read-modify-write sequence with an inter-process lock or an atomic state update. [race condition]

Severity Level: Major ⚠️
- ⚠️ Concurrent Gemini launches can reuse one subscription account.
- ⚠️ `account-rotation.json` loses increments under contention.
- ⚠️ Intended account load balancing becomes less reliable.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** internal/cli/gemini.go
**Line:** 97:97
**Comment:**
	*Race Condition: Default execution now invokes the shared file-backed rotation routine for every Gemini launch. That routine performs an unlocked read, increment, and rename, so concurrent `prism gemini` processes can select the same account and overwrite each other's rotation state. Protect the rotation read-modify-write sequence with an inter-process lock or an atomic state update.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}

func runGemini(
ctx context.Context,
prismURL string,
prismCredential string,
provider string,
account string,
args []string,
stdin io.Reader,
Expand All @@ -126,7 +111,7 @@ func runGemini(
if err != nil {
return err
}
bridge, err := startGeminiBridge(prismURL, prismCredential, provider, account, stderr)
bridge, err := startGeminiBridge(prismURL, prismCredential, account, stderr)
if err != nil {
return err
}
Expand Down Expand Up @@ -191,7 +176,7 @@ func withDefaultGeminiModel(args []string) []string {
return append([]string{"--model", defaultGeminiModel}, args...)
}

func startGeminiBridge(prismURL string, prismCredential string, provider string, account string, stderr io.Writer) (*geminiBridge, error) {
func startGeminiBridge(prismURL string, prismCredential string, account string, stderr io.Writer) (*geminiBridge, error) {
target, err := url.Parse(prismURL)
if err != nil || (target.Scheme != "https" && target.Scheme != "http") || target.Host == "" {
return nil, errors.New("Prism URL is invalid")
Expand All @@ -202,9 +187,6 @@ func startGeminiBridge(prismURL string, prismCredential string, provider string,
if strings.TrimSpace(account) == "" || strings.ContainsAny(account, "\r\n") {
return nil, errors.New("Gemini account selector is invalid")
}
if provider != "gemini-ai" && provider != "gemini" {
return nil, errors.New("Gemini account provider is invalid")
}
credentialBytes := make([]byte, 32)
if _, err := rand.Read(credentialBytes); err != nil {
return nil, errors.New("could not create a local Gemini credential")
Expand All @@ -223,7 +205,6 @@ func startGeminiBridge(prismURL string, prismCredential string, provider string,
request.Header.Del("X-Prism-Gemini-Provider")
request.Header.Set("Authorization", "Bearer "+prismCredential)
request.Header.Set("X-Prism-Gemini-Account", "b64:"+base64.RawURLEncoding.EncodeToString([]byte(account)))
request.Header.Set("X-Prism-Gemini-Provider", provider)
}
proxy.ErrorLog = log.New(stderr, "prism: ", 0)
proxy.ErrorHandler = func(response http.ResponseWriter, _ *http.Request, _ error) {
Expand Down Expand Up @@ -297,8 +278,8 @@ func printGeminiHelp(output io.Writer) {
prism gemini [--account <alias-or-id>] [Gemini CLI arguments...]

Runs the official Gemini CLI through Prism's Vault-backed Google accounts.
AI Studio accounts are preferred and selected in balanced rotation. A valid
Code Assist account can still be selected explicitly with --account.
Only fixed-price Gemini subscription and Code Assist OAuth accounts are used;
AI Studio API keys are intentionally unsupported to prevent usage-based charges.
The default model is gemini-3.7-flash; use --model gemini-3.1-pro-preview for
hard software-engineering and multi-step tool-use work.
Run 'gemini --help' for Gemini CLI options.`)
Expand Down
27 changes: 13 additions & 14 deletions internal/cli/gemini_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,24 @@ func TestGeminiHelpDocumentsOfficialCLIAccountsAndModels(t *testing.T) {
if err := runGeminiCommand(context.Background(), []string{"--help"}, &output, io.Discard); err != nil {
t.Fatal(err)
}
for _, value := range []string{"official Gemini CLI", "--account", "balanced rotation", "AI Studio", "gemini-3.7-flash", "gemini-3.1-pro-preview"} {
for _, value := range []string{"official Gemini CLI", "--account", "subscription", "AI Studio API keys are intentionally unsupported", "gemini-3.7-flash", "gemini-3.1-pro-preview"} {
if !strings.Contains(output.String(), value) {
t.Fatalf("help omitted %q: %s", value, output.String())
}
}
}

func TestGeminiAccountSelectionPrefersAIStudioAndSupportsExplicitCodeAssist(t *testing.T) {
func TestGeminiAccountSelectionUsesSubscriptionAccounts(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
aiStudio := []api.Credential{{ID: "ai-1", Name: "personal"}, {ID: "ai-2", Name: "work-admin"}}
codeAssist := []api.Credential{{ID: "oauth-1", Name: "enterprise"}}
accounts := []api.Credential{{ID: "oauth-1", Name: "personal"}, {ID: "oauth-2", Name: "work-admin"}}

provider, account, err := selectGeminiAccount("", aiStudio, codeAssist)
if err != nil || provider != "gemini-ai" || account != "ai-1" {
t.Fatalf("default = %q/%q, error = %v", provider, account, err)
account, err := selectGeminiAccount("", accounts)
if err != nil || account != "oauth-1" {
t.Fatalf("default = %q, error = %v", account, err)
}
provider, account, err = selectGeminiAccount("enterprise", aiStudio, codeAssist)
if err != nil || provider != "gemini" || account != "oauth-1" {
t.Fatalf("explicit = %q/%q, error = %v", provider, account, err)
account, err = selectGeminiAccount("work-admin", accounts)
if err != nil || account != "oauth-2" {
t.Fatalf("explicit = %q, error = %v", account, err)
}
}

Expand All @@ -67,8 +66,8 @@ func TestGeminiBridgeAuthenticatesLocallyAndSelectsAccount(t *testing.T) {
if request.Header.Get("X-Prism-Gemini-Account") != "b64:cGVyc29uQGV4YW1wbGUuY29t" {
t.Errorf("account = %q", request.Header.Get("X-Prism-Gemini-Account"))
}
if request.Header.Get("X-Prism-Gemini-Provider") != "gemini-ai" {
t.Errorf("provider = %q", request.Header.Get("X-Prism-Gemini-Provider"))
if request.Header.Get("X-Prism-Gemini-Provider") != "" {
t.Errorf("provider header leaked = %q", request.Header.Get("X-Prism-Gemini-Provider"))
}
if request.Header.Get("X-Goog-Api-Key") != "" || request.Header.Get("X-Prism-Gemini-Bridge") != "" {
t.Errorf("private headers leaked")
Expand All @@ -78,7 +77,7 @@ func TestGeminiBridgeAuthenticatesLocallyAndSelectsAccount(t *testing.T) {
}))
defer upstream.Close()

bridge, err := startGeminiBridge(upstream.URL, "circles-secret", "gemini-ai", "person@example.com", io.Discard)
bridge, err := startGeminiBridge(upstream.URL, "circles-secret", "person@example.com", io.Discard)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -118,7 +117,7 @@ func TestRunGeminiUsesOfficialCLIWithGatewayEnvironment(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer upstream.Close()
var output bytes.Buffer
if err := runGemini(context.Background(), upstream.URL, "circles-secret", "gemini-ai", "person@example.com", withDefaultGeminiModel([]string{"-p", "hello"}), strings.NewReader(""), &output, io.Discard); err != nil {
if err := runGemini(context.Background(), upstream.URL, "circles-secret", "person@example.com", withDefaultGeminiModel([]string{"-p", "hello"}), strings.NewReader(""), &output, io.Discard); err != nil {
t.Fatal(err)
}
for _, value := range []string{"--model\ngemini-3.7-flash\n-p\nhello", "base=http://127.0.0.1:", "headers=X-Prism-Gemini-Bridge:", "settings=/", "trust=true", `"selectedType":"gateway"`, `"useExternal":true`} {
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,8 +798,8 @@ Usage:
prism <provider> auth remove <credential-id> [--profile <name>]
prism version

Static providers: gemini-ai, groq, mistral, deepseek, opencode-go, cloudflare,
vercel, and gemini-app. Secret values are read from hidden stdin and are never
Static providers: groq, mistral, deepseek, opencode-go, cloudflare, vercel,
and gemini-app. Secret values are read from hidden stdin and are never
accepted as command-line options. Cloudflare requires --provider-account-id;
Vercel accepts --owner-id and prompts separately for an optional session cookie.

Expand Down
Loading