opencode-gemini-auth 1.3.4 → 1.3.7

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
@@ -37,7 +37,7 @@ Add the plugin to your Opencode configuration file
37
37
  - A browser window will open for you to approve the access.
38
38
  - The plugin spins up a temporary local server to capture the callback.
39
39
  - If the local server fails (e.g., port in use or headless environment),
40
- you can manually copy/paste the callback URL as instructed.
40
+ you can manually paste the callback URL or just the authorization code.
41
41
 
42
42
  Once authenticated, Opencode will use your Google account for Gemini requests.
43
43
 
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.4",
4
+ "version": "1.3.7",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -11,7 +11,7 @@
11
11
  "license": "MIT",
12
12
  "type": "module",
13
13
  "devDependencies": {
14
- "@opencode-ai/plugin": "^0.15.31",
14
+ "@opencode-ai/plugin": "^1.0.203",
15
15
  "@types/bun": "latest"
16
16
  },
17
17
  "peerDependencies": {
@@ -107,53 +107,24 @@ export async function exchangeGemini(
107
107
  try {
108
108
  const { verifier } = decodeState(state);
109
109
 
110
- const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
111
- method: "POST",
112
- headers: {
113
- "Content-Type": "application/x-www-form-urlencoded",
114
- },
115
- body: new URLSearchParams({
116
- client_id: GEMINI_CLIENT_ID,
117
- client_secret: GEMINI_CLIENT_SECRET,
118
- code,
119
- grant_type: "authorization_code",
120
- redirect_uri: GEMINI_REDIRECT_URI,
121
- code_verifier: verifier,
122
- }),
123
- });
124
-
125
- if (!tokenResponse.ok) {
126
- const errorText = await tokenResponse.text();
127
- return { type: "failed", error: errorText };
128
- }
129
-
130
- const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
131
-
132
- const userInfoResponse = await fetch(
133
- "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
134
- {
135
- headers: {
136
- Authorization: `Bearer ${tokenPayload.access_token}`,
137
- },
138
- },
139
- );
140
-
141
- const userInfo = userInfoResponse.ok
142
- ? ((await userInfoResponse.json()) as GeminiUserInfo)
143
- : {};
144
-
145
- const refreshToken = tokenPayload.refresh_token;
146
- if (!refreshToken) {
147
- return { type: "failed", error: "Missing refresh token in response" };
148
- }
149
-
110
+ return await exchangeGeminiWithVerifierInternal(code, verifier);
111
+ } catch (error) {
150
112
  return {
151
- type: "success",
152
- refresh: refreshToken,
153
- access: tokenPayload.access_token,
154
- expires: Date.now() + tokenPayload.expires_in * 1000,
155
- email: userInfo.email,
113
+ type: "failed",
114
+ error: error instanceof Error ? error.message : "Unknown error",
156
115
  };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Exchange an authorization code using a known PKCE verifier.
121
+ */
122
+ export async function exchangeGeminiWithVerifier(
123
+ code: string,
124
+ verifier: string,
125
+ ): Promise<GeminiTokenExchangeResult> {
126
+ try {
127
+ return await exchangeGeminiWithVerifierInternal(code, verifier);
157
128
  } catch (error) {
158
129
  return {
159
130
  type: "failed",
@@ -161,3 +132,56 @@ export async function exchangeGemini(
161
132
  };
162
133
  }
163
134
  }
135
+
136
+ async function exchangeGeminiWithVerifierInternal(
137
+ code: string,
138
+ verifier: string,
139
+ ): Promise<GeminiTokenExchangeResult> {
140
+ const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
141
+ method: "POST",
142
+ headers: {
143
+ "Content-Type": "application/x-www-form-urlencoded",
144
+ },
145
+ body: new URLSearchParams({
146
+ client_id: GEMINI_CLIENT_ID,
147
+ client_secret: GEMINI_CLIENT_SECRET,
148
+ code,
149
+ grant_type: "authorization_code",
150
+ redirect_uri: GEMINI_REDIRECT_URI,
151
+ code_verifier: verifier,
152
+ }),
153
+ });
154
+
155
+ if (!tokenResponse.ok) {
156
+ const errorText = await tokenResponse.text();
157
+ return { type: "failed", error: errorText };
158
+ }
159
+
160
+ const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
161
+
162
+ const userInfoResponse = await fetch(
163
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
164
+ {
165
+ headers: {
166
+ Authorization: `Bearer ${tokenPayload.access_token}`,
167
+ },
168
+ },
169
+ );
170
+
171
+ const userInfo = userInfoResponse.ok
172
+ ? ((await userInfoResponse.json()) as GeminiUserInfo)
173
+ : {};
174
+
175
+ const refreshToken = tokenPayload.refresh_token;
176
+ if (!refreshToken) {
177
+ return { type: "failed", error: "Missing refresh token in response" };
178
+ }
179
+
180
+ return {
181
+ type: "success",
182
+ refresh: refreshToken,
183
+ access: tokenPayload.access_token,
184
+ expires: Date.now() + tokenPayload.expires_in * 1000,
185
+ email: userInfo.email,
186
+ };
187
+ }
@@ -120,34 +120,6 @@ export function extractUsageMetadata(body: GeminiApiBody): GeminiUsageMetadata |
120
120
  };
