Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 48 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,23 @@
> Deprecation details: https://developers.google.com/gemini-code-assist/docs/deprecations/code-assist-individuals
> Policy discussion: https://github.com/google-gemini/gemini-cli/discussions/22970

**Authenticate the Opencode CLI with your Google account.** This plugin enables
you to use your existing Gemini plan and quotas (including the free tier)
directly within Opencode.
**Authenticate the Opencode CLI with an eligible organization-backed Google
account.** Gemini CLI OAuth is now limited to Gemini Code Assist Standard and
Enterprise subscriptions. Personal plans and the free tier must use the native
Gemini API-key flow instead of this plugin.

## Prerequisites

- [Opencode CLI](https://opencode.ai) installed.
- A Google account with access to Gemini.
- A Gemini Code Assist Standard or Enterprise subscription. Consumer Google
accounts are no longer supported by this OAuth flow.

## Installation

Add the plugin to your Opencode configuration file
(`~/.config/opencode/opencode.json` or similar):
Add the plugin to your OpenCode configuration file
(`~/.config/opencode/opencode.json` or similar).

OpenCode V1:

```json
{
Expand All @@ -40,6 +44,15 @@ Add the plugin to your Opencode configuration file
}
```

OpenCode V2:

```json
{
"$schema": "https://opencode.ai/config.json",
"plugins": ["opencode-gemini-auth@latest"]
}
```

> [!IMPORTANT]
> Explicitly configure a Google Cloud `projectId` if you're using an
> organization-backed Gemini Code Assist subscription
Expand All @@ -48,13 +61,21 @@ Add the plugin to your Opencode configuration file
> a Gemini Code Assist subscription tier. You can still set `projectId` to
> force a specific project.

## Usage
### OpenCode V2 Compatibility

1. **Login**: Run the authentication command in your terminal:
OpenCode V2 loads the package's native `./server` entrypoint. It registers the
Gemini CLI OAuth method through `integration.transform` and rewrites Google
provider requests and responses through provider-scoped V2 session HTTP hooks.
The entrypoint follows the current `Plugin.define` contract from
`@opencode-ai/plugin@beta`.

```bash
opencode auth login
```
The V1 entrypoint remains unchanged. The V2 entrypoint currently covers login
and model requests; the `/gquota` command, quota tool, retry transport, and TUI
capacity notifications remain V1-only.

## Usage

1. **Login**: Run `opencode auth login` on V1 or `opencode2 auth login` on V2.

2. **Select Provider**: Choose **Google** from the list.
3. **Authenticate**: Select **OAuth with Google (Gemini CLI)**.
Expand All @@ -65,7 +86,7 @@ Add the plugin to your Opencode configuration file

Once authenticated, Opencode will use your Google account for Gemini requests.

To check your current Gemini Code Assist quota buckets at any time, run:
On V1, check your current Gemini Code Assist quota buckets with:

```bash
/gquota
Expand All @@ -79,7 +100,7 @@ By default, the plugin attempts to provision or find a suitable Google Cloud
project. To force a specific project, set the `projectId` in your configuration
or via environment variables:

**File:** `~/.config/opencode/opencode.json`
OpenCode V1:

```json
{
Expand All @@ -93,6 +114,20 @@ or via environment variables:
}
```

OpenCode V2:

```json
{
"providers": {
"google": {
"settings": {
"projectId": "your-specific-project-id"
}
}
}
}
```

You can also set `OPENCODE_GEMINI_PROJECT_ID`, `GOOGLE_CLOUD_PROJECT`, or
`GOOGLE_CLOUD_PROJECT_ID` to supply the project ID via environment variables.

Expand Down
206 changes: 203 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js",
"default": "./dist/server.js"
}
},
"files": [
Expand All @@ -22,7 +27,7 @@
"type": "module",
"scripts": {
"build": "tsup",
"smoke:node-import": "node -e \"import(require.resolve('opencode-gemini-auth')).then(() => console.log('ok')).catch((error) => { console.error(error); process.exit(1); })\"",
"smoke:node-import": "node -e \"Promise.all([import('opencode-gemini-auth'), import('opencode-gemini-auth/server')]).then(() => console.log('ok')).catch((error) => { console.error(error); process.exit(1); })\"",
"prepack": "bun run build && bun run smoke:node-import",
"prepublishOnly": "bun test && bun run prepack",
"update:gemini-cli": "git -C .local/gemini-cli pull --ff-only",
Expand All @@ -35,7 +40,7 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@opencode-ai/plugin": "^1.2.20",
"@opencode-ai/plugin": "beta",
"@openauthjs/openauth": "^0.4.3"
}
}
1 change: 1 addition & 0 deletions server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./src/plugin-v2";
62 changes: 62 additions & 0 deletions src/plugin-v2.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";

import plugin, { setupV2 } from "./plugin-v2";

test("V2 plugin registers OAuth and rewrites Gemini requests and responses", async () => {
let method: any;
const hooks: Record<string, (event: any) => Promise<void>> = {};
const credential = {
type: "oauth",
methodID: "gemini-cli",
refresh: "refresh-token||managed-project",
access: "access-token",
expires: Date.now() + 60_000,
};

const context = {
catalog: {
provider: { async get() { return { data: { settings: {} } }; } },
},
integration: {
async transform(callback: (draft: any) => void) {
callback({ method: { update(input: any) { method = input; } } });
},
connection: {
async active() { return { type: "credential" }; },
async resolve() { return credential; },
},
},
session: {
async hook(name: string, callback: (event: any) => Promise<void>) { hooks[name] = callback; },
},
};
await setupV2(context as unknown as Parameters<typeof setupV2>[0]);

expect(plugin.id).toBe("opencode.provider.google-gemini-cli");
expect(plugin.setup).toBe(setupV2);
expect(method.integrationID).toBe("google");
expect(method.method.id).toBe("gemini-cli");

const event = {
model: { providerID: "google", id: "gemini-2.5-pro" },
request: new Request(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse",
{ method: "POST", body: JSON.stringify({ contents: [] }) },
),
};
await hooks["http.request"]!(event);

expect(event.request.url).toContain("cloudcode-pa.googleapis.com/v1internal:streamGenerateContent");
expect(event.request.headers.get("authorization")).toBe("Bearer access-token");
expect(await event.request.clone().json()).toMatchObject({
project: "managed-project",
model: "gemini-2.5-pro",
});

const responseEvent = {
...event,
response: Response.json({ response: { candidates: [] } }),
};
await hooks["http.response"]!(responseEvent);
expect(await responseEvent.response.json()).toEqual({ candidates: [] });
});
149 changes: 149 additions & 0 deletions src/plugin-v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Integration, Plugin, type Credential } from "@opencode-ai/plugin";

import { GEMINI_PROVIDER_ID } from "./constants";
import type { GeminiTokenExchangeResult } from "./gemini/oauth";
import { createOAuthAuthorizeMethod } from "./plugin/oauth-authorize";
import { resolveProjectContextFromAccessToken } from "./plugin/project";
import { resolveConfiguredProjectId } from "./plugin/provider";
import {
isGenerativeLanguageRequest,
prepareGeminiRequest,
transformGeminiResponse,
} from "./plugin/request";
import { refreshAccessToken } from "./plugin/token";
import type { OAuthAuthDetails, PluginClient } from "./plugin/types";

const GEMINI_OAUTH_METHOD_ID = Integration.MethodID.make("gemini-cli");

type V2Context = Pick<Plugin.Context, "catalog" | "integration" | "session">;

const noPersistClient = {
auth: { set: async () => {} },
} as PluginClient;

export async function setupV2(ctx: V2Context): Promise<void> {
const requests = new WeakMap<Request, { streaming: boolean; requestedModel?: string }>();
const getConfiguredProjectId = () => resolveV2ConfiguredProjectId(ctx);
const authorize = createOAuthAuthorizeMethod({ getConfiguredProjectId });

await ctx.integration.transform((draft) => {
draft.method.update({
integrationID: GEMINI_PROVIDER_ID,
method: {
id: GEMINI_OAUTH_METHOD_ID,
type: "oauth",
label: "OAuth with Google (Gemini CLI)",
},
authorize: async () => {
const authorization = await authorize();
return authorization.method === "auto"
? {
url: authorization.url,
instructions: authorization.instructions,
mode: "auto",
callback: authorization.callback().then(toV2Credential),
}
: {
url: authorization.url,
instructions: authorization.instructions,
mode: "code",
callback: (code: string) => authorization.callback(code).then(toV2Credential),
};
},
refresh: async (credential) => {
const refreshed = await refreshAccessToken(credential, noPersistClient);
if (!refreshed?.access || refreshed.expires === undefined) {
throw new Error("Gemini OAuth token refresh failed");
}
return { ...credential, ...refreshed };
},
label: (credential) =>
typeof credential.metadata?.email === "string" ? credential.metadata.email : undefined,
});
});

await ctx.session.hook("http.request", async (event) => {
if (!isGenerativeLanguageRequest(event.request)) {
return;
}

const connection = await ctx.integration.connection.active(GEMINI_PROVIDER_ID);
const credential = connection
? await ctx.integration.connection.resolve(connection)
: undefined;
if (!isV2Credential(credential) || credential.methodID !== GEMINI_OAUTH_METHOD_ID) {
return;
}

const project = await resolveProjectContextFromAccessToken(
credential,
credential.access,
await getConfiguredProjectId(),
undefined,
event.model.id,
);
const original = event.request;
const body = original.method === "GET" || original.method === "HEAD"
? undefined
: await original.clone().text();
const transformed = prepareGeminiRequest(
original,
{ method: original.method, headers: original.headers, body, signal: original.signal },
credential.access,
project.effectiveProjectId,
);
const request = new Request(transformed.request, transformed.init);
requests.set(request, {
streaming: transformed.streaming,
requestedModel: transformed.requestedModel,
});
event.request = request;
}, { providerID: GEMINI_PROVIDER_ID });

await ctx.session.hook("http.response", async (event) => {
const request = requests.get(event.request);
if (!request) return;
event.response = await transformGeminiResponse(
event.response,
request.streaming,
null,
request.requestedModel,
);
}, { providerID: GEMINI_PROVIDER_ID });
}

async function resolveV2ConfiguredProjectId(ctx: V2Context): Promise<string | undefined> {
const fromEnvironment = resolveConfiguredProjectId();
if (fromEnvironment) return fromEnvironment;
try {
const provider = await ctx.catalog.provider.get({ providerID: GEMINI_PROVIDER_ID });
return resolveConfiguredProjectId({
provider: { options: provider.data.settings },
});
} catch {
return undefined;
}
}

function toV2Credential(result: GeminiTokenExchangeResult): Credential.OAuth {
if (result.type !== "success") throw new Error(result.error);
return {
type: "oauth",
methodID: GEMINI_OAUTH_METHOD_ID,
refresh: result.refresh,
access: result.access,
expires: result.expires,
metadata: result.email ? { email: result.email } : undefined,
};
}

function isV2Credential(value: unknown): value is Credential.OAuth {
return !!value && typeof value === "object" &&
(value as { type?: unknown }).type === "oauth" &&
typeof (value as { methodID?: unknown }).methodID === "string";
}

export default Plugin.define({
id: "opencode.provider.google-gemini-cli",
setup: setupV2,
});
7 changes: 4 additions & 3 deletions src/plugin/oauth-authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ export function createOAuthAuthorizeMethod(options?: {
}): () => Promise<{
url: string;
instructions: string;
method: string;
callback: (() => Promise<GeminiTokenExchangeResult>) | ((callbackUrl: string) => Promise<GeminiTokenExchangeResult>);
}> {
} & (
| { method: "auto"; callback: () => Promise<GeminiTokenExchangeResult> }
| { method: "code"; callback: (callbackUrl: string) => Promise<GeminiTokenExchangeResult> }
)> {
return async () => {
const maybeHydrateProjectId = async (
result: GeminiTokenExchangeResult,
Expand Down
Loading