opencode-gemini-auth 1.3.10 → 1.4.0
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 +38 -0
- package/package.json +5 -2
- package/src/plugin/auth.test.ts +58 -0
- package/src/plugin/oauth-authorize.ts +198 -0
- package/src/plugin/project/api.ts +202 -0
- package/src/plugin/project/context.ts +187 -0
- package/src/plugin/project/index.ts +6 -0
- package/src/plugin/project/types.ts +67 -0
- package/src/plugin/project/utils.ts +120 -0
- package/src/plugin/quota.test.ts +62 -0
- package/src/plugin/quota.ts +182 -0
- package/src/plugin/request/identifiers.ts +100 -0
- package/src/plugin/request/index.ts +3 -0
- package/src/plugin/request/openai.ts +128 -0
- package/src/plugin/request/prepare.ts +190 -0
- package/src/plugin/request/response.ts +191 -0
- package/src/plugin/request/shared.ts +72 -0
- package/src/plugin/{request-helpers.ts → request-helpers/errors.ts} +34 -213
- package/src/plugin/request-helpers/index.ts +12 -0
- package/src/plugin/request-helpers/parsing.ts +44 -0
- package/src/plugin/request-helpers/thinking.ts +36 -0
- package/src/plugin/request-helpers/types.ts +78 -0
- package/src/plugin/request-helpers.test.ts +84 -0
- package/src/plugin/request.test.ts +91 -0
- package/src/plugin/retry/helpers.ts +175 -0
- package/src/plugin/retry/index.ts +81 -0
- package/src/plugin/retry/quota.ts +210 -0
- package/src/plugin/retry.test.ts +106 -0
- package/src/plugin/token.test.ts +31 -0
- package/src/plugin/token.ts +24 -0
- package/src/plugin/types.ts +4 -0
- package/src/plugin.ts +130 -587
- package/src/plugin/project.ts +0 -551
- package/src/plugin/request.ts +0 -483
package/src/plugin.ts
CHANGED
|
@@ -1,34 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
|
|
4
|
-
import {
|
|
5
|
-
authorizeGemini,
|
|
6
|
-
exchangeGeminiWithVerifier,
|
|
7
|
-
} from "./gemini/oauth";
|
|
8
|
-
import type { GeminiTokenExchangeResult } from "./gemini/oauth";
|
|
1
|
+
import { GEMINI_PROVIDER_ID } from "./constants";
|
|
2
|
+
import { createOAuthAuthorizeMethod } from "./plugin/oauth-authorize";
|
|
9
3
|
import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
|
|
4
|
+
import { resolveCachedAuth } from "./plugin/cache";
|
|
5
|
+
import { ensureProjectContext, retrieveUserQuota } from "./plugin/project";
|
|
10
6
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from "./plugin/
|
|
7
|
+
createGeminiQuotaTool,
|
|
8
|
+
GEMINI_QUOTA_TOOL_NAME,
|
|
9
|
+
} from "./plugin/quota";
|
|
14
10
|
import { isGeminiDebugEnabled, logGeminiDebugMessage, startGeminiDebugRequest } from "./plugin/debug";
|
|
15
11
|
import {
|
|
16
12
|
isGenerativeLanguageRequest,
|
|
17
13
|
prepareGeminiRequest,
|
|
18
14
|
transformGeminiResponse,
|
|
19
15
|
} from "./plugin/request";
|
|
16
|
+
import { fetchWithRetry } from "./plugin/retry";
|
|
20
17
|
import { refreshAccessToken } from "./plugin/token";
|
|
21
|
-
import { startOAuthListener, type OAuthListener } from "./plugin/server";
|
|
22
18
|
import type {
|
|
23
19
|
GetAuth,
|
|
24
20
|
LoaderResult,
|
|
25
21
|
OAuthAuthDetails,
|
|
22
|
+
PluginClient,
|
|
26
23
|
PluginContext,
|
|
27
24
|
PluginResult,
|
|
28
|
-
ProjectContextResult,
|
|
29
25
|
Provider,
|
|
30
26
|
} from "./plugin/types";
|
|
31
27
|
|
|
28
|
+
const GEMINI_QUOTA_COMMAND = "gquota";
|
|
29
|
+
const GEMINI_QUOTA_COMMAND_TEMPLATE = `Retrieve Gemini Code Assist quota usage for the current authenticated account.
|
|
30
|
+
|
|
31
|
+
Immediately call \`${GEMINI_QUOTA_TOOL_NAME}\` with no arguments and return its output verbatim.
|
|
32
|
+
Do not call other tools.
|
|
33
|
+
`;
|
|
34
|
+
let latestGeminiAuthResolver: GetAuth | undefined;
|
|
35
|
+
let latestGeminiConfiguredProjectId: string | undefined;
|
|
36
|
+
|
|
32
37
|
/**
|
|
33
38
|
* Registers the Gemini OAuth provider for Opencode, handling auth, request rewriting,
|
|
34
39
|
* debug logging, and response normalization for Gemini Code Assist endpoints.
|
|
@@ -36,37 +41,32 @@ import type {
|
|
|
36
41
|
export const GeminiCLIOAuthPlugin = async (
|
|
37
42
|
{ client }: PluginContext,
|
|
38
43
|
): Promise<PluginResult> => ({
|
|
44
|
+
config: async (config) => {
|
|
45
|
+
config.command = config.command || {};
|
|
46
|
+
config.command[GEMINI_QUOTA_COMMAND] = {
|
|
47
|
+
description: "Show Gemini Code Assist quota usage",
|
|
48
|
+
template: GEMINI_QUOTA_COMMAND_TEMPLATE,
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
tool: {
|
|
52
|
+
[GEMINI_QUOTA_TOOL_NAME]: createGeminiQuotaTool({
|
|
53
|
+
client,
|
|
54
|
+
getAuthResolver: () => latestGeminiAuthResolver,
|
|
55
|
+
getConfiguredProjectId: () => latestGeminiConfiguredProjectId,
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
39
58
|
auth: {
|
|
40
59
|
provider: GEMINI_PROVIDER_ID,
|
|
41
60
|
loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
|
|
61
|
+
latestGeminiAuthResolver = getAuth;
|
|
42
62
|
const auth = await getAuth();
|
|
43
63
|
if (!isOAuthAuth(auth)) {
|
|
44
64
|
return null;
|
|
45
65
|
}
|
|
46
66
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
: undefined;
|
|
51
|
-
const projectIdFromConfig =
|
|
52
|
-
providerOptions && typeof providerOptions.projectId === "string"
|
|
53
|
-
? providerOptions.projectId.trim()
|
|
54
|
-
: "";
|
|
55
|
-
const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
|
|
56
|
-
const googleProjectIdFromEnv =
|
|
57
|
-
process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
|
|
58
|
-
process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
|
|
59
|
-
"";
|
|
60
|
-
const configuredProjectId =
|
|
61
|
-
projectIdFromEnv || projectIdFromConfig || googleProjectIdFromEnv || undefined;
|
|
62
|
-
|
|
63
|
-
if (provider.models) {
|
|
64
|
-
for (const model of Object.values(provider.models)) {
|
|
65
|
-
if (model) {
|
|
66
|
-
model.cost = { input: 0, output: 0 };
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
67
|
+
const configuredProjectId = resolveConfiguredProjectId(provider);
|
|
68
|
+
latestGeminiConfiguredProjectId = configuredProjectId;
|
|
69
|
+
normalizeProviderModelCosts(provider);
|
|
70
70
|
|
|
71
71
|
return {
|
|
72
72
|
apiKey: "",
|
|
@@ -80,7 +80,7 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
80
80
|
return fetch(input, init);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
let authRecord = latestAuth;
|
|
83
|
+
let authRecord = resolveCachedAuth(latestAuth);
|
|
84
84
|
if (accessTokenExpired(authRecord)) {
|
|
85
85
|
const refreshed = await refreshAccessToken(authRecord, client);
|
|
86
86
|
if (!refreshed) {
|
|
@@ -89,53 +89,46 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
89
89
|
authRecord = refreshed;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
if (!accessToken) {
|
|
92
|
+
if (!authRecord.access) {
|
|
94
93
|
return fetch(input, init);
|
|
95
94
|
}
|
|
96
95
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const projectContext = await resolveProjectContext();
|
|
112
|
-
|
|
113
|
-
const {
|
|
114
|
-
request,
|
|
115
|
-
init: transformedInit,
|
|
116
|
-
streaming,
|
|
117
|
-
requestedModel,
|
|
118
|
-
} = prepareGeminiRequest(
|
|
96
|
+
const projectContext = await ensureProjectContextOrThrow(
|
|
97
|
+
authRecord,
|
|
98
|
+
client,
|
|
99
|
+
configuredProjectId,
|
|
100
|
+
);
|
|
101
|
+
await maybeLogAvailableQuotaModels(
|
|
102
|
+
authRecord.access,
|
|
103
|
+
projectContext.effectiveProjectId,
|
|
104
|
+
);
|
|
105
|
+
const transformed = prepareGeminiRequest(
|
|
119
106
|
input,
|
|
120
107
|
init,
|
|
121
|
-
|
|
108
|
+
authRecord.access,
|
|
122
109
|
projectContext.effectiveProjectId,
|
|
123
110
|
);
|
|
124
|
-
|
|
125
|
-
const originalUrl = toUrlString(input);
|
|
126
|
-
const resolvedUrl = toUrlString(request);
|
|
127
111
|
const debugContext = startGeminiDebugRequest({
|
|
128
|
-
originalUrl,
|
|
129
|
-
resolvedUrl,
|
|
130
|
-
method:
|
|
131
|
-
headers:
|
|
132
|
-
body:
|
|
133
|
-
streaming,
|
|
112
|
+
originalUrl: toUrlString(input),
|
|
113
|
+
resolvedUrl: toUrlString(transformed.request),
|
|
114
|
+
method: transformed.init.method,
|
|
115
|
+
headers: transformed.init.headers,
|
|
116
|
+
body: transformed.init.body,
|
|
117
|
+
streaming: transformed.streaming,
|
|
134
118
|
projectId: projectContext.effectiveProjectId,
|
|
135
119
|
});
|
|
136
120
|
|
|
137
|
-
|
|
138
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Retry transport/429 failures while preserving the requested model.
|
|
123
|
+
* We intentionally do not auto-downgrade model tiers to avoid misleading users.
|
|
124
|
+
*/
|
|
125
|
+
const response = await fetchWithRetry(transformed.request, transformed.init);
|
|
126
|
+
return transformGeminiResponse(
|
|
127
|
+
response,
|
|
128
|
+
transformed.streaming,
|
|
129
|
+
debugContext,
|
|
130
|
+
transformed.requestedModel,
|
|
131
|
+
);
|
|
139
132
|
},
|
|
140
133
|
};
|
|
141
134
|
},
|
|
@@ -143,170 +136,7 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
143
136
|
{
|
|
144
137
|
label: "OAuth with Google (Gemini CLI)",
|
|
145
138
|
type: "oauth",
|
|
146
|
-
authorize:
|
|
147
|
-
const maybeHydrateProjectId = async (
|
|
148
|
-
result: GeminiTokenExchangeResult,
|
|
149
|
-
): Promise<GeminiTokenExchangeResult> => {
|
|
150
|
-
if (result.type !== "success") {
|
|
151
|
-
return result;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const accessToken = result.access;
|
|
155
|
-
if (!accessToken) {
|
|
156
|
-
return result;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const projectFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
|
|
160
|
-
const googleProjectFromEnv =
|
|
161
|
-
process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
|
|
162
|
-
process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
|
|
163
|
-
"";
|
|
164
|
-
const configuredProjectId =
|
|
165
|
-
projectFromEnv || googleProjectFromEnv || undefined;
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
const authSnapshot = {
|
|
169
|
-
type: "oauth",
|
|
170
|
-
refresh: result.refresh,
|
|
171
|
-
access: result.access,
|
|
172
|
-
expires: result.expires,
|
|
173
|
-
} satisfies OAuthAuthDetails;
|
|
174
|
-
const projectContext = await resolveProjectContextFromAccessToken(
|
|
175
|
-
authSnapshot,
|
|
176
|
-
accessToken,
|
|
177
|
-
configuredProjectId,
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
if (projectContext.auth.refresh !== result.refresh) {
|
|
181
|
-
if (isGeminiDebugEnabled()) {
|
|
182
|
-
logGeminiDebugMessage(
|
|
183
|
-
`OAuth project resolved during auth: ${projectContext.effectiveProjectId || "none"}`,
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
return { ...result, refresh: projectContext.auth.refresh };
|
|
187
|
-
}
|
|
188
|
-
} catch (error) {
|
|
189
|
-
if (isGeminiDebugEnabled()) {
|
|
190
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
191
|
-
console.warn(`[Gemini OAuth] Project resolution skipped: ${message}`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return result;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
const isHeadless = !!(
|
|
199
|
-
process.env.SSH_CONNECTION ||
|
|
200
|
-
process.env.SSH_CLIENT ||
|
|
201
|
-
process.env.SSH_TTY ||
|
|
202
|
-
process.env.OPENCODE_HEADLESS
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
let listener: OAuthListener | null = null;
|
|
206
|
-
if (!isHeadless) {
|
|
207
|
-
try {
|
|
208
|
-
listener = await startOAuthListener();
|
|
209
|
-
} catch (error) {
|
|
210
|
-
if (error instanceof Error) {
|
|
211
|
-
console.log(
|
|
212
|
-
`Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL or authorization code.`,
|
|
213
|
-
);
|
|
214
|
-
} else {
|
|
215
|
-
console.log(
|
|
216
|
-
"Warning: Couldn't start the local callback listener. You'll need to paste the callback URL or authorization code.",
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
} else {
|
|
221
|
-
console.log(
|
|
222
|
-
"Headless environment detected. You'll need to paste the callback URL or authorization code.",
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const authorization = await authorizeGemini();
|
|
227
|
-
if (!isHeadless) {
|
|
228
|
-
openBrowserUrl(authorization.url);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (listener) {
|
|
232
|
-
return {
|
|
233
|
-
url: authorization.url,
|
|
234
|
-
instructions:
|
|
235
|
-
"Complete the sign-in flow in your browser. We'll automatically detect the redirect back to localhost.",
|
|
236
|
-
method: "auto",
|
|
237
|
-
callback: async (): Promise<GeminiTokenExchangeResult> => {
|
|
238
|
-
try {
|
|
239
|
-
const callbackUrl = await listener.waitForCallback();
|
|
240
|
-
const code = callbackUrl.searchParams.get("code");
|
|
241
|
-
const state = callbackUrl.searchParams.get("state");
|
|
242
|
-
|
|
243
|
-
if (!code || !state) {
|
|
244
|
-
return {
|
|
245
|
-
type: "failed",
|
|
246
|
-
error: "Missing code or state in callback URL",
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (state !== authorization.state) {
|
|
251
|
-
return {
|
|
252
|
-
type: "failed",
|
|
253
|
-
error: "State mismatch in callback URL (possible CSRF attempt)",
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return await maybeHydrateProjectId(
|
|
258
|
-
await exchangeGeminiWithVerifier(code, authorization.verifier),
|
|
259
|
-
);
|
|
260
|
-
} catch (error) {
|
|
261
|
-
return {
|
|
262
|
-
type: "failed",
|
|
263
|
-
error: error instanceof Error ? error.message : "Unknown error",
|
|
264
|
-
};
|
|
265
|
-
} finally {
|
|
266
|
-
try {
|
|
267
|
-
await listener?.close();
|
|
268
|
-
} catch {
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
},
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return {
|
|
276
|
-
url: authorization.url,
|
|
277
|
-
instructions:
|
|
278
|
-
"Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...) or just the authorization code.",
|
|
279
|
-
method: "code",
|
|
280
|
-
callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
|
|
281
|
-
try {
|
|
282
|
-
const { code, state } = parseOAuthCallbackInput(callbackUrl);
|
|
283
|
-
|
|
284
|
-
if (!code) {
|
|
285
|
-
return {
|
|
286
|
-
type: "failed",
|
|
287
|
-
error: "Missing authorization code in callback input",
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (state && state !== authorization.state) {
|
|
292
|
-
return {
|
|
293
|
-
type: "failed",
|
|
294
|
-
error: "State mismatch in callback input (possible CSRF attempt)",
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return await maybeHydrateProjectId(
|
|
299
|
-
await exchangeGeminiWithVerifier(code, authorization.verifier),
|
|
300
|
-
);
|
|
301
|
-
} catch (error) {
|
|
302
|
-
return {
|
|
303
|
-
type: "failed",
|
|
304
|
-
error: error instanceof Error ? error.message : "Unknown error",
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
},
|
|
308
|
-
};
|
|
309
|
-
},
|
|
139
|
+
authorize: createOAuthAuthorizeMethod(),
|
|
310
140
|
},
|
|
311
141
|
{
|
|
312
142
|
provider: GEMINI_PROVIDER_ID,
|
|
@@ -318,382 +148,95 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
318
148
|
});
|
|
319
149
|
|
|
320
150
|
export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
return value.toString();
|
|
151
|
+
const loggedQuotaModelsByProject = new Set<string>();
|
|
152
|
+
|
|
153
|
+
function resolveConfiguredProjectId(provider: Provider): string | undefined {
|
|
154
|
+
const providerOptions =
|
|
155
|
+
provider && typeof provider === "object"
|
|
156
|
+
? ((provider as { options?: Record<string, unknown> }).options ?? undefined)
|
|
157
|
+
: undefined;
|
|
158
|
+
const projectIdFromConfig =
|
|
159
|
+
providerOptions && typeof providerOptions.projectId === "string"
|
|
160
|
+
? providerOptions.projectId.trim()
|
|
161
|
+
: "";
|
|
162
|
+
const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
|
|
163
|
+
const googleProjectIdFromEnv =
|
|
164
|
+
process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
|
|
165
|
+
process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
|
|
166
|
+
"";
|
|
167
|
+
|
|
168
|
+
return projectIdFromEnv || projectIdFromConfig || googleProjectIdFromEnv || undefined;
|
|
341
169
|
}
|
|
342
170
|
|
|
343
|
-
function
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
return {};
|
|
171
|
+
function normalizeProviderModelCosts(provider: Provider): void {
|
|
172
|
+
if (!provider.models) {
|
|
173
|
+
return;
|
|
347
174
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const url = new URL(trimmed);
|
|
352
|
-
return {
|
|
353
|
-
code: url.searchParams.get("code") || undefined,
|
|
354
|
-
state: url.searchParams.get("state") || undefined,
|
|
355
|
-
};
|
|
356
|
-
} catch {
|
|
357
|
-
return {};
|
|
175
|
+
for (const model of Object.values(provider.models)) {
|
|
176
|
+
if (model) {
|
|
177
|
+
model.cost = { input: 0, output: 0 };
|
|
358
178
|
}
|
|
359
179
|
}
|
|
360
|
-
|
|
361
|
-
const candidate = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed;
|
|
362
|
-
if (candidate.includes("=")) {
|
|
363
|
-
const params = new URLSearchParams(candidate);
|
|
364
|
-
const code = params.get("code") || undefined;
|
|
365
|
-
const state = params.get("state") || undefined;
|
|
366
|
-
if (code || state) {
|
|
367
|
-
return { code, state };
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return { code: trimmed };
|
|
372
180
|
}
|
|
373
181
|
|
|
374
|
-
function
|
|
182
|
+
async function ensureProjectContextOrThrow(
|
|
183
|
+
authRecord: OAuthAuthDetails,
|
|
184
|
+
client: PluginClient,
|
|
185
|
+
configuredProjectId?: string,
|
|
186
|
+
) {
|
|
375
187
|
try {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
? "open"
|
|
381
|
-
: platform === "win32"
|
|
382
|
-
? "rundll32"
|
|
383
|
-
: "xdg-open";
|
|
384
|
-
const args =
|
|
385
|
-
platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
386
|
-
const child = spawn(command, args, {
|
|
387
|
-
stdio: "ignore",
|
|
388
|
-
detached: true,
|
|
389
|
-
});
|
|
390
|
-
child.unref?.();
|
|
391
|
-
} catch {
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Sends requests with bounded retry logic for transient Cloud Code failures.
|
|
397
|
-
* Mirrors the Gemini CLI handling of Code Assist rate-limit signals.
|
|
398
|
-
*/
|
|
399
|
-
async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined): Promise<Response> {
|
|
400
|
-
const maxRetries = DEFAULT_MAX_RETRIES;
|
|
401
|
-
const baseDelayMs = DEFAULT_BASE_DELAY_MS;
|
|
402
|
-
const maxDelayMs = DEFAULT_MAX_DELAY_MS;
|
|
403
|
-
|
|
404
|
-
if (!canRetryRequest(init)) {
|
|
405
|
-
return fetch(input, init);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
let attempt = 0;
|
|
409
|
-
while (true) {
|
|
410
|
-
const response = await fetch(input, init);
|
|
411
|
-
if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt >= maxRetries) {
|
|
412
|
-
return response;
|
|
188
|
+
return await ensureProjectContext(authRecord, client, configuredProjectId);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof Error) {
|
|
191
|
+
console.error(error.message);
|
|
413
192
|
}
|
|
414
|
-
|
|
415
|
-
let retryDelayMs: number | null = null;
|
|
416
|
-
if (response.status === 429) {
|
|
417
|
-
const quotaContext = await classifyQuotaResponse(response);
|
|
418
|
-
if (quotaContext?.terminal) {
|
|
419
|
-
return response;
|
|
420
|
-
}
|
|
421
|
-
retryDelayMs = quotaContext?.retryDelayMs ?? null;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
const delayMs = await getRetryDelayMs(
|
|
425
|
-
response,
|
|
426
|
-
attempt,
|
|
427
|
-
baseDelayMs,
|
|
428
|
-
maxDelayMs,
|
|
429
|
-
retryDelayMs,
|
|
430
|
-
);
|
|
431
|
-
if (!delayMs || delayMs <= 0) {
|
|
432
|
-
return response;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (init?.signal?.aborted) {
|
|
436
|
-
return response;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
await wait(delayMs);
|
|
440
|
-
attempt += 1;
|
|
193
|
+
throw error;
|
|
441
194
|
}
|
|
442
195
|
}
|
|
443
196
|
|
|
444
|
-
function
|
|
445
|
-
if (!init?.body) {
|
|
446
|
-
return true;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const body = init.body;
|
|
450
|
-
if (typeof body === "string") {
|
|
451
|
-
return true;
|
|
452
|
-
}
|
|
453
|
-
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
|
|
454
|
-
return true;
|
|
455
|
-
}
|
|
456
|
-
if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
|
|
457
|
-
return true;
|
|
458
|
-
}
|
|
459
|
-
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body)) {
|
|
460
|
-
return true;
|
|
461
|
-
}
|
|
462
|
-
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
463
|
-
return true;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
return false;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Resolves a retry delay from headers, body hints, or exponential backoff.
|
|
471
|
-
* Honors RetryInfo/Retry-After hints emitted by Code Assist.
|
|
472
|
-
*/
|
|
473
|
-
async function getRetryDelayMs(
|
|
474
|
-
response: Response,
|
|
475
|
-
attempt: number,
|
|
476
|
-
baseDelayMs: number,
|
|
477
|
-
maxDelayMs: number,
|
|
478
|
-
bodyDelayMs: number | null = null,
|
|
479
|
-
): Promise<number | null> {
|
|
480
|
-
const headerDelayMs = parseRetryAfterMs(response.headers.get("retry-after-ms"));
|
|
481
|
-
if (headerDelayMs !== null) {
|
|
482
|
-
return clampDelay(headerDelayMs, maxDelayMs);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
486
|
-
if (retryAfter !== null) {
|
|
487
|
-
return clampDelay(retryAfter, maxDelayMs);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const parsedBodyDelayMs = bodyDelayMs ?? (await parseRetryDelayFromBody(response));
|
|
491
|
-
if (parsedBodyDelayMs !== null) {
|
|
492
|
-
return clampDelay(parsedBodyDelayMs, maxDelayMs);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
const fallback = baseDelayMs * Math.pow(2, attempt);
|
|
496
|
-
return clampDelay(fallback, maxDelayMs);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function clampDelay(delayMs: number, maxDelayMs: number): number {
|
|
500
|
-
if (!Number.isFinite(delayMs)) {
|
|
501
|
-
return maxDelayMs;
|
|
502
|
-
}
|
|
503
|
-
return Math.min(Math.max(0, delayMs), maxDelayMs);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function parseRetryAfterMs(value: string | null): number | null {
|
|
507
|
-
if (!value) {
|
|
508
|
-
return null;
|
|
509
|
-
}
|
|
510
|
-
const parsed = Number(value);
|
|
511
|
-
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
512
|
-
return null;
|
|
513
|
-
}
|
|
514
|
-
return parsed;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function parseRetryAfter(value: string | null): number | null {
|
|
518
|
-
if (!value) {
|
|
519
|
-
return null;
|
|
520
|
-
}
|
|
521
|
-
const trimmed = value.trim();
|
|
522
|
-
if (!trimmed) {
|
|
523
|
-
return null;
|
|
524
|
-
}
|
|
525
|
-
const asNumber = Number(trimmed);
|
|
526
|
-
if (Number.isFinite(asNumber)) {
|
|
527
|
-
return Math.max(0, Math.round(asNumber * 1000));
|
|
528
|
-
}
|
|
529
|
-
const asDate = Date.parse(trimmed);
|
|
530
|
-
if (!Number.isNaN(asDate)) {
|
|
531
|
-
return Math.max(0, asDate - Date.now());
|
|
532
|
-
}
|
|
533
|
-
return null;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
async function parseRetryDelayFromBody(response: Response): Promise<number | null> {
|
|
537
|
-
let text = "";
|
|
538
|
-
try {
|
|
539
|
-
text = await response.clone().text();
|
|
540
|
-
} catch {
|
|
541
|
-
return null;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (!text) {
|
|
545
|
-
return null;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
let parsed: any;
|
|
549
|
-
try {
|
|
550
|
-
parsed = JSON.parse(text);
|
|
551
|
-
} catch {
|
|
552
|
-
return null;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
const details = parsed?.error?.details;
|
|
556
|
-
if (!Array.isArray(details)) {
|
|
557
|
-
const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
|
|
558
|
-
return parseRetryDelayFromMessage(message);
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
for (const detail of details) {
|
|
562
|
-
if (!detail || typeof detail !== "object") {
|
|
563
|
-
continue;
|
|
564
|
-
}
|
|
565
|
-
const retryDelay = (detail as Record<string, unknown>).retryDelay;
|
|
566
|
-
if (!retryDelay) {
|
|
567
|
-
continue;
|
|
568
|
-
}
|
|
569
|
-
const delayMs = parseRetryDelayValue(retryDelay);
|
|
570
|
-
if (delayMs !== null) {
|
|
571
|
-
return delayMs;
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
|
|
576
|
-
return parseRetryDelayFromMessage(message);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function parseRetryDelayValue(value: unknown): number | null {
|
|
580
|
-
if (!value) {
|
|
581
|
-
return null;
|
|
582
|
-
}
|
|
583
|
-
|
|
197
|
+
function toUrlString(value: RequestInfo): string {
|
|
584
198
|
if (typeof value === "string") {
|
|
585
|
-
|
|
586
|
-
if (!match || !match[1]) {
|
|
587
|
-
return null;
|
|
588
|
-
}
|
|
589
|
-
const seconds = Number(match[1]);
|
|
590
|
-
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
591
|
-
return null;
|
|
592
|
-
}
|
|
593
|
-
return Math.round(seconds * 1000);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
if (typeof value === "object") {
|
|
597
|
-
const record = value as Record<string, unknown>;
|
|
598
|
-
const seconds = typeof record.seconds === "number" ? record.seconds : 0;
|
|
599
|
-
const nanos = typeof record.nanos === "number" ? record.nanos : 0;
|
|
600
|
-
if (!Number.isFinite(seconds) || !Number.isFinite(nanos)) {
|
|
601
|
-
return null;
|
|
602
|
-
}
|
|
603
|
-
const totalMs = Math.round(seconds * 1000 + nanos / 1e6);
|
|
604
|
-
return totalMs > 0 ? totalMs : null;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
return null;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* Parses retry delays embedded in error message strings (e.g., "Please retry in 5s").
|
|
612
|
-
*/
|
|
613
|
-
function parseRetryDelayFromMessage(message: string): number | null {
|
|
614
|
-
if (!message) {
|
|
615
|
-
return null;
|
|
616
|
-
}
|
|
617
|
-
const retryMatch = message.match(/Please retry in ([0-9.]+(?:ms|s))/);
|
|
618
|
-
if (retryMatch?.[1]) {
|
|
619
|
-
return parseRetryDelayValue(retryMatch[1]);
|
|
199
|
+
return value;
|
|
620
200
|
}
|
|
621
|
-
const
|
|
622
|
-
if (
|
|
623
|
-
return
|
|
201
|
+
const candidate = (value as Request).url;
|
|
202
|
+
if (candidate) {
|
|
203
|
+
return candidate;
|
|
624
204
|
}
|
|
625
|
-
return
|
|
205
|
+
return value.toString();
|
|
626
206
|
}
|
|
627
207
|
|
|
628
208
|
/**
|
|
629
|
-
*
|
|
630
|
-
*
|
|
209
|
+
* Debug-only, best-effort model visibility log from Code Assist quota buckets.
|
|
210
|
+
*
|
|
211
|
+
* Why: it gives a concrete backend-side list of model IDs currently visible to the
|
|
212
|
+
* current account/project, which helps explain 404/notFound model failures quickly.
|
|
631
213
|
*/
|
|
632
|
-
async function
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
} catch {
|
|
639
|
-
return null;
|
|
214
|
+
async function maybeLogAvailableQuotaModels(
|
|
215
|
+
accessToken: string,
|
|
216
|
+
projectId: string,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
if (!isGeminiDebugEnabled() || !projectId) {
|
|
219
|
+
return;
|
|
640
220
|
}
|
|
641
221
|
|
|
642
|
-
if (
|
|
643
|
-
return
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
let parsed: any;
|
|
647
|
-
try {
|
|
648
|
-
parsed = JSON.parse(text);
|
|
649
|
-
} catch {
|
|
650
|
-
return null;
|
|
222
|
+
if (loggedQuotaModelsByProject.has(projectId)) {
|
|
223
|
+
return;
|
|
651
224
|
}
|
|
225
|
+
loggedQuotaModelsByProject.add(projectId);
|
|
652
226
|
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
const errorInfo = details.find(
|
|
658
|
-
(detail: any) =>
|
|
659
|
-
detail &&
|
|
660
|
-
typeof detail === "object" &&
|
|
661
|
-
detail["@type"] === "type.googleapis.com/google.rpc.ErrorInfo",
|
|
662
|
-
);
|
|
663
|
-
|
|
664
|
-
if (errorInfo?.domain && !CLOUDCODE_DOMAINS.includes(errorInfo.domain)) {
|
|
665
|
-
return null;
|
|
227
|
+
const quota = await retrieveUserQuota(accessToken, projectId);
|
|
228
|
+
if (!quota?.buckets) {
|
|
229
|
+
logGeminiDebugMessage(`Code Assist quota model lookup returned no buckets for project: ${projectId}`);
|
|
230
|
+
return;
|
|
666
231
|
}
|
|
667
232
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
return { terminal: false, retryDelayMs };
|
|
233
|
+
const modelIds = [...new Set(quota.buckets.map((bucket) => bucket.modelId).filter(Boolean))];
|
|
234
|
+
if (modelIds.length === 0) {
|
|
235
|
+
logGeminiDebugMessage(`Code Assist quota buckets contained no model IDs for project: ${projectId}`);
|
|
236
|
+
return;
|
|
673
237
|
}
|
|
674
238
|
|
|
675
|
-
|
|
676
|
-
(
|
|
677
|
-
detail &&
|
|
678
|
-
typeof detail === "object" &&
|
|
679
|
-
detail["@type"] === "type.googleapis.com/google.rpc.QuotaFailure",
|
|
239
|
+
logGeminiDebugMessage(
|
|
240
|
+
`Code Assist models visible via quota buckets (${projectId}): ${modelIds.join(", ")}`,
|
|
680
241
|
);
|
|
681
|
-
|
|
682
|
-
if (quotaFailure?.violations && Array.isArray(quotaFailure.violations)) {
|
|
683
|
-
const combined = quotaFailure.violations
|
|
684
|
-
.map((violation: any) => String(violation?.description ?? "").toLowerCase())
|
|
685
|
-
.join(" ");
|
|
686
|
-
if (combined.includes("daily") || combined.includes("per day")) {
|
|
687
|
-
return { terminal: true, retryDelayMs };
|
|
688
|
-
}
|
|
689
|
-
return { terminal: false, retryDelayMs };
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
return { terminal: false, retryDelayMs };
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function wait(ms: number): Promise<void> {
|
|
696
|
-
return new Promise((resolve) => {
|
|
697
|
-
setTimeout(resolve, ms);
|
|
698
|
-
});
|
|
699
242
|
}
|