opencode-gemini-auth 1.0.9 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,3 +38,42 @@ echo "Plugin update script finished successfully.")
38
38
  ```bash
39
39
  opencode # Reinstalls latest
40
40
  ```
41
+
42
+ ## Local Development
43
+
44
+ When you want Opencode to use a local checkout of this plugin, point the
45
+ `plugin` entry in your config to the folder via a `file://` URL:
46
+
47
+ ```json
48
+ {
49
+ "$schema": "https://opencode.ai/config.json",
50
+ "plugin": ["file:///absolute/path/to/opencode-gemini-auth"]
51
+ }
52
+ ```
53
+
54
+ Replace `/absolute/path/to/opencode-gemini-auth` with the absolute path to
55
+ your local clone.
56
+
57
+ ## Debugging Gemini Requests
58
+
59
+ Set `OPENCODE_GEMINI_DEBUG=1` in the environment when you run an Opencode
60
+ command to capture every Gemini request/response that this plugin issues. When
61
+ enabled, the plugin writes to a timestamped `gemini-debug-<ISO>.log` file in
62
+ your current working directory so the CLI output stays clean.
63
+
64
+ ```bash
65
+ OPENCODE_GEMINI_DEBUG=1 opencode
66
+ ```
67
+
68
+ The logger shows the transformed URL, HTTP method, sanitized headers (the
69
+ `Authorization` header is redacted), whether the call used streaming, and a
70
+ truncated preview (2 KB) of both the request and response bodies. This is handy
71
+ when diagnosing "Bad Request" responses from Gemini. Remember that payloads may
72
+ still include parts of your prompt or response, so only enable this flag when
73
+ you're comfortable keeping that information in the generated log file.
74
+
75
+ **404s on `gemini-2.5-flash-image`.** Opencode fires internal
76
+ summarization/title requests at `gemini-2.5-flash-image`. The plugin
77
+ automatically remaps those payloads to `gemini-2.5-flash`, eliminating the extra
78
+ 404s for accounts without image access. If you still see a 404, confirm your
79
+ project actually has access to the fallback model.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-gemini-auth",
3
3
  "module": "index.ts",
