opencode-gemini-auth 1.3.7 → 1.3.9

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
@@ -15,7 +15,7 @@ directly within Opencode, bypassing separate API billing.
15
15
  ## Installation
16
16
 
17
17
  Add the plugin to your Opencode configuration file
18
- (`~/.config/opencode/config.json` or similar):
18
+ (`~/.config/opencode/opencode.json` or similar):
19
19
 
20
20
  ```json
21
21
  {
@@ -24,6 +24,12 @@ Add the plugin to your Opencode configuration file
24
24
  }
25
25
  ```
26
26
 
27
+ > [!IMPORTANT]
28
+ > If you're using a paid Gemini Code Assist subscription (Standard/Enterprise),
29
+ > explicitly configure a Google Cloud `projectId`. Free tier accounts should
30
+ > auto-provision a managed project, but you can still set `projectId` to force
31
+ > a specific project.
32
+
27
33
  ## Usage
28
34
 
29
35
  1. **Login**: Run the authentication command in your terminal:
@@ -46,7 +52,10 @@ Once authenticated, Opencode will use your Google account for Gemini requests.
46
52
  ### Google Cloud Project
47
53
 
48
54
  By default, the plugin attempts to provision or find a suitable Google Cloud
49
- project. To force a specific project, set the `projectId` in your configuration:
55
+ project. To force a specific project, set the `projectId` in your configuration
56
+ or via environment variables:
57
+
58
+ **File:** `~/.config/opencode/opencode.json`
50
59
 
51
60
  ```json
52
61
  {
@@ -60,19 +69,44 @@ project. To force a specific project, set the `projectId` in your configuration:
60
69
  }
61
70
  ```
62
71
 
63
- ### Thinking Models
72
+ You can also set `OPENCODE_GEMINI_PROJECT_ID`, `GOOGLE_CLOUD_PROJECT`, or
73
+ `GOOGLE_CLOUD_PROJECT_ID` to supply the project ID via environment variables.
64
74
 
65
- Configure "thinking" capabilities for Gemini models using the `thinkingConfig`
66
- option in your `config.json`.
75
+ ### Model list
67
76
 
68
- **Gemini 3 (Thinking Level)**
69
- Use `thinkingLevel` (`"low"`, `"high"`) for Gemini 3 models.
77
+ Below are example model entries you can add under `provider.google.models` in your
78
+ Opencode config. Each model can include an `options.thinkingConfig` block to
79
+ enable "thinking" features.
70
80
 
71
81
  ```json
72
82
  {
73
83
  "provider": {
74
84
  "google": {
75
85
  "models": {
86
+ "gemini-2.5-flash": {
87
+ "options": {
88
+ "thinkingConfig": {
89
+ "thinkingBudget": 8192,
90
+ "includeThoughts": true
91
+ }
92
+ }
93
+ },
94
+ "gemini-2.5-pro": {
95
+ "options": {
96
+ "thinkingConfig": {
97
+ "thinkingBudget": 8192,
98
+ "includeThoughts": true
99
+ }
100
+ }
101
+ },
102
+ "gemini-3-flash-preview": {
103
+ "options": {
104
+ "thinkingConfig": {
105
+ "thinkingLevel": "high",
106
+ "includeThoughts": true
107
+ }
108
+ }
109
+ },
76
110
  "gemini-3-pro-preview": {
77
111
  "options": {
78
112
  "thinkingConfig": {
@@ -87,14 +121,33 @@ Use `thinkingLevel` (`"low"`, `"high"`) for Gemini 3 models.
87
121
  }
88
122
  ```
89
123
 
