opencode-gemini-auth 1.3.9 → 1.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -177,6 +177,20 @@ If automatic provisioning fails, you may need to set up the project manually:
177
177
  (`cloudaicompanion.googleapis.com`).
178
178
  4. Configure the `projectId` in your Opencode config as shown above.
179
179
 
180
+ ### Quotas, Plans, and 429 Errors
181
+
182
+ Common causes of `429 RESOURCE_EXHAUSTED` or `QUOTA_EXHAUSTED`:
183
+
184
+ - **No project ID configured**: the plugin uses a managed free-tier project, which has lower quotas.
185
+ - **Model-specific limits**: quotas are tracked per model (e.g., `gemini-3-pro-preview` vs `gemini-3-flash-preview`).
186
+ - **Large prompts**: OAuth/Code Assist does not support cached content, so long system prompts and history can burn quota quickly.
187
+ - **Parallel sessions**: multiple Opencode windows can drain the same bucket.
188
+
189
+ Notes:
190
+
191
+ - **Gemini CLI auto-fallbacks**: the official CLI may fall back to Flash when Pro quotas are exhausted, so it can appear to “work” even if the Pro bucket is depleted.
192
+ - **Paid plans still require a project**: to use paid quotas in Opencode, set `provider.google.options.projectId` (or `OPENCODE_GEMINI_PROJECT_ID`) and re-authenticate.
193
+
180
194
  ### Debugging
181
195
 
182
196
  To view detailed logs of Gemini requests and responses, set the
@@ -189,6 +203,35 @@ OPENCODE_GEMINI_DEBUG=1 opencode
189
203
  This will generate `gemini-debug-<timestamp>.log` files in your working
190
204
  directory containing sanitized request/response details.
191
205
 
206
+ ## Parity Notes
207
+
208
+ This plugin mirrors the official Gemini CLI OAuth flow and Code Assist
209
+ endpoints. In particular, project onboarding and quota retry handling follow
210
+ the same behavior patterns as the Gemini CLI.
211
+
212
+ ### References
213
+
214
+ - Gemini CLI repository: https://github.com/google-gemini/gemini-cli
215
+ - Gemini CLI quota documentation: https://developers.google.com/gemini-code-assist/resources/quotas
216
+
217
+ ### Local upstream mirror (optional)
218
+
219
+ For local parity checks, you can keep a separate clone of the official
220
+ `gemini-cli` in this repo at `.local/gemini-cli`.
221
+
222
+ This mirror is intentionally untracked, so contributors must set it up once on
223
+ their machine:
224
+
225
+ ```bash
226
+ git clone https://github.com/google-gemini/gemini-cli.git .local/gemini-cli
227
+ ```
228
+
229
+ After setup, pull upstream updates with:
230
+
231
+ ```bash
232
+ bun run update:gemini-cli
233
+ ```
234
+
192
235
  ### Updating
193
236
 
194
237
  Opencode does not automatically update plugins. To update to the latest version,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-gemini-auth",
3
3
  "module": "index.ts",
4
- "version": "1.3.9",
4
+ "version": "1.3.11",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -10,6 +10,9 @@
10
10
  ],
11
11
  "license": "MIT",
12
12
  "type": "module",
