opencode-gemini-auth 1.3.9 → 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 +43 -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} +35 -198
- 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 +106 -476
- package/src/plugin/project.ts +0 -544
- 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
|
|
@@ -189,7 +38,7 @@ export function rewriteGeminiPreviewAccessError(
|
|
|
189
38
|
}
|
|
190
39
|
|
|
191
40
|
/**
|
|
192
|
-
* Enhances Gemini
|
|
41
|
+
* Enhances Gemini errors with validation/quota messaging and retry hints.
|
|
193
42
|
*/
|
|
194
43
|
export function enhanceGeminiErrorResponse(
|
|
195
44
|
body: GeminiApiBody,
|
|
@@ -245,11 +94,7 @@ export function enhanceGeminiErrorResponse(
|
|
|
245
94
|
}
|
|
246
95
|
}
|
|
247
96
|
|
|
248
|
-
|
|
249
|
-
return { retryAfterMs };
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return null;
|
|
97
|
+
return retryAfterMs !== undefined ? { retryAfterMs } : null;
|
|
253
98
|
}
|
|
254
99
|
|
|
255
100
|
function needsPreviewAccessOverride(
|
|
@@ -260,21 +105,14 @@ function needsPreviewAccessOverride(
|
|
|
260
105
|
if (status !== 404) {
|
|
261
106
|
return false;
|
|
262
107
|
}
|
|
263
|
-
|
|
264
108
|
if (isGeminiThreeModel(requestedModel)) {
|
|
265
109
|
return true;
|
|
266
110
|
}
|
|
267
|
-
|
|
268
|
-
const errorMessage = typeof body.error?.message === "string" ? body.error.message : "";
|
|
269
|
-
return isGeminiThreeModel(errorMessage);
|
|
111
|
+
return isGeminiThreeModel(typeof body.error?.message === "string" ? body.error.message : "");
|
|
270
112
|
}
|
|
271
113
|
|
|
272
114
|
function isGeminiThreeModel(target?: string): boolean {
|
|
273
|
-
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return /gemini[\s-]?3/i.test(target);
|
|
115
|
+
return !!target && /gemini[\s-]?3/i.test(target);
|
|
278
116
|
}
|
|
279
117
|
|
|
280
118
|
function extractValidationInfo(details: unknown[]): { link?: string; learnMore?: string } | null {
|
|
@@ -350,17 +188,17 @@ function extractQuotaInfo(details: unknown[]): { retryable: boolean } | null {
|
|
|
350
188
|
(detail as GoogleRpcQuotaFailure)["@type"] === "type.googleapis.com/google.rpc.QuotaFailure",
|
|
351
189
|
);
|
|
352
190
|
|
|
353
|
-
if (quotaFailure?.violations?.length) {
|
|
354
|
-
|
|
355
|
-
.map((violation) => violation.description?.toLowerCase() ?? "")
|
|
356
|
-
.join(" ");
|
|
357
|
-
if (description.includes("daily") || description.includes("per day")) {
|
|
358
|
-
return { retryable: false };
|
|
359
|
-
}
|
|
360
|
-
return { retryable: true };
|
|
191
|
+
if (!quotaFailure?.violations?.length) {
|
|
192
|
+
return null;
|
|
361
193
|
}
|
|
362
194
|
|
|
363
|
-
|
|
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 };
|
|
364
202
|
}
|
|
365
203
|
|
|
366
204
|
function extractRetryDelay(details: unknown[], errorMessage?: string): number | null {
|
|
@@ -378,24 +216,23 @@ function extractRetryDelay(details: unknown[], errorMessage?: string): number |
|
|
|
378
216
|
}
|
|
379
217
|
}
|
|
380
218
|
|
|
381
|
-
if (errorMessage) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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]);
|
|
390
230
|
}
|
|
391
231
|
|
|
392
232
|
return null;
|
|
393
233
|
}
|
|
394
234
|
|
|
395
235
|
function parseRetryDelayValue(value: string | { seconds?: number; nanos?: number }): number | null {
|
|
396
|
-
if (!value) {
|
|
397
|
-
return null;
|
|
398
|
-
}
|
|
399
236
|
if (typeof value === "string") {
|
|
400
237
|
const trimmed = value.trim();
|
|
401
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
|
+
});
|