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/src/plugin.ts CHANGED
@@ -1,34 +1,39 @@
1
- import { spawn } from "node:child_process";
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
- ensureProjectContext,
12
- resolveProjectContextFromAccessToken,
13
- } from "./plugin/project";
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 providerOptions =
48
- provider && typeof provider === "object"
49
- ? ((provider as { options?: Record<string, unknown> }).options ?? undefined)
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
- const accessToken = authRecord.access;
93
- if (!accessToken) {
92
+ if (!authRecord.access) {
94
93
  return fetch(input, init);
95
94
  }
96
95
 
97
- /**
98
- * Ensures we have a usable project context for the current auth snapshot.
99
- */
100
- async function resolveProjectContext(): Promise<ProjectContextResult> {
101
- try {
102
- return await ensureProjectContext(authRecord, client, configuredProjectId);
103
- } catch (error) {
104
- if (error instanceof Error) {
105
- console.error(error.message);
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
- accessToken,
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: transformedInit.method,
131
- headers: transformedInit.headers,
132
- body: transformedInit.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
- const response = await fetchWithRetry(request, transformedInit);
138
- return transformGeminiResponse(response, streaming, debugContext, requestedModel);
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: async () => {
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
- const RETRYABLE_STATUS_CODES = new Set([429, 503]);
323
- const DEFAULT_MAX_RETRIES = 2;
324
- const DEFAULT_BASE_DELAY_MS = 800;
325
- const DEFAULT_MAX_DELAY_MS = 8000;
326
- const CLOUDCODE_DOMAINS = [
327
- "cloudcode-pa.googleapis.com",
328
- "staging-cloudcode-pa.googleapis.com",
329
- "autopush-cloudcode-pa.googleapis.com",
330
- ];
331
-
332
- function toUrlString(value: RequestInfo): string {
333
- if (typeof value === "string") {
334
- return value;
335
- }
336
- const candidate = (value as Request).url;
337
- if (candidate) {
338
- return candidate;
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 parseOAuthCallbackInput(input: string): { code?: string; state?: string } {
344
- const trimmed = input.trim();
345
- if (!trimmed) {
346
- return {};
171
+ function normalizeProviderModelCosts(provider: Provider): void {
172
+ if (!provider.models) {
173
+ return;
347
174
  }
348
-
349
- if (/^https?:\/\//i.test(trimmed)) {
350
- try {
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 openBrowserUrl(url: string): void {
182
+ async function ensureProjectContextOrThrow(
183
+ authRecord: OAuthAuthDetails,
184
+ client: PluginClient,
185
+ configuredProjectId?: string,
186
+ ) {
375
187
  try {
376
- // Best-effort: don't block auth flow if spawning fails.
377
- const platform = process.platform;
378
- const command =
379
- platform === "darwin"
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 canRetryRequest(init: RequestInit | undefined): boolean {
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
- const match = value.match(/^([\d.]+)s$/);
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 afterMatch = message.match(/after\s+([0-9.]+(?:ms|s))/i);
622
- if (afterMatch?.[1]) {
623
- return parseRetryDelayValue(afterMatch[1]);
201
+ const candidate = (value as Request).url;
202
+ if (candidate) {
203
+ return candidate;
624
204
  }
625
- return null;
205
+ return value.toString();
626
206
  }
627
207
 
628
208
  /**
629
- * Classifies quota errors as terminal vs retryable and extracts retry hints.
630
- * Matches Gemini CLI semantics: QUOTA_EXHAUSTED is terminal, RATE_LIMIT_EXCEEDED is retryable.
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 classifyQuotaResponse(
633
- response: Response,
634
- ): Promise<{ terminal: boolean; retryDelayMs?: number } | null> {
635
- let text = "";
636
- try {
637
- text = await response.clone().text();
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 (!text) {
643
- return null;
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 error = parsed?.error ?? {};
654
- const details = Array.isArray(error?.details) ? error.details : [];
655
- const retryDelayMs = parseRetryDelayFromMessage(error?.message ?? "") ?? undefined;
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
- if (errorInfo?.reason === "QUOTA_EXHAUSTED") {
669
- return { terminal: true, retryDelayMs };
670
- }
671
- if (errorInfo?.reason === "RATE_LIMIT_EXCEEDED") {
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
- const quotaFailure = details.find(
676
- (detail: any) =>
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
  }