diff --git a/packages/opencode/src/plugin/gemini/auth.ts b/packages/opencode/src/plugin/gemini/auth.ts new file mode 100644 index 00000000000..9842314775a --- /dev/null +++ b/packages/opencode/src/plugin/gemini/auth.ts @@ -0,0 +1,41 @@ +import type { AuthDetails, OAuthAuthDetails, PartialAuthDetails, PartialOAuthAuthDetails, RefreshParts } from "./types" + +const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000 + +export function isOAuthAuth(auth: AuthDetails | PartialAuthDetails): auth is OAuthAuthDetails | PartialOAuthAuthDetails { + return auth.type === "oauth" +} + +export function isFullOAuthAuth(auth: AuthDetails | PartialAuthDetails): auth is OAuthAuthDetails { + return auth.type === "oauth" && typeof auth.access === "string" && typeof auth.expires === "number" +} + +export function parseRefreshParts(refresh: string): RefreshParts { + const [refreshToken = "", projectId = "", managedProjectId = ""] = (refresh ?? "").split("|") + return { + refreshToken, + projectId: projectId || undefined, + managedProjectId: managedProjectId || undefined, + } +} + +export function formatRefreshParts(parts: RefreshParts): string { + if (!parts.refreshToken) { + return "" + } + + if (!parts.projectId && !parts.managedProjectId) { + return parts.refreshToken + } + + const projectSegment = parts.projectId ?? "" + const managedSegment = parts.managedProjectId ?? "" + return `${parts.refreshToken}|${projectSegment}|${managedSegment}` +} + +export function accessTokenExpired(auth: OAuthAuthDetails | PartialOAuthAuthDetails): boolean { + if (!auth.access || typeof auth.expires !== "number") { + return true + } + return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS +} diff --git a/packages/opencode/src/plugin/gemini/cache.ts b/packages/opencode/src/plugin/gemini/cache.ts new file mode 100644 index 00000000000..8c7e6a428c7 --- /dev/null +++ b/packages/opencode/src/plugin/gemini/cache.ts @@ -0,0 +1,59 @@ +import { accessTokenExpired } from "./auth" +import type { OAuthAuthDetails, PartialOAuthAuthDetails } from "./types" + +const authCache = new Map() + +function normalizeRefreshKey(refresh?: string): string | undefined { + const key = refresh?.trim() + return key ? key : undefined +} + +export function resolveCachedAuth(auth: OAuthAuthDetails | PartialOAuthAuthDetails): OAuthAuthDetails | PartialOAuthAuthDetails { + const key = normalizeRefreshKey(auth.refresh) + if (!key) { + return auth + } + + const cached = authCache.get(key) + if (!cached) { + if (auth.access && typeof auth.expires === "number") { + authCache.set(key, auth as OAuthAuthDetails) + } + return auth + } + + if (!accessTokenExpired(auth)) { + if (auth.access && typeof auth.expires === "number") { + authCache.set(key, auth as OAuthAuthDetails) + } + return auth + } + + if (!accessTokenExpired(cached)) { + return cached + } + + if (auth.access && typeof auth.expires === "number") { + authCache.set(key, auth as OAuthAuthDetails) + } + return auth +} + +export function storeCachedAuth(auth: OAuthAuthDetails): void { + const key = normalizeRefreshKey(auth.refresh) + if (!key) { + return + } + authCache.set(key, auth) +} + +export function clearCachedAuth(refresh?: string): void { + if (!refresh) { + authCache.clear() + return + } + const key = normalizeRefreshKey(refresh) + if (key) { + authCache.delete(key) + } +} diff --git a/packages/opencode/src/plugin/gemini/constants.ts b/packages/opencode/src/plugin/gemini/constants.ts new file mode 100644 index 00000000000..a48134cb425 --- /dev/null +++ b/packages/opencode/src/plugin/gemini/constants.ts @@ -0,0 +1,21 @@ +export const GEMINI_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" + +export const GEMINI_CLIENT_SECRET = "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" + +export const GEMINI_SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +] as const + +export const GEMINI_REDIRECT_URI = "http://localhost:8085/oauth2callback" + +export const GEMINI_CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com" + +export const CODE_ASSIST_HEADERS = { + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "gl-node/22.17.0", + "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", +} as const + +export const GEMINI_PROVIDER_ID = "google" diff --git a/packages/opencode/src/plugin/gemini/debug.ts b/packages/opencode/src/plugin/gemini/debug.ts new file mode 100644 index 00000000000..323ad3f8120 --- /dev/null +++ b/packages/opencode/src/plugin/gemini/debug.ts @@ -0,0 +1,165 @@ +import { createWriteStream } from "node:fs" +import { join } from "node:path" +import { cwd, env } from "node:process" + +const DEBUG_FLAG = env.OPENCODE_GEMINI_DEBUG ?? "" +const MAX_BODY_PREVIEW_CHARS = 2000 +const debugEnabled = DEBUG_FLAG.trim() === "1" +const logFilePath = debugEnabled ? defaultLogFilePath() : undefined +const logWriter = createLogWriter(logFilePath) + +export interface GeminiDebugContext { + id: string + streaming: boolean + startedAt: number +} + +interface GeminiDebugRequestMeta { + originalUrl: string + resolvedUrl: string + method?: string + headers?: HeadersInit + body?: BodyInit | null + streaming: boolean + projectId?: string +} + +interface GeminiDebugResponseMeta { + body?: string + note?: string + error?: unknown + headersOverride?: HeadersInit +} + +let requestCounter = 0 + +export function startGeminiDebugRequest(meta: GeminiDebugRequestMeta): GeminiDebugContext | null { + if (!debugEnabled) { + return null + } + + const id = `GEMINI-${++requestCounter}` + const method = meta.method ?? "GET" + logDebug(`[Gemini Debug ${id}] ${method} ${meta.resolvedUrl}`) + if (meta.originalUrl && meta.originalUrl !== meta.resolvedUrl) { + logDebug(`[Gemini Debug ${id}] Original URL: ${meta.originalUrl}`) + } + if (meta.projectId) { + logDebug(`[Gemini Debug ${id}] Project: ${meta.projectId}`) + } + logDebug(`[Gemini Debug ${id}] Streaming: ${meta.streaming ? "yes" : "no"}`) + logDebug(`[Gemini Debug ${id}] Headers: ${JSON.stringify(maskHeaders(meta.headers))}`) + const bodyPreview = formatBodyPreview(meta.body) + if (bodyPreview) { + logDebug(`[Gemini Debug ${id}] Body Preview: ${bodyPreview}`) + } + + return { id, streaming: meta.streaming, startedAt: Date.now() } +} + +export function logGeminiDebugResponse( + context: GeminiDebugContext | null | undefined, + response: Response, + meta: GeminiDebugResponseMeta = {}, +): void { + if (!debugEnabled || !context) { + return + } + + const durationMs = Date.now() - context.startedAt + logDebug(`[Gemini Debug ${context.id}] Response ${response.status} ${response.statusText} (${durationMs}ms)`) + logDebug( + `[Gemini Debug ${context.id}] Response Headers: ${JSON.stringify(maskHeaders(meta.headersOverride ?? response.headers))}`, + ) + + if (meta.note) { + logDebug(`[Gemini Debug ${context.id}] Note: ${meta.note}`) + } + + if (meta.error) { + logDebug(`[Gemini Debug ${context.id}] Error: ${formatError(meta.error)}`) + } + + if (meta.body) { + logDebug(`[Gemini Debug ${context.id}] Response Body Preview: ${truncateForLog(meta.body)}`) + } +} + +function maskHeaders(headers?: HeadersInit | Headers): Record { + if (!headers) { + return {} + } + + const result: Record = {} + const parsed = headers instanceof Headers ? headers : new Headers(headers) + parsed.forEach((value, key) => { + if (key.toLowerCase() === "authorization") { + result[key] = "[redacted]" + } else { + result[key] = value + } + }) + return result +} + +function formatBodyPreview(body?: BodyInit | null): string | undefined { + if (body == null) { + return undefined + } + + if (typeof body === "string") { + return truncateForLog(body) + } + + if (body instanceof URLSearchParams) { + return truncateForLog(body.toString()) + } + + if (typeof Blob !== "undefined" && body instanceof Blob) { + return `[Blob size=${body.size}]` + } + + if (typeof FormData !== "undefined" && body instanceof FormData) { + return "[FormData payload omitted]" + } + + return `[${body.constructor?.name ?? typeof body} payload omitted]` +} + +function truncateForLog(text: string): string { + if (text.length <= MAX_BODY_PREVIEW_CHARS) { + return text + } + return `${text.slice(0, MAX_BODY_PREVIEW_CHARS)}... (truncated ${text.length - MAX_BODY_PREVIEW_CHARS} chars)` +} + +function logDebug(line: string): void { + logWriter(line) +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? error.message + } + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function defaultLogFilePath(): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + return join(cwd(), `gemini-debug-${timestamp}.log`) +} + +function createLogWriter(filePath?: string): (line: string) => void { + if (!filePath) { + return () => {} + } + + const stream = createWriteStream(filePath, { flags: "a" }) + return (line: string) => { + stream.write(`${line}\n`) + } +} diff --git a/packages/opencode/src/plugin/gemini/index.ts b/packages/opencode/src/plugin/gemini/index.ts new file mode 100644 index 00000000000..f33334296c7 --- /dev/null +++ b/packages/opencode/src/plugin/gemini/index.ts @@ -0,0 +1,253 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { OAUTH_DUMMY_KEY } from "../../auth" +import { accessTokenExpired, isOAuthAuth, isFullOAuthAuth } from "./auth" +import { GEMINI_PROVIDER_ID } from "./constants" +import { authorizeGemini, exchangeGemini, exchangeGeminiWithVerifier, type GeminiTokenExchangeResult } from "./oauth" +import { ensureProjectContext } from "./project" +import { startGeminiDebugRequest } from "./debug" +import { isGenerativeLanguageRequest, prepareGeminiRequest, transformGeminiResponse } from "./request" +import { resolveCachedAuth } from "./cache" +import { startOAuthListener, type OAuthListener } from "./server" +import { refreshAccessToken } from "./token" +import type { OAuthAuthDetails } from "./types" + +export async function GeminiAuthPlugin(input: PluginInput): Promise { + return { + auth: { + provider: GEMINI_PROVIDER_ID, + async loader(getAuth, provider) { + const auth = await getAuth() + if (!isOAuthAuth(auth)) { + return {} + } + + const providerOptions = provider?.options ?? undefined + const projectIdFromConfig = + providerOptions && typeof providerOptions.projectId === "string" ? providerOptions.projectId.trim() : "" + const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "" + const configuredProjectId = projectIdFromEnv || projectIdFromConfig || undefined + + if (provider?.models) { + for (const model of Object.values(provider.models)) { + if (model) { + model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } } + } + } + } + + return { + apiKey: OAUTH_DUMMY_KEY, + async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { + // Convert URL to string for consistency + const requestUrl = requestInput instanceof URL ? requestInput.toString() : requestInput + + if (!isGenerativeLanguageRequest(requestUrl)) { + return fetch(requestInput, init) + } + + const latestAuth = await getAuth() + if (!isOAuthAuth(latestAuth)) { + return fetch(requestInput, init) + } + + let authRecord = resolveCachedAuth(latestAuth) + if (accessTokenExpired(authRecord)) { + const refreshed = await refreshAccessToken(authRecord, input.client) + if (!refreshed) { + return fetch(requestInput, init) + } + authRecord = refreshed + } + + // At this point authRecord should be a full OAuthAuthDetails with access token + if (!isFullOAuthAuth(authRecord)) { + return fetch(requestInput, init) + } + + const accessToken = authRecord.access + + async function resolveProjectContext() { + try { + return await ensureProjectContext(authRecord as OAuthAuthDetails, input.client, configuredProjectId) + } catch (error) { + if (error instanceof Error) { + console.error(error.message) + } + throw error + } + } + + const projectContext = await resolveProjectContext() + + const { request, init: transformedInit, streaming, requestedModel } = prepareGeminiRequest( + requestUrl, + init, + accessToken, + projectContext.effectiveProjectId, + ) + + const originalUrl = toUrlString(requestUrl) + const resolvedUrl = toUrlString(request) + const debugContext = startGeminiDebugRequest({ + originalUrl, + resolvedUrl, + method: transformedInit.method, + headers: transformedInit.headers, + body: transformedInit.body, + streaming, + projectId: projectContext.effectiveProjectId, + }) + + const response = await fetch(request, transformedInit) + return transformGeminiResponse(response, streaming, debugContext, requestedModel) + }, + } + }, + methods: [ + { + label: "OAuth with Google (Gemini CLI)", + type: "oauth", + authorize: async () => { + const isHeadless = Boolean( + process.env.SSH_CONNECTION || + process.env.SSH_CLIENT || + process.env.SSH_TTY || + process.env.OPENCODE_HEADLESS, + ) + + let listener: OAuthListener | null = null + if (!isHeadless) { + try { + listener = await startOAuthListener() + } catch (error) { + if (error instanceof Error) { + console.log( + `Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL or authorization code.`, + ) + } else { + console.log( + "Warning: Couldn't start the local callback listener. You'll need to paste the callback URL or authorization code.", + ) + } + } + } else { + console.log("Headless environment detected. You'll need to paste the callback URL or authorization code.") + } + + const authorization = await authorizeGemini() + + if (listener) { + return { + url: authorization.url, + instructions: + "Complete the sign-in flow in your browser. We'll automatically detect the redirect back to localhost.", + method: "auto", + callback: async (): Promise => { + try { + const callbackUrl = await listener.waitForCallback() + const code = callbackUrl.searchParams.get("code") + const state = callbackUrl.searchParams.get("state") + + if (!code || !state) { + return { + type: "failed", + error: "Missing code or state in callback URL", + } + } + + return await exchangeGemini(code, state) + } catch (error) { + return { + type: "failed", + error: error instanceof Error ? error.message : "Unknown error", + } + } finally { + try { + await listener?.close() + } catch {} + } + }, + } + } + + return { + url: authorization.url, + instructions: + "Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...) or just the authorization code.", + method: "code", + callback: async (callbackUrl: string): Promise => { + try { + const { code, state } = parseOAuthCallbackInput(callbackUrl) + + if (!code) { + return { + type: "failed", + error: "Missing authorization code in callback input", + } + } + + if (state) { + return exchangeGemini(code, state) + } + + return exchangeGeminiWithVerifier(code, authorization.verifier) + } catch (error) { + return { + type: "failed", + error: error instanceof Error ? error.message : "Unknown error", + } + } + }, + } + }, + }, + { + label: "Manually enter API Key", + type: "api", + }, + ], + }, + } +} + +function toUrlString(value: RequestInfo): string { + if (typeof value === "string") { + return value + } + const candidate = (value as Request).url + if (candidate) { + return candidate + } + return value.toString() +} + +function parseOAuthCallbackInput(input: string): { code?: string; state?: string } { + const trimmed = input.trim() + if (!trimmed) { + return {} + } + + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed) + return { + code: url.searchParams.get("code") || undefined, + state: url.searchParams.get("state") || undefined, + } + } catch { + return {} + } + } + + const candidate = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed + if (candidate.includes("=")) { + const params = new URLSearchParams(candidate) + const code = params.get("code") || undefined + const state = params.get("state") || undefined + if (code || state) { + return { code, state } + } + } + + return { code: trimmed } +} diff --git a/packages/opencode/src/plugin/gemini/oauth.ts b/packages/opencode/src/plugin/gemini/oauth.ts new file mode 100644 index 00000000000..c8d6f843e57 --- /dev/null +++ b/packages/opencode/src/plugin/gemini/oauth.ts @@ -0,0 +1,141 @@ +import { generatePKCE } from "@openauthjs/openauth/pkce" + +import { GEMINI_CLIENT_ID, GEMINI_CLIENT_SECRET, GEMINI_REDIRECT_URI, GEMINI_SCOPES } from "./constants" + +interface PkcePair { + challenge: string + verifier: string +} + +interface GeminiAuthState { + verifier: string +} + +export interface GeminiAuthorization { + url: string + verifier: string +} + +interface GeminiTokenExchangeSuccess { + type: "success" + refresh: string + access: string + expires: number +} + +interface GeminiTokenExchangeFailure { + type: "failed" + error: string +} + +export type GeminiTokenExchangeResult = GeminiTokenExchangeSuccess | GeminiTokenExchangeFailure + +interface GeminiTokenResponse { + access_token: string + expires_in: number + refresh_token: string +} + + +function encodeState(payload: GeminiAuthState): string { + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url") +} + +function decodeState(state: string): GeminiAuthState { + const normalized = state.replace(/-/g, "+").replace(/_/g, "/") + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=") + const json = Buffer.from(padded, "base64").toString("utf8") + const parsed = JSON.parse(json) + if (typeof parsed.verifier !== "string") { + throw new Error("Missing PKCE verifier in state") + } + return { + verifier: parsed.verifier, + } +} + +export async function authorizeGemini(): Promise { + const pkce = (await generatePKCE()) as PkcePair + + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth") + url.searchParams.set("client_id", GEMINI_CLIENT_ID) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", GEMINI_REDIRECT_URI) + url.searchParams.set("scope", GEMINI_SCOPES.join(" ")) + url.searchParams.set("code_challenge", pkce.challenge) + url.searchParams.set("code_challenge_method", "S256") + url.searchParams.set("state", encodeState({ verifier: pkce.verifier })) + url.searchParams.set("access_type", "offline") + url.searchParams.set("prompt", "consent") + + return { + url: url.toString(), + verifier: pkce.verifier, + } +} + +export async function exchangeGemini(code: string, state: string): Promise { + try { + const { verifier } = decodeState(state) + + return await exchangeGeminiWithVerifierInternal(code, verifier) + } catch (error) { + return { + type: "failed", + error: error instanceof Error ? error.message : "Unknown error", + } + } +} + +export async function exchangeGeminiWithVerifier( + code: string, + verifier: string, +): Promise { + try { + return await exchangeGeminiWithVerifierInternal(code, verifier) + } catch (error) { + return { + type: "failed", + error: error instanceof Error ? error.message : "Unknown error", + } + } +} + +async function exchangeGeminiWithVerifierInternal( + code: string, + verifier: string, +): Promise { + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: GEMINI_CLIENT_ID, + client_secret: GEMINI_CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: GEMINI_REDIRECT_URI, + code_verifier: verifier, + }), + }) + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text() + return { type: "failed", error: errorText } + } + + const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse + + const refreshToken = tokenPayload.refresh_token + if (!refreshToken) { + return { type: "failed", error: "Missing refresh token in response" } + } + + return { + type: "success", + refresh: refreshToken, + access: tokenPayload.access_token, + expires: Date.now() + tokenPayload.expires_in * 1000, + } +} diff --git a/packages/opencode/src/plugin/gemini/project.ts b/packages/opencode/src/plugin/gemini/project.ts new file mode 100644 index 00000000000..12c811ab483 --- /dev/null +++ b/packages/opencode/src/plugin/gemini/project.ts @@ -0,0 +1,306 @@ +import { CODE_ASSIST_HEADERS, GEMINI_CODE_ASSIST_ENDPOINT, GEMINI_PROVIDER_ID } from "./constants" +import { formatRefreshParts, parseRefreshParts } from "./auth" +import type { OAuthAuthDetails, PluginClient, ProjectContextResult } from "./types" + +const projectContextResultCache = new Map() +const projectContextPendingCache = new Map>() + +const CODE_ASSIST_METADATA = { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +} as const + +interface GeminiUserTier { + id?: string + isDefault?: boolean + userDefinedCloudaicompanionProject?: boolean +} + +interface LoadCodeAssistPayload { + cloudaicompanionProject?: string + currentTier?: { + id?: string + } + allowedTiers?: GeminiUserTier[] +} + +interface OnboardUserPayload { + done?: boolean + response?: { + cloudaicompanionProject?: { + id?: string + } + } +} + +class ProjectIdRequiredError extends Error { + constructor() { + super( + "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).", + ) + } +} + +function buildMetadata(projectId?: string): Record { + const metadata: Record = { + ideType: CODE_ASSIST_METADATA.ideType, + platform: CODE_ASSIST_METADATA.platform, + pluginType: CODE_ASSIST_METADATA.pluginType, + } + if (projectId) { + metadata.duetProject = projectId + } + return metadata +} + +function getDefaultTierId(allowedTiers?: GeminiUserTier[]): string | undefined { + if (!allowedTiers || allowedTiers.length === 0) { + return undefined + } + for (const tier of allowedTiers) { + if (tier?.isDefault) { + return tier.id + } + } + return allowedTiers[0]?.id +} + +function wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function getCacheKey(auth: OAuthAuthDetails): string | undefined { + const refresh = auth.refresh?.trim() + return refresh ? refresh : undefined +} + +export function invalidateProjectContextCache(refresh?: string): void { + if (!refresh) { + projectContextPendingCache.clear() + projectContextResultCache.clear() + return + } + + projectContextPendingCache.delete(refresh) + projectContextResultCache.delete(refresh) + + const prefix = `${refresh}|cfg:` + for (const key of projectContextPendingCache.keys()) { + if (key.startsWith(prefix)) { + projectContextPendingCache.delete(key) + } + } + for (const key of projectContextResultCache.keys()) { + if (key.startsWith(prefix)) { + projectContextResultCache.delete(key) + } + } +} + +export async function loadManagedProject(accessToken: string, projectId?: string): Promise { + try { + const metadata = buildMetadata(projectId) + + const requestBody: Record = { metadata } + if (projectId) { + requestBody.cloudaicompanionProject = projectId + } + + const response = await fetch(`${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + ...CODE_ASSIST_HEADERS, + }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + return null + } + + return (await response.json()) as LoadCodeAssistPayload + } catch (error) { + console.error("Failed to load Gemini managed project:", error) + return null + } +} + +export async function onboardManagedProject( + accessToken: string, + tierId: string, + projectId?: string, + attempts = 10, + delayMs = 5000, +): Promise { + const metadata = buildMetadata(projectId) + const requestBody: Record = { + tierId, + metadata, + } + + if (tierId !== "FREE") { + if (!projectId) { + throw new ProjectIdRequiredError() + } + requestBody.cloudaicompanionProject = projectId + } + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetch(`${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + ...CODE_ASSIST_HEADERS, + }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + return undefined + } + + const payload = (await response.json()) as OnboardUserPayload + const managedProjectId = payload.response?.cloudaicompanionProject?.id + if (payload.done && managedProjectId) { + return managedProjectId + } + if (payload.done && projectId) { + return projectId + } + } catch (error) { + console.error("Failed to onboard Gemini managed project:", error) + return undefined + } + + await wait(delayMs) + } + + return undefined +} + +export async function ensureProjectContext( + auth: OAuthAuthDetails, + client: PluginClient, + configuredProjectId?: string, +): Promise { + const accessToken = auth.access + if (!accessToken) { + return { auth, effectiveProjectId: "" } + } + + const cacheKey = (() => { + const base = getCacheKey(auth) + if (!base) return undefined + const project = configuredProjectId?.trim() ?? "" + return project ? `${base}|cfg:${project}` : base + })() + if (cacheKey) { + const cached = projectContextResultCache.get(cacheKey) + if (cached) { + return cached + } + const pending = projectContextPendingCache.get(cacheKey) + if (pending) { + return pending + } + } + + const resolveContext = async (): Promise => { + const parts = parseRefreshParts(auth.refresh) + const effectiveConfiguredProjectId = configuredProjectId?.trim() || undefined + const projectId = effectiveConfiguredProjectId ?? parts.projectId + + if (projectId || parts.managedProjectId) { + return { + auth, + effectiveProjectId: projectId || parts.managedProjectId || "", + } + } + + const loadPayload = await loadManagedProject(accessToken, projectId) + if (loadPayload?.cloudaicompanionProject) { + const managedProjectId = loadPayload.cloudaicompanionProject + const updatedAuth: OAuthAuthDetails = { + ...auth, + refresh: formatRefreshParts({ + refreshToken: parts.refreshToken, + projectId, + managedProjectId, + }), + } + + await client.auth.set({ + path: { id: GEMINI_PROVIDER_ID }, + body: updatedAuth, + }) + + return { auth: updatedAuth, effectiveProjectId: managedProjectId } + } + + if (!loadPayload) { + throw new ProjectIdRequiredError() + } + + const currentTierId = loadPayload.currentTier?.id ?? undefined + if (currentTierId && currentTierId !== "FREE") { + throw new ProjectIdRequiredError() + } + + const defaultTierId = getDefaultTierId(loadPayload.allowedTiers) + const tierId = defaultTierId ?? "FREE" + + if (tierId !== "FREE") { + throw new ProjectIdRequiredError() + } + + const managedProjectId = await onboardManagedProject(accessToken, tierId, projectId) + if (managedProjectId) { + const updatedAuth: OAuthAuthDetails = { + ...auth, + refresh: formatRefreshParts({ + refreshToken: parts.refreshToken, + projectId, + managedProjectId, + }), + } + + await client.auth.set({ + path: { id: GEMINI_PROVIDER_ID }, + body: updatedAuth, + }) + + return { auth: updatedAuth, effectiveProjectId: managedProjectId } + } + + throw new ProjectIdRequiredError() + } + + if (!cacheKey) { + return resolveContext() + } + + const promise = resolveContext() + .then((result) => { + const nextKey = getCacheKey(result.auth) ?? cacheKey + projectContextPendingCache.delete(cacheKey) + projectContextResultCache.set(nextKey, result) + if (nextKey !== cacheKey) { + projectContextResultCache.delete(cacheKey) + } + return result + }) + .catch((error) => { + projectContextPendingCache.delete(cacheKey) + throw error + }) + + projectContextPendingCache.set(cacheKey, promise) + return promise +} diff --git a/packages/opencode/src/plugin/gemini/request-helpers.ts b/packages/opencode/src/plugin/gemini/request-helpers.ts new file mode 100644 index 00000000000..5903b0cfe7e --- /dev/null +++ b/packages/opencode/src/plugin/gemini/request-helpers.ts @@ -0,0 +1,145 @@ +const GEMINI_PREVIEW_LINK = "https://goo.gle/enable-preview-features" + +export interface GeminiApiError { + code?: number + message?: string + status?: string + [key: string]: unknown +} + +export interface GeminiApiBody { + response?: unknown + error?: GeminiApiError + [key: string]: unknown +} + +export interface GeminiUsageMetadata { + totalTokenCount?: number + promptTokenCount?: number + candidatesTokenCount?: number + cachedContentTokenCount?: number +} + +export interface ThinkingConfig { + thinkingBudget?: number + thinkingLevel?: string + includeThoughts?: boolean +} + +export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined { + if (!config || typeof config !== "object") { + return undefined + } + + const record = config as Record + const budgetRaw = record.thinkingBudget ?? record.thinking_budget + const levelRaw = record.thinkingLevel ?? record.thinking_level + const includeRaw = record.includeThoughts ?? record.include_thoughts + + const thinkingBudget = typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined + const thinkingLevel = typeof levelRaw === "string" && levelRaw.length > 0 ? levelRaw.toLowerCase() : undefined + const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined + + if (thinkingBudget === undefined && thinkingLevel === undefined && includeThoughts === undefined) { + return undefined + } + + const normalized: ThinkingConfig = {} + if (thinkingBudget !== undefined) { + normalized.thinkingBudget = thinkingBudget + } + if (thinkingLevel !== undefined) { + normalized.thinkingLevel = thinkingLevel + } + if (includeThoughts !== undefined) { + normalized.includeThoughts = includeThoughts + } + return normalized +} + +export function parseGeminiApiBody(rawText: string): GeminiApiBody | null { + try { + const parsed = JSON.parse(rawText) + if (Array.isArray(parsed)) { + const firstObject = parsed.find((item: unknown) => typeof item === "object" && item !== null) + if (firstObject && typeof firstObject === "object") { + return firstObject as GeminiApiBody + } + return null + } + + if (parsed && typeof parsed === "object") { + return parsed as GeminiApiBody + } + + return null + } catch { + return null + } +} + +export function extractUsageMetadata(body: GeminiApiBody): GeminiUsageMetadata | null { + const usage = (body.response && typeof body.response === "object" + ? (body.response as { usageMetadata?: unknown }).usageMetadata + : undefined) as GeminiUsageMetadata | undefined + + if (!usage || typeof usage !== "object") { + return null + } + + const asRecord = usage as Record + const toNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined + + return { + totalTokenCount: toNumber(asRecord.totalTokenCount), + promptTokenCount: toNumber(asRecord.promptTokenCount), + candidatesTokenCount: toNumber(asRecord.candidatesTokenCount), + cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount), + } +} + +export function rewriteGeminiPreviewAccessError( + body: GeminiApiBody, + status: number, + requestedModel?: string, +): GeminiApiBody | null { + if (!needsPreviewAccessOverride(status, body, requestedModel)) { + return null + } + + const error: GeminiApiError = body.error ?? {} + const trimmedMessage = typeof error.message === "string" ? error.message.trim() : "" + const messagePrefix = + trimmedMessage.length > 0 ? trimmedMessage : "Gemini 3 preview features are not enabled for this account." + const enhancedMessage = `${messagePrefix} Request preview access at ${GEMINI_PREVIEW_LINK} before using Gemini 3 models.` + + return { + ...body, + error: { + ...error, + message: enhancedMessage, + }, + } +} + +function needsPreviewAccessOverride(status: number, body: GeminiApiBody, requestedModel?: string): boolean { + if (status !== 404) { + return false + } + + if (isGeminiThreeModel(requestedModel)) { + return true + } + + const errorMessage = typeof body.error?.message === "string" ? body.error.message : "" + return isGeminiThreeModel(errorMessage) +} + +function isGeminiThreeModel(target?: string): boolean { + if (!target) { + return false + } + + return /gemini[\s-]?3/i.test(target) +} diff --git a/packages/opencode/src/plugin/gemini/request.ts b/packages/opencode/src/plugin/gemini/request.ts new file mode 100644 index 00000000000..347970c1e5b --- /dev/null +++ b/packages/opencode/src/plugin/gemini/request.ts @@ -0,0 +1,342 @@ +import { CODE_ASSIST_HEADERS, GEMINI_CODE_ASSIST_ENDPOINT } from "./constants" +import { logGeminiDebugResponse, type GeminiDebugContext } from "./debug" +import { + extractUsageMetadata, + normalizeThinkingConfig, + parseGeminiApiBody, + rewriteGeminiPreviewAccessError, + type GeminiApiBody, +} from "./request-helpers" + +const STREAM_ACTION = "streamGenerateContent" +const MODEL_FALLBACKS: Record = { + "gemini-2.5-flash-image": "gemini-2.5-flash", +} + +export function isGenerativeLanguageRequest(input: RequestInfo): input is string { + return toRequestUrlString(input).includes("generativelanguage.googleapis.com") +} + +function transformStreamingLine(line: string): string { + if (!line.startsWith("data:")) { + return line + } + const json = line.slice(5).trim() + if (!json) { + return line + } + try { + const parsed = JSON.parse(json) as { response?: unknown } + if (parsed.response !== undefined) { + return `data: ${JSON.stringify(parsed.response)}` + } + } catch {} + return line +} + +function transformStreamingPayloadStream(stream: ReadableStream): ReadableStream { + const decoder = new TextDecoder() + const encoder = new TextEncoder() + let buffer = "" + let reader: ReadableStreamDefaultReader | null = null + + return new ReadableStream({ + start(controller) { + reader = stream.getReader() + const pump = (): void => { + reader! + .read() + .then(({ done, value }) => { + if (done) { + buffer += decoder.decode() + if (buffer.length > 0) { + controller.enqueue(encoder.encode(transformStreamingLine(buffer))) + } + controller.close() + return + } + + buffer += decoder.decode(value, { stream: true }) + + let newlineIndex = buffer.indexOf("\n") + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex) + buffer = buffer.slice(newlineIndex + 1) + const hasCarriageReturn = line.endsWith("\r") + const rawLine = hasCarriageReturn ? line.slice(0, -1) : line + const transformed = transformStreamingLine(rawLine) + const suffix = hasCarriageReturn ? "\r\n" : "\n" + controller.enqueue(encoder.encode(`${transformed}${suffix}`)) + newlineIndex = buffer.indexOf("\n") + } + + pump() + }) + .catch((error) => { + controller.error(error) + }) + } + + pump() + }, + cancel(reason) { + if (reader) { + reader.cancel(reason).catch(() => {}) + } + }, + }) +} + +export function prepareGeminiRequest( + input: RequestInfo, + init: RequestInit | undefined, + accessToken: string, + projectId: string, +): { request: RequestInfo; init: RequestInit; streaming: boolean; requestedModel?: string } { + const baseInit: RequestInit = { ...init } + const headers = new Headers(init?.headers ?? {}) + + if (!isGenerativeLanguageRequest(input)) { + return { + request: input, + init: { ...baseInit, headers }, + streaming: false, + } + } + + headers.set("Authorization", `Bearer ${accessToken}`) + headers.delete("x-api-key") + headers.delete("x-goog-api-key") + + const match = toRequestUrlString(input).match(/\/models\/([^:]+):(\w+)/) + if (!match) { + return { + request: input, + init: { ...baseInit, headers }, + streaming: false, + } + } + + const [, rawModel = "", rawAction = ""] = match + const effectiveModel = MODEL_FALLBACKS[rawModel] ?? rawModel + const streaming = rawAction === STREAM_ACTION + const transformedUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:${rawAction}${streaming ? "?alt=sse" : ""}` + + let body = baseInit.body + if (typeof baseInit.body === "string" && baseInit.body) { + try { + const parsedBody = JSON.parse(baseInit.body) as Record + const isWrapped = typeof parsedBody.project === "string" && "request" in parsedBody + + if (isWrapped) { + const wrappedBody = { + ...parsedBody, + model: effectiveModel, + } as Record + body = JSON.stringify(wrappedBody) + } else { + const requestPayload: Record = { ...parsedBody } + + const rawGenerationConfig = requestPayload.generationConfig as Record | undefined + const normalizedThinking = normalizeThinkingConfig(rawGenerationConfig?.thinkingConfig) + if (normalizedThinking) { + if (rawGenerationConfig) { + rawGenerationConfig.thinkingConfig = normalizedThinking + requestPayload.generationConfig = rawGenerationConfig + } else { + requestPayload.generationConfig = { thinkingConfig: normalizedThinking } + } + } else if (rawGenerationConfig?.thinkingConfig) { + delete rawGenerationConfig.thinkingConfig + requestPayload.generationConfig = rawGenerationConfig + } + + if ("system_instruction" in requestPayload) { + requestPayload.systemInstruction = requestPayload.system_instruction + delete requestPayload.system_instruction + } + + const cachedContentFromExtra = + typeof requestPayload.extra_body === "object" && requestPayload.extra_body + ? (requestPayload.extra_body as Record).cached_content ?? + (requestPayload.extra_body as Record).cachedContent + : undefined + const cachedContent = + (requestPayload.cached_content as string | undefined) ?? + (requestPayload.cachedContent as string | undefined) ?? + (cachedContentFromExtra as string | undefined) + if (cachedContent) { + requestPayload.cachedContent = cachedContent + } + + delete requestPayload.cached_content + if (requestPayload.extra_body && typeof requestPayload.extra_body === "object") { + delete (requestPayload.extra_body as Record).cached_content + delete (requestPayload.extra_body as Record).cachedContent + if (Object.keys(requestPayload.extra_body as Record).length === 0) { + delete requestPayload.extra_body + } + } + + if ("model" in requestPayload) { + delete requestPayload.model + } + + const wrappedBody = { + project: projectId, + model: effectiveModel, + request: requestPayload, + } + + body = JSON.stringify(wrappedBody) + } + } catch (error) { + console.error("Failed to transform Gemini request body:", error) + } + } + + if (streaming) { + headers.set("Accept", "text/event-stream") + } + + headers.set("User-Agent", CODE_ASSIST_HEADERS["User-Agent"]) + headers.set("X-Goog-Api-Client", CODE_ASSIST_HEADERS["X-Goog-Api-Client"]) + headers.set("Client-Metadata", CODE_ASSIST_HEADERS["Client-Metadata"]) + + return { + request: transformedUrl, + init: { + ...baseInit, + headers, + body, + }, + streaming, + requestedModel: rawModel, + } +} + +function toRequestUrlString(value: RequestInfo): string { + if (typeof value === "string") { + return value + } + if (value instanceof URL) { + return value.toString() + } + const candidate = (value as Request).url + if (candidate) { + return candidate + } + return value.toString() +} + +export async function transformGeminiResponse( + response: Response, + streaming: boolean, + debugContext?: GeminiDebugContext | null, + requestedModel?: string, +): Promise { + const contentType = response.headers.get("content-type") ?? "" + const isJsonResponse = contentType.includes("application/json") + const isEventStreamResponse = contentType.includes("text/event-stream") + + if (!isJsonResponse && !isEventStreamResponse) { + logGeminiDebugResponse(debugContext, response, { + note: "Non-JSON response (body omitted)", + }) + return response + } + + try { + const headers = new Headers(response.headers) + + if (streaming && response.ok && isEventStreamResponse && response.body) { + logGeminiDebugResponse(debugContext, response, { + note: "Streaming SSE payload (body omitted)", + headersOverride: headers, + }) + + return new Response(transformStreamingPayloadStream(response.body), { + status: response.status, + statusText: response.statusText, + headers, + }) + } + + const text = await response.text() + + if (!response.ok && text) { + try { + const errorBody = JSON.parse(text) + if (errorBody?.error?.details && Array.isArray(errorBody.error.details)) { + const retryInfo = errorBody.error.details.find( + (detail: any) => detail["@type"] === "type.googleapis.com/google.rpc.RetryInfo", + ) + + if (retryInfo?.retryDelay) { + const match = retryInfo.retryDelay.match(/^([\d.]+)s$/) + if (match && match[1]) { + const retrySeconds = parseFloat(match[1]) + if (!Number.isNaN(retrySeconds) && retrySeconds > 0) { + const retryAfterSec = Math.ceil(retrySeconds).toString() + const retryAfterMs = Math.ceil(retrySeconds * 1000).toString() + headers.set("Retry-After", retryAfterSec) + headers.set("retry-after-ms", retryAfterMs) + } + } + } + } + } catch {} + } + + const init = { + status: response.status, + statusText: response.statusText, + headers, + } + + const parsed: GeminiApiBody | null = !streaming || !isEventStreamResponse ? parseGeminiApiBody(text) : null + const patched = parsed ? rewriteGeminiPreviewAccessError(parsed, response.status, requestedModel) : null + const effectiveBody = patched ?? parsed ?? undefined + + const usage = effectiveBody ? extractUsageMetadata(effectiveBody) : null + if (usage?.cachedContentTokenCount !== undefined) { + headers.set("x-gemini-cached-content-token-count", String(usage.cachedContentTokenCount)) + if (usage.totalTokenCount !== undefined) { + headers.set("x-gemini-total-token-count", String(usage.totalTokenCount)) + } + if (usage.promptTokenCount !== undefined) { + headers.set("x-gemini-prompt-token-count", String(usage.promptTokenCount)) + } + if (usage.candidatesTokenCount !== undefined) { + headers.set("x-gemini-candidates-token-count", String(usage.candidatesTokenCount)) + } + } + + logGeminiDebugResponse(debugContext, response, { + body: text, + note: streaming ? "Streaming SSE payload (buffered)" : undefined, + headersOverride: headers, + }) + + if (!parsed) { + return new Response(text, init) + } + + if (effectiveBody?.response !== undefined) { + return new Response(JSON.stringify(effectiveBody.response), init) + } + + if (patched) { + return new Response(JSON.stringify(patched), init) + } + + return new Response(text, init) + } catch (error) { + logGeminiDebugResponse(debugContext, response, { + error, + note: "Failed to transform Gemini response", + }) + console.error("Failed to transform Gemini response:", error) + return response + } +} diff --git a/packages/opencode/src/plugin/gemini/server.ts b/packages/opencode/src/plugin/gemini/server.ts new file mode 100644 index 00000000000..dddc47f96af --- /dev/null +++ b/packages/opencode/src/plugin/gemini/server.ts @@ -0,0 +1,205 @@ +import { GEMINI_REDIRECT_URI } from "./constants" + +interface OAuthListenerOptions { + timeoutMs?: number +} + +export interface OAuthListener { + waitForCallback(): Promise + close(): Promise +} + +const redirectUri = new URL(GEMINI_REDIRECT_URI) +const callbackPath = redirectUri.pathname || "/" + +const SUCCESS_RESPONSE = ` + + + + Opencode Gemini OAuth + + + +
+
+ + Gemini linked to Opencode +
+

