opencode-gemini-auth 1.3.10 → 1.3.11

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,31 +1,23 @@
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";
10
- import {
11
- ensureProjectContext,
12
- resolveProjectContextFromAccessToken,
13
- } from "./plugin/project";
4
+ import { resolveCachedAuth } from "./plugin/cache";
5
+ import { ensureProjectContext, retrieveUserQuota } from "./plugin/project";
14
6
  import { isGeminiDebugEnabled, logGeminiDebugMessage, startGeminiDebugRequest } from "./plugin/debug";
15
7
  import {
16
8
  isGenerativeLanguageRequest,
17
9
  prepareGeminiRequest,
18
10
  transformGeminiResponse,
19
11
  } from "./plugin/request";
12
+ import { fetchWithRetry } from "./plugin/retry";
20
13
  import { refreshAccessToken } from "./plugin/token";
21
- import { startOAuthListener, type OAuthListener } from "./plugin/server";
22
14
  import type {
23
15
  GetAuth,
24
16
  LoaderResult,
25
17
  OAuthAuthDetails,
18
+ PluginClient,
26
19
  PluginContext,
27
20
  PluginResult,
28
- ProjectContextResult,
29
21
  Provider,
30
22
  } from "./plugin/types";
31
23
 
@@ -44,29 +36,8 @@ export const GeminiCLIOAuthPlugin = async (
44
36
  return null;
45
37
  }
46
38
 
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
- }
39
+ const configuredProjectId = resolveConfiguredProjectId(provider);
40
+ normalizeProviderModelCosts(provider);
70
41
 
71
42
  return {
72
43
  apiKey: "",
@@ -80,7 +51,7 @@ export const GeminiCLIOAuthPlugin = async (
80
51
  return fetch(input, init);
81
52
  }
82
53
 
83
- let authRecord = latestAuth;
54
+ let authRecord = resolveCachedAuth(latestAuth);
84
55
  if (accessTokenExpired(authRecord)) {
85
56
  const refreshed = await refreshAccessToken(authRecord, client);
86
57
  if (!refreshed) {
@@ -89,53 +60,46 @@ export const GeminiCLIOAuthPlugin = async (
89
60
  authRecord = refreshed;
90
61
  }
91
62
 
92
- const accessToken = authRecord.access;
93
- if (!accessToken) {
63
+ if (!authRecord.access) {
94
64
  return fetch(input, init);
95
65
  }
96
66
 
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(
67
+ const projectContext = await ensureProjectContextOrThrow(
68
+ authRecord,
69
+ client,
70
+ configuredProjectId,
71
+ );
72
+ await maybeLogAvailableQuotaModels(
73
+ authRecord.access,
74
+ projectContext.effectiveProjectId,
75
+ );
76
+ const transformed = prepareGeminiRequest(
119
77
  input,
120
78
  init,
121
- accessToken,
79
+ authRecord.access,
122
80
  projectContext.effectiveProjectId,
123
81
  );
124
-
125
- const originalUrl = toUrlString(input);
126
- const resolvedUrl = toUrlString(request);
127
82
  const debugContext = startGeminiDebugRequest({
128
- originalUrl,
129
- resolvedUrl,
130
- method: transformedInit.method,
131
- headers: transformedInit.headers,
132
- body: transformedInit.body,
133
- streaming,
83
+ originalUrl: toUrlString(input),
84
+ resolvedUrl: toUrlString(transformed.request),
85
+ method: transformed.init.method,
86
+ headers: transformed.init.headers,
87
+ body: transformed.init.body,
88
+ streaming: transformed.streaming,
134
89
  projectId: projectContext.effectiveProjectId,
135
90
  });
136
91
 
137
- const response = await fetchWithRetry(request, transformedInit);
138
- return transformGeminiResponse(response, streaming, debugContext, requestedModel);
92
+ /**
93
+ * Retry transport/429 failures while preserving the requested model.
94
+ * We intentionally do not auto-downgrade model tiers to avoid misleading users.
95
+ */
96
+ const response = await fetchWithRetry(transformed.request, transformed.init);
97
+ return transformGeminiResponse(
98
+ response,
99
+ transformed.streaming,
100
+ debugContext,
101
+ transformed.requestedModel,
102
+ );
139
103
  },
140
104
  };
141
105
  },
@@ -143,170 +107,7 @@ export const GeminiCLIOAuthPlugin = async (
143
107
  {
144
108
  label: "OAuth with Google (Gemini CLI)",
145
109
  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
- },
110
+ authorize: createOAuthAuthorizeMethod(),
310
111
  },
311
112
  {
312
113
  provider: GEMINI_PROVIDER_ID,
@@ -318,382 +119,95 @@ export const GeminiCLIOAuthPlugin = async (
318
119
  });
319
120
 
320
121
  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();
341
- }
342
-
343
- function parseOAuthCallbackInput(input: string): { code?: string; state?: string } {
344
- const trimmed = input.trim();
345
- if (!trimmed) {
346
- return {};
347
- }
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 {};
358
- }
359
- }
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
- }
373
-
374
- function openBrowserUrl(url: string): void {
375
- 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
- }
122
+ const loggedQuotaModelsByProject = new Set<string>();
123
+
124
+ function resolveConfiguredProjectId(provider: Provider): string | undefined {
125
+ const providerOptions =
126
+ provider && typeof provider === "object"
127
+ ? ((provider as { options?: Record<string, unknown> }).options ?? undefined)
128
+ : undefined;
129
+ const projectIdFromConfig =
130
+ providerOptions && typeof providerOptions.projectId === "string"
131
+ ? providerOptions.projectId.trim()
132
+ : "";
133
+ const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
134
+ const googleProjectIdFromEnv =
135
+ process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
136
+ process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
137
+ "";
138
+
139
+ return projectIdFromEnv || projectIdFromConfig || googleProjectIdFromEnv || undefined;
393
140
  }