121
121
  }
122
122
 
123
- /**
124
- * Walks SSE lines to find a usage-bearing response chunk.
125
- */
126
- export function extractUsageFromSsePayload(payload: string): GeminiUsageMetadata | null {
127
- const lines = payload.split("\n");
128
- for (const line of lines) {
129
- if (!line.startsWith("data:")) {
130
- continue;
131
- }
132
- const jsonText = line.slice(5).trim();
133
- if (!jsonText) {
134
- continue;
135
- }
136
- try {
137
- const parsed = JSON.parse(jsonText);
138
- if (parsed && typeof parsed === "object") {
139
- const usage = extractUsageMetadata({ response: (parsed as Record<string, unknown>).response });
140
- if (usage) {
141
- return usage;
142
- }
143
- }
144
- } catch {
145
- continue;
146
- }
147
- }
148
- return null;
149
- }
150
-
151
123
  /**
152
124
  * Enhances 404 errors for Gemini 3 models with a direct preview-access message.
153
125
  */
@@ -1,7 +1,6 @@
1
1
  import { CODE_ASSIST_HEADERS, GEMINI_CODE_ASSIST_ENDPOINT } from "../constants";
2
2
  import { logGeminiDebugResponse, type GeminiDebugContext } from "./debug";
3
3
  import {
4
- extractUsageFromSsePayload,
5
4
  extractUsageMetadata,
6
5
  normalizeThinkingConfig,
7
6
  parseGeminiApiBody,
@@ -20,32 +19,85 @@ const MODEL_FALLBACKS: Record<string, string> = {
20
19
  * @returns True when the URL targets generativelanguage.googleapis.com.
21
20
  */
22
21
  export function isGenerativeLanguageRequest(input: RequestInfo): input is string {
23
- return typeof input === "string" && input.includes("generativelanguage.googleapis.com");
22
+ return toRequestUrlString(input).includes("generativelanguage.googleapis.com");
24
23
  }
25
24
 
26
25
  /**
27
- * Rewrites SSE payloads so downstream consumers see only the inner `response` objects.
26
+ * Rewrites SSE payload lines so downstream consumers see only the inner `response` objects.
28
27
  */
29
- function transformStreamingPayload(payload: string): string {
30
- return payload
31
- .split("\n")
32
- .map((line) => {
33
- if (!line.startsWith("data:")) {
34
- return line;
35
- }
36
- const json = line.slice(5).trim();
37
- if (!json) {
38
- return line;
28
+ function transformStreamingLine(line: string): string {
29
+ if (!line.startsWith("data:")) {
30
+ return line;
31
+ }
32
+ const json = line.slice(5).trim();
33
+ if (!json) {
34
+ return line;
35
+ }
36
+ try {
37
+ const parsed = JSON.parse(json) as { response?: unknown };
38
+ if (parsed.response !== undefined) {
39
+ return `data: ${JSON.stringify(parsed.response)}`;
40
+ }
41
+ } catch (_) {}
42
+ return line;
43
+ }
44
+
45
+ /**
46
+ * Streams SSE payloads, rewriting data lines on the fly.
47
+ */
48
+ function transformStreamingPayloadStream(
49
+ stream: ReadableStream<Uint8Array>,
50
+ ): ReadableStream<Uint8Array> {
51
+ const decoder = new TextDecoder();
52
+ const encoder = new TextEncoder();
53
+ let buffer = "";
54
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
55
+
56
+ return new ReadableStream<Uint8Array>({
57
+ start(controller) {
58
+ reader = stream.getReader();
59
+ const pump = (): void => {
60
+ reader!
61
+ .read()
62
+ .then(({ done, value }) => {
63
+ if (done) {
64
+ buffer += decoder.decode();
65
+ if (buffer.length > 0) {
66
+ controller.enqueue(encoder.encode(transformStreamingLine(buffer)));
67
+ }
68
+ controller.close();
69
+ return;
70
+ }
71
+
72
+ buffer += decoder.decode(value, { stream: true });
73
+
74
+ let newlineIndex = buffer.indexOf("\n");
75
+ while (newlineIndex !== -1) {
76
+ const line = buffer.slice(0, newlineIndex);
77
+ buffer = buffer.slice(newlineIndex + 1);
78
+ const hasCarriageReturn = line.endsWith("\r");
79
+ const rawLine = hasCarriageReturn ? line.slice(0, -1) : line;
80
+ const transformed = transformStreamingLine(rawLine);
81
+ const suffix = hasCarriageReturn ? "\r\n" : "\n";
82
+ controller.enqueue(encoder.encode(`${transformed}${suffix}`));
83
+ newlineIndex = buffer.indexOf("\n");
84
+ }
85
+
86
+ pump();
87
+ })
88
+ .catch((error) => {
89
+ controller.error(error);
90
+ });
91
+ };
92
+
93
+ pump();
94
+ },
95
+ cancel(reason) {
96
+ if (reader) {
97
+ reader.cancel(reason).catch(() => {});
39
98
  }
40
- try {
41
- const parsed = JSON.parse(json) as { response?: unknown };
42
- if (parsed.response !== undefined) {
43
- return `data: ${JSON.stringify(parsed.response)}`;
44
- }
45
- } catch (_) {}
46
- return line;
47
- })
48
- .join("\n");
99
+ },
100
+ });
49
101
  }
50
102
 
51
103
  /**
@@ -72,7 +124,7 @@ export function prepareGeminiRequest(
72
124
  headers.set("Authorization", `Bearer ${accessToken}`);
73
125
  headers.delete("x-api-key");
74
126
 
75
- const match = input.match(/\/models\/([^:]+):(\w+)/);
127
+ const match = toRequestUrlString(input).match(/\/models\/([^:]+):(\w+)/);
76
128
  if (!match) {
77
129
  return {
78
130
  request: input,
@@ -136,7 +188,6 @@ export function prepareGeminiRequest(
136
188
  }
137
189
 
138
190
  delete requestPayload.cached_content;
139
- delete requestPayload.cachedContent;
140
191
  if (requestPayload.extra_body && typeof requestPayload.extra_body === "object") {
141
192
  delete (requestPayload.extra_body as Record<string, unknown>).cached_content;
142
193
  delete (requestPayload.extra_body as Record<string, unknown>).cachedContent;
@@ -182,9 +233,23 @@ export function prepareGeminiRequest(
182
233
  };
183
234
  }
184
235
 
236
+ function toRequestUrlString(value: RequestInfo): string {
237
+ if (typeof value === "string") {
238
+ return value;
239
+ }
240
+ if (value instanceof URL) {
241
+ return value.toString();
242
+ }
243
+ const candidate = (value as Request).url;
244
+ if (candidate) {
245
+ return candidate;
246
+ }
247
+ return value.toString();
248
+ }
249
+
185
250
  /**
186
251
  * Normalizes Gemini responses: applies retry headers, extracts cache usage into headers,
187
- * rewrites preview errors, flattens streaming payloads, and logs debug metadata.
252
+ * rewrites preview errors, rewrites streaming payloads, and logs debug metadata.
188
253
  */
189
254
  export async function transformGeminiResponse(
190
255
  response: Response,
@@ -204,8 +269,22 @@ export async function transformGeminiResponse(
204
269
  }
205
270
 
206
271
  try {
207
- const text = await response.text();
208
272
  const headers = new Headers(response.headers);
273
+
274
+ if (streaming && response.ok && isEventStreamResponse && response.body) {
275
+ logGeminiDebugResponse(debugContext, response, {
276
+ note: "Streaming SSE payload (body omitted)",
277
+ headersOverride: headers,
278
+ });
279
+
280
+ return new Response(transformStreamingPayloadStream(response.body), {
281
+ status: response.status,
282
+ statusText: response.statusText,
283
+ headers,
284
+ });
285
+ }
286
+
287
+ const text = await response.text();
209
288
 
210
289
  if (!response.ok && text) {
211
290
  try {
@@ -238,12 +317,11 @@ export async function transformGeminiResponse(
238
317
  headers,
239
318
  };
240
319
 
241
- const usageFromSse = streaming && isEventStreamResponse ? extractUsageFromSsePayload(text) : null;
242
320
  const parsed: GeminiApiBody | null = !streaming || !isEventStreamResponse ? parseGeminiApiBody(text) : null;
243
321
  const patched = parsed ? rewriteGeminiPreviewAccessError(parsed, response.status, requestedModel) : null;
244
322
  const effectiveBody = patched ?? parsed ?? undefined;
245
323
 
246
- const usage = usageFromSse ?? (effectiveBody ? extractUsageMetadata(effectiveBody) : null);
324
+ const usage = effectiveBody ? extractUsageMetadata(effectiveBody) : null;
247
325
  if (usage?.cachedContentTokenCount !== undefined) {
248
326
  headers.set("x-gemini-cached-content-token-count", String(usage.cachedContentTokenCount));
249
327
  if (usage.totalTokenCount !== undefined) {
@@ -259,14 +337,10 @@ export async function transformGeminiResponse(
259
337
 
260
338
  logGeminiDebugResponse(debugContext, response, {
261
339
  body: text,
262
- note: streaming ? "Streaming SSE payload" : undefined,
340
+ note: streaming ? "Streaming SSE payload (buffered)" : undefined,
263
341
  headersOverride: headers,
264
342
  });
265
343
 
266
- if (streaming && response.ok && isEventStreamResponse) {
267
- return new Response(transformStreamingPayload(text), init);
268
- }
269
-
270
344
  if (!parsed) {
271
345
  return new Response(text, init);
272
346
  }
package/src/plugin.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
3
  import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
4
- import { authorizeGemini, exchangeGemini } from "./gemini/oauth";
4
+ import {
5
+ authorizeGemini,
6
+ exchangeGemini,
7
+ exchangeGeminiWithVerifier,
8
+ } from "./gemini/oauth";
5
9
  import type { GeminiTokenExchangeResult } from "./gemini/oauth";
6
10
  import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
7
11
  import { ensureProjectContext } from "./plugin/project";
@@ -146,16 +150,18 @@ export const GeminiCLIOAuthPlugin = async (
146
150
  } catch (error) {
147
151
  if (error instanceof Error) {
148
152
  console.log(
149
- `Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL.`,
153
+ `Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL or authorization code.`,
150
154
  );
151
155
  } else {
152
156
  console.log(
153
- "Warning: Couldn't start the local callback listener. You'll need to paste the callback URL.",
157
+ "Warning: Couldn't start the local callback listener. You'll need to paste the callback URL or authorization code.",
154
158
  );
155
159
  }
156
160
  }
157
161
  } else {
158
- console.log("Headless environment detected. You'll need to paste the callback URL.");
162
+ console.log(
163
+ "Headless environment detected. You'll need to paste the callback URL or authorization code.",
164
+ );
159
165
  }
160
166
 
161
167
  const authorization = await authorizeGemini();
@@ -201,22 +207,24 @@ export const GeminiCLIOAuthPlugin = async (
201
207
  return {
202
208
  url: authorization.url,
203
209
  instructions:
204
- "Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...)",
210
+ "Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...) or just the authorization code.",
205
211
  method: "code",
206
212
  callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
207
213
  try {
208
- const url = new URL(callbackUrl);
209
- const code = url.searchParams.get("code");
210
- const state = url.searchParams.get("state");
214
+ const { code, state } = parseOAuthCallbackInput(callbackUrl);
211
215
 
212
- if (!code || !state) {
216
+ if (!code) {
213
217
  return {
214
218
  type: "failed",
215
- error: "Missing code or state in callback URL",
219
+ error: "Missing authorization code in callback input",
216
220
  };
217
221
  }
218
222
 
219
- return exchangeGemini(code, state);
223
+ if (state) {
224
+ return exchangeGemini(code, state);
225
+ }
226
+
227
+ return exchangeGeminiWithVerifier(code, authorization.verifier);
220
228
  } catch (error) {
221
229
  return {
222
230
  type: "failed",
@@ -249,6 +257,37 @@ function toUrlString(value: RequestInfo): string {
249
257
  return value.toString();
250
258
  }
251
259
 
260
+ function parseOAuthCallbackInput(input: string): { code?: string; state?: string } {
261
+ const trimmed = input.trim();
262
+ if (!trimmed) {
263
+ return {};
264
+ }
265
+
266
+ if (/^https?:\/\//i.test(trimmed)) {
267
+ try {
268
+ const url = new URL(trimmed);
269
+ return {
270
+ code: url.searchParams.get("code") || undefined,
271
+ state: url.searchParams.get("state") || undefined,
272
+ };
273
+ } catch {
274
+ return {};
275
+ }
276
+ }
277
+
278
+ const candidate = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed;
279
+ if (candidate.includes("=")) {
280
+ const params = new URLSearchParams(candidate);
281
+ const code = params.get("code") || undefined;
282
+ const state = params.get("state") || undefined;
283
+ if (code || state) {
284
+ return { code, state };
285
+ }
286
+ }
287
+
288
+ return { code: trimmed };
289
+ }
290
+
252
291
  function openBrowserUrl(url: string): void {
253
292
  try {
254
293
  // Best-effort: don't block auth flow if spawning fails.