4
- "version": "1.0.9",
4
+ "version": "1.0.10",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -0,0 +1,168 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { cwd, env } from "node:process";
4
+
5
+ const DEBUG_FLAG = env.OPENCODE_GEMINI_DEBUG ?? "";
6
+ const MAX_BODY_PREVIEW_CHARS = 2000;
7
+ const debugEnabled = DEBUG_FLAG.trim() === "1";
8
+ const logFilePath = debugEnabled ? defaultLogFilePath() : undefined;
9
+ const logWriter = createLogWriter(logFilePath);
10
+
11
+ export interface GeminiDebugContext {
12
+ id: string;
13
+ streaming: boolean;
14
+ startedAt: number;
15
+ }
16
+
17
+ interface GeminiDebugRequestMeta {
18
+ originalUrl: string;
19
+ resolvedUrl: string;
20
+ method?: string;
21
+ headers?: HeadersInit;
22
+ body?: BodyInit | null;
23
+ streaming: boolean;
24
+ projectId?: string;
25
+ }
26
+
27
+ interface GeminiDebugResponseMeta {
28
+ body?: string;
29
+ note?: string;
30
+ error?: unknown;
31
+ }
32
+
33
+ let requestCounter = 0;
34
+
35
+ export function startGeminiDebugRequest(meta: GeminiDebugRequestMeta): GeminiDebugContext | null {
36
+ if (!debugEnabled) {
37
+ return null;
38
+ }
39
+
40
+ const id = `GEMINI-${++requestCounter}`;
41
+ const method = meta.method ?? "GET";
42
+ logDebug(`[Gemini Debug ${id}] ${method} ${meta.resolvedUrl}`);
43
+ if (meta.originalUrl && meta.originalUrl !== meta.resolvedUrl) {
44
+ logDebug(`[Gemini Debug ${id}] Original URL: ${meta.originalUrl}`);
45
+ }
46
+ if (meta.projectId) {
47
+ logDebug(`[Gemini Debug ${id}] Project: ${meta.projectId}`);
48
+ }
49
+ logDebug(`[Gemini Debug ${id}] Streaming: ${meta.streaming ? "yes" : "no"}`);
50
+ logDebug(`[Gemini Debug ${id}] Headers: ${JSON.stringify(maskHeaders(meta.headers))}`);
51
+ const bodyPreview = formatBodyPreview(meta.body);
52
+ if (bodyPreview) {
53
+ logDebug(`[Gemini Debug ${id}] Body Preview: ${bodyPreview}`);
54
+ }
55
+
56
+ return { id, streaming: meta.streaming, startedAt: Date.now() };
57
+ }
58
+
59
+ export function logGeminiDebugResponse(
60
+ context: GeminiDebugContext | null | undefined,
61
+ response: Response,
62
+ meta: GeminiDebugResponseMeta = {},
63
+ ): void {
64
+ if (!debugEnabled || !context) {
65
+ return;
66
+ }
67
+
68
+ const durationMs = Date.now() - context.startedAt;
69
+ logDebug(
70
+ `[Gemini Debug ${context.id}] Response ${response.status} ${response.statusText} (${durationMs}ms)`,
71
+ );
72
+ logDebug(
73
+ `[Gemini Debug ${context.id}] Response Headers: ${JSON.stringify(maskHeaders(response.headers))}`,
74
+ );
75
+
76
+ if (meta.note) {
77
+ logDebug(`[Gemini Debug ${context.id}] Note: ${meta.note}`);
78
+ }
79
+
80
+ if (meta.error) {
81
+ logDebug(`[Gemini Debug ${context.id}] Error: ${formatError(meta.error)}`);
82
+ }
83
+
84
+ if (meta.body) {
85
+ logDebug(
86
+ `[Gemini Debug ${context.id}] Response Body Preview: ${truncateForLog(meta.body)}`,
87
+ );
88
+ }
89
+ }
90
+
91
+ function maskHeaders(headers?: HeadersInit | Headers): Record<string, string> {
92
+ if (!headers) {
93
+ return {};
94
+ }
95
+
96
+ const result: Record<string, string> = {};
97
+ const parsed = headers instanceof Headers ? headers : new Headers(headers);
98
+ parsed.forEach((value, key) => {
99
+ if (key.toLowerCase() === "authorization") {
100
+ result[key] = "[redacted]";
101
+ } else {
102
+ result[key] = value;
103
+ }
104
+ });
105
+ return result;
106
+ }
107
+
108
+ function formatBodyPreview(body?: BodyInit | null): string | undefined {
109
+ if (body == null) {
110
+ return undefined;
111
+ }
112
+
113
+ if (typeof body === "string") {
114
+ return truncateForLog(body);
115
+ }
116
+
117
+ if (body instanceof URLSearchParams) {
118
+ return truncateForLog(body.toString());
119
+ }
120
+
121
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
122
+ return `[Blob size=${body.size}]`;
123
+ }
124
+
125
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
126
+ return "[FormData payload omitted]";
127
+ }
128
+
129
+ return `[${body.constructor?.name ?? typeof body} payload omitted]`;
130
+ }
131
+
132
+ function truncateForLog(text: string): string {
133
+ if (text.length <= MAX_BODY_PREVIEW_CHARS) {
134
+ return text;
135
+ }
136
+ return `${text.slice(0, MAX_BODY_PREVIEW_CHARS)}... (truncated ${text.length - MAX_BODY_PREVIEW_CHARS} chars)`;
137
+ }
138
+
139
+ function logDebug(line: string): void {
140
+ logWriter(line);
141
+ }
142
+
143
+ function formatError(error: unknown): string {
144
+ if (error instanceof Error) {
145
+ return error.stack ?? error.message;
146
+ }
147
+ try {
148
+ return JSON.stringify(error);
149
+ } catch {
150
+ return String(error);
151
+ }
152
+ }
153
+
154
+ function defaultLogFilePath(): string {
155
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
156
+ return join(cwd(), `gemini-debug-${timestamp}.log`);
157
+ }
158
+
159
+ function createLogWriter(filePath?: string): (line: string) => void {
160
+ if (!filePath) {
161
+ return () => {};
162
+ }
163
+
164
+ const stream = createWriteStream(filePath, { flags: "a" });
165
+ return (line: string) => {
166
+ stream.write(`${line}\n`);
167
+ };
168
+ }
@@ -2,8 +2,12 @@ import {
2
2
  CODE_ASSIST_HEADERS,
3
3
  GEMINI_CODE_ASSIST_ENDPOINT,
4
4
  } from "../constants";
5
+ import { logGeminiDebugResponse, type GeminiDebugContext } from "./debug";
5
6
 
6
7
  const STREAM_ACTION = "streamGenerateContent";
8
+ const MODEL_FALLBACKS: Record<string, string> = {
9
+ "gemini-2.5-flash-image": "gemini-2.5-flash",
10
+ };
7
11
 
8
12
  export function isGenerativeLanguageRequest(input: RequestInfo): input is string {
9
13
  return typeof input === "string" && input.includes("generativelanguage.googleapis.com");
@@ -60,23 +64,26 @@ export function prepareGeminiRequest(
60
64
  };
61
65
  }
62
66
 
