opencode-gemini-auth 1.1.4 → 1.1.6
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/auth.ts +9 -0
- package/src/plugin/cache.ts +12 -0
- package/src/plugin/cli.ts +3 -0
- package/src/plugin/debug.ts +31 -1
- package/src/plugin/project.ts +27 -0
- package/src/plugin/request-helpers.ts +196 -0
- package/src/plugin/request.ts +111 -98
- package/src/plugin/server.ts +2 -3
- package/src/plugin/token.ts +7 -1
- package/src/plugin.ts +43 -20
package/package.json
CHANGED
package/src/plugin/auth.ts
CHANGED
|
@@ -6,6 +6,9 @@ export function isOAuthAuth(auth: AuthDetails): auth is OAuthAuthDetails {
|
|
|
6
6
|
return auth.type === "oauth";
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Splits a packed refresh string into its constituent refresh token and project IDs.
|
|
11
|
+
*/
|
|
9
12
|
export function parseRefreshParts(refresh: string): RefreshParts {
|
|
10
13
|
const [refreshToken = "", projectId = "", managedProjectId = ""] = (refresh ?? "").split("|");
|
|
11
14
|
return {
|
|
@@ -15,12 +18,18 @@ export function parseRefreshParts(refresh: string): RefreshParts {
|
|
|
15
18
|
};
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Serializes refresh token parts into the stored string format.
|
|
23
|
+
*/
|
|
18
24
|
export function formatRefreshParts(parts: RefreshParts): string {
|
|
19
25
|
const projectSegment = parts.projectId ?? "";
|
|
20
26
|
const base = `${parts.refreshToken}|${projectSegment}`;
|
|
21
27
|
return parts.managedProjectId ? `${base}|${parts.managedProjectId}` : base;
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Determines whether an access token is expired or missing, with buffer for clock skew.
|
|
32
|
+
*/
|
|
24
33
|
export function accessTokenExpired(auth: OAuthAuthDetails): boolean {
|
|
25
34
|
if (!auth.access || typeof auth.expires !== "number") {
|
|
26
35
|
return true;
|
package/src/plugin/cache.ts
CHANGED
|
@@ -3,11 +3,17 @@ import type { OAuthAuthDetails } from "./types";
|
|
|
3
3
|
|
|
4
4
|
const authCache = new Map<string, OAuthAuthDetails>();
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Produces a stable cache key from a refresh token string.
|
|
8
|
+
*/
|
|
6
9
|
function normalizeRefreshKey(refresh?: string): string | undefined {
|
|
7
10
|
const key = refresh?.trim();
|
|
8
11
|
return key ? key : undefined;
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Returns a cached auth snapshot when available, favoring unexpired tokens.
|
|
16
|
+
*/
|
|
11
17
|
export function resolveCachedAuth(auth: OAuthAuthDetails): OAuthAuthDetails {
|
|
12
18
|
const key = normalizeRefreshKey(auth.refresh);
|
|
13
19
|
if (!key) {
|
|
@@ -33,6 +39,9 @@ export function resolveCachedAuth(auth: OAuthAuthDetails): OAuthAuthDetails {
|
|
|
33
39
|
return auth;
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Stores the latest auth snapshot keyed by refresh token.
|
|
44
|
+
*/
|
|
36
45
|
export function storeCachedAuth(auth: OAuthAuthDetails): void {
|
|
37
46
|
const key = normalizeRefreshKey(auth.refresh);
|
|
38
47
|
if (!key) {
|
|
@@ -41,6 +50,9 @@ export function storeCachedAuth(auth: OAuthAuthDetails): void {
|
|
|
41
50
|
authCache.set(key, auth);
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Clears cached auth globally or for a specific refresh token.
|
|
55
|
+
*/
|
|
44
56
|
export function clearCachedAuth(refresh?: string): void {
|
|
45
57
|
if (!refresh) {
|
|
46
58
|
authCache.clear();
|
package/src/plugin/cli.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { stdin as input, stdout as output } from "node:process";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Prompts the user for a project ID via stdin/stdout.
|
|
6
|
+
*/
|
|
4
7
|
export async function promptProjectId(): Promise<string> {
|
|
5
8
|
const rl = createInterface({ input, output });
|
|
6
9
|
try {
|
package/src/plugin/debug.ts
CHANGED
|
@@ -28,10 +28,14 @@ interface GeminiDebugResponseMeta {
|
|
|
28
28
|
body?: string;
|
|
29
29
|
note?: string;
|
|
30
30
|
error?: unknown;
|
|
31
|
+
headersOverride?: HeadersInit;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
let requestCounter = 0;
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Begins a debug trace for a Gemini request, logging request metadata when debugging is enabled.
|
|
38
|
+
*/
|
|
35
39
|
export function startGeminiDebugRequest(meta: GeminiDebugRequestMeta): GeminiDebugContext | null {
|
|
36
40
|
if (!debugEnabled) {
|
|
37
41
|
return null;
|
|
@@ -56,6 +60,9 @@ export function startGeminiDebugRequest(meta: GeminiDebugRequestMeta): GeminiDeb
|
|
|
56
60
|
return { id, streaming: meta.streaming, startedAt: Date.now() };
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Logs response details for a previously started debug trace when debugging is enabled.
|
|
65
|
+
*/
|
|
59
66
|
export function logGeminiDebugResponse(
|
|
60
67
|
context: GeminiDebugContext | null | undefined,
|
|
61
68
|
response: Response,
|
|
@@ -70,7 +77,9 @@ export function logGeminiDebugResponse(
|
|
|
70
77
|
`[Gemini Debug ${context.id}] Response ${response.status} ${response.statusText} (${durationMs}ms)`,
|
|
71
78
|
);
|
|
72
79
|
logDebug(
|
|
73
|
-
`[Gemini Debug ${context.id}] Response Headers: ${JSON.stringify(
|
|
80
|
+
`[Gemini Debug ${context.id}] Response Headers: ${JSON.stringify(
|
|
81
|
+
maskHeaders(meta.headersOverride ?? response.headers),
|
|
82
|
+
)}`,
|
|
74
83
|
);
|
|
75
84
|
|
|
76
85
|
if (meta.note) {
|
|
@@ -88,6 +97,9 @@ export function logGeminiDebugResponse(
|
|
|
88
97
|
}
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Obscures sensitive headers and returns a plain object for logging.
|
|
102
|
+
*/
|
|
91
103
|
function maskHeaders(headers?: HeadersInit | Headers): Record<string, string> {
|
|
92
104
|
if (!headers) {
|
|
93
105
|
return {};
|
|
@@ -105,6 +117,9 @@ function maskHeaders(headers?: HeadersInit | Headers): Record<string, string> {
|
|
|
105
117
|
return result;
|
|
106
118
|
}
|
|
107
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Produces a short, type-aware preview of a request/response body for logs.
|
|
122
|
+
*/
|
|
108
123
|
function formatBodyPreview(body?: BodyInit | null): string | undefined {
|
|
109
124
|
if (body == null) {
|
|
110
125
|
return undefined;
|
|
@@ -129,6 +144,9 @@ function formatBodyPreview(body?: BodyInit | null): string | undefined {
|
|
|
129
144
|
return `[${body.constructor?.name ?? typeof body} payload omitted]`;
|
|
130
145
|
}
|
|
131
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Truncates long strings to a fixed preview length for logging.
|
|
149
|
+
*/
|
|
132
150
|
function truncateForLog(text: string): string {
|
|
133
151
|
if (text.length <= MAX_BODY_PREVIEW_CHARS) {
|
|
134
152
|
return text;
|
|
@@ -136,10 +154,16 @@ function truncateForLog(text: string): string {
|
|
|
136
154
|
return `${text.slice(0, MAX_BODY_PREVIEW_CHARS)}... (truncated ${text.length - MAX_BODY_PREVIEW_CHARS} chars)`;
|
|
137
155
|
}
|
|
138
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Writes a single debug line using the configured writer.
|
|
159
|
+
*/
|
|
139
160
|
function logDebug(line: string): void {
|
|
140
161
|
logWriter(line);
|
|
141
162
|
}
|
|
142
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Converts unknown error-like values into printable strings.
|
|
166
|
+
*/
|
|
143
167
|
function formatError(error: unknown): string {
|
|
144
168
|
if (error instanceof Error) {
|
|
145
169
|
return error.stack ?? error.message;
|
|
@@ -151,11 +175,17 @@ function formatError(error: unknown): string {
|
|
|
151
175
|
}
|
|
152
176
|
}
|
|
153
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Builds a timestamped log file path in the current working directory.
|
|
180
|
+
*/
|
|
154
181
|
function defaultLogFilePath(): string {
|
|
155
182
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
156
183
|
return join(cwd(), `gemini-debug-${timestamp}.log`);
|
|
157
184
|
}
|
|
158
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Creates a line writer that appends to a file when provided.
|
|
188
|
+
*/
|
|
159
189
|
function createLogWriter(filePath?: string): (line: string) => void {
|
|
160
190
|
if (!filePath) {
|
|
161
191
|
return () => {};
|
package/src/plugin/project.ts
CHANGED
|
@@ -43,6 +43,9 @@ interface OnboardUserPayload {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
class ProjectIdRequiredError extends Error {
|
|
46
|
+
/**
|
|
47
|
+
* Error raised when a required Google Cloud project is missing during Gemini onboarding.
|
|
48
|
+
*/
|
|
46
49
|
constructor() {
|
|
47
50
|
super(
|
|
48
51
|
"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.",
|
|
@@ -50,6 +53,9 @@ class ProjectIdRequiredError extends Error {
|
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Builds metadata headers required by the Code Assist API.
|
|
58
|
+
*/
|
|
53
59
|
function buildMetadata(projectId?: string): Record<string, string> {
|
|
54
60
|
const metadata: Record<string, string> = {
|
|
55
61
|
ideType: CODE_ASSIST_METADATA.ideType,
|
|
@@ -62,6 +68,9 @@ function buildMetadata(projectId?: string): Record<string, string> {
|
|
|
62
68
|
return metadata;
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Selects the default tier ID from the allowed tiers list.
|
|
73
|
+
*/
|
|
65
74
|
function getDefaultTierId(allowedTiers?: GeminiUserTier[]): string | undefined {
|
|
66
75
|
if (!allowedTiers || allowedTiers.length === 0) {
|
|
67
76
|
return undefined;
|
|
@@ -74,17 +83,26 @@ function getDefaultTierId(allowedTiers?: GeminiUserTier[]): string | undefined {
|
|
|
74
83
|
return allowedTiers[0]?.id;
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Promise-based delay utility.
|
|
88
|
+
*/
|
|
77
89
|
function wait(ms: number): Promise<void> {
|
|
78
90
|
return new Promise(function (resolve) {
|
|
79
91
|
setTimeout(resolve, ms);
|
|
80
92
|
});
|
|
81
93
|
}
|
|
82
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Generates a cache key for project context based on refresh token.
|
|
97
|
+
*/
|
|
83
98
|
function getCacheKey(auth: OAuthAuthDetails): string | undefined {
|
|
84
99
|
const refresh = auth.refresh?.trim();
|
|
85
100
|
return refresh ? refresh : undefined;
|
|
86
101
|
}
|
|
87
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Clears cached project context results and pending promises, globally or for a refresh key.
|
|
105
|
+
*/
|
|
88
106
|
export function invalidateProjectContextCache(refresh?: string): void {
|
|
89
107
|
if (!refresh) {
|
|
90
108
|
projectContextPendingCache.clear();
|
|
@@ -95,6 +113,9 @@ export function invalidateProjectContextCache(refresh?: string): void {
|
|
|
95
113
|
projectContextResultCache.delete(refresh);
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Loads managed project information for the given access token and optional project.
|
|
118
|
+
*/
|
|
98
119
|
export async function loadManagedProject(
|
|
99
120
|
accessToken: string,
|
|
100
121
|
projectId?: string,
|
|
@@ -132,6 +153,9 @@ export async function loadManagedProject(
|
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Onboards a managed project for the user, optionally retrying until completion.
|
|
158
|
+
*/
|
|
135
159
|
export async function onboardManagedProject(
|
|
136
160
|
accessToken: string,
|
|
137
161
|
tierId: string,
|
|
@@ -190,6 +214,9 @@ export async function onboardManagedProject(
|
|
|
190
214
|
return undefined;
|
|
191
215
|
}
|
|
192
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Resolves an effective project ID for the current auth state, caching results per refresh token.
|
|
219
|
+
*/
|
|
193
220
|
export async function ensureProjectContext(
|
|
194
221
|
auth: OAuthAuthDetails,
|
|
195
222
|
client: PluginClient,
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
const GEMINI_PREVIEW_LINK = "https://goo.gle/enable-preview-features";
|
|
2
|
+
|
|
3
|
+
export interface GeminiApiError {
|
|
4
|
+
code?: number;
|
|
5
|
+
message?: string;
|
|
6
|
+
status?: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minimal representation of Gemini API responses we touch.
|
|
12
|
+
*/
|
|
13
|
+
export interface GeminiApiBody {
|
|
14
|
+
response?: unknown;
|
|
15
|
+
error?: GeminiApiError;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Usage metadata exposed by Gemini responses. Fields are optional to reflect partial payloads.
|
|
21
|
+
*/
|
|
22
|
+
export interface GeminiUsageMetadata {
|
|
23
|
+
totalTokenCount?: number;
|
|
24
|
+
promptTokenCount?: number;
|
|
25
|
+
candidatesTokenCount?: number;
|
|
26
|
+
cachedContentTokenCount?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalized thinking configuration accepted by Gemini.
|
|
31
|
+
*/
|
|
32
|
+
export interface ThinkingConfig {
|
|
33
|
+
thinkingBudget?: number;
|
|
34
|
+
includeThoughts?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined {
|
|
41
|
+
if (!config || typeof config !== "object") {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const record = config as Record<string, unknown>;
|
|
46
|
+
const budgetRaw = record.thinkingBudget ?? record.thinking_budget;
|
|
47
|
+
const includeRaw = record.includeThoughts ?? record.include_thoughts;
|
|
48
|
+
|
|
49
|
+
const thinkingBudget = typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined;
|
|
50
|
+
const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined;
|
|
51
|
+
|
|
52
|
+
const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0;
|
|
53
|
+
const finalInclude = enableThinking ? includeThoughts ?? false : false;
|
|
54
|
+
|
|
55
|
+
if (!enableThinking && finalInclude === false && thinkingBudget === undefined && includeThoughts === undefined) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const normalized: ThinkingConfig = {};
|
|
60
|
+
if (thinkingBudget !== undefined) {
|
|
61
|
+
normalized.thinkingBudget = thinkingBudget;
|
|
62
|
+
}
|
|
63
|
+
if (finalInclude !== undefined) {
|
|
64
|
+
normalized.includeThoughts = finalInclude;
|
|
65
|
+
}
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parses a Gemini API body; handles array-wrapped responses the API sometimes returns.
|
|
71
|
+
*/
|
|
72
|
+
export function parseGeminiApiBody(rawText: string): GeminiApiBody | null {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(rawText);
|
|
75
|
+
if (Array.isArray(parsed)) {
|
|
76
|
+
const firstObject = parsed.find((item: unknown) => typeof item === "object" && item !== null);
|
|
77
|
+
if (firstObject && typeof firstObject === "object") {
|
|
78
|
+
return firstObject as GeminiApiBody;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (parsed && typeof parsed === "object") {
|
|
84
|
+
return parsed as GeminiApiBody;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extracts usageMetadata from a response object, guarding types.
|
|
95
|
+
*/
|
|
96
|
+
export function extractUsageMetadata(body: GeminiApiBody): GeminiUsageMetadata | null {
|
|
97
|
+
const usage = (body.response && typeof body.response === "object"
|
|
98
|
+
? (body.response as { usageMetadata?: unknown }).usageMetadata
|
|
99
|
+
: undefined) as GeminiUsageMetadata | undefined;
|
|
100
|
+
|
|
101
|
+
if (!usage || typeof usage !== "object") {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const asRecord = usage as Record<string, unknown>;
|
|
106
|
+
const toNumber = (value: unknown): number | undefined =>
|
|
107
|
+
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
totalTokenCount: toNumber(asRecord.totalTokenCount),
|
|
111
|
+
promptTokenCount: toNumber(asRecord.promptTokenCount),
|
|
112
|
+
candidatesTokenCount: toNumber(asRecord.candidatesTokenCount),
|
|
113
|
+
cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Walks SSE lines to find a usage-bearing response chunk.
|
|
119
|
+
*/
|
|
120
|
+
export function extractUsageFromSsePayload(payload: string): GeminiUsageMetadata | null {
|
|
121
|
+
const lines = payload.split("\n");
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
if (!line.startsWith("data:")) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const jsonText = line.slice(5).trim();
|
|
127
|
+
if (!jsonText) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(jsonText);
|
|
132
|
+
if (parsed && typeof parsed === "object") {
|
|
133
|
+
const usage = extractUsageMetadata({ response: (parsed as Record<string, unknown>).response });
|
|
134
|
+
if (usage) {
|
|
135
|
+
return usage;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Enhances 404 errors for Gemini 3 models with a direct preview-access message.
|
|
147
|
+
*/
|
|
148
|
+
export function rewriteGeminiPreviewAccessError(
|
|
149
|
+
body: GeminiApiBody,
|
|
150
|
+
status: number,
|
|
151
|
+
requestedModel?: string,
|
|
152
|
+
): GeminiApiBody | null {
|
|
153
|
+
if (!needsPreviewAccessOverride(status, body, requestedModel)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const error: GeminiApiError = body.error ?? {};
|
|
158
|
+
const trimmedMessage = typeof error.message === "string" ? error.message.trim() : "";
|
|
159
|
+
const messagePrefix = trimmedMessage.length > 0
|
|
160
|
+
? trimmedMessage
|
|
161
|
+
: "Gemini 3 preview features are not enabled for this account.";
|
|
162
|
+
const enhancedMessage = `${messagePrefix} Request preview access at ${GEMINI_PREVIEW_LINK} before using Gemini 3 models.`;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
...body,
|
|
166
|
+
error: {
|
|
167
|
+
...error,
|
|
168
|
+
message: enhancedMessage,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function needsPreviewAccessOverride(
|
|
174
|
+
status: number,
|
|
175
|
+
body: GeminiApiBody,
|
|
176
|
+
requestedModel?: string,
|
|
177
|
+
): boolean {
|
|
178
|
+
if (status !== 404) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (isGeminiThreeModel(requestedModel)) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const errorMessage = typeof body.error?.message === "string" ? body.error.message : "";
|
|
187
|
+
return isGeminiThreeModel(errorMessage);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isGeminiThreeModel(target?: string): boolean {
|
|
191
|
+
if (!target) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return /gemini[\s-]?3/i.test(target);
|
|
196
|
+
}
|
package/src/plugin/request.ts
CHANGED
|
@@ -1,32 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CODE_ASSIST_HEADERS,
|
|
3
|
-
GEMINI_CODE_ASSIST_ENDPOINT,
|
|
4
|
-
} from "../constants";
|
|
1
|
+
import { CODE_ASSIST_HEADERS, GEMINI_CODE_ASSIST_ENDPOINT } from "../constants";
|
|
5
2
|
import { logGeminiDebugResponse, type GeminiDebugContext } from "./debug";
|
|
3
|
+
import {
|
|
4
|
+
extractUsageFromSsePayload,
|
|
5
|
+
extractUsageMetadata,
|
|
6
|
+
normalizeThinkingConfig,
|
|
7
|
+
parseGeminiApiBody,
|
|
8
|
+
rewriteGeminiPreviewAccessError,
|
|
9
|
+
type GeminiApiBody,
|
|
10
|
+
type GeminiUsageMetadata,
|
|
11
|
+
} from "./request-helpers";
|
|
6
12
|
|
|
7
13
|
const STREAM_ACTION = "streamGenerateContent";
|
|
8
14
|
const MODEL_FALLBACKS: Record<string, string> = {
|
|
9
15
|
"gemini-2.5-flash-image": "gemini-2.5-flash",
|
|
10
16
|
};
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
status?: string;
|
|
17
|
-
[key: string]: unknown;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface GeminiApiBody {
|
|
21
|
-
response?: unknown;
|
|
22
|
-
error?: GeminiApiError;
|
|
23
|
-
[key: string]: unknown;
|
|
24
|
-
}
|
|
25
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Detects Gemini/Generative Language API requests by URL.
|
|
19
|
+
* @param input Request target passed to fetch.
|
|
20
|
+
* @returns True when the URL targets generativelanguage.googleapis.com.
|
|
21
|
+
*/
|
|
26
22
|
export function isGenerativeLanguageRequest(input: RequestInfo): input is string {
|
|
27
23
|
return typeof input === "string" && input.includes("generativelanguage.googleapis.com");
|
|
28
24
|
}
|
|
29
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Rewrites SSE payloads so downstream consumers see only the inner `response` objects.
|
|
28
|
+
*/
|
|
30
29
|
function transformStreamingPayload(payload: string): string {
|
|
31
30
|
return payload
|
|
32
31
|
.split("\n")
|
|
@@ -49,6 +48,10 @@ function transformStreamingPayload(payload: string): string {
|
|
|
49
48
|
.join("\n");
|
|
50
49
|
}
|
|
51
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Rewrites OpenAI-style requests into Gemini Code Assist shape, normalizing model, headers,
|
|
53
|
+
* optional cached_content, and thinking config. Also toggles streaming mode for SSE actions.
|
|
54
|
+
*/
|
|
52
55
|
export function prepareGeminiRequest(
|
|
53
56
|
input: RequestInfo,
|
|
54
57
|
init: RequestInit | undefined,
|
|
@@ -100,11 +103,48 @@ export function prepareGeminiRequest(
|
|
|
100
103
|
} else {
|
|
101
104
|
const requestPayload: Record<string, unknown> = { ...parsedBody };
|
|
102
105
|
|
|
106
|
+
const rawGenerationConfig = requestPayload.generationConfig as Record<string, unknown> | undefined;
|
|
107
|
+
const normalizedThinking = normalizeThinkingConfig(rawGenerationConfig?.thinkingConfig);
|
|
108
|
+
if (normalizedThinking) {
|
|
109
|
+
if (rawGenerationConfig) {
|
|
110
|
+
rawGenerationConfig.thinkingConfig = normalizedThinking;
|
|
111
|
+
requestPayload.generationConfig = rawGenerationConfig;
|
|
112
|
+
} else {
|
|
113
|
+
requestPayload.generationConfig = { thinkingConfig: normalizedThinking };
|
|
114
|
+
}
|
|
115
|
+
} else if (rawGenerationConfig?.thinkingConfig) {
|
|
116
|
+
delete rawGenerationConfig.thinkingConfig;
|
|
117
|
+
requestPayload.generationConfig = rawGenerationConfig;
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
if ("system_instruction" in requestPayload) {
|
|
104
121
|
requestPayload.systemInstruction = requestPayload.system_instruction;
|
|
105
122
|
delete requestPayload.system_instruction;
|
|
106
123
|
}
|
|
107
124
|
|
|
125
|
+
const cachedContentFromExtra =
|
|
126
|
+
typeof requestPayload.extra_body === "object" && requestPayload.extra_body
|
|
127
|
+
? (requestPayload.extra_body as Record<string, unknown>).cached_content ??
|
|
128
|
+
(requestPayload.extra_body as Record<string, unknown>).cachedContent
|
|
129
|
+
: undefined;
|
|
130
|
+
const cachedContent =
|
|
131
|
+
(requestPayload.cached_content as string | undefined) ??
|
|
132
|
+
(requestPayload.cachedContent as string | undefined) ??
|
|
133
|
+
(cachedContentFromExtra as string | undefined);
|
|
134
|
+
if (cachedContent) {
|
|
135
|
+
requestPayload.cachedContent = cachedContent;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
delete requestPayload.cached_content;
|
|
139
|
+
delete requestPayload.cachedContent;
|
|
140
|
+
if (requestPayload.extra_body && typeof requestPayload.extra_body === "object") {
|
|
141
|
+
delete (requestPayload.extra_body as Record<string, unknown>).cached_content;
|
|
142
|
+
delete (requestPayload.extra_body as Record<string, unknown>).cachedContent;
|
|
143
|
+
if (Object.keys(requestPayload.extra_body as Record<string, unknown>).length === 0) {
|
|
144
|
+
delete requestPayload.extra_body;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
108
148
|
if ("model" in requestPayload) {
|
|
109
149
|
delete requestPayload.model;
|
|
110
150
|
}
|
|
@@ -142,6 +182,10 @@ export function prepareGeminiRequest(
|
|
|
142
182
|
};
|
|
143
183
|
}
|
|
144
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Normalizes Gemini responses: applies retry headers, extracts cache usage into headers,
|
|
187
|
+
* rewrites preview errors, flattens streaming payloads, and logs debug metadata.
|
|
188
|
+
*/
|
|
145
189
|
export async function transformGeminiResponse(
|
|
146
190
|
response: Response,
|
|
147
191
|
streaming: boolean,
|
|
@@ -162,35 +206,77 @@ export async function transformGeminiResponse(
|
|
|
162
206
|
try {
|
|
163
207
|
const text = await response.text();
|
|
164
208
|
const headers = new Headers(response.headers);
|
|
209
|
+
|
|
210
|
+
if (!response.ok && text) {
|
|
211
|
+
try {
|
|
212
|
+
const errorBody = JSON.parse(text);
|
|
213
|
+
if (errorBody?.error?.details && Array.isArray(errorBody.error.details)) {
|
|
214
|
+
const retryInfo = errorBody.error.details.find(
|
|
215
|
+
(detail: any) => detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo'
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (retryInfo?.retryDelay) {
|
|
219
|
+
const match = retryInfo.retryDelay.match(/^([\d.]+)s$/);
|
|
220
|
+
if (match && match[1]) {
|
|
221
|
+
const retrySeconds = parseFloat(match[1]);
|
|
222
|
+
if (!isNaN(retrySeconds) && retrySeconds > 0) {
|
|
223
|
+
const retryAfterSec = Math.ceil(retrySeconds).toString();
|
|
224
|
+
const retryAfterMs = Math.ceil(retrySeconds * 1000).toString();
|
|
225
|
+
headers.set('Retry-After', retryAfterSec);
|
|
226
|
+
headers.set('retry-after-ms', retryAfterMs);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch (parseError) {
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
165
235
|
const init = {
|
|
166
236
|
status: response.status,
|
|
167
237
|
statusText: response.statusText,
|
|
168
238
|
headers,
|
|
169
239
|
};
|
|
170
240
|
|
|
241
|
+
const usageFromSse = streaming && isEventStreamResponse ? extractUsageFromSsePayload(text) : null;
|
|
242
|
+
const parsed: GeminiApiBody | null = !streaming || !isEventStreamResponse ? parseGeminiApiBody(text) : null;
|
|
243
|
+
const patched = parsed ? rewriteGeminiPreviewAccessError(parsed, response.status, requestedModel) : null;
|
|
244
|
+
const effectiveBody = patched ?? parsed ?? undefined;
|
|
245
|
+
|
|
246
|
+
const usage = usageFromSse ?? (effectiveBody ? extractUsageMetadata(effectiveBody) : null);
|
|
247
|
+
if (usage?.cachedContentTokenCount !== undefined) {
|
|
248
|
+
headers.set("x-gemini-cached-content-token-count", String(usage.cachedContentTokenCount));
|
|
249
|
+
if (usage.totalTokenCount !== undefined) {
|
|
250
|
+
headers.set("x-gemini-total-token-count", String(usage.totalTokenCount));
|
|
251
|
+
}
|
|
252
|
+
if (usage.promptTokenCount !== undefined) {
|
|
253
|
+
headers.set("x-gemini-prompt-token-count", String(usage.promptTokenCount));
|
|
254
|
+
}
|
|
255
|
+
if (usage.candidatesTokenCount !== undefined) {
|
|
256
|
+
headers.set("x-gemini-candidates-token-count", String(usage.candidatesTokenCount));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
171
260
|
logGeminiDebugResponse(debugContext, response, {
|
|
172
261
|
body: text,
|
|
173
262
|
note: streaming ? "Streaming SSE payload" : undefined,
|
|
263
|
+
headersOverride: headers,
|
|
174
264
|
});
|
|
175
265
|
|
|
176
266
|
if (streaming && response.ok && isEventStreamResponse) {
|
|
177
267
|
return new Response(transformStreamingPayload(text), init);
|
|
178
268
|
}
|
|
179
269
|
|
|
180
|
-
const parsed = parseGeminiApiBody(text);
|
|
181
270
|
if (!parsed) {
|
|
182
271
|
return new Response(text, init);
|
|
183
272
|
}
|
|
184
273
|
|
|
185
|
-
|
|
186
|
-
const effectiveBody = patched ?? parsed;
|
|
187
|
-
|
|
188
|
-
if (effectiveBody.response !== undefined) {
|
|
274
|
+
if (effectiveBody?.response !== undefined) {
|
|
189
275
|
return new Response(JSON.stringify(effectiveBody.response), init);
|
|
190
276
|
}
|
|
191
277
|
|
|
192
278
|
if (patched) {
|
|
193
|
-
return new Response(JSON.stringify(
|
|
279
|
+
return new Response(JSON.stringify(patched), init);
|
|
194
280
|
}
|
|
195
281
|
|
|
196
282
|
return new Response(text, init);
|
|
@@ -203,76 +289,3 @@ export async function transformGeminiResponse(
|
|
|
203
289
|
return response;
|
|
204
290
|
}
|
|
205
291
|
}
|
|
206
|
-
|
|
207
|
-
function rewriteGeminiPreviewAccessError(
|
|
208
|
-
body: GeminiApiBody,
|
|
209
|
-
status: number,
|
|
210
|
-
requestedModel?: string,
|
|
211
|
-
): GeminiApiBody | null {
|
|
212
|
-
if (!needsPreviewAccessOverride(status, body, requestedModel)) {
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const error: GeminiApiError = body.error ?? {};
|
|
217
|
-
const trimmedMessage = typeof error.message === "string" ? error.message.trim() : "";
|
|
218
|
-
const messagePrefix = trimmedMessage.length > 0
|
|
219
|
-
? trimmedMessage
|
|
220
|
-
: "Gemini 3 preview features are not enabled for this account.";
|
|
221
|
-
const enhancedMessage = `${messagePrefix} Request preview access at ${GEMINI_PREVIEW_LINK} before using Gemini 3 models.`;
|
|
222
|
-
|
|
223
|
-
return {
|
|
224
|
-
...body,
|
|
225
|
-
error: {
|
|
226
|
-
...error,
|
|
227
|
-
message: enhancedMessage,
|
|
228
|
-
},
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function needsPreviewAccessOverride(
|
|
233
|
-
status: number,
|
|
234
|
-
body: GeminiApiBody,
|
|
235
|
-
requestedModel?: string,
|
|
236
|
-
): boolean {
|
|
237
|
-
if (status !== 404) {
|
|
238
|
-
return false;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (isGeminiThreeModel(requestedModel)) {
|
|
242
|
-
return true;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const errorMessage = typeof body.error?.message === "string" ? body.error.message : "";
|
|
246
|
-
return isGeminiThreeModel(errorMessage);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function isGeminiThreeModel(target?: string): boolean {
|
|
250
|
-
if (!target) {
|
|
251
|
-
return false;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return /gemini[\s-]?3/i.test(target);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function parseGeminiApiBody(rawText: string): GeminiApiBody | null {
|
|
258
|
-
try {
|
|
259
|
-
const parsed = JSON.parse(rawText);
|
|
260
|
-
if (Array.isArray(parsed)) {
|
|
261
|
-
const firstObject = parsed.find(function (item: unknown) {
|
|
262
|
-
return typeof item === "object" && item !== null;
|
|
263
|
-
});
|
|
264
|
-
if (firstObject && typeof firstObject === "object") {
|
|
265
|
-
return firstObject as GeminiApiBody;
|
|
266
|
-
}
|
|
267
|
-
return null;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (parsed && typeof parsed === "object") {
|
|
271
|
-
return parsed as GeminiApiBody;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return null;
|
|
275
|
-
} catch {
|
|
276
|
-
return null;
|
|
277
|
-
}
|
|
278
|
-
}
|
package/src/plugin/server.ts
CHANGED
|
@@ -24,8 +24,8 @@ const redirectUri = new URL(GEMINI_REDIRECT_URI);
|
|
|
24
24
|
const callbackPath = redirectUri.pathname || "/";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Starts a lightweight HTTP server that listens for the Gemini OAuth redirect
|
|
28
|
+
* and resolves with the captured callback URL.
|
|
29
29
|
*/
|
|
30
30
|
export async function startOAuthListener(
|
|
31
31
|
{ timeoutMs = 5 * 60 * 1000 }: OAuthListenerOptions = {},
|
|
@@ -206,7 +206,6 @@ const successResponse = `<!DOCTYPE html>
|
|
|
206
206
|
|
|
207
207
|
resolveCallback(url);
|
|
208
208
|
|
|
209
|
-
// Close the server after handling the first valid callback.
|
|
210
209
|
setImmediate(() => {
|
|
211
210
|
server.close();
|
|
212
211
|
});
|
package/src/plugin/token.ts
CHANGED
|
@@ -19,6 +19,9 @@ interface OAuthErrorPayload {
|
|
|
19
19
|
error_description?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Parses OAuth error payloads returned by Google token endpoints, tolerating varied shapes.
|
|
24
|
+
*/
|
|
22
25
|
function parseOAuthErrorPayload(text: string | undefined): { code?: string; description?: string } {
|
|
23
26
|
if (!text) {
|
|
24
27
|
return {};
|
|
@@ -55,6 +58,9 @@ function parseOAuthErrorPayload(text: string | undefined): { code?: string; desc
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Refreshes a Gemini OAuth access token, updates persisted credentials, and handles revocation.
|
|
63
|
+
*/
|
|
58
64
|
export async function refreshAccessToken(
|
|
59
65
|
auth: OAuthAuthDetails,
|
|
60
66
|
client: PluginClient,
|
|
@@ -83,7 +89,7 @@ export async function refreshAccessToken(
|
|
|
83
89
|
try {
|
|
84
90
|
errorText = await response.text();
|
|
85
91
|
} catch {
|
|
86
|
-
|
|
92
|
+
errorText = undefined;
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
const { code, description } = parseOAuthErrorPayload(errorText);
|
package/src/plugin.ts
CHANGED
|
@@ -21,6 +21,10 @@ import type {
|
|
|
21
21
|
Provider,
|
|
22
22
|
} from "./plugin/types";
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Registers the Gemini OAuth provider for Opencode, handling auth, request rewriting,
|
|
26
|
+
* debug logging, and response normalization for Gemini Code Assist endpoints.
|
|
27
|
+
*/
|
|
24
28
|
export const GeminiCLIOAuthPlugin = async (
|
|
25
29
|
{ client }: PluginContext,
|
|
26
30
|
): Promise<PluginResult> => ({
|
|
@@ -66,6 +70,9 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
66
70
|
return fetch(input, init);
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Ensures we have a usable project context for the current auth snapshot.
|
|
75
|
+
*/
|
|
69
76
|
async function resolveProjectContext(): Promise<ProjectContextResult> {
|
|
70
77
|
try {
|
|
71
78
|
return await ensureProjectContext(authRecord, client);
|
|
@@ -115,29 +122,46 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
115
122
|
authorize: async () => {
|
|
116
123
|
console.log("\n=== Google Gemini OAuth Setup ===");
|
|
117
124
|
|
|
125
|
+
const isHeadless = !!(
|
|
126
|
+
process.env.SSH_CONNECTION ||
|
|
127
|
+
process.env.SSH_CLIENT ||
|
|
128
|
+
process.env.SSH_TTY ||
|
|
129
|
+
process.env.OPENCODE_HEADLESS
|
|
130
|
+
);
|
|
131
|
+
|
|
118
132
|
let listener: OAuthListener | null = null;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
133
|
+
if (!isHeadless) {
|
|
134
|
+
try {
|
|
135
|
+
listener = await startOAuthListener();
|
|
136
|
+
const { host } = new URL(GEMINI_REDIRECT_URI);
|
|
137
|
+
console.log("1. You'll be asked to sign in to your Google account and grant permission.");
|
|
138
|
+
console.log(
|
|
139
|
+
`2. We'll automatically capture the browser redirect on http://${host}. No need to paste anything back here.`,
|
|
140
|
+
);
|
|
141
|
+
console.log("3. Once you see the 'Authentication complete' page in your browser, return to this terminal.");
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.log("1. You'll be asked to sign in to your Google account and grant permission.");
|
|
144
|
+
console.log("2. After you approve, the browser will try to redirect to a 'localhost' page.");
|
|
145
|
+
console.log(
|
|
146
|
+
"3. This page will show an error like 'This site can't be reached'. This is perfectly normal and means it worked!",
|
|
147
|
+
);
|
|
148
|
+
console.log(
|
|
149
|
+
"4. Once you see that error, copy the entire URL from the address bar, paste it back here, and press Enter.",
|
|
150
|
+
);
|
|
151
|
+
if (error instanceof Error) {
|
|
152
|
+
console.log(`\nWarning: Couldn't start the local callback listener (${error.message}). Falling back to manual copy/paste.`);
|
|
153
|
+
} else {
|
|
154
|
+
console.log("\nWarning: Couldn't start the local callback listener. Falling back to manual copy/paste.");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
console.log("Headless environment detected. Using manual OAuth flow.");
|
|
128
159
|
console.log("1. You'll be asked to sign in to your Google account and grant permission.");
|
|
129
|
-
console.log("2. After you approve, the browser will
|
|
160
|
+
console.log("2. After you approve, the browser will redirect to a 'localhost' URL.");
|
|
130
161
|
console.log(
|
|
131
|
-
"3.
|
|
162
|
+
"3. Copy the ENTIRE URL from your browser's address bar (it will look like: http://localhost:8085/oauth2callback?code=...&state=...)",
|
|
132
163
|
);
|
|
133
|
-
console.log(
|
|
134
|
-
"4. Once you see that error, copy the entire URL from the address bar, paste it back here, and press Enter.",
|
|
135
|
-
);
|
|
136
|
-
if (error instanceof Error) {
|
|
137
|
-
console.log(`\nWarning: Couldn't start the local callback listener (${error.message}). Falling back to manual copy/paste.`);
|
|
138
|
-
} else {
|
|
139
|
-
console.log("\nWarning: Couldn't start the local callback listener. Falling back to manual copy/paste.");
|
|
140
|
-
}
|
|
164
|
+
console.log("4. Paste the URL back here and press Enter.");
|
|
141
165
|
}
|
|
142
166
|
console.log("\n");
|
|
143
167
|
|
|
@@ -173,7 +197,6 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
173
197
|
try {
|
|
174
198
|
await listener?.close();
|
|
175
199
|
} catch {
|
|
176
|
-
// Ignore close errors.
|
|
177
200
|
}
|
|
178
201
|
}
|
|
179
202
|
},
|