From a0e1db22e7084a54f2dbc78a9261e3f1bae326f9 Mon Sep 17 00:00:00 2001 From: YG Park Date: Tue, 25 Aug 2026 16:16:22 +0900 Subject: [PATCH] fix(gemini): require subscription OAuth accounts --- README.md | 15 ++++++----- internal/api/client.go | 2 +- internal/api/client_test.go | 9 +++++++ internal/cli/gemini.go | 53 ++++++++++++------------------------- internal/cli/gemini_test.go | 27 +++++++++---------- internal/cli/run.go | 4 +-- 6 files changed, 50 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 52118bf..a97b224 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/internal/api/client.go b/internal/api/client.go index 4eb2d15..29209e5 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -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": {}, } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index a631ea1..82edbe5 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -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 diff --git a/internal/cli/gemini.go b/internal/cli/gemini.go index 09a5b63..dd62b18 100644 --- a/internal/cli/gemini.go +++ b/internal/cli/gemini.go @@ -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 } - 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) } func runGemini( ctx context.Context, prismURL string, prismCredential string, - provider string, account string, args []string, stdin io.Reader, @@ -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 } @@ -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") @@ -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") @@ -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) { @@ -297,8 +278,8 @@ func printGeminiHelp(output io.Writer) { prism gemini [--account ] [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.`) diff --git a/internal/cli/gemini_test.go b/internal/cli/gemini_test.go index b008783..6b13dbf 100644 --- a/internal/cli/gemini_test.go +++ b/internal/cli/gemini_test.go @@ -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) } } @@ -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") @@ -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) } @@ -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`} { diff --git a/internal/cli/run.go b/internal/cli/run.go index 08731f9..32b1fd1 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -798,8 +798,8 @@ Usage: prism auth remove [--profile ] 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.