opencode-gemini-auth 1.3.7 → 1.3.8

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
@@ -15,7 +15,7 @@ directly within Opencode, bypassing separate API billing.
15
15
  ## Installation
16
16
 
17
17
  Add the plugin to your Opencode configuration file
18
- (`~/.config/opencode/config.json` or similar):
18
+ (`~/.config/opencode/opencode.json` or similar):
19
19
 
20
20
  ```json
21
21
  {
@@ -60,19 +60,41 @@ project. To force a specific project, set the `projectId` in your configuration:
60
60
  }
61
61
  ```
62
62
 
63
- ### Thinking Models
64
-
65
- Configure "thinking" capabilities for Gemini models using the `thinkingConfig`
66
- option in your `config.json`.
63
+ ### Model list
67
64
 
68
- **Gemini 3 (Thinking Level)**
69
- Use `thinkingLevel` (`"low"`, `"high"`) for Gemini 3 models.
65
+ Below are example model entries you can add under `provider.google.models` in your
66
+ Opencode config. Each model can include an `options.thinkingConfig` block to
67
+ enable "thinking" features.
70
68
 
71
69
  ```json
72
70
  {
73
71
  "provider": {
74
72
  "google": {
75
73
  "models": {
74
+ "gemini-2.5-flash": {
75
+ "options": {
76
+ "thinkingConfig": {
77
+ "thinkingBudget": 8192,
78
+ "includeThoughts": true
79
+ }
80
+ }
81
+ },
82
+ "gemini-2.5-pro": {
83
+ "options": {
84
+ "thinkingConfig": {
85
+ "thinkingBudget": 8192,
86
+ "includeThoughts": true
87
+ }
88
+ }
89
+ },
90
+ "gemini-3-flash-preview": {
91
+ "options": {
92
+ "thinkingConfig": {
93
+ "thinkingLevel": "high",
94
+ "includeThoughts": true
95
+ }
96
+ }
97
+ },
76
98
  "gemini-3-pro-preview": {
77
99
  "options": {
78
100
  "thinkingConfig": {
@@ -87,14 +109,33 @@ Use `thinkingLevel` (`"low"`, `"high"`) for Gemini 3 models.
87
109
  }
88
110
  ```
89
111
 
90
- **Gemini 2.5 (Thinking Budget)**
91
- Use `thinkingBudget` (token count) for Gemini 2.5 models.
112
+ Note: Available model names and previews may change—check Google's documentation or
113
+ the Gemini product page for the current model identifiers.
114
+
115
+ ### Thinking Models
116
+
117
+ The plugin supports configuring Gemini "thinking" features per-model via
118
+ `thinkingConfig`. The available fields depend on the model family:
119
+
120
+ - For Gemini 3 models: use `thinkingLevel` with values `"low"` or `"high"`.
121
+ - For Gemini 2.5 models: use `thinkingBudget` (token count).
122
+ - `includeThoughts` (boolean) controls whether the model emits internal thoughts.
123
+
124
+ A combined example showing both model types:
92
125
 
93
126
  ```json
94
127
  {
95
128
  "provider": {
96
129
  "google": {
97
130
  "models": {
131
+ "gemini-3-pro-preview": {
132
+ "options": {
133
+ "thinkingConfig": {
134
+ "thinkingLevel": "high",
135
+ "includeThoughts": true
136
+ }
137
+ }
138
+ },
98
139
  "gemini-2.5-flash": {
99
140
  "options": {
100
141
  "thinkingConfig": {
@@ -109,6 +150,9 @@ Use `thinkingBudget` (token count) for Gemini 2.5 models.
109
150
  }
110
151
  ```
111
152
 
153
+ If you don't set a `thinkingConfig` for a model, the plugin will use default
154
+ behavior for that model.
155
+
112
156
  ## Troubleshooting
113
157
 
114
158
  ### Manual Google Cloud Setup
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-gemini-auth",
3
3
  "module": "index.ts",
4
- "version": "1.3.7",
4
+ "version": "1.3.8",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -11,7 +11,7 @@
11
11
  "license": "MIT",
12
12
  "type": "module",
13
13
  "devDependencies": {
14
- "@opencode-ai/plugin": "^1.0.203",
14
+ "@opencode-ai/plugin": "^1.1.25",
15
15
  "@types/bun": "latest"
16
16
  },