394
141
 
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);
142
+ function normalizeProviderModelCosts(provider: Provider): void {
143
+ if (!provider.models) {
144
+ return;
406
145
  }
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;
413
- }
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;
146
+ for (const model of Object.values(provider.models)) {
147
+ if (model) {
148
+ model.cost = { input: 0, output: 0 };
437
149
  }
438
-
439
- await wait(delayMs);
440
- attempt += 1;
441
150
  }
442
151
  }
443
152
 
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;
153
+ async function ensureProjectContextOrThrow(
154
+ authRecord: OAuthAuthDetails,
155
+ client: PluginClient,
156
+ configuredProjectId?: string,
157
+ ) {
549
158
  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;
159
+ return await ensureProjectContext(authRecord, client, configuredProjectId);
160
+ } catch (error) {
161
+ if (error instanceof Error) {
162
+ console.error(error.message);
572
163
  }
164
+ throw error;
573
165
  }
574
-
575
- const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
576
- return parseRetryDelayFromMessage(message);
577
166
  }
578
167
 
579
- function parseRetryDelayValue(value: unknown): number | null {
580
- if (!value) {
581
- return null;
582
- }
583
-
168
+ function toUrlString(value: RequestInfo): string {
584
169
  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]);
170
+ return value;
620
171
  }
621
- const afterMatch = message.match(/after\s+([0-9.]+(?:ms|s))/i);
622
- if (afterMatch?.[1]) {
623
- return parseRetryDelayValue(afterMatch[1]);
172
+ const candidate = (value as Request).url;
173
+ if (candidate) {
174
+ return candidate;
624
175
  }
625
- return null;
176
+ return value.toString();
626
177
  }
627
178
 
628
179
  /**
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.
180
+ * Debug-only, best-effort model visibility log from Code Assist quota buckets.
181
+ *
182
+ * Why: it gives a concrete backend-side list of model IDs currently visible to the
183
+ * current account/project, which helps explain 404/notFound model failures quickly.
631
184
  */
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;
185
+ async function maybeLogAvailableQuotaModels(
186
+ accessToken: string,
187
+ projectId: string,
188
+ ): Promise<void> {
189
+ if (!isGeminiDebugEnabled() || !projectId) {
190
+ return;
640
191
  }
641
192
 
642
- if (!text) {
643
- return null;
644
- }
645
-
646
- let parsed: any;
647
- try {
648
- parsed = JSON.parse(text);
649
- } catch {
650
- return null;
193
+ if (loggedQuotaModelsByProject.has(projectId)) {
194
+ return;
651
195
  }
196
+ loggedQuotaModelsByProject.add(projectId);
652
197
 
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;
198
+ const quota = await retrieveUserQuota(accessToken, projectId);
199
+ if (!quota?.buckets) {
200
+ logGeminiDebugMessage(`Code Assist quota model lookup returned no buckets for project: ${projectId}`);
201
+ return;
666
202
  }
667
203
 
668
- if (errorInfo?.reason === "QUOTA_EXHAUSTED") {
669
- return { terminal: true, retryDelayMs };
670
- }
671
- if (errorInfo?.reason === "RATE_LIMIT_EXCEEDED") {
672
- return { terminal: false, retryDelayMs };
204
+ const modelIds = [...new Set(quota.buckets.map((bucket) => bucket.modelId).filter(Boolean))];
205
+ if (modelIds.length === 0) {
206
+ logGeminiDebugMessage(`Code Assist quota buckets contained no model IDs for project: ${projectId}`);
207
+ return;
673
208
  }
674
209
 
675
- const quotaFailure = details.find(
676
- (detail: any) =>
677
- detail &&
678
- typeof detail === "object" &&
679
- detail["@type"] === "type.googleapis.com/google.rpc.QuotaFailure",
210
+ logGeminiDebugMessage(
211
+ `Code Assist models visible via quota buckets (${projectId}): ${modelIds.join(", ")}`,
680
212
  );
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
213
  }