opencode-gemini-auth 1.3.8 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import {
4
4
  GEMINI_PROVIDER_ID,
5
5
  } from "../constants";
6
6
  import { formatRefreshParts, parseRefreshParts } from "./auth";
7
+ import { logGeminiDebugResponse, startGeminiDebugRequest } from "./debug";
7
8
  import type {
8
9
  OAuthAuthDetails,
9
10
  PluginClient,
@@ -13,6 +14,9 @@ import type {
13
14
  const projectContextResultCache = new Map<string, ProjectContextResult>();
14
15
  const projectContextPendingCache = new Map<string, Promise<ProjectContextResult>>();
15
16
 
17
+ const FREE_TIER_ID = "free-tier";
18
+ const LEGACY_TIER_ID = "legacy-tier";
19
+
16
20
  const CODE_ASSIST_METADATA = {
17
21
  ideType: "IDE_UNSPECIFIED",
18
22
  platform: "PLATFORM_UNSPECIFIED",
@@ -23,17 +27,30 @@ interface GeminiUserTier {
23
27
  id?: string;
24
28
  isDefault?: boolean;
25
29
  userDefinedCloudaicompanionProject?: boolean;
30
+ name?: string;
31
+ description?: string;
32
+ }
33
+
34
+ interface CloudAiCompanionProject {
35
+ id?: string;
36
+ }
37
+
38
+ interface GeminiIneligibleTier {
39
+ reasonMessage?: string;
26
40
  }
27
41
 
28
42
  interface LoadCodeAssistPayload {
29
- cloudaicompanionProject?: string;
43
+ cloudaicompanionProject?: string | CloudAiCompanionProject;
30
44
  currentTier?: {
31
45
  id?: string;
46
+ name?: string;
32
47
  };
33
48
  allowedTiers?: GeminiUserTier[];
49
+ ineligibleTiers?: GeminiIneligibleTier[];
34
50
  }
35
51
 
36
52
  interface OnboardUserPayload {
53
+ name?: string;
37
54
  done?: boolean;
38
55
  response?: {
39
56
  cloudaicompanionProject?: {
@@ -48,7 +65,7 @@ class ProjectIdRequiredError extends Error {
48
65
  */
49
66
  constructor() {
50
67
  super(
51
- "Google Gemini requires a Google Cloud project. Enable the Gemini for Google Cloud API on a project you control, then set `provider.google.options.projectId` in your Opencode config (or set OPENCODE_GEMINI_PROJECT_ID).",
68
+ "Google Gemini requires a Google Cloud project. Enable the Gemini for Google Cloud API on a project you control, then set `provider.google.options.projectId` in your Opencode config (or set OPENCODE_GEMINI_PROJECT_ID / GOOGLE_CLOUD_PROJECT).",
52
69
  );
53
70
  }
54
71
  }
@@ -56,31 +73,94 @@ class ProjectIdRequiredError extends Error {
56
73
  /**
57
74
  * Builds metadata headers required by the Code Assist API.
58
75
  */
59
- function buildMetadata(projectId?: string): Record<string, string> {
76
+ function buildMetadata(projectId?: string, includeDuetProject = true): Record<string, string> {
60
77
  const metadata: Record<string, string> = {
61
78
  ideType: CODE_ASSIST_METADATA.ideType,
62
79
  platform: CODE_ASSIST_METADATA.platform,
63
80
  pluginType: CODE_ASSIST_METADATA.pluginType,
64
81
  };
65
- if (projectId) {
82
+ if (projectId && includeDuetProject) {
66
83
  metadata.duetProject = projectId;
67
84
  }
68
85
  return metadata;
69
86
  }
70
87
 
88
+ /**
89
+ * Normalizes project identifiers from API payloads or config.
90
+ */
91
+ function normalizeProjectId(value?: string | CloudAiCompanionProject): string | undefined {
92
+ if (!value) {
93
+ return undefined;
94
+ }
95
+ if (typeof value === "string") {
96
+ const trimmed = value.trim();
97
+ return trimmed ? trimmed : undefined;
98
+ }
99
+ if (typeof value === "object" && typeof value.id === "string") {
100
+ const trimmed = value.id.trim();
101
+ return trimmed ? trimmed : undefined;
102
+ }
103
+ return undefined;
104
+ }
105
+
71
106
  /**
72
107
  * Selects the default tier ID from the allowed tiers list.
73
108
  */
74
- function getDefaultTierId(allowedTiers?: GeminiUserTier[]): string | undefined {
75
- if (!allowedTiers || allowedTiers.length === 0) {
109
+ function pickOnboardTier(allowedTiers?: GeminiUserTier[]): GeminiUserTier {
110
+ if (allowedTiers && allowedTiers.length > 0) {
111
+ for (const tier of allowedTiers) {
112
+ if (tier?.isDefault) {
113
+ return tier;
114
+ }
115
+ }
116
+ return allowedTiers[0] ?? { id: LEGACY_TIER_ID, userDefinedCloudaicompanionProject: true };
117
+ }
118
+ return { id: LEGACY_TIER_ID, userDefinedCloudaicompanionProject: true };
119
+ }
120
+
121
+ /**
122
+ * Builds a concise error message from ineligible tier payloads.
123
+ */
124
+ function buildIneligibleTierMessage(tiers?: GeminiIneligibleTier[]): string | undefined {
125
+ if (!tiers || tiers.length === 0) {
126
+ return undefined;
127
+ }
128
+ const reasons = tiers
129
+ .map((tier) => tier?.reasonMessage?.trim())
130
+ .filter((message): message is string => !!message);
131
+ if (reasons.length === 0) {
76
132
  return undefined;
77
133
  }
78
- for (const tier of allowedTiers) {
79
- if (tier?.isDefault) {
80
- return tier.id;
134
+ return reasons.join(", ");
135
+ }
136
+
137
+ function isVpcScError(payload: unknown): boolean {
138
+ if (!payload || typeof payload !== "object") {
139
+ return false;
140
+ }
141
+ const error = (payload as { error?: unknown }).error;
142
+ if (!error || typeof error !== "object") {
143
+ return false;
144
+ }
145
+ const details = (error as { details?: unknown }).details;
146
+ if (!Array.isArray(details)) {
147
+ return false;
148
+ }
149
+ return details.some((detail) => {
150
+ if (!detail || typeof detail !== "object") {
151
+ return false;
81
152
  }
153
+ const reason = (detail as { reason?: unknown }).reason;
154
+ return reason === "SECURITY_POLICY_VIOLATED";
155
+ });
156
+ }
157
+
158
+ function parseJsonSafe(text: string): unknown {
159
+ try {
160
+ return JSON.parse(text);
161
+ } catch {
162
+ return null;
82
163
  }
83
- return allowedTiers[0]?.id;
84
164
  }
85
165
 
86
166
  /**
@@ -140,24 +220,53 @@ export async function loadManagedProject(
140
220
  if (projectId) {
141
221
  requestBody.cloudaicompanionProject = projectId;
142
222
  }
223
+ const url = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`;
224
+ const headers = {
225
+ "Content-Type": "application/json",
226
+ Authorization: `Bearer ${accessToken}`,
227
+ ...CODE_ASSIST_HEADERS,
228
+ };
229
+ const debugContext = startGeminiDebugRequest({
230
+ originalUrl: url,
231
+ resolvedUrl: url,
232
+ method: "POST",
233
+ headers,
234
+ body: JSON.stringify(requestBody),
235
+ streaming: false,
236
+ projectId,
237
+ });
143
238
 
144
- const response = await fetch(
145
- `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`,
146
- {
147
- method: "POST",
148
- headers: {
149
- "Content-Type": "application/json",
150
- Authorization: `Bearer ${accessToken}`,
151
- ...CODE_ASSIST_HEADERS,
152
- },
153
- body: JSON.stringify(requestBody),
154
- },
155
- );
239
+ const response = await fetch(url, {
240
+ method: "POST",
241
+ headers,
242
+ body: JSON.stringify(requestBody),
243
+ });
244
+ let responseBody: string | undefined;
245
+ if (debugContext || !response.ok) {
246
+ try {
247
+ responseBody = await response.clone().text();
248
+ } catch {
249
+ responseBody = undefined;
250
+ }
251
+ }
252
+ if (debugContext) {
253
+ logGeminiDebugResponse(debugContext, response, { body: responseBody });
254
+ }
156
255
 
157
256
  if (!response.ok) {
257
+ if (responseBody) {
258
+ const parsed = parseJsonSafe(responseBody);
259
+ if (isVpcScError(parsed)) {
260
+ return { currentTier: { id: "standard-tier" } };
261
+ }
262
+ }
158
263
  return null;
159
264
  }
160
265
 
266
+ if (responseBody) {
267
+ return parseJsonSafe(responseBody) as LoadCodeAssistPayload;
268
+ }
269
+
161
270
  return (await response.json()) as LoadCodeAssistPayload;
162
271
  } catch (error) {
163
272
  console.error("Failed to load Gemini managed project:", error);
@@ -176,57 +285,198 @@ export async function onboardManagedProject(
176
285
  attempts = 10,
177
286
  delayMs = 5000,
178
287
  ): Promise<string | undefined> {
179
- const metadata = buildMetadata(projectId);
288
+ const isFreeTier = tierId === FREE_TIER_ID;
289
+ const metadata = buildMetadata(projectId, !isFreeTier);
180
290
  const requestBody: Record<string, unknown> = {
181
291
  tierId,
182
292
  metadata,
183
293
  };
184
294
 
185
- if (tierId !== "FREE") {
295
+ if (!isFreeTier) {
186
296
  if (!projectId) {
187
297
  throw new ProjectIdRequiredError();
188
298
  }
189
299
  requestBody.cloudaicompanionProject = projectId;
190
300
  }
191
301
 
192
- for (let attempt = 0; attempt < attempts; attempt += 1) {
193
- try {
194
- const response = await fetch(
195
- `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`,
196
- {
197
- method: "POST",
198
- headers: {
199
- "Content-Type": "application/json",
200
- Authorization: `Bearer ${accessToken}`,
201
- ...CODE_ASSIST_HEADERS,
202
- },
203
- body: JSON.stringify(requestBody),
204
- },
205
- );
206
-
207
- if (!response.ok) {
208
- return undefined;
209
- }
302
+ const baseUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal`;
303
+ const onboardUrl = `${baseUrl}:onboardUser`;
304
+ const headers = {
305
+ "Content-Type": "application/json",
306
+ Authorization: `Bearer ${accessToken}`,
307
+ ...CODE_ASSIST_HEADERS,
308
+ };
210
309
 
211
- const payload = (await response.json()) as OnboardUserPayload;
212
- const managedProjectId = payload.response?.cloudaicompanionProject?.id;
213
- if (payload.done && managedProjectId) {
214
- return managedProjectId;
215
- }
216
- if (payload.done && projectId) {
217
- return projectId;
310
+ try {
311
+ const debugContext = startGeminiDebugRequest({
312
+ originalUrl: onboardUrl,
313
+ resolvedUrl: onboardUrl,
314
+ method: "POST",
315
+ headers,
316
+ body: JSON.stringify(requestBody),
317
+ streaming: false,
318
+ projectId,
319
+ });
320
+
321
+ const response = await fetch(onboardUrl, {
322
+ method: "POST",
323
+ headers,
324
+ body: JSON.stringify(requestBody),
325
+ });
326
+ if (debugContext) {
327
+ let responseBody: string | undefined;
328
+ try {
329
+ responseBody = await response.clone().text();
330
+ } catch {
331
+ responseBody = undefined;
218
332
  }
219
- } catch (error) {
220
- console.error("Failed to onboard Gemini managed project:", error);
333
+ logGeminiDebugResponse(debugContext, response, { body: responseBody });
334
+ }
335
+
336
+ if (!response.ok) {
221
337
  return undefined;
222
338
  }
223
339
 
224
- await wait(delayMs);
340
+ let payload = (await response.json()) as OnboardUserPayload;
341
+ if (!payload.done && payload.name) {
342
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
343
+ await wait(delayMs);
344
+ const operationUrl = `${baseUrl}/${payload.name}`;
345
+ const opDebugContext = startGeminiDebugRequest({
346
+ originalUrl: operationUrl,
347
+ resolvedUrl: operationUrl,
348
+ method: "GET",
349
+ headers,
350
+ streaming: false,
351
+ projectId,
352
+ });
353
+ const opResponse = await fetch(operationUrl, {
354
+ method: "GET",
355
+ headers,
356
+ });
357
+ if (opDebugContext) {
358
+ let responseBody: string | undefined;
359
+ try {
360
+ responseBody = await opResponse.clone().text();
361
+ } catch {
362
+ responseBody = undefined;
363
+ }
364
+ logGeminiDebugResponse(opDebugContext, opResponse, { body: responseBody });
365
+ }
366
+ if (!opResponse.ok) {
367
+ return undefined;
368
+ }
369
+ payload = (await opResponse.json()) as OnboardUserPayload;
370
+ if (payload.done) {
371
+ break;
372
+ }
373
+ }
374
+ }
375
+
376
+ const managedProjectId = payload.response?.cloudaicompanionProject?.id;
377
+ if (payload.done && managedProjectId) {
378
+ return managedProjectId;
379
+ }
380
+ if (payload.done && projectId) {
381
+ return projectId;
382
+ }
383
+ } catch (error) {
384
+ console.error("Failed to onboard Gemini managed project:", error);
385
+ return undefined;
225
386
  }
226
387
 
227
388
  return undefined;
228
389
  }
229
390
 
391
+ /**
392
+ * Resolves a project context for an access token, optionally persisting updated auth.
393
+ */
394
+ export async function resolveProjectContextFromAccessToken(
395
+ auth: OAuthAuthDetails,
396
+ accessToken: string,
397
+ configuredProjectId?: string,
398
+ persistAuth?: (auth: OAuthAuthDetails) => Promise<void>,
399
+ ): Promise<ProjectContextResult> {
400
+ const parts = parseRefreshParts(auth.refresh);
401
+ const effectiveConfiguredProjectId = configuredProjectId?.trim() || undefined;
402
+ const projectId = effectiveConfiguredProjectId ?? parts.projectId;
403
+
404
+ if (projectId || parts.managedProjectId) {
405
+ return {
406
+ auth,
407
+ effectiveProjectId: projectId || parts.managedProjectId || "",
408
+ };
409
+ }
410
+
411
+ const loadPayload = await loadManagedProject(accessToken, projectId);
412
+ if (!loadPayload) {
413
+ throw new ProjectIdRequiredError();
414
+ }
415
+
416
+ const managedProjectId = normalizeProjectId(loadPayload.cloudaicompanionProject);
417
+ if (managedProjectId) {
418
+ const updatedAuth: OAuthAuthDetails = {
419
+ ...auth,
420
+ refresh: formatRefreshParts({
421
+ refreshToken: parts.refreshToken,
422
+ projectId,
423
+ managedProjectId,
424
+ }),
425
+ };
426
+
427
+ if (persistAuth) {
428
+ await persistAuth(updatedAuth);
429
+ }
430
+
431
+ return { auth: updatedAuth, effectiveProjectId: managedProjectId };
432
+ }
433
+
434
+ const currentTierId = loadPayload.currentTier?.id;
435
+ if (currentTierId) {
436
+ if (projectId) {
437
+ return { auth, effectiveProjectId: projectId };
438
+ }
439
+
440
+ const ineligibleMessage = buildIneligibleTierMessage(loadPayload.ineligibleTiers);
441
+ if (ineligibleMessage) {
442
+ throw new Error(ineligibleMessage);
443
+ }
444
+
445
+ throw new ProjectIdRequiredError();
446
+ }
447
+
448
+ const tier = pickOnboardTier(loadPayload.allowedTiers);
449
+ const tierId = tier.id ?? LEGACY_TIER_ID;
450
+
451
+ if (tierId !== FREE_TIER_ID && !projectId) {
452
+ throw new ProjectIdRequiredError();
453
+ }
454
+
455
+ const onboardedProjectId = await onboardManagedProject(accessToken, tierId, projectId);
456
+ if (onboardedProjectId) {
457
+ const updatedAuth: OAuthAuthDetails = {
458
+ ...auth,
459
+ refresh: formatRefreshParts({
460
+ refreshToken: parts.refreshToken,
461
+ projectId,
462
+ managedProjectId: onboardedProjectId,
463
+ }),
464
+ };
465
+
466
+ if (persistAuth) {
467
+ await persistAuth(updatedAuth);
468
+ }
469
+
470
+ return { auth: updatedAuth, effectiveProjectId: onboardedProjectId };
471
+ }
472
+
473
+ if (projectId) {
474
+ return { auth, effectiveProjectId: projectId };
475
+ }
476
+
477
+ throw new ProjectIdRequiredError();
478
+ }
479
+
230
480
  /**
231
481
  * Resolves an effective project ID for the current auth state, caching results per refresh token.
232
482
  */
@@ -257,75 +507,18 @@ export async function ensureProjectContext(
257
507
  }
258
508
  }
259
509
 
260
- const resolveContext = async (): Promise<ProjectContextResult> => {
261
- const parts = parseRefreshParts(auth.refresh);
262
- const effectiveConfiguredProjectId = configuredProjectId?.trim() || undefined;
263
- const projectId = effectiveConfiguredProjectId ?? parts.projectId;
264
-
265
- if (projectId || parts.managedProjectId) {
266
- return {
267
- auth,
268
- effectiveProjectId: projectId || parts.managedProjectId || "",
269
- };
270
- }
271
-
272
- const loadPayload = await loadManagedProject(accessToken, projectId);
273
- if (loadPayload?.cloudaicompanionProject) {
274
- const managedProjectId = loadPayload.cloudaicompanionProject;
275
- const updatedAuth: OAuthAuthDetails = {
276
- ...auth,
277
- refresh: formatRefreshParts({
278
- refreshToken: parts.refreshToken,
279
- projectId,
280
- managedProjectId,
281
- }),
282
- };
283
-
284
- await client.auth.set({
285
- path: { id: GEMINI_PROVIDER_ID },
286
- body: updatedAuth,
287
- });
288
-
289
- return { auth: updatedAuth, effectiveProjectId: managedProjectId };
290
- }
291
-
292
- if (!loadPayload) {
293
- throw new ProjectIdRequiredError();
294
- }
295
-
296
- const currentTierId = loadPayload.currentTier?.id ?? undefined;
297
- if (currentTierId && currentTierId !== "FREE") {
298
- throw new ProjectIdRequiredError();
299
- }
300
-
301
- const defaultTierId = getDefaultTierId(loadPayload.allowedTiers);
302
- const tierId = defaultTierId ?? "FREE";
303
-
304
- if (tierId !== "FREE") {
305
- throw new ProjectIdRequiredError();
306
- }
307
-
308
- const managedProjectId = await onboardManagedProject(accessToken, tierId, projectId);
309
- if (managedProjectId) {
310
- const updatedAuth: OAuthAuthDetails = {
311
- ...auth,
312
- refresh: formatRefreshParts({
313
- refreshToken: parts.refreshToken,
314
- projectId,
315
- managedProjectId,
316
- }),
317
- };
318
-
319
- await client.auth.set({
320
- path: { id: GEMINI_PROVIDER_ID },
321
- body: updatedAuth,
322
- });
323
-
324
- return { auth: updatedAuth, effectiveProjectId: managedProjectId };
325
- }
326
-
327
- throw new ProjectIdRequiredError();
328
- };
510
+ const resolveContext = async (): Promise<ProjectContextResult> =>
511
+ resolveProjectContextFromAccessToken(
512
+ auth,
513
+ accessToken,
514
+ configuredProjectId,
515
+ async (updatedAuth) => {
516
+ await client.auth.set({
517
+ path: { id: GEMINI_PROVIDER_ID },
518
+ body: updatedAuth,
519
+ });
520
+ },
521
+ );
329
522
 
330
523
  if (!cacheKey) {
331
524
  return resolveContext();