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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-gemini-auth",
3
3
  "module": "index.ts",
4
- "version": "1.1.4",
4
+ "version": "1.1.6",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -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;
@@ -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 {
@@ -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(maskHeaders(response.headers))}`,
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 () => {};
@@ -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
+ }
@@ -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
- const GEMINI_PREVIEW_LINK = "https://goo.gle/enable-preview-features";
12
-
13
- interface GeminiApiError {
14
- code?: number;
15
- message?: string;
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
- const patched = rewriteGeminiPreviewAccessError(parsed, response.status, requestedModel);
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(effectiveBody), init);
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
- }
@@ -24,8 +24,8 @@ const redirectUri = new URL(GEMINI_REDIRECT_URI);
24
24
  const callbackPath = redirectUri.pathname || "/";
25
25
 
26
26
  /**
27
- * Start a lightweight HTTP server that listens for the Gemini OAuth redirect.
28
- * Returns a listener object that resolves with the callback once received.
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
  });
@@ -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
- // Ignore body parsing failures; we'll fall back to status only.
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
- try {
120
- listener = await startOAuthListener();
121
- const { host } = new URL(GEMINI_REDIRECT_URI);
122
- console.log("1. You'll be asked to sign in to your Google account and grant permission.");
123
- console.log(
124
- `2. We'll automatically capture the browser redirect on http://${host}. No need to paste anything back here.`,
125
- );
126
- console.log("3. Once you see the 'Authentication complete' page in your browser, return to this terminal.");
127
- } catch (error) {
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 try to redirect to a 'localhost' page.");
160
+ console.log("2. After you approve, the browser will redirect to a 'localhost' URL.");
130
161
  console.log(
131
- "3. This page will show an error like 'This site can’t be reached'. This is perfectly normal and means it worked!",
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
  },