opencode-gemini-auth 1.3.6 → 1.3.8
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 +54 -10
- package/package.json +2 -2
- package/src/gemini/oauth.ts +71 -45
- package/src/plugin/request.ts +134 -0
- package/src/plugin.ts +251 -12
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ directly within Opencode, bypassing separate API billing.
|
|
|
15
15
|
## Installation
|
|
16
16
|
|
|
17
17
|
Add the plugin to your Opencode configuration file
|
|
18
|
-
(`~/.config/opencode/
|
|
18
|
+
(`~/.config/opencode/opencode.json` or similar):
|
|
19
19
|
|
|
20
20
|
```json
|
|
21
21
|
{
|
|
@@ -37,7 +37,7 @@ Add the plugin to your Opencode configuration file
|
|
|
37
37
|
- A browser window will open for you to approve the access.
|
|
38
38
|
- The plugin spins up a temporary local server to capture the callback.
|
|
39
39
|
- If the local server fails (e.g., port in use or headless environment),
|
|
40
|
-
you can manually
|
|
40
|
+
you can manually paste the callback URL or just the authorization code.
|
|
41
41
|
|
|
42
42
|
Once authenticated, Opencode will use your Google account for Gemini requests.
|
|
43
43
|
|
|
@@ -60,19 +60,41 @@ project. To force a specific project, set the `projectId` in your configuration:
|
|
|
60
60
|
}
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
###
|
|
64
|
-
|
|
65
|
-
Configure "thinking" capabilities for Gemini models using the `thinkingConfig`
|
|
66
|
-
option in your `config.json`.
|
|
63
|
+
### Model list
|
|
67
64
|
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
Below are example model entries you can add under `provider.google.models` in your
|
|
66
|
+
Opencode config. Each model can include an `options.thinkingConfig` block to
|
|
67
|
+
enable "thinking" features.
|
|
70
68
|
|
|
71
69
|
```json
|
|
72
70
|
{
|
|
73
71
|
"provider": {
|
|
74
72
|
"google": {
|
|
75
73
|
"models": {
|
|
74
|
+
"gemini-2.5-flash": {
|
|
75
|
+
"options": {
|
|
76
|
+
"thinkingConfig": {
|
|
77
|
+
"thinkingBudget": 8192,
|
|
78
|
+
"includeThoughts": true
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"gemini-2.5-pro": {
|
|
83
|
+
"options": {
|
|
84
|
+
"thinkingConfig": {
|
|
85
|
+
"thinkingBudget": 8192,
|
|
86
|
+
"includeThoughts": true
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"gemini-3-flash-preview": {
|
|
91
|
+
"options": {
|
|
92
|
+
"thinkingConfig": {
|
|
93
|
+
"thinkingLevel": "high",
|
|
94
|
+
"includeThoughts": true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
76
98
|
"gemini-3-pro-preview": {
|
|
77
99
|
"options": {
|
|
78
100
|
"thinkingConfig": {
|
|
@@ -87,14 +109,33 @@ Use `thinkingLevel` (`"low"`, `"high"`) for Gemini 3 models.
|
|
|
87
109
|
}
|
|
88
110
|
```
|
|
89
111
|
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
Note: Available model names and previews may change—check Google's documentation or
|
|
113
|
+
the Gemini product page for the current model identifiers.
|
|
114
|
+
|
|
115
|
+
### Thinking Models
|
|
116
|
+
|
|
117
|
+
The plugin supports configuring Gemini "thinking" features per-model via
|
|
118
|
+
`thinkingConfig`. The available fields depend on the model family:
|
|
119
|
+
|
|
120
|
+
- For Gemini 3 models: use `thinkingLevel` with values `"low"` or `"high"`.
|
|
121
|
+
- For Gemini 2.5 models: use `thinkingBudget` (token count).
|
|
122
|
+
- `includeThoughts` (boolean) controls whether the model emits internal thoughts.
|
|
123
|
+
|
|
124
|
+
A combined example showing both model types:
|
|
92
125
|
|
|
93
126
|
```json
|
|
94
127
|
{
|
|
95
128
|
"provider": {
|
|
96
129
|
"google": {
|
|
97
130
|
"models": {
|
|
131
|
+
"gemini-3-pro-preview": {
|
|
132
|
+
"options": {
|
|
133
|
+
"thinkingConfig": {
|
|
134
|
+
"thinkingLevel": "high",
|
|
135
|
+
"includeThoughts": true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
98
139
|
"gemini-2.5-flash": {
|
|
99
140
|
"options": {
|
|
100
141
|
"thinkingConfig": {
|
|
@@ -109,6 +150,9 @@ Use `thinkingBudget` (token count) for Gemini 2.5 models.
|
|
|
109
150
|
}
|
|
110
151
|
```
|
|
111
152
|
|
|
153
|
+
If you don't set a `thinkingConfig` for a model, the plugin will use default
|
|
154
|
+
behavior for that model.
|
|
155
|
+
|
|
112
156
|
## Troubleshooting
|
|
113
157
|
|
|
114
158
|
### Manual Google Cloud Setup
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-gemini-auth",
|
|
3
3
|
"module": "index.ts",
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.8",
|
|
5
5
|
"author": "jenslys",
|
|
6
6
|
"repository": "https://github.com/jenslys/opencode-gemini-auth",
|
|
7
7
|
"files": [
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"type": "module",
|
|
13
13
|
"devDependencies": {
|
|
14
|
-
"@opencode-ai/plugin": "^
|
|
14
|
+
"@opencode-ai/plugin": "^1.1.25",
|
|
15
15
|
"@types/bun": "latest"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
package/src/gemini/oauth.ts
CHANGED
|
@@ -90,6 +90,8 @@ export async function authorizeGemini(): Promise<GeminiAuthorization> {
|
|
|
90
90
|
url.searchParams.set("state", encodeState({ verifier: pkce.verifier }));
|
|
91
91
|
url.searchParams.set("access_type", "offline");
|
|
92
92
|
url.searchParams.set("prompt", "consent");
|
|
93
|
+
// Add a fragment so any stray terminal glyphs are ignored by the auth server.
|
|
94
|
+
url.hash = "opencode";
|
|
93
95
|
|
|
94
96
|
return {
|
|
95
97
|
url: url.toString(),
|
|
@@ -107,53 +109,24 @@ export async function exchangeGemini(
|
|
|
107
109
|
try {
|
|
108
110
|
const { verifier } = decodeState(state);
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
headers: {
|
|
113
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
114
|
-
},
|
|
115
|
-
body: new URLSearchParams({
|
|
116
|
-
client_id: GEMINI_CLIENT_ID,
|
|
117
|
-
client_secret: GEMINI_CLIENT_SECRET,
|
|
118
|
-
code,
|
|
119
|
-
grant_type: "authorization_code",
|
|
120
|
-
redirect_uri: GEMINI_REDIRECT_URI,
|
|
121
|
-
code_verifier: verifier,
|
|
122
|
-
}),
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
if (!tokenResponse.ok) {
|
|
126
|
-
const errorText = await tokenResponse.text();
|
|
127
|
-
return { type: "failed", error: errorText };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
|
|
131
|
-
|
|
132
|
-
const userInfoResponse = await fetch(
|
|
133
|
-
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
|
134
|
-
{
|
|
135
|
-
headers: {
|
|
136
|
-
Authorization: `Bearer ${tokenPayload.access_token}`,
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
const userInfo = userInfoResponse.ok
|
|
142
|
-
? ((await userInfoResponse.json()) as GeminiUserInfo)
|
|
143
|
-
: {};
|
|
144
|
-
|
|
145
|
-
const refreshToken = tokenPayload.refresh_token;
|
|
146
|
-
if (!refreshToken) {
|
|
147
|
-
return { type: "failed", error: "Missing refresh token in response" };
|
|
148
|
-
}
|
|
149
|
-
|
|
112
|
+
return await exchangeGeminiWithVerifierInternal(code, verifier);
|
|
113
|
+
} catch (error) {
|
|
150
114
|
return {
|
|
151
|
-
type: "
|
|
152
|
-
|
|
153
|
-
access: tokenPayload.access_token,
|
|
154
|
-
expires: Date.now() + tokenPayload.expires_in * 1000,
|
|
155
|
-
email: userInfo.email,
|
|
115
|
+
type: "failed",
|
|
116
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
156
117
|
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Exchange an authorization code using a known PKCE verifier.
|
|
123
|
+
*/
|
|
124
|
+
export async function exchangeGeminiWithVerifier(
|
|
125
|
+
code: string,
|
|
126
|
+
verifier: string,
|
|
127
|
+
): Promise<GeminiTokenExchangeResult> {
|
|
128
|
+
try {
|
|
129
|
+
return await exchangeGeminiWithVerifierInternal(code, verifier);
|
|
157
130
|
} catch (error) {
|
|
158
131
|
return {
|
|
159
132
|
type: "failed",
|
|
@@ -161,3 +134,56 @@ export async function exchangeGemini(
|
|
|
161
134
|
};
|
|
162
135
|
}
|
|
163
136
|
}
|
|
137
|
+
|
|
138
|
+
async function exchangeGeminiWithVerifierInternal(
|
|
139
|
+
code: string,
|
|
140
|
+
verifier: string,
|
|
141
|
+
): Promise<GeminiTokenExchangeResult> {
|
|
142
|
+
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
146
|
+
},
|
|
147
|
+
body: new URLSearchParams({
|
|
148
|
+
client_id: GEMINI_CLIENT_ID,
|
|
149
|
+
client_secret: GEMINI_CLIENT_SECRET,
|
|
150
|
+
code,
|
|
151
|
+
grant_type: "authorization_code",
|
|
152
|
+
redirect_uri: GEMINI_REDIRECT_URI,
|
|
153
|
+
code_verifier: verifier,
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!tokenResponse.ok) {
|
|
158
|
+
const errorText = await tokenResponse.text();
|
|
159
|
+
return { type: "failed", error: errorText };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
|
|
163
|
+
|
|
164
|
+
const userInfoResponse = await fetch(
|
|
165
|
+
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
|
166
|
+
{
|
|
167
|
+
headers: {
|
|
168
|
+
Authorization: `Bearer ${tokenPayload.access_token}`,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const userInfo = userInfoResponse.ok
|
|
174
|
+
? ((await userInfoResponse.json()) as GeminiUserInfo)
|
|
175
|
+
: {};
|
|
176
|
+
|
|
177
|
+
const refreshToken = tokenPayload.refresh_token;
|
|
178
|
+
if (!refreshToken) {
|
|
179
|
+
return { type: "failed", error: "Missing refresh token in response" };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
type: "success",
|
|
184
|
+
refresh: refreshToken,
|
|
185
|
+
access: tokenPayload.access_token,
|
|
186
|
+
expires: Date.now() + tokenPayload.expires_in * 1000,
|
|
187
|
+
email: userInfo.email,
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/plugin/request.ts
CHANGED
|
@@ -13,6 +13,137 @@ const STREAM_ACTION = "streamGenerateContent";
|
|
|
13
13
|
const MODEL_FALLBACKS: Record<string, string> = {
|
|
14
14
|
"gemini-2.5-flash-image": "gemini-2.5-flash",
|
|
15
15
|
};
|
|
16
|
+
|
|
17
|
+
interface GeminiFunctionCallPart {
|
|
18
|
+
functionCall?: {
|
|
19
|
+
name: string;
|
|
20
|
+
args?: Record<string, unknown>;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
};
|
|
23
|
+
thoughtSignature?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GeminiContentPart {
|
|
28
|
+
role?: string;
|
|
29
|
+
parts?: GeminiFunctionCallPart[];
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface OpenAIToolCall {
|
|
34
|
+
id?: string;
|
|
35
|
+
type?: string;
|
|
36
|
+
function?: {
|
|
37
|
+
name?: string;
|
|
38
|
+
arguments?: string;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
};
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface OpenAIMessage {
|
|
45
|
+
role?: string;
|
|
46
|
+
content?: string | null;
|
|
47
|
+
tool_calls?: OpenAIToolCall[];
|
|
48
|
+
tool_call_id?: string;
|
|
49
|
+
name?: string;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Transforms OpenAI tool_calls to Gemini functionCall format and adds thoughtSignature.
|
|
55
|
+
* This ensures compatibility when OpenCode sends OpenAI-format function calls.
|
|
56
|
+
*/
|
|
57
|
+
function transformOpenAIToolCalls(requestPayload: Record<string, unknown>): void {
|
|
58
|
+
const messages = requestPayload.messages;
|
|
59
|
+
if (!messages || !Array.isArray(messages)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const message of messages) {
|
|
64
|
+
if (message && typeof message === "object") {
|
|
65
|
+
const msgObj = message as OpenAIMessage;
|
|
66
|
+
const toolCalls = msgObj.tool_calls;
|
|
67
|
+
if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) {
|
|
68
|
+
const parts: GeminiFunctionCallPart[] = [];
|
|
69
|
+
|
|
70
|
+
if (typeof msgObj.content === "string" && msgObj.content.length > 0) {
|
|
71
|
+
parts.push({ text: msgObj.content });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const toolCall of toolCalls) {
|
|
75
|
+
if (toolCall && typeof toolCall === "object") {
|
|
76
|
+
const functionObj = toolCall.function;
|
|
77
|
+
if (functionObj && typeof functionObj === "object") {
|
|
78
|
+
const name = functionObj.name;
|
|
79
|
+
const argsStr = functionObj.arguments;
|
|
80
|
+
let args: Record<string, unknown> = {};
|
|
81
|
+
if (typeof argsStr === "string") {
|
|
82
|
+
try {
|
|
83
|
+
args = JSON.parse(argsStr) as Record<string, unknown>;
|
|
84
|
+
} catch {
|
|
85
|
+
args = {};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
parts.push({
|
|
90
|
+
functionCall: {
|
|
91
|
+
name: name ?? "",
|
|
92
|
+
args,
|
|
93
|
+
},
|
|
94
|
+
thoughtSignature: "skip_thought_signature_validator",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
msgObj.parts = parts;
|
|
101
|
+
delete msgObj.tool_calls;
|
|
102
|
+
delete msgObj.content;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Adds thoughtSignature to function call parts in the request payload.
|
|
110
|
+
* Gemini 3+ models require thoughtSignature for function calls when using thinking capabilities.
|
|
111
|
+
* This must be applied to all content blocks in the conversation history.
|
|
112
|
+
* Handles both flat contents arrays and nested request.contents (wrapped bodies).
|
|
113
|
+
*/
|
|
114
|
+
function addThoughtSignaturesToFunctionCalls(requestPayload: Record<string, unknown>): void {
|
|
115
|
+
const processContents = (contents: unknown): void => {
|
|
116
|
+
if (!contents || !Array.isArray(contents)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const content of contents) {
|
|
121
|
+
if (content && typeof content === "object") {
|
|
122
|
+
const contentObj = content as Record<string, unknown>;
|
|
123
|
+
const parts = contentObj.parts;
|
|
124
|
+
if (parts && Array.isArray(parts)) {
|
|
125
|
+
for (const part of parts) {
|
|
126
|
+
if (part && typeof part === "object") {
|
|
127
|
+
const partObj = part as Record<string, unknown>;
|
|
128
|
+
if (partObj.functionCall && !partObj.thoughtSignature) {
|
|
129
|
+
partObj.thoughtSignature = "skip_thought_signature_validator";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
processContents(requestPayload.contents);
|
|
139
|
+
|
|
140
|
+
const nestedRequest = requestPayload.request;
|
|
141
|
+
if (nestedRequest && typeof nestedRequest === "object") {
|
|
142
|
+
const requestObj = nestedRequest as Record<string, unknown>;
|
|
143
|
+
processContents(requestObj.contents);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
16
147
|
/**
|
|
17
148
|
* Detects Gemini/Generative Language API requests by URL.
|
|
18
149
|
* @param input Request target passed to fetch.
|
|
@@ -155,6 +286,9 @@ export function prepareGeminiRequest(
|
|
|
155
286
|
} else {
|
|
156
287
|
const requestPayload: Record<string, unknown> = { ...parsedBody };
|
|
157
288
|
|
|
289
|
+
transformOpenAIToolCalls(requestPayload);
|
|
290
|
+
addThoughtSignaturesToFunctionCalls(requestPayload);
|
|
291
|
+
|
|
158
292
|
const rawGenerationConfig = requestPayload.generationConfig as Record<string, unknown> | undefined;
|
|
159
293
|
const normalizedThinking = normalizeThinkingConfig(rawGenerationConfig?.thinkingConfig);
|
|
160
294
|
if (normalizedThinking) {
|
package/src/plugin.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
|
|
3
3
|
import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
authorizeGemini,
|
|
6
|
+
exchangeGemini,
|
|
7
|
+
exchangeGeminiWithVerifier,
|
|
8
|
+
} from "./gemini/oauth";
|
|
5
9
|
import type { GeminiTokenExchangeResult } from "./gemini/oauth";
|
|
6
10
|
import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
|
|
7
11
|
import { ensureProjectContext } from "./plugin/project";
|
|
@@ -122,7 +126,7 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
122
126
|
projectId: projectContext.effectiveProjectId,
|
|
123
127
|
});
|
|
124
128
|
|
|
125
|
-
const response = await
|
|
129
|
+
const response = await fetchWithRetry(request, transformedInit);
|
|
126
130
|
return transformGeminiResponse(response, streaming, debugContext, requestedModel);
|
|
127
131
|
},
|
|
128
132
|
};
|
|
@@ -146,16 +150,18 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
146
150
|
} catch (error) {
|
|
147
151
|
if (error instanceof Error) {
|
|
148
152
|
console.log(
|
|
149
|
-
`Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL.`,
|
|
153
|
+
`Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL or authorization code.`,
|
|
150
154
|
);
|
|
151
155
|
} else {
|
|
152
156
|
console.log(
|
|
153
|
-
"Warning: Couldn't start the local callback listener. You'll need to paste the callback URL.",
|
|
157
|
+
"Warning: Couldn't start the local callback listener. You'll need to paste the callback URL or authorization code.",
|
|
154
158
|
);
|
|
155
159
|
}
|
|
156
160
|
}
|
|
157
161
|
} else {
|
|
158
|
-
console.log(
|
|
162
|
+
console.log(
|
|
163
|
+
"Headless environment detected. You'll need to paste the callback URL or authorization code.",
|
|
164
|
+
);
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
const authorization = await authorizeGemini();
|
|
@@ -201,22 +207,24 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
201
207
|
return {
|
|
202
208
|
url: authorization.url,
|
|
203
209
|
instructions:
|
|
204
|
-
"Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...)",
|
|
210
|
+
"Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...) or just the authorization code.",
|
|
205
211
|
method: "code",
|
|
206
212
|
callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
|
|
207
213
|
try {
|
|
208
|
-
const
|
|
209
|
-
const code = url.searchParams.get("code");
|
|
210
|
-
const state = url.searchParams.get("state");
|
|
214
|
+
const { code, state } = parseOAuthCallbackInput(callbackUrl);
|
|
211
215
|
|
|
212
|
-
if (!code
|
|
216
|
+
if (!code) {
|
|
213
217
|
return {
|
|
214
218
|
type: "failed",
|
|
215
|
-
error: "Missing code
|
|
219
|
+
error: "Missing authorization code in callback input",
|
|
216
220
|
};
|
|
217
221
|
}
|
|
218
222
|
|
|
219
|
-
|
|
223
|
+
if (state) {
|
|
224
|
+
return exchangeGemini(code, state);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return exchangeGeminiWithVerifier(code, authorization.verifier);
|
|
220
228
|
} catch (error) {
|
|
221
229
|
return {
|
|
222
230
|
type: "failed",
|
|
@@ -238,6 +246,11 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
238
246
|
|
|
239
247
|
export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
|
|
240
248
|
|
|
249
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 503]);
|
|
250
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
251
|
+
const DEFAULT_BASE_DELAY_MS = 800;
|
|
252
|
+
const DEFAULT_MAX_DELAY_MS = 8000;
|
|
253
|
+
|
|
241
254
|
function toUrlString(value: RequestInfo): string {
|
|
242
255
|
if (typeof value === "string") {
|
|
243
256
|
return value;
|
|
@@ -249,6 +262,37 @@ function toUrlString(value: RequestInfo): string {
|
|
|
249
262
|
return value.toString();
|
|
250
263
|
}
|
|
251
264
|
|
|
265
|
+
function parseOAuthCallbackInput(input: string): { code?: string; state?: string } {
|
|
266
|
+
const trimmed = input.trim();
|
|
267
|
+
if (!trimmed) {
|
|
268
|
+
return {};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
272
|
+
try {
|
|
273
|
+
const url = new URL(trimmed);
|
|
274
|
+
return {
|
|
275
|
+
code: url.searchParams.get("code") || undefined,
|
|
276
|
+
state: url.searchParams.get("state") || undefined,
|
|
277
|
+
};
|
|
278
|
+
} catch {
|
|
279
|
+
return {};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const candidate = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed;
|
|
284
|
+
if (candidate.includes("=")) {
|
|
285
|
+
const params = new URLSearchParams(candidate);
|
|
286
|
+
const code = params.get("code") || undefined;
|
|
287
|
+
const state = params.get("state") || undefined;
|
|
288
|
+
if (code || state) {
|
|
289
|
+
return { code, state };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { code: trimmed };
|
|
294
|
+
}
|
|
295
|
+
|
|
252
296
|
function openBrowserUrl(url: string): void {
|
|
253
297
|
try {
|
|
254
298
|
// Best-effort: don't block auth flow if spawning fails.
|
|
@@ -269,3 +313,198 @@ function openBrowserUrl(url: string): void {
|
|
|
269
313
|
} catch {
|
|
270
314
|
}
|
|
271
315
|
}
|
|
316
|
+
|
|
317
|
+
async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined): Promise<Response> {
|
|
318
|
+
const maxRetries = DEFAULT_MAX_RETRIES;
|
|
319
|
+
const baseDelayMs = DEFAULT_BASE_DELAY_MS;
|
|
320
|
+
const maxDelayMs = DEFAULT_MAX_DELAY_MS;
|
|
321
|
+
|
|
322
|
+
if (!canRetryRequest(init)) {
|
|
323
|
+
return fetch(input, init);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let attempt = 0;
|
|
327
|
+
while (true) {
|
|
328
|
+
const response = await fetch(input, init);
|
|
329
|
+
if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt >= maxRetries) {
|
|
330
|
+
return response;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const delayMs = await getRetryDelayMs(response, attempt, baseDelayMs, maxDelayMs);
|
|
334
|
+
if (!delayMs || delayMs <= 0) {
|
|
335
|
+
return response;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (init?.signal?.aborted) {
|
|
339
|
+
return response;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
await wait(delayMs);
|
|
343
|
+
attempt += 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function canRetryRequest(init: RequestInit | undefined): boolean {
|
|
348
|
+
if (!init?.body) {
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const body = init.body;
|
|
353
|
+
if (typeof body === "string") {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body)) {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function getRetryDelayMs(
|
|
373
|
+
response: Response,
|
|
374
|
+
attempt: number,
|
|
375
|
+
baseDelayMs: number,
|
|
376
|
+
maxDelayMs: number,
|
|
377
|
+
): Promise<number | null> {
|
|
378
|
+
const headerDelayMs = parseRetryAfterMs(response.headers.get("retry-after-ms"));
|
|
379
|
+
if (headerDelayMs !== null) {
|
|
380
|
+
return clampDelay(headerDelayMs, maxDelayMs);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
384
|
+
if (retryAfter !== null) {
|
|
385
|
+
return clampDelay(retryAfter, maxDelayMs);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const bodyDelayMs = await parseRetryDelayFromBody(response);
|
|
389
|
+
if (bodyDelayMs !== null) {
|
|
390
|
+
return clampDelay(bodyDelayMs, maxDelayMs);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const fallback = baseDelayMs * Math.pow(2, attempt);
|
|
394
|
+
return clampDelay(fallback, maxDelayMs);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function clampDelay(delayMs: number, maxDelayMs: number): number {
|
|
398
|
+
if (!Number.isFinite(delayMs)) {
|
|
399
|
+
return maxDelayMs;
|
|
400
|
+
}
|
|
401
|
+
return Math.min(Math.max(0, delayMs), maxDelayMs);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function parseRetryAfterMs(value: string | null): number | null {
|
|
405
|
+
if (!value) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
const parsed = Number(value);
|
|
409
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
return parsed;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function parseRetryAfter(value: string | null): number | null {
|
|
416
|
+
if (!value) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
const trimmed = value.trim();
|
|
420
|
+
if (!trimmed) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
const asNumber = Number(trimmed);
|
|
424
|
+
if (Number.isFinite(asNumber)) {
|
|
425
|
+
return Math.max(0, Math.round(asNumber * 1000));
|
|
426
|
+
}
|
|
427
|
+
const asDate = Date.parse(trimmed);
|
|
428
|
+
if (!Number.isNaN(asDate)) {
|
|
429
|
+
return Math.max(0, asDate - Date.now());
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function parseRetryDelayFromBody(response: Response): Promise<number | null> {
|
|
435
|
+
let text = "";
|
|
436
|
+
try {
|
|
437
|
+
text = await response.clone().text();
|
|
438
|
+
} catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!text) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let parsed: any;
|
|
447
|
+
try {
|
|
448
|
+
parsed = JSON.parse(text);
|
|
449
|
+
} catch {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const details = parsed?.error?.details;
|
|
454
|
+
if (!Array.isArray(details)) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const detail of details) {
|
|
459
|
+
if (!detail || typeof detail !== "object") {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const retryDelay = (detail as Record<string, unknown>).retryDelay;
|
|
463
|
+
if (!retryDelay) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const delayMs = parseRetryDelayValue(retryDelay);
|
|
467
|
+
if (delayMs !== null) {
|
|
468
|
+
return delayMs;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function parseRetryDelayValue(value: unknown): number | null {
|
|
476
|
+
if (!value) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (typeof value === "string") {
|
|
481
|
+
const match = value.match(/^([\d.]+)s$/);
|
|
482
|
+
if (!match || !match[1]) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
const seconds = Number(match[1]);
|
|
486
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
return Math.round(seconds * 1000);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (typeof value === "object") {
|
|
493
|
+
const record = value as Record<string, unknown>;
|
|
494
|
+
const seconds = typeof record.seconds === "number" ? record.seconds : 0;
|
|
495
|
+
const nanos = typeof record.nanos === "number" ? record.nanos : 0;
|
|
496
|
+
if (!Number.isFinite(seconds) || !Number.isFinite(nanos)) {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
const totalMs = Math.round(seconds * 1000 + nanos / 1e6);
|
|
500
|
+
return totalMs > 0 ? totalMs : null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function wait(ms: number): Promise<void> {
|
|
507
|
+
return new Promise((resolve) => {
|
|
508
|
+
setTimeout(resolve, ms);
|
|
509
|
+
});
|
|
510
|
+
}
|