13
+ "scripts": {
14
+ "update:gemini-cli": "git -C .local/gemini-cli pull --ff-only"
15
+ },
13
16
  "devDependencies": {
14
17
  "@opencode-ai/plugin": "^1.1.48",
15
18
  "@types/bun": "latest"
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ accessTokenExpired,
5
+ formatRefreshParts,
6
+ isOAuthAuth,
7
+ parseRefreshParts,
8
+ } from "./auth";
9
+
10
+ describe("auth helpers", () => {
11
+ it("parses refresh parts with project and managed ids", () => {
12
+ const parts = parseRefreshParts("refresh|project-123|managed-456");
13
+ expect(parts.refreshToken).toBe("refresh");
14
+ expect(parts.projectId).toBe("project-123");
15
+ expect(parts.managedProjectId).toBe("managed-456");
16
+ });
17
+
18
+ it("formats refresh parts with optional segments", () => {
19
+ expect(formatRefreshParts({ refreshToken: "refresh" })).toBe("refresh");
20
+ expect(
21
+ formatRefreshParts({
22
+ refreshToken: "refresh",
23
+ projectId: "project-123",
24
+ }),
25
+ ).toBe("refresh|project-123|");
26
+ expect(
27
+ formatRefreshParts({
28
+ refreshToken: "refresh",
29
+ managedProjectId: "managed-456",
30
+ }),
31
+ ).toBe("refresh||managed-456");
32
+ });
33
+
34
+ it("detects OAuth auth payloads", () => {
35
+ expect(isOAuthAuth({ type: "oauth", refresh: "refresh" })).toBe(true);
36
+ expect(isOAuthAuth({ type: "api", refresh: "refresh" } as never)).toBe(false);
37
+ });
38
+
39
+ it("treats tokens near expiry as expired", () => {
40
+ const now = Date.now();
41
+ expect(
42
+ accessTokenExpired({
43
+ type: "oauth",
44
+ refresh: "refresh",
45
+ access: "access",
46
+ expires: now + 59_000,
47
+ }),
48
+ ).toBe(true);
49
+ expect(
50
+ accessTokenExpired({
51
+ type: "oauth",
52
+ refresh: "refresh",
53
+ access: "access",
54
+ expires: now + 61_000,
55
+ }),
56
+ ).toBe(false);
57
+ });
58
+ });
@@ -0,0 +1,198 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import { authorizeGemini, exchangeGeminiWithVerifier } from "../gemini/oauth";
4
+ import type { GeminiTokenExchangeResult } from "../gemini/oauth";
5
+ import { isGeminiDebugEnabled, logGeminiDebugMessage } from "./debug";
6
+ import { resolveProjectContextFromAccessToken } from "./project";
7
+ import { startOAuthListener, type OAuthListener } from "./server";
8
+ import type { OAuthAuthDetails } from "./types";
9
+
10
+ /**
11
+ * Builds the OAuth authorize callback used by plugin auth methods.
12
+ */
13
+ export function createOAuthAuthorizeMethod(): () => Promise<{
14
+ url: string;
15
+ instructions: string;
16
+ method: string;
17
+ callback: (() => Promise<GeminiTokenExchangeResult>) | ((callbackUrl: string) => Promise<GeminiTokenExchangeResult>);
18
+ }> {
19
+ return async () => {
20
+ const maybeHydrateProjectId = async (
21
+ result: GeminiTokenExchangeResult,
22
+ ): Promise<GeminiTokenExchangeResult> => {
23
+ if (result.type !== "success" || !result.access) {
24
+ return result;
25
+ }
26
+
27
+ const projectFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
28
+ const googleProjectFromEnv =
29
+ process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
30
+ process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
31
+ "";
32
+ const configuredProjectId = projectFromEnv || googleProjectFromEnv || undefined;
33
+
34
+ try {
35
+ const authSnapshot = {
36
+ type: "oauth",
37
+ refresh: result.refresh,
38
+ access: result.access,
39
+ expires: result.expires,
40
+ } satisfies OAuthAuthDetails;
41
+ const projectContext = await resolveProjectContextFromAccessToken(
42
+ authSnapshot,
43
+ result.access,
44
+ configuredProjectId,
45
+ );
46
+
47
+ if (projectContext.auth.refresh !== result.refresh && isGeminiDebugEnabled()) {
48
+ logGeminiDebugMessage(
49
+ `OAuth project resolved during auth: ${projectContext.effectiveProjectId || "none"}`,
50
+ );
51
+ }
52
+ return projectContext.auth.refresh !== result.refresh
53
+ ? { ...result, refresh: projectContext.auth.refresh }
54
+ : result;
55
+ } catch (error) {
56
+ if (isGeminiDebugEnabled()) {
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ console.warn(`[Gemini OAuth] Project resolution skipped: ${message}`);
59
+ }
60
+ return result;
61
+ }
62
+ };
63
+
64
+ const isHeadless = !!(
65
+ process.env.SSH_CONNECTION ||
66
+ process.env.SSH_CLIENT ||
67
+ process.env.SSH_TTY ||
68
+ process.env.OPENCODE_HEADLESS
69
+ );
70
+
71
+ let listener: OAuthListener | null = null;
72
+ if (!isHeadless) {
73
+ try {
74
+ listener = await startOAuthListener();
75
+ } catch (error) {
76
+ const detail = error instanceof Error ? ` (${error.message})` : "";
77
+ console.log(
78
+ `Warning: Couldn't start the local callback listener${detail}. You'll need to paste the callback URL or authorization code.`,
79
+ );
80
+ }
81
+ } else {
82
+ console.log(
83
+ "Headless environment detected. You'll need to paste the callback URL or authorization code.",
84
+ );
85
+ }
86
+
87
+ const authorization = await authorizeGemini();
88
+ if (!isHeadless) {
89
+ openBrowserUrl(authorization.url);
90
+ }
91
+
92
+ if (listener) {
93
+ return {
94
+ url: authorization.url,
95
+ instructions:
96
+ "Complete the sign-in flow in your browser. We'll automatically detect the redirect back to localhost.",
97
+ method: "auto",
98
+ callback: async (): Promise<GeminiTokenExchangeResult> => {
99
+ try {
100
+ const callbackUrl = await listener.waitForCallback();
101
+ const code = callbackUrl.searchParams.get("code");
102
+ const state = callbackUrl.searchParams.get("state");
103
+
104
+ if (!code || !state) {
105
+ return { type: "failed", error: "Missing code or state in callback URL" };
106
+ }
107
+ if (state !== authorization.state) {
108
+ return { type: "failed", error: "State mismatch in callback URL (possible CSRF attempt)" };
109
+ }
110
+ return await maybeHydrateProjectId(
111
+ await exchangeGeminiWithVerifier(code, authorization.verifier),
112
+ );
113
+ } catch (error) {
114
+ return {
115
+ type: "failed",
116
+ error: error instanceof Error ? error.message : "Unknown error",
117
+ };
118
+ } finally {
119
+ try {
120
+ await listener?.close();
121
+ } catch {}
122
+ }
123
+ },
124
+ };
125
+ }
126
+
127
+ return {
128
+ url: authorization.url,
129
+ instructions:
130
+ "Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...) or just the authorization code.",
131
+ method: "code",
132
+ callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
133
+ try {
134
+ const { code, state } = parseOAuthCallbackInput(callbackUrl);
135
+ if (!code) {
136
+ return { type: "failed", error: "Missing authorization code in callback input" };
137
+ }
138
+ if (state && state !== authorization.state) {
139
+ return { type: "failed", error: "State mismatch in callback input (possible CSRF attempt)" };
140
+ }
141
+ return await maybeHydrateProjectId(
142
+ await exchangeGeminiWithVerifier(code, authorization.verifier),
143
+ );
144
+ } catch (error) {
145
+ return {
146
+ type: "failed",
147
+ error: error instanceof Error ? error.message : "Unknown error",
148
+ };
149
+ }
150
+ },
151
+ };
152
+ };
153
+ }
154
+
155
+ function parseOAuthCallbackInput(input: string): { code?: string; state?: string } {
156
+ const trimmed = input.trim();
157
+ if (!trimmed) {
158
+ return {};
159
+ }
160
+
161
+ if (/^https?:\/\//i.test(trimmed)) {
162
+ try {
163
+ const url = new URL(trimmed);
164
+ return {
165
+ code: url.searchParams.get("code") || undefined,
166
+ state: url.searchParams.get("state") || undefined,
167
+ };
168
+ } catch {
169
+ return {};
170
+ }
171
+ }
172
+
173
+ const candidate = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed;
174
+ if (candidate.includes("=")) {
175
+ const params = new URLSearchParams(candidate);
176
+ const code = params.get("code") || undefined;
177
+ const state = params.get("state") || undefined;
178
+ if (code || state) {
179
+ return { code, state };
180
+ }
181
+ }
182
+
183
+ return { code: trimmed };
184
+ }
185
+
186
+ function openBrowserUrl(url: string): void {
187
+ try {
188
+ const platform = process.platform;
189
+ const command =
190
+ platform === "darwin" ? "open" : platform === "win32" ? "rundll32" : "xdg-open";
191
+ const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
192
+ const child = spawn(command, args, {
193
+ stdio: "ignore",
194
+ detached: true,
195
+ });
196
+ child.unref?.();
197
+ } catch {}
198
+ }
@@ -0,0 +1,209 @@
1
+ import { CODE_ASSIST_HEADERS, GEMINI_CODE_ASSIST_ENDPOINT } from "../../constants";
2
+ import { logGeminiDebugResponse, startGeminiDebugRequest } from "../debug";
3
+ import {
4
+ FREE_TIER_ID,
5
+ type LoadCodeAssistPayload,
6
+ type OnboardUserPayload,
7
+ ProjectIdRequiredError,
8
+ } from "./types";
9
+ import { buildMetadata, isVpcScError, parseJsonSafe, wait } from "./utils";
10
+
11
+ /**
12
+ * Loads managed project information for the given access token and optional project.
13
+ */
14
+ export async function loadManagedProject(
15
+ accessToken: string,
16
+ projectId?: string,
17
+ ): Promise<LoadCodeAssistPayload | null> {
18
+ try {
19
+ const metadata = buildMetadata(projectId);
20
+ const requestBody: Record<string, unknown> = { metadata };
21
+ if (projectId) {
22
+ requestBody.cloudaicompanionProject = projectId;
23
+ }
24
+
25
+ const url = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`;
26
+ const headers = {
27
+ "Content-Type": "application/json",
28
+ Authorization: `Bearer ${accessToken}`,
29
+ ...CODE_ASSIST_HEADERS,
30
+ };
31
+ const debugContext = startGeminiDebugRequest({
32
+ originalUrl: url,
33
+ resolvedUrl: url,
34
+ method: "POST",
35
+ headers,
36
+ body: JSON.stringify(requestBody),
37
+ streaming: false,
38
+ projectId,
39
+ });
40
+
41
+ const response = await fetch(url, {
42
+ method: "POST",
43
+ headers,
44
+ body: JSON.stringify(requestBody),
45
+ });
46
+ const responseBody = await readResponseTextIfNeeded(response, !!debugContext);
47
+ if (debugContext) {
48
+ logGeminiDebugResponse(debugContext, response, { body: responseBody });
49
+ }
50
+
51
+ if (!response.ok) {
52
+ if (responseBody && isVpcScError(parseJsonSafe(responseBody))) {
53
+ return { currentTier: { id: "standard-tier" } };
54
+ }
55
+ return null;
56
+ }
57
+
58
+ if (responseBody) {
59
+ return parseJsonSafe(responseBody) as LoadCodeAssistPayload;
60
+ }
61
+ return (await response.json()) as LoadCodeAssistPayload;
62
+ } catch (error) {
63
+ console.error("Failed to load Gemini managed project:", error);
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Onboards a managed project for the user, optionally retrying until completion.
70
+ */
71
+ export async function onboardManagedProject(
72
+ accessToken: string,
73
+ tierId: string,
74
+ projectId?: string,
75
+ attempts = 10,
76
+ delayMs = 5000,
77
+ ): Promise<string | undefined> {
78
+ const isFreeTier = tierId === FREE_TIER_ID;
79
+ const metadata = buildMetadata(projectId, !isFreeTier);
80
+ const requestBody: Record<string, unknown> = { tierId, metadata };
81
+
82
+ if (!isFreeTier) {
83
+ if (!projectId) {
84
+ throw new ProjectIdRequiredError();
85
+ }
86
+ requestBody.cloudaicompanionProject = projectId;
87
+ }
88
+
89
+ const baseUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal`;
90
+ const onboardUrl = `${baseUrl}:onboardUser`;
91
+ const headers = {
92
+ "Content-Type": "application/json",
93
+ Authorization: `Bearer ${accessToken}`,
94
+ ...CODE_ASSIST_HEADERS,
95
+ };
96
+
97
+ try {
98
+ const response = await fetchWithDebug(onboardUrl, "POST", headers, requestBody, projectId);
99
+ if (!response.ok) {
100
+ return undefined;
101
+ }
102
+
103
+ let payload = (await response.json()) as OnboardUserPayload;
104
+ if (!payload.done && payload.name) {
105
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
106
+ await wait(delayMs);
107
+ const operationUrl = `${baseUrl}/${payload.name}`;
108
+ const opResponse = await fetchWithDebug(operationUrl, "GET", headers, undefined, projectId);
109
+ if (!opResponse.ok) {
110
+ return undefined;
111
+ }
112
+ payload = (await opResponse.json()) as OnboardUserPayload;
113
+ if (payload.done) {
114
+ break;
115
+ }
116
+ }
117
+ }
118
+
119
+ const managedProjectId = payload.response?.cloudaicompanionProject?.id;
120
+ if (payload.done && managedProjectId) {
121
+ return managedProjectId;
122
+ }
123
+ if (payload.done && projectId) {
124
+ return projectId;
125
+ }
126
+ } catch (error) {
127
+ console.error("Failed to onboard Gemini managed project:", error);
128
+ return undefined;
129
+ }
130
+
131
+ return undefined;
132
+ }
133
+
134
+ interface RetrieveUserQuotaBucket {
135
+ modelId?: string;
136
+ }
137
+
138
+ interface RetrieveUserQuotaResponse {
139
+ buckets?: RetrieveUserQuotaBucket[];
140
+ }
141
+
142
+ /**
143
+ * Retrieves Code Assist quota buckets, which include model IDs visible to the current account/project.
144
+ */
145
+ export async function retrieveUserQuota(
146
+ accessToken: string,
147
+ projectId: string,
148
+ ): Promise<RetrieveUserQuotaResponse | null> {
149
+ const url = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:retrieveUserQuota`;
150
+ const headers = {
151
+ "Content-Type": "application/json",
152
+ Authorization: `Bearer ${accessToken}`,
153
+ ...CODE_ASSIST_HEADERS,
154
+ };
155
+
156
+ try {
157
+ const response = await fetch(url, {
158
+ method: "POST",
159
+ headers,
160
+ body: JSON.stringify({ project: projectId }),
161
+ });
162
+
163
+ if (!response.ok) {
164
+ return null;
165
+ }
166
+ return (await response.json()) as RetrieveUserQuotaResponse;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ async function fetchWithDebug(
173
+ url: string,
174
+ method: "GET" | "POST",
175
+ headers: Record<string, string>,
176
+ body: Record<string, unknown> | undefined,
177
+ projectId?: string,
178
+ ): Promise<Response> {
179
+ const debugContext = startGeminiDebugRequest({
180
+ originalUrl: url,
181
+ resolvedUrl: url,
182
+ method,
183
+ headers,
184
+ body: body ? JSON.stringify(body) : undefined,
185
+ streaming: false,
186
+ projectId,
187
+ });
188
+ const response = await fetch(url, {
189
+ method,
190
+ headers,
191
+ body: body ? JSON.stringify(body) : undefined,
192
+ });
193
+ if (debugContext) {
194
+ const responseBody = await readResponseTextIfNeeded(response, true);
195
+ logGeminiDebugResponse(debugContext, response, { body: responseBody });
196
+ }
197
+ return response;
198
+ }
199
+
200
+ async function readResponseTextIfNeeded(response: Response, needed: boolean): Promise<string | undefined> {
201
+ if (!needed && response.ok) {
202
+ return undefined;
203
+ }
204
+ try {
205
+ return await response.clone().text();
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ }