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
41 changes: 41 additions & 0 deletions packages/opencode/src/plugin/gemini/auth.ts
Original file line number Diff line number Diff line change
@@ -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
}
59 changes: 59 additions & 0 deletions packages/opencode/src/plugin/gemini/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { accessTokenExpired } from "./auth"
import type { OAuthAuthDetails, PartialOAuthAuthDetails } from "./types"

const authCache = new Map<string, OAuthAuthDetails>()

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)
}
}
21 changes: 21 additions & 0 deletions packages/opencode/src/plugin/gemini/constants.ts
Original file line number Diff line number Diff line change
@@ -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"
165 changes: 165 additions & 0 deletions packages/opencode/src/plugin/gemini/debug.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
if (!headers) {
return {}
}

const result: Record<string, string> = {}
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`)
}
}
Loading