opencode-gemini-auth 1.0.7 → 1.0.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 +25 -2
- package/package.json +1 -1
- package/src/plugin/auth.ts +3 -1
- package/src/plugin/project.ts +82 -27
- package/src/plugin/request.ts +30 -21
- package/src/plugin/token.ts +85 -1
- package/src/plugin.ts +9 -1
package/README.md
CHANGED
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
# Gemini OAuth Plugin for Opencode
|
|
2
2
|
|
|
3
|
-
Authenticate the Opencode CLI with your Google account so you can use your
|
|
3
|
+
Authenticate the Opencode CLI with your Google account so you can use your
|
|
4
|
+
existing Gemini plan and its included quota instead of API billing.
|
|
4
5
|
|
|
5
6
|
## Setup
|
|
6
7
|
|
|
7
8
|
1. Add the plugin to your [Opencode config](https://opencode.ai/docs/config/):
|
|
9
|
+
|
|
8
10
|
```json
|
|
9
11
|
{
|
|
10
12
|
"$schema": "https://opencode.ai/config.json",
|
|
11
13
|
"plugin": ["opencode-gemini-auth"]
|
|
12
14
|
}
|
|
13
15
|
```
|
|
16
|
+
|
|
14
17
|
2. Run `opencode auth login`.
|
|
15
18
|
3. Choose the Google provider and select **OAuth with Google (Gemini CLI)**.
|
|
16
19
|
|
|
17
|
-
The plugin spins up a local callback listener, so after approving in the
|
|
20
|
+
The plugin spins up a local callback listener, so after approving in the
|
|
21
|
+
browser you'll land on an "Authentication complete" page with no URL
|
|
22
|
+
copy/paste required. If that port is already taken, the CLI automatically
|
|
23
|
+
falls back to the classic copy/paste flow and explains what to do.
|
|
24
|
+
|
|
25
|
+
## Updating
|
|
26
|
+
|
|
27
|
+
> [!WARNING]
|
|
28
|
+
> OpenCode does NOT auto-update plugins
|
|
29
|
+
|
|
30
|
+
To get the latest version:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
(cd ~ && sed -i.bak '/"opencode-gemini-auth"/d' .cache/opencode/package.json && \
|
|
34
|
+
rm -rf .cache/opencode/node_modules/opencode-gemini-auth && \
|
|
35
|
+
echo "Plugin update script finished successfully.")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
opencode # Reinstalls latest
|
|
40
|
+
```
|
package/package.json
CHANGED
package/src/plugin/auth.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { AuthDetails, OAuthAuthDetails, RefreshParts } from "./types";
|
|
2
2
|
|
|
3
|
+
const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
|
|
4
|
+
|
|
3
5
|
export function isOAuthAuth(auth: AuthDetails): auth is OAuthAuthDetails {
|
|
4
6
|
return auth.type === "oauth";
|
|
5
7
|
}
|
|
@@ -23,5 +25,5 @@ export function accessTokenExpired(auth: OAuthAuthDetails): boolean {
|
|
|
23
25
|
if (!auth.access || typeof auth.expires !== "number") {
|
|
24
26
|
return true;
|
|
25
27
|
}
|
|
26
|
-
return auth.expires <= Date.now();
|
|
28
|
+
return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS;
|
|
27
29
|
}
|
package/src/plugin/project.ts
CHANGED
|
@@ -9,6 +9,24 @@ import type {
|
|
|
9
9
|
ProjectContextResult,
|
|
10
10
|
} from "./types";
|
|
11
11
|
|
|
12
|
+
const projectContextResultCache = new Map<string, ProjectContextResult>();
|
|
13
|
+
const projectContextPendingCache = new Map<string, Promise<ProjectContextResult>>();
|
|
14
|
+
|
|
15
|
+
function getCacheKey(auth: OAuthAuthDetails): string | undefined {
|
|
16
|
+
const refresh = auth.refresh?.trim();
|
|
17
|
+
return refresh ? refresh : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function invalidateProjectContextCache(refresh?: string): void {
|
|
21
|
+
if (!refresh) {
|
|
22
|
+
projectContextPendingCache.clear();
|
|
23
|
+
projectContextResultCache.clear();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
projectContextPendingCache.delete(refresh);
|
|
27
|
+
projectContextResultCache.delete(refresh);
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
export async function loadManagedProject(accessToken: string): Promise<{
|
|
13
31
|
managedProjectId?: string;
|
|
14
32
|
needsOnboarding: boolean;
|
|
@@ -157,42 +175,79 @@ export async function ensureProjectContext(
|
|
|
157
175
|
auth: OAuthAuthDetails,
|
|
158
176
|
client: PluginClient,
|
|
159
177
|
): Promise<ProjectContextResult> {
|
|
160
|
-
|
|
178
|
+
const accessToken = auth.access;
|
|
179
|
+
if (!accessToken) {
|
|
161
180
|
return { auth, effectiveProjectId: "" };
|
|
162
181
|
}
|
|
163
182
|
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
183
|
+
const cacheKey = getCacheKey(auth);
|
|
184
|
+
if (cacheKey) {
|
|
185
|
+
const cached = projectContextResultCache.get(cacheKey);
|
|
186
|
+
if (cached) {
|
|
187
|
+
return cached;
|
|
188
|
+
}
|
|
189
|
+
const pending = projectContextPendingCache.get(cacheKey);
|
|
190
|
+
if (pending) {
|
|
191
|
+
return pending;
|
|
192
|
+
}
|
|
170
193
|
}
|
|
171
194
|
|
|
172
|
-
const
|
|
173
|
-
|
|
195
|
+
const resolveContext = async (): Promise<ProjectContextResult> => {
|
|
196
|
+
const parts = parseRefreshParts(auth.refresh);
|
|
197
|
+
if (parts.projectId || parts.managedProjectId) {
|
|
198
|
+
return {
|
|
199
|
+
auth,
|
|
200
|
+
effectiveProjectId: parts.projectId || parts.managedProjectId || "",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
174
203
|
|
|
175
|
-
|
|
176
|
-
managedProjectId =
|
|
177
|
-
}
|
|
204
|
+
const loadResult = await loadManagedProject(accessToken);
|
|
205
|
+
let managedProjectId = loadResult.managedProjectId;
|
|
178
206
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
refresh: formatRefreshParts({
|
|
183
|
-
refreshToken: parts.refreshToken,
|
|
184
|
-
projectId: parts.projectId,
|
|
185
|
-
managedProjectId,
|
|
186
|
-
}),
|
|
187
|
-
};
|
|
207
|
+
if (!managedProjectId && loadResult.needsOnboarding) {
|
|
208
|
+
managedProjectId = await onboardManagedProject(accessToken);
|
|
209
|
+
}
|
|
188
210
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
211
|
+
if (managedProjectId) {
|
|
212
|
+
const updatedAuth: OAuthAuthDetails = {
|
|
213
|
+
...auth,
|
|
214
|
+
refresh: formatRefreshParts({
|
|
215
|
+
refreshToken: parts.refreshToken,
|
|
216
|
+
projectId: parts.projectId,
|
|
217
|
+
managedProjectId,
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
await client.auth.set({
|
|
222
|
+
path: { id: "gemini-cli" },
|
|
223
|
+
body: updatedAuth,
|
|
224
|
+
});
|
|
193
225
|
|
|
194
|
-
|
|
226
|
+
return { auth: updatedAuth, effectiveProjectId: managedProjectId };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { auth, effectiveProjectId: "" };
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (!cacheKey) {
|
|
233
|
+
return resolveContext();
|
|
195
234
|
}
|
|
196
235
|
|
|
197
|
-
|
|
236
|
+
const promise = resolveContext()
|
|
237
|
+
.then((result) => {
|
|
238
|
+
const nextKey = getCacheKey(result.auth) ?? cacheKey;
|
|
239
|
+
projectContextPendingCache.delete(cacheKey);
|
|
240
|
+
projectContextResultCache.set(nextKey, result);
|
|
241
|
+
if (nextKey !== cacheKey) {
|
|
242
|
+
projectContextResultCache.delete(cacheKey);
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
})
|
|
246
|
+
.catch((error) => {
|
|
247
|
+
projectContextPendingCache.delete(cacheKey);
|
|
248
|
+
throw error;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
projectContextPendingCache.set(cacheKey, promise);
|
|
252
|
+
return promise;
|
|
198
253
|
}
|
package/src/plugin/request.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
|
|
6
6
|
const STREAM_ACTION = "streamGenerateContent";
|
|
7
7
|
|
|
8
|
-
function isGenerativeLanguageRequest(input: RequestInfo): input is string {
|
|
8
|
+
export function isGenerativeLanguageRequest(input: RequestInfo): input is string {
|
|
9
9
|
return typeof input === "string" && input.includes("generativelanguage.googleapis.com");
|
|
10
10
|
}
|
|
11
11
|
|
|
@@ -39,8 +39,6 @@ export function prepareGeminiRequest(
|
|
|
39
39
|
): { request: RequestInfo; init: RequestInit; streaming: boolean } {
|
|
40
40
|
const baseInit: RequestInit = { ...init };
|
|
41
41
|
const headers = new Headers(init?.headers ?? {});
|
|
42
|
-
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
43
|
-
headers.delete("x-api-key");
|
|
44
42
|
|
|
45
43
|
if (!isGenerativeLanguageRequest(input)) {
|
|
46
44
|
return {
|
|
@@ -50,6 +48,9 @@ export function prepareGeminiRequest(
|
|
|
50
48
|
};
|
|
51
49
|
}
|
|
52
50
|
|
|
51
|
+
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
52
|
+
headers.delete("x-api-key");
|
|
53
|
+
|
|
53
54
|
const match = input.match(/\/models\/([^:]+):(\w+)/);
|
|
54
55
|
if (!match) {
|
|
55
56
|
return {
|
|
@@ -67,28 +68,36 @@ export function prepareGeminiRequest(
|
|
|
67
68
|
|
|
68
69
|
let body = baseInit.body;
|
|
69
70
|
if (typeof baseInit.body === "string" && baseInit.body) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
const trimmedPayload = baseInit.body.trim();
|
|
72
|
+
const looksCodeAssistPayload =
|
|
73
|
+
trimmedPayload.startsWith("{") &&
|
|
74
|
+
trimmedPayload.includes('"project"') &&
|
|
75
|
+
trimmedPayload.includes('"request"');
|
|
73
76
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
if (!looksCodeAssistPayload) {
|
|
78
|
+
try {
|
|
79
|
+
const parsedBody = JSON.parse(baseInit.body) as Record<string, unknown>;
|
|
80
|
+
const requestPayload: Record<string, unknown> = { ...parsedBody };
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
if ("system_instruction" in requestPayload) {
|
|
83
|
+
requestPayload.systemInstruction = requestPayload.system_instruction;
|
|
84
|
+
delete requestPayload.system_instruction;
|
|
85
|
+
}
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
if ("model" in requestPayload) {
|
|
88
|
+
delete requestPayload.model;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const wrappedBody = {
|
|
92
|
+
project: projectId,
|
|
93
|
+
model,
|
|
94
|
+
request: requestPayload,
|
|
95
|
+
};
|
|
88
96
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
body = JSON.stringify(wrappedBody);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error("Failed to transform Gemini request body:", error);
|
|
100
|
+
}
|
|
92
101
|
}
|
|
93
102
|
}
|
|
94
103
|
|
package/src/plugin/token.ts
CHANGED
|
@@ -1,7 +1,55 @@
|
|
|
1
1
|
import { GEMINI_CLIENT_ID, GEMINI_CLIENT_SECRET } from "../constants";
|
|
2
2
|
import { formatRefreshParts, parseRefreshParts } from "./auth";
|
|
3
|
+
import { invalidateProjectContextCache } from "./project";
|
|
3
4
|
import type { OAuthAuthDetails, PluginClient, RefreshParts } from "./types";
|
|
4
5
|
|
|
6
|
+
interface OAuthErrorPayload {
|
|
7
|
+
error?:
|
|
8
|
+
| string
|
|
9
|
+
| {
|
|
10
|
+
code?: string;
|
|
11
|
+
status?: string;
|
|
12
|
+
message?: string;
|
|
13
|
+
};
|
|
14
|
+
error_description?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseOAuthErrorPayload(text: string | undefined): { code?: string; description?: string } {
|
|
18
|
+
if (!text) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const payload = JSON.parse(text) as OAuthErrorPayload;
|
|
24
|
+
if (!payload || typeof payload !== "object") {
|
|
25
|
+
return { description: text };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let code: string | undefined;
|
|
29
|
+
if (typeof payload.error === "string") {
|
|
30
|
+
code = payload.error;
|
|
31
|
+
} else if (payload.error && typeof payload.error === "object") {
|
|
32
|
+
code = payload.error.status ?? payload.error.code;
|
|
33
|
+
if (!payload.error_description && payload.error.message) {
|
|
34
|
+
return { code, description: payload.error.message };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const description = payload.error_description;
|
|
39
|
+
if (description) {
|
|
40
|
+
return { code, description };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (payload.error && typeof payload.error === "object" && payload.error.message) {
|
|
44
|
+
return { code, description: payload.error.message };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { code };
|
|
48
|
+
} catch {
|
|
49
|
+
return { description: text };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
5
53
|
export async function refreshAccessToken(
|
|
6
54
|
auth: OAuthAuthDetails,
|
|
7
55
|
client: PluginClient,
|
|
@@ -26,6 +74,41 @@ export async function refreshAccessToken(
|
|
|
26
74
|
});
|
|
27
75
|
|
|
28
76
|
if (!response.ok) {
|
|
77
|
+
let errorText: string | undefined;
|
|
78
|
+
try {
|
|
79
|
+
errorText = await response.text();
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore body parsing failures; we'll fall back to status only.
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { code, description } = parseOAuthErrorPayload(errorText);
|
|
85
|
+
const details = [code, description ?? errorText].filter(Boolean).join(": ");
|
|
86
|
+
const baseMessage = `Gemini token refresh failed (${response.status} ${response.statusText})`;
|
|
87
|
+
console.warn(`[Gemini OAuth] ${details ? `${baseMessage} - ${details}` : baseMessage}`);
|
|
88
|
+
|
|
89
|
+
if (code === "invalid_grant") {
|
|
90
|
+
console.warn(
|
|
91
|
+
"[Gemini OAuth] Google revoked the stored refresh token. Run `opencode auth login` and reauthenticate the Google provider.",
|
|
92
|
+
);
|
|
93
|
+
invalidateProjectContextCache(auth.refresh);
|
|
94
|
+
try {
|
|
95
|
+
const clearedAuth: OAuthAuthDetails = {
|
|
96
|
+
type: "oauth",
|
|
97
|
+
refresh: formatRefreshParts({
|
|
98
|
+
refreshToken: "",
|
|
99
|
+
projectId: parts.projectId,
|
|
100
|
+
managedProjectId: parts.managedProjectId,
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
await client.auth.set({
|
|
104
|
+
path: { id: "gemini-cli" },
|
|
105
|
+
body: clearedAuth,
|
|
106
|
+
});
|
|
107
|
+
} catch (storeError) {
|
|
108
|
+
console.error("Failed to clear stored Gemini OAuth credentials:", storeError);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
29
112
|
return undefined;
|
|
30
113
|
}
|
|
31
114
|
|
|
@@ -52,10 +135,11 @@ export async function refreshAccessToken(
|
|
|
52
135
|
path: { id: "gemini-cli" },
|
|
53
136
|
body: updatedAuth,
|
|
54
137
|
});
|
|
138
|
+
invalidateProjectContextCache(auth.refresh);
|
|
55
139
|
|
|
56
140
|
return updatedAuth;
|
|
57
141
|
} catch (error) {
|
|
58
|
-
console.error("Failed to refresh Gemini access token:", error);
|
|
142
|
+
console.error("Failed to refresh Gemini access token due to an unexpected error:", error);
|
|
59
143
|
return undefined;
|
|
60
144
|
}
|
|
61
145
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -4,7 +4,11 @@ import type { GeminiTokenExchangeResult } from "./gemini/oauth";
|
|
|
4
4
|
import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
|
|
5
5
|
import { promptProjectId } from "./plugin/cli";
|
|
6
6
|
import { ensureProjectContext } from "./plugin/project";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
isGenerativeLanguageRequest,
|
|
9
|
+
prepareGeminiRequest,
|
|
10
|
+
transformGeminiResponse,
|
|
11
|
+
} from "./plugin/request";
|
|
8
12
|
import { refreshAccessToken } from "./plugin/token";
|
|
9
13
|
import { startOAuthListener, type OAuthListener } from "./plugin/server";
|
|
10
14
|
import type {
|
|
@@ -37,6 +41,10 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
37
41
|
return {
|
|
38
42
|
apiKey: "",
|
|
39
43
|
async fetch(input, init) {
|
|
44
|
+
if (!isGenerativeLanguageRequest(input)) {
|
|
45
|
+
return fetch(input, init);
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
const latestAuth = await getAuth();
|
|
41
49
|
if (!isOAuthAuth(latestAuth)) {
|
|
42
50
|
return fetch(input, init);
|