opencode-gemini-auth 1.3.8 → 1.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,100 @@ 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) {
76
126
  return undefined;
77
127
  }
78
- for (const tier of allowedTiers) {
79
- if (tier?.isDefault) {
80
- return tier.id;
128
+ const reasons = tiers
129
+ .map((tier) => tier?.reasonMessage?.trim())
130
+ .filter((message): message is string => !!message);
131
+ if (reasons.length === 0) {
132
+ return undefined;
133
+ }
134
+ return reasons.join(", ");
135
+ }
136
+
137
+ /**
138
+ * Detects VPC-SC errors from Cloud Code responses.
139
+ */
140
+ function isVpcScError(payload: unknown): boolean {
141
+ if (!payload || typeof payload !== "object") {
142
+ return false;
143
+ }
144
+ const error = (payload as { error?: unknown }).error;
145
+ if (!error || typeof error !== "object") {
146
+ return false;
147
+ }
148
+ const details = (error as { details?: unknown }).details;
149
+ if (!Array.isArray(details)) {
150
+ return false;
151
+ }
152
+ return details.some((detail) => {
153
+ if (!detail || typeof detail !== "object") {
154
+ return false;
81
155
  }
156
+ const reason = (detail as { reason?: unknown }).reason;
157
+ return reason === "SECURITY_POLICY_VIOLATED";
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Safely parses JSON, returning null on failure.
163
+ */
164
+ function parseJsonSafe(text: string): unknown {
165
+ try {
166
+ return JSON.parse(text);
167
+ } catch {
168
+ return null;
82
169
  }
83
- return allowedTiers[0]?.id;
84
170
  }
85
171
 
86
172
  /**
@@ -140,24 +226,53 @@ export async function loadManagedProject(
140
226
  if (projectId) {
141
227
  requestBody.cloudaicompanionProject = projectId;
142
228
  }
229
+ const url = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`;
230
+ const headers = {
231
+ "Content-Type": "application/json",
232
+ Authorization: `Bearer ${accessToken}`,
233
+ ...CODE_ASSIST_HEADERS,
234
+ };
235
+ const debugContext = startGeminiDebugRequest({
236
+ originalUrl: url,
237
+ resolvedUrl: url,
238
+ method: "POST",
239
+ headers,
240
+ body: JSON.stringify(requestBody),
241
+ streaming: false,
242
+ projectId,
243
+ });
143
244
 
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
- );
245
+ const response = await fetch(url, {
246
+ method: "POST",
247
+ headers,
248
+ body: JSON.stringify(requestBody),
249
+ });
250
+ let responseBody: string | undefined;
251
+ if (debugContext || !response.ok) {
252
+ try {
253
+ responseBody = await response.clone().text();
254
+ } catch {
255
+ responseBody = undefined;
256
+ }
257
+ }
258
+ if (debugContext) {
259
+ logGeminiDebugResponse(debugContext, response, { body: responseBody });
260
+ }
156
261
 
157
262
  if (!response.ok) {
263
+ if (responseBody) {
264
+ const parsed = parseJsonSafe(responseBody);
265
+ if (isVpcScError(parsed)) {
266
+ return { currentTier: { id: "standard-tier" } };
267
+ }
268
+ }
158
269
  return null;
159
270
  }
160
271
 
272
+ if (responseBody) {
273
+ return parseJsonSafe(responseBody) as LoadCodeAssistPayload;
274
+ }
275
+
161
276
  return (await response.json()) as LoadCodeAssistPayload;
162
277
  } catch (error) {
163
278
  console.error("Failed to load Gemini managed project:", error);
@@ -176,57 +291,199 @@ export async function onboardManagedProject(
176
291
  attempts = 10,
177
292
  delayMs = 5000,
178
293
  ): Promise<string | undefined> {
179
- const metadata = buildMetadata(projectId);
294
+ const isFreeTier = tierId === FREE_TIER_ID;
295
+ const metadata = buildMetadata(projectId, !isFreeTier);
180
296
  const requestBody: Record<string, unknown> = {
181
297
  tierId,
182
298
  metadata,
183
299
  };
184
300
 
185
- if (tierId !== "FREE") {
301
+ if (!isFreeTier) {
186
302
  if (!projectId) {
187
303
  throw new ProjectIdRequiredError();
188
304
  }
189
305
  requestBody.cloudaicompanionProject = projectId;
190
306
  }
191
307
 
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
- }
308
+ const baseUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal`;
309
+ const onboardUrl = `${baseUrl}:onboardUser`;
310
+ const headers = {
311
+ "Content-Type": "application/json",
312
+ Authorization: `Bearer ${accessToken}`,
313
+ ...CODE_ASSIST_HEADERS,
314
+ };
210
315
 
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;
316
+ try {
317
+ const debugContext = startGeminiDebugRequest({
318
+ originalUrl: onboardUrl,
319
+ resolvedUrl: onboardUrl,
320
+ method: "POST",
321
+ headers,
322
+ body: JSON.stringify(requestBody),
323
+ streaming: false,
324
+ projectId,
325
+ });
326
+
327
+ const response = await fetch(onboardUrl, {
328
+ method: "POST",
329
+ headers,
330
+ body: JSON.stringify(requestBody),
331
+ });
332
+ if (debugContext) {
333
+ let responseBody: string | undefined;
334
+ try {
335
+ responseBody = await response.clone().text();
336
+ } catch {
337
+ responseBody = undefined;
218
338
  }
219
- } catch (error) {
220
- console.error("Failed to onboard Gemini managed project:", error);
339
+ logGeminiDebugResponse(debugContext, response, { body: responseBody });
340
+ }
341
+
342
+ if (!response.ok) {
221
343
  return undefined;
222
344
  }
223
345
 
224
- await wait(delayMs);
346
+ let payload = (await response.json()) as OnboardUserPayload;
347
+ if (!payload.done && payload.name) {
348
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
349
+ await wait(delayMs);
350
+ const operationUrl = `${baseUrl}/${payload.name}`;
351
+ const opDebugContext = startGeminiDebugRequest({
352
+ originalUrl: operationUrl,
353
+ resolvedUrl: operationUrl,
354
+ method: "GET",
355
+ headers,
356
+ streaming: false,
357
+ projectId,
358
+ });
359
+ const opResponse = await fetch(operationUrl, {
360
+ method: "GET",
361
+ headers,
362
+ });
363
+ if (opDebugContext) {
364
+ let responseBody: string | undefined;
365
+ try {
366
+ responseBody = await opResponse.clone().text();
367
+ } catch {
368
+ responseBody = undefined;
369
+ }
370
+ logGeminiDebugResponse(opDebugContext, opResponse, { body: responseBody });
371
+ }
372
+ if (!opResponse.ok) {
373
+ return undefined;
374
+ }
375
+ payload = (await opResponse.json()) as OnboardUserPayload;
376
+ if (payload.done) {
377
+ break;
378
+ }
379
+ }
380
+ }
381
+
382
+ const managedProjectId = payload.response?.cloudaicompanionProject?.id;
383
+ if (payload.done && managedProjectId) {
384
+ return managedProjectId;
385
+ }
386
+ if (payload.done && projectId) {
387
+ return projectId;
388
+ }
389
+ } catch (error) {
390
+ console.error("Failed to onboard Gemini managed project:", error);
391
+ return undefined;
225
392
  }
226
393
 
227
394
  return undefined;
228
395
  }
229
396
 
397
+ /**
398
+ * Resolves a project context for an access token, optionally persisting updated auth.
399
+ */
400
+ export async function resolveProjectContextFromAccessToken(
401
+ auth: OAuthAuthDetails,
402
+ accessToken: string,
403
+ configuredProjectId?: string,
404
+ persistAuth?: (auth: OAuthAuthDetails) => Promise<void>,
405
+ ): Promise<ProjectContextResult> {
406
+ const parts = parseRefreshParts(auth.refresh);
407
+ const effectiveConfiguredProjectId = configuredProjectId?.trim() || undefined;
408
+ const projectId = effectiveConfiguredProjectId ?? parts.projectId;
409
+
410
+ if (projectId || parts.managedProjectId) {
411
+ const effectiveProjectId = projectId || parts.managedProjectId || "";
412
+ return {
413
+ auth,
414
+ effectiveProjectId,
415
+ };
416
+ }
417
+
418
+ const loadPayload = await loadManagedProject(accessToken, projectId);
419
+ if (!loadPayload) {
420
+ throw new ProjectIdRequiredError();
421
+ }
422
+
423
+ const managedProjectId = normalizeProjectId(loadPayload.cloudaicompanionProject);
424
+ if (managedProjectId) {
425
+ const updatedAuth: OAuthAuthDetails = {
426
+ ...auth,
427
+ refresh: formatRefreshParts({
428
+ refreshToken: parts.refreshToken,
429
+ projectId,
430
+ managedProjectId,
431
+ }),
432
+ };
433
+
434
+ if (persistAuth) {
435
+ await persistAuth(updatedAuth);
436
+ }
437
+
438
+ return { auth: updatedAuth, effectiveProjectId: managedProjectId };
439
+ }
440
+
441
+ const currentTierId = loadPayload.currentTier?.id;
442
+ if (currentTierId) {
443
+ if (projectId) {
444
+ return { auth, effectiveProjectId: projectId };
445
+ }
446
+
447
+ const ineligibleMessage = buildIneligibleTierMessage(loadPayload.ineligibleTiers);
448
+ if (ineligibleMessage) {
449
+ throw new Error(ineligibleMessage);
450
+ }
451
+
452
+ throw new ProjectIdRequiredError();
453
+ }
454
+
455
+ const tier = pickOnboardTier(loadPayload.allowedTiers);
456
+ const tierId = tier.id ?? LEGACY_TIER_ID;
457
+
458
+ if (tierId !== FREE_TIER_ID && !projectId) {
459
+ throw new ProjectIdRequiredError();
460
+ }
461
+
462
+ const onboardedProjectId = await onboardManagedProject(accessToken, tierId, projectId);
463
+ if (onboardedProjectId) {
464
+ const updatedAuth: OAuthAuthDetails = {
465
+ ...auth,
466
+ refresh: formatRefreshParts({
467
+ refreshToken: parts.refreshToken,
468
+ projectId,
469
+ managedProjectId: onboardedProjectId,
470
+ }),
471
+ };
472
+
473
+ if (persistAuth) {
474
+ await persistAuth(updatedAuth);
475
+ }
476
+
477
+ return { auth: updatedAuth, effectiveProjectId: onboardedProjectId };
478
+ }
479
+
480
+ if (projectId) {
481
+ return { auth, effectiveProjectId: projectId };
482
+ }
483
+
484
+ throw new ProjectIdRequiredError();
485
+ }
486
+
230
487
  /**
231
488
  * Resolves an effective project ID for the current auth state, caching results per refresh token.
232
489
  */
@@ -257,75 +514,18 @@ export async function ensureProjectContext(
257
514
  }
258
515
  }
259
516
 
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
- };
517
+ const resolveContext = async (): Promise<ProjectContextResult> =>
518
+ resolveProjectContextFromAccessToken(
519
+ auth,
520
+ accessToken,
521
+ configuredProjectId,
522
+ async (updatedAuth) => {
523
+ await client.auth.set({
524
+ path: { id: GEMINI_PROVIDER_ID },
525
+ body: updatedAuth,
526
+ });
527
+ },
528
+ );
329
529
 
330
530
  if (!cacheKey) {
331
531
  return resolveContext();