90
- **Gemini 2.5 (Thinking Budget)**
91
- Use `thinkingBudget` (token count) for Gemini 2.5 models.
124
+ Note: Available model names and previews may change—check Google's documentation or
125
+ the Gemini product page for the current model identifiers.
126
+
127
+ ### Thinking Models
128
+
129
+ The plugin supports configuring Gemini "thinking" features per-model via
130
+ `thinkingConfig`. The available fields depend on the model family:
131
+
132
+ - For Gemini 3 models: use `thinkingLevel` with values `"low"` or `"high"`.
133
+ - For Gemini 2.5 models: use `thinkingBudget` (token count).
134
+ - `includeThoughts` (boolean) controls whether the model emits internal thoughts.
135
+
136
+ A combined example showing both model types:
92
137
 
93
138
  ```json
94
139
  {
95
140
  "provider": {
96
141
  "google": {
97
142
  "models": {
143
+ "gemini-3-pro-preview": {
144
+ "options": {
145
+ "thinkingConfig": {
146
+ "thinkingLevel": "high",
147
+ "includeThoughts": true
148
+ }
149
+ }
150
+ },
98
151
  "gemini-2.5-flash": {
99
152
  "options": {
100
153
  "thinkingConfig": {
@@ -109,6 +162,9 @@ Use `thinkingBudget` (token count) for Gemini 2.5 models.
109
162
  }
110
163
  ```
111
164
 
165
+ If you don't set a `thinkingConfig` for a model, the plugin will use default
166
+ behavior for that model.
167
+
112
168
  ## Troubleshooting
113
169
 
114
170
  ### Manual Google Cloud Setup
