├── .gitattributes ├── src ├── shims.d.ts ├── constants.ts ├── plugin │ ├── auth.ts │ ├── cache.ts │ ├── types.ts │ ├── token.test.ts │ ├── token.ts │ ├── debug.ts │ ├── request-helpers.ts │ ├── server.ts │ ├── project.ts │ └── request.ts ├── gemini │ └── oauth.ts └── plugin.ts ├── index.ts ├── .gitignore ├── package.json ├── tsconfig.json ├── LICENSE ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── release.yml ├── bun.lock └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@openauthjs/openauth/pkce" { 2 | interface PkcePair { 3 | challenge: string; 4 | verifier: string; 5 | } 6 | 7 | export function generatePKCE(): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | GeminiCLIOAuthPlugin, 3 | GoogleOAuthPlugin, 4 | } from "./src/plugin"; 5 | 6 | export { 7 | authorizeGemini, 8 | exchangeGemini, 9 | } from "./src/gemini/oauth"; 10 | 11 | export type { 12 | GeminiAuthorization, 13 | GeminiTokenExchangeResult, 14 | } from "./src/gemini/oauth"; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | # code coverage 10 | coverage 11 | *.lcov 12 | 13 | # logs 14 | logs 15 | _.log 16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 | gemini-debug-*.log 18 | 19 | # dotenv environment variable files 20 | .env 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | .env.local 25 | 26 | # caches 27 | .eslintcache 28 | .cache 29 | *.tsbuildinfo 30 | 31 | # IntelliJ based IDEs 32 | .idea 33 | 34 | # Finder (MacOS) folder config 35 | .DS_Store 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opencode-gemini-auth", 3 | "module": "index.ts", 4 | "version": "1.3.3", 5 | "author": "jenslys", 6 | "repository": "https://github.com/jenslys/opencode-gemini-auth", 7 | "files": [ 8 | "index.ts", 9 | "src" 10 | ], 11 | "license": "MIT", 12 | "type": "module", 13 | "devDependencies": { 14 | "@opencode-ai/plugin": "^0.15.31", 15 | "@types/bun": "latest" 16 | }, 17 | "peerDependencies": { 18 | "typescript": "^5.9.3" 19 | }, 20 | "dependencies": { 21 | "@openauthjs/openauth": "^0.4.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jens 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants used for Google Gemini OAuth flows and Cloud Code Assist API integration. 3 | */ 4 | export const GEMINI_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"; 5 | 6 | /** 7 | * Client secret issued for the Gemini CLI OAuth application. 8 | */ 9 | export const GEMINI_CLIENT_SECRET = "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"; 10 | 11 | /** 12 | * Scopes required for Gemini CLI integrations. 13 | */ 14 | export const GEMINI_SCOPES: readonly string[] = [ 15 | "https://www.googleapis.com/auth/cloud-platform", 16 | "https://www.googleapis.com/auth/userinfo.email", 17 | "https://www.googleapis.com/auth/userinfo.profile", 18 | ]; 19 | 20 | /** 21 | * OAuth redirect URI used by the local CLI callback server. 22 | */ 23 | export const GEMINI_REDIRECT_URI = "http://localhost:8085/oauth2callback"; 24 | 25 | /** 26 | * Root endpoint for the Cloud Code Assist API which backs Gemini CLI traffic. 27 | */ 28 | export const GEMINI_CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; 29 | 30 | export const CODE_ASSIST_HEADERS = { 31 | "User-Agent": "google-api-nodejs-client/9.15.1", 32 | "X-Goog-Api-Client": "gl-node/22.17.0", 33 | "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", 34 | } as const; 35 | 36 | /** 37 | * Provider identifier shared between the plugin loader and credential store. 38 | */ 39 | export const GEMINI_PROVIDER_ID = "google"; 40 | -------------------------------------------------------------------------------- /src/plugin/auth.ts: -------------------------------------------------------------------------------- 1 | import type { AuthDetails, OAuthAuthDetails, RefreshParts } from "./types"; 2 | 3 | const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000; 4 | 5 | export function isOAuthAuth(auth: AuthDetails): auth is OAuthAuthDetails { 6 | return auth.type === "oauth"; 7 | } 8 | 9 | /** 10 | * Splits a packed refresh string into its constituent refresh token and project IDs. 11 | */ 12 | export function parseRefreshParts(refresh: string): RefreshParts { 13 | const [refreshToken = "", projectId = "", managedProjectId = ""] = (refresh ?? "").split("|"); 14 | return { 15 | refreshToken, 16 | projectId: projectId || undefined, 17 | managedProjectId: managedProjectId || undefined, 18 | }; 19 | } 20 | 21 | /** 22 | * Serializes refresh token parts into the stored string format. 23 | */ 24 | export function formatRefreshParts(parts: RefreshParts): string { 25 | if (!parts.refreshToken) { 26 | return ""; 27 | } 28 | 29 | if (!parts.projectId && !parts.managedProjectId) { 30 | return parts.refreshToken; 31 | } 32 | 33 | const projectSegment = parts.projectId ?? ""; 34 | const managedSegment = parts.managedProjectId ?? ""; 35 | return `${parts.refreshToken}|${projectSegment}|${managedSegment}`; 36 | } 37 | 38 | /** 39 | * Determines whether an access token is expired or missing, with buffer for clock skew. 40 | */ 41 | export function accessTokenExpired(auth: OAuthAuthDetails): boolean { 42 | if (!auth.access || typeof auth.expires !== "number") { 43 | return true; 44 | } 45 | return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS; 46 | } 47 | -------------------------------------------------------------------------------- /src/plugin/cache.ts: -------------------------------------------------------------------------------- 1 | import { accessTokenExpired } from "./auth"; 2 | import type { OAuthAuthDetails } from "./types"; 3 | 4 | const authCache = new Map(); 5 | 6 | /** 7 | * Produces a stable cache key from a refresh token string. 8 | */ 9 | function normalizeRefreshKey(refresh?: string): string | undefined { 10 | const key = refresh?.trim(); 11 | return key ? key : undefined; 12 | } 13 | 14 | /** 15 | * Returns a cached auth snapshot when available, favoring unexpired tokens. 16 | */ 17 | export function resolveCachedAuth(auth: OAuthAuthDetails): OAuthAuthDetails { 18 | const key = normalizeRefreshKey(auth.refresh); 19 | if (!key) { 20 | return auth; 21 | } 22 | 23 | const cached = authCache.get(key); 24 | if (!cached) { 25 | authCache.set(key, auth); 26 | return auth; 27 | } 28 | 29 | if (!accessTokenExpired(auth)) { 30 | authCache.set(key, auth); 31 | return auth; 32 | } 33 | 34 | if (!accessTokenExpired(cached)) { 35 | return cached; 36 | } 37 | 38 | authCache.set(key, auth); 39 | return auth; 40 | } 41 | 42 | /** 43 | * Stores the latest auth snapshot keyed by refresh token. 44 | */ 45 | export function storeCachedAuth(auth: OAuthAuthDetails): void { 46 | const key = normalizeRefreshKey(auth.refresh); 47 | if (!key) { 48 | return; 49 | } 50 | authCache.set(key, auth); 51 | } 52 | 53 | /** 54 | * Clears cached auth globally or for a specific refresh token. 55 | */ 56 | export function clearCachedAuth(refresh?: string): void { 57 | if (!refresh) { 58 | authCache.clear(); 59 | return; 60 | } 61 | const key = normalizeRefreshKey(refresh); 62 | if (key) { 63 | authCache.delete(key); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a reproducible bug 4 | title: "[Bug]: " 5 | labels: ["bug"] 6 | assignees: "" 7 | --- 8 | 9 | ## Description 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | ## Reproduction Steps 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ## Expected Behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ## Actual Behavior 27 | 28 | A clear and concise description of what actually happens. 29 | 30 | ## Screenshots 31 | 32 | If applicable, add screenshots to help explain your problem. 33 | 34 | ## Environment 35 | 36 | - OS: [e.g. iOS, Android, Windows, macOS, Linux] 37 | - Version: [e.g. 22] 38 | 39 | ## Opencode Information 40 | 41 | - Opencode Type: [cli, web, desktop] 42 | - Opencode Version: [Output of `opencode --version`] 43 | - Opencode Settings: [Content of `~/.config/opencode/opencode.json`] 44 | 45 | ## Debug Information 46 | 47 |
48 | Click to expand debug information 49 | 50 | To help us diagnose the issue, please provide debug logs if possible. 51 | 52 | **How to enable debug logging:** 53 | Set `OPENCODE_GEMINI_DEBUG=1` in the environment when you run an Opencode command. For example: `OPENCODE_GEMINI_DEBUG=1 opencode` 54 | The plugin writes to a timestamped `gemini-debug-.log` file in your current working directory. 55 | 56 | **Debug Logs:** 57 | 58 | ``` 59 | [Paste content of `gemini-debug-.log` here] 60 | ``` 61 | 62 | **Bug Report (if applicable):** 63 | [If there's a specific bug report file or output, please attach/paste it here] 64 | 65 |
66 | 67 | ## Additional Context 68 | 69 | Add any other context about the problem here. 70 | -------------------------------------------------------------------------------- /src/plugin/types.ts: -------------------------------------------------------------------------------- 1 | import type { GeminiTokenExchangeResult } from "../gemini/oauth"; 2 | 3 | export interface OAuthAuthDetails { 4 | type: "oauth"; 5 | refresh: string; 6 | access?: string; 7 | expires?: number; 8 | } 9 | 10 | export interface NonOAuthAuthDetails { 11 | type: string; 12 | [key: string]: unknown; 13 | } 14 | 15 | export type AuthDetails = OAuthAuthDetails | NonOAuthAuthDetails; 16 | 17 | export type GetAuth = () => Promise; 18 | 19 | export interface ProviderModel { 20 | cost?: { 21 | input: number; 22 | output: number; 23 | }; 24 | [key: string]: unknown; 25 | } 26 | 27 | export interface Provider { 28 | models?: Record; 29 | options?: Record; 30 | } 31 | 32 | export interface LoaderResult { 33 | apiKey: string; 34 | fetch(input: RequestInfo, init?: RequestInit): Promise; 35 | } 36 | 37 | export interface AuthMethod { 38 | provider?: string; 39 | label: string; 40 | type: "oauth" | "api"; 41 | authorize?: () => Promise<{ 42 | url: string; 43 | instructions: string; 44 | method: string; 45 | callback: (() => Promise) | ((callbackUrl: string) => Promise); 46 | }>; 47 | } 48 | 49 | export interface PluginClient { 50 | auth: { 51 | set(input: { path: { id: string }; body: OAuthAuthDetails }): Promise; 52 | }; 53 | } 54 | 55 | export interface PluginContext { 56 | client: PluginClient; 57 | } 58 | 59 | export interface PluginResult { 60 | auth: { 61 | provider: string; 62 | loader: (getAuth: GetAuth, provider: Provider) => Promise; 63 | methods: AuthMethod[]; 64 | }; 65 | } 66 | 67 | export interface RefreshParts { 68 | refreshToken: string; 69 | projectId?: string; 70 | managedProjectId?: string; 71 | } 72 | 73 | export interface ProjectContextResult { 74 | auth: OAuthAuthDetails; 75 | effectiveProjectId: string; 76 | } 77 | -------------------------------------------------------------------------------- /src/plugin/token.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, mock } from "bun:test"; 2 | 3 | import { GEMINI_PROVIDER_ID } from "../constants"; 4 | import { refreshAccessToken } from "./token"; 5 | import type { OAuthAuthDetails, PluginClient } from "./types"; 6 | 7 | const baseAuth: OAuthAuthDetails = { 8 | type: "oauth", 9 | refresh: "refresh-token|project-123", 10 | access: "old-access", 11 | expires: Date.now() - 1000, 12 | }; 13 | 14 | function createClient() { 15 | return { 16 | auth: { 17 | set: mock(async () => {}), 18 | }, 19 | } as PluginClient & { 20 | auth: { set: ReturnType Promise>> }; 21 | }; 22 | } 23 | 24 | describe("refreshAccessToken", () => { 25 | beforeEach(() => { 26 | mock.restore(); 27 | }); 28 | 29 | it("updates the caller but skips persisting when refresh token is unchanged", async () => { 30 | const client = createClient(); 31 | const fetchMock = mock(async () => { 32 | return new Response( 33 | JSON.stringify({ 34 | access_token: "new-access", 35 | expires_in: 3600, 36 | }), 37 | { status: 200 }, 38 | ); 39 | }); 40 | (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; 41 | 42 | const result = await refreshAccessToken(baseAuth, client); 43 | 44 | expect(result?.access).toBe("new-access"); 45 | expect(client.auth.set.mock.calls.length).toBe(0); 46 | }); 47 | 48 | it("persists when Google rotates the refresh token", async () => { 49 | const client = createClient(); 50 | const fetchMock = mock(async () => { 51 | return new Response( 52 | JSON.stringify({ 53 | access_token: "next-access", 54 | expires_in: 3600, 55 | refresh_token: "rotated-token", 56 | }), 57 | { status: 200 }, 58 | ); 59 | }); 60 | (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; 61 | 62 | const result = await refreshAccessToken(baseAuth, client); 63 | 64 | expect(result?.access).toBe("next-access"); 65 | expect(client.auth.set.mock.calls.length).toBe(1); 66 | expect(client.auth.set.mock.calls[0]?.[0]).toEqual({ 67 | path: { id: GEMINI_PROVIDER_ID }, 68 | body: expect.objectContaining({ 69 | type: "oauth", 70 | refresh: expect.stringContaining("rotated-token"), 71 | }), 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "configVersion": 0, 4 | "workspaces": { 5 | "": { 6 | "name": "opencode-gemini-auth", 7 | "dependencies": { 8 | "@openauthjs/openauth": "^0.4.3", 9 | }, 10 | "devDependencies": { 11 | "@opencode-ai/plugin": "^0.15.30", 12 | "@types/bun": "latest", 13 | }, 14 | "peerDependencies": { 15 | "typescript": "^5", 16 | }, 17 | }, 18 | }, 19 | "packages": { 20 | "@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="], 21 | 22 | "@opencode-ai/plugin": ["@opencode-ai/plugin@0.15.31", "", { "dependencies": { "@opencode-ai/sdk": "0.15.31", "zod": "4.1.8" } }, "sha512-htKKCq9Htljf7vX5ANLDB7bU7TeJYrl8LP2CQUtCAguKUpVvpj5tiZ+edlCdhGFEqlpSp+pkiTEY5LCv1muowg=="], 23 | 24 | "@opencode-ai/sdk": ["@opencode-ai/sdk@0.15.31", "", {}, "sha512-95HWBiNKQnwsubkR2E7QhBD/CH9yteZGrviWar0aKHWu8/RjWw9m7Znbv8DI+y6i2dMwBBcGQ8LJ7x0abzys4A=="], 25 | 26 | "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], 27 | 28 | "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], 29 | 30 | "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], 31 | 32 | "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], 33 | 34 | "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], 35 | 36 | "@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="], 37 | 38 | "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], 39 | 40 | "@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], 41 | 42 | "arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="], 43 | 44 | "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], 45 | 46 | "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 47 | 48 | "hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="], 49 | 50 | "jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], 51 | 52 | "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 53 | 54 | "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 55 | 56 | "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], 57 | 58 | "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gemini OAuth Plugin for Opencode 2 | 3 | ![License](https://img.shields.io/npm/l/opencode-gemini-auth) 4 | ![Version](https://img.shields.io/npm/v/opencode-gemini-auth) 5 | 6 | **Authenticate the Opencode CLI with your Google account.** This plugin enables 7 | you to use your existing Gemini plan and quotas (including the free tier) 8 | directly within Opencode, bypassing separate API billing. 9 | 10 | ## Prerequisites 11 | 12 | - [Opencode CLI](https://opencode.ai) installed. 13 | - A Google account with access to Gemini. 14 | 15 | ## Installation 16 | 17 | Add the plugin to your Opencode configuration file 18 | (`~/.config/opencode/config.json` or similar): 19 | 20 | ```json 21 | { 22 | "$schema": "https://opencode.ai/config.json", 23 | "plugin": ["opencode-gemini-auth@latest"] 24 | } 25 | ``` 26 | 27 | ## Usage 28 | 29 | 1. **Login**: Run the authentication command in your terminal: 30 | 31 | ```bash 32 | opencode auth login 33 | ``` 34 | 35 | 2. **Select Provider**: Choose **Google** from the list. 36 | 3. **Authenticate**: Select **OAuth with Google (Gemini CLI)**. 37 | - A browser window will open for you to approve the access. 38 | - The plugin spins up a temporary local server to capture the callback. 39 | - If the local server fails (e.g., port in use or headless environment), 40 | you can manually copy/paste the callback URL as instructed. 41 | 42 | Once authenticated, Opencode will use your Google account for Gemini requests. 43 | 44 | ## Configuration 45 | 46 | ### Google Cloud Project 47 | 48 | By default, the plugin attempts to provision or find a suitable Google Cloud 49 | project. To force a specific project, set the `projectId` in your configuration: 50 | 51 | ```json 52 | { 53 | "provider": { 54 | "google": { 55 | "options": { 56 | "projectId": "your-specific-project-id" 57 | } 58 | } 59 | } 60 | } 61 | ``` 62 | 63 | ### Thinking Models 64 | 65 | Configure "thinking" capabilities for Gemini models using the `thinkingConfig` 66 | option in your `config.json`. 67 | 68 | **Gemini 3 (Thinking Level)** 69 | Use `thinkingLevel` (`"low"`, `"high"`) for Gemini 3 models. 70 | 71 | ```json 72 | { 73 | "provider": { 74 | "google": { 75 | "models": { 76 | "gemini-3-pro-preview": { 77 | "options": { 78 | "thinkingConfig": { 79 | "thinkingLevel": "high", 80 | "includeThoughts": true 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | ``` 89 | 90 | **Gemini 2.5 (Thinking Budget)** 91 | Use `thinkingBudget` (token count) for Gemini 2.5 models. 92 | 93 | ```json 94 | { 95 | "provider": { 96 | "google": { 97 | "models": { 98 | "gemini-2.5-flash": { 99 | "options": { 100 | "thinkingConfig": { 101 | "thinkingBudget": 8192, 102 | "includeThoughts": true 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | } 110 | ``` 111 | 112 | ## Troubleshooting 113 | 114 | ### Manual Google Cloud Setup 115 | 116 | If automatic provisioning fails, you may need to set up the project manually: 117 | 118 | 1. Go to the [Google Cloud Console](https://console.cloud.google.com/). 119 | 2. Create or select a project. 120 | 3. Enable the **Gemini for Google Cloud API** 121 | (`cloudaicompanion.googleapis.com`). 122 | 4. Configure the `projectId` in your Opencode config as shown above. 123 | 124 | ### Debugging 125 | 126 | To view detailed logs of Gemini requests and responses, set the 127 | `OPENCODE_GEMINI_DEBUG` environment variable: 128 | 129 | ```bash 130 | OPENCODE_GEMINI_DEBUG=1 opencode 131 | ``` 132 | 133 | This will generate `gemini-debug-.log` files in your working 134 | directory containing sanitized request/response details. 135 | 136 | ### Updating 137 | 138 | Opencode does not automatically update plugins. To update to the latest version, 139 | you must clear the cached plugin: 140 | 141 | ```bash 142 | # Clear the specific plugin cache 143 | rm -rf ~/.cache/opencode/node_modules/opencode-gemini-auth 144 | 145 | # Run Opencode to trigger a fresh install 146 | opencode 147 | ``` 148 | 149 | ## Development 150 | 151 | To develop on this plugin locally: 152 | 153 | 1. **Clone**: 154 | 155 | ```bash 156 | git clone https://github.com/jenslys/opencode-gemini-auth.git 157 | cd opencode-gemini-auth 158 | bun install 159 | ``` 160 | 161 | 2. **Link**: 162 | Update your Opencode config to point to your local directory using a 163 | `file://` URL: 164 | 165 | ```json 166 | { 167 | "plugin": ["file:///absolute/path/to/opencode-gemini-auth"] 168 | } 169 | ``` 170 | 171 | ## License 172 | 173 | MIT 174 | -------------------------------------------------------------------------------- /src/gemini/oauth.ts: -------------------------------------------------------------------------------- 1 | import { generatePKCE } from "@openauthjs/openauth/pkce"; 2 | 3 | import { 4 | GEMINI_CLIENT_ID, 5 | GEMINI_CLIENT_SECRET, 6 | GEMINI_REDIRECT_URI, 7 | GEMINI_SCOPES, 8 | } from "../constants"; 9 | 10 | interface PkcePair { 11 | challenge: string; 12 | verifier: string; 13 | } 14 | 15 | interface GeminiAuthState { 16 | verifier: string; 17 | } 18 | 19 | /** 20 | * Result returned to the caller after constructing an OAuth authorization URL. 21 | */ 22 | export interface GeminiAuthorization { 23 | url: string; 24 | verifier: string; 25 | } 26 | 27 | interface GeminiTokenExchangeSuccess { 28 | type: "success"; 29 | refresh: string; 30 | access: string; 31 | expires: number; 32 | email?: string; 33 | } 34 | 35 | interface GeminiTokenExchangeFailure { 36 | type: "failed"; 37 | error: string; 38 | } 39 | 40 | export type GeminiTokenExchangeResult = 41 | | GeminiTokenExchangeSuccess 42 | | GeminiTokenExchangeFailure; 43 | 44 | interface GeminiTokenResponse { 45 | access_token: string; 46 | expires_in: number; 47 | refresh_token: string; 48 | } 49 | 50 | interface GeminiUserInfo { 51 | email?: string; 52 | } 53 | 54 | /** 55 | * Encode an object into a URL-safe base64 string. 56 | */ 57 | function encodeState(payload: GeminiAuthState): string { 58 | return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); 59 | } 60 | 61 | /** 62 | * Decode an OAuth state parameter back into its structured representation. 63 | */ 64 | function decodeState(state: string): GeminiAuthState { 65 | const normalized = state.replace(/-/g, "+").replace(/_/g, "/"); 66 | const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "="); 67 | const json = Buffer.from(padded, "base64").toString("utf8"); 68 | const parsed = JSON.parse(json); 69 | if (typeof parsed.verifier !== "string") { 70 | throw new Error("Missing PKCE verifier in state"); 71 | } 72 | return { 73 | verifier: parsed.verifier, 74 | }; 75 | } 76 | 77 | /** 78 | * Build the Gemini OAuth authorization URL including PKCE. 79 | */ 80 | export async function authorizeGemini(): Promise { 81 | const pkce = (await generatePKCE()) as PkcePair; 82 | 83 | const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); 84 | url.searchParams.set("client_id", GEMINI_CLIENT_ID); 85 | url.searchParams.set("response_type", "code"); 86 | url.searchParams.set("redirect_uri", GEMINI_REDIRECT_URI); 87 | url.searchParams.set("scope", GEMINI_SCOPES.join(" ")); 88 | url.searchParams.set("code_challenge", pkce.challenge); 89 | url.searchParams.set("code_challenge_method", "S256"); 90 | url.searchParams.set("state", encodeState({ verifier: pkce.verifier })); 91 | url.searchParams.set("access_type", "offline"); 92 | url.searchParams.set("prompt", "consent"); 93 | 94 | return { 95 | url: url.toString(), 96 | verifier: pkce.verifier, 97 | }; 98 | } 99 | 100 | /** 101 | * Exchange an authorization code for Gemini CLI access and refresh tokens. 102 | */ 103 | export async function exchangeGemini( 104 | code: string, 105 | state: string, 106 | ): Promise { 107 | try { 108 | const { verifier } = decodeState(state); 109 | 110 | const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { 111 | method: "POST", 112 | headers: { 113 | "Content-Type": "application/x-www-form-urlencoded", 114 | }, 115 | body: new URLSearchParams({ 116 | client_id: GEMINI_CLIENT_ID, 117 | client_secret: GEMINI_CLIENT_SECRET, 118 | code, 119 | grant_type: "authorization_code", 120 | redirect_uri: GEMINI_REDIRECT_URI, 121 | code_verifier: verifier, 122 | }), 123 | }); 124 | 125 | if (!tokenResponse.ok) { 126 | const errorText = await tokenResponse.text(); 127 | return { type: "failed", error: errorText }; 128 | } 129 | 130 | const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse; 131 | 132 | const userInfoResponse = await fetch( 133 | "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", 134 | { 135 | headers: { 136 | Authorization: `Bearer ${tokenPayload.access_token}`, 137 | }, 138 | }, 139 | ); 140 | 141 | const userInfo = userInfoResponse.ok 142 | ? ((await userInfoResponse.json()) as GeminiUserInfo) 143 | : {}; 144 | 145 | const refreshToken = tokenPayload.refresh_token; 146 | if (!refreshToken) { 147 | return { type: "failed", error: "Missing refresh token in response" }; 148 | } 149 | 150 | return { 151 | type: "success", 152 | refresh: refreshToken, 153 | access: tokenPayload.access_token, 154 | expires: Date.now() + tokenPayload.expires_in * 1000, 155 | email: userInfo.email, 156 | }; 157 | } catch (error) { 158 | return { 159 | type: "failed", 160 | error: error instanceof Error ? error.message : "Unknown error", 161 | }; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/plugin/token.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GEMINI_CLIENT_ID, 3 | GEMINI_CLIENT_SECRET, 4 | GEMINI_PROVIDER_ID, 5 | } from "../constants"; 6 | import { formatRefreshParts, parseRefreshParts } from "./auth"; 7 | import { clearCachedAuth, storeCachedAuth } from "./cache"; 8 | import { invalidateProjectContextCache } from "./project"; 9 | import type { OAuthAuthDetails, PluginClient, RefreshParts } from "./types"; 10 | 11 | interface OAuthErrorPayload { 12 | error?: 13 | | string 14 | | { 15 | code?: string; 16 | status?: string; 17 | message?: string; 18 | }; 19 | error_description?: string; 20 | } 21 | 22 | /** 23 | * Parses OAuth error payloads returned by Google token endpoints, tolerating varied shapes. 24 | */ 25 | function parseOAuthErrorPayload(text: string | undefined): { code?: string; description?: string } { 26 | if (!text) { 27 | return {}; 28 | } 29 | 30 | try { 31 | const payload = JSON.parse(text) as OAuthErrorPayload; 32 | if (!payload || typeof payload !== "object") { 33 | return { description: text }; 34 | } 35 | 36 | let code: string | undefined; 37 | if (typeof payload.error === "string") { 38 | code = payload.error; 39 | } else if (payload.error && typeof payload.error === "object") { 40 | code = payload.error.status ?? payload.error.code; 41 | if (!payload.error_description && payload.error.message) { 42 | return { code, description: payload.error.message }; 43 | } 44 | } 45 | 46 | const description = payload.error_description; 47 | if (description) { 48 | return { code, description }; 49 | } 50 | 51 | if (payload.error && typeof payload.error === "object" && payload.error.message) { 52 | return { code, description: payload.error.message }; 53 | } 54 | 55 | return { code }; 56 | } catch { 57 | return { description: text }; 58 | } 59 | } 60 | 61 | /** 62 | * Refreshes a Gemini OAuth access token, updates persisted credentials, and handles revocation. 63 | */ 64 | export async function refreshAccessToken( 65 | auth: OAuthAuthDetails, 66 | client: PluginClient, 67 | ): Promise { 68 | const parts = parseRefreshParts(auth.refresh); 69 | if (!parts.refreshToken) { 70 | return undefined; 71 | } 72 | 73 | try { 74 | const response = await fetch("https://oauth2.googleapis.com/token", { 75 | method: "POST", 76 | headers: { 77 | "Content-Type": "application/x-www-form-urlencoded", 78 | }, 79 | body: new URLSearchParams({ 80 | grant_type: "refresh_token", 81 | refresh_token: parts.refreshToken, 82 | client_id: GEMINI_CLIENT_ID, 83 | client_secret: GEMINI_CLIENT_SECRET, 84 | }), 85 | }); 86 | 87 | if (!response.ok) { 88 | let errorText: string | undefined; 89 | try { 90 | errorText = await response.text(); 91 | } catch { 92 | errorText = undefined; 93 | } 94 | 95 | const { code, description } = parseOAuthErrorPayload(errorText); 96 | const details = [code, description ?? errorText].filter(Boolean).join(": "); 97 | const baseMessage = `Gemini token refresh failed (${response.status} ${response.statusText})`; 98 | console.warn(`[Gemini OAuth] ${details ? `${baseMessage} - ${details}` : baseMessage}`); 99 | 100 | if (code === "invalid_grant") { 101 | console.warn( 102 | "[Gemini OAuth] Google revoked the stored refresh token. Run `opencode auth login` and reauthenticate the Google provider.", 103 | ); 104 | invalidateProjectContextCache(auth.refresh); 105 | try { 106 | const clearedAuth: OAuthAuthDetails = { 107 | type: "oauth", 108 | refresh: formatRefreshParts({ 109 | refreshToken: "", 110 | projectId: parts.projectId, 111 | managedProjectId: parts.managedProjectId, 112 | }), 113 | }; 114 | await client.auth.set({ 115 | path: { id: GEMINI_PROVIDER_ID }, 116 | body: clearedAuth, 117 | }); 118 | } catch (storeError) { 119 | console.error("Failed to clear stored Gemini OAuth credentials:", storeError); 120 | } 121 | } 122 | 123 | return undefined; 124 | } 125 | 126 | const payload = (await response.json()) as { 127 | access_token: string; 128 | expires_in: number; 129 | refresh_token?: string; 130 | }; 131 | 132 | const refreshedParts: RefreshParts = { 133 | refreshToken: payload.refresh_token ?? parts.refreshToken, 134 | projectId: parts.projectId, 135 | managedProjectId: parts.managedProjectId, 136 | }; 137 | 138 | const updatedAuth: OAuthAuthDetails = { 139 | ...auth, 140 | access: payload.access_token, 141 | expires: Date.now() + payload.expires_in * 1000, 142 | refresh: formatRefreshParts(refreshedParts), 143 | }; 144 | 145 | storeCachedAuth(updatedAuth); 146 | invalidateProjectContextCache(auth.refresh); 147 | 148 | try { 149 | await client.auth.set({ 150 | path: { id: GEMINI_PROVIDER_ID }, 151 | body: updatedAuth, 152 | }); 153 | } catch (storeError) { 154 | console.error("Failed to persist refreshed Gemini OAuth credentials:", storeError); 155 | } 156 | 157 | return updatedAuth; 158 | } catch (error) { 159 | console.error("Failed to refresh Gemini access token due to an unexpected error:", error); 160 | return undefined; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | id-token: write 12 | 13 | jobs: 14 | publish: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Use Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | registry-url: https://registry.npmjs.org 27 | always-auth: true 28 | 29 | - name: Determine release state 30 | id: determine 31 | run: | 32 | set -euo pipefail 33 | CURRENT_VERSION=$(node -p "require('./package.json').version") 34 | echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" 35 | if git rev-parse HEAD^ >/dev/null 2>&1; then 36 | PREVIOUS_VERSION=$(node -e "const { execSync } = require('node:child_process'); try { const data = execSync('git show HEAD^:package.json', { stdio: ['ignore', 'pipe', 'ignore'] }); const json = JSON.parse(data.toString()); if (json && typeof json.version === 'string') { process.stdout.write(json.version); } } catch (error) {}") 37 | PREVIOUS_VERSION=${PREVIOUS_VERSION//$'\n'/} 38 | else 39 | PREVIOUS_VERSION="" 40 | fi 41 | echo "previous_version=$PREVIOUS_VERSION" >> "$GITHUB_OUTPUT" 42 | if [ "$CURRENT_VERSION" = "$PREVIOUS_VERSION" ]; then 43 | echo "changed=false" >> "$GITHUB_OUTPUT" 44 | else 45 | echo "changed=true" >> "$GITHUB_OUTPUT" 46 | fi 47 | git fetch --tags --force 48 | if git tag -l "v$CURRENT_VERSION" | grep -q "v$CURRENT_VERSION"; then 49 | echo "tag_exists=true" >> "$GITHUB_OUTPUT" 50 | else 51 | echo "tag_exists=false" >> "$GITHUB_OUTPUT" 52 | fi 53 | 54 | - name: Verify NPM token 55 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 56 | run: | 57 | if [ -z "${NPM_TOKEN}" ]; then 58 | echo "NPM_TOKEN secret is required" >&2 59 | exit 1 60 | fi 61 | env: 62 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 63 | 64 | - name: Install dependencies 65 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 66 | run: npm install 67 | 68 | - name: Generate release notes 69 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 70 | id: release_notes 71 | run: | 72 | set -euo pipefail 73 | CURRENT_VERSION="${{ steps.determine.outputs.current_version }}" 74 | PREVIOUS_VERSION="${{ steps.determine.outputs.previous_version }}" 75 | RANGE="" 76 | COMPARE_URL="" 77 | LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true) 78 | if [ -z "$LAST_TAG" ] && [ -n "$PREVIOUS_VERSION" ] && git rev-parse "refs/tags/v${PREVIOUS_VERSION}" >/dev/null 2>&1; then 79 | LAST_TAG="v${PREVIOUS_VERSION}" 80 | fi 81 | if [ -n "$LAST_TAG" ]; then 82 | RANGE="${LAST_TAG}..HEAD" 83 | COMPARE_URL="https://github.com/${GITHUB_REPOSITORY}/compare/${LAST_TAG}...v${CURRENT_VERSION}" 84 | fi 85 | if [ -n "$RANGE" ]; then 86 | CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' "$RANGE") 87 | else 88 | CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)') 89 | fi 90 | if [ -z "$CHANGELOG" ]; then 91 | CHANGELOG="- No commits found for this release." 92 | fi 93 | BODY_FILE=$(mktemp) 94 | { 95 | echo "## Release v${CURRENT_VERSION}" 96 | echo "" 97 | if [ -n "$COMPARE_URL" ]; then 98 | echo "Compare changes: $COMPARE_URL" 99 | echo "" 100 | fi 101 | printf "%s\n" "$CHANGELOG" 102 | echo "" 103 | echo "### Update Instructions" 104 | echo "" 105 | echo "To get the latest version:" 106 | echo "" 107 | printf '%s\n' '```bash' 108 | printf '%s\n' '(cd ~ && sed -i.bak '\''/"opencode-gemini-auth"/d'\'' .cache/opencode/package.json && \' 109 | printf '%s\n' 'rm -rf .cache/opencode/node_modules/opencode-gemini-auth && \' 110 | printf '%s\n' 'echo "Plugin update script finished successfully.")' 111 | printf '%s\n' '```' 112 | 113 | } >"$BODY_FILE" 114 | cat "$BODY_FILE" 115 | { 116 | echo "body<>"$GITHUB_OUTPUT" 120 | 121 | - name: Create GitHub release 122 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 123 | uses: actions/create-release@v1 124 | env: 125 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 126 | with: 127 | tag_name: v${{ steps.determine.outputs.current_version }} 128 | release_name: v${{ steps.determine.outputs.current_version }} 129 | body: ${{ steps.release_notes.outputs.body }} 130 | generate_release_notes: false 131 | 132 | - name: Publish to npm 133 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 134 | env: 135 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 136 | run: npm publish --access public --provenance 137 | -------------------------------------------------------------------------------- /src/plugin/debug.ts: -------------------------------------------------------------------------------- 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 | headersOverride?: HeadersInit; 32 | } 33 | 34 | let requestCounter = 0; 35 | 36 | /** 37 | * Begins a debug trace for a Gemini request, logging request metadata when debugging is enabled. 38 | */ 39 | export function startGeminiDebugRequest(meta: GeminiDebugRequestMeta): GeminiDebugContext | null { 40 | if (!debugEnabled) { 41 | return null; 42 | } 43 | 44 | const id = `GEMINI-${++requestCounter}`; 45 | const method = meta.method ?? "GET"; 46 | logDebug(`[Gemini Debug ${id}] ${method} ${meta.resolvedUrl}`); 47 | if (meta.originalUrl && meta.originalUrl !== meta.resolvedUrl) { 48 | logDebug(`[Gemini Debug ${id}] Original URL: ${meta.originalUrl}`); 49 | } 50 | if (meta.projectId) { 51 | logDebug(`[Gemini Debug ${id}] Project: ${meta.projectId}`); 52 | } 53 | logDebug(`[Gemini Debug ${id}] Streaming: ${meta.streaming ? "yes" : "no"}`); 54 | logDebug(`[Gemini Debug ${id}] Headers: ${JSON.stringify(maskHeaders(meta.headers))}`); 55 | const bodyPreview = formatBodyPreview(meta.body); 56 | if (bodyPreview) { 57 | logDebug(`[Gemini Debug ${id}] Body Preview: ${bodyPreview}`); 58 | } 59 | 60 | return { id, streaming: meta.streaming, startedAt: Date.now() }; 61 | } 62 | 63 | /** 64 | * Logs response details for a previously started debug trace when debugging is enabled. 65 | */ 66 | export function logGeminiDebugResponse( 67 | context: GeminiDebugContext | null | undefined, 68 | response: Response, 69 | meta: GeminiDebugResponseMeta = {}, 70 | ): void { 71 | if (!debugEnabled || !context) { 72 | return; 73 | } 74 | 75 | const durationMs = Date.now() - context.startedAt; 76 | logDebug( 77 | `[Gemini Debug ${context.id}] Response ${response.status} ${response.statusText} (${durationMs}ms)`, 78 | ); 79 | logDebug( 80 | `[Gemini Debug ${context.id}] Response Headers: ${JSON.stringify( 81 | maskHeaders(meta.headersOverride ?? response.headers), 82 | )}`, 83 | ); 84 | 85 | if (meta.note) { 86 | logDebug(`[Gemini Debug ${context.id}] Note: ${meta.note}`); 87 | } 88 | 89 | if (meta.error) { 90 | logDebug(`[Gemini Debug ${context.id}] Error: ${formatError(meta.error)}`); 91 | } 92 | 93 | if (meta.body) { 94 | logDebug( 95 | `[Gemini Debug ${context.id}] Response Body Preview: ${truncateForLog(meta.body)}`, 96 | ); 97 | } 98 | } 99 | 100 | /** 101 | * Obscures sensitive headers and returns a plain object for logging. 102 | */ 103 | function maskHeaders(headers?: HeadersInit | Headers): Record { 104 | if (!headers) { 105 | return {}; 106 | } 107 | 108 | const result: Record = {}; 109 | const parsed = headers instanceof Headers ? headers : new Headers(headers); 110 | parsed.forEach((value, key) => { 111 | if (key.toLowerCase() === "authorization") { 112 | result[key] = "[redacted]"; 113 | } else { 114 | result[key] = value; 115 | } 116 | }); 117 | return result; 118 | } 119 | 120 | /** 121 | * Produces a short, type-aware preview of a request/response body for logs. 122 | */ 123 | function formatBodyPreview(body?: BodyInit | null): string | undefined { 124 | if (body == null) { 125 | return undefined; 126 | } 127 | 128 | if (typeof body === "string") { 129 | return truncateForLog(body); 130 | } 131 | 132 | if (body instanceof URLSearchParams) { 133 | return truncateForLog(body.toString()); 134 | } 135 | 136 | if (typeof Blob !== "undefined" && body instanceof Blob) { 137 | return `[Blob size=${body.size}]`; 138 | } 139 | 140 | if (typeof FormData !== "undefined" && body instanceof FormData) { 141 | return "[FormData payload omitted]"; 142 | } 143 | 144 | return `[${body.constructor?.name ?? typeof body} payload omitted]`; 145 | } 146 | 147 | /** 148 | * Truncates long strings to a fixed preview length for logging. 149 | */ 150 | function truncateForLog(text: string): string { 151 | if (text.length <= MAX_BODY_PREVIEW_CHARS) { 152 | return text; 153 | } 154 | return `${text.slice(0, MAX_BODY_PREVIEW_CHARS)}... (truncated ${text.length - MAX_BODY_PREVIEW_CHARS} chars)`; 155 | } 156 | 157 | /** 158 | * Writes a single debug line using the configured writer. 159 | */ 160 | function logDebug(line: string): void { 161 | logWriter(line); 162 | } 163 | 164 | /** 165 | * Converts unknown error-like values into printable strings. 166 | */ 167 | function formatError(error: unknown): string { 168 | if (error instanceof Error) { 169 | return error.stack ?? error.message; 170 | } 171 | try { 172 | return JSON.stringify(error); 173 | } catch { 174 | return String(error); 175 | } 176 | } 177 | 178 | /** 179 | * Builds a timestamped log file path in the current working directory. 180 | */ 181 | function defaultLogFilePath(): string { 182 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); 183 | return join(cwd(), `gemini-debug-${timestamp}.log`); 184 | } 185 | 186 | /** 187 | * Creates a line writer that appends to a file when provided. 188 | */ 189 | function createLogWriter(filePath?: string): (line: string) => void { 190 | if (!filePath) { 191 | return () => {}; 192 | } 193 | 194 | const stream = createWriteStream(filePath, { flags: "a" }); 195 | return (line: string) => { 196 | stream.write(`${line}\n`); 197 | }; 198 | } 199 | -------------------------------------------------------------------------------- /src/plugin/request-helpers.ts: -------------------------------------------------------------------------------- 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 | * Thinking configuration accepted by Gemini. 31 | * - Gemini 3 models use thinkingLevel (string: 'low', 'medium', 'high') 32 | * - Gemini 2.5 models use thinkingBudget (number) 33 | */ 34 | export interface ThinkingConfig { 35 | thinkingBudget?: number; 36 | thinkingLevel?: string; 37 | includeThoughts?: boolean; 38 | } 39 | 40 | /** 41 | * Normalizes thinkingConfig - passes through values as-is without mapping. 42 | * User should use thinkingLevel for Gemini 3 and thinkingBudget for Gemini 2.5. 43 | */ 44 | export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined { 45 | if (!config || typeof config !== "object") { 46 | return undefined; 47 | } 48 | 49 | const record = config as Record; 50 | const budgetRaw = record.thinkingBudget ?? record.thinking_budget; 51 | const levelRaw = record.thinkingLevel ?? record.thinking_level; 52 | const includeRaw = record.includeThoughts ?? record.include_thoughts; 53 | 54 | const thinkingBudget = typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined; 55 | const thinkingLevel = typeof levelRaw === "string" && levelRaw.length > 0 ? levelRaw.toLowerCase() : undefined; 56 | const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined; 57 | 58 | if (thinkingBudget === undefined && thinkingLevel === undefined && includeThoughts === undefined) { 59 | return undefined; 60 | } 61 | 62 | const normalized: ThinkingConfig = {}; 63 | if (thinkingBudget !== undefined) { 64 | normalized.thinkingBudget = thinkingBudget; 65 | } 66 | if (thinkingLevel !== undefined) { 67 | normalized.thinkingLevel = thinkingLevel; 68 | } 69 | if (includeThoughts !== undefined) { 70 | normalized.includeThoughts = includeThoughts; 71 | } 72 | return normalized; 73 | } 74 | 75 | /** 76 | * Parses a Gemini API body; handles array-wrapped responses the API sometimes returns. 77 | */ 78 | export function parseGeminiApiBody(rawText: string): GeminiApiBody | null { 79 | try { 80 | const parsed = JSON.parse(rawText); 81 | if (Array.isArray(parsed)) { 82 | const firstObject = parsed.find((item: unknown) => typeof item === "object" && item !== null); 83 | if (firstObject && typeof firstObject === "object") { 84 | return firstObject as GeminiApiBody; 85 | } 86 | return null; 87 | } 88 | 89 | if (parsed && typeof parsed === "object") { 90 | return parsed as GeminiApiBody; 91 | } 92 | 93 | return null; 94 | } catch { 95 | return null; 96 | } 97 | } 98 | 99 | /** 100 | * Extracts usageMetadata from a response object, guarding types. 101 | */ 102 | export function extractUsageMetadata(body: GeminiApiBody): GeminiUsageMetadata | null { 103 | const usage = (body.response && typeof body.response === "object" 104 | ? (body.response as { usageMetadata?: unknown }).usageMetadata 105 | : undefined) as GeminiUsageMetadata | undefined; 106 | 107 | if (!usage || typeof usage !== "object") { 108 | return null; 109 | } 110 | 111 | const asRecord = usage as Record; 112 | const toNumber = (value: unknown): number | undefined => 113 | typeof value === "number" && Number.isFinite(value) ? value : undefined; 114 | 115 | return { 116 | totalTokenCount: toNumber(asRecord.totalTokenCount), 117 | promptTokenCount: toNumber(asRecord.promptTokenCount), 118 | candidatesTokenCount: toNumber(asRecord.candidatesTokenCount), 119 | cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount), 120 | }; 121 | } 122 | 123 | /** 124 | * Walks SSE lines to find a usage-bearing response chunk. 125 | */ 126 | export function extractUsageFromSsePayload(payload: string): GeminiUsageMetadata | null { 127 | const lines = payload.split("\n"); 128 | for (const line of lines) { 129 | if (!line.startsWith("data:")) { 130 | continue; 131 | } 132 | const jsonText = line.slice(5).trim(); 133 | if (!jsonText) { 134 | continue; 135 | } 136 | try { 137 | const parsed = JSON.parse(jsonText); 138 | if (parsed && typeof parsed === "object") { 139 | const usage = extractUsageMetadata({ response: (parsed as Record).response }); 140 | if (usage) { 141 | return usage; 142 | } 143 | } 144 | } catch { 145 | continue; 146 | } 147 | } 148 | return null; 149 | } 150 | 151 | /** 152 | * Enhances 404 errors for Gemini 3 models with a direct preview-access message. 153 | */ 154 | export function rewriteGeminiPreviewAccessError( 155 | body: GeminiApiBody, 156 | status: number, 157 | requestedModel?: string, 158 | ): GeminiApiBody | null { 159 | if (!needsPreviewAccessOverride(status, body, requestedModel)) { 160 | return null; 161 | } 162 | 163 | const error: GeminiApiError = body.error ?? {}; 164 | const trimmedMessage = typeof error.message === "string" ? error.message.trim() : ""; 165 | const messagePrefix = trimmedMessage.length > 0 166 | ? trimmedMessage 167 | : "Gemini 3 preview features are not enabled for this account."; 168 | const enhancedMessage = `${messagePrefix} Request preview access at ${GEMINI_PREVIEW_LINK} before using Gemini 3 models.`; 169 | 170 | return { 171 | ...body, 172 | error: { 173 | ...error, 174 | message: enhancedMessage, 175 | }, 176 | }; 177 | } 178 | 179 | function needsPreviewAccessOverride( 180 | status: number, 181 | body: GeminiApiBody, 182 | requestedModel?: string, 183 | ): boolean { 184 | if (status !== 404) { 185 | return false; 186 | } 187 | 188 | if (isGeminiThreeModel(requestedModel)) { 189 | return true; 190 | } 191 | 192 | const errorMessage = typeof body.error?.message === "string" ? body.error.message : ""; 193 | return isGeminiThreeModel(errorMessage); 194 | } 195 | 196 | function isGeminiThreeModel(target?: string): boolean { 197 | if (!target) { 198 | return false; 199 | } 200 | 201 | return /gemini[\s-]?3/i.test(target); 202 | } 203 | -------------------------------------------------------------------------------- /src/plugin/server.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "node:http"; 2 | 3 | import { GEMINI_REDIRECT_URI } from "../constants"; 4 | 5 | interface OAuthListenerOptions { 6 | /** 7 | * How long to wait for the OAuth redirect before timing out (in milliseconds). 8 | */ 9 | timeoutMs?: number; 10 | } 11 | 12 | export interface OAuthListener { 13 | /** 14 | * Resolves with the callback URL once Google redirects back to the local server. 15 | */ 16 | waitForCallback(): Promise; 17 | /** 18 | * Cleanly stop listening for callbacks. 19 | */ 20 | close(): Promise; 21 | } 22 | 23 | const redirectUri = new URL(GEMINI_REDIRECT_URI); 24 | const callbackPath = redirectUri.pathname || "/"; 25 | 26 | /** 27 | * Starts a lightweight HTTP server that listens for the Gemini OAuth redirect 28 | * and resolves with the captured callback URL. 29 | */ 30 | export async function startOAuthListener( 31 | { timeoutMs = 5 * 60 * 1000 }: OAuthListenerOptions = {}, 32 | ): Promise { 33 | const port = redirectUri.port 34 | ? Number.parseInt(redirectUri.port, 10) 35 | : redirectUri.protocol === "https:" 36 | ? 443 37 | : 80; 38 | const origin = `${redirectUri.protocol}//${redirectUri.host}`; 39 | 40 | let settled = false; 41 | let resolveCallback: (url: URL) => void; 42 | let rejectCallback: (error: Error) => void; 43 | const callbackPromise = new Promise((resolve, reject) => { 44 | resolveCallback = (url: URL) => { 45 | if (settled) return; 46 | settled = true; 47 | if (timeoutHandle) clearTimeout(timeoutHandle); 48 | resolve(url); 49 | }; 50 | rejectCallback = (error: Error) => { 51 | if (settled) return; 52 | settled = true; 53 | if (timeoutHandle) clearTimeout(timeoutHandle); 54 | reject(error); 55 | }; 56 | }); 57 | 58 | const successResponse = ` 59 | 60 | 61 | 62 | Opencode Gemini OAuth 63 | 162 | 163 | 164 |
165 |
166 | 175 | Gemini linked to Opencode 176 |
177 |

You're connected to Opencode

178 |

Your Google account is now linked to Opencode. You can close this window and continue in the CLI.

179 | Close window 180 |

Need to reconnect later? Re-run the authentication command in Opencode.

181 |
182 | 183 | `; 184 | 185 | const timeoutHandle = setTimeout(() => { 186 | rejectCallback(new Error("Timed out waiting for OAuth callback")); 187 | }, timeoutMs); 188 | timeoutHandle.unref?.(); 189 | 190 | const server = createServer((request, response) => { 191 | if (!request.url) { 192 | response.writeHead(400, { "Content-Type": "text/plain" }); 193 | response.end("Invalid request"); 194 | return; 195 | } 196 | 197 | const url = new URL(request.url, origin); 198 | if (url.pathname !== callbackPath) { 199 | response.writeHead(404, { "Content-Type": "text/plain" }); 200 | response.end("Not found"); 201 | return; 202 | } 203 | 204 | response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); 205 | response.end(successResponse); 206 | 207 | resolveCallback(url); 208 | 209 | setImmediate(() => { 210 | server.close(); 211 | }); 212 | }); 213 | 214 | await new Promise((resolve, reject) => { 215 | const handleError = (error: Error) => { 216 | server.off("error", handleError); 217 | reject(error); 218 | }; 219 | server.once("error", handleError); 220 | server.listen(port, "127.0.0.1", () => { 221 | server.off("error", handleError); 222 | resolve(); 223 | }); 224 | }); 225 | 226 | server.on("error", (error) => { 227 | rejectCallback(error instanceof Error ? error : new Error(String(error))); 228 | }); 229 | 230 | return { 231 | waitForCallback: () => callbackPromise, 232 | close: () => 233 | new Promise((resolve, reject) => { 234 | server.close((error) => { 235 | if (error && (error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") { 236 | reject(error); 237 | return; 238 | } 239 | if (!settled) { 240 | rejectCallback(new Error("OAuth listener closed before callback")); 241 | } 242 | resolve(); 243 | }); 244 | }), 245 | }; 246 | } 247 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | 3 | import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants"; 4 | import { authorizeGemini, exchangeGemini } from "./gemini/oauth"; 5 | import type { GeminiTokenExchangeResult } from "./gemini/oauth"; 6 | import { accessTokenExpired, isOAuthAuth } from "./plugin/auth"; 7 | import { ensureProjectContext } from "./plugin/project"; 8 | import { startGeminiDebugRequest } from "./plugin/debug"; 9 | import { 10 | isGenerativeLanguageRequest, 11 | prepareGeminiRequest, 12 | transformGeminiResponse, 13 | } from "./plugin/request"; 14 | import { refreshAccessToken } from "./plugin/token"; 15 | import { startOAuthListener, type OAuthListener } from "./plugin/server"; 16 | import type { 17 | GetAuth, 18 | LoaderResult, 19 | PluginContext, 20 | PluginResult, 21 | ProjectContextResult, 22 | Provider, 23 | } from "./plugin/types"; 24 | 25 | /** 26 | * Registers the Gemini OAuth provider for Opencode, handling auth, request rewriting, 27 | * debug logging, and response normalization for Gemini Code Assist endpoints. 28 | */ 29 | export const GeminiCLIOAuthPlugin = async ( 30 | { client }: PluginContext, 31 | ): Promise => ({ 32 | auth: { 33 | provider: GEMINI_PROVIDER_ID, 34 | loader: async (getAuth: GetAuth, provider: Provider): Promise => { 35 | const auth = await getAuth(); 36 | if (!isOAuthAuth(auth)) { 37 | return null; 38 | } 39 | 40 | const providerOptions = 41 | provider && typeof provider === "object" 42 | ? ((provider as { options?: Record }).options ?? undefined) 43 | : undefined; 44 | const projectIdFromConfig = 45 | providerOptions && typeof providerOptions.projectId === "string" 46 | ? providerOptions.projectId.trim() 47 | : ""; 48 | const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? ""; 49 | const configuredProjectId = projectIdFromEnv || projectIdFromConfig || undefined; 50 | 51 | if (provider.models) { 52 | for (const model of Object.values(provider.models)) { 53 | if (model) { 54 | model.cost = { input: 0, output: 0 }; 55 | } 56 | } 57 | } 58 | 59 | return { 60 | apiKey: "", 61 | async fetch(input, init) { 62 | if (!isGenerativeLanguageRequest(input)) { 63 | return fetch(input, init); 64 | } 65 | 66 | const latestAuth = await getAuth(); 67 | if (!isOAuthAuth(latestAuth)) { 68 | return fetch(input, init); 69 | } 70 | 71 | let authRecord = latestAuth; 72 | if (accessTokenExpired(authRecord)) { 73 | const refreshed = await refreshAccessToken(authRecord, client); 74 | if (!refreshed) { 75 | return fetch(input, init); 76 | } 77 | authRecord = refreshed; 78 | } 79 | 80 | const accessToken = authRecord.access; 81 | if (!accessToken) { 82 | return fetch(input, init); 83 | } 84 | 85 | /** 86 | * Ensures we have a usable project context for the current auth snapshot. 87 | */ 88 | async function resolveProjectContext(): Promise { 89 | try { 90 | return await ensureProjectContext(authRecord, client, configuredProjectId); 91 | } catch (error) { 92 | if (error instanceof Error) { 93 | console.error(error.message); 94 | } 95 | throw error; 96 | } 97 | } 98 | 99 | const projectContext = await resolveProjectContext(); 100 | 101 | const { 102 | request, 103 | init: transformedInit, 104 | streaming, 105 | requestedModel, 106 | } = prepareGeminiRequest( 107 | input, 108 | init, 109 | accessToken, 110 | projectContext.effectiveProjectId, 111 | ); 112 | 113 | const originalUrl = toUrlString(input); 114 | const resolvedUrl = toUrlString(request); 115 | const debugContext = startGeminiDebugRequest({ 116 | originalUrl, 117 | resolvedUrl, 118 | method: transformedInit.method, 119 | headers: transformedInit.headers, 120 | body: transformedInit.body, 121 | streaming, 122 | projectId: projectContext.effectiveProjectId, 123 | }); 124 | 125 | const response = await fetch(request, transformedInit); 126 | return transformGeminiResponse(response, streaming, debugContext, requestedModel); 127 | }, 128 | }; 129 | }, 130 | methods: [ 131 | { 132 | label: "OAuth with Google (Gemini CLI)", 133 | type: "oauth", 134 | authorize: async () => { 135 | const isHeadless = !!( 136 | process.env.SSH_CONNECTION || 137 | process.env.SSH_CLIENT || 138 | process.env.SSH_TTY || 139 | process.env.OPENCODE_HEADLESS 140 | ); 141 | 142 | let listener: OAuthListener | null = null; 143 | if (!isHeadless) { 144 | try { 145 | listener = await startOAuthListener(); 146 | } catch (error) { 147 | if (error instanceof Error) { 148 | console.log( 149 | `Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL.`, 150 | ); 151 | } else { 152 | console.log( 153 | "Warning: Couldn't start the local callback listener. You'll need to paste the callback URL.", 154 | ); 155 | } 156 | } 157 | } else { 158 | console.log("Headless environment detected. You'll need to paste the callback URL."); 159 | } 160 | 161 | const authorization = await authorizeGemini(); 162 | if (!isHeadless) { 163 | openBrowserUrl(authorization.url); 164 | } 165 | 166 | if (listener) { 167 | return { 168 | url: authorization.url, 169 | instructions: 170 | "Complete the sign-in flow in your browser. We'll automatically detect the redirect back to localhost.", 171 | method: "auto", 172 | callback: async (): Promise => { 173 | try { 174 | const callbackUrl = await listener.waitForCallback(); 175 | const code = callbackUrl.searchParams.get("code"); 176 | const state = callbackUrl.searchParams.get("state"); 177 | 178 | if (!code || !state) { 179 | return { 180 | type: "failed", 181 | error: "Missing code or state in callback URL", 182 | }; 183 | } 184 | 185 | return await exchangeGemini(code, state); 186 | } catch (error) { 187 | return { 188 | type: "failed", 189 | error: error instanceof Error ? error.message : "Unknown error", 190 | }; 191 | } finally { 192 | try { 193 | await listener?.close(); 194 | } catch { 195 | } 196 | } 197 | }, 198 | }; 199 | } 200 | 201 | return { 202 | url: authorization.url, 203 | instructions: 204 | "Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...)", 205 | method: "code", 206 | callback: async (callbackUrl: string): Promise => { 207 | try { 208 | const url = new URL(callbackUrl); 209 | const code = url.searchParams.get("code"); 210 | const state = url.searchParams.get("state"); 211 | 212 | if (!code || !state) { 213 | return { 214 | type: "failed", 215 | error: "Missing code or state in callback URL", 216 | }; 217 | } 218 | 219 | return exchangeGemini(code, state); 220 | } catch (error) { 221 | return { 222 | type: "failed", 223 | error: error instanceof Error ? error.message : "Unknown error", 224 | }; 225 | } 226 | }, 227 | }; 228 | }, 229 | }, 230 | { 231 | provider: GEMINI_PROVIDER_ID, 232 | label: "Manually enter API Key", 233 | type: "api", 234 | }, 235 | ], 236 | }, 237 | }); 238 | 239 | export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin; 240 | 241 | function toUrlString(value: RequestInfo): string { 242 | if (typeof value === "string") { 243 | return value; 244 | } 245 | const candidate = (value as Request).url; 246 | if (candidate) { 247 | return candidate; 248 | } 249 | return value.toString(); 250 | } 251 | 252 | function openBrowserUrl(url: string): void { 253 | try { 254 | // Best-effort: don't block auth flow if spawning fails. 255 | const platform = process.platform; 256 | const command = 257 | platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; 258 | const args = 259 | platform === "win32" ? ["/c", "start", "", url] : [url]; 260 | const child = spawn(command, args, { 261 | stdio: "ignore", 262 | detached: true, 263 | }); 264 | child.unref?.(); 265 | } catch { 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/plugin/project.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CODE_ASSIST_HEADERS, 3 | GEMINI_CODE_ASSIST_ENDPOINT, 4 | GEMINI_PROVIDER_ID, 5 | } from "../constants"; 6 | import { formatRefreshParts, parseRefreshParts } from "./auth"; 7 | import type { 8 | OAuthAuthDetails, 9 | PluginClient, 10 | ProjectContextResult, 11 | } from "./types"; 12 | 13 | const projectContextResultCache = new Map(); 14 | const projectContextPendingCache = new Map>(); 15 | 16 | const CODE_ASSIST_METADATA = { 17 | ideType: "IDE_UNSPECIFIED", 18 | platform: "PLATFORM_UNSPECIFIED", 19 | pluginType: "GEMINI", 20 | } as const; 21 | 22 | interface GeminiUserTier { 23 | id?: string; 24 | isDefault?: boolean; 25 | userDefinedCloudaicompanionProject?: boolean; 26 | } 27 | 28 | interface LoadCodeAssistPayload { 29 | cloudaicompanionProject?: string; 30 | currentTier?: { 31 | id?: string; 32 | }; 33 | allowedTiers?: GeminiUserTier[]; 34 | } 35 | 36 | interface OnboardUserPayload { 37 | done?: boolean; 38 | response?: { 39 | cloudaicompanionProject?: { 40 | id?: string; 41 | }; 42 | }; 43 | } 44 | 45 | class ProjectIdRequiredError extends Error { 46 | /** 47 | * Error raised when a required Google Cloud project is missing during Gemini onboarding. 48 | */ 49 | constructor() { 50 | super( 51 | "Google Gemini requires a Google Cloud project. Enable the Gemini for Google Cloud API on a project you control, then set `provider.google.options.projectId` in your Opencode config (or set OPENCODE_GEMINI_PROJECT_ID).", 52 | ); 53 | } 54 | } 55 | 56 | /** 57 | * Builds metadata headers required by the Code Assist API. 58 | */ 59 | function buildMetadata(projectId?: string): Record { 60 | const metadata: Record = { 61 | ideType: CODE_ASSIST_METADATA.ideType, 62 | platform: CODE_ASSIST_METADATA.platform, 63 | pluginType: CODE_ASSIST_METADATA.pluginType, 64 | }; 65 | if (projectId) { 66 | metadata.duetProject = projectId; 67 | } 68 | return metadata; 69 | } 70 | 71 | /** 72 | * Selects the default tier ID from the allowed tiers list. 73 | */ 74 | function getDefaultTierId(allowedTiers?: GeminiUserTier[]): string | undefined { 75 | if (!allowedTiers || allowedTiers.length === 0) { 76 | return undefined; 77 | } 78 | for (const tier of allowedTiers) { 79 | if (tier?.isDefault) { 80 | return tier.id; 81 | } 82 | } 83 | return allowedTiers[0]?.id; 84 | } 85 | 86 | /** 87 | * Promise-based delay utility. 88 | */ 89 | function wait(ms: number): Promise { 90 | return new Promise(function (resolve) { 91 | setTimeout(resolve, ms); 92 | }); 93 | } 94 | 95 | /** 96 | * Generates a cache key for project context based on refresh token. 97 | */ 98 | function getCacheKey(auth: OAuthAuthDetails): string | undefined { 99 | const refresh = auth.refresh?.trim(); 100 | return refresh ? refresh : undefined; 101 | } 102 | 103 | /** 104 | * Clears cached project context results and pending promises, globally or for a refresh key. 105 | */ 106 | export function invalidateProjectContextCache(refresh?: string): void { 107 | if (!refresh) { 108 | projectContextPendingCache.clear(); 109 | projectContextResultCache.clear(); 110 | return; 111 | } 112 | 113 | projectContextPendingCache.delete(refresh); 114 | projectContextResultCache.delete(refresh); 115 | 116 | const prefix = `${refresh}|cfg:`; 117 | for (const key of projectContextPendingCache.keys()) { 118 | if (key.startsWith(prefix)) { 119 | projectContextPendingCache.delete(key); 120 | } 121 | } 122 | for (const key of projectContextResultCache.keys()) { 123 | if (key.startsWith(prefix)) { 124 | projectContextResultCache.delete(key); 125 | } 126 | } 127 | } 128 | 129 | /** 130 | * Loads managed project information for the given access token and optional project. 131 | */ 132 | export async function loadManagedProject( 133 | accessToken: string, 134 | projectId?: string, 135 | ): Promise { 136 | try { 137 | const metadata = buildMetadata(projectId); 138 | 139 | const requestBody: Record = { metadata }; 140 | if (projectId) { 141 | requestBody.cloudaicompanionProject = projectId; 142 | } 143 | 144 | const response = await fetch( 145 | `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, 146 | { 147 | method: "POST", 148 | headers: { 149 | "Content-Type": "application/json", 150 | Authorization: `Bearer ${accessToken}`, 151 | ...CODE_ASSIST_HEADERS, 152 | }, 153 | body: JSON.stringify(requestBody), 154 | }, 155 | ); 156 | 157 | if (!response.ok) { 158 | return null; 159 | } 160 | 161 | return (await response.json()) as LoadCodeAssistPayload; 162 | } catch (error) { 163 | console.error("Failed to load Gemini managed project:", error); 164 | return null; 165 | } 166 | } 167 | 168 | 169 | /** 170 | * Onboards a managed project for the user, optionally retrying until completion. 171 | */ 172 | export async function onboardManagedProject( 173 | accessToken: string, 174 | tierId: string, 175 | projectId?: string, 176 | attempts = 10, 177 | delayMs = 5000, 178 | ): Promise { 179 | const metadata = buildMetadata(projectId); 180 | const requestBody: Record = { 181 | tierId, 182 | metadata, 183 | }; 184 | 185 | if (tierId !== "FREE") { 186 | if (!projectId) { 187 | throw new ProjectIdRequiredError(); 188 | } 189 | requestBody.cloudaicompanionProject = projectId; 190 | } 191 | 192 | for (let attempt = 0; attempt < attempts; attempt += 1) { 193 | try { 194 | const response = await fetch( 195 | `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, 196 | { 197 | method: "POST", 198 | headers: { 199 | "Content-Type": "application/json", 200 | Authorization: `Bearer ${accessToken}`, 201 | ...CODE_ASSIST_HEADERS, 202 | }, 203 | body: JSON.stringify(requestBody), 204 | }, 205 | ); 206 | 207 | if (!response.ok) { 208 | return undefined; 209 | } 210 | 211 | const payload = (await response.json()) as OnboardUserPayload; 212 | const managedProjectId = payload.response?.cloudaicompanionProject?.id; 213 | if (payload.done && managedProjectId) { 214 | return managedProjectId; 215 | } 216 | if (payload.done && projectId) { 217 | return projectId; 218 | } 219 | } catch (error) { 220 | console.error("Failed to onboard Gemini managed project:", error); 221 | return undefined; 222 | } 223 | 224 | await wait(delayMs); 225 | } 226 | 227 | return undefined; 228 | } 229 | 230 | /** 231 | * Resolves an effective project ID for the current auth state, caching results per refresh token. 232 | */ 233 | export async function ensureProjectContext( 234 | auth: OAuthAuthDetails, 235 | client: PluginClient, 236 | configuredProjectId?: string, 237 | ): Promise { 238 | const accessToken = auth.access; 239 | if (!accessToken) { 240 | return { auth, effectiveProjectId: "" }; 241 | } 242 | 243 | const cacheKey = (() => { 244 | const base = getCacheKey(auth); 245 | if (!base) return undefined; 246 | const project = configuredProjectId?.trim() ?? ""; 247 | return project ? `${base}|cfg:${project}` : base; 248 | })(); 249 | if (cacheKey) { 250 | const cached = projectContextResultCache.get(cacheKey); 251 | if (cached) { 252 | return cached; 253 | } 254 | const pending = projectContextPendingCache.get(cacheKey); 255 | if (pending) { 256 | return pending; 257 | } 258 | } 259 | 260 | const resolveContext = async (): Promise => { 261 | const parts = parseRefreshParts(auth.refresh); 262 | const effectiveConfiguredProjectId = configuredProjectId?.trim() || undefined; 263 | const projectId = effectiveConfiguredProjectId ?? parts.projectId; 264 | 265 | if (projectId || parts.managedProjectId) { 266 | return { 267 | auth, 268 | effectiveProjectId: projectId || parts.managedProjectId || "", 269 | }; 270 | } 271 | 272 | const loadPayload = await loadManagedProject(accessToken, projectId); 273 | if (loadPayload?.cloudaicompanionProject) { 274 | const managedProjectId = loadPayload.cloudaicompanionProject; 275 | const updatedAuth: OAuthAuthDetails = { 276 | ...auth, 277 | refresh: formatRefreshParts({ 278 | refreshToken: parts.refreshToken, 279 | projectId, 280 | managedProjectId, 281 | }), 282 | }; 283 | 284 | await client.auth.set({ 285 | path: { id: GEMINI_PROVIDER_ID }, 286 | body: updatedAuth, 287 | }); 288 | 289 | return { auth: updatedAuth, effectiveProjectId: managedProjectId }; 290 | } 291 | 292 | if (!loadPayload) { 293 | throw new ProjectIdRequiredError(); 294 | } 295 | 296 | const currentTierId = loadPayload.currentTier?.id ?? undefined; 297 | if (currentTierId && currentTierId !== "FREE") { 298 | throw new ProjectIdRequiredError(); 299 | } 300 | 301 | const defaultTierId = getDefaultTierId(loadPayload.allowedTiers); 302 | const tierId = defaultTierId ?? "FREE"; 303 | 304 | if (tierId !== "FREE") { 305 | throw new ProjectIdRequiredError(); 306 | } 307 | 308 | const managedProjectId = await onboardManagedProject(accessToken, tierId, projectId); 309 | if (managedProjectId) { 310 | const updatedAuth: OAuthAuthDetails = { 311 | ...auth, 312 | refresh: formatRefreshParts({ 313 | refreshToken: parts.refreshToken, 314 | projectId, 315 | managedProjectId, 316 | }), 317 | }; 318 | 319 | await client.auth.set({ 320 | path: { id: GEMINI_PROVIDER_ID }, 321 | body: updatedAuth, 322 | }); 323 | 324 | return { auth: updatedAuth, effectiveProjectId: managedProjectId }; 325 | } 326 | 327 | throw new ProjectIdRequiredError(); 328 | }; 329 | 330 | if (!cacheKey) { 331 | return resolveContext(); 332 | } 333 | 334 | const promise = resolveContext() 335 | .then((result) => { 336 | const nextKey = getCacheKey(result.auth) ?? cacheKey; 337 | projectContextPendingCache.delete(cacheKey); 338 | projectContextResultCache.set(nextKey, result); 339 | if (nextKey !== cacheKey) { 340 | projectContextResultCache.delete(cacheKey); 341 | } 342 | return result; 343 | }) 344 | .catch((error) => { 345 | projectContextPendingCache.delete(cacheKey); 346 | throw error; 347 | }); 348 | 349 | projectContextPendingCache.set(cacheKey, promise); 350 | return promise; 351 | } 352 | -------------------------------------------------------------------------------- /src/plugin/request.ts: -------------------------------------------------------------------------------- 1 | import { CODE_ASSIST_HEADERS, GEMINI_CODE_ASSIST_ENDPOINT } from "../constants"; 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"; 12 | 13 | const STREAM_ACTION = "streamGenerateContent"; 14 | const MODEL_FALLBACKS: Record = { 15 | "gemini-2.5-flash-image": "gemini-2.5-flash", 16 | }; 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 | */ 22 | export function isGenerativeLanguageRequest(input: RequestInfo): input is string { 23 | return typeof input === "string" && input.includes("generativelanguage.googleapis.com"); 24 | } 25 | 26 | /** 27 | * Rewrites SSE payloads so downstream consumers see only the inner `response` objects. 28 | */ 29 | function transformStreamingPayload(payload: string): string { 30 | return payload 31 | .split("\n") 32 | .map((line) => { 33 | if (!line.startsWith("data:")) { 34 | return line; 35 | } 36 | const json = line.slice(5).trim(); 37 | if (!json) { 38 | return line; 39 | } 40 | try { 41 | const parsed = JSON.parse(json) as { response?: unknown }; 42 | if (parsed.response !== undefined) { 43 | return `data: ${JSON.stringify(parsed.response)}`; 44 | } 45 | } catch (_) {} 46 | return line; 47 | }) 48 | .join("\n"); 49 | } 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 | */ 55 | export function prepareGeminiRequest( 56 | input: RequestInfo, 57 | init: RequestInit | undefined, 58 | accessToken: string, 59 | projectId: string, 60 | ): { request: RequestInfo; init: RequestInit; streaming: boolean; requestedModel?: string } { 61 | const baseInit: RequestInit = { ...init }; 62 | const headers = new Headers(init?.headers ?? {}); 63 | 64 | if (!isGenerativeLanguageRequest(input)) { 65 | return { 66 | request: input, 67 | init: { ...baseInit, headers }, 68 | streaming: false, 69 | }; 70 | } 71 | 72 | headers.set("Authorization", `Bearer ${accessToken}`); 73 | headers.delete("x-api-key"); 74 | 75 | const match = input.match(/\/models\/([^:]+):(\w+)/); 76 | if (!match) { 77 | return { 78 | request: input, 79 | init: { ...baseInit, headers }, 80 | streaming: false, 81 | }; 82 | } 83 | 84 | const [, rawModel = "", rawAction = ""] = match; 85 | const effectiveModel = MODEL_FALLBACKS[rawModel] ?? rawModel; 86 | const streaming = rawAction === STREAM_ACTION; 87 | const transformedUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:${rawAction}${ 88 | streaming ? "?alt=sse" : "" 89 | }`; 90 | 91 | let body = baseInit.body; 92 | if (typeof baseInit.body === "string" && baseInit.body) { 93 | try { 94 | const parsedBody = JSON.parse(baseInit.body) as Record; 95 | const isWrapped = typeof parsedBody.project === "string" && "request" in parsedBody; 96 | 97 | if (isWrapped) { 98 | const wrappedBody = { 99 | ...parsedBody, 100 | model: effectiveModel, 101 | } as Record; 102 | body = JSON.stringify(wrappedBody); 103 | } else { 104 | const requestPayload: Record = { ...parsedBody }; 105 | 106 | const rawGenerationConfig = requestPayload.generationConfig as Record | 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 | 120 | if ("system_instruction" in requestPayload) { 121 | requestPayload.systemInstruction = requestPayload.system_instruction; 122 | delete requestPayload.system_instruction; 123 | } 124 | 125 | const cachedContentFromExtra = 126 | typeof requestPayload.extra_body === "object" && requestPayload.extra_body 127 | ? (requestPayload.extra_body as Record).cached_content ?? 128 | (requestPayload.extra_body as Record).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).cached_content; 142 | delete (requestPayload.extra_body as Record).cachedContent; 143 | if (Object.keys(requestPayload.extra_body as Record).length === 0) { 144 | delete requestPayload.extra_body; 145 | } 146 | } 147 | 148 | if ("model" in requestPayload) { 149 | delete requestPayload.model; 150 | } 151 | 152 | const wrappedBody = { 153 | project: projectId, 154 | model: effectiveModel, 155 | request: requestPayload, 156 | }; 157 | 158 | body = JSON.stringify(wrappedBody); 159 | } 160 | } catch (error) { 161 | console.error("Failed to transform Gemini request body:", error); 162 | } 163 | } 164 | 165 | if (streaming) { 166 | headers.set("Accept", "text/event-stream"); 167 | } 168 | 169 | headers.set("User-Agent", CODE_ASSIST_HEADERS["User-Agent"]); 170 | headers.set("X-Goog-Api-Client", CODE_ASSIST_HEADERS["X-Goog-Api-Client"]); 171 | headers.set("Client-Metadata", CODE_ASSIST_HEADERS["Client-Metadata"]); 172 | 173 | return { 174 | request: transformedUrl, 175 | init: { 176 | ...baseInit, 177 | headers, 178 | body, 179 | }, 180 | streaming, 181 | requestedModel: rawModel, 182 | }; 183 | } 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 | */ 189 | export async function transformGeminiResponse( 190 | response: Response, 191 | streaming: boolean, 192 | debugContext?: GeminiDebugContext | null, 193 | requestedModel?: string, 194 | ): Promise { 195 | const contentType = response.headers.get("content-type") ?? ""; 196 | const isJsonResponse = contentType.includes("application/json"); 197 | const isEventStreamResponse = contentType.includes("text/event-stream"); 198 | 199 | if (!isJsonResponse && !isEventStreamResponse) { 200 | logGeminiDebugResponse(debugContext, response, { 201 | note: "Non-JSON response (body omitted)", 202 | }); 203 | return response; 204 | } 205 | 206 | try { 207 | const text = await response.text(); 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 | 235 | const init = { 236 | status: response.status, 237 | statusText: response.statusText, 238 | headers, 239 | }; 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 | 260 | logGeminiDebugResponse(debugContext, response, { 261 | body: text, 262 | note: streaming ? "Streaming SSE payload" : undefined, 263 | headersOverride: headers, 264 | }); 265 | 266 | if (streaming && response.ok && isEventStreamResponse) { 267 | return new Response(transformStreamingPayload(text), init); 268 | } 269 | 270 | if (!parsed) { 271 | return new Response(text, init); 272 | } 273 | 274 | if (effectiveBody?.response !== undefined) { 275 | return new Response(JSON.stringify(effectiveBody.response), init); 276 | } 277 | 278 | if (patched) { 279 | return new Response(JSON.stringify(patched), init); 280 | } 281 | 282 | return new Response(text, init); 283 | } catch (error) { 284 | logGeminiDebugResponse(debugContext, response, { 285 | error, 286 | note: "Failed to transform Gemini response", 287 | }); 288 | console.error("Failed to transform Gemini response:", error); 289 | return response; 290 | } 291 | } 292 | --------------------------------------------------------------------------------