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/README.md +32 -0
- package/package.json +4 -1
- package/src/plugin/auth.test.ts +58 -0
- package/src/plugin/oauth-authorize.ts +198 -0
- package/src/plugin/project/api.ts +209 -0
- package/src/plugin/project/context.ts +187 -0
- package/src/plugin/project/index.ts +6 -0
- package/src/plugin/project/types.ts +55 -0
- package/src/plugin/project/utils.ts +120 -0
- package/src/plugin/request/identifiers.ts +100 -0
- package/src/plugin/request/index.ts +3 -0
- package/src/plugin/request/openai.ts +128 -0
- package/src/plugin/request/prepare.ts +190 -0
- package/src/plugin/request/response.ts +191 -0
- package/src/plugin/request/shared.ts +72 -0
- package/src/plugin/{request-helpers.ts → request-helpers/errors.ts} +34 -213
- package/src/plugin/request-helpers/index.ts +12 -0
- package/src/plugin/request-helpers/parsing.ts +44 -0
- package/src/plugin/request-helpers/thinking.ts +36 -0
- package/src/plugin/request-helpers/types.ts +78 -0
- package/src/plugin/request-helpers.test.ts +84 -0
- package/src/plugin/request.test.ts +91 -0
- package/src/plugin/retry/helpers.ts +175 -0
- package/src/plugin/retry/index.ts +81 -0
- package/src/plugin/retry/quota.ts +210 -0
- package/src/plugin/retry.test.ts +106 -0
- package/src/plugin/token.test.ts +31 -0
- package/src/plugin/token.ts +24 -0
- package/src/plugin.ts +102 -588
- package/src/plugin/project.ts +0 -551
- package/src/plugin/request.ts +0 -483
|
@@ -1,164 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
CLOUDCODE_DOMAINS,
|
|
3
|
+
GEMINI_PREVIEW_LINK,
|
|
4
|
+
type GeminiApiBody,
|
|
5
|
+
type GeminiErrorEnhancement,
|
|
6
|
+
type GoogleRpcErrorInfo,
|
|
7
|
+
type GoogleRpcHelp,
|
|
8
|
+
type GoogleRpcQuotaFailure,
|
|
9
|
+
type GoogleRpcRetryInfo,
|
|
10
|
+
} from "./types";
|
|
162
11
|
|
|
163
12
|
/**
|
|
164
13
|
* Enhances 404 errors for Gemini 3 models with a direct preview-access message.
|
|
@@ -172,7 +21,7 @@ export function rewriteGeminiPreviewAccessError(
|
|
|
172
21
|
return null;
|
|
173
22
|
}
|
|
174
23
|
|
|
175
|
-
const error
|
|
24
|
+
const error = body.error ?? {};
|
|
176
25
|
const trimmedMessage = typeof error.message === "string" ? error.message.trim() : "";
|
|
177
26
|
const messagePrefix = trimmedMessage.length > 0
|
|
178
27
|
? trimmedMessage
|
|
@@ -188,12 +37,8 @@ export function rewriteGeminiPreviewAccessError(
|
|
|
188
37
|
};
|
|
189
38
|
}
|
|
190
39
|
|
|
191
|
-
/**
|
|
192
|
-
* Enhances Gemini error responses with friendly messages and retry hints.
|
|
193
|
-
*/
|
|
194
40
|
/**
|
|
195
41
|
* Enhances Gemini errors with validation/quota messaging and retry hints.
|
|
196
|
-
* Keeps messaging aligned with Gemini CLI's Cloud Code error handling.
|
|
197
42
|
*/
|
|
198
43
|
export function enhanceGeminiErrorResponse(
|
|
199
44
|
body: GeminiApiBody,
|
|
@@ -249,11 +94,7 @@ export function enhanceGeminiErrorResponse(
|
|
|
249
94
|
}
|
|
250
95
|
}
|
|
251
96
|
|
|
252
|
-
|
|
253
|
-
return { retryAfterMs };
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return null;
|
|
97
|
+
return retryAfterMs !== undefined ? { retryAfterMs } : null;
|
|
257
98
|
}
|
|
258
99
|
|
|
259
100
|
function needsPreviewAccessOverride(
|
|
@@ -264,26 +105,16 @@ function needsPreviewAccessOverride(
|
|
|
264
105
|
if (status !== 404) {
|
|
265
106
|
return false;
|
|
266
107
|
}
|
|
267
|
-
|
|
268
108
|
if (isGeminiThreeModel(requestedModel)) {
|
|
269
109
|
return true;
|
|
270
110
|
}
|
|
271
|
-
|
|
272
|
-
const errorMessage = typeof body.error?.message === "string" ? body.error.message : "";
|
|
273
|
-
return isGeminiThreeModel(errorMessage);
|
|
111
|
+
return isGeminiThreeModel(typeof body.error?.message === "string" ? body.error.message : "");
|
|
274
112
|
}
|
|
275
113
|
|
|
276
114
|
function isGeminiThreeModel(target?: string): boolean {
|
|
277
|
-
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return /gemini[\s-]?3/i.test(target);
|
|
115
|
+
return !!target && /gemini[\s-]?3/i.test(target);
|
|
282
116
|
}
|
|
283
117
|
|
|
284
|
-
/**
|
|
285
|
-
* Extracts validation URLs when the backend requires account verification.
|
|
286
|
-
*/
|
|
287
118
|
function extractValidationInfo(details: unknown[]): { link?: string; learnMore?: string } | null {
|
|
288
119
|
const errorInfo = details.find(
|
|
289
120
|
(detail): detail is GoogleRpcErrorInfo =>
|
|
@@ -335,9 +166,6 @@ function extractValidationInfo(details: unknown[]): { link?: string; learnMore?:
|
|
|
335
166
|
return link || learnMore ? { link, learnMore } : null;
|
|
336
167
|
}
|
|
337
168
|
|
|
338
|
-
/**
|
|
339
|
-
* Classifies quota-related error details as retryable or terminal.
|
|
340
|
-
*/
|
|
341
169
|
function extractQuotaInfo(details: unknown[]): { retryable: boolean } | null {
|
|
342
170
|
const errorInfo = details.find(
|
|
343
171
|
(detail): detail is GoogleRpcErrorInfo =>
|
|
@@ -360,22 +188,19 @@ function extractQuotaInfo(details: unknown[]): { retryable: boolean } | null {
|
|
|
360
188
|
(detail as GoogleRpcQuotaFailure)["@type"] === "type.googleapis.com/google.rpc.QuotaFailure",
|
|
361
189
|
);
|
|
362
190
|
|
|
363
|
-
if (quotaFailure?.violations?.length) {
|
|
364
|
-
|
|
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 };
|
|
191
|
+
if (!quotaFailure?.violations?.length) {
|
|
192
|
+
return null;
|
|
371
193
|
}
|
|
372
194
|
|
|
373
|
-
|
|
195
|
+
const description = quotaFailure.violations
|
|
196
|
+
.map((violation) => violation.description?.toLowerCase() ?? "")
|
|
197
|
+
.join(" ");
|
|
198
|
+
if (description.includes("daily") || description.includes("per day")) {
|
|
199
|
+
return { retryable: false };
|
|
200
|
+
}
|
|
201
|
+
return { retryable: true };
|
|
374
202
|
}
|
|
375
203
|
|
|
376
|
-
/**
|
|
377
|
-
* Extracts retry delay hints from structured error details or message text.
|
|
378
|
-
*/
|
|
379
204
|
function extractRetryDelay(details: unknown[], errorMessage?: string): number | null {
|
|
380
205
|
const retryInfo = details.find(
|
|
381
206
|
(detail): detail is GoogleRpcRetryInfo =>
|
|
@@ -391,27 +216,23 @@ function extractRetryDelay(details: unknown[], errorMessage?: string): number |
|
|
|
391
216
|
}
|
|
392
217
|
}
|
|
393
218
|
|
|
394
|
-
if (errorMessage) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
219
|
+
if (!errorMessage) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const retryMatch = errorMessage.match(/Please retry in ([0-9.]+(?:ms|s))/);
|
|
224
|
+
if (retryMatch?.[1]) {
|
|
225
|
+
return parseRetryDelayValue(retryMatch[1]);
|
|
226
|
+
}
|
|
227
|
+
const resetMatch = errorMessage.match(/after\s+([0-9.]+(?:ms|s))/i);
|
|
228
|
+
if (resetMatch?.[1]) {
|
|
229
|
+
return parseRetryDelayValue(resetMatch[1]);
|
|
403
230
|
}
|
|
404
231
|
|
|
405
232
|
return null;
|
|
406
233
|
}
|
|
407
234
|
|
|
408
|
-
/**
|
|
409
|
-
* Parses retry delay values from strings or protobuf-style objects.
|
|
410
|
-
*/
|
|
411
235
|
function parseRetryDelayValue(value: string | { seconds?: number; nanos?: number }): number | null {
|
|
412
|
-
if (!value) {
|
|
413
|
-
return null;
|
|
414
|
-
}
|
|
415
236
|
if (typeof value === "string") {
|
|
416
237
|
const trimmed = value.trim();
|
|
417
238
|
if (!trimmed) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CLOUDCODE_DOMAINS,
|
|
3
|
+
GEMINI_PREVIEW_LINK,
|
|
4
|
+
type GeminiApiBody,
|
|
5
|
+
type GeminiApiError,
|
|
6
|
+
type GeminiErrorEnhancement,
|
|
7
|
+
type GeminiUsageMetadata,
|
|
8
|
+
type ThinkingConfig,
|
|
9
|
+
} from "./types";
|
|
10
|
+
export { normalizeThinkingConfig } from "./thinking";
|
|
11
|
+
export { parseGeminiApiBody, extractUsageMetadata } from "./parsing";
|
|
12
|
+
export { rewriteGeminiPreviewAccessError, enhanceGeminiErrorResponse } from "./errors";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { GeminiApiBody, GeminiUsageMetadata } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses a Gemini API body; handles array-wrapped responses the API sometimes returns.
|
|
5
|
+
*/
|
|
6
|
+
export function parseGeminiApiBody(rawText: string): GeminiApiBody | null {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(rawText);
|
|
9
|
+
if (Array.isArray(parsed)) {
|
|
10
|
+
const firstObject = parsed.find((item: unknown) => typeof item === "object" && item !== null);
|
|
11
|
+
return firstObject && typeof firstObject === "object"
|
|
12
|
+
? (firstObject as GeminiApiBody)
|
|
13
|
+
: null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return parsed && typeof parsed === "object" ? (parsed as GeminiApiBody) : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extracts usageMetadata from a response object, guarding types.
|
|
24
|
+
*/
|
|
25
|
+
export function extractUsageMetadata(body: GeminiApiBody): GeminiUsageMetadata | null {
|
|
26
|
+
const usage = (body.response && typeof body.response === "object"
|
|
27
|
+
? (body.response as { usageMetadata?: unknown }).usageMetadata
|
|
28
|
+
: undefined) as GeminiUsageMetadata | undefined;
|
|
29
|
+
|
|
30
|
+
if (!usage || typeof usage !== "object") {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const asRecord = usage as Record<string, unknown>;
|
|
35
|
+
const toNumber = (value: unknown): number | undefined =>
|
|
36
|
+
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
totalTokenCount: toNumber(asRecord.totalTokenCount),
|
|
40
|
+
promptTokenCount: toNumber(asRecord.promptTokenCount),
|
|
41
|
+
candidatesTokenCount: toNumber(asRecord.candidatesTokenCount),
|
|
42
|
+
cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ThinkingConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes thinkingConfig aliases into canonical Gemini field names.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined {
|
|
7
|
+
if (!config || typeof config !== "object") {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const record = config as Record<string, unknown>;
|
|
12
|
+
const budgetRaw = record.thinkingBudget ?? record.thinking_budget;
|
|
13
|
+
const levelRaw = record.thinkingLevel ?? record.thinking_level;
|
|
14
|
+
const includeRaw = record.includeThoughts ?? record.include_thoughts;
|
|
15
|
+
|
|
16
|
+
const thinkingBudget = typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined;
|
|
17
|
+
const thinkingLevel = typeof levelRaw === "string" && levelRaw.length > 0 ? levelRaw.toLowerCase() : undefined;
|
|
18
|
+
const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined;
|
|
19
|
+
|
|
20
|
+
if (thinkingBudget === undefined && thinkingLevel === undefined && includeThoughts === undefined) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const normalized: ThinkingConfig = {};
|
|
25
|
+
if (thinkingBudget !== undefined) {
|
|
26
|
+
normalized.thinkingBudget = thinkingBudget;
|
|
27
|
+
}
|
|
28
|
+
if (thinkingLevel !== undefined) {
|
|
29
|
+
normalized.thinkingLevel = thinkingLevel;
|
|
30
|
+
}
|
|
31
|
+
if (includeThoughts !== undefined) {
|
|
32
|
+
normalized.includeThoughts = includeThoughts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export 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
|
+
export interface GeminiErrorEnhancement {
|
|
21
|
+
body?: GeminiApiBody;
|
|
22
|
+
retryAfterMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Usage metadata exposed by Gemini responses. Fields are optional to reflect partial payloads.
|
|
27
|
+
*/
|
|
28
|
+
export interface GeminiUsageMetadata {
|
|
29
|
+
totalTokenCount?: number;
|
|
30
|
+
promptTokenCount?: number;
|
|
31
|
+
candidatesTokenCount?: number;
|
|
32
|
+
cachedContentTokenCount?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Thinking configuration accepted by Gemini.
|
|
37
|
+
* - Gemini 3 models use thinkingLevel (string: 'low', 'medium', 'high')
|
|
38
|
+
* - Gemini 2.5 models use thinkingBudget (number)
|
|
39
|
+
*/
|
|
40
|
+
export interface ThinkingConfig {
|
|
41
|
+
thinkingBudget?: number;
|
|
42
|
+
thinkingLevel?: string;
|
|
43
|
+
includeThoughts?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GoogleRpcErrorInfo {
|
|
47
|
+
"@type"?: string;
|
|
48
|
+
reason?: string;
|
|
49
|
+
domain?: string;
|
|
50
|
+
metadata?: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GoogleRpcHelp {
|
|
54
|
+
"@type"?: string;
|
|
55
|
+
links?: Array<{
|
|
56
|
+
description?: string;
|
|
57
|
+
url?: string;
|
|
58
|
+
}>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface GoogleRpcQuotaFailure {
|
|
62
|
+
"@type"?: string;
|
|
63
|
+
violations?: Array<{
|
|
64
|
+
subject?: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface GoogleRpcRetryInfo {
|
|
70
|
+
"@type"?: string;
|
|
71
|
+
retryDelay?: string | { seconds?: number; nanos?: number };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const CLOUDCODE_DOMAINS = [
|
|
75
|
+
"cloudcode-pa.googleapis.com",
|
|
76
|
+
"staging-cloudcode-pa.googleapis.com",
|
|
77
|
+
"autopush-cloudcode-pa.googleapis.com",
|
|
78
|
+
];
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { GEMINI_CODE_ASSIST_ENDPOINT } from "../constants";
|
|
4
|
+
import {
|
|
5
|
+
isGenerativeLanguageRequest,
|
|
6
|
+
prepareGeminiRequest,
|
|
7
|
+
transformGeminiResponse,
|
|
8
|
+
} from "./request";
|
|
9
|
+
|
|
10
|
+
describe("request helpers", () => {
|
|
11
|
+
it("detects generativelanguage URLs", () => {
|
|
12
|
+
expect(
|
|
13
|
+
isGenerativeLanguageRequest(
|
|
14
|
+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent",
|
|
15
|
+
),
|
|
16
|
+
).toBe(true);
|
|
17
|
+
expect(isGenerativeLanguageRequest("https://example.com/foo")).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("wraps requests for Gemini Code Assist streaming", () => {
|
|
21
|
+
const input =
|
|
22
|
+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:streamGenerateContent";
|
|
23
|
+
const init: RequestInit = {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
"x-api-key": "should-be-removed",
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify({
|
|
30
|
+
contents: [{ role: "user", parts: [{ text: "hi" }] }],
|
|
31
|
+
system_instruction: { parts: [{ text: "system" }] },
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const result = prepareGeminiRequest(input, init, "token-123", "project-456");
|
|
36
|
+
|
|
37
|
+
expect(result.streaming).toBe(true);
|
|
38
|
+
expect(typeof result.request).toBe("string");
|
|
39
|
+
expect(result.request).toBe(
|
|
40
|
+
`${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const headers = new Headers(result.init.headers);
|
|
44
|
+
expect(headers.get("Authorization")).toBe("Bearer token-123");
|
|
45
|
+
expect(headers.get("x-api-key")).toBeNull();
|
|
46
|
+
expect(headers.get("Accept")).toBe("text/event-stream");
|
|
47
|
+
expect(headers.get("x-activity-request-id")).toBeTruthy();
|
|
48
|
+
|
|
49
|
+
const parsed = JSON.parse(result.init.body as string) as Record<string, unknown>;
|
|
50
|
+
expect(parsed.project).toBe("project-456");
|
|
51
|
+
expect(parsed.model).toBe("gemini-3-flash-preview");
|
|
52
|
+
expect(parsed.user_prompt_id).toBeTruthy();
|
|
53
|
+
expect((parsed.request as Record<string, unknown>).session_id).toBeTruthy();
|
|
54
|
+
expect((parsed.request as Record<string, unknown>).systemInstruction).toBeDefined();
|
|
55
|
+
expect((parsed.request as Record<string, unknown>).system_instruction).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("maps traceId to responseId for JSON responses", async () => {
|
|
59
|
+
const response = new Response(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
traceId: "trace-123",
|
|
62
|
+
response: {
|
|
63
|
+
candidates: [],
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
{
|
|
67
|
+
status: 200,
|
|
68
|
+
headers: { "content-type": "application/json" },
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const transformed = await transformGeminiResponse(response, false);
|
|
73
|
+
const parsed = (await transformed.json()) as Record<string, unknown>;
|
|
74
|
+
expect(parsed.responseId).toBe("trace-123");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("maps traceId to responseId for streaming payloads", async () => {
|
|
78
|
+
const response = new Response(
|
|
79
|
+
'data: {"traceId":"trace-456","response":{"candidates":[]}}\n\n',
|
|
80
|
+
{
|
|
81
|
+
status: 200,
|
|
82
|
+
headers: { "content-type": "text/event-stream" },
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const transformed = await transformGeminiResponse(response, true);
|
|
87
|
+
const payload = await transformed.text();
|
|
88
|
+
expect(payload).toContain('"responseId":"trace-456"');
|
|
89
|
+
expect(payload).not.toContain('"traceId"');
|
|
90
|
+
});
|
|
91
|
+
});
|