opencode-gemini-auth 1.4.6 → 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 +2 -2
- package/src/plugin/oauth-authorize.ts +46 -19
- 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 +109 -0
- package/src/plugin/provider.test.ts +83 -0
- package/src/plugin/provider.ts +70 -0
- package/src/plugin/server.ts +51 -28
- package/src/plugin/types.ts +5 -0
- package/src/plugin.ts +125 -122
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-gemini-auth",
|
|
3
3
|
"module": "index.ts",
|
|
4
|
-
"version": "1.4.
|
|
4
|
+
"version": "1.4.8",
|
|
5
5
|
"author": "jenslys",
|
|
6
6
|
"repository": "https://github.com/jenslys/opencode-gemini-auth",
|
|
7
7
|
"files": [
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typescript": "^5.9.3"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@opencode-ai/plugin": "^1.2.
|
|
25
|
+
"@opencode-ai/plugin": "^1.2.20",
|
|
26
26
|
"@openauthjs/openauth": "^0.4.3"
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -4,13 +4,16 @@ import { authorizeGemini, exchangeGeminiWithVerifier } from "../gemini/oauth";
|
|
|
4
4
|
import type { GeminiTokenExchangeResult } from "../gemini/oauth";
|
|
5
5
|
import { isGeminiDebugEnabled, logGeminiDebugMessage } from "./debug";
|
|
6
6
|
import { resolveProjectContextFromAccessToken } from "./project";
|
|
7
|
+
import { resolveConfiguredProjectId } from "./provider";
|
|
7
8
|
import { startOAuthListener, type OAuthListener } from "./server";
|
|
8
9
|
import type { OAuthAuthDetails } from "./types";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Builds the OAuth authorize callback used by plugin auth methods.
|
|
12
13
|
*/
|
|
13
|
-
export function createOAuthAuthorizeMethod(
|
|
14
|
+
export function createOAuthAuthorizeMethod(options?: {
|
|
15
|
+
getConfiguredProjectId?: () => Promise<string | undefined> | string | undefined;
|
|
16
|
+
}): () => Promise<{
|
|
14
17
|
url: string;
|
|
15
18
|
instructions: string;
|
|
16
19
|
method: string;
|
|
@@ -24,12 +27,9 @@ export function createOAuthAuthorizeMethod(): () => Promise<{
|
|
|
24
27
|
return result;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
|
|
31
|
-
"";
|
|
32
|
-
const configuredProjectId = projectFromEnv || googleProjectFromEnv || undefined;
|
|
30
|
+
const configuredProjectId = resolveConfiguredProjectId({
|
|
31
|
+
configProjectId: await options?.getConfiguredProjectId?.(),
|
|
32
|
+
});
|
|
33
33
|
|
|
34
34
|
try {
|
|
35
35
|
const authSnapshot = {
|
|
@@ -97,19 +97,38 @@ export function createOAuthAuthorizeMethod(): () => Promise<{
|
|
|
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
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { formatRefreshParts } from "./auth";
|
|
3
4
|
import { resolveProjectContextFromAccessToken } from "./project";
|
|
4
5
|
import type { OAuthAuthDetails } from "./types";
|
|
5
6
|
|
|
@@ -109,4 +110,112 @@ describe("resolveProjectContextFromAccessToken", () => {
|
|
|
109
110
|
resolveProjectContextFromAccessToken(baseAuth, baseAuth.access ?? ""),
|
|
110
111
|
).rejects.toThrow("Google Gemini requires a Google Cloud project");
|
|
111
112
|
});
|
|
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
|
+
|
|
186
|
+
it("prefers a configured project id over a persisted managed project id", async () => {
|
|
187
|
+
const authWithManagedProject: OAuthAuthDetails = {
|
|
188
|
+
...baseAuth,
|
|
189
|
+
refresh: formatRefreshParts({
|
|
190
|
+
refreshToken: "refresh-token",
|
|
191
|
+
managedProjectId: "managed-project",
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
|
|
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}`);
|
|
209
|
+
});
|
|
210
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
211
|
+
|
|
212
|
+
const result = await resolveProjectContextFromAccessToken(
|
|
213
|
+
authWithManagedProject,
|
|
214
|
+
authWithManagedProject.access ?? "",
|
|
215
|
+
"configured-project",
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
expect(result.effectiveProjectId).toBe("configured-project");
|
|
219
|
+
expect(loadBody?.cloudaicompanionProject).toBe("configured-project");
|
|
220
|
+
});
|
|
112
221
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { Config } from "@opencode-ai/sdk";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
resolveConfiguredProjectId,
|
|
6
|
+
resolveConfiguredProjectIdFromClient,
|
|
7
|
+
resolveConfiguredProjectIdFromConfig,
|
|
8
|
+
} from "./provider";
|
|
9
|
+
import type { PluginClient, Provider } from "./types";
|
|
10
|
+
|
|
11
|
+
describe("resolveConfiguredProjectId", () => {
|
|
12
|
+
it("reads project id from provider options", () => {
|
|
13
|
+
const provider = {
|
|
14
|
+
id: "google",
|
|
15
|
+
name: "Google",
|
|
16
|
+
source: "config",
|
|
17
|
+
env: [],
|
|
18
|
+
options: {
|
|
19
|
+
projectId: " provider-project ",
|
|
20
|
+
},
|
|
21
|
+
models: {},
|
|
22
|
+
} satisfies Provider;
|
|
23
|
+
|
|
24
|
+
expect(
|
|
25
|
+
resolveConfiguredProjectId({
|
|
26
|
+
provider,
|
|
27
|
+
env: {},
|
|
28
|
+
}),
|
|
29
|
+
).toBe("provider-project");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("falls back to the top-level config project id when provider options are unavailable", () => {
|
|
33
|
+
const config = {
|
|
34
|
+
provider: {
|
|
35
|
+
google: {
|
|
36
|
+
options: {
|
|
37
|
+
projectId: "config-project",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
} satisfies Config;
|
|
42
|
+
|
|
43
|
+
expect(resolveConfiguredProjectIdFromConfig(config)).toBe("config-project");
|
|
44
|
+
expect(
|
|
45
|
+
resolveConfiguredProjectId({
|
|
46
|
+
config,
|
|
47
|
+
env: {},
|
|
48
|
+
}),
|
|
49
|
+
).toBe("config-project");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("prefers OPENCODE_GEMINI_PROJECT_ID over config and google cloud env vars", () => {
|
|
53
|
+
expect(
|
|
54
|
+
resolveConfiguredProjectId({
|
|
55
|
+
configProjectId: "config-project",
|
|
56
|
+
env: {
|
|
57
|
+
OPENCODE_GEMINI_PROJECT_ID: "opencode-project",
|
|
58
|
+
GOOGLE_CLOUD_PROJECT: "google-project",
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
).toBe("opencode-project");
|
|
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
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Config } from "@opencode-ai/sdk";
|
|
2
|
+
|
|
3
|
+
import { GEMINI_PROVIDER_ID } from "../constants";
|
|
4
|
+
import type { PluginClient, Provider } from "./types";
|
|
5
|
+
|
|
6
|
+
interface ResolveConfiguredProjectIdInput {
|
|
7
|
+
provider?: Provider | null;
|
|
8
|
+
config?: Config | null;
|
|
9
|
+
configProjectId?: string;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveConfiguredProjectId(
|
|
14
|
+
input: ResolveConfiguredProjectIdInput = {},
|
|
15
|
+
): string | undefined {
|
|
16
|
+
const env = input.env ?? process.env;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
normalizeProjectId(env.OPENCODE_GEMINI_PROJECT_ID) ??
|
|
20
|
+
resolveConfiguredProjectIdFromProvider(input.provider) ??
|
|
21
|
+
normalizeProjectId(input.configProjectId) ??
|
|
22
|
+
resolveConfiguredProjectIdFromConfig(input.config) ??
|
|
23
|
+
normalizeProjectId(env.GOOGLE_CLOUD_PROJECT) ??
|
|
24
|
+
normalizeProjectId(env.GOOGLE_CLOUD_PROJECT_ID)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveConfiguredProjectIdFromProvider(
|
|
29
|
+
provider: Provider | null | undefined,
|
|
30
|
+
): string | undefined {
|
|
31
|
+
if (!provider || typeof provider !== "object") {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return normalizeProjectId(provider.options?.projectId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveConfiguredProjectIdFromConfig(
|
|
38
|
+
config: Config | null | undefined,
|
|
39
|
+
): string | undefined {
|
|
40
|
+
if (!config?.provider || typeof config.provider !== "object") {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const providerConfig = config.provider[GEMINI_PROVIDER_ID];
|
|
45
|
+
return normalizeProjectId(providerConfig?.options?.projectId);
|
|
46
|
+
}
|
|
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
|
+
|
|
63
|
+
function normalizeProjectId(value: unknown): string | undefined {
|
|
64
|
+
if (typeof value !== "string") {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const trimmed = value.trim();
|
|
69
|
+
return trimmed || undefined;
|
|
70
|
+
}
|
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,6 +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 {
|
|
13
|
+
resolveConfiguredProjectId,
|
|
14
|
+
resolveConfiguredProjectIdFromClient,
|
|
15
|
+
resolveConfiguredProjectIdFromConfig,
|
|
16
|
+
} from "./plugin/provider";
|
|
12
17
|
import {
|
|
13
18
|
isGenerativeLanguageRequest,
|
|
14
19
|
prepareGeminiRequest,
|
|
@@ -42,143 +47,141 @@ let latestGeminiConfiguredProjectId: string | undefined;
|
|
|
42
47
|
*/
|
|
43
48
|
export const GeminiCLIOAuthPlugin = async (
|
|
44
49
|
{ client }: PluginContext,
|
|
45
|
-
): Promise<PluginResult> =>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
getAuthResolver: () => latestGeminiAuthResolver,
|
|
57
|
-
getConfiguredProjectId: () => latestGeminiConfiguredProjectId,
|
|
58
|
-
}),
|
|
59
|
-
},
|
|
60
|
-
auth: {
|
|
61
|
-
provider: GEMINI_PROVIDER_ID,
|
|
62
|
-
loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
|
|
63
|
-
latestGeminiAuthResolver = getAuth;
|
|
64
|
-
const auth = await getAuth();
|
|
65
|
-
if (!isOAuthAuth(auth)) {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
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
|
+
};
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
}
|
|
73
86
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (!isGenerativeLanguageRequest(input)) {
|
|
78
|
-
return fetch(input, init);
|
|
79
|
-
}
|
|
87
|
+
await resolveLatestConfiguredProjectId(provider);
|
|
88
|
+
normalizeProviderModelCosts(provider);
|
|
89
|
+
const thinkingConfigDefaults = resolveThinkingConfigDefaults(provider);
|
|
80
90
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
91
|
+
return {
|
|
92
|
+
apiKey: "",
|
|
93
|
+
async fetch(input, init) {
|
|
94
|
+
if (!isGenerativeLanguageRequest(input)) {
|
|
95
|
+
return fetch(input, init);
|
|
96
|
+
}
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const refreshed = await refreshAccessToken(authRecord, client);
|
|
89
|
-
if (!refreshed) {
|
|
98
|
+
const latestAuth = await getAuth();
|
|
99
|
+
if (!isOAuthAuth(latestAuth)) {
|
|
90
100
|
return fetch(input, init);
|
|
91
101
|
}
|
|
92
|
-
authRecord = refreshed;
|
|
93
|
-
}
|
|
94
102
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
}
|
|
98
115
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
});
|
|
125
143
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
}),
|
|
143
171
|
},
|
|
144
|
-
|
|
172
|
+
{
|
|
173
|
+
provider: GEMINI_PROVIDER_ID,
|
|
174
|
+
label: "Manually enter API Key",
|
|
175
|
+
type: "api",
|
|
176
|
+
},
|
|
177
|
+
],
|
|
145
178
|
},
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
label: "OAuth with Google (Gemini CLI)",
|
|
149
|
-
type: "oauth",
|
|
150
|
-
authorize: createOAuthAuthorizeMethod(),
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
provider: GEMINI_PROVIDER_ID,
|
|
154
|
-
label: "Manually enter API Key",
|
|
155
|
-
type: "api",
|
|
156
|
-
},
|
|
157
|
-
],
|
|
158
|
-
},
|
|
159
|
-
});
|
|
179
|
+
};
|
|
180
|
+
};
|
|
160
181
|
|
|
161
182
|
export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
|
|
162
183
|
const loggedQuotaModelsByProject = new Set<string>();
|
|
163
184
|
|
|
164
|
-
function resolveConfiguredProjectId(provider: Provider): string | undefined {
|
|
165
|
-
const providerOptions =
|
|
166
|
-
provider && typeof provider === "object"
|
|
167
|
-
? ((provider as { options?: Record<string, unknown> }).options ?? undefined)
|
|
168
|
-
: undefined;
|
|
169
|
-
const projectIdFromConfig =
|
|
170
|
-
providerOptions && typeof providerOptions.projectId === "string"
|
|
171
|
-
? providerOptions.projectId.trim()
|
|
172
|
-
: "";
|
|
173
|
-
const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
|
|
174
|
-
const googleProjectIdFromEnv =
|
|
175
|
-
process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
|
|
176
|
-
process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
|
|
177
|
-
"";
|
|
178
|
-
|
|
179
|
-
return projectIdFromEnv || projectIdFromConfig || googleProjectIdFromEnv || undefined;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
185
|
function normalizeProviderModelCosts(provider: Provider): void {
|
|
183
186
|
if (!provider.models) {
|
|
184
187
|
return;
|