You're connected to Opencode

+

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

+ Close window +

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

+
+ +` + +export async function startOAuthListener({ timeoutMs = 5 * 60 * 1000 }: OAuthListenerOptions = {}): Promise { + const port = redirectUri.port + ? Number.parseInt(redirectUri.port, 10) + : redirectUri.protocol === "https:" + ? 443 + : 80 + + let settled = false + let resolveCallback: (url: URL) => void + let rejectCallback: (error: Error) => void + + const callbackPromise = new Promise((resolve, reject) => { + resolveCallback = (url: URL) => { + if (settled) return + settled = true + if (timeoutHandle) clearTimeout(timeoutHandle) + resolve(url) + } + rejectCallback = (error: Error) => { + if (settled) return + settled = true + if (timeoutHandle) clearTimeout(timeoutHandle) + reject(error) + } + }) + + const timeoutHandle = setTimeout(() => { + rejectCallback(new Error("Timed out waiting for OAuth callback")) + }, timeoutMs) + timeoutHandle.unref?.() + + const server = Bun.serve({ + port, + hostname: "127.0.0.1", + fetch(request) { + const url = new URL(request.url) + if (url.pathname !== callbackPath) { + return new Response("Not found", { status: 404 }) + } + + resolveCallback(url) + + queueMicrotask(() => { + server.stop() + }) + + return new Response(SUCCESS_RESPONSE, { + headers: { + "Content-Type": "text/html; charset=utf-8", + }, + }) + }, + }) + + return { + waitForCallback: () => callbackPromise, + close: async () => { + server.stop() + if (!settled) { + rejectCallback(new Error("OAuth listener closed before callback")) + } + }, + } +} diff --git a/packages/opencode/src/plugin/gemini/token.ts b/packages/opencode/src/plugin/gemini/token.ts new file mode 100644 index 00000000000..98a0d864745 --- /dev/null +++ b/packages/opencode/src/plugin/gemini/token.ts @@ -0,0 +1,155 @@ +import { GEMINI_CLIENT_ID, GEMINI_CLIENT_SECRET, GEMINI_PROVIDER_ID } from "./constants" +import { formatRefreshParts, parseRefreshParts } from "./auth" +import { storeCachedAuth } from "./cache" +import { invalidateProjectContextCache } from "./project" +import type { OAuthAuthDetails, PartialOAuthAuthDetails, PluginClient, RefreshParts } from "./types" + +interface OAuthErrorPayload { + error?: + | string + | { + code?: string + status?: string + message?: string + } + error_description?: string +} + +function parseOAuthErrorPayload(text: string | undefined): { code?: string; description?: string } { + if (!text) { + return {} + } + + try { + const payload = JSON.parse(text) as OAuthErrorPayload + if (!payload || typeof payload !== "object") { + return { description: text } + } + + let code: string | undefined + if (typeof payload.error === "string") { + code = payload.error + } else if (payload.error && typeof payload.error === "object") { + code = payload.error.status ?? payload.error.code + if (!payload.error_description && payload.error.message) { + return { code, description: payload.error.message } + } + } + + const description = payload.error_description + if (description) { + return { code, description } + } + + if (payload.error && typeof payload.error === "object" && payload.error.message) { + return { code, description: payload.error.message } + } + + return { code } + } catch { + return { description: text } + } +} + +export async function refreshAccessToken( + auth: OAuthAuthDetails | PartialOAuthAuthDetails, + client: PluginClient, +): Promise { + const parts = parseRefreshParts(auth.refresh) + if (!parts.refreshToken) { + return undefined + } + + try { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: parts.refreshToken, + client_id: GEMINI_CLIENT_ID, + client_secret: GEMINI_CLIENT_SECRET, + }), + }) + + if (!response.ok) { + let errorText: string | undefined + try { + errorText = await response.text() + } catch { + errorText = undefined + } + + const { code, description } = parseOAuthErrorPayload(errorText) + const details = [code, description ?? errorText].filter(Boolean).join(": ") + const baseMessage = `Gemini token refresh failed (${response.status} ${response.statusText})` + console.warn(`[Gemini OAuth] ${details ? `${baseMessage} - ${details}` : baseMessage}`) + + if (code === "invalid_grant") { + console.warn( + "[Gemini OAuth] Google revoked the stored refresh token. Run `opencode auth login` and reauthenticate the Google provider.", + ) + invalidateProjectContextCache(auth.refresh) + try { + // Clear the credentials by setting empty/expired values + const clearedAuth: OAuthAuthDetails = { + type: "oauth", + refresh: formatRefreshParts({ + refreshToken: "", + projectId: parts.projectId, + managedProjectId: parts.managedProjectId, + }), + access: "", + expires: 0, + } + await client.auth.set({ + path: { id: GEMINI_PROVIDER_ID }, + body: clearedAuth, + }) + } catch (storeError) { + console.error("Failed to clear stored Gemini OAuth credentials:", storeError) + } + } + + return undefined + } + + const payload = (await response.json()) as { + access_token: string + expires_in: number + refresh_token?: string + } + + const refreshedParts: RefreshParts = { + refreshToken: payload.refresh_token ?? parts.refreshToken, + projectId: parts.projectId, + managedProjectId: parts.managedProjectId, + } + + const updatedAuth: OAuthAuthDetails = { + type: "oauth", + access: payload.access_token, + expires: Date.now() + payload.expires_in * 1000, + refresh: formatRefreshParts(refreshedParts), + } + + storeCachedAuth(updatedAuth) + invalidateProjectContextCache(auth.refresh) + + try { + await client.auth.set({ + path: { id: GEMINI_PROVIDER_ID }, + body: updatedAuth, + }) + } catch (storeError) { + console.error("Failed to persist refreshed Gemini OAuth credentials:", storeError) + } + + return updatedAuth + } catch (error) { + console.error("Failed to refresh Gemini access token due to an unexpected error:", error) + return undefined + } +} diff --git a/packages/opencode/src/plugin/gemini/types.ts b/packages/opencode/src/plugin/gemini/types.ts new file mode 100644 index 00000000000..489617462ae --- /dev/null +++ b/packages/opencode/src/plugin/gemini/types.ts @@ -0,0 +1,86 @@ +import type { GeminiTokenExchangeResult } from "./oauth" +import type { PluginInput } from "@opencode-ai/plugin" + +export interface OAuthAuthDetails { + type: "oauth" + refresh: string + access: string + expires: number +} + +export interface NonOAuthAuthDetails { + type: string + [key: string]: unknown +} + +export type AuthDetails = OAuthAuthDetails | NonOAuthAuthDetails + +export type GetAuth = () => Promise + +export interface ProviderModel { + cost?: { + input: number + output: number + cache?: { read: number; write: number } + } + [key: string]: unknown +} + +export interface ProviderInfo { + models?: Record + options?: Record +} + +export interface LoaderResult { + apiKey: string + fetch(input: RequestInfo, init?: RequestInit): Promise +} + +export interface AuthMethod { + provider?: string + label: string + type: "oauth" | "api" + authorize?: () => Promise<{ + url: string + instructions: string + method: string + callback: + | (() => Promise) + | ((callbackUrl: string) => Promise) + }> +} + +export type PluginClient = PluginInput["client"] + +export interface PluginContext { + client: PluginClient +} + +export interface PluginResult { + auth: { + provider: string + loader: (getAuth: GetAuth, provider: ProviderInfo) => Promise + methods: AuthMethod[] + } +} + +export interface RefreshParts { + refreshToken: string + projectId?: string + managedProjectId?: string +} + +export interface ProjectContextResult { + auth: OAuthAuthDetails + effectiveProjectId: string +} + +// Type for auth that may have optional access/expires (during auth flow) +export interface PartialOAuthAuthDetails { + type: "oauth" + refresh: string + access?: string + expires?: number +} + +export type PartialAuthDetails = PartialOAuthAuthDetails | NonOAuthAuthDetails diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index f57b46a3521..b67bd6da3f3 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -8,6 +8,7 @@ import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" +import { GeminiAuthPlugin } from "./gemini" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -15,7 +16,7 @@ export namespace Plugin { const BUILTIN = ["opencode-copilot-auth@0.0.12", "opencode-anthropic-auth@0.0.8"] // Built-in plugins that are directly imported (not installed from npm) - const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin] + const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, GeminiAuthPlugin] const state = Instance.state(async () => { const client = createOpencodeClient({