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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-gemini-auth",
3
3
  "module": "index.ts",
4
- "version": "1.1.5",
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,
@@ -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
- const patched = rewriteGeminiPreviewAccessError(parsed, response.status, requestedModel);
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(effectiveBody), init);
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
- }
@@ -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,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
  },