opencode-gemini-auth 1.1.5 → 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 +85 -104
- package/src/plugin/server.ts +2 -3
- package/src/plugin/token.ts +7 -1
- package/src/plugin.ts +7 -2
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,
|
|
@@ -163,24 +207,19 @@ export async function transformGeminiResponse(
|
|
|
163
207
|
const text = await response.text();
|
|
164
208
|
const headers = new Headers(response.headers);
|
|
165
209
|
|
|
166
|
-
// Extract retry timing from Google's structured error response
|
|
167
|
-
// Google returns retry timing in error.details[].retryDelay: "55.846891726s"
|
|
168
210
|
if (!response.ok && text) {
|
|
169
211
|
try {
|
|
170
212
|
const errorBody = JSON.parse(text);
|
|
171
213
|
if (errorBody?.error?.details && Array.isArray(errorBody.error.details)) {
|
|
172
|
-
// Look for RetryInfo type
|
|
173
214
|
const retryInfo = errorBody.error.details.find(
|
|
174
215
|
(detail: any) => detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo'
|
|
175
216
|
);
|
|
176
217
|
|
|
177
218
|
if (retryInfo?.retryDelay) {
|
|
178
|
-
// Parse "55.846891726s" format
|
|
179
219
|
const match = retryInfo.retryDelay.match(/^([\d.]+)s$/);
|
|
180
220
|
if (match && match[1]) {
|
|
181
221
|
const retrySeconds = parseFloat(match[1]);
|
|
182
222
|
if (!isNaN(retrySeconds) && retrySeconds > 0) {
|
|
183
|
-
// Add both formats for compatibility
|
|
184
223
|
const retryAfterSec = Math.ceil(retrySeconds).toString();
|
|
185
224
|
const retryAfterMs = Math.ceil(retrySeconds * 1000).toString();
|
|
186
225
|
headers.set('Retry-After', retryAfterSec);
|
|
@@ -190,7 +229,6 @@ export async function transformGeminiResponse(
|
|
|
190
229
|
}
|
|
191
230
|
}
|
|
192
231
|
} catch (parseError) {
|
|
193
|
-
// If JSON parsing fails, continue without retry headers
|
|
194
232
|
}
|
|
195
233
|
}
|
|
196
234
|
|
|
@@ -200,29 +238,45 @@ export async function transformGeminiResponse(
|
|
|
200
238
|
headers,
|
|
201
239
|
};
|
|
202
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
|
+
|
|
203
260
|
logGeminiDebugResponse(debugContext, response, {
|
|
204
261
|
body: text,
|
|
205
262
|
note: streaming ? "Streaming SSE payload" : undefined,
|
|
263
|
+
headersOverride: headers,
|
|
206
264
|
});
|
|
207
265
|
|
|
208
266
|
if (streaming && response.ok && isEventStreamResponse) {
|
|
209
267
|
return new Response(transformStreamingPayload(text), init);
|
|
210
268
|
}
|
|
211
269
|
|
|
212
|
-
const parsed = parseGeminiApiBody(text);
|
|
213
270
|
if (!parsed) {
|
|
214
271
|
return new Response(text, init);
|
|
215
272
|
}
|
|
216
273
|
|
|
217
|
-
|
|
218
|
-
const effectiveBody = patched ?? parsed;
|
|
219
|
-
|
|
220
|
-
if (effectiveBody.response !== undefined) {
|
|
274
|
+
if (effectiveBody?.response !== undefined) {
|
|
221
275
|
return new Response(JSON.stringify(effectiveBody.response), init);
|
|
222
276
|
}
|
|
223
277
|
|
|
224
278
|
if (patched) {
|
|
225
|
-
return new Response(JSON.stringify(
|
|
279
|
+
return new Response(JSON.stringify(patched), init);
|
|
226
280
|
}
|
|
227
281
|
|
|
228
282
|
return new Response(text, init);
|
|
@@ -235,76 +289,3 @@ export async function transformGeminiResponse(
|
|
|
235
289
|
return response;
|
|
236
290
|
}
|
|
237
291
|
}
|
|
238
|
-
|
|
239
|
-
function rewriteGeminiPreviewAccessError(
|
|
240
|
-
body: GeminiApiBody,
|
|
241
|
-
status: number,
|
|
242
|
-
requestedModel?: string,
|
|
243
|
-
): GeminiApiBody | null {
|
|
244
|
-
if (!needsPreviewAccessOverride(status, body, requestedModel)) {
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const error: GeminiApiError = body.error ?? {};
|
|
249
|
-
const trimmedMessage = typeof error.message === "string" ? error.message.trim() : "";
|
|
250
|
-
const messagePrefix = trimmedMessage.length > 0
|
|
251
|
-
? trimmedMessage
|
|
252
|
-
: "Gemini 3 preview features are not enabled for this account.";
|
|
253
|
-
const enhancedMessage = `${messagePrefix} Request preview access at ${GEMINI_PREVIEW_LINK} before using Gemini 3 models.`;
|
|
254
|
-
|
|
255
|
-
return {
|
|
256
|
-
...body,
|
|
257
|
-
error: {
|
|
258
|
-
...error,
|
|
259
|
-
message: enhancedMessage,
|
|
260
|
-
},
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function needsPreviewAccessOverride(
|
|
265
|
-
status: number,
|
|
266
|
-
body: GeminiApiBody,
|
|
267
|
-
requestedModel?: string,
|
|
268
|
-
): boolean {
|
|
269
|
-
if (status !== 404) {
|
|
270
|
-
return false;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (isGeminiThreeModel(requestedModel)) {
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const errorMessage = typeof body.error?.message === "string" ? body.error.message : "";
|
|
278
|
-
return isGeminiThreeModel(errorMessage);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function isGeminiThreeModel(target?: string): boolean {
|
|
282
|
-
if (!target) {
|
|
283
|
-
return false;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
return /gemini[\s-]?3/i.test(target);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function parseGeminiApiBody(rawText: string): GeminiApiBody | null {
|
|
290
|
-
try {
|
|
291
|
-
const parsed = JSON.parse(rawText);
|
|
292
|
-
if (Array.isArray(parsed)) {
|
|
293
|
-
const firstObject = parsed.find(function (item: unknown) {
|
|
294
|
-
return typeof item === "object" && item !== null;
|
|
295
|
-
});
|
|
296
|
-
if (firstObject && typeof firstObject === "object") {
|
|
297
|
-
return firstObject as GeminiApiBody;
|
|
298
|
-
}
|
|
299
|
-
return null;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (parsed && typeof parsed === "object") {
|
|
303
|
-
return parsed as GeminiApiBody;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
return null;
|
|
307
|
-
} catch {
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
}
|
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,7 +122,6 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
115
122
|
authorize: async () => {
|
|
116
123
|
console.log("\n=== Google Gemini OAuth Setup ===");
|
|
117
124
|
|
|
118
|
-
// Detect headless/SSH environment
|
|
119
125
|
const isHeadless = !!(
|
|
120
126
|
process.env.SSH_CONNECTION ||
|
|
121
127
|
process.env.SSH_CLIENT ||
|
|
@@ -191,7 +197,6 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
191
197
|
try {
|
|
192
198
|
await listener?.close();
|
|
193
199
|
} catch {
|
|
194
|
-
// Ignore close errors.
|
|
195
200
|
}
|
|
196
201
|
}
|
|
197
202
|
},
|