opencode-gemini-auth 1.0.8 → 1.0.10
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 +39 -0
- package/package.json +1 -1
- package/src/plugin/auth.ts +3 -1
- package/src/plugin/debug.ts +168 -0
- package/src/plugin/project.ts +82 -27
- package/src/plugin/request.ts +47 -19
- package/src/plugin/token.ts +85 -1
- package/src/plugin.ts +34 -2
package/README.md
CHANGED
|
@@ -38,3 +38,42 @@ echo "Plugin update script finished successfully.")
|
|
|
38
38
|
```bash
|
|
39
39
|
opencode # Reinstalls latest
|
|
40
40
|
```
|
|
41
|
+
|
|
42
|
+
## Local Development
|
|
43
|
+
|
|
44
|
+
When you want Opencode to use a local checkout of this plugin, point the
|
|
45
|
+
`plugin` entry in your config to the folder via a `file://` URL:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"$schema": "https://opencode.ai/config.json",
|
|
50
|
+
"plugin": ["file:///absolute/path/to/opencode-gemini-auth"]
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Replace `/absolute/path/to/opencode-gemini-auth` with the absolute path to
|
|
55
|
+
your local clone.
|
|
56
|
+
|
|
57
|
+
## Debugging Gemini Requests
|
|
58
|
+
|
|
59
|
+
Set `OPENCODE_GEMINI_DEBUG=1` in the environment when you run an Opencode
|
|
60
|
+
command to capture every Gemini request/response that this plugin issues. When
|
|
61
|
+
enabled, the plugin writes to a timestamped `gemini-debug-<ISO>.log` file in
|
|
62
|
+
your current working directory so the CLI output stays clean.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
OPENCODE_GEMINI_DEBUG=1 opencode
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The logger shows the transformed URL, HTTP method, sanitized headers (the
|
|
69
|
+
`Authorization` header is redacted), whether the call used streaming, and a
|
|
70
|
+
truncated preview (2 KB) of both the request and response bodies. This is handy
|
|
71
|
+
when diagnosing "Bad Request" responses from Gemini. Remember that payloads may
|
|
72
|
+
still include parts of your prompt or response, so only enable this flag when
|
|
73
|
+
you're comfortable keeping that information in the generated log file.
|
|
74
|
+
|
|
75
|
+
**404s on `gemini-2.5-flash-image`.** Opencode fires internal
|
|
76
|
+
summarization/title requests at `gemini-2.5-flash-image`. The plugin
|
|
77
|
+
automatically remaps those payloads to `gemini-2.5-flash`, eliminating the extra
|
|
78
|
+
404s for accounts without image access. If you still see a 404, confirm your
|
|
79
|
+
project actually has access to the fallback model.
|
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
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { cwd, env } from "node:process";
|
|
4
|
+
|
|
5
|
+
const DEBUG_FLAG = env.OPENCODE_GEMINI_DEBUG ?? "";
|
|
6
|
+
const MAX_BODY_PREVIEW_CHARS = 2000;
|
|
7
|
+
const debugEnabled = DEBUG_FLAG.trim() === "1";
|
|
8
|
+
const logFilePath = debugEnabled ? defaultLogFilePath() : undefined;
|
|
9
|
+
const logWriter = createLogWriter(logFilePath);
|
|
10
|
+
|
|
11
|
+
export interface GeminiDebugContext {
|
|
12
|
+
id: string;
|
|
13
|
+
streaming: boolean;
|
|
14
|
+
startedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GeminiDebugRequestMeta {
|
|
18
|
+
originalUrl: string;
|
|
19
|
+
resolvedUrl: string;
|
|
20
|
+
method?: string;
|
|
21
|
+
headers?: HeadersInit;
|
|
22
|
+
body?: BodyInit | null;
|
|
23
|
+
streaming: boolean;
|
|
24
|
+
projectId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GeminiDebugResponseMeta {
|
|
28
|
+
body?: string;
|
|
29
|
+
note?: string;
|
|
30
|
+
error?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let requestCounter = 0;
|
|
34
|
+
|
|
35
|
+
export function startGeminiDebugRequest(meta: GeminiDebugRequestMeta): GeminiDebugContext | null {
|
|
36
|
+
if (!debugEnabled) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const id = `GEMINI-${++requestCounter}`;
|
|
41
|
+
const method = meta.method ?? "GET";
|
|
42
|
+
logDebug(`[Gemini Debug ${id}] ${method} ${meta.resolvedUrl}`);
|
|
43
|
+
if (meta.originalUrl && meta.originalUrl !== meta.resolvedUrl) {
|
|
44
|
+
logDebug(`[Gemini Debug ${id}] Original URL: ${meta.originalUrl}`);
|
|
45
|
+
}
|
|
46
|
+
if (meta.projectId) {
|
|
47
|
+
logDebug(`[Gemini Debug ${id}] Project: ${meta.projectId}`);
|
|
48
|
+
}
|
|
49
|
+
logDebug(`[Gemini Debug ${id}] Streaming: ${meta.streaming ? "yes" : "no"}`);
|
|
50
|
+
logDebug(`[Gemini Debug ${id}] Headers: ${JSON.stringify(maskHeaders(meta.headers))}`);
|
|
51
|
+
const bodyPreview = formatBodyPreview(meta.body);
|
|
52
|
+
if (bodyPreview) {
|
|
53
|
+
logDebug(`[Gemini Debug ${id}] Body Preview: ${bodyPreview}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { id, streaming: meta.streaming, startedAt: Date.now() };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function logGeminiDebugResponse(
|
|
60
|
+
context: GeminiDebugContext | null | undefined,
|
|
61
|
+
response: Response,
|
|
62
|
+
meta: GeminiDebugResponseMeta = {},
|
|
63
|
+
): void {
|
|
64
|
+
if (!debugEnabled || !context) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const durationMs = Date.now() - context.startedAt;
|
|
69
|
+
logDebug(
|
|
70
|
+
`[Gemini Debug ${context.id}] Response ${response.status} ${response.statusText} (${durationMs}ms)`,
|
|
71
|
+
);
|
|
72
|
+
logDebug(
|
|
73
|
+
`[Gemini Debug ${context.id}] Response Headers: ${JSON.stringify(maskHeaders(response.headers))}`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (meta.note) {
|
|
77
|
+
logDebug(`[Gemini Debug ${context.id}] Note: ${meta.note}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (meta.error) {
|
|
81
|
+
logDebug(`[Gemini Debug ${context.id}] Error: ${formatError(meta.error)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (meta.body) {
|
|
85
|
+
logDebug(
|
|
86
|
+
`[Gemini Debug ${context.id}] Response Body Preview: ${truncateForLog(meta.body)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function maskHeaders(headers?: HeadersInit | Headers): Record<string, string> {
|
|
92
|
+
if (!headers) {
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result: Record<string, string> = {};
|
|
97
|
+
const parsed = headers instanceof Headers ? headers : new Headers(headers);
|
|
98
|
+
parsed.forEach((value, key) => {
|
|
99
|
+
if (key.toLowerCase() === "authorization") {
|
|
100
|
+
result[key] = "[redacted]";
|
|
101
|
+
} else {
|
|
102
|
+
result[key] = value;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatBodyPreview(body?: BodyInit | null): string | undefined {
|
|
109
|
+
if (body == null) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof body === "string") {
|
|
114
|
+
return truncateForLog(body);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (body instanceof URLSearchParams) {
|
|
118
|
+
return truncateForLog(body.toString());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
122
|
+
return `[Blob size=${body.size}]`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
126
|
+
return "[FormData payload omitted]";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return `[${body.constructor?.name ?? typeof body} payload omitted]`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function truncateForLog(text: string): string {
|
|
133
|
+
if (text.length <= MAX_BODY_PREVIEW_CHARS) {
|
|
134
|
+
return text;
|
|
135
|
+
}
|
|
136
|
+
return `${text.slice(0, MAX_BODY_PREVIEW_CHARS)}... (truncated ${text.length - MAX_BODY_PREVIEW_CHARS} chars)`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function logDebug(line: string): void {
|
|
140
|
+
logWriter(line);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatError(error: unknown): string {
|
|
144
|
+
if (error instanceof Error) {
|
|
145
|
+
return error.stack ?? error.message;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
return JSON.stringify(error);
|
|
149
|
+
} catch {
|
|
150
|
+
return String(error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function defaultLogFilePath(): string {
|
|
155
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
156
|
+
return join(cwd(), `gemini-debug-${timestamp}.log`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function createLogWriter(filePath?: string): (line: string) => void {
|
|
160
|
+
if (!filePath) {
|
|
161
|
+
return () => {};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const stream = createWriteStream(filePath, { flags: "a" });
|
|
165
|
+
return (line: string) => {
|
|
166
|
+
stream.write(`${line}\n`);
|
|
167
|
+
};
|
|
168
|
+
}
|
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
|
@@ -2,10 +2,14 @@ import {
|
|
|
2
2
|
CODE_ASSIST_HEADERS,
|
|
3
3
|
GEMINI_CODE_ASSIST_ENDPOINT,
|
|
4
4
|
} from "../constants";
|
|
5
|
+
import { logGeminiDebugResponse, type GeminiDebugContext } from "./debug";
|
|
5
6
|
|
|
6
7
|
const STREAM_ACTION = "streamGenerateContent";
|
|
8
|
+
const MODEL_FALLBACKS: Record<string, string> = {
|
|
9
|
+
"gemini-2.5-flash-image": "gemini-2.5-flash",
|
|
10
|
+
};
|
|
7
11
|
|
|
8
|
-
function isGenerativeLanguageRequest(input: RequestInfo): input is string {
|
|
12
|
+
export function isGenerativeLanguageRequest(input: RequestInfo): input is string {
|
|
9
13
|
return typeof input === "string" && input.includes("generativelanguage.googleapis.com");
|
|
10
14
|
}
|
|
11
15
|
|
|
@@ -60,9 +64,10 @@ export function prepareGeminiRequest(
|
|
|
60
64
|
};
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
const [,
|
|
64
|
-
const
|
|
65
|
-
const
|
|
67
|
+
const [, rawModel = "", rawAction = ""] = match;
|
|
68
|
+
const effectiveModel = MODEL_FALLBACKS[rawModel] ?? rawModel;
|
|
69
|
+
const streaming = rawAction === STREAM_ACTION;
|
|
70
|
+
const transformedUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:${rawAction}${
|
|
66
71
|
streaming ? "?alt=sse" : ""
|
|
67
72
|
}`;
|
|
68
73
|
|
|
@@ -70,24 +75,34 @@ export function prepareGeminiRequest(
|
|
|
70
75
|
if (typeof baseInit.body === "string" && baseInit.body) {
|
|
71
76
|
try {
|
|
72
77
|
const parsedBody = JSON.parse(baseInit.body) as Record<string, unknown>;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
const isWrapped = typeof parsedBody.project === "string" && "request" in parsedBody;
|
|
79
|
+
|
|
80
|
+
if (isWrapped) {
|
|
81
|
+
const wrappedBody = {
|
|
82
|
+
...parsedBody,
|
|
83
|
+
model: effectiveModel,
|
|
84
|
+
} as Record<string, unknown>;
|
|
85
|
+
body = JSON.stringify(wrappedBody);
|
|
86
|
+
} else {
|
|
87
|
+
const requestPayload: Record<string, unknown> = { ...parsedBody };
|
|
88
|
+
|
|
89
|
+
if ("system_instruction" in requestPayload) {
|
|
90
|
+
requestPayload.systemInstruction = requestPayload.system_instruction;
|
|
91
|
+
delete requestPayload.system_instruction;
|
|
92
|
+
}
|
|
79
93
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
if ("model" in requestPayload) {
|
|
95
|
+
delete requestPayload.model;
|
|
96
|
+
}
|
|
83
97
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
const wrappedBody = {
|
|
99
|
+
project: projectId,
|
|
100
|
+
model: effectiveModel,
|
|
101
|
+
request: requestPayload,
|
|
102
|
+
};
|
|
89
103
|
|
|
90
|
-
|
|
104
|
+
body = JSON.stringify(wrappedBody);
|
|
105
|
+
}
|
|
91
106
|
} catch (error) {
|
|
92
107
|
console.error("Failed to transform Gemini request body:", error);
|
|
93
108
|
}
|
|
@@ -115,9 +130,13 @@ export function prepareGeminiRequest(
|
|
|
115
130
|
export async function transformGeminiResponse(
|
|
116
131
|
response: Response,
|
|
117
132
|
streaming: boolean,
|
|
133
|
+
debugContext?: GeminiDebugContext | null,
|
|
118
134
|
): Promise<Response> {
|
|
119
135
|
const contentType = response.headers.get("content-type") ?? "";
|
|
120
136
|
if (!streaming && !contentType.includes("application/json")) {
|
|
137
|
+
logGeminiDebugResponse(debugContext, response, {
|
|
138
|
+
note: "Non-JSON response (body omitted)",
|
|
139
|
+
});
|
|
121
140
|
return response;
|
|
122
141
|
}
|
|
123
142
|
|
|
@@ -130,6 +149,11 @@ export async function transformGeminiResponse(
|
|
|
130
149
|
headers,
|
|
131
150
|
};
|
|
132
151
|
|
|
152
|
+
logGeminiDebugResponse(debugContext, response, {
|
|
153
|
+
body: text,
|
|
154
|
+
note: streaming ? "Streaming SSE payload" : undefined,
|
|
155
|
+
});
|
|
156
|
+
|
|
133
157
|
if (streaming) {
|
|
134
158
|
return new Response(transformStreamingPayload(text), init);
|
|
135
159
|
}
|
|
@@ -141,6 +165,10 @@ export async function transformGeminiResponse(
|
|
|
141
165
|
|
|
142
166
|
return new Response(text, init);
|
|
143
167
|
} catch (error) {
|
|
168
|
+
logGeminiDebugResponse(debugContext, response, {
|
|
169
|
+
error,
|
|
170
|
+
note: "Failed to transform Gemini response",
|
|
171
|
+
});
|
|
144
172
|
console.error("Failed to transform Gemini response:", error);
|
|
145
173
|
return response;
|
|
146
174
|
}
|
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,12 @@ 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 { startGeminiDebugRequest } from "./plugin/debug";
|
|
8
|
+
import {
|
|
9
|
+
isGenerativeLanguageRequest,
|
|
10
|
+
prepareGeminiRequest,
|
|
11
|
+
transformGeminiResponse,
|
|
12
|
+
} from "./plugin/request";
|
|
8
13
|
import { refreshAccessToken } from "./plugin/token";
|
|
9
14
|
import { startOAuthListener, type OAuthListener } from "./plugin/server";
|
|
10
15
|
import type {
|
|
@@ -37,6 +42,10 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
37
42
|
return {
|
|
38
43
|
apiKey: "",
|
|
39
44
|
async fetch(input, init) {
|
|
45
|
+
if (!isGenerativeLanguageRequest(input)) {
|
|
46
|
+
return fetch(input, init);
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
const latestAuth = await getAuth();
|
|
41
50
|
if (!isOAuthAuth(latestAuth)) {
|
|
42
51
|
return fetch(input, init);
|
|
@@ -65,8 +74,20 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
65
74
|
projectContext.effectiveProjectId,
|
|
66
75
|
);
|
|
67
76
|
|
|
77
|
+
const originalUrl = toUrlString(input);
|
|
78
|
+
const resolvedUrl = toUrlString(request);
|
|
79
|
+
const debugContext = startGeminiDebugRequest({
|
|
80
|
+
originalUrl,
|
|
81
|
+
resolvedUrl,
|
|
82
|
+
method: transformedInit.method,
|
|
83
|
+
headers: transformedInit.headers,
|
|
84
|
+
body: transformedInit.body,
|
|
85
|
+
streaming,
|
|
86
|
+
projectId: projectContext.effectiveProjectId,
|
|
87
|
+
});
|
|
88
|
+
|
|
68
89
|
const response = await fetch(request, transformedInit);
|
|
69
|
-
return transformGeminiResponse(response, streaming);
|
|
90
|
+
return transformGeminiResponse(response, streaming, debugContext);
|
|
70
91
|
},
|
|
71
92
|
};
|
|
72
93
|
},
|
|
@@ -181,3 +202,14 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
181
202
|
});
|
|
182
203
|
|
|
183
204
|
export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
|
|
205
|
+
|
|
206
|
+
function toUrlString(value: RequestInfo): string {
|
|
207
|
+
if (typeof value === "string") {
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
const candidate = (value as Request).url;
|
|
211
|
+
if (candidate) {
|
|
212
|
+
return candidate;
|
|
213
|
+
}
|
|
214
|
+
return value.toString();
|
|
215
|
+
}
|