63
- const [, model, action] = match;
64
- const streaming = action === STREAM_ACTION;
65
- const transformedUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:${action}${
67
+ const [, rawModel = "", rawAction = ""] = match;
68
+ const effectiveModel = MODEL_FALLBACKS[rawModel] ?? rawModel;
69
+ const streaming = rawAction === STREAM_ACTION;
70
+ const transformedUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:${rawAction}${
66
71
  streaming ? "?alt=sse" : ""
67
72
  }`;
68
73
 
69
74
  let body = baseInit.body;
70
75
  if (typeof baseInit.body === "string" && baseInit.body) {
71
- const trimmedPayload = baseInit.body.trim();
72
- const looksCodeAssistPayload =
73
- trimmedPayload.startsWith("{") &&
74
- trimmedPayload.includes('"project"') &&
75
- trimmedPayload.includes('"request"');
76
+ try {
77
+ const parsedBody = JSON.parse(baseInit.body) as Record<string, unknown>;
78
+ const isWrapped = typeof parsedBody.project === "string" && "request" in parsedBody;
76
79
 
77
- if (!looksCodeAssistPayload) {
78
- try {
79
- const parsedBody = JSON.parse(baseInit.body) as Record<string, unknown>;
80
+ if (isWrapped) {
81
+ const wrappedBody = {
82
+ ...parsedBody,
83
+ model: effectiveModel,
84
+ } as Record<string, unknown>;
85
+ body = JSON.stringify(wrappedBody);
86
+ } else {
80
87
  const requestPayload: Record<string, unknown> = { ...parsedBody };
81
88
 
82
89
  if ("system_instruction" in requestPayload) {
@@ -90,14 +97,14 @@ export function prepareGeminiRequest(
90
97
 
91
98
  const wrappedBody = {
92
99
  project: projectId,
93
- model,
100
+ model: effectiveModel,
94
101
  request: requestPayload,
95
102
  };
96
103
 
97
104
  body = JSON.stringify(wrappedBody);
98
- } catch (error) {
99
- console.error("Failed to transform Gemini request body:", error);
100
105
  }
106
+ } catch (error) {
107
+ console.error("Failed to transform Gemini request body:", error);
101
108
  }
102
109
  }
103
110
 
@@ -123,9 +130,13 @@ export function prepareGeminiRequest(
123
130
  export async function transformGeminiResponse(
124
131
  response: Response,
125
132
  streaming: boolean,
133
+ debugContext?: GeminiDebugContext | null,
126
134
  ): Promise<Response> {
127
135
  const contentType = response.headers.get("content-type") ?? "";
128
136
  if (!streaming && !contentType.includes("application/json")) {
137
+ logGeminiDebugResponse(debugContext, response, {
138
+ note: "Non-JSON response (body omitted)",
139
+ });
129
140
  return response;
130
141
  }
131
142
 
@@ -138,6 +149,11 @@ export async function transformGeminiResponse(
138
149
  headers,
139
150
  };
140
151
 
152
+ logGeminiDebugResponse(debugContext, response, {
153
+ body: text,
154
+ note: streaming ? "Streaming SSE payload" : undefined,
155
+ });
156
+
141
157
  if (streaming) {
142
158
  return new Response(transformStreamingPayload(text), init);
143
159
  }
@@ -149,6 +165,10 @@ export async function transformGeminiResponse(
149
165
 
150
166
  return new Response(text, init);
151
167
  } catch (error) {
168
+ logGeminiDebugResponse(debugContext, response, {
169
+ error,
170
+ note: "Failed to transform Gemini response",
171
+ });
152
172
  console.error("Failed to transform Gemini response:", error);
153
173
  return response;
154
174
  }
package/src/plugin.ts CHANGED
@@ -4,6 +4,7 @@ import type { GeminiTokenExchangeResult } from "./gemini/oauth";
4
4
  import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
5
5
  import { promptProjectId } from "./plugin/cli";
6
6
  import { ensureProjectContext } from "./plugin/project";
7
+ import { startGeminiDebugRequest } from "./plugin/debug";
7
8
  import {
8
9
  isGenerativeLanguageRequest,
9
10
  prepareGeminiRequest,
@@ -73,8 +74,20 @@ export const GeminiCLIOAuthPlugin = async (
73
74
  projectContext.effectiveProjectId,
74
75
  );
75
76
 
77
+ const originalUrl = toUrlString(input);
78
+ const resolvedUrl = toUrlString(request);
79
+ const debugContext = startGeminiDebugRequest({
80
+ originalUrl,
81
+ resolvedUrl,
82
+ method: transformedInit.method,
83
+ headers: transformedInit.headers,
84
+ body: transformedInit.body,
85
+ streaming,
86
+ projectId: projectContext.effectiveProjectId,
87
+ });
88
+
76
89
  const response = await fetch(request, transformedInit);
77
- return transformGeminiResponse(response, streaming);
90
+ return transformGeminiResponse(response, streaming, debugContext);
78
91
  },
79
92
  };
80
93
  },
@@ -189,3 +202,14 @@ export const GeminiCLIOAuthPlugin = async (
189
202
  });
190
203
 
191
204
  export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
205
+
206
+ function toUrlString(value: RequestInfo): string {
207
+ if (typeof value === "string") {
208
+ return value;
209
+ }
210
+ const candidate = (value as Request).url;
211
+ if (candidate) {
212
+ return candidate;
213
+ }
214
+ return value.toString();
215
+ }