17
17
  "peerDependencies": {
@@ -90,6 +90,8 @@ export async function authorizeGemini(): Promise<GeminiAuthorization> {
90
90
  url.searchParams.set("state", encodeState({ verifier: pkce.verifier }));
91
91
  url.searchParams.set("access_type", "offline");
92
92
  url.searchParams.set("prompt", "consent");
93
+ // Add a fragment so any stray terminal glyphs are ignored by the auth server.
94
+ url.hash = "opencode";
93
95
 
94
96
  return {
95
97
  url: url.toString(),
@@ -13,6 +13,137 @@ const STREAM_ACTION = "streamGenerateContent";
13
13
  const MODEL_FALLBACKS: Record<string, string> = {
14
14
  "gemini-2.5-flash-image": "gemini-2.5-flash",
15
15
  };
16
+
17
+ interface GeminiFunctionCallPart {
18
+ functionCall?: {
19
+ name: string;
20
+ args?: Record<string, unknown>;
21
+ [key: string]: unknown;
22
+ };
23
+ thoughtSignature?: string;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface GeminiContentPart {
28
+ role?: string;
29
+ parts?: GeminiFunctionCallPart[];
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ interface OpenAIToolCall {
34
+ id?: string;
35
+ type?: string;
36
+ function?: {
37
+ name?: string;
38
+ arguments?: string;
39
+ [key: string]: unknown;
40
+ };
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ interface OpenAIMessage {
45
+ role?: string;
46
+ content?: string | null;
47
+ tool_calls?: OpenAIToolCall[];
48
+ tool_call_id?: string;
49
+ name?: string;
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ /**
54
+ * Transforms OpenAI tool_calls to Gemini functionCall format and adds thoughtSignature.
55
+ * This ensures compatibility when OpenCode sends OpenAI-format function calls.
56
+ */
57
+ function transformOpenAIToolCalls(requestPayload: Record<string, unknown>): void {
58
+ const messages = requestPayload.messages;
59
+ if (!messages || !Array.isArray(messages)) {
60
+ return;
61
+ }
62
+
63
+ for (const message of messages) {
64
+ if (message && typeof message === "object") {
65
+ const msgObj = message as OpenAIMessage;
66
+ const toolCalls = msgObj.tool_calls;
67
+ if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) {
68
+ const parts: GeminiFunctionCallPart[] = [];
69
+
70
+ if (typeof msgObj.content === "string" && msgObj.content.length > 0) {
71
+ parts.push({ text: msgObj.content });
72
+ }
73
+
74
+ for (const toolCall of toolCalls) {
75
+ if (toolCall && typeof toolCall === "object") {
76
+ const functionObj = toolCall.function;
77
+ if (functionObj && typeof functionObj === "object") {
78
+ const name = functionObj.name;
79
+ const argsStr = functionObj.arguments;
80
+ let args: Record<string, unknown> = {};
81
+ if (typeof argsStr === "string") {
82
+ try {
83
+ args = JSON.parse(argsStr) as Record<string, unknown>;
84
+ } catch {
85
+ args = {};
86
+ }
87
+ }
88
+
89
+ parts.push({
90
+ functionCall: {
91
+ name: name ?? "",
92
+ args,
93
+ },
94
+ thoughtSignature: "skip_thought_signature_validator",
95
+ });
96
+ }
97
+ }
98
+ }
99
+
100
+ msgObj.parts = parts;
101
+ delete msgObj.tool_calls;
102
+ delete msgObj.content;
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Adds thoughtSignature to function call parts in the request payload.
110
+ * Gemini 3+ models require thoughtSignature for function calls when using thinking capabilities.
111
+ * This must be applied to all content blocks in the conversation history.
112
+ * Handles both flat contents arrays and nested request.contents (wrapped bodies).
113
+ */
114
+ function addThoughtSignaturesToFunctionCalls(requestPayload: Record<string, unknown>): void {
115
+ const processContents = (contents: unknown): void => {
116
+ if (!contents || !Array.isArray(contents)) {
117
+ return;
118
+ }
119
+
120
+ for (const content of contents) {
121
+ if (content && typeof content === "object") {
122
+ const contentObj = content as Record<string, unknown>;
123
+ const parts = contentObj.parts;
124
+ if (parts && Array.isArray(parts)) {
125
+ for (const part of parts) {
126
+ if (part && typeof part === "object") {
127
+ const partObj = part as Record<string, unknown>;
128
+ if (partObj.functionCall && !partObj.thoughtSignature) {
129
+ partObj.thoughtSignature = "skip_thought_signature_validator";
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ };
137
+
138
+ processContents(requestPayload.contents);
139
+
140
+ const nestedRequest = requestPayload.request;
141
+ if (nestedRequest && typeof nestedRequest === "object") {
142
+ const requestObj = nestedRequest as Record<string, unknown>;
143
+ processContents(requestObj.contents);
144
+ }
145
+ }
146
+
16
147
  /**
17
148
  * Detects Gemini/Generative Language API requests by URL.
18
149
  * @param input Request target passed to fetch.
@@ -155,6 +286,9 @@ export function prepareGeminiRequest(
155
286
  } else {
156
287
  const requestPayload: Record<string, unknown> = { ...parsedBody };
157
288
 
289
+ transformOpenAIToolCalls(requestPayload);
290
+ addThoughtSignaturesToFunctionCalls(requestPayload);
291
+
158
292
  const rawGenerationConfig = requestPayload.generationConfig as Record<string, unknown> | undefined;
159
293
  const normalizedThinking = normalizeThinkingConfig(rawGenerationConfig?.thinkingConfig);
160
294
  if (normalizedThinking) {
package/src/plugin.ts CHANGED
@@ -126,7 +126,7 @@ export const GeminiCLIOAuthPlugin = async (
126
126
  projectId: projectContext.effectiveProjectId,
127
127
  });
128
128
 
129
- const response = await fetch(request, transformedInit);
129
+ const response = await fetchWithRetry(request, transformedInit);
130
130
  return transformGeminiResponse(response, streaming, debugContext, requestedModel);
131
131
  },
132
132
  };
@@ -246,6 +246,11 @@ export const GeminiCLIOAuthPlugin = async (
246
246
 
247
247
  export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
248
248
 
249
+ const RETRYABLE_STATUS_CODES = new Set([429, 503]);
250
+ const DEFAULT_MAX_RETRIES = 2;
251
+ const DEFAULT_BASE_DELAY_MS = 800;
252
+ const DEFAULT_MAX_DELAY_MS = 8000;
253
+
249
254
  function toUrlString(value: RequestInfo): string {
250
255
  if (typeof value === "string") {
251
256
  return value;
@@ -308,3 +313,198 @@ function openBrowserUrl(url: string): void {
308
313
  } catch {
309
314
  }
310
315
  }
316
+
317
+ async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined): Promise<Response> {
318
+ const maxRetries = DEFAULT_MAX_RETRIES;
319
+ const baseDelayMs = DEFAULT_BASE_DELAY_MS;
320
+ const maxDelayMs = DEFAULT_MAX_DELAY_MS;
321
+
322
+ if (!canRetryRequest(init)) {
323
+ return fetch(input, init);
324
+ }
325
+
326
+ let attempt = 0;
327
+ while (true) {
328
+ const response = await fetch(input, init);
329
+ if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt >= maxRetries) {
330
+ return response;
331
+ }
332
+
333
+ const delayMs = await getRetryDelayMs(response, attempt, baseDelayMs, maxDelayMs);
334
+ if (!delayMs || delayMs <= 0) {
335
+ return response;
336
+ }
337
+
338
+ if (init?.signal?.aborted) {
339
+ return response;
340
+ }
341
+
342
+ await wait(delayMs);
343
+ attempt += 1;
344
+ }
345
+ }
346
+
347
+ function canRetryRequest(init: RequestInit | undefined): boolean {
348
+ if (!init?.body) {
349
+ return true;
350
+ }
351
+
352
+ const body = init.body;
353
+ if (typeof body === "string") {
354
+ return true;
355
+ }
356
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
357
+ return true;
358
+ }
359
+ if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
360
+ return true;
361
+ }
362
+ if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body)) {
363
+ return true;
364
+ }
365
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
366
+ return true;
367
+ }
368
+
369
+ return false;
370
+ }
371
+
372
+ async function getRetryDelayMs(
373
+ response: Response,
374
+ attempt: number,
375
+ baseDelayMs: number,
376
+ maxDelayMs: number,
377
+ ): Promise<number | null> {
378
+ const headerDelayMs = parseRetryAfterMs(response.headers.get("retry-after-ms"));
379
+ if (headerDelayMs !== null) {
380
+ return clampDelay(headerDelayMs, maxDelayMs);
381
+ }
382
+
383
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
384
+ if (retryAfter !== null) {
385
+ return clampDelay(retryAfter, maxDelayMs);
386
+ }
387
+
388
+ const bodyDelayMs = await parseRetryDelayFromBody(response);
389
+ if (bodyDelayMs !== null) {
390
+ return clampDelay(bodyDelayMs, maxDelayMs);
391
+ }
392
+
393
+ const fallback = baseDelayMs * Math.pow(2, attempt);
394
+ return clampDelay(fallback, maxDelayMs);
395
+ }
396
+
397
+ function clampDelay(delayMs: number, maxDelayMs: number): number {
398
+ if (!Number.isFinite(delayMs)) {
399
+ return maxDelayMs;
400
+ }
401
+ return Math.min(Math.max(0, delayMs), maxDelayMs);
402
+ }
403
+
404
+ function parseRetryAfterMs(value: string | null): number | null {
405
+ if (!value) {
406
+ return null;
407
+ }
408
+ const parsed = Number(value);
409
+ if (!Number.isFinite(parsed) || parsed <= 0) {
410
+ return null;
411
+ }
412
+ return parsed;
413
+ }
414
+
415
+ function parseRetryAfter(value: string | null): number | null {
416
+ if (!value) {
417
+ return null;
418
+ }
419
+ const trimmed = value.trim();
420
+ if (!trimmed) {
421
+ return null;
422
+ }
423
+ const asNumber = Number(trimmed);
424
+ if (Number.isFinite(asNumber)) {
425
+ return Math.max(0, Math.round(asNumber * 1000));
426
+ }
427
+ const asDate = Date.parse(trimmed);
428
+ if (!Number.isNaN(asDate)) {
429
+ return Math.max(0, asDate - Date.now());
430
+ }
431
+ return null;
432
+ }
433
+
434
+ async function parseRetryDelayFromBody(response: Response): Promise<number | null> {
435
+ let text = "";
436
+ try {
437
+ text = await response.clone().text();
438
+ } catch {
439
+ return null;
440
+ }
441
+
442
+ if (!text) {
443
+ return null;
444
+ }
445
+
446
+ let parsed: any;
447
+ try {
448
+ parsed = JSON.parse(text);
449
+ } catch {
450
+ return null;
451
+ }
452
+
453
+ const details = parsed?.error?.details;
454
+ if (!Array.isArray(details)) {
455
+ return null;
456
+ }
457
+
458
+ for (const detail of details) {
459
+ if (!detail || typeof detail !== "object") {
460
+ continue;
461
+ }
462
+ const retryDelay = (detail as Record<string, unknown>).retryDelay;
463
+ if (!retryDelay) {
464
+ continue;
465
+ }
466
+ const delayMs = parseRetryDelayValue(retryDelay);
467
+ if (delayMs !== null) {
468
+ return delayMs;
469
+ }
470
+ }
471
+
472
+ return null;
473
+ }
474
+
475
+ function parseRetryDelayValue(value: unknown): number | null {
476
+ if (!value) {
477
+ return null;
478
+ }
479
+
480
+ if (typeof value === "string") {
481
+ const match = value.match(/^([\d.]+)s$/);
482
+ if (!match || !match[1]) {
483
+ return null;
484
+ }
485
+ const seconds = Number(match[1]);
486
+ if (!Number.isFinite(seconds) || seconds <= 0) {
487
+ return null;
488
+ }
489
+ return Math.round(seconds * 1000);
490
+ }
491
+
492
+ if (typeof value === "object") {
493
+ const record = value as Record<string, unknown>;
494
+ const seconds = typeof record.seconds === "number" ? record.seconds : 0;
495
+ const nanos = typeof record.nanos === "number" ? record.nanos : 0;
496
+ if (!Number.isFinite(seconds) || !Number.isFinite(nanos)) {
497
+ return null;
498
+ }
499
+ const totalMs = Math.round(seconds * 1000 + nanos / 1e6);
500
+ return totalMs > 0 ? totalMs : null;
501
+ }
502
+
503
+ return null;
504
+ }
505
+
506
+ function wait(ms: number): Promise<void> {
507
+ return new Promise((resolve) => {
508
+ setTimeout(resolve, ms);
509
+ });
510
+ }