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.
@@ -0,0 +1,439 @@
1
+ const GEMINI_PREVIEW_LINK = "https://goo.gle/enable-preview-features";
2
+
3
+ export interface GeminiApiError {
4
+ code?: number;
5
+ message?: string;
6
+ status?: string;
7
+ details?: unknown[];
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ /**
12
+ * Minimal representation of Gemini API responses we touch.
13
+ */
14
+ export interface GeminiApiBody {
15
+ response?: unknown;
16
+ error?: GeminiApiError;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ interface GoogleRpcErrorInfo {
21
+ "@type"?: string;
22
+ reason?: string;
23
+ domain?: string;
24
+ metadata?: Record<string, string>;
25
+ }
26
+
27
+ interface GoogleRpcHelp {
28
+ "@type"?: string;
29
+ links?: Array<{
30
+ description?: string;
31
+ url?: string;
32
+ }>;
33
+ }
34
+
35
+ interface GoogleRpcQuotaFailure {
36
+ "@type"?: string;
37
+ violations?: Array<{
38
+ subject?: string;
39
+ description?: string;
40
+ }>;
41
+ }
42
+
43
+ interface GoogleRpcRetryInfo {
44
+ "@type"?: string;
45
+ retryDelay?: string | { seconds?: number; nanos?: number };
46
+ }
47
+
48
+ const CLOUDCODE_DOMAINS = [
49
+ "cloudcode-pa.googleapis.com",
50
+ "staging-cloudcode-pa.googleapis.com",
51
+ "autopush-cloudcode-pa.googleapis.com",
52
+ ];
53
+
54
+ export interface GeminiErrorEnhancement {
55
+ body?: GeminiApiBody;
56
+ retryAfterMs?: number;
57
+ }
58
+
59
+ /**
60
+ * Usage metadata exposed by Gemini responses. Fields are optional to reflect partial payloads.
61
+ */
62
+ export interface GeminiUsageMetadata {
63
+ totalTokenCount?: number;
64
+ promptTokenCount?: number;
65
+ candidatesTokenCount?: number;
66
+ cachedContentTokenCount?: number;
67
+ }
68
+
69
+ /**
70
+ * Thinking configuration accepted by Gemini.
71
+ * - Gemini 3 models use thinkingLevel (string: 'low', 'medium', 'high')
72
+ * - Gemini 2.5 models use thinkingBudget (number)
73
+ */
74
+ export interface ThinkingConfig {
75
+ thinkingBudget?: number;
76
+ thinkingLevel?: string;
77
+ includeThoughts?: boolean;
78
+ }
79
+
80
+ /**
81
+ * Normalizes thinkingConfig - passes through values as-is without mapping.
82
+ * User should use thinkingLevel for Gemini 3 and thinkingBudget for Gemini 2.5.
83
+ */
84
+ export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined {
85
+ if (!config || typeof config !== "object") {
86
+ return undefined;
87
+ }
88
+
89
+ const record = config as Record<string, unknown>;
90
+ const budgetRaw = record.thinkingBudget ?? record.thinking_budget;
91
+ const levelRaw = record.thinkingLevel ?? record.thinking_level;
92
+ const includeRaw = record.includeThoughts ?? record.include_thoughts;
93
+
94
+ const thinkingBudget = typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined;
95
+ const thinkingLevel = typeof levelRaw === "string" && levelRaw.length > 0 ? levelRaw.toLowerCase() : undefined;
96
+ const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined;
97
+
98
+ if (thinkingBudget === undefined && thinkingLevel === undefined && includeThoughts === undefined) {
99
+ return undefined;
100
+ }
101
+
102
+ const normalized: ThinkingConfig = {};
103
+ if (thinkingBudget !== undefined) {
104
+ normalized.thinkingBudget = thinkingBudget;
105
+ }
106
+ if (thinkingLevel !== undefined) {
107
+ normalized.thinkingLevel = thinkingLevel;
108
+ }
109
+ if (includeThoughts !== undefined) {
110
+ normalized.includeThoughts = includeThoughts;
111
+ }
112
+ return normalized;
113
+ }
114
+
115
+ /**
116
+ * Parses a Gemini API body; handles array-wrapped responses the API sometimes returns.
117
+ */
118
+ export function parseGeminiApiBody(rawText: string): GeminiApiBody | null {
119
+ try {
120
+ const parsed = JSON.parse(rawText);
121
+ if (Array.isArray(parsed)) {
122
+ const firstObject = parsed.find((item: unknown) => typeof item === "object" && item !== null);
123
+ if (firstObject && typeof firstObject === "object") {
124
+ return firstObject as GeminiApiBody;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ if (parsed && typeof parsed === "object") {
130
+ return parsed as GeminiApiBody;
131
+ }
132
+
133
+ return null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Extracts usageMetadata from a response object, guarding types.
141
+ */
142
+ export function extractUsageMetadata(body: GeminiApiBody): GeminiUsageMetadata | null {
143
+ const usage = (body.response && typeof body.response === "object"
144
+ ? (body.response as { usageMetadata?: unknown }).usageMetadata
145
+ : undefined) as GeminiUsageMetadata | undefined;
146
+
147
+ if (!usage || typeof usage !== "object") {
148
+ return null;
149
+ }
150
+
151
+ const asRecord = usage as Record<string, unknown>;
152
+ const toNumber = (value: unknown): number | undefined =>
153
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
154
+
155
+ return {
156
+ totalTokenCount: toNumber(asRecord.totalTokenCount),
157
+ promptTokenCount: toNumber(asRecord.promptTokenCount),
158
+ candidatesTokenCount: toNumber(asRecord.candidatesTokenCount),
159
+ cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Enhances 404 errors for Gemini 3 models with a direct preview-access message.
165
+ */
166
+ export function rewriteGeminiPreviewAccessError(
167
+ body: GeminiApiBody,
168
+ status: number,
169
+ requestedModel?: string,
170
+ ): GeminiApiBody | null {
171
+ if (!needsPreviewAccessOverride(status, body, requestedModel)) {
172
+ return null;
173
+ }
174
+
175
+ const error: GeminiApiError = body.error ?? {};
176
+ const trimmedMessage = typeof error.message === "string" ? error.message.trim() : "";
177
+ const messagePrefix = trimmedMessage.length > 0
178
+ ? trimmedMessage
179
+ : "Gemini 3 preview features are not enabled for this account.";
180
+ const enhancedMessage = `${messagePrefix} Request preview access at ${GEMINI_PREVIEW_LINK} before using Gemini 3 models.`;
181
+
182
+ return {
183
+ ...body,
184
+ error: {
185
+ ...error,
186
+ message: enhancedMessage,
187
+ },
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Enhances Gemini error responses with friendly messages and retry hints.
193
+ */
194
+ /**
195
+ * Enhances Gemini errors with validation/quota messaging and retry hints.
196
+ * Keeps messaging aligned with Gemini CLI's Cloud Code error handling.
197
+ */
198
+ export function enhanceGeminiErrorResponse(
199
+ body: GeminiApiBody,
200
+ status: number,
201
+ ): GeminiErrorEnhancement | null {
202
+ const error = body.error;
203
+ if (!error) {
204
+ return null;
205
+ }
206
+
207
+ const details = Array.isArray(error.details) ? error.details : [];
208
+ const retryAfterMs = extractRetryDelay(details, error.message) ?? undefined;
209
+
210
+ if (status === 403) {
211
+ const validationInfo = extractValidationInfo(details);
212
+ if (validationInfo) {
213
+ const message = [
214
+ error.message ?? "Account validation required for Gemini Code Assist.",
215
+ validationInfo.link ? `Complete validation: ${validationInfo.link}` : undefined,
216
+ validationInfo.learnMore ? `Learn more: ${validationInfo.learnMore}` : undefined,
217
+ ]
218
+ .filter(Boolean)
219
+ .join(" ");
220
+ return {
221
+ body: {
222
+ ...body,
223
+ error: {
224
+ ...error,
225
+ message,
226
+ },
227
+ },
228
+ retryAfterMs,
229
+ };
230
+ }
231
+ }
232
+
233
+ if (status === 429) {
234
+ const quotaInfo = extractQuotaInfo(details);
235
+ if (quotaInfo) {
236
+ const message = quotaInfo.retryable
237
+ ? `Rate limit exceeded. ${retryAfterMs ? "Please retry shortly." : "Please retry."}`
238
+ : "Quota exhausted for this account. Please wait for your quota to reset or upgrade your plan.";
239
+ return {
240
+ body: {
241
+ ...body,
242
+ error: {
243
+ ...error,
244
+ message,
245
+ },
246
+ },
247
+ retryAfterMs,
248
+ };
249
+ }
250
+ }
251
+
252
+ if (retryAfterMs !== undefined) {
253
+ return { retryAfterMs };
254
+ }
255
+
256
+ return null;
257
+ }
258
+
259
+ function needsPreviewAccessOverride(
260
+ status: number,
261
+ body: GeminiApiBody,
262
+ requestedModel?: string,
263
+ ): boolean {
264
+ if (status !== 404) {
265
+ return false;
266
+ }
267
+
268
+ if (isGeminiThreeModel(requestedModel)) {
269
+ return true;
270
+ }
271
+
272
+ const errorMessage = typeof body.error?.message === "string" ? body.error.message : "";
273
+ return isGeminiThreeModel(errorMessage);
274
+ }
275
+
276
+ function isGeminiThreeModel(target?: string): boolean {
277
+ if (!target) {
278
+ return false;
279
+ }
280
+
281
+ return /gemini[\s-]?3/i.test(target);
282
+ }
283
+
284
+ /**
285
+ * Extracts validation URLs when the backend requires account verification.
286
+ */
287
+ function extractValidationInfo(details: unknown[]): { link?: string; learnMore?: string } | null {
288
+ const errorInfo = details.find(
289
+ (detail): detail is GoogleRpcErrorInfo =>
290
+ typeof detail === "object" &&
291
+ detail !== null &&
292
+ (detail as GoogleRpcErrorInfo)["@type"] === "type.googleapis.com/google.rpc.ErrorInfo",
293
+ );
294
+
295
+ if (
296
+ !errorInfo ||
297
+ errorInfo.reason !== "VALIDATION_REQUIRED" ||
298
+ !errorInfo.domain ||
299
+ !CLOUDCODE_DOMAINS.includes(errorInfo.domain)
300
+ ) {
301
+ return null;
302
+ }
303
+
304
+ const helpDetail = details.find(
305
+ (detail): detail is GoogleRpcHelp =>
306
+ typeof detail === "object" &&
307
+ detail !== null &&
308
+ (detail as GoogleRpcHelp)["@type"] === "type.googleapis.com/google.rpc.Help",
309
+ );
310
+
311
+ let link: string | undefined;
312
+ let learnMore: string | undefined;
313
+ if (helpDetail?.links && helpDetail.links.length > 0) {
314
+ link = helpDetail.links[0]?.url;
315
+ const learnMoreLink = helpDetail.links.find((candidate) => {
316
+ if (!candidate?.url) {
317
+ return false;
318
+ }
319
+ if (candidate.description?.toLowerCase().trim() === "learn more") {
320
+ return true;
321
+ }
322
+ try {
323
+ return new URL(candidate.url).hostname === "support.google.com";
324
+ } catch {
325
+ return false;
326
+ }
327
+ });
328
+ learnMore = learnMoreLink?.url;
329
+ }
330
+
331
+ if (!link && errorInfo.metadata?.validation_link) {
332
+ link = errorInfo.metadata.validation_link;
333
+ }
334
+
335
+ return link || learnMore ? { link, learnMore } : null;
336
+ }
337
+
338
+ /**
339
+ * Classifies quota-related error details as retryable or terminal.
340
+ */
341
+ function extractQuotaInfo(details: unknown[]): { retryable: boolean } | null {
342
+ const errorInfo = details.find(
343
+ (detail): detail is GoogleRpcErrorInfo =>
344
+ typeof detail === "object" &&
345
+ detail !== null &&
346
+ (detail as GoogleRpcErrorInfo)["@type"] === "type.googleapis.com/google.rpc.ErrorInfo",
347
+ );
348
+
349
+ if (errorInfo?.reason === "RATE_LIMIT_EXCEEDED") {
350
+ return { retryable: true };
351
+ }
352
+ if (errorInfo?.reason === "QUOTA_EXHAUSTED") {
353
+ return { retryable: false };
354
+ }
355
+
356
+ const quotaFailure = details.find(
357
+ (detail): detail is GoogleRpcQuotaFailure =>
358
+ typeof detail === "object" &&
359
+ detail !== null &&
360
+ (detail as GoogleRpcQuotaFailure)["@type"] === "type.googleapis.com/google.rpc.QuotaFailure",
361
+ );
362
+
363
+ if (quotaFailure?.violations?.length) {
364
+ const description = quotaFailure.violations
365
+ .map((violation) => violation.description?.toLowerCase() ?? "")
366
+ .join(" ");
367
+ if (description.includes("daily") || description.includes("per day")) {
368
+ return { retryable: false };
369
+ }
370
+ return { retryable: true };
371
+ }
372
+
373
+ return null;
374
+ }
375
+
376
+ /**
377
+ * Extracts retry delay hints from structured error details or message text.
378
+ */
379
+ function extractRetryDelay(details: unknown[], errorMessage?: string): number | null {
380
+ const retryInfo = details.find(
381
+ (detail): detail is GoogleRpcRetryInfo =>
382
+ typeof detail === "object" &&
383
+ detail !== null &&
384
+ (detail as GoogleRpcRetryInfo)["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
385
+ );
386
+
387
+ if (retryInfo?.retryDelay) {
388
+ const delayMs = parseRetryDelayValue(retryInfo.retryDelay);
389
+ if (delayMs !== null) {
390
+ return delayMs;
391
+ }
392
+ }
393
+
394
+ if (errorMessage) {
395
+ const retryMatch = errorMessage.match(/Please retry in ([0-9.]+(?:ms|s))/);
396
+ if (retryMatch?.[1]) {
397
+ return parseRetryDelayValue(retryMatch[1]);
398
+ }
399
+ const resetMatch = errorMessage.match(/after\s+([0-9.]+(?:ms|s))/i);
400
+ if (resetMatch?.[1]) {
401
+ return parseRetryDelayValue(resetMatch[1]);
402
+ }
403
+ }
404
+
405
+ return null;
406
+ }
407
+
408
+ /**
409
+ * Parses retry delay values from strings or protobuf-style objects.
410
+ */
411
+ function parseRetryDelayValue(value: string | { seconds?: number; nanos?: number }): number | null {
412
+ if (!value) {
413
+ return null;
414
+ }
415
+ if (typeof value === "string") {
416
+ const trimmed = value.trim();
417
+ if (!trimmed) {
418
+ return null;
419
+ }
420
+ if (trimmed.endsWith("ms")) {
421
+ const ms = Number(trimmed.slice(0, -2));
422
+ return Number.isFinite(ms) && ms > 0 ? Math.round(ms) : null;
423
+ }
424
+ const match = trimmed.match(/^([\d.]+)s$/);
425
+ if (match?.[1]) {
426
+ const seconds = Number(match[1]);
427
+ return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : null;
428
+ }
429
+ return null;
430
+ }
431
+
432
+ const seconds = typeof value.seconds === "number" ? value.seconds : 0;
433
+ const nanos = typeof value.nanos === "number" ? value.nanos : 0;
434
+ if (!Number.isFinite(seconds) || !Number.isFinite(nanos)) {
435
+ return null;
436
+ }
437
+ const totalMs = Math.round(seconds * 1000 + nanos / 1e6);
438
+ return totalMs > 0 ? totalMs : null;
439
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { GEMINI_CODE_ASSIST_ENDPOINT } from "../constants";
4
+ import { isGenerativeLanguageRequest, prepareGeminiRequest } from "./request";
5
+
6
+ describe("request helpers", () => {
7
+ it("detects generativelanguage URLs", () => {
8
+ expect(
9
+ isGenerativeLanguageRequest(
10
+ "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent",
11
+ ),
12
+ ).toBe(true);
13
+ expect(isGenerativeLanguageRequest("https://example.com/foo")).toBe(false);
14
+ });
15
+
16
+ it("wraps requests for Gemini Code Assist streaming", () => {
17
+ const input =
18
+ "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:streamGenerateContent";
19
+ const init: RequestInit = {
20
+ method: "POST",
21
+ headers: {
22
+ "Content-Type": "application/json",
23
+ "x-api-key": "should-be-removed",
24
+ },
25
+ body: JSON.stringify({
26
+ contents: [{ role: "user", parts: [{ text: "hi" }] }],
27
+ system_instruction: { parts: [{ text: "system" }] },
28
+ }),
29
+ };
30
+
31
+ const result = prepareGeminiRequest(input, init, "token-123", "project-456");
32
+
33
+ expect(result.streaming).toBe(true);
34
+ expect(typeof result.request).toBe("string");
35
+ expect(result.request).toBe(
36
+ `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`,
37
+ );
38
+
39
+ const headers = new Headers(result.init.headers);
40
+ expect(headers.get("Authorization")).toBe("Bearer token-123");
41
+ expect(headers.get("x-api-key")).toBeNull();
42
+ expect(headers.get("Accept")).toBe("text/event-stream");
43
+
44
+ const parsed = JSON.parse(result.init.body as string) as Record<string, unknown>;
45
+ expect(parsed.project).toBe("project-456");
46
+ expect(parsed.model).toBe("gemini-3-flash-preview");
47
+ expect((parsed.request as Record<string, unknown>).systemInstruction).toBeDefined();
48
+ expect((parsed.request as Record<string, unknown>).system_instruction).toBeUndefined();
49
+ });
50
+ });