opencode-gemini-auth 1.0.9 → 1.1.0
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 +47 -0
- package/package.json +1 -1
- package/src/constants.ts +5 -0
- package/src/plugin/cache.ts +53 -0
- package/src/plugin/debug.ts +168 -0
- package/src/plugin/project.ts +152 -103
- package/src/plugin/request.ts +34 -14
- package/src/plugin/token.test.ts +74 -0
- package/src/plugin/token.ts +18 -6
- package/src/plugin.ts +41 -5
package/README.md
CHANGED
|
@@ -38,3 +38,50 @@ echo "Plugin update script finished successfully.")
|
|
|
38
38
|
```bash
|
|
39
39
|
opencode # Reinstalls latest
|
|
40
40
|
```
|
|
41
|
+
|
|
42
|
+
## Local Development
|
|
43
|
+
|
|
44
|
+
First, clone the repository and install dependencies:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/jenslys/opencode-gemini-auth.git
|
|
48
|
+
cd opencode-gemini-auth
|
|
49
|
+
bun install
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
When you want Opencode to use a local checkout of this plugin, point the
|
|
53
|
+
`plugin` entry in your config to the folder via a `file://` URL:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"$schema": "https://opencode.ai/config.json",
|
|
58
|
+
"plugin": ["file:///absolute/path/to/opencode-gemini-auth"]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Replace `/absolute/path/to/opencode-gemini-auth` with the absolute path to
|
|
63
|
+
your local clone.
|
|
64
|
+
|
|
65
|
+
## Debugging Gemini Requests
|
|
66
|
+
|
|
67
|
+
Set `OPENCODE_GEMINI_DEBUG=1` in the environment when you run an Opencode
|
|
68
|
+
command to capture every Gemini request/response that this plugin issues. When
|
|
69
|
+
enabled, the plugin writes to a timestamped `gemini-debug-<ISO>.log` file in
|
|
70
|
+
your current working directory so the CLI output stays clean.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
OPENCODE_GEMINI_DEBUG=1 opencode
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The logger shows the transformed URL, HTTP method, sanitized headers (the
|
|
77
|
+
`Authorization` header is redacted), whether the call used streaming, and a
|
|
78
|
+
truncated preview (2 KB) of both the request and response bodies. This is handy
|
|
79
|
+
when diagnosing "Bad Request" responses from Gemini. Remember that payloads may
|
|
80
|
+
still include parts of your prompt or response, so only enable this flag when
|
|
81
|
+
you're comfortable keeping that information in the generated log file.
|
|
82
|
+
|
|
83
|
+
**404s on `gemini-2.5-flash-image`.** Opencode fires internal
|
|
84
|
+
summarization/title requests at `gemini-2.5-flash-image`. The plugin
|
|
85
|
+
automatically remaps those payloads to `gemini-2.5-flash`, eliminating the extra
|
|
86
|
+
404s for accounts without image access. If you still see a 404, confirm your
|
|
87
|
+
project actually has access to the fallback model.
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -32,3 +32,8 @@ export const CODE_ASSIST_HEADERS = {
|
|
|
32
32
|
"X-Goog-Api-Client": "gl-node/22.17.0",
|
|
33
33
|
"Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
|
|
34
34
|
} as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Provider identifier shared between the plugin loader and credential store.
|
|
38
|
+
*/
|
|
39
|
+
export const GEMINI_PROVIDER_ID = "google";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { accessTokenExpired } from "./auth";
|
|
2
|
+
import type { OAuthAuthDetails } from "./types";
|
|
3
|
+
|
|
4
|
+
const authCache = new Map<string, OAuthAuthDetails>();
|
|
5
|
+
|
|
6
|
+
function normalizeRefreshKey(refresh?: string): string | undefined {
|
|
7
|
+
const key = refresh?.trim();
|
|
8
|
+
return key ? key : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveCachedAuth(auth: OAuthAuthDetails): OAuthAuthDetails {
|
|
12
|
+
const key = normalizeRefreshKey(auth.refresh);
|
|
13
|
+
if (!key) {
|
|
14
|
+
return auth;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const cached = authCache.get(key);
|
|
18
|
+
if (!cached) {
|
|
19
|
+
authCache.set(key, auth);
|
|
20
|
+
return auth;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!accessTokenExpired(auth)) {
|
|
24
|
+
authCache.set(key, auth);
|
|
25
|
+
return auth;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!accessTokenExpired(cached)) {
|
|
29
|
+
return cached;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
authCache.set(key, auth);
|
|
33
|
+
return auth;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function storeCachedAuth(auth: OAuthAuthDetails): void {
|
|
37
|
+
const key = normalizeRefreshKey(auth.refresh);
|
|
38
|
+
if (!key) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
authCache.set(key, auth);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clearCachedAuth(refresh?: string): void {
|
|
45
|
+
if (!refresh) {
|
|
46
|
+
authCache.clear();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const key = normalizeRefreshKey(refresh);
|
|
50
|
+
if (key) {
|
|
51
|
+
authCache.delete(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { cwd, env } from "node:process";
|
|
4
|
+
|
|
5
|
+
const DEBUG_FLAG = env.OPENCODE_GEMINI_DEBUG ?? "";
|
|
6
|
+
const MAX_BODY_PREVIEW_CHARS = 2000;
|
|
7
|
+
const debugEnabled = DEBUG_FLAG.trim() === "1";
|
|
8
|
+
const logFilePath = debugEnabled ? defaultLogFilePath() : undefined;
|
|
9
|
+
const logWriter = createLogWriter(logFilePath);
|
|
10
|
+
|
|
11
|
+
export interface GeminiDebugContext {
|
|
12
|
+
id: string;
|
|
13
|
+
streaming: boolean;
|
|
14
|
+
startedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GeminiDebugRequestMeta {
|
|
18
|
+
originalUrl: string;
|
|
19
|
+
resolvedUrl: string;
|
|
20
|
+
method?: string;
|
|
21
|
+
headers?: HeadersInit;
|
|
22
|
+
body?: BodyInit | null;
|
|
23
|
+
streaming: boolean;
|
|
24
|
+
projectId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GeminiDebugResponseMeta {
|
|
28
|
+
body?: string;
|
|
29
|
+
note?: string;
|
|
30
|
+
error?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let requestCounter = 0;
|
|
34
|
+
|
|
35
|
+
export function startGeminiDebugRequest(meta: GeminiDebugRequestMeta): GeminiDebugContext | null {
|
|
36
|
+
if (!debugEnabled) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const id = `GEMINI-${++requestCounter}`;
|
|
41
|
+
const method = meta.method ?? "GET";
|
|
42
|
+
logDebug(`[Gemini Debug ${id}] ${method} ${meta.resolvedUrl}`);
|
|
43
|
+
if (meta.originalUrl && meta.originalUrl !== meta.resolvedUrl) {
|
|
44
|
+
logDebug(`[Gemini Debug ${id}] Original URL: ${meta.originalUrl}`);
|
|
45
|
+
}
|
|
46
|
+
if (meta.projectId) {
|
|
47
|
+
logDebug(`[Gemini Debug ${id}] Project: ${meta.projectId}`);
|
|
48
|
+
}
|
|
49
|
+
logDebug(`[Gemini Debug ${id}] Streaming: ${meta.streaming ? "yes" : "no"}`);
|
|
50
|
+
logDebug(`[Gemini Debug ${id}] Headers: ${JSON.stringify(maskHeaders(meta.headers))}`);
|
|
51
|
+
const bodyPreview = formatBodyPreview(meta.body);
|
|
52
|
+
if (bodyPreview) {
|
|
53
|
+
logDebug(`[Gemini Debug ${id}] Body Preview: ${bodyPreview}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { id, streaming: meta.streaming, startedAt: Date.now() };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function logGeminiDebugResponse(
|
|
60
|
+
context: GeminiDebugContext | null | undefined,
|
|
61
|
+
response: Response,
|
|
62
|
+
meta: GeminiDebugResponseMeta = {},
|
|
63
|
+
): void {
|
|
64
|
+
if (!debugEnabled || !context) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const durationMs = Date.now() - context.startedAt;
|
|
69
|
+
logDebug(
|
|
70
|
+
`[Gemini Debug ${context.id}] Response ${response.status} ${response.statusText} (${durationMs}ms)`,
|
|
71
|
+
);
|
|
72
|
+
logDebug(
|
|
73
|
+
`[Gemini Debug ${context.id}] Response Headers: ${JSON.stringify(maskHeaders(response.headers))}`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (meta.note) {
|
|
77
|
+
logDebug(`[Gemini Debug ${context.id}] Note: ${meta.note}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (meta.error) {
|
|
81
|
+
logDebug(`[Gemini Debug ${context.id}] Error: ${formatError(meta.error)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (meta.body) {
|
|
85
|
+
logDebug(
|
|
86
|
+
`[Gemini Debug ${context.id}] Response Body Preview: ${truncateForLog(meta.body)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function maskHeaders(headers?: HeadersInit | Headers): Record<string, string> {
|
|
92
|
+
if (!headers) {
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result: Record<string, string> = {};
|
|
97
|
+
const parsed = headers instanceof Headers ? headers : new Headers(headers);
|
|
98
|
+
parsed.forEach((value, key) => {
|
|
99
|
+
if (key.toLowerCase() === "authorization") {
|
|
100
|
+
result[key] = "[redacted]";
|
|
101
|
+
} else {
|
|
102
|
+
result[key] = value;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatBodyPreview(body?: BodyInit | null): string | undefined {
|
|
109
|
+
if (body == null) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof body === "string") {
|
|
114
|
+
return truncateForLog(body);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (body instanceof URLSearchParams) {
|
|
118
|
+
return truncateForLog(body.toString());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
122
|
+
return `[Blob size=${body.size}]`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
126
|
+
return "[FormData payload omitted]";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return `[${body.constructor?.name ?? typeof body} payload omitted]`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function truncateForLog(text: string): string {
|
|
133
|
+
if (text.length <= MAX_BODY_PREVIEW_CHARS) {
|
|
134
|
+
return text;
|
|
135
|
+
}
|
|
136
|
+
return `${text.slice(0, MAX_BODY_PREVIEW_CHARS)}... (truncated ${text.length - MAX_BODY_PREVIEW_CHARS} chars)`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function logDebug(line: string): void {
|
|
140
|
+
logWriter(line);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatError(error: unknown): string {
|
|
144
|
+
if (error instanceof Error) {
|
|
145
|
+
return error.stack ?? error.message;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
return JSON.stringify(error);
|
|
149
|
+
} catch {
|
|
150
|
+
return String(error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function defaultLogFilePath(): string {
|
|
155
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
156
|
+
return join(cwd(), `gemini-debug-${timestamp}.log`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function createLogWriter(filePath?: string): (line: string) => void {
|
|
160
|
+
if (!filePath) {
|
|
161
|
+
return () => {};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const stream = createWriteStream(filePath, { flags: "a" });
|
|
165
|
+
return (line: string) => {
|
|
166
|
+
stream.write(`${line}\n`);
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/plugin/project.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CODE_ASSIST_HEADERS,
|
|
3
3
|
GEMINI_CODE_ASSIST_ENDPOINT,
|
|
4
|
+
GEMINI_PROVIDER_ID,
|
|
4
5
|
} from "../constants";
|
|
5
6
|
import { formatRefreshParts, parseRefreshParts } from "./auth";
|
|
6
7
|
import type {
|
|
@@ -12,6 +13,73 @@ import type {
|
|
|
12
13
|
const projectContextResultCache = new Map<string, ProjectContextResult>();
|
|
13
14
|
const projectContextPendingCache = new Map<string, Promise<ProjectContextResult>>();
|
|
14
15
|
|
|
16
|
+
const CODE_ASSIST_METADATA = {
|
|
17
|
+
ideType: "IDE_UNSPECIFIED",
|
|
18
|
+
platform: "PLATFORM_UNSPECIFIED",
|
|
19
|
+
pluginType: "GEMINI",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
interface GeminiUserTier {
|
|
23
|
+
id?: string;
|
|
24
|
+
isDefault?: boolean;
|
|
25
|
+
userDefinedCloudaicompanionProject?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface LoadCodeAssistPayload {
|
|
29
|
+
cloudaicompanionProject?: string;
|
|
30
|
+
currentTier?: {
|
|
31
|
+
id?: string;
|
|
32
|
+
};
|
|
33
|
+
allowedTiers?: GeminiUserTier[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface OnboardUserPayload {
|
|
37
|
+
done?: boolean;
|
|
38
|
+
response?: {
|
|
39
|
+
cloudaicompanionProject?: {
|
|
40
|
+
id?: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class ProjectIdRequiredError extends Error {
|
|
46
|
+
constructor() {
|
|
47
|
+
super(
|
|
48
|
+
"Google Gemini requires a Google Cloud project. Enable the Gemini for Google Cloud API on a project you control, rerun `opencode auth login`, and supply that project ID when prompted.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildMetadata(projectId?: string): Record<string, string> {
|
|
54
|
+
const metadata: Record<string, string> = {
|
|
55
|
+
ideType: CODE_ASSIST_METADATA.ideType,
|
|
56
|
+
platform: CODE_ASSIST_METADATA.platform,
|
|
57
|
+
pluginType: CODE_ASSIST_METADATA.pluginType,
|
|
58
|
+
};
|
|
59
|
+
if (projectId) {
|
|
60
|
+
metadata.duetProject = projectId;
|
|
61
|
+
}
|
|
62
|
+
return metadata;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getDefaultTierId(allowedTiers?: GeminiUserTier[]): string | undefined {
|
|
66
|
+
if (!allowedTiers || allowedTiers.length === 0) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
for (const tier of allowedTiers) {
|
|
70
|
+
if (tier?.isDefault) {
|
|
71
|
+
return tier.id;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return allowedTiers[0]?.id;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function wait(ms: number): Promise<void> {
|
|
78
|
+
return new Promise(function (resolve) {
|
|
79
|
+
setTimeout(resolve, ms);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
15
83
|
function getCacheKey(auth: OAuthAuthDetails): string | undefined {
|
|
16
84
|
const refresh = auth.refresh?.trim();
|
|
17
85
|
return refresh ? refresh : undefined;
|
|
@@ -27,11 +95,18 @@ export function invalidateProjectContextCache(refresh?: string): void {
|
|
|
27
95
|
projectContextResultCache.delete(refresh);
|
|
28
96
|
}
|
|
29
97
|
|
|
30
|
-
export async function loadManagedProject(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
98
|
+
export async function loadManagedProject(
|
|
99
|
+
accessToken: string,
|
|
100
|
+
projectId?: string,
|
|
101
|
+
): Promise<LoadCodeAssistPayload | null> {
|
|
34
102
|
try {
|
|
103
|
+
const metadata = buildMetadata(projectId);
|
|
104
|
+
|
|
105
|
+
const requestBody: Record<string, unknown> = { metadata };
|
|
106
|
+
if (projectId) {
|
|
107
|
+
requestBody.cloudaicompanionProject = projectId;
|
|
108
|
+
}
|
|
109
|
+
|
|
35
110
|
const response = await fetch(
|
|
36
111
|
`${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`,
|
|
37
112
|
{
|
|
@@ -41,134 +116,78 @@ export async function loadManagedProject(accessToken: string): Promise<{
|
|
|
41
116
|
Authorization: `Bearer ${accessToken}`,
|
|
42
117
|
...CODE_ASSIST_HEADERS,
|
|
43
118
|
},
|
|
44
|
-
body: JSON.stringify(
|
|
45
|
-
metadata: {
|
|
46
|
-
ideType: "IDE_UNSPECIFIED",
|
|
47
|
-
platform: "PLATFORM_UNSPECIFIED",
|
|
48
|
-
pluginType: "GEMINI",
|
|
49
|
-
},
|
|
50
|
-
}),
|
|
119
|
+
body: JSON.stringify(requestBody),
|
|
51
120
|
},
|
|
52
121
|
);
|
|
53
122
|
|
|
54
123
|
if (!response.ok) {
|
|
55
|
-
return
|
|
124
|
+
return null;
|
|
56
125
|
}
|
|
57
126
|
|
|
58
|
-
|
|
59
|
-
cloudaicompanionProject?: string;
|
|
60
|
-
currentTier?: string;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
if (payload.cloudaicompanionProject) {
|
|
64
|
-
return {
|
|
65
|
-
managedProjectId: payload.cloudaicompanionProject,
|
|
66
|
-
needsOnboarding: false,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return { needsOnboarding: !payload.currentTier };
|
|
127
|
+
return (await response.json()) as LoadCodeAssistPayload;
|
|
71
128
|
} catch (error) {
|
|
72
129
|
console.error("Failed to load Gemini managed project:", error);
|
|
73
|
-
return
|
|
130
|
+
return null;
|
|
74
131
|
}
|
|
75
132
|
}
|
|
76
133
|
|
|
77
|
-
|
|
134
|
+
|
|
135
|
+
export async function onboardManagedProject(
|
|
78
136
|
accessToken: string,
|
|
79
|
-
|
|
137
|
+
tierId: string,
|
|
138
|
+
projectId?: string,
|
|
80
139
|
attempts = 10,
|
|
81
|
-
|
|
140
|
+
delayMs = 5000,
|
|
82
141
|
): Promise<string | undefined> {
|
|
83
|
-
|
|
84
|
-
|
|
142
|
+
const metadata = buildMetadata(projectId);
|
|
143
|
+
const requestBody: Record<string, unknown> = {
|
|
144
|
+
tierId,
|
|
145
|
+
metadata,
|
|
146
|
+
};
|
|
85
147
|
|
|
148
|
+
if (tierId !== "FREE") {
|
|
149
|
+
if (!projectId) {
|
|
150
|
+
throw new ProjectIdRequiredError();
|
|
151
|
+
}
|
|
152
|
+
requestBody.cloudaicompanionProject = projectId;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
86
156
|
try {
|
|
87
157
|
const response = await fetch(
|
|
88
|
-
`${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal
|
|
158
|
+
`${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`,
|
|
89
159
|
{
|
|
90
|
-
method: "
|
|
160
|
+
method: "POST",
|
|
91
161
|
headers: {
|
|
162
|
+
"Content-Type": "application/json",
|
|
92
163
|
Authorization: `Bearer ${accessToken}`,
|
|
164
|
+
...CODE_ASSIST_HEADERS,
|
|
93
165
|
},
|
|
166
|
+
body: JSON.stringify(requestBody),
|
|
94
167
|
},
|
|
95
168
|
);
|
|
96
169
|
|
|
97
170
|
if (!response.ok) {
|
|
98
|
-
|
|
171
|
+
return undefined;
|
|
99
172
|
}
|
|
100
173
|
|
|
101
|
-
const payload = (await response.json()) as
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
};
|
|
107
|
-
};
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const projectId = payload.response?.cloudaicompanionProject?.id;
|
|
174
|
+
const payload = (await response.json()) as OnboardUserPayload;
|
|
175
|
+
const managedProjectId = payload.response?.cloudaicompanionProject?.id;
|
|
176
|
+
if (payload.done && managedProjectId) {
|
|
177
|
+
return managedProjectId;
|
|
178
|
+
}
|
|
111
179
|
if (payload.done && projectId) {
|
|
112
180
|
return projectId;
|
|
113
181
|
}
|
|
114
182
|
} catch (error) {
|
|
115
|
-
console.error("Failed to
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return undefined;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export async function onboardManagedProject(
|
|
122
|
-
accessToken: string,
|
|
123
|
-
): Promise<string | undefined> {
|
|
124
|
-
try {
|
|
125
|
-
const response = await fetch(
|
|
126
|
-
`${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`,
|
|
127
|
-
{
|
|
128
|
-
method: "POST",
|
|
129
|
-
headers: {
|
|
130
|
-
"Content-Type": "application/json",
|
|
131
|
-
Authorization: `Bearer ${accessToken}`,
|
|
132
|
-
...CODE_ASSIST_HEADERS,
|
|
133
|
-
},
|
|
134
|
-
body: JSON.stringify({
|
|
135
|
-
tierId: "FREE",
|
|
136
|
-
metadata: {
|
|
137
|
-
ideType: "IDE_UNSPECIFIED",
|
|
138
|
-
platform: "PLATFORM_UNSPECIFIED",
|
|
139
|
-
pluginType: "GEMINI",
|
|
140
|
-
},
|
|
141
|
-
}),
|
|
142
|
-
},
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
if (!response.ok) {
|
|
183
|
+
console.error("Failed to onboard Gemini managed project:", error);
|
|
146
184
|
return undefined;
|
|
147
185
|
}
|
|
148
186
|
|
|
149
|
-
|
|
150
|
-
done?: boolean;
|
|
151
|
-
name?: string;
|
|
152
|
-
response?: {
|
|
153
|
-
cloudaicompanionProject?: {
|
|
154
|
-
id?: string;
|
|
155
|
-
};
|
|
156
|
-
};
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
if (payload.done && payload.response?.cloudaicompanionProject?.id) {
|
|
160
|
-
return payload.response.cloudaicompanionProject.id;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (!payload.done && payload.name) {
|
|
164
|
-
return pollOperation(accessToken, payload.name);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return undefined;
|
|
168
|
-
} catch (error) {
|
|
169
|
-
console.error("Failed to onboard Gemini managed project:", error);
|
|
170
|
-
return undefined;
|
|
187
|
+
await wait(delayMs);
|
|
171
188
|
}
|
|
189
|
+
|
|
190
|
+
return undefined;
|
|
172
191
|
}
|
|
173
192
|
|
|
174
193
|
export async function ensureProjectContext(
|
|
@@ -201,13 +220,43 @@ export async function ensureProjectContext(
|
|
|
201
220
|
};
|
|
202
221
|
}
|
|
203
222
|
|
|
204
|
-
const
|
|
205
|
-
|
|
223
|
+
const loadPayload = await loadManagedProject(accessToken, parts.projectId);
|
|
224
|
+
if (loadPayload?.cloudaicompanionProject) {
|
|
225
|
+
const managedProjectId = loadPayload.cloudaicompanionProject;
|
|
226
|
+
const updatedAuth: OAuthAuthDetails = {
|
|
227
|
+
...auth,
|
|
228
|
+
refresh: formatRefreshParts({
|
|
229
|
+
refreshToken: parts.refreshToken,
|
|
230
|
+
projectId: parts.projectId,
|
|
231
|
+
managedProjectId,
|
|
232
|
+
}),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
await client.auth.set({
|
|
236
|
+
path: { id: GEMINI_PROVIDER_ID },
|
|
237
|
+
body: updatedAuth,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return { auth: updatedAuth, effectiveProjectId: managedProjectId };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!loadPayload) {
|
|
244
|
+
throw new ProjectIdRequiredError();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const currentTierId = loadPayload.currentTier?.id ?? undefined;
|
|
248
|
+
if (currentTierId && currentTierId !== "FREE") {
|
|
249
|
+
throw new ProjectIdRequiredError();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const defaultTierId = getDefaultTierId(loadPayload.allowedTiers);
|
|
253
|
+
const tierId = defaultTierId ?? "FREE";
|
|
206
254
|
|
|
207
|
-
if (
|
|
208
|
-
|
|
255
|
+
if (tierId !== "FREE") {
|
|
256
|
+
throw new ProjectIdRequiredError();
|
|
209
257
|
}
|
|
210
258
|
|
|
259
|
+
const managedProjectId = await onboardManagedProject(accessToken, tierId, parts.projectId);
|
|
211
260
|
if (managedProjectId) {
|
|
212
261
|
const updatedAuth: OAuthAuthDetails = {
|
|
213
262
|
...auth,
|
|
@@ -219,14 +268,14 @@ export async function ensureProjectContext(
|
|
|
219
268
|
};
|
|
220
269
|
|
|
221
270
|
await client.auth.set({
|
|
222
|
-
path: { id:
|
|
271
|
+
path: { id: GEMINI_PROVIDER_ID },
|
|
223
272
|
body: updatedAuth,
|
|
224
273
|
});
|
|
225
274
|
|
|
226
275
|
return { auth: updatedAuth, effectiveProjectId: managedProjectId };
|
|
227
276
|
}
|
|
228
277
|
|
|
229
|
-
|
|
278
|
+
throw new ProjectIdRequiredError();
|
|
230
279
|
};
|
|
231
280
|
|
|
232
281
|
if (!cacheKey) {
|
package/src/plugin/request.ts
CHANGED
|
@@ -2,8 +2,12 @@ import {
|
|
|
2
2
|
CODE_ASSIST_HEADERS,
|
|
3
3
|
GEMINI_CODE_ASSIST_ENDPOINT,
|
|
4
4
|
} from "../constants";
|
|
5
|
+
import { logGeminiDebugResponse, type GeminiDebugContext } from "./debug";
|
|
5
6
|
|
|
6
7
|
const STREAM_ACTION = "streamGenerateContent";
|
|
8
|
+
const MODEL_FALLBACKS: Record<string, string> = {
|
|
9
|
+
"gemini-2.5-flash-image": "gemini-2.5-flash",
|
|
10
|
+
};
|
|
7
11
|
|
|
8
12
|
export function isGenerativeLanguageRequest(input: RequestInfo): input is string {
|
|
9
13
|
return typeof input === "string" && input.includes("generativelanguage.googleapis.com");
|
|
@@ -60,23 +64,26 @@ export function prepareGeminiRequest(
|
|
|
60
64
|
};
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
const [,
|
|
64
|
-
const
|
|
65
|
-
const
|
|
67
|
+
const [, rawModel = "", rawAction = ""] = match;
|
|
68
|
+
const effectiveModel = MODEL_FALLBACKS[rawModel] ?? rawModel;
|
|
69
|
+
const streaming = rawAction === STREAM_ACTION;
|
|
70
|
+
const transformedUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:${rawAction}${
|
|
66
71
|
streaming ? "?alt=sse" : ""
|
|
67
72
|
}`;
|
|
68
73
|
|
|
69
74
|
let body = baseInit.body;
|
|
70
75
|
if (typeof baseInit.body === "string" && baseInit.body) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
trimmedPayload.includes('"project"') &&
|
|
75
|
-
trimmedPayload.includes('"request"');
|
|
76
|
+
try {
|
|
77
|
+
const parsedBody = JSON.parse(baseInit.body) as Record<string, unknown>;
|
|
78
|
+
const isWrapped = typeof parsedBody.project === "string" && "request" in parsedBody;
|
|
76
79
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
if (isWrapped) {
|
|
81
|
+
const wrappedBody = {
|
|
82
|
+
...parsedBody,
|
|
83
|
+
model: effectiveModel,
|
|
84
|
+
} as Record<string, unknown>;
|
|
85
|
+
body = JSON.stringify(wrappedBody);
|
|
86
|
+
} else {
|
|
80
87
|
const requestPayload: Record<string, unknown> = { ...parsedBody };
|
|
81
88
|
|
|
82
89
|
if ("system_instruction" in requestPayload) {
|
|
@@ -90,14 +97,14 @@ export function prepareGeminiRequest(
|
|
|
90
97
|
|
|
91
98
|
const wrappedBody = {
|
|
92
99
|
project: projectId,
|
|
93
|
-
model,
|
|
100
|
+
model: effectiveModel,
|
|
94
101
|
request: requestPayload,
|
|
95
102
|
};
|
|
96
103
|
|
|
97
104
|
body = JSON.stringify(wrappedBody);
|
|
98
|
-
} catch (error) {
|
|
99
|
-
console.error("Failed to transform Gemini request body:", error);
|
|
100
105
|
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error("Failed to transform Gemini request body:", error);
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -123,9 +130,13 @@ export function prepareGeminiRequest(
|
|
|
123
130
|
export async function transformGeminiResponse(
|
|
124
131
|
response: Response,
|
|
125
132
|
streaming: boolean,
|
|
133
|
+
debugContext?: GeminiDebugContext | null,
|
|
126
134
|
): Promise<Response> {
|
|
127
135
|
const contentType = response.headers.get("content-type") ?? "";
|
|
128
136
|
if (!streaming && !contentType.includes("application/json")) {
|
|
137
|
+
logGeminiDebugResponse(debugContext, response, {
|
|
138
|
+
note: "Non-JSON response (body omitted)",
|
|
139
|
+
});
|
|
129
140
|
return response;
|
|
130
141
|
}
|
|
131
142
|
|
|
@@ -138,6 +149,11 @@ export async function transformGeminiResponse(
|
|
|
138
149
|
headers,
|
|
139
150
|
};
|
|
140
151
|
|
|
152
|
+
logGeminiDebugResponse(debugContext, response, {
|
|
153
|
+
body: text,
|
|
154
|
+
note: streaming ? "Streaming SSE payload" : undefined,
|
|
155
|
+
});
|
|
156
|
+
|
|
141
157
|
if (streaming) {
|
|
142
158
|
return new Response(transformStreamingPayload(text), init);
|
|
143
159
|
}
|
|
@@ -149,6 +165,10 @@ export async function transformGeminiResponse(
|
|
|
149
165
|
|
|
150
166
|
return new Response(text, init);
|
|
151
167
|
} catch (error) {
|
|
168
|
+
logGeminiDebugResponse(debugContext, response, {
|
|
169
|
+
error,
|
|
170
|
+
note: "Failed to transform Gemini response",
|
|
171
|
+
});
|
|
152
172
|
console.error("Failed to transform Gemini response:", error);
|
|
153
173
|
return response;
|
|
154
174
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { GEMINI_PROVIDER_ID } from "../constants";
|
|
4
|
+
import { refreshAccessToken } from "./token";
|
|
5
|
+
import type { OAuthAuthDetails, PluginClient } from "./types";
|
|
6
|
+
|
|
7
|
+
const baseAuth: OAuthAuthDetails = {
|
|
8
|
+
type: "oauth",
|
|
9
|
+
refresh: "refresh-token|project-123",
|
|
10
|
+
access: "old-access",
|
|
11
|
+
expires: Date.now() - 1000,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function createClient() {
|
|
15
|
+
return {
|
|
16
|
+
auth: {
|
|
17
|
+
set: mock(async () => {}),
|
|
18
|
+
},
|
|
19
|
+
} as PluginClient & {
|
|
20
|
+
auth: { set: ReturnType<typeof mock<(input: any) => Promise<void>>> };
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("refreshAccessToken", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
mock.restore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("updates the caller but skips persisting when refresh token is unchanged", async () => {
|
|
30
|
+
const client = createClient();
|
|
31
|
+
const fetchMock = mock(async () => {
|
|
32
|
+
return new Response(
|
|
33
|
+
JSON.stringify({
|
|
34
|
+
access_token: "new-access",
|
|
35
|
+
expires_in: 3600,
|
|
36
|
+
}),
|
|
37
|
+
{ status: 200 },
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
41
|
+
|
|
42
|
+
const result = await refreshAccessToken(baseAuth, client);
|
|
43
|
+
|
|
44
|
+
expect(result?.access).toBe("new-access");
|
|
45
|
+
expect(client.auth.set.mock.calls.length).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("persists when Google rotates the refresh token", async () => {
|
|
49
|
+
const client = createClient();
|
|
50
|
+
const fetchMock = mock(async () => {
|
|
51
|
+
return new Response(
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
access_token: "next-access",
|
|
54
|
+
expires_in: 3600,
|
|
55
|
+
refresh_token: "rotated-token",
|
|
56
|
+
}),
|
|
57
|
+
{ status: 200 },
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
61
|
+
|
|
62
|
+
const result = await refreshAccessToken(baseAuth, client);
|
|
63
|
+
|
|
64
|
+
expect(result?.access).toBe("next-access");
|
|
65
|
+
expect(client.auth.set.mock.calls.length).toBe(1);
|
|
66
|
+
expect(client.auth.set.mock.calls[0]?.[0]).toEqual({
|
|
67
|
+
path: { id: GEMINI_PROVIDER_ID },
|
|
68
|
+
body: expect.objectContaining({
|
|
69
|
+
type: "oauth",
|
|
70
|
+
refresh: expect.stringContaining("rotated-token"),
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/plugin/token.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
GEMINI_CLIENT_ID,
|
|
3
|
+
GEMINI_CLIENT_SECRET,
|
|
4
|
+
GEMINI_PROVIDER_ID,
|
|
5
|
+
} from "../constants";
|
|
2
6
|
import { formatRefreshParts, parseRefreshParts } from "./auth";
|
|
7
|
+
import { clearCachedAuth, storeCachedAuth } from "./cache";
|
|
3
8
|
import { invalidateProjectContextCache } from "./project";
|
|
4
9
|
import type { OAuthAuthDetails, PluginClient, RefreshParts } from "./types";
|
|
5
10
|
|
|
@@ -101,7 +106,7 @@ export async function refreshAccessToken(
|
|
|
101
106
|
}),
|
|
102
107
|
};
|
|
103
108
|
await client.auth.set({
|
|
104
|
-
path: { id:
|
|
109
|
+
path: { id: GEMINI_PROVIDER_ID },
|
|
105
110
|
body: clearedAuth,
|
|
106
111
|
});
|
|
107
112
|
} catch (storeError) {
|
|
@@ -131,12 +136,19 @@ export async function refreshAccessToken(
|
|
|
131
136
|
refresh: formatRefreshParts(refreshedParts),
|
|
132
137
|
};
|
|
133
138
|
|
|
134
|
-
|
|
135
|
-
path: { id: "gemini-cli" },
|
|
136
|
-
body: updatedAuth,
|
|
137
|
-
});
|
|
139
|
+
storeCachedAuth(updatedAuth);
|
|
138
140
|
invalidateProjectContextCache(auth.refresh);
|
|
139
141
|
|
|
142
|
+
const refreshTokenRotated =
|
|
143
|
+
typeof payload.refresh_token === "string" && payload.refresh_token !== parts.refreshToken;
|
|
144
|
+
|
|
145
|
+
if (refreshTokenRotated) {
|
|
146
|
+
await client.auth.set({
|
|
147
|
+
path: { id: GEMINI_PROVIDER_ID },
|
|
148
|
+
body: updatedAuth,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
140
152
|
return updatedAuth;
|
|
141
153
|
} catch (error) {
|
|
142
154
|
console.error("Failed to refresh Gemini access token due to an unexpected error:", error);
|
package/src/plugin.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { GEMINI_REDIRECT_URI } from "./constants";
|
|
1
|
+
import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
|
|
2
2
|
import { authorizeGemini, exchangeGemini } from "./gemini/oauth";
|
|
3
3
|
import type { GeminiTokenExchangeResult } from "./gemini/oauth";
|
|
4
4
|
import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
|
|
5
5
|
import { promptProjectId } from "./plugin/cli";
|
|
6
6
|
import { ensureProjectContext } from "./plugin/project";
|
|
7
|
+
import { startGeminiDebugRequest } from "./plugin/debug";
|
|
7
8
|
import {
|
|
8
9
|
isGenerativeLanguageRequest,
|
|
9
10
|
prepareGeminiRequest,
|
|
@@ -16,6 +17,7 @@ import type {
|
|
|
16
17
|
LoaderResult,
|
|
17
18
|
PluginContext,
|
|
18
19
|
PluginResult,
|
|
20
|
+
ProjectContextResult,
|
|
19
21
|
Provider,
|
|
20
22
|
} from "./plugin/types";
|
|
21
23
|
|
|
@@ -23,7 +25,7 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
23
25
|
{ client }: PluginContext,
|
|
24
26
|
): Promise<PluginResult> => ({
|
|
25
27
|
auth: {
|
|
26
|
-
provider:
|
|
28
|
+
provider: GEMINI_PROVIDER_ID,
|
|
27
29
|
loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
|
|
28
30
|
const auth = await getAuth();
|
|
29
31
|
if (!isOAuthAuth(auth)) {
|
|
@@ -64,7 +66,18 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
64
66
|
return fetch(input, init);
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
async function resolveProjectContext(): Promise<ProjectContextResult> {
|
|
70
|
+
try {
|
|
71
|
+
return await ensureProjectContext(authRecord, client);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error instanceof Error) {
|
|
74
|
+
console.error(error.message);
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const projectContext = await resolveProjectContext();
|
|
68
81
|
|
|
69
82
|
const { request, init: transformedInit, streaming } = prepareGeminiRequest(
|
|
70
83
|
input,
|
|
@@ -73,8 +86,20 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
73
86
|
projectContext.effectiveProjectId,
|
|
74
87
|
);
|
|
75
88
|
|
|
89
|
+
const originalUrl = toUrlString(input);
|
|
90
|
+
const resolvedUrl = toUrlString(request);
|
|
91
|
+
const debugContext = startGeminiDebugRequest({
|
|
92
|
+
originalUrl,
|
|
93
|
+
resolvedUrl,
|
|
94
|
+
method: transformedInit.method,
|
|
95
|
+
headers: transformedInit.headers,
|
|
96
|
+
body: transformedInit.body,
|
|
97
|
+
streaming,
|
|
98
|
+
projectId: projectContext.effectiveProjectId,
|
|
99
|
+
});
|
|
100
|
+
|
|
76
101
|
const response = await fetch(request, transformedInit);
|
|
77
|
-
return transformGeminiResponse(response, streaming);
|
|
102
|
+
return transformGeminiResponse(response, streaming, debugContext);
|
|
78
103
|
},
|
|
79
104
|
};
|
|
80
105
|
},
|
|
@@ -180,7 +205,7 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
180
205
|
},
|
|
181
206
|
},
|
|
182
207
|
{
|
|
183
|
-
provider:
|
|
208
|
+
provider: GEMINI_PROVIDER_ID,
|
|
184
209
|
label: "Manually enter API Key",
|
|
185
210
|
type: "api",
|
|
186
211
|
},
|
|
@@ -189,3 +214,14 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
189
214
|
});
|
|
190
215
|
|
|
191
216
|
export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
|
|
217
|
+
|
|
218
|
+
function toUrlString(value: RequestInfo): string {
|
|
219
|
+
if (typeof value === "string") {
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
const candidate = (value as Request).url;
|
|
223
|
+
if (candidate) {
|
|
224
|
+
return candidate;
|
|
225
|
+
}
|
|
226
|
+
return value.toString();
|
|
227
|
+
}
|