package/index.ts CHANGED
@@ -5,7 +5,7 @@ export {
5
5
 
6
6
  export {
7
7
  authorizeGemini,
8
- exchangeGemini,
8
+ exchangeGeminiWithVerifier,
9
9
  } from "./src/gemini/oauth";
10
10
 
11
11
  export type {
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.7",
4
+ "version": "1.3.9",
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": "^1.0.203",
14
+ "@opencode-ai/plugin": "^1.1.48",
15
15
  "@types/bun": "latest"
16
16
  },
17
17
  "peerDependencies": {
@@ -1,4 +1,5 @@
1
1
  import { generatePKCE } from "@openauthjs/openauth/pkce";
2
+ import { randomBytes } from "node:crypto";
2
3
 
3
4
  import {
4
5
  GEMINI_CLIENT_ID,
@@ -6,22 +7,24 @@ import {
6
7
  GEMINI_REDIRECT_URI,
7
8
  GEMINI_SCOPES,
8
9
  } from "../constants";
10
+ import {
11
+ formatDebugBodyPreview,
12
+ isGeminiDebugEnabled,
13
+ logGeminiDebugMessage,
14
+ } from "../plugin/debug";
9
15
 
10
16
  interface PkcePair {
11
17
  challenge: string;
12
18
  verifier: string;
13
19
  }
14
20
 
15
- interface GeminiAuthState {
16
- verifier: string;
17
- }
18
-
19
21
  /**
20
22
  * Result returned to the caller after constructing an OAuth authorization URL.
21
23
  */
22
24
  export interface GeminiAuthorization {
23
25
  url: string;
24
26
  verifier: string;
27
+ state: string;
25
28
  }
26
29
 
27
30
  interface GeminiTokenExchangeSuccess {
@@ -51,34 +54,12 @@ interface GeminiUserInfo {
51
54
  email?: string;
52
55
  }
53
56
 
54
- /**
55
- * Encode an object into a URL-safe base64 string.
56
- */
57
- function encodeState(payload: GeminiAuthState): string {
58
- return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
59
- }
60
-
61
- /**
62
- * Decode an OAuth state parameter back into its structured representation.
63
- */
64
- function decodeState(state: string): GeminiAuthState {
65
- const normalized = state.replace(/-/g, "+").replace(/_/g, "/");
66
- const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "=");
67
- const json = Buffer.from(padded, "base64").toString("utf8");
68
- const parsed = JSON.parse(json);
69
- if (typeof parsed.verifier !== "string") {
70
- throw new Error("Missing PKCE verifier in state");
71
- }
72
- return {
73
- verifier: parsed.verifier,
74
- };
75
- }
76
-
77
57
  /**
78
58
  * Build the Gemini OAuth authorization URL including PKCE.
79
59
  */
80
60
  export async function authorizeGemini(): Promise<GeminiAuthorization> {
81
61
  const pkce = (await generatePKCE()) as PkcePair;
62
+ const state = randomBytes(32).toString("hex");
82
63
 
83
64
  const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
84
65
  url.searchParams.set("client_id", GEMINI_CLIENT_ID);
@@ -87,35 +68,19 @@ export async function authorizeGemini(): Promise<GeminiAuthorization> {
87
68
  url.searchParams.set("scope", GEMINI_SCOPES.join(" "));
88
69
  url.searchParams.set("code_challenge", pkce.challenge);
89
70
  url.searchParams.set("code_challenge_method", "S256");
90
- url.searchParams.set("state", encodeState({ verifier: pkce.verifier }));
71
+ url.searchParams.set("state", state);
91
72
  url.searchParams.set("access_type", "offline");
92
73
  url.searchParams.set("prompt", "consent");
74
+ // Add a fragment so any stray terminal glyphs are ignored by the auth server.
75
+ url.hash = "opencode";
93
76
 
94
77
  return {
95
78
  url: url.toString(),
96
79
  verifier: pkce.verifier,
80
+ state,
97
81
  };
98
82
  }
99
83
 
100
- /**
101
- * Exchange an authorization code for Gemini CLI access and refresh tokens.
102
- */
103
- export async function exchangeGemini(
104
- code: string,
105
- state: string,
106
- ): Promise<GeminiTokenExchangeResult> {
107
- try {
108
- const { verifier } = decodeState(state);
109
-
110
- return await exchangeGeminiWithVerifierInternal(code, verifier);
111
- } catch (error) {
112
- return {
113
- type: "failed",
114
- error: error instanceof Error ? error.message : "Unknown error",
115
- };
116
- }
117
- }
118
-
119
84
  /**
120
85
  * Exchange an authorization code using a known PKCE verifier.
121
86
  */
@@ -137,6 +102,9 @@ async function exchangeGeminiWithVerifierInternal(
137
102
  code: string,
138
103
  verifier: string,
139
104
  ): Promise<GeminiTokenExchangeResult> {
105
+ if (isGeminiDebugEnabled()) {
106
+ logGeminiDebugMessage("OAuth exchange: POST https://oauth2.googleapis.com/token");
107
+ }
140
108
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
141
109
  method: "POST",
142
110
  headers: {
@@ -154,11 +122,28 @@ async function exchangeGeminiWithVerifierInternal(
154
122
 
155
123
  if (!tokenResponse.ok) {
156
124
  const errorText = await tokenResponse.text();
125
+ if (isGeminiDebugEnabled()) {
126
+ logGeminiDebugMessage(
127
+ `OAuth exchange response: ${tokenResponse.status} ${tokenResponse.statusText}`,
128
+ );
129
+ const preview = formatDebugBodyPreview(errorText);
130
+ if (preview) {
131
+ logGeminiDebugMessage(`OAuth exchange error body: ${preview}`);
132
+ }
133
+ }
157
134
  return { type: "failed", error: errorText };
158
135
  }
159
136
 
160
137
  const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
138
+ if (isGeminiDebugEnabled()) {
139
+ logGeminiDebugMessage(
140
+ `OAuth exchange success: expires_in=${tokenPayload.expires_in}s refresh_token=${tokenPayload.refresh_token ? "yes" : "no"}`,
141
+ );
142
+ }
161
143
 
144
+ if (isGeminiDebugEnabled()) {
145
+ logGeminiDebugMessage("OAuth userinfo: GET https://www.googleapis.com/oauth2/v1/userinfo");
146
+ }
162
147
  const userInfoResponse = await fetch(
163
148
  "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
164
149
  {
@@ -167,6 +152,11 @@ async function exchangeGeminiWithVerifierInternal(
167
152
  },
168
153
  },
169
154
  );
155
+ if (isGeminiDebugEnabled()) {
156
+ logGeminiDebugMessage(
157
+ `OAuth userinfo response: ${userInfoResponse.status} ${userInfoResponse.statusText}`,
158
+ );
159
+ }
170
160
 
171
161
  const userInfo = userInfoResponse.ok
172
162
  ? ((await userInfoResponse.json()) as GeminiUserInfo)
@@ -33,6 +33,33 @@ interface GeminiDebugResponseMeta {
33
33
 
34
34
  let requestCounter = 0;
35
35
 
36
+ /**
37
+ * Returns true when Gemini debug logging is enabled.
38
+ */
39
+ export function isGeminiDebugEnabled(): boolean {
40
+ return debugEnabled;
41
+ }
42
+
43
+ /**
44
+ * Writes an arbitrary debug line when debugging is enabled.
45
+ */
46
+ export function logGeminiDebugMessage(message: string): void {
47
+ if (!debugEnabled) {
48
+ return;
49
+ }
50
+ logDebug(`[Gemini Debug] ${message}`);
51
+ }
52
+
53
+ /**
54
+ * Produces a truncated preview of a debug body payload.
55
+ */
56
+ export function formatDebugBodyPreview(text?: string | null): string | undefined {
57
+ if (!text) {
58
+ return undefined;
59
+ }
60
+ return truncateForLog(text);
61
+ }
62
+
36
63
  /**
37
64
  * Begins a debug trace for a Gemini request, logging request metadata when debugging is enabled.
38
65
  */
@@ -82,6 +109,11 @@ export function logGeminiDebugResponse(
82
109
  )}`,
83
110
  );
84
111
 
112
+ const traceId = getHeaderValue(meta.headersOverride ?? response.headers, "x-cloudaicompanion-trace-id");
113
+ if (traceId) {
114
+ logDebug(`[Gemini Debug ${context.id}] Trace ID: ${traceId}`);
115
+ }
116
+
85
117
  if (meta.note) {
86
118
  logDebug(`[Gemini Debug ${context.id}] Note: ${meta.note}`);
87
119
  }
@@ -117,6 +149,34 @@ function maskHeaders(headers?: HeadersInit | Headers): Record<string, string> {
117
149
  return result;
118
150
  }
119
151
 
152
+ /**
153
+ * Reads a header value from a HeadersInit or Headers instance.
154
+ */
155
+ function getHeaderValue(headers: HeadersInit | Headers, key: string): string | undefined {
156
+ const target = key.toLowerCase();
157
+ if (headers instanceof Headers) {
158
+ const value = headers.get(key);
159
+ return value ?? undefined;
160
+ }
161
+
162
+ if (Array.isArray(headers)) {
163
+ for (const [headerKey, headerValue] of headers) {
164
+ if (headerKey.toLowerCase() === target) {
165
+ return headerValue;
166
+ }
167
+ }
168
+ return undefined;
169
+ }
170
+
171
+ const record = headers as Record<string, string | undefined>;
172
+ for (const [headerKey, headerValue] of Object.entries(record)) {
173
+ if (headerKey.toLowerCase() === target) {
174
+ return headerValue ?? undefined;
175
+ }
176
+ }
177
+ return undefined;
178
+ }
179
+
120
180
  /**
121
181
  * Produces a short, type-aware preview of a request/response body for logs.
122
182
  */
@@ -0,0 +1,112 @@
1
+ import { beforeEach, describe, expect, it, mock } from "bun:test";
2
+
3
+ import { resolveProjectContextFromAccessToken } from "./project";
4
+ import type { OAuthAuthDetails } from "./types";
5
+
6
+ const baseAuth: OAuthAuthDetails = {
7
+ type: "oauth",
8
+ refresh: "refresh-token",
9
+ access: "access-token",
10
+ expires: Date.now() + 60_000,
11
+ };
12
+
13
+ function toUrlString(input: RequestInfo): string {
14
+ if (typeof input === "string") {
15
+ return input;
16
+ }
17
+ if (input instanceof URL) {
18
+ return input.toString();
19
+ }
20
+ return (input as Request).url ?? input.toString();
21
+ }
22
+
23
+ describe("resolveProjectContextFromAccessToken", () => {
24
+ beforeEach(() => {
25
+ mock.restore();
26
+ });
27
+
28
+ it("stores managed project id from loadCodeAssist without onboarding", async () => {
29
+ const fetchMock = mock(async (input: RequestInfo) => {
30
+ const url = toUrlString(input);
31
+ if (url.includes(":loadCodeAssist")) {
32
+ return new Response(
33
+ JSON.stringify({
34
+ currentTier: { id: "free-tier" },
35
+ cloudaicompanionProject: "projects/server-project",
36
+ }),
37
+ { status: 200 },
38
+ );
39
+ }
40
+ throw new Error(`Unexpected fetch to ${url}`);
41
+ });
42
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
43
+
44
+ const result = await resolveProjectContextFromAccessToken(
45
+ baseAuth,
46
+ baseAuth.access ?? "",
47
+ );
48
+
49
+ expect(result.effectiveProjectId).toBe("projects/server-project");
50
+ expect(result.auth.refresh).toContain("projects/server-project");
51
+ expect(fetchMock.mock.calls.length).toBe(1);
52
+ });
53
+
54
+ it("onboards free-tier users without sending a project id", async () => {
55
+ let onboardBody: Record<string, unknown> | undefined;
56
+ const fetchMock = mock(async (input: RequestInfo, init?: RequestInit) => {
57
+ const url = toUrlString(input);
58
+ if (url.includes(":loadCodeAssist")) {
59
+ return new Response(
60
+ JSON.stringify({
61
+ allowedTiers: [{ id: "free-tier", isDefault: true }],
62
+ }),
63
+ { status: 200 },
64
+ );
65
+ }
66
+ if (url.includes(":onboardUser")) {
67
+ const rawBody = typeof init?.body === "string" ? init.body : "{}";
68
+ onboardBody = JSON.parse(rawBody) as Record<string, unknown>;
69
+ return new Response(
70
+ JSON.stringify({
71
+ done: true,
72
+ response: { cloudaicompanionProject: { id: "managed-project" } },
73
+ }),
74
+ { status: 200 },
75
+ );
76
+ }
77
+ throw new Error(`Unexpected fetch to ${url}`);
78
+ });
79
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
80
+
81
+ const result = await resolveProjectContextFromAccessToken(
82
+ baseAuth,
83
+ baseAuth.access ?? "",
84
+ );
85
+
86
+ expect(result.effectiveProjectId).toBe("managed-project");
87
+ expect(result.auth.refresh).toContain("managed-project");
88
+ expect(onboardBody?.cloudaicompanionProject).toBeUndefined();
89
+ const metadata = onboardBody?.metadata as Record<string, unknown> | undefined;
90
+ expect(metadata?.duetProject).toBeUndefined();
91
+ });
92
+
93
+ it("throws when a non-free tier requires a project id", async () => {
94
+ const fetchMock = mock(async (input: RequestInfo) => {
95
+ const url = toUrlString(input);
96
+ if (url.includes(":loadCodeAssist")) {
97
+ return new Response(
98
+ JSON.stringify({
99
+ allowedTiers: [{ id: "standard-tier", isDefault: true }],
100
+ }),
101
+ { status: 200 },
102
+ );
103
+ }
104
+ throw new Error(`Unexpected fetch to ${url}`);
105
+ });
106
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
107
+
108
+ await expect(
109
+ resolveProjectContextFromAccessToken(baseAuth, baseAuth.access ?? ""),
110
+ ).rejects.toThrow("Google Gemini requires a Google Cloud project");
111
+ });
112
+ });