opencode-gemini-auth 1.4.7 → 1.4.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/package.json +1 -1
- package/src/plugin/oauth-authorize.ts +41 -14
- package/src/plugin/project/context.ts +14 -3
- package/src/plugin/project/types.ts +27 -0
- package/src/plugin/project/utils.ts +21 -0
- package/src/plugin/project.test.ts +87 -3
- package/src/plugin/provider.test.ts +26 -2
- package/src/plugin/provider.ts +16 -1
- package/src/plugin/server.ts +51 -28
- package/src/plugin/types.ts +5 -0
- package/src/plugin.ts +125 -111
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ import type { OAuthAuthDetails } from "./types";
|
|
|
12
12
|
* Builds the OAuth authorize callback used by plugin auth methods.
|
|
13
13
|
*/
|
|
14
14
|
export function createOAuthAuthorizeMethod(options?: {
|
|
15
|
-
getConfiguredProjectId?: () => string | undefined;
|
|
15
|
+
getConfiguredProjectId?: () => Promise<string | undefined> | string | undefined;
|
|
16
16
|
}): () => Promise<{
|
|
17
17
|
url: string;
|
|
18
18
|
instructions: string;
|
|
@@ -28,7 +28,7 @@ export function createOAuthAuthorizeMethod(options?: {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const configuredProjectId = resolveConfiguredProjectId({
|
|
31
|
-
configProjectId: options?.getConfiguredProjectId?.(),
|
|
31
|
+
configProjectId: await options?.getConfiguredProjectId?.(),
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
try {
|
|
@@ -97,19 +97,38 @@ export function createOAuthAuthorizeMethod(options?: {
|
|
|
97
97
|
method: "auto",
|
|
98
98
|
callback: async (): Promise<GeminiTokenExchangeResult> => {
|
|
99
99
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
100
|
+
while (true) {
|
|
101
|
+
const callbackUrl = await listener.waitForCallback();
|
|
102
|
+
const callbackError = callbackUrl.searchParams.get("error");
|
|
103
|
+
const callbackErrorDescription = callbackUrl.searchParams.get("error_description");
|
|
104
|
+
if (callbackError) {
|
|
105
|
+
return {
|
|
106
|
+
type: "failed",
|
|
107
|
+
error: callbackErrorDescription || callbackError,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const code = callbackUrl.searchParams.get("code");
|
|
112
|
+
const state = callbackUrl.searchParams.get("state");
|
|
113
|
+
if (!code || !state) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (state !== authorization.state) {
|
|
117
|
+
if (isGeminiDebugEnabled()) {
|
|
118
|
+
logGeminiDebugMessage("Ignoring OAuth callback with mismatched state");
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const exchangeResult = await exchangeGeminiWithVerifier(code, authorization.verifier);
|
|
124
|
+
if (shouldIgnoreMalformedAuthCode(exchangeResult)) {
|
|
125
|
+
if (isGeminiDebugEnabled()) {
|
|
126
|
+
logGeminiDebugMessage("Ignoring malformed OAuth callback code and waiting for the next redirect");
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
return await maybeHydrateProjectId(exchangeResult);
|
|
109
131
|
}
|
|
110
|
-
return await maybeHydrateProjectId(
|
|
111
|
-
await exchangeGeminiWithVerifier(code, authorization.verifier),
|
|
112
|
-
);
|
|
113
132
|
} catch (error) {
|
|
114
133
|
return {
|
|
115
134
|
type: "failed",
|
|
@@ -196,3 +215,11 @@ function openBrowserUrl(url: string): void {
|
|
|
196
215
|
child.unref?.();
|
|
197
216
|
} catch {}
|
|
198
217
|
}
|
|
218
|
+
|
|
219
|
+
function shouldIgnoreMalformedAuthCode(result: GeminiTokenExchangeResult): boolean {
|
|
220
|
+
if (result.type !== "failed") {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return /invalid_grant/i.test(result.error) && /malformed auth code/i.test(result.error);
|
|
225
|
+
}
|
|
@@ -3,7 +3,13 @@ import { formatRefreshParts, parseRefreshParts } from "../auth";
|
|
|
3
3
|
import type { OAuthAuthDetails, PluginClient, ProjectContextResult } from "../types";
|
|
4
4
|
import { loadManagedProject, onboardManagedProject } from "./api";
|
|
5
5
|
import { FREE_TIER_ID, LEGACY_TIER_ID, ProjectIdRequiredError } from "./types";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildIneligibleTierMessage,
|
|
8
|
+
getCacheKey,
|
|
9
|
+
normalizeProjectId,
|
|
10
|
+
pickOnboardTier,
|
|
11
|
+
throwIfValidationRequired,
|
|
12
|
+
} from "./utils";
|
|
7
13
|
|
|
8
14
|
const projectContextResultCache = new Map<string, ProjectContextResult>();
|
|
9
15
|
const projectContextPendingCache = new Map<string, Promise<ProjectContextResult>>();
|
|
@@ -44,9 +50,10 @@ export async function resolveProjectContextFromAccessToken(
|
|
|
44
50
|
persistAuth?: (auth: OAuthAuthDetails) => Promise<void>,
|
|
45
51
|
): Promise<ProjectContextResult> {
|
|
46
52
|
const parts = parseRefreshParts(auth.refresh);
|
|
47
|
-
const
|
|
53
|
+
const configuredProject = configuredProjectId?.trim();
|
|
54
|
+
const projectId = configuredProject || parts.projectId;
|
|
48
55
|
|
|
49
|
-
if (projectId || parts.managedProjectId) {
|
|
56
|
+
if (!configuredProject && (projectId || parts.managedProjectId)) {
|
|
50
57
|
return {
|
|
51
58
|
auth,
|
|
52
59
|
effectiveProjectId: projectId || parts.managedProjectId || "",
|
|
@@ -68,6 +75,10 @@ export async function resolveProjectContextFromAccessToken(
|
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
const currentTierId = loadPayload.currentTier?.id;
|
|
78
|
+
if (!currentTierId) {
|
|
79
|
+
throwIfValidationRequired(loadPayload.ineligibleTiers);
|
|
80
|
+
}
|
|
81
|
+
|
|
71
82
|
if (currentTierId) {
|
|
72
83
|
if (projectId) {
|
|
73
84
|
return { auth, effectiveProjectId: projectId };
|
|
@@ -20,7 +20,10 @@ export interface CloudAiCompanionProject {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface GeminiIneligibleTier {
|
|
23
|
+
reasonCode?: string;
|
|
23
24
|
reasonMessage?: string;
|
|
25
|
+
validationUrl?: string;
|
|
26
|
+
validationLearnMoreUrl?: string;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export interface LoadCodeAssistPayload {
|
|
@@ -65,3 +68,27 @@ export class ProjectIdRequiredError extends Error {
|
|
|
65
68
|
);
|
|
66
69
|
}
|
|
67
70
|
}
|
|
71
|
+
|
|
72
|
+
export class AccountValidationRequiredError extends Error {
|
|
73
|
+
validationUrl?: string;
|
|
74
|
+
validationLearnMoreUrl?: string;
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
message: string,
|
|
78
|
+
validationUrl?: string,
|
|
79
|
+
validationLearnMoreUrl?: string,
|
|
80
|
+
) {
|
|
81
|
+
const parts = [message.trim()];
|
|
82
|
+
if (validationUrl) {
|
|
83
|
+
parts.push(`Complete validation: ${validationUrl}`);
|
|
84
|
+
}
|
|
85
|
+
if (validationLearnMoreUrl) {
|
|
86
|
+
parts.push(`Learn more: ${validationLearnMoreUrl}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
super(parts.join("\n"));
|
|
90
|
+
this.name = "AccountValidationRequiredError";
|
|
91
|
+
this.validationUrl = validationUrl;
|
|
92
|
+
this.validationLearnMoreUrl = validationLearnMoreUrl;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OAuthAuthDetails } from "../types";
|
|
2
2
|
import {
|
|
3
|
+
AccountValidationRequiredError,
|
|
3
4
|
CODE_ASSIST_METADATA,
|
|
4
5
|
LEGACY_TIER_ID,
|
|
5
6
|
type CloudAiCompanionProject,
|
|
@@ -68,6 +69,26 @@ export function buildIneligibleTierMessage(tiers?: GeminiIneligibleTier[]): stri
|
|
|
68
69
|
return reasons.length > 0 ? reasons.join(", ") : undefined;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
export function throwIfValidationRequired(tiers?: GeminiIneligibleTier[]): void {
|
|
73
|
+
if (!tiers || tiers.length === 0) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const validationTier = tiers.find((tier) => {
|
|
78
|
+
const reasonCode = tier?.reasonCode?.trim().toUpperCase();
|
|
79
|
+
return reasonCode === "VALIDATION_REQUIRED" && !!tier.validationUrl?.trim();
|
|
80
|
+
});
|
|
81
|
+
if (!validationTier) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new AccountValidationRequiredError(
|
|
86
|
+
validationTier.reasonMessage?.trim() || "Verify your account to continue.",
|
|
87
|
+
validationTier.validationUrl?.trim(),
|
|
88
|
+
validationTier.validationLearnMoreUrl?.trim(),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
71
92
|
/**
|
|
72
93
|
* Detects VPC-SC errors from Cloud Code responses.
|
|
73
94
|
*/
|
|
@@ -111,6 +111,78 @@ describe("resolveProjectContextFromAccessToken", () => {
|
|
|
111
111
|
).rejects.toThrow("Google Gemini requires a Google Cloud project");
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
it("continues with an allowed paid tier even when free tier is ineligible", async () => {
|
|
115
|
+
let onboardBody: Record<string, unknown> | undefined;
|
|
116
|
+
const fetchMock = mock(async (input: RequestInfo, init?: RequestInit) => {
|
|
117
|
+
const url = toUrlString(input);
|
|
118
|
+
if (url.includes(":loadCodeAssist")) {
|
|
119
|
+
return new Response(
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
allowedTiers: [{ id: "standard-tier", isDefault: true }],
|
|
122
|
+
ineligibleTiers: [
|
|
123
|
+
{
|
|
124
|
+
reasonCode: "INELIGIBLE_ACCOUNT",
|
|
125
|
+
reasonMessage: "Not eligible for free tier",
|
|
126
|
+
tierId: "free-tier",
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}),
|
|
130
|
+
{ status: 200 },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (url.includes(":onboardUser")) {
|
|
134
|
+
const rawBody = typeof init?.body === "string" ? init.body : "{}";
|
|
135
|
+
onboardBody = JSON.parse(rawBody) as Record<string, unknown>;
|
|
136
|
+
return new Response(
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
done: true,
|
|
139
|
+
response: { cloudaicompanionProject: { id: "configured-project" } },
|
|
140
|
+
}),
|
|
141
|
+
{ status: 200 },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
throw new Error(`Unexpected fetch to ${url}`);
|
|
145
|
+
});
|
|
146
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
147
|
+
|
|
148
|
+
const result = await resolveProjectContextFromAccessToken(
|
|
149
|
+
baseAuth,
|
|
150
|
+
baseAuth.access ?? "",
|
|
151
|
+
"configured-project",
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(result.effectiveProjectId).toBe("configured-project");
|
|
155
|
+
expect(onboardBody?.cloudaicompanionProject).toBe("configured-project");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("throws validation-required errors before tier onboarding even when allowed tiers exist", async () => {
|
|
159
|
+
const fetchMock = mock(async (input: RequestInfo) => {
|
|
160
|
+
const url = toUrlString(input);
|
|
161
|
+
if (url.includes(":loadCodeAssist")) {
|
|
162
|
+
return new Response(
|
|
163
|
+
JSON.stringify({
|
|
164
|
+
allowedTiers: [{ id: "standard-tier", isDefault: true }],
|
|
165
|
+
ineligibleTiers: [
|
|
166
|
+
{
|
|
167
|
+
reasonCode: "VALIDATION_REQUIRED",
|
|
168
|
+
reasonMessage: "Verify your account to continue.",
|
|
169
|
+
validationUrl: "https://example.com/verify",
|
|
170
|
+
validationLearnMoreUrl: "https://example.com/help",
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
}),
|
|
174
|
+
{ status: 200 },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`Unexpected fetch to ${url}`);
|
|
178
|
+
});
|
|
179
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
resolveProjectContextFromAccessToken(baseAuth, baseAuth.access ?? "", "configured-project"),
|
|
183
|
+
).rejects.toThrow("Complete validation: https://example.com/verify");
|
|
184
|
+
});
|
|
185
|
+
|
|
114
186
|
it("prefers a configured project id over a persisted managed project id", async () => {
|
|
115
187
|
const authWithManagedProject: OAuthAuthDetails = {
|
|
116
188
|
...baseAuth,
|
|
@@ -120,8 +192,20 @@ describe("resolveProjectContextFromAccessToken", () => {
|
|
|
120
192
|
}),
|
|
121
193
|
};
|
|
122
194
|
|
|
123
|
-
|
|
124
|
-
|
|
195
|
+
let loadBody: Record<string, unknown> | undefined;
|
|
196
|
+
const fetchMock = mock(async (input: RequestInfo, init?: RequestInit) => {
|
|
197
|
+
const url = toUrlString(input);
|
|
198
|
+
if (url.includes(":loadCodeAssist")) {
|
|
199
|
+
const rawBody = typeof init?.body === "string" ? init.body : "{}";
|
|
200
|
+
loadBody = JSON.parse(rawBody) as Record<string, unknown>;
|
|
201
|
+
return new Response(
|
|
202
|
+
JSON.stringify({
|
|
203
|
+
currentTier: { id: "standard-tier" },
|
|
204
|
+
}),
|
|
205
|
+
{ status: 200 },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`Unexpected fetch to ${url}`);
|
|
125
209
|
});
|
|
126
210
|
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
127
211
|
|
|
@@ -132,6 +216,6 @@ describe("resolveProjectContextFromAccessToken", () => {
|
|
|
132
216
|
);
|
|
133
217
|
|
|
134
218
|
expect(result.effectiveProjectId).toBe("configured-project");
|
|
135
|
-
expect(
|
|
219
|
+
expect(loadBody?.cloudaicompanionProject).toBe("configured-project");
|
|
136
220
|
});
|
|
137
221
|
});
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import type { Config } from "@opencode-ai/sdk";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
resolveConfiguredProjectId,
|
|
6
|
+
resolveConfiguredProjectIdFromClient,
|
|
7
|
+
resolveConfiguredProjectIdFromConfig,
|
|
8
|
+
} from "./provider";
|
|
9
|
+
import type { PluginClient, Provider } from "./types";
|
|
6
10
|
|
|
7
11
|
describe("resolveConfiguredProjectId", () => {
|
|
8
12
|
it("reads project id from provider options", () => {
|
|
@@ -56,4 +60,24 @@ describe("resolveConfiguredProjectId", () => {
|
|
|
56
60
|
}),
|
|
57
61
|
).toBe("opencode-project");
|
|
58
62
|
});
|
|
63
|
+
|
|
64
|
+
it("reads the current project id from the Opencode config API when available", async () => {
|
|
65
|
+
const client = {
|
|
66
|
+
config: {
|
|
67
|
+
get: async () => ({
|
|
68
|
+
data: {
|
|
69
|
+
provider: {
|
|
70
|
+
google: {
|
|
71
|
+
options: {
|
|
72
|
+
projectId: "live-config-project",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
} satisfies Config,
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
} satisfies PluginClient;
|
|
80
|
+
|
|
81
|
+
await expect(resolveConfiguredProjectIdFromClient(client)).resolves.toBe("live-config-project");
|
|
82
|
+
});
|
|
59
83
|
});
|
package/src/plugin/provider.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Config } from "@opencode-ai/sdk";
|
|
2
2
|
|
|
3
3
|
import { GEMINI_PROVIDER_ID } from "../constants";
|
|
4
|
-
import type { Provider } from "./types";
|
|
4
|
+
import type { PluginClient, Provider } from "./types";
|
|
5
5
|
|
|
6
6
|
interface ResolveConfiguredProjectIdInput {
|
|
7
7
|
provider?: Provider | null;
|
|
@@ -45,6 +45,21 @@ export function resolveConfiguredProjectIdFromConfig(
|
|
|
45
45
|
return normalizeProjectId(providerConfig?.options?.projectId);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export async function resolveConfiguredProjectIdFromClient(
|
|
49
|
+
client: PluginClient | null | undefined,
|
|
50
|
+
): Promise<string | undefined> {
|
|
51
|
+
if (!client?.config?.get) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const result = await client.config.get();
|
|
57
|
+
return resolveConfiguredProjectIdFromConfig(result?.data);
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
function normalizeProjectId(value: unknown): string | undefined {
|
|
49
64
|
if (typeof value !== "string") {
|
|
50
65
|
return undefined;
|
package/src/plugin/server.ts
CHANGED
|
@@ -37,23 +37,12 @@ export async function startOAuthListener(
|
|
|
37
37
|
: 80;
|
|
38
38
|
const origin = `${redirectUri.protocol}//${redirectUri.host}`;
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
settled = true;
|
|
47
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
48
|
-
resolve(url);
|
|
49
|
-
};
|
|
50
|
-
rejectCallback = (error: Error) => {
|
|
51
|
-
if (settled) return;
|
|
52
|
-
settled = true;
|
|
53
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
54
|
-
reject(error);
|
|
55
|
-
};
|
|
56
|
-
});
|
|
40
|
+
const callbackQueue: URL[] = [];
|
|
41
|
+
const callbackWaiters: Array<{
|
|
42
|
+
resolve: (url: URL) => void;
|
|
43
|
+
reject: (error: Error) => void;
|
|
44
|
+
}> = [];
|
|
45
|
+
let terminalError: Error | undefined;
|
|
57
46
|
|
|
58
47
|
const successResponse = `<!DOCTYPE html>
|
|
59
48
|
<html lang="en">
|
|
@@ -182,8 +171,27 @@ const successResponse = `<!DOCTYPE html>
|
|
|
182
171
|
</body>
|
|
183
172
|
</html>`;
|
|
184
173
|
|
|
174
|
+
const deliverCallback = (url: URL) => {
|
|
175
|
+
const waiter = callbackWaiters.shift();
|
|
176
|
+
if (waiter) {
|
|
177
|
+
waiter.resolve(url);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
callbackQueue.push(url);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const failPendingWaiters = (error: Error) => {
|
|
184
|
+
if (terminalError) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
terminalError = error;
|
|
188
|
+
while (callbackWaiters.length > 0) {
|
|
189
|
+
callbackWaiters.shift()?.reject(error);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
185
193
|
const timeoutHandle = setTimeout(() => {
|
|
186
|
-
|
|
194
|
+
failPendingWaiters(new Error("Timed out waiting for OAuth callback"));
|
|
187
195
|
}, timeoutMs);
|
|
188
196
|
timeoutHandle.unref?.();
|
|
189
197
|
|
|
@@ -201,14 +209,18 @@ const successResponse = `<!DOCTYPE html>
|
|
|
201
209
|
return;
|
|
202
210
|
}
|
|
203
211
|
|
|
212
|
+
const hasCode = !!url.searchParams.get("code");
|
|
213
|
+
const hasState = !!url.searchParams.get("state");
|
|
214
|
+
const hasError = !!url.searchParams.get("error");
|
|
215
|
+
if (!hasError && (!hasCode || !hasState)) {
|
|
216
|
+
response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
217
|
+
response.end("Ignoring incomplete OAuth callback. Return to the Google sign-in flow.");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
204
221
|
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
205
222
|
response.end(successResponse);
|
|
206
|
-
|
|
207
|
-
resolveCallback(url);
|
|
208
|
-
|
|
209
|
-
setImmediate(() => {
|
|
210
|
-
server.close();
|
|
211
|
-
});
|
|
223
|
+
deliverCallback(url);
|
|
212
224
|
});
|
|
213
225
|
|
|
214
226
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -224,11 +236,21 @@ const successResponse = `<!DOCTYPE html>
|
|
|
224
236
|
});
|
|
225
237
|
|
|
226
238
|
server.on("error", (error) => {
|
|
227
|
-
|
|
239
|
+
failPendingWaiters(error instanceof Error ? error : new Error(String(error)));
|
|
228
240
|
});
|
|
229
241
|
|
|
230
242
|
return {
|
|
231
|
-
waitForCallback: () =>
|
|
243
|
+
waitForCallback: async () => {
|
|
244
|
+
if (callbackQueue.length > 0) {
|
|
245
|
+
return callbackQueue.shift() as URL;
|
|
246
|
+
}
|
|
247
|
+
if (terminalError) {
|
|
248
|
+
throw terminalError;
|
|
249
|
+
}
|
|
250
|
+
return await new Promise<URL>((resolve, reject) => {
|
|
251
|
+
callbackWaiters.push({ resolve, reject });
|
|
252
|
+
});
|
|
253
|
+
},
|
|
232
254
|
close: () =>
|
|
233
255
|
new Promise<void>((resolve, reject) => {
|
|
234
256
|
server.close((error) => {
|
|
@@ -236,9 +258,10 @@ const successResponse = `<!DOCTYPE html>
|
|
|
236
258
|
reject(error);
|
|
237
259
|
return;
|
|
238
260
|
}
|
|
239
|
-
if (
|
|
240
|
-
|
|
261
|
+
if (timeoutHandle) {
|
|
262
|
+
clearTimeout(timeoutHandle);
|
|
241
263
|
}
|
|
264
|
+
failPendingWaiters(new Error("OAuth listener closed before callback"));
|
|
242
265
|
resolve();
|
|
243
266
|
});
|
|
244
267
|
}),
|
package/src/plugin/types.ts
CHANGED
|
@@ -52,6 +52,11 @@ export interface PluginClient {
|
|
|
52
52
|
auth: {
|
|
53
53
|
set(input: { path: { id: string }; body: OAuthAuthDetails }): Promise<void>;
|
|
54
54
|
};
|
|
55
|
+
config?: {
|
|
56
|
+
get(options?: unknown): Promise<{
|
|
57
|
+
data?: Config;
|
|
58
|
+
} | undefined>;
|
|
59
|
+
};
|
|
55
60
|
tui?: {
|
|
56
61
|
showToast(input: {
|
|
57
62
|
body: {
|
package/src/plugin.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
} from "./plugin/quota";
|
|
10
10
|
import { isGeminiDebugEnabled, logGeminiDebugMessage, startGeminiDebugRequest } from "./plugin/debug";
|
|
11
11
|
import { maybeShowGeminiCapacityToast, maybeShowGeminiTestToast } from "./plugin/notify";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
resolveConfiguredProjectId,
|
|
14
|
+
resolveConfiguredProjectIdFromClient,
|
|
15
|
+
resolveConfiguredProjectIdFromConfig,
|
|
16
|
+
} from "./plugin/provider";
|
|
13
17
|
import {
|
|
14
18
|
isGenerativeLanguageRequest,
|
|
15
19
|
prepareGeminiRequest,
|
|
@@ -43,127 +47,137 @@ let latestGeminiConfiguredProjectId: string | undefined;
|
|
|
43
47
|
*/
|
|
44
48
|
export const GeminiCLIOAuthPlugin = async (
|
|
45
49
|
{ client }: PluginContext,
|
|
46
|
-
): Promise<PluginResult> =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
client,
|
|
58
|
-
getAuthResolver: () => latestGeminiAuthResolver,
|
|
59
|
-
getConfiguredProjectId: () => latestGeminiConfiguredProjectId,
|
|
60
|
-
}),
|
|
61
|
-
},
|
|
62
|
-
auth: {
|
|
63
|
-
provider: GEMINI_PROVIDER_ID,
|
|
64
|
-
loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
|
|
65
|
-
latestGeminiAuthResolver = getAuth;
|
|
66
|
-
const auth = await getAuth();
|
|
67
|
-
if (!isOAuthAuth(auth)) {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
50
|
+
): Promise<PluginResult> => {
|
|
51
|
+
const resolveLatestConfiguredProjectId = async (provider?: Provider): Promise<string | undefined> => {
|
|
52
|
+
const configProjectId =
|
|
53
|
+
(await resolveConfiguredProjectIdFromClient(client)) ?? latestGeminiConfiguredProjectId;
|
|
54
|
+
const resolvedProjectId = resolveConfiguredProjectId({
|
|
55
|
+
provider,
|
|
56
|
+
configProjectId,
|
|
57
|
+
});
|
|
58
|
+
latestGeminiConfiguredProjectId = resolvedProjectId;
|
|
59
|
+
return resolvedProjectId;
|
|
60
|
+
};
|
|
70
61
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
62
|
+
return {
|
|
63
|
+
config: async (config) => {
|
|
64
|
+
latestGeminiConfiguredProjectId = resolveConfiguredProjectIdFromConfig(config);
|
|
65
|
+
config.command = config.command || {};
|
|
66
|
+
config.command[GEMINI_QUOTA_COMMAND] = {
|
|
67
|
+
description: "Show Gemini Code Assist quota usage",
|
|
68
|
+
template: GEMINI_QUOTA_COMMAND_TEMPLATE,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
tool: {
|
|
72
|
+
[GEMINI_QUOTA_TOOL_NAME]: createGeminiQuotaTool({
|
|
73
|
+
client,
|
|
74
|
+
getAuthResolver: () => latestGeminiAuthResolver,
|
|
75
|
+
getConfiguredProjectId: () => latestGeminiConfiguredProjectId,
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
auth: {
|
|
79
|
+
provider: GEMINI_PROVIDER_ID,
|
|
80
|
+
loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
|
|
81
|
+
latestGeminiAuthResolver = getAuth;
|
|
82
|
+
const auth = await getAuth();
|
|
83
|
+
if (!isOAuthAuth(auth)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
78
86
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (!isGenerativeLanguageRequest(input)) {
|
|
83
|
-
return fetch(input, init);
|
|
84
|
-
}
|
|
87
|
+
await resolveLatestConfiguredProjectId(provider);
|
|
88
|
+
normalizeProviderModelCosts(provider);
|
|
89
|
+
const thinkingConfigDefaults = resolveThinkingConfigDefaults(provider);
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
return {
|
|
92
|
+
apiKey: "",
|
|
93
|
+
async fetch(input, init) {
|
|
94
|
+
if (!isGenerativeLanguageRequest(input)) {
|
|
95
|
+
return fetch(input, init);
|
|
96
|
+
}
|
|
90
97
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const refreshed = await refreshAccessToken(authRecord, client);
|
|
94
|
-
if (!refreshed) {
|
|
98
|
+
const latestAuth = await getAuth();
|
|
99
|
+
if (!isOAuthAuth(latestAuth)) {
|
|
95
100
|
return fetch(input, init);
|
|
96
101
|
}
|
|
97
|
-
authRecord = refreshed;
|
|
98
|
-
}
|
|
99
102
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
let authRecord = resolveCachedAuth(latestAuth);
|
|
104
|
+
if (accessTokenExpired(authRecord)) {
|
|
105
|
+
const refreshed = await refreshAccessToken(authRecord, client);
|
|
106
|
+
if (!refreshed) {
|
|
107
|
+
return fetch(input, init);
|
|
108
|
+
}
|
|
109
|
+
authRecord = refreshed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!authRecord.access) {
|
|
113
|
+
return fetch(input, init);
|
|
114
|
+
}
|
|
103
115
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
116
|
+
const configuredProjectId = await resolveLatestConfiguredProjectId(provider);
|
|
117
|
+
const projectContext = await ensureProjectContextOrThrow(
|
|
118
|
+
authRecord,
|
|
119
|
+
client,
|
|
120
|
+
configuredProjectId,
|
|
121
|
+
);
|
|
122
|
+
await maybeShowGeminiTestToast(client, projectContext.effectiveProjectId);
|
|
123
|
+
await maybeLogAvailableQuotaModels(
|
|
124
|
+
authRecord.access,
|
|
125
|
+
projectContext.effectiveProjectId,
|
|
126
|
+
);
|
|
127
|
+
const transformed = prepareGeminiRequest(
|
|
128
|
+
input,
|
|
129
|
+
init,
|
|
130
|
+
authRecord.access,
|
|
131
|
+
projectContext.effectiveProjectId,
|
|
132
|
+
thinkingConfigDefaults,
|
|
133
|
+
);
|
|
134
|
+
const debugContext = startGeminiDebugRequest({
|
|
135
|
+
originalUrl: toUrlString(input),
|
|
136
|
+
resolvedUrl: toUrlString(transformed.request),
|
|
137
|
+
method: transformed.init.method,
|
|
138
|
+
headers: transformed.init.headers,
|
|
139
|
+
body: transformed.init.body,
|
|
140
|
+
streaming: transformed.streaming,
|
|
141
|
+
projectId: projectContext.effectiveProjectId,
|
|
142
|
+
});
|
|
130
143
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Retry transport/429 failures while preserving the requested model.
|
|
146
|
+
* We intentionally do not auto-downgrade model tiers to avoid misleading users.
|
|
147
|
+
*/
|
|
148
|
+
const response = await fetchWithRetry(transformed.request, transformed.init);
|
|
149
|
+
await maybeShowGeminiCapacityToast(
|
|
150
|
+
client,
|
|
151
|
+
response,
|
|
152
|
+
projectContext.effectiveProjectId,
|
|
153
|
+
transformed.requestedModel,
|
|
154
|
+
);
|
|
155
|
+
return transformGeminiResponse(
|
|
156
|
+
response,
|
|
157
|
+
transformed.streaming,
|
|
158
|
+
debugContext,
|
|
159
|
+
transformed.requestedModel,
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
methods: [
|
|
165
|
+
{
|
|
166
|
+
label: "OAuth with Google (Gemini CLI)",
|
|
167
|
+
type: "oauth",
|
|
168
|
+
authorize: createOAuthAuthorizeMethod({
|
|
169
|
+
getConfiguredProjectId: () => resolveLatestConfiguredProjectId(),
|
|
170
|
+
}),
|
|
148
171
|
},
|
|
149
|
-
|
|
172
|
+
{
|
|
173
|
+
provider: GEMINI_PROVIDER_ID,
|
|
174
|
+
label: "Manually enter API Key",
|
|
175
|
+
type: "api",
|
|
176
|
+
},
|
|
177
|
+
],
|
|
150
178
|
},
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
label: "OAuth with Google (Gemini CLI)",
|
|
154
|
-
type: "oauth",
|
|
155
|
-
authorize: createOAuthAuthorizeMethod({
|
|
156
|
-
getConfiguredProjectId: () => latestGeminiConfiguredProjectId,
|
|
157
|
-
}),
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
provider: GEMINI_PROVIDER_ID,
|
|
161
|
-
label: "Manually enter API Key",
|
|
162
|
-
type: "api",
|
|
163
|
-
},
|
|
164
|
-
],
|
|
165
|
-
},
|
|
166
|
-
});
|
|
179
|
+
};
|
|
180
|
+
};
|
|
167
181
|
|
|
168
182
|
export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
|
|
169
183
|
const loggedQuotaModelsByProject = new Set<string>();
|