opencode-gemini-auth 1.3.7 → 1.3.9
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 +65 -9
- package/index.ts +1 -1
- package/package.json +2 -2
- package/src/gemini/oauth.ts +37 -47
- package/src/plugin/debug.ts +60 -0
- package/src/plugin/project.test.ts +112 -0
- package/src/plugin/project.ts +314 -121
- package/src/plugin/request-helpers.ts +249 -0
- package/src/plugin/request.ts +148 -30
- package/src/plugin/token.ts +32 -7
- package/src/plugin.ts +286 -13
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
|
{
|
|
@@ -24,6 +24,12 @@ Add the plugin to your Opencode configuration file
|
|
|
24
24
|
}
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
> [!IMPORTANT]
|
|
28
|
+
> If you're using a paid Gemini Code Assist subscription (Standard/Enterprise),
|
|
29
|
+
> explicitly configure a Google Cloud `projectId`. Free tier accounts should
|
|
30
|
+
> auto-provision a managed project, but you can still set `projectId` to force
|
|
31
|
+
> a specific project.
|
|
32
|
+
|
|
27
33
|
## Usage
|
|
28
34
|
|
|
29
35
|
1. **Login**: Run the authentication command in your terminal:
|
|
@@ -46,7 +52,10 @@ Once authenticated, Opencode will use your Google account for Gemini requests.
|
|
|
46
52
|
### Google Cloud Project
|
|
47
53
|
|
|
48
54
|
By default, the plugin attempts to provision or find a suitable Google Cloud
|
|
49
|
-
project. To force a specific project, set the `projectId` in your configuration
|
|
55
|
+
project. To force a specific project, set the `projectId` in your configuration
|
|
56
|
+
or via environment variables:
|
|
57
|
+
|
|
58
|
+
**File:** `~/.config/opencode/opencode.json`
|
|
50
59
|
|
|
51
60
|
```json
|
|
52
61
|
{
|
|
@@ -60,19 +69,44 @@ project. To force a specific project, set the `projectId` in your configuration:
|
|
|
60
69
|
}
|
|
61
70
|
```
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
You can also set `OPENCODE_GEMINI_PROJECT_ID`, `GOOGLE_CLOUD_PROJECT`, or
|
|
73
|
+
`GOOGLE_CLOUD_PROJECT_ID` to supply the project ID via environment variables.
|
|
64
74
|
|
|
65
|
-
|
|
66
|
-
option in your `config.json`.
|
|
75
|
+
### Model list
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
Below are example model entries you can add under `provider.google.models` in your
|
|
78
|
+
Opencode config. Each model can include an `options.thinkingConfig` block to
|
|
79
|
+
enable "thinking" features.
|
|
70
80
|
|
|
71
81
|
```json
|
|
72
82
|
{
|
|
73
83
|
"provider": {
|
|
74
84
|
"google": {
|
|
75
85
|
"models": {
|
|
86
|
+
"gemini-2.5-flash": {
|
|
87
|
+
"options": {
|
|
88
|
+
"thinkingConfig": {
|
|
89
|
+
"thinkingBudget": 8192,
|
|
90
|
+
"includeThoughts": true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"gemini-2.5-pro": {
|
|
95
|
+
"options": {
|
|
96
|
+
"thinkingConfig": {
|
|
97
|
+
"thinkingBudget": 8192,
|
|
98
|
+
"includeThoughts": true
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"gemini-3-flash-preview": {
|
|
103
|
+
"options": {
|
|
104
|
+
"thinkingConfig": {
|
|
105
|
+
"thinkingLevel": "high",
|
|
106
|
+
"includeThoughts": true
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
76
110
|
"gemini-3-pro-preview": {
|
|
77
111
|
"options": {
|
|
78
112
|
"thinkingConfig": {
|
|
@@ -87,14 +121,33 @@ Use `thinkingLevel` (`"low"`, `"high"`) for Gemini 3 models.
|
|
|
87
121
|
}
|
|
88
122
|
```
|
|
89
123
|
|
|
90
|
-
|
|
91
|
-
|
|
124
|
+
Note: Available model names and previews may change—check Google's documentation or
|
|
125
|
+
the Gemini product page for the current model identifiers.
|
|
126
|
+
|
|
127
|
+
### Thinking Models
|
|
128
|
+
|
|
129
|
+
The plugin supports configuring Gemini "thinking" features per-model via
|
|
130
|
+
`thinkingConfig`. The available fields depend on the model family:
|
|
131
|
+
|
|
132
|
+
- For Gemini 3 models: use `thinkingLevel` with values `"low"` or `"high"`.
|
|
133
|
+
- For Gemini 2.5 models: use `thinkingBudget` (token count).
|
|
134
|
+
- `includeThoughts` (boolean) controls whether the model emits internal thoughts.
|
|
135
|
+
|
|
136
|
+
A combined example showing both model types:
|
|
92
137
|
|
|
93
138
|
```json
|
|
94
139
|
{
|
|
95
140
|
"provider": {
|
|
96
141
|
"google": {
|
|
97
142
|
"models": {
|
|
143
|
+
"gemini-3-pro-preview": {
|
|
144
|
+
"options": {
|
|
145
|
+
"thinkingConfig": {
|
|
146
|
+
"thinkingLevel": "high",
|
|
147
|
+
"includeThoughts": true
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
98
151
|
"gemini-2.5-flash": {
|
|
99
152
|
"options": {
|
|
100
153
|
"thinkingConfig": {
|
|
@@ -109,6 +162,9 @@ Use `thinkingBudget` (token count) for Gemini 2.5 models.
|
|
|
109
162
|
}
|
|
110
163
|
```
|
|
111
164
|
|
|
165
|
+
If you don't set a `thinkingConfig` for a model, the plugin will use default
|
|
166
|
+
behavior for that model.
|
|
167
|
+
|
|
112
168
|
## Troubleshooting
|
|
113
169
|
|
|
114
170
|
### Manual Google Cloud Setup
|
package/index.ts
CHANGED
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.9",
|
|
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": "^1.
|
|
14
|
+
"@opencode-ai/plugin": "^1.1.48",
|
|
15
15
|
"@types/bun": "latest"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
package/src/gemini/oauth.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
GEMINI_CLIENT_ID,
|
|
@@ -6,22 +7,24 @@ import {
|
|
|
6
7
|
GEMINI_REDIRECT_URI,
|
|
7
8
|
GEMINI_SCOPES,
|
|
8
9
|
} from "../constants";
|
|
10
|
+
import {
|
|
11
|
+
formatDebugBodyPreview,
|
|
12
|
+
isGeminiDebugEnabled,
|
|
13
|
+
logGeminiDebugMessage,
|
|
14
|
+
} from "../plugin/debug";
|
|
9
15
|
|
|
10
16
|
interface PkcePair {
|
|
11
17
|
challenge: string;
|
|
12
18
|
verifier: string;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
|
-
interface GeminiAuthState {
|
|
16
|
-
verifier: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
21
|
/**
|
|
20
22
|
* Result returned to the caller after constructing an OAuth authorization URL.
|
|
21
23
|
*/
|
|
22
24
|
export interface GeminiAuthorization {
|
|
23
25
|
url: string;
|
|
24
26
|
verifier: string;
|
|
27
|
+
state: string;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
interface GeminiTokenExchangeSuccess {
|
|
@@ -51,34 +54,12 @@ interface GeminiUserInfo {
|
|
|
51
54
|
email?: string;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
/**
|
|
55
|
-
* Encode an object into a URL-safe base64 string.
|
|
56
|
-
*/
|
|
57
|
-
function encodeState(payload: GeminiAuthState): string {
|
|
58
|
-
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Decode an OAuth state parameter back into its structured representation.
|
|
63
|
-
*/
|
|
64
|
-
function decodeState(state: string): GeminiAuthState {
|
|
65
|
-
const normalized = state.replace(/-/g, "+").replace(/_/g, "/");
|
|
66
|
-
const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "=");
|
|
67
|
-
const json = Buffer.from(padded, "base64").toString("utf8");
|
|
68
|
-
const parsed = JSON.parse(json);
|
|
69
|
-
if (typeof parsed.verifier !== "string") {
|
|
70
|
-
throw new Error("Missing PKCE verifier in state");
|
|
71
|
-
}
|
|
72
|
-
return {
|
|
73
|
-
verifier: parsed.verifier,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
57
|
/**
|
|
78
58
|
* Build the Gemini OAuth authorization URL including PKCE.
|
|
79
59
|
*/
|
|
80
60
|
export async function authorizeGemini(): Promise<GeminiAuthorization> {
|
|
81
61
|
const pkce = (await generatePKCE()) as PkcePair;
|
|
62
|
+
const state = randomBytes(32).toString("hex");
|
|
82
63
|
|
|
83
64
|
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
|
84
65
|
url.searchParams.set("client_id", GEMINI_CLIENT_ID);
|
|
@@ -87,35 +68,19 @@ export async function authorizeGemini(): Promise<GeminiAuthorization> {
|
|
|
87
68
|
url.searchParams.set("scope", GEMINI_SCOPES.join(" "));
|
|
88
69
|
url.searchParams.set("code_challenge", pkce.challenge);
|
|
89
70
|
url.searchParams.set("code_challenge_method", "S256");
|
|
90
|
-
url.searchParams.set("state",
|
|
71
|
+
url.searchParams.set("state", state);
|
|
91
72
|
url.searchParams.set("access_type", "offline");
|
|
92
73
|
url.searchParams.set("prompt", "consent");
|
|
74
|
+
// Add a fragment so any stray terminal glyphs are ignored by the auth server.
|
|
75
|
+
url.hash = "opencode";
|
|
93
76
|
|
|
94
77
|
return {
|
|
95
78
|
url: url.toString(),
|
|
96
79
|
verifier: pkce.verifier,
|
|
80
|
+
state,
|
|
97
81
|
};
|
|
98
82
|
}
|
|
99
83
|
|
|
100
|
-
/**
|
|
101
|
-
* Exchange an authorization code for Gemini CLI access and refresh tokens.
|
|
102
|
-
*/
|
|
103
|
-
export async function exchangeGemini(
|
|
104
|
-
code: string,
|
|
105
|
-
state: string,
|
|
106
|
-
): Promise<GeminiTokenExchangeResult> {
|
|
107
|
-
try {
|
|
108
|
-
const { verifier } = decodeState(state);
|
|
109
|
-
|
|
110
|
-
return await exchangeGeminiWithVerifierInternal(code, verifier);
|
|
111
|
-
} catch (error) {
|
|
112
|
-
return {
|
|
113
|
-
type: "failed",
|
|
114
|
-
error: error instanceof Error ? error.message : "Unknown error",
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
84
|
/**
|
|
120
85
|
* Exchange an authorization code using a known PKCE verifier.
|
|
121
86
|
*/
|
|
@@ -137,6 +102,9 @@ async function exchangeGeminiWithVerifierInternal(
|
|
|
137
102
|
code: string,
|
|
138
103
|
verifier: string,
|
|
139
104
|
): Promise<GeminiTokenExchangeResult> {
|
|
105
|
+
if (isGeminiDebugEnabled()) {
|
|
106
|
+
logGeminiDebugMessage("OAuth exchange: POST https://oauth2.googleapis.com/token");
|
|
107
|
+
}
|
|
140
108
|
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
|
141
109
|
method: "POST",
|
|
142
110
|
headers: {
|
|
@@ -154,11 +122,28 @@ async function exchangeGeminiWithVerifierInternal(
|
|
|
154
122
|
|
|
155
123
|
if (!tokenResponse.ok) {
|
|
156
124
|
const errorText = await tokenResponse.text();
|
|
125
|
+
if (isGeminiDebugEnabled()) {
|
|
126
|
+
logGeminiDebugMessage(
|
|
127
|
+
`OAuth exchange response: ${tokenResponse.status} ${tokenResponse.statusText}`,
|
|
128
|
+
);
|
|
129
|
+
const preview = formatDebugBodyPreview(errorText);
|
|
130
|
+
if (preview) {
|
|
131
|
+
logGeminiDebugMessage(`OAuth exchange error body: ${preview}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
157
134
|
return { type: "failed", error: errorText };
|
|
158
135
|
}
|
|
159
136
|
|
|
160
137
|
const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
|
|
138
|
+
if (isGeminiDebugEnabled()) {
|
|
139
|
+
logGeminiDebugMessage(
|
|
140
|
+
`OAuth exchange success: expires_in=${tokenPayload.expires_in}s refresh_token=${tokenPayload.refresh_token ? "yes" : "no"}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
161
143
|
|
|
144
|
+
if (isGeminiDebugEnabled()) {
|
|
145
|
+
logGeminiDebugMessage("OAuth userinfo: GET https://www.googleapis.com/oauth2/v1/userinfo");
|
|
146
|
+
}
|
|
162
147
|
const userInfoResponse = await fetch(
|
|
163
148
|
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
|
164
149
|
{
|
|
@@ -167,6 +152,11 @@ async function exchangeGeminiWithVerifierInternal(
|
|
|
167
152
|
},
|
|
168
153
|
},
|
|
169
154
|
);
|
|
155
|
+
if (isGeminiDebugEnabled()) {
|
|
156
|
+
logGeminiDebugMessage(
|
|
157
|
+
`OAuth userinfo response: ${userInfoResponse.status} ${userInfoResponse.statusText}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
170
160
|
|
|
171
161
|
const userInfo = userInfoResponse.ok
|
|
172
162
|
? ((await userInfoResponse.json()) as GeminiUserInfo)
|
package/src/plugin/debug.ts
CHANGED
|
@@ -33,6 +33,33 @@ interface GeminiDebugResponseMeta {
|
|
|
33
33
|
|
|
34
34
|
let requestCounter = 0;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Returns true when Gemini debug logging is enabled.
|
|
38
|
+
*/
|
|
39
|
+
export function isGeminiDebugEnabled(): boolean {
|
|
40
|
+
return debugEnabled;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Writes an arbitrary debug line when debugging is enabled.
|
|
45
|
+
*/
|
|
46
|
+
export function logGeminiDebugMessage(message: string): void {
|
|
47
|
+
if (!debugEnabled) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
logDebug(`[Gemini Debug] ${message}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Produces a truncated preview of a debug body payload.
|
|
55
|
+
*/
|
|
56
|
+
export function formatDebugBodyPreview(text?: string | null): string | undefined {
|
|
57
|
+
if (!text) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return truncateForLog(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
36
63
|
/**
|
|
37
64
|
* Begins a debug trace for a Gemini request, logging request metadata when debugging is enabled.
|
|
38
65
|
*/
|
|
@@ -82,6 +109,11 @@ export function logGeminiDebugResponse(
|
|
|
82
109
|
)}`,
|
|
83
110
|
);
|
|
84
111
|
|
|
112
|
+
const traceId = getHeaderValue(meta.headersOverride ?? response.headers, "x-cloudaicompanion-trace-id");
|
|
113
|
+
if (traceId) {
|
|
114
|
+
logDebug(`[Gemini Debug ${context.id}] Trace ID: ${traceId}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
85
117
|
if (meta.note) {
|
|
86
118
|
logDebug(`[Gemini Debug ${context.id}] Note: ${meta.note}`);
|
|
87
119
|
}
|
|
@@ -117,6 +149,34 @@ function maskHeaders(headers?: HeadersInit | Headers): Record<string, string> {
|
|
|
117
149
|
return result;
|
|
118
150
|
}
|
|
119
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Reads a header value from a HeadersInit or Headers instance.
|
|
154
|
+
*/
|
|
155
|
+
function getHeaderValue(headers: HeadersInit | Headers, key: string): string | undefined {
|
|
156
|
+
const target = key.toLowerCase();
|
|
157
|
+
if (headers instanceof Headers) {
|
|
158
|
+
const value = headers.get(key);
|
|
159
|
+
return value ?? undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (Array.isArray(headers)) {
|
|
163
|
+
for (const [headerKey, headerValue] of headers) {
|
|
164
|
+
if (headerKey.toLowerCase() === target) {
|
|
165
|
+
return headerValue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const record = headers as Record<string, string | undefined>;
|
|
172
|
+
for (const [headerKey, headerValue] of Object.entries(record)) {
|
|
173
|
+
if (headerKey.toLowerCase() === target) {
|
|
174
|
+
return headerValue ?? undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
120
180
|
/**
|
|
121
181
|
* Produces a short, type-aware preview of a request/response body for logs.
|
|
122
182
|
*/
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { resolveProjectContextFromAccessToken } from "./project";
|
|
4
|
+
import type { OAuthAuthDetails } from "./types";
|
|
5
|
+
|
|
6
|
+
const baseAuth: OAuthAuthDetails = {
|
|
7
|
+
type: "oauth",
|
|
8
|
+
refresh: "refresh-token",
|
|
9
|
+
access: "access-token",
|
|
10
|
+
expires: Date.now() + 60_000,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function toUrlString(input: RequestInfo): string {
|
|
14
|
+
if (typeof input === "string") {
|
|
15
|
+
return input;
|
|
16
|
+
}
|
|
17
|
+
if (input instanceof URL) {
|
|
18
|
+
return input.toString();
|
|
19
|
+
}
|
|
20
|
+
return (input as Request).url ?? input.toString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("resolveProjectContextFromAccessToken", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mock.restore();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("stores managed project id from loadCodeAssist without onboarding", async () => {
|
|
29
|
+
const fetchMock = mock(async (input: RequestInfo) => {
|
|
30
|
+
const url = toUrlString(input);
|
|
31
|
+
if (url.includes(":loadCodeAssist")) {
|
|
32
|
+
return new Response(
|
|
33
|
+
JSON.stringify({
|
|
34
|
+
currentTier: { id: "free-tier" },
|
|
35
|
+
cloudaicompanionProject: "projects/server-project",
|
|
36
|
+
}),
|
|
37
|
+
{ status: 200 },
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Unexpected fetch to ${url}`);
|
|
41
|
+
});
|
|
42
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
43
|
+
|
|
44
|
+
const result = await resolveProjectContextFromAccessToken(
|
|
45
|
+
baseAuth,
|
|
46
|
+
baseAuth.access ?? "",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(result.effectiveProjectId).toBe("projects/server-project");
|
|
50
|
+
expect(result.auth.refresh).toContain("projects/server-project");
|
|
51
|
+
expect(fetchMock.mock.calls.length).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("onboards free-tier users without sending a project id", async () => {
|
|
55
|
+
let onboardBody: Record<string, unknown> | undefined;
|
|
56
|
+
const fetchMock = mock(async (input: RequestInfo, init?: RequestInit) => {
|
|
57
|
+
const url = toUrlString(input);
|
|
58
|
+
if (url.includes(":loadCodeAssist")) {
|
|
59
|
+
return new Response(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
allowedTiers: [{ id: "free-tier", isDefault: true }],
|
|
62
|
+
}),
|
|
63
|
+
{ status: 200 },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (url.includes(":onboardUser")) {
|
|
67
|
+
const rawBody = typeof init?.body === "string" ? init.body : "{}";
|
|
68
|
+
onboardBody = JSON.parse(rawBody) as Record<string, unknown>;
|
|
69
|
+
return new Response(
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
done: true,
|
|
72
|
+
response: { cloudaicompanionProject: { id: "managed-project" } },
|
|
73
|
+
}),
|
|
74
|
+
{ status: 200 },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Unexpected fetch to ${url}`);
|
|
78
|
+
});
|
|
79
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
80
|
+
|
|
81
|
+
const result = await resolveProjectContextFromAccessToken(
|
|
82
|
+
baseAuth,
|
|
83
|
+
baseAuth.access ?? "",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(result.effectiveProjectId).toBe("managed-project");
|
|
87
|
+
expect(result.auth.refresh).toContain("managed-project");
|
|
88
|
+
expect(onboardBody?.cloudaicompanionProject).toBeUndefined();
|
|
89
|
+
const metadata = onboardBody?.metadata as Record<string, unknown> | undefined;
|
|
90
|
+
expect(metadata?.duetProject).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("throws when a non-free tier requires a project id", async () => {
|
|
94
|
+
const fetchMock = mock(async (input: RequestInfo) => {
|
|
95
|
+
const url = toUrlString(input);
|
|
96
|
+
if (url.includes(":loadCodeAssist")) {
|
|
97
|
+
return new Response(
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
allowedTiers: [{ id: "standard-tier", isDefault: true }],
|
|
100
|
+
}),
|
|
101
|
+
{ status: 200 },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`Unexpected fetch to ${url}`);
|
|
105
|
+
});
|
|
106
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
107
|
+
|
|
108
|
+
await expect(
|
|
109
|
+
resolveProjectContextFromAccessToken(baseAuth, baseAuth.access ?? ""),
|
|
110
|
+
).rejects.toThrow("Google Gemini requires a Google Cloud project");
|
|
111
|
+
});
|
|
112
|
+
});
|