opencode-gemini-auth-proxy 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.
- package/LICENSE +21 -0
- package/README.md +254 -0
- package/index.ts +14 -0
- package/package.json +23 -0
- package/src/constants.ts +39 -0
- package/src/fetch.ts +11 -0
- package/src/gemini/oauth.ts +178 -0
- package/src/plugin/auth.test.ts +58 -0
- package/src/plugin/auth.ts +46 -0
- package/src/plugin/cache.ts +65 -0
- package/src/plugin/debug.ts +258 -0
- package/src/plugin/project.test.ts +112 -0
- package/src/plugin/project.ts +552 -0
- package/src/plugin/request-helpers.test.ts +84 -0
- package/src/plugin/request-helpers.ts +439 -0
- package/src/plugin/request.test.ts +50 -0
- package/src/plugin/request.ts +483 -0
- package/src/plugin/server.ts +246 -0
- package/src/plugin/token.test.ts +74 -0
- package/src/plugin/token.ts +188 -0
- package/src/plugin/types.ts +76 -0
- package/src/plugin.ts +700 -0
- package/src/shims.d.ts +8 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CODE_ASSIST_HEADERS,
|
|
3
|
+
GEMINI_CODE_ASSIST_ENDPOINT,
|
|
4
|
+
GEMINI_PROVIDER_ID,
|
|
5
|
+
} from "../constants";
|
|
6
|
+
import { formatRefreshParts, parseRefreshParts } from "./auth";
|
|
7
|
+
import { logGeminiDebugResponse, startGeminiDebugRequest } from "./debug";
|
|
8
|
+
import type {
|
|
9
|
+
OAuthAuthDetails,
|
|
10
|
+
PluginClient,
|
|
11
|
+
ProjectContextResult,
|
|
12
|
+
} from "./types";
|
|
13
|
+
import proxyFetch from '../fetch';
|
|
14
|
+
|
|
15
|
+
const projectContextResultCache = new Map<string, ProjectContextResult>();
|
|
16
|
+
const projectContextPendingCache = new Map<string, Promise<ProjectContextResult>>();
|
|
17
|
+
|
|
18
|
+
const FREE_TIER_ID = "free-tier";
|
|
19
|
+
const LEGACY_TIER_ID = "legacy-tier";
|
|
20
|
+
|
|
21
|
+
const CODE_ASSIST_METADATA = {
|
|
22
|
+
ideType: "IDE_UNSPECIFIED",
|
|
23
|
+
platform: "PLATFORM_UNSPECIFIED",
|
|
24
|
+
pluginType: "GEMINI",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
interface GeminiUserTier {
|
|
28
|
+
id?: string;
|
|
29
|
+
isDefault?: boolean;
|
|
30
|
+
userDefinedCloudaicompanionProject?: boolean;
|
|
31
|
+
name?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CloudAiCompanionProject {
|
|
36
|
+
id?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface GeminiIneligibleTier {
|
|
40
|
+
reasonMessage?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface LoadCodeAssistPayload {
|
|
44
|
+
cloudaicompanionProject?: string | CloudAiCompanionProject;
|
|
45
|
+
currentTier?: {
|
|
46
|
+
id?: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
};
|
|
49
|
+
allowedTiers?: GeminiUserTier[];
|
|
50
|
+
ineligibleTiers?: GeminiIneligibleTier[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface OnboardUserPayload {
|
|
54
|
+
name?: string;
|
|
55
|
+
done?: boolean;
|
|
56
|
+
response?: {
|
|
57
|
+
cloudaicompanionProject?: {
|
|
58
|
+
id?: string;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class ProjectIdRequiredError extends Error {
|
|
64
|
+
/**
|
|
65
|
+
* Error raised when a required Google Cloud project is missing during Gemini onboarding.
|
|
66
|
+
*/
|
|
67
|
+
constructor() {
|
|
68
|
+
super(
|
|
69
|
+
"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).",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Builds metadata headers required by the Code Assist API.
|
|
76
|
+
*/
|
|
77
|
+
function buildMetadata(projectId?: string, includeDuetProject = true): Record<string, string> {
|
|
78
|
+
const metadata: Record<string, string> = {
|
|
79
|
+
ideType: CODE_ASSIST_METADATA.ideType,
|
|
80
|
+
platform: CODE_ASSIST_METADATA.platform,
|
|
81
|
+
pluginType: CODE_ASSIST_METADATA.pluginType,
|
|
82
|
+
};
|
|
83
|
+
if (projectId && includeDuetProject) {
|
|
84
|
+
metadata.duetProject = projectId;
|
|
85
|
+
}
|
|
86
|
+
return metadata;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Normalizes project identifiers from API payloads or config.
|
|
91
|
+
*/
|
|
92
|
+
function normalizeProjectId(value?: string | CloudAiCompanionProject): string | undefined {
|
|
93
|
+
if (!value) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
if (typeof value === "string") {
|
|
97
|
+
const trimmed = value.trim();
|
|
98
|
+
return trimmed ? trimmed : undefined;
|
|
99
|
+
}
|
|
100
|
+
if (typeof value === "object" && typeof value.id === "string") {
|
|
101
|
+
const trimmed = value.id.trim();
|
|
102
|
+
return trimmed ? trimmed : undefined;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Selects the default tier ID from the allowed tiers list.
|
|
109
|
+
*/
|
|
110
|
+
function pickOnboardTier(allowedTiers?: GeminiUserTier[]): GeminiUserTier {
|
|
111
|
+
if (allowedTiers && allowedTiers.length > 0) {
|
|
112
|
+
for (const tier of allowedTiers) {
|
|
113
|
+
if (tier?.isDefault) {
|
|
114
|
+
return tier;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return allowedTiers[0] ?? { id: LEGACY_TIER_ID, userDefinedCloudaicompanionProject: true };
|
|
118
|
+
}
|
|
119
|
+
return { id: LEGACY_TIER_ID, userDefinedCloudaicompanionProject: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Builds a concise error message from ineligible tier payloads.
|
|
124
|
+
*/
|
|
125
|
+
function buildIneligibleTierMessage(tiers?: GeminiIneligibleTier[]): string | undefined {
|
|
126
|
+
if (!tiers || tiers.length === 0) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const reasons = tiers
|
|
130
|
+
.map((tier) => tier?.reasonMessage?.trim())
|
|
131
|
+
.filter((message): message is string => !!message);
|
|
132
|
+
if (reasons.length === 0) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
return reasons.join(", ");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Detects VPC-SC errors from Cloud Code responses.
|
|
140
|
+
*/
|
|
141
|
+
function isVpcScError(payload: unknown): boolean {
|
|
142
|
+
if (!payload || typeof payload !== "object") {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
const error = (payload as { error?: unknown }).error;
|
|
146
|
+
if (!error || typeof error !== "object") {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const details = (error as { details?: unknown }).details;
|
|
150
|
+
if (!Array.isArray(details)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return details.some((detail) => {
|
|
154
|
+
if (!detail || typeof detail !== "object") {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
const reason = (detail as { reason?: unknown }).reason;
|
|
158
|
+
return reason === "SECURITY_POLICY_VIOLATED";
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Safely parses JSON, returning null on failure.
|
|
164
|
+
*/
|
|
165
|
+
function parseJsonSafe(text: string): unknown {
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(text);
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Promise-based delay utility.
|
|
175
|
+
*/
|
|
176
|
+
function wait(ms: number): Promise<void> {
|
|
177
|
+
return new Promise(function (resolve) {
|
|
178
|
+
setTimeout(resolve, ms);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Generates a cache key for project context based on refresh token.
|
|
184
|
+
*/
|
|
185
|
+
function getCacheKey(auth: OAuthAuthDetails): string | undefined {
|
|
186
|
+
const refresh = auth.refresh?.trim();
|
|
187
|
+
return refresh ? refresh : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Clears cached project context results and pending promises, globally or for a refresh key.
|
|
192
|
+
*/
|
|
193
|
+
export function invalidateProjectContextCache(refresh?: string): void {
|
|
194
|
+
if (!refresh) {
|
|
195
|
+
projectContextPendingCache.clear();
|
|
196
|
+
projectContextResultCache.clear();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
projectContextPendingCache.delete(refresh);
|
|
201
|
+
projectContextResultCache.delete(refresh);
|
|
202
|
+
|
|
203
|
+
const prefix = `${refresh}|cfg:`;
|
|
204
|
+
for (const key of projectContextPendingCache.keys()) {
|
|
205
|
+
if (key.startsWith(prefix)) {
|
|
206
|
+
projectContextPendingCache.delete(key);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const key of projectContextResultCache.keys()) {
|
|
210
|
+
if (key.startsWith(prefix)) {
|
|
211
|
+
projectContextResultCache.delete(key);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Loads managed project information for the given access token and optional project.
|
|
218
|
+
*/
|
|
219
|
+
export async function loadManagedProject(
|
|
220
|
+
accessToken: string,
|
|
221
|
+
projectId?: string,
|
|
222
|
+
): Promise<LoadCodeAssistPayload | null> {
|
|
223
|
+
try {
|
|
224
|
+
const metadata = buildMetadata(projectId);
|
|
225
|
+
|
|
226
|
+
const requestBody: Record<string, unknown> = { metadata };
|
|
227
|
+
if (projectId) {
|
|
228
|
+
requestBody.cloudaicompanionProject = projectId;
|
|
229
|
+
}
|
|
230
|
+
const url = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`;
|
|
231
|
+
const headers = {
|
|
232
|
+
"Content-Type": "application/json",
|
|
233
|
+
Authorization: `Bearer ${accessToken}`,
|
|
234
|
+
...CODE_ASSIST_HEADERS,
|
|
235
|
+
};
|
|
236
|
+
const debugContext = startGeminiDebugRequest({
|
|
237
|
+
originalUrl: url,
|
|
238
|
+
resolvedUrl: url,
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers,
|
|
241
|
+
body: JSON.stringify(requestBody),
|
|
242
|
+
streaming: false,
|
|
243
|
+
projectId,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const response = await proxyFetch(url, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers,
|
|
249
|
+
body: JSON.stringify(requestBody),
|
|
250
|
+
});
|
|
251
|
+
let responseBody: string | undefined;
|
|
252
|
+
if (debugContext || !response.ok) {
|
|
253
|
+
try {
|
|
254
|
+
responseBody = await response.clone().text();
|
|
255
|
+
} catch {
|
|
256
|
+
responseBody = undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (debugContext) {
|
|
260
|
+
logGeminiDebugResponse(debugContext, response, { body: responseBody });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
if (responseBody) {
|
|
265
|
+
const parsed = parseJsonSafe(responseBody);
|
|
266
|
+
if (isVpcScError(parsed)) {
|
|
267
|
+
return { currentTier: { id: "standard-tier" } };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (responseBody) {
|
|
274
|
+
return parseJsonSafe(responseBody) as LoadCodeAssistPayload;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return (await response.json()) as LoadCodeAssistPayload;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error("Failed to load Gemini managed project:", error);
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Onboards a managed project for the user, optionally retrying until completion.
|
|
287
|
+
*/
|
|
288
|
+
export async function onboardManagedProject(
|
|
289
|
+
accessToken: string,
|
|
290
|
+
tierId: string,
|
|
291
|
+
projectId?: string,
|
|
292
|
+
attempts = 10,
|
|
293
|
+
delayMs = 5000,
|
|
294
|
+
): Promise<string | undefined> {
|
|
295
|
+
const isFreeTier = tierId === FREE_TIER_ID;
|
|
296
|
+
const metadata = buildMetadata(projectId, !isFreeTier);
|
|
297
|
+
const requestBody: Record<string, unknown> = {
|
|
298
|
+
tierId,
|
|
299
|
+
metadata,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (!isFreeTier) {
|
|
303
|
+
if (!projectId) {
|
|
304
|
+
throw new ProjectIdRequiredError();
|
|
305
|
+
}
|
|
306
|
+
requestBody.cloudaicompanionProject = projectId;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const baseUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal`;
|
|
310
|
+
const onboardUrl = `${baseUrl}:onboardUser`;
|
|
311
|
+
const headers = {
|
|
312
|
+
"Content-Type": "application/json",
|
|
313
|
+
Authorization: `Bearer ${accessToken}`,
|
|
314
|
+
...CODE_ASSIST_HEADERS,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const debugContext = startGeminiDebugRequest({
|
|
319
|
+
originalUrl: onboardUrl,
|
|
320
|
+
resolvedUrl: onboardUrl,
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers,
|
|
323
|
+
body: JSON.stringify(requestBody),
|
|
324
|
+
streaming: false,
|
|
325
|
+
projectId,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const response = await proxyFetch(onboardUrl, {
|
|
329
|
+
method: "POST",
|
|
330
|
+
headers,
|
|
331
|
+
body: JSON.stringify(requestBody),
|
|
332
|
+
});
|
|
333
|
+
if (debugContext) {
|
|
334
|
+
let responseBody: string | undefined;
|
|
335
|
+
try {
|
|
336
|
+
responseBody = await response.clone().text();
|
|
337
|
+
} catch {
|
|
338
|
+
responseBody = undefined;
|
|
339
|
+
}
|
|
340
|
+
logGeminiDebugResponse(debugContext, response, { body: responseBody });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!response.ok) {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let payload = (await response.json()) as OnboardUserPayload;
|
|
348
|
+
if (!payload.done && payload.name) {
|
|
349
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
350
|
+
await wait(delayMs);
|
|
351
|
+
const operationUrl = `${baseUrl}/${payload.name}`;
|
|
352
|
+
const opDebugContext = startGeminiDebugRequest({
|
|
353
|
+
originalUrl: operationUrl,
|
|
354
|
+
resolvedUrl: operationUrl,
|
|
355
|
+
method: "GET",
|
|
356
|
+
headers,
|
|
357
|
+
streaming: false,
|
|
358
|
+
projectId,
|
|
359
|
+
});
|
|
360
|
+
const opResponse = await proxyFetch(operationUrl, {
|
|
361
|
+
method: "GET",
|
|
362
|
+
headers,
|
|
363
|
+
});
|
|
364
|
+
if (opDebugContext) {
|
|
365
|
+
let responseBody: string | undefined;
|
|
366
|
+
try {
|
|
367
|
+
responseBody = await opResponse.clone().text();
|
|
368
|
+
} catch {
|
|
369
|
+
responseBody = undefined;
|
|
370
|
+
}
|
|
371
|
+
logGeminiDebugResponse(opDebugContext, opResponse, { body: responseBody });
|
|
372
|
+
}
|
|
373
|
+
if (!opResponse.ok) {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
payload = (await opResponse.json()) as OnboardUserPayload;
|
|
377
|
+
if (payload.done) {
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const managedProjectId = payload.response?.cloudaicompanionProject?.id;
|
|
384
|
+
if (payload.done && managedProjectId) {
|
|
385
|
+
return managedProjectId;
|
|
386
|
+
}
|
|
387
|
+
if (payload.done && projectId) {
|
|
388
|
+
return projectId;
|
|
389
|
+
}
|
|
390
|
+
} catch (error) {
|
|
391
|
+
console.error("Failed to onboard Gemini managed project:", error);
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Resolves a project context for an access token, optionally persisting updated auth.
|
|
400
|
+
*/
|
|
401
|
+
export async function resolveProjectContextFromAccessToken(
|
|
402
|
+
auth: OAuthAuthDetails,
|
|
403
|
+
accessToken: string,
|
|
404
|
+
configuredProjectId?: string,
|
|
405
|
+
persistAuth?: (auth: OAuthAuthDetails) => Promise<void>,
|
|
406
|
+
): Promise<ProjectContextResult> {
|
|
407
|
+
const parts = parseRefreshParts(auth.refresh);
|
|
408
|
+
const effectiveConfiguredProjectId = configuredProjectId?.trim() || undefined;
|
|
409
|
+
const projectId = effectiveConfiguredProjectId ?? parts.projectId;
|
|
410
|
+
|
|
411
|
+
if (projectId || parts.managedProjectId) {
|
|
412
|
+
const effectiveProjectId = projectId || parts.managedProjectId || "";
|
|
413
|
+
return {
|
|
414
|
+
auth,
|
|
415
|
+
effectiveProjectId,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const loadPayload = await loadManagedProject(accessToken, projectId);
|
|
420
|
+
if (!loadPayload) {
|
|
421
|
+
throw new ProjectIdRequiredError();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const managedProjectId = normalizeProjectId(loadPayload.cloudaicompanionProject);
|
|
425
|
+
if (managedProjectId) {
|
|
426
|
+
const updatedAuth: OAuthAuthDetails = {
|
|
427
|
+
...auth,
|
|
428
|
+
refresh: formatRefreshParts({
|
|
429
|
+
refreshToken: parts.refreshToken,
|
|
430
|
+
projectId,
|
|
431
|
+
managedProjectId,
|
|
432
|
+
}),
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
if (persistAuth) {
|
|
436
|
+
await persistAuth(updatedAuth);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { auth: updatedAuth, effectiveProjectId: managedProjectId };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const currentTierId = loadPayload.currentTier?.id;
|
|
443
|
+
if (currentTierId) {
|
|
444
|
+
if (projectId) {
|
|
445
|
+
return { auth, effectiveProjectId: projectId };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const ineligibleMessage = buildIneligibleTierMessage(loadPayload.ineligibleTiers);
|
|
449
|
+
if (ineligibleMessage) {
|
|
450
|
+
throw new Error(ineligibleMessage);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
throw new ProjectIdRequiredError();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const tier = pickOnboardTier(loadPayload.allowedTiers);
|
|
457
|
+
const tierId = tier.id ?? LEGACY_TIER_ID;
|
|
458
|
+
|
|
459
|
+
if (tierId !== FREE_TIER_ID && !projectId) {
|
|
460
|
+
throw new ProjectIdRequiredError();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const onboardedProjectId = await onboardManagedProject(accessToken, tierId, projectId);
|
|
464
|
+
if (onboardedProjectId) {
|
|
465
|
+
const updatedAuth: OAuthAuthDetails = {
|
|
466
|
+
...auth,
|
|
467
|
+
refresh: formatRefreshParts({
|
|
468
|
+
refreshToken: parts.refreshToken,
|
|
469
|
+
projectId,
|
|
470
|
+
managedProjectId: onboardedProjectId,
|
|
471
|
+
}),
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
if (persistAuth) {
|
|
475
|
+
await persistAuth(updatedAuth);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return { auth: updatedAuth, effectiveProjectId: onboardedProjectId };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (projectId) {
|
|
482
|
+
return { auth, effectiveProjectId: projectId };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
throw new ProjectIdRequiredError();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Resolves an effective project ID for the current auth state, caching results per refresh token.
|
|
490
|
+
*/
|
|
491
|
+
export async function ensureProjectContext(
|
|
492
|
+
auth: OAuthAuthDetails,
|
|
493
|
+
client: PluginClient,
|
|
494
|
+
configuredProjectId?: string,
|
|
495
|
+
): Promise<ProjectContextResult> {
|
|
496
|
+
const accessToken = auth.access;
|
|
497
|
+
if (!accessToken) {
|
|
498
|
+
return { auth, effectiveProjectId: "" };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const cacheKey = (() => {
|
|
502
|
+
const base = getCacheKey(auth);
|
|
503
|
+
if (!base) return undefined;
|
|
504
|
+
const project = configuredProjectId?.trim() ?? "";
|
|
505
|
+
return project ? `${base}|cfg:${project}` : base;
|
|
506
|
+
})();
|
|
507
|
+
if (cacheKey) {
|
|
508
|
+
const cached = projectContextResultCache.get(cacheKey);
|
|
509
|
+
if (cached) {
|
|
510
|
+
return cached;
|
|
511
|
+
}
|
|
512
|
+
const pending = projectContextPendingCache.get(cacheKey);
|
|
513
|
+
if (pending) {
|
|
514
|
+
return pending;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const resolveContext = async (): Promise<ProjectContextResult> =>
|
|
519
|
+
resolveProjectContextFromAccessToken(
|
|
520
|
+
auth,
|
|
521
|
+
accessToken,
|
|
522
|
+
configuredProjectId,
|
|
523
|
+
async (updatedAuth) => {
|
|
524
|
+
await client.auth.set({
|
|
525
|
+
path: { id: GEMINI_PROVIDER_ID },
|
|
526
|
+
body: updatedAuth,
|
|
527
|
+
});
|
|
528
|
+
},
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
if (!cacheKey) {
|
|
532
|
+
return resolveContext();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const promise = resolveContext()
|
|
536
|
+
.then((result) => {
|
|
537
|
+
const nextKey = getCacheKey(result.auth) ?? cacheKey;
|
|
538
|
+
projectContextPendingCache.delete(cacheKey);
|
|
539
|
+
projectContextResultCache.set(nextKey, result);
|
|
540
|
+
if (nextKey !== cacheKey) {
|
|
541
|
+
projectContextResultCache.delete(cacheKey);
|
|
542
|
+
}
|
|
543
|
+
return result;
|
|
544
|
+
})
|
|
545
|
+
.catch((error) => {
|
|
546
|
+
projectContextPendingCache.delete(cacheKey);
|
|
547
|
+
throw error;
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
projectContextPendingCache.set(cacheKey, promise);
|
|
551
|
+
return promise;
|
|
552
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { enhanceGeminiErrorResponse } from "./request-helpers";
|
|
4
|
+
|
|
5
|
+
describe("enhanceGeminiErrorResponse", () => {
|
|
6
|
+
it("adds retry hint and rate-limit message for 429 rate limits", () => {
|
|
7
|
+
const body = {
|
|
8
|
+
error: {
|
|
9
|
+
message: "rate limited",
|
|
10
|
+
details: [
|
|
11
|
+
{
|
|
12
|
+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
|
13
|
+
reason: "RATE_LIMIT_EXCEEDED",
|
|
14
|
+
domain: "cloudcode-pa.googleapis.com",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"@type": "type.googleapis.com/google.rpc.RetryInfo",
|
|
18
|
+
retryDelay: "5s",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const result = enhanceGeminiErrorResponse(body, 429);
|
|
25
|
+
expect(result?.retryAfterMs).toBe(5000);
|
|
26
|
+
expect(result?.body?.error?.message).toContain("Rate limit exceeded");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("adds quota exhausted message for terminal limits", () => {
|
|
30
|
+
const body = {
|
|
31
|
+
error: {
|
|
32
|
+
message: "quota exhausted",
|
|
33
|
+
details: [
|
|
34
|
+
{
|
|
35
|
+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
|
36
|
+
reason: "QUOTA_EXHAUSTED",
|
|
37
|
+
domain: "cloudcode-pa.googleapis.com",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const result = enhanceGeminiErrorResponse(body, 429);
|
|
44
|
+
expect(result?.body?.error?.message).toContain("Quota exhausted");
|
|
45
|
+
expect(result?.retryAfterMs).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("adds validation links for VALIDATION_REQUIRED errors", () => {
|
|
49
|
+
const body = {
|
|
50
|
+
error: {
|
|
51
|
+
message: "validation required",
|
|
52
|
+
details: [
|
|
53
|
+
{
|
|
54
|
+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
|
55
|
+
reason: "VALIDATION_REQUIRED",
|
|
56
|
+
domain: "cloudcode-pa.googleapis.com",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"@type": "type.googleapis.com/google.rpc.Help",
|
|
60
|
+
links: [
|
|
61
|
+
{ url: "https://example.com/validate" },
|
|
62
|
+
{ description: "Learn more", url: "https://support.google.com/help" },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const result = enhanceGeminiErrorResponse(body, 403);
|
|
70
|
+
expect(result?.body?.error?.message).toContain("Complete validation: https://example.com/validate");
|
|
71
|
+
expect(result?.body?.error?.message).toContain("Learn more: https://support.google.com/help");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("extracts retry delay from message text when details are missing", () => {
|
|
75
|
+
const body = {
|
|
76
|
+
error: {
|
|
77
|
+
message: "Please retry in 2s",
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const result = enhanceGeminiErrorResponse(body, 503);
|
|
82
|
+
expect(result?.retryAfterMs).toBe(2000);
|
|
83
|
+
});
|
|
84
